From 216494c3db6dd3c229b366eb56f5c7703782b03e Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 14 Jun 2025 19:57:59 +0200 Subject: [PATCH 1/4] feat(clan-service): add module context for better error messages --- .../distributed-service/inventory-adapter.nix | 1 + .../distributed-service/service-module.nix | 51 ++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/lib/inventory/distributed-service/inventory-adapter.nix b/lib/inventory/distributed-service/inventory-adapter.nix index 049e0af43..833702e54 100644 --- a/lib/inventory/distributed-service/inventory-adapter.nix +++ b/lib/inventory/distributed-service/inventory-adapter.nix @@ -19,6 +19,7 @@ let { modules, prefix }: (lib.evalModules { class = "clan.service"; + specialArgs._ctx = prefix; modules = [ ./service-module.nix # feature modules diff --git a/lib/inventory/distributed-service/service-module.nix b/lib/inventory/distributed-service/service-module.nix index 214414439..5ab2082e2 100644 --- a/lib/inventory/distributed-service/service-module.nix +++ b/lib/inventory/distributed-service/service-module.nix @@ -1,8 +1,14 @@ -{ lib, config, ... }: +{ + lib, + config, + _ctx, + ... +}: let inherit (lib) mkOption types; inherit (types) attrsWith submoduleWith; + errorContext = "Error context: ${lib.concatStringsSep "." _ctx}"; # TODO: # Remove once this gets merged upstream; performs in O(n*log(n) instead of O(n^2)) # https://github.com/NixOS/nixpkgs/pull/355616/files @@ -53,7 +59,8 @@ let # This prints the path where the option should be defined rather than the plain path within settings # "The option `instances.foo.roles.server.machines.test.settings.<>' was accessed but has no value defined. Try setting the option." prefix = - [ + _ctx + ++ [ "instances" instanceName "roles" @@ -78,7 +85,7 @@ let (lib.setDefaultModuleLocation "Via clan.service module: roles.${roleName}.interface" config.roles.${roleName}.interface ) - (lib.setDefaultModuleLocation "inventory.instances.${instanceName}.roles.${roleName}.settings" + (lib.setDefaultModuleLocation "instances.${instanceName}.roles.${roleName}.settings" config.instances.${instanceName}.roles.${roleName}.settings ) settings @@ -156,7 +163,9 @@ in default = throw '' The clan service module ${config.manifest.name} doesn't define any instances. - Did you forget to create instances via 'inventory.instances'? + Did you forget to create instances via 'instances'? + + ${errorContext} ''; description = '' Instances of the service. @@ -204,7 +213,9 @@ in Instance '${name}' of service '${config.manifest.name}' mut define members via 'roles'. To include a machine: - 'instances.${name}.roles..machines.' must be set. + 'instances.${name}.roles..machines.' must be set. + + ${errorContext} ''; type = attrsWith { placeholder = "roleName"; @@ -301,6 +312,8 @@ in To define multiple instance behavior: `roles.client.perInstance = { ... }: {}` + + ${errorContext} ''; type = attrsWith { placeholder = "roleName"; @@ -379,7 +392,7 @@ in }; ``` - - `settings`: The settings of the role, as defined in `inventory` + - `settings`: The settings of the role, as defined in `instances` ```nix { timeout = 30; @@ -438,7 +451,18 @@ in type = attrsWith { placeholder = "serviceName"; elemType = submoduleWith { - modules = [ ./service-module.nix ]; + modules = [ + { + _module.args._ctx = _ctx ++ [ + config.manifest.name + "roles" + roleName + "perInstance" + "services" + ]; + } + ./service-module.nix + ]; }; }; apply = _: throw "Not implemented yet"; @@ -554,7 +578,16 @@ in type = attrsWith { placeholder = "serviceName"; elemType = submoduleWith { - modules = [ ./service-module.nix ]; + modules = [ + { + _module.args._ctx = _ctx ++ [ + config.manifest.name + "perMachine" + "services" + ]; + } + ./service-module.nix + ]; }; }; apply = _: throw "Not implemented yet"; @@ -605,6 +638,8 @@ in - 'instances..roles..machines..settings' should be used instead. If that is insufficient, you might also consider using 'roles..perInstance' instead of 'perMachine'. + + ${errorContext} ''; }; From aa65e8e533689fdb2ef0a1e33d4b38975d1dfdec Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 14 Jun 2025 19:59:40 +0200 Subject: [PATCH 2/4] chore(clan-service): remove and unify unecessary bindings --- .../distributed-service/service-module.nix | 77 +++++++------------ 1 file changed, 28 insertions(+), 49 deletions(-) diff --git a/lib/inventory/distributed-service/service-module.nix b/lib/inventory/distributed-service/service-module.nix index 5ab2082e2..99704bc6a 100644 --- a/lib/inventory/distributed-service/service-module.nix +++ b/lib/inventory/distributed-service/service-module.nix @@ -13,25 +13,6 @@ let # Remove once this gets merged upstream; performs in O(n*log(n) instead of O(n^2)) # https://github.com/NixOS/nixpkgs/pull/355616/files uniqueStrings = list: builtins.attrNames (builtins.groupBy lib.id list); - - checkInstanceRoles = - instanceName: instanceRoles: - let - unmatchedRoles = lib.filter (roleName: !lib.elem roleName (lib.attrNames config.roles)) ( - lib.attrNames instanceRoles - ); - in - if unmatchedRoles == [ ] then - true - else - throw '' - inventory instance: 'instances.${instanceName}' defines the following roles: - ${builtins.toJSON unmatchedRoles} - - But the clan-service module '${config.manifest.name}' defines roles: - ${builtins.toJSON (lib.attrNames config.roles)} - ''; - /** Merges the role- and machine-settings using the role interface @@ -95,32 +76,6 @@ let ]; }; - /** - Makes a module extensible - returning its config - and making it extensible via '__functor' polymorphism - - Example: - - ```nix-repl - res = makeExtensibleConfig (evalModules { options.foo = mkOption { default = 42; };) - res - => - { - foo = 42; - _functor = ; - } - - # This allows to override using mkDefault, mkForce, etc. - res { foo = 100; } - => - { - foo = 100; - _functor = ; - } - ``` - */ - # Extend evalModules result by a module, returns .config. extendEval = eval: m: (eval.extendModules { modules = lib.toList m; }).config; @@ -154,10 +109,7 @@ let in { options = { - # TODO: deduplicate this with inventory.instances - # Although inventory has stricter constraints instances = mkOption { - # Instances are created in the inventory visible = false; defaultText = "Throws: 'The service must define its instances' when not defined"; default = throw '' @@ -269,7 +221,34 @@ in ]; }; }; - apply = v: lib.seq (checkInstanceRoles name v) v; + apply = + v: + lib.seq ( + ( + + instanceName: instanceRoles: + let + unmatchedRoles = lib.filter (roleName: !lib.elem roleName (lib.attrNames config.roles)) ( + lib.attrNames instanceRoles + ); + in + if unmatchedRoles == [ ] then + true + else + throw '' + Instance: 'instances.${instanceName}' uses the following roles: + ${builtins.toJSON unmatchedRoles} + + But the clan-service module '${config.manifest.name}' only defines roles: + ${builtins.toJSON (lib.attrNames config.roles)} + + ${errorContext} + '' + + ) + name + v + ) v; }; } ) From aa26d2ebf2cc27d4317ea08c6f140e80186a57bf Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sat, 14 Jun 2025 20:03:25 +0200 Subject: [PATCH 3/4] feat(clan-services): enable recursive services Using recursive services is potentially complex and requires carefully designed services. Nested Services create nixos modules which must be mergable as always. --- .../distributed-service/service-module.nix | 132 +++++++++++------- .../distributed-service/tests/default.nix | 1 + .../tests/nested_services/default.nix | 4 + .../tests/nested_services/simple.nix | 117 ++++++++++++++++ 4 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 lib/inventory/distributed-service/tests/nested_services/default.nix create mode 100644 lib/inventory/distributed-service/tests/nested_services/simple.nix diff --git a/lib/inventory/distributed-service/service-module.nix b/lib/inventory/distributed-service/service-module.nix index 99704bc6a..1dcf61719 100644 --- a/lib/inventory/distributed-service/service-module.nix +++ b/lib/inventory/distributed-service/service-module.nix @@ -26,8 +26,6 @@ let The caller is responsible to use .config or .extendModules */ - # TODO: evaluate against the role.settings statically and use extendModules to get the machineSettings - # Doing this might improve performance evalMachineSettings = { roleName, @@ -91,15 +89,12 @@ let instanceName: instance: lib.mapAttrs (roleName: role: { machines = lib.mapAttrs (machineName: v: { - # TODO: evaluate the settings against the interface - # settings = (evalMachineSettings { inherit roleName instanceName; inherit (v) settings; }).config; settings = (evalMachineSettings { inherit roleName instanceName machineName; inherit (v) settings; }).config; }) role.machines; - # TODO: evaluate the settings against the interface settings = (evalMachineSettings { inherit roleName instanceName; @@ -140,11 +135,6 @@ in ( { name, ... }: { - # options.settings = mkOption { - # description = "settings of 'instance': ${name}"; - # default = {}; - # apply = v: lib.seq (checkInstanceSettings name v) v; - # }; options.roles = mkOption { description = '' Roles of the instance. @@ -328,8 +318,6 @@ in - *defaults* that depend on the *machine* or *instance* should be added to *settings* later in 'perInstance' or 'perMachine' ''; type = types.deferredModule; - # TODO: Default to an empty module - # need to test that an the empty module can be evaluated to empty settings default = { }; }; options.perInstance = mkOption { @@ -424,7 +412,6 @@ in ``` ''; }; - # TODO: Recursive services options.services = mkOption { visible = false; type = attrsWith { @@ -444,7 +431,6 @@ in ]; }; }; - apply = _: throw "Not implemented yet"; default = { }; }; }) @@ -551,7 +537,6 @@ in ``` ''; }; - # TODO: Recursive services options.services = mkOption { visible = false; type = attrsWith { @@ -569,7 +554,6 @@ in ]; }; }; - apply = _: throw "Not implemented yet"; default = { }; }; }) @@ -603,7 +587,6 @@ in in uniqueStrings (collectRoles machineScope.instances); }; - # TODO: instances..roles should contain all roles, even if nobody has the role inherit (machineScope) instances; # There are no machine settings. @@ -641,7 +624,7 @@ in allMachines :: { :: { nixosModule :: NixOSModule; - services :: { }; # TODO: nested services + services :: { }; }; }; }; @@ -680,6 +663,7 @@ in type = types.attrsOf types.raw; }; + # The result collected from 'perMachine' result.allMachines = mkOption { visible = false; readOnly = true; @@ -734,43 +718,93 @@ in default = lib.mapAttrs ( machineName: machineResult: let - instanceResults = lib.foldlAttrs ( - acc: roleName: role: - acc - ++ lib.foldlAttrs ( - acc: instanceName: instance: - if instance.allMachines.${machineName}.nixosModule or { } != { } then - acc - ++ [ - (lib.setDefaultModuleLocation - "Via instances.${instanceName}.roles.${roleName}.machines.${machineName}" - instance.allMachines.${machineName}.nixosModule - ) - ] - else - acc - ) [ ] role.allInstances - ) [ ] config.result.allRoles; + instanceResults = + lib.foldlAttrs + ( + roleAcc: roleName: role: + roleAcc + // lib.foldlAttrs ( + instanceAcc: instanceName: instance: + instanceAcc + // { + nixosModules = + ( + (lib.mapAttrsToList ( + nestedServiceName: serviceModule: + let + unmatchedMachines = lib.attrNames ( + lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines) + ); + in + if unmatchedMachines != [ ] then + throw '' + The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines} + Either remove the machines, or include them into the parent via a role. + (Added via roles.${roleName}.perInstance.services.${nestedServiceName}) + + ${errorContext} + '' + else + serviceModule.result.final.${machineName}.nixosModule + ) instance.allMachines.${machineName}.services) + + ) + ++ ( + if instance.allMachines.${machineName}.nixosModule or { } != { } then + instanceAcc.nixosModules + ++ [ + (lib.setDefaultModuleLocation + "Via instances.${instanceName}.roles.${roleName}.machines.${machineName}" + instance.allMachines.${machineName}.nixosModule + ) + ] + else + instanceAcc.nixosModules + ); + } + ) roleAcc role.allInstances + ) + { + nixosModules = [ ]; + # ... + } + config.result.allRoles; in { - inherit instanceResults; + inherit instanceResults machineResult; nixosModule = { - imports = [ - # include service assertions: - ( + imports = + [ + # include service assertions: + ( + let + failedAssertions = (lib.filterAttrs (_: v: !v.assertion) config.result.assertions); + in + { + assertions = lib.attrValues failedAssertions; + } + ) + (lib.setDefaultModuleLocation "Via ${config.manifest.name}.perMachine - machine='${machineName}';" machineResult.nixosModule) + ] + ++ (lib.mapAttrsToList ( + nestedServiceName: serviceModule: let - failedAssertions = (lib.filterAttrs (_: v: !v.assertion) config.result.assertions); + unmatchedMachines = lib.attrNames ( + lib.removeAttrs serviceModule.result.final (lib.attrNames config.result.allMachines) + ); in - { - assertions = lib.attrValues failedAssertions; - } - ) + if unmatchedMachines != [ ] then + throw '' + The following machines are not part of the parent service: ${builtins.toJSON unmatchedMachines} + Either remove the machines, or include them into the parent via a role. + (Added via perMachine.services.${nestedServiceName}) - # For error backtracing. This module was produced by the 'perMachine' function - # TODO: check if we need this or if it leads to better errors if we pass the underlying module locations - # (lib.setDefaultModuleLocation "clan.service: ${config.manifest.name} - via perMachine" machineResult.nixosModule) - (machineResult.nixosModule) - ] ++ instanceResults; + ${errorContext} + '' + else + serviceModule.result.final.${machineName}.nixosModule + ) machineResult.services) + ++ instanceResults.nixosModules; }; } ) config.result.allMachines; diff --git a/lib/inventory/distributed-service/tests/default.nix b/lib/inventory/distributed-service/tests/default.nix index b8bb70c59..b4fb4221b 100644 --- a/lib/inventory/distributed-service/tests/default.nix +++ b/lib/inventory/distributed-service/tests/default.nix @@ -278,4 +278,5 @@ in per_machine_args = import ./per_machine_args.nix { inherit lib callInventoryAdapter; }; per_instance_args = import ./per_instance_args.nix { inherit lib callInventoryAdapter; }; + nested = import ./nested_services { inherit lib clanLib; }; } diff --git a/lib/inventory/distributed-service/tests/nested_services/default.nix b/lib/inventory/distributed-service/tests/nested_services/default.nix new file mode 100644 index 000000000..509276c9b --- /dev/null +++ b/lib/inventory/distributed-service/tests/nested_services/default.nix @@ -0,0 +1,4 @@ +{ clanLib, lib, ... }: +{ + test_simple = import ./simple.nix { inherit clanLib lib; }; +} diff --git a/lib/inventory/distributed-service/tests/nested_services/simple.nix b/lib/inventory/distributed-service/tests/nested_services/simple.nix new file mode 100644 index 000000000..2e1e58492 --- /dev/null +++ b/lib/inventory/distributed-service/tests/nested_services/simple.nix @@ -0,0 +1,117 @@ +/* + service-B :: Service + exports a nixosModule which set "address" and "hostname" + Note: How we use null together with mkIf to create optional values. + This is a method, to create mergable modules + + service-A :: Service + + service-A.roles.server.perInstance.services."B" + imports service-B + configures a client with hostname = "johnny" + + service-A.perMachine.services."B" + imports service-B + configures a client with address = "root" +*/ +{ clanLib, lib, ... }: +let + service-B = ( + { lib, ... }: + { + manifest.name = "service-B"; + + roles.client.interface = { + options.hostname = lib.mkOption { default = null; }; + options.address = lib.mkOption { default = null; }; + }; + roles.client.perInstance = + { settings, ... }: + { + nixosModule = { + imports = [ + # Only export the value that is actually set. + (lib.mkIf (settings.hostname != null) { + hostname = settings.hostname; + }) + (lib.mkIf (settings.address != null) { + address = settings.address; + }) + ]; + }; + }; + } + ); + service-A = + { ... }: + { + manifest.name = "service-A"; + + instances.foo = { + roles.server.machines."jon" = { }; + }; + instances.bar = { + roles.server.machines."jon" = { }; + }; + + roles.server = { + perInstance = + { machine, instanceName, ... }: + { + services."B" = { + imports = [ + service-B + ]; + instances."B-for-A" = { + roles.client.machines.${machine.name} = { + settings.hostname = instanceName + "+johnny"; + }; + }; + }; + }; + }; + perMachine = + { machine, ... }: + { + services."B" = { + imports = [ + service-B + ]; + instances."B-for-A" = { + roles.client.machines.${machine.name} = { + settings.address = "root"; + }; + }; + }; + }; + }; + + eval = clanLib.inventory.evalClanService { + modules = [ + (service-A) + ]; + prefix = [ ]; + }; + + evalNixos = lib.evalModules { + modules = [ + { + options.assertions = lib.mkOption { }; + options.hostname = lib.mkOption { type = lib.types.separatedString " "; }; + options.address = lib.mkOption { type = lib.types.str; }; + } + eval.config.result.final."jon".nixosModule + ]; + }; +in +{ + # Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance" + inherit eval; + expr = evalNixos.config; + expected = { + address = "root"; + assertions = [ ]; + # Concatenates hostnames from both instances + hostname = "bar+johnny foo+johnny"; + }; +} From 39d0347c223a5613b2bed62f4216820d3fae8a63 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sun, 15 Jun 2025 20:41:40 +0200 Subject: [PATCH 4/4] Fix(clan-services): allMachines might not contain the machineName --- .../distributed-service/service-module.nix | 2 +- .../tests/nested_services/default.nix | 4 + .../multi_import_duplication.nix | 125 ++++++++++++++++++ .../tests/nested_services/multi_machine.nix | 108 +++++++++++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 lib/inventory/distributed-service/tests/nested_services/multi_import_duplication.nix create mode 100644 lib/inventory/distributed-service/tests/nested_services/multi_machine.nix diff --git a/lib/inventory/distributed-service/service-module.nix b/lib/inventory/distributed-service/service-module.nix index 1dcf61719..dc597fdf4 100644 --- a/lib/inventory/distributed-service/service-module.nix +++ b/lib/inventory/distributed-service/service-module.nix @@ -746,7 +746,7 @@ in '' else serviceModule.result.final.${machineName}.nixosModule - ) instance.allMachines.${machineName}.services) + ) instance.allMachines.${machineName}.services or { }) ) ++ ( diff --git a/lib/inventory/distributed-service/tests/nested_services/default.nix b/lib/inventory/distributed-service/tests/nested_services/default.nix index 509276c9b..eb066bd68 100644 --- a/lib/inventory/distributed-service/tests/nested_services/default.nix +++ b/lib/inventory/distributed-service/tests/nested_services/default.nix @@ -1,4 +1,8 @@ { clanLib, lib, ... }: { test_simple = import ./simple.nix { inherit clanLib lib; }; + + test_multi_machine = import ./multi_machine.nix { inherit clanLib lib; }; + + test_multi_import_duplication = import ./multi_import_duplication.nix { inherit clanLib lib; }; } diff --git a/lib/inventory/distributed-service/tests/nested_services/multi_import_duplication.nix b/lib/inventory/distributed-service/tests/nested_services/multi_import_duplication.nix new file mode 100644 index 000000000..2d5184e78 --- /dev/null +++ b/lib/inventory/distributed-service/tests/nested_services/multi_import_duplication.nix @@ -0,0 +1,125 @@ +{ clanLib, lib, ... }: +let + # Potentially imported many times + # To add the ssh key + example-admin = ( + { lib, ... }: + { + manifest.name = "example-admin"; + + roles.client.interface = { + options.keys = lib.mkOption { }; + }; + + roles.client.perInstance = + { settings, ... }: + { + nixosModule = { + inherit (settings) keys; + }; + }; + } + ); + + consumer-A = + { ... }: + { + manifest.name = "consumer-A"; + + instances.foo = { + roles.server.machines."jon" = { }; + }; + instances.bar = { + roles.server.machines."jon" = { }; + }; + + roles.server = { + perInstance = + { machine, instanceName, ... }: + { + services."example-admin" = { + imports = [ + example-admin + ]; + instances."${instanceName}" = { + roles.client.machines.${machine.name} = { + settings.keys = [ "pubkey-1" ]; + }; + }; + }; + }; + }; + }; + consumer-B = + { ... }: + { + manifest.name = "consumer-A"; + + instances.foo = { + roles.server.machines."jon" = { }; + }; + instances.bar = { + roles.server.machines."jon" = { }; + }; + + roles.server = { + perInstance = + { machine, instanceName, ... }: + { + services."example-admin" = { + imports = [ + example-admin + ]; + instances."${instanceName}" = { + roles.client.machines.${machine.name} = { + settings.keys = [ + "pubkey-1" + ]; + }; + }; + }; + }; + }; + }; + + eval = clanLib.inventory.evalClanService { + modules = [ + (consumer-A) + ]; + prefix = [ ]; + }; + eval2 = clanLib.inventory.evalClanService { + modules = [ + (consumer-B) + ]; + prefix = [ ]; + }; + + evalNixos = lib.evalModules { + modules = [ + { + options.assertions = lib.mkOption { }; + # This is suboptimal + options.keys = lib.mkOption { }; + } + eval.config.result.final.jon.nixosModule + eval2.config.result.final.jon.nixosModule + ]; + }; +in +{ + # Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance" + inherit eval; + expr = evalNixos.config; + expected = { + assertions = [ ]; + # TODO: Some deduplication mechanism is nice + # Could add types.set or do 'apply = unique', or something else ? + keys = [ + "pubkey-1" + "pubkey-1" + "pubkey-1" + "pubkey-1" + ]; + }; +} diff --git a/lib/inventory/distributed-service/tests/nested_services/multi_machine.nix b/lib/inventory/distributed-service/tests/nested_services/multi_machine.nix new file mode 100644 index 000000000..520a41941 --- /dev/null +++ b/lib/inventory/distributed-service/tests/nested_services/multi_machine.nix @@ -0,0 +1,108 @@ +{ clanLib, lib, ... }: +let + service-B = ( + { lib, ... }: + { + manifest.name = "service-B"; + + roles.client.interface = { + options.user = lib.mkOption { }; + options.host = lib.mkOption { }; + }; + roles.client.perInstance = + { settings, instanceName, ... }: + { + nixosModule = { + units.${instanceName} = { + script = settings.user + "@" + settings.host; + }; + }; + }; + perMachine = + { ... }: + { + nixosModule = { + ssh.enable = true; + }; + }; + } + ); + service-A = + { ... }: + { + manifest.name = "service-A"; + + instances.foo = { + roles.server.machines."jon" = { }; + roles.server.machines."sara" = { }; + }; + + roles.server = { + perInstance = + { machine, instanceName, ... }: + { + services."B" = { + imports = [ + service-B + ]; + instances."A-${instanceName}-B" = { + roles.client.machines.${machine.name} = { + settings.user = "johnny"; + settings.host = machine.name; + }; + }; + }; + }; + }; + }; + + eval = clanLib.inventory.evalClanService { + modules = [ + (service-A) + ]; + prefix = [ ]; + }; + + evalNixos = lib.mapAttrs ( + _n: v: + (lib.evalModules { + modules = [ + { + options.assertions = lib.mkOption { }; + options.units = lib.mkOption { }; + options.ssh = lib.mkOption { }; + } + v.nixosModule + ]; + }).config + ) eval.config.result.final; +in +{ + # Check that the nixos system has the settings from the nested module, as well as those from the "perMachine" and "perInstance" + inherit eval; + expr = evalNixos; + expected = { + jon = { + assertions = [ ]; + ssh = { + enable = true; + }; + units = { + A-foo-B = { + script = "johnny@jon"; + }; + }; + }; + sara = { + assertions = [ ]; + ssh = { + enable = true; + }; + units = { + A-foo-B = { + script = "johnny@sara"; + }; + }; + }; + }; +}