From 9d903f3ef79bc90ed3ea2b752e25b7909d33e54a Mon Sep 17 00:00:00 2001 From: Valentin Gagarin Date: Wed, 27 Aug 2025 00:45:49 +0200 Subject: [PATCH] Complete the data model with a runtime environment and end-to-end test (#481) Closes #103 At last, a fully fledged data model for what Fediversity really is and does. This comes with a test that exercises a very simple but functionally complete arrangement with all ingredients fo the business logic: a dummy resource (login shell), a dummy application (`hello`, which needs a shell to live in), a dummy environment (a single NixOS VM that allows for one, the operator's, login shell), and a deployment of that environment given a dummy configuration (that enables `hello`). The next step will be to lift this purely evaluation-level test into a VM test which verifies that the resulting VM indeed has `hello` deployed to the operator's user account. Caveats: - The exact naming has a bit of room for improvement, and may have diverged from the design document - The test is not as pedantically type safe as it could be, since we simply use `types.raw` for resources such as NixOS users settings which *could* be more finely delineated Co-authored-by: Kiara Grouwstra Co-authored-by: kiara Grouwstra Reviewed-on: https://git.fediversity.eu/Fediversity/Fediversity/pulls/481 Reviewed-by: kiara Grouwstra Co-authored-by: Valentin Gagarin Co-committed-by: Valentin Gagarin --- default.nix | 4 +- deployment/data-model-test.nix | 161 ++++++++++++++++++++++++++++++--- deployment/data-model.nix | 120 +++++++++++++++++++++++- deployment/function.nix | 9 +- 4 files changed, 271 insertions(+), 23 deletions(-) diff --git a/default.nix b/default.nix index 24b73cd2..e929e516 100644 --- a/default.nix +++ b/default.nix @@ -11,7 +11,8 @@ let ; inherit (pkgs) lib; inherit (import sources.flake-inputs) import-flake; - inherit ((import-flake { src = ./.; }).inputs) nixops4; + inputs = (import-flake { src = ./.; }).inputs; + inherit (inputs) nixops4; panel = import ./panel { inherit sources system; }; pre-commit-check = (import "${git-hooks}/nix" { @@ -78,6 +79,7 @@ in # re-export inputs so they can be overridden granularly # (they can't be accessed from the outside any other way) inherit + inputs sources system pkgs diff --git a/deployment/data-model-test.nix b/deployment/data-model-test.nix index ac35df39..24d5cd6c 100644 --- a/deployment/data-model-test.nix +++ b/deployment/data-model-test.nix @@ -1,7 +1,7 @@ let inherit (import ../default.nix { }) pkgs inputs; inherit (pkgs) lib; - inherit (lib) mkOption; + inherit (lib) mkOption types; eval = module: (lib.evalModules { @@ -13,17 +13,83 @@ let ./data-model.nix ]; }).config; + nixops4Deployment = inputs.nixops4.modules.nixops4Deployment.default; + inherit (inputs.nixops4.lib) mkDeployment; in { _class = "nix-unit"; test-eval = { + /** + This tests a very simple arrangement that features all ingredients of the Fediversity business logic: + application, resource, environment, deployment; and wires it all up in one end-to-end exercise. + - The dummy resource is a login shell made available for some user. + - The dummy application is `hello` that requires a shell to be deployed. + - The dummy environment is a single NixOS VM that hosts one login shell, for the operator. + - The dummy configuration enables the `hello` application. + This will produce a NixOps4 deployment for a NixOS VM with a login shell for the operator and `hello` available. + */ expr = let fediversity = eval ( { config, ... }: { config = { + resources.login-shell = { + description = "The operator needs to be able to log into the shell"; + request = + { ... }: + { + _class = "fediversity-resource-request"; + options = { + wheel = mkOption { + description = "Whether the login user needs root permissions"; + type = types.bool; + default = false; + }; + packages = mkOption { + description = "Packages that need to be available in the user environment"; + type = with types; attrsOf package; + }; + }; + }; + policy = + { config, ... }: + { + _class = "fediversity-resource-policy"; + options = { + username = mkOption { + description = "Username for the operator"; + type = types.str; # TODO: use the proper constraints from NixOS + }; + wheel = mkOption { + description = "Whether to allow login with root permissions"; + type = types.bool; + default = false; + }; + }; + config = { + resource-type = types.raw; # TODO: splice out the user type from NixOS + apply = + requests: + let + # Filter out requests that need wheel if policy doesn't allow it + validRequests = lib.filterAttrs ( + _name: req: !req.login-shell.wheel || config.wheel + ) requests.resources; + in + lib.optionalAttrs (validRequests != { }) { + ${config.username} = { + isNormalUser = true; + packages = + with lib; + attrValues (concatMapAttrs (_name: request: request.login-shell.packages) validRequests); + extraGroups = lib.optional config.wheel "wheel"; + }; + }; + }; + }; + }; applications.hello = { ... }: { @@ -31,15 +97,42 @@ in module = { ... }: { - options = { - enable = lib.mkEnableOption "Hello in the shell"; + options.enable = lib.mkEnableOption "Hello in the shell"; + }; + implementation = cfg: { + input = cfg; + output = lib.optionalAttrs cfg.enable { + resources.hello.login-shell.packages.hello = pkgs.hello; + }; + }; + }; + environments.single-nixos-vm = + { config, ... }: + { + resources.operator-environment.login-shell.username = "operator"; + implementation = requests: { + input = requests; + output = + { providers, ... }: + { + providers = { + inherit (inputs.nixops4.modules.nixops4Provider) local; + }; + resources.the-machine = { + type = providers.local.exec; + imports = [ + inputs.nixops4-nixos.modules.nixops4Resource.nixos + ]; + nixos.module = + { ... }: + { + users.users = config.resources.shell.login-shell.apply ( + lib.filterAttrs (_name: value: value ? login-shell) requests + ); + }; + }; }; - }; - implementation = - cfg: - lib.optionalAttrs cfg.enable { - dummy.login-shell.packages.hello = pkgs.hello; - }; + }; }; }; options = { @@ -51,20 +144,64 @@ in applications.hello.enable = true; }; }; + example-deployment = mkOption { + type = types.submodule nixops4Deployment; + readOnly = true; + default = config.environments.single-nixos-vm.deployment config.example-configuration; + }; }; } ); + resources = fediversity.applications.hello.resources fediversity.example-configuration.applications.hello; + hello-shell = resources.resources.hello.login-shell; + environment = fediversity.environments.single-nixos-vm.resources.operator-environment.login-shell; + result = mkDeployment { + modules = [ + (fediversity.environments.single-nixos-vm.deployment fediversity.example-configuration) + ]; + }; + in { - inherit (fediversity) - example-configuration - ; + number-of-resources = with lib; length (attrNames fediversity.resources); + inherit (fediversity) example-configuration; + hello-package-exists = hello-shell.packages ? hello; + wheel-required = hello-shell.wheel; + wheel-allowed = environment.wheel; + operator-shell = + let + operator = (environment.apply resources).operator; + in + { + inherit (operator) isNormalUser; + packages = map (p: "${p.pname}") operator.packages; + extraGroups = operator.extraGroups; + }; + deployment = { + inherit (result) _type; + deploymentFunction = lib.isFunction result.deploymentFunction; + getProviders = lib.isFunction result.getProviders; + }; }; expected = { + number-of-resources = 1; example-configuration = { enable = true; applications.hello.enable = true; }; + hello-package-exists = true; + wheel-required = false; + wheel-allowed = false; + operator-shell = { + isNormalUser = true; + packages = [ "hello" ]; + extraGroups = [ ]; + }; + deployment = { + _type = "nixops4Deployment"; + deploymentFunction = true; + getProviders = true; + }; }; }; } diff --git a/deployment/data-model.nix b/deployment/data-model.nix index 8f584af4..c3d5d53a 100644 --- a/deployment/data-model.nix +++ b/deployment/data-model.nix @@ -1,6 +1,7 @@ { lib, config, + inputs, ... }: let @@ -15,19 +16,73 @@ let ; functionType = import ./function.nix; - application-resources = { + application-resources = submodule { options.resources = mkOption { # TODO: maybe transpose, and group the resources by type instead type = attrsOf ( - attrTag (lib.mapAttrs (_name: resource: mkOption { type = resource.request; }) config.resources) + attrTag ( + lib.mapAttrs (_name: resource: mkOption { type = submodule resource.request; }) config.resources + ) ); }; }; + nixops4Deployment = types.deferredModuleWith { + staticModules = [ + inputs.nixops4.modules.nixops4Deployment.default + + { + _class = "nixops4Deployment"; + _module.args = { + resourceProviderSystem = builtins.currentSystem; + resources = { }; + }; + } + ]; + }; in { - _class = "nixops4Deployment"; - options = { + resources = mkOption { + description = "Collection of deployment resources that can be required by applications and policed by hosting providers"; + type = attrsOf ( + submodule ( + { ... }: + { + _class = "fediversity-resource"; + options = { + description = mkOption { + description = "Description of the resource to help application module authors and hosting providers to work with it"; + type = types.str; + }; + request = mkOption { + description = "Options for declaring resource requirements by an application, a description of how the resource is consumed or accessed"; + type = deferredModuleWith { staticModules = [ { _class = "fediversity-resource-request"; } ]; }; + }; + policy = mkOption { + description = "Options for configuring the resource policy for the hosting provider, a description of how the resource is made available"; + type = deferredModuleWith { + staticModules = [ + (policy: { + _class = "fediversity-resource-policy"; + options.resource-type = mkOption { + description = "The type of resource this policy configures"; + type = types.optionType; + }; + # TODO(@fricklerhandwerk): we may want to make the function type explict here: `request -> resource-type` + # and then also rename this to be consistent with the application's resource mapping + options.apply = mkOption { + description = "Apply the policy to a request"; + type = functionTo policy.config.resource-type; + }; + }) + ]; + }; + }; + }; + } + ) + ); + }; applications = mkOption { description = "Collection of Fediversity applications"; type = attrsOf ( @@ -52,12 +107,13 @@ in readOnly = true; default = input: (application.config.implementation input).output; }; + # TODO(@fricklerhandwerk): this needs a better name, it's just the type config-mapping = mkOption { description = "Function type for the mapping from application configuration to required resources"; type = submodule functionType; readOnly = true; default = { - input-type = application.config.module; + input-type = submodule application.config.module; output-type = application-resources; }; }; @@ -65,6 +121,60 @@ in }) ); }; + environments = mkOption { + description = "Run-time environments for Fediversity applications to be deployed to"; + type = attrsOf ( + submodule (environment: { + _class = "fediversity-environment"; + options = { + resources = mkOption { + description = '' + Resources made available by the hosting provider, and their policies. + + Setting this is optional, but provides a place to declare that information for programmatic use in the resource mapping. + ''; + # TODO: maybe transpose, and group the resources by type instead + type = attrsOf ( + attrTag ( + lib.mapAttrs (_name: resource: mkOption { type = submodule resource.policy; }) config.resources + ) + ); + }; + implementation = mkOption { + description = "Mapping of resources required by applications to available resources; the result can be deployed"; + type = environment.config.resource-mapping.function-type; + }; + resource-mapping = mkOption { + description = "Function type for the mapping from resources to a (NixOps4) deployment"; + type = submodule functionType; + readOnly = true; + default = { + input-type = application-resources; + output-type = nixops4Deployment; + }; + }; + # TODO(@fricklerhandwerk): maybe this should be a separate thing such as `fediversity-setup`, + # which makes explicit which applications and environments are available. + # then the deployments can simply be the result of the function application baked into this module. + deployment = mkOption { + description = "Generate a deployment from a configuration, by applying an environment's resource policies to the applications' resource mappings"; + type = functionTo (environment.config.resource-mapping.output-type); + readOnly = true; + default = + cfg: + # TODO: check cfg.enable.true + let + required-resources = lib.mapAttrs ( + name: application-settings: config.applications.${name}.resources application-settings + ) cfg.applications; + in + (environment.config.implementation required-resources).output; + + }; + }; + }) + ); + }; configuration = mkOption { description = "Configuration type declaring options to be set by operators"; type = optionType; diff --git a/deployment/function.nix b/deployment/function.nix index d1a047f0..f0210a34 100644 --- a/deployment/function.nix +++ b/deployment/function.nix @@ -5,7 +5,6 @@ let inherit (lib) mkOption types; inherit (types) - deferredModule submodule functionTo optionType @@ -14,10 +13,10 @@ in { options = { input-type = mkOption { - type = deferredModule; + type = optionType; }; output-type = mkOption { - type = deferredModule; + type = optionType; }; function-type = mkOption { type = optionType; @@ -25,10 +24,10 @@ in default = functionTo (submodule { options = { input = mkOption { - type = submodule config.input-type; + type = config.input-type; }; output = mkOption { - type = submodule config.output-type; + type = config.output-type; }; }; });