diff --git a/flake.nix b/flake.nix index 72e2a6e..96d9459 100644 --- a/flake.nix +++ b/flake.nix @@ -46,12 +46,7 @@ cargoArtifacts = craneLib.buildDepsOnly commonArgs; in { - demo-vm = pkgs.callPackage ./nix/demo-vm.nix { - nixosModule = self.nixosModules.default; - fc-packages = { - inherit (self.packages.${system}) fc-common fc-evaluator fc-migrate-cli fc-queue-runner fc-server; - }; - }; + demo-vm = pkgs.callPackage ./nix/demo-vm.nix {inherit self;}; # FC Packages fc-common = pkgs.callPackage ./nix/packages/fc-common.nix { @@ -77,35 +72,26 @@ checks = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; - testArgs = { - nixosModule = self.nixosModules.default; - fc-packages = { - inherit (self.packages.${system}) fc-common fc-evaluator fc-migrate-cli fc-queue-runner fc-server; - }; - }; in { # Split VM integration tests - service-startup = pkgs.callPackage ./nix/tests/service-startup.nix testArgs; - basic-api = pkgs.callPackage ./nix/tests/basic-api.nix testArgs; - auth-rbac = pkgs.callPackage ./nix/tests/auth-rbac.nix testArgs; - api-crud = pkgs.callPackage ./nix/tests/api-crud.nix testArgs; - features = pkgs.callPackage ./nix/tests/features.nix testArgs; - e2e = pkgs.callPackage ./nix/tests/e2e.nix testArgs; - - # Legacy monolithic test (for reference, can be removed after split tests pass) - vm-test = pkgs.callPackage ./nix/vm-test.nix testArgs; + service-startup = pkgs.callPackage ./nix/tests/startup.nix {inherit self;}; + basic-api = pkgs.callPackage ./nix/tests/basic-api.nix {inherit self;}; + auth-rbac = pkgs.callPackage ./nix/tests/auth-rbac.nix {inherit self;}; + api-crud = pkgs.callPackage ./nix/tests/api-crud.nix {inherit self;}; + features = pkgs.callPackage ./nix/tests/features.nix {inherit self;}; + webhooks = pkgs.callPackage ./nix/tests/webhooks.nix {inherit self;}; + e2e = pkgs.callPackage ./nix/tests/e2e.nix {inherit self;}; }); devShells = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; in { default = pkgs.mkShell { - name = "fc"; + name = "fc-dev"; inputsFrom = [self.packages.${system}.fc-server]; strictDeps = true; packages = with pkgs; [ - postgresql pkg-config openssl @@ -116,5 +102,7 @@ ]; }; }); + + formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.alejandra); }; } diff --git a/nix/demo-vm.nix b/nix/demo-vm.nix index e994cae..2c1f034 100644 --- a/nix/demo-vm.nix +++ b/nix/demo-vm.nix @@ -1,15 +1,15 @@ { pkgs, - fc-packages, - nixosModule, + self, }: let + fc-packages = self.packages.${pkgs.stdenv.hostPlatform.system}; nixos = pkgs.nixos ({ modulesPath, pkgs, ... }: { imports = [ - nixosModule + self.nixosModules.fc-ci (modulesPath + "/virtualisation/qemu-vm.nix") ]; @@ -36,6 +36,7 @@ evaluatorPackage = fc-packages.fc-evaluator; queueRunnerPackage = fc-packages.fc-queue-runner; migratePackage = fc-packages.fc-migrate-cli; + server.enable = true; evaluator.enable = true; queueRunner.enable = true; diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix index b394e3c..c5dcf63 100644 --- a/nix/modules/nixos.nix +++ b/nix/modules/nixos.nix @@ -4,60 +4,73 @@ lib, ... }: let + inherit (lib.modules) mkIf mkDefault; inherit (lib.options) mkOption mkEnableOption; inherit (lib.types) bool str int package listOf submodule nullOr; + inherit (lib.attrsets) recursiveUpdate optionalAttrs; + inherit (lib.lists) optional map; + cfg = config.services.fc; settingsFormat = pkgs.formats.toml {}; settingsType = settingsFormat.type; # Build the final settings by merging declarative config into settings - finalSettings = lib.recursiveUpdate cfg.settings (lib.optionalAttrs (cfg.declarative.projects != [] || cfg.declarative.apiKeys != []) { + finalSettings = recursiveUpdate cfg.settings (optionalAttrs (cfg.declarative.projects != [] || cfg.declarative.apiKeys != []) { declarative = { - projects = map (p: { - name = p.name; - repository_url = p.repositoryUrl; - description = p.description or null; - jobsets = map (j: { - name = j.name; - nix_expression = j.nixExpression; - enabled = j.enabled; - flake_mode = j.flakeMode; - check_interval = j.checkInterval; - }) p.jobsets; - }) cfg.declarative.projects; - api_keys = map (k: { - name = k.name; - key = k.key; - role = k.role; - }) cfg.declarative.apiKeys; + projects = + map (p: { + name = p.name; + repository_url = p.repositoryUrl; + description = p.description or null; + jobsets = + map (j: { + name = j.name; + nix_expression = j.nixExpression; + enabled = j.enabled; + flake_mode = j.flakeMode; + check_interval = j.checkInterval; + }) + p.jobsets; + }) + cfg.declarative.projects; + + api_keys = + map (k: { + name = k.name; + key = k.key; + role = k.role; + }) + cfg.declarative.apiKeys; }; }); settingsFile = settingsFormat.generate "fc.toml" finalSettings; - inherit (builtins) map; - jobsetOpts = { options = { - name = mkOption { - type = str; - description = "Jobset name."; - }; - nixExpression = mkOption { - type = str; - description = "Nix expression to evaluate (e.g. 'packages', 'checks', 'hydraJobs')."; - }; enabled = mkOption { type = bool; default = true; description = "Whether this jobset is enabled for evaluation."; }; + + name = mkOption { + type = str; + description = "Jobset name."; + }; + + nixExpression = mkOption { + type = str; + description = "Nix expression to evaluate (e.g. 'packages', 'checks', 'hydraJobs')."; + }; + flakeMode = mkOption { type = bool; default = true; description = "Whether to evaluate as a flake."; }; + checkInterval = mkOption { type = int; default = 60; @@ -72,15 +85,18 @@ type = str; description = "Project name (unique identifier)."; }; + repositoryUrl = mkOption { type = str; description = "Git repository URL."; }; + description = mkOption { type = nullOr str; default = null; description = "Optional project description."; }; + jobsets = mkOption { type = listOf (submodule jobsetOpts); default = []; @@ -95,6 +111,7 @@ type = str; description = "Human-readable name for this API key."; }; + key = mkOption { type = str; description = '' @@ -102,10 +119,21 @@ Will be hashed before storage. Consider using a secrets manager. ''; }; + role = mkOption { type = str; default = "admin"; - description = "Role: admin, read-only, create-projects, eval-jobset, cancel-build, restart-jobs, bump-to-front."; + description = '' + Role: + + * admin, + * read-only, + * create-projects, + * eval-jobset, + * cancel-build, + * restart-jobs, + * bump-to-front. + ''; }; }; }; @@ -121,13 +149,15 @@ in { evaluatorPackage = mkOption { type = package; default = cfg.package; - description = "The FC evaluator package. Defaults to cfg.package."; + defaultText = "cfg.package"; + description = "The FC evaluator package."; }; queueRunnerPackage = mkOption { type = package; default = cfg.package; - description = "The FC queue runner package. Defaults to cfg.package."; + defaultText = "cfg.package"; + description = "The FC queue runner package."; }; migratePackage = mkOption { @@ -139,8 +169,8 @@ in { type = settingsType; default = {}; description = '' - FC configuration as a Nix attribute set. - Will be converted to TOML and written to fc.toml. + FC configuration as a Nix attribute set. Will be converted to TOML + and written to {file}`fc.toml`. ''; }; @@ -152,19 +182,23 @@ in { Declarative project definitions. These are upserted on every server startup, ensuring the database matches this configuration. ''; - example = lib.literalExpression '' - [ - { - name = "my-project"; - repositoryUrl = "https://github.com/user/repo"; - description = "My Nix project"; - jobsets = [ - { name = "packages"; nixExpression = "packages"; } - { name = "checks"; nixExpression = "checks"; } - ]; - } - ] - ''; + example = [ + { + name = "my-project"; + repositoryUrl = "https://github.com/user/repo"; + description = "My Nix project"; + jobsets = [ + { + name = "packages"; + nixExpression = "packages"; + } + { + name = "checks"; + nixExpression = "checks"; + } + ]; + } + ]; }; apiKeys = mkOption { @@ -174,12 +208,18 @@ in { Declarative API key definitions. Keys are upserted on every server startup. Use a secrets manager for production deployments. ''; - example = lib.literalExpression '' - [ - { name = "admin"; key = "fc_admin_secret"; role = "admin"; } - { name = "ci-bot"; key = "fc_ci_bot_key"; role = "eval-jobset"; } - ] - ''; + example = [ + { + name = "admin"; + key = "fc_admin_secret"; + role = "admin"; + } + { + name = "ci-bot"; + key = "fc_ci_bot_key"; + role = "eval-jobset"; + } + ]; }; }; @@ -204,7 +244,7 @@ in { }; }; - config = lib.mkIf cfg.enable { + config = mkIf cfg.enable { users.users.fc = { isSystemUser = true; group = "fc"; @@ -214,7 +254,7 @@ in { users.groups.fc = {}; - services.postgresql = lib.mkIf cfg.database.createLocally { + services.postgresql = mkIf cfg.database.createLocally { enable = true; ensureDatabases = ["fc"]; ensureUsers = [ @@ -225,14 +265,18 @@ in { ]; }; - services.fc.settings = lib.mkDefault { + services.fc.settings = mkDefault { database.url = "postgresql:///fc?host=/run/postgresql"; server.host = "127.0.0.1"; server.port = 3000; - gc.gc_roots_dir = "/nix/var/nix/gcroots/per-user/fc/fc-roots"; - gc.enabled = true; - gc.max_age_days = 30; - gc.cleanup_interval = 3600; + + gc = { + gc_roots_dir = "/nix/var/nix/gcroots/per-user/fc/fc-roots"; + enabled = true; + max_age_days = 30; + cleanup_interval = 3600; + }; + logs.log_dir = "/var/lib/fc/logs"; cache.enabled = true; evaluator.restrict_eval = true; @@ -240,125 +284,129 @@ in { signing.enabled = false; }; - systemd.tmpfiles.rules = [ - (lib.mkIf cfg.server.enable "d /var/lib/fc/logs 0750 fc fc -") - (lib.mkIf cfg.queueRunner.enable "d /nix/var/nix/gcroots/per-user/fc 0755 fc fc -") - ]; - - systemd.services.fc-server = lib.mkIf cfg.server.enable { - description = "FC CI Server"; - wantedBy = ["multi-user.target"]; - after = ["network.target"] ++ lib.optional cfg.database.createLocally "postgresql.target"; - requires = lib.optional cfg.database.createLocally "postgresql.target"; - - path = with pkgs; [nix zstd]; - - serviceConfig = { - ExecStartPre = "${cfg.migratePackage}/bin/fc-migrate up ${finalSettings.database.url or "postgresql:///fc?host=/run/postgresql"}"; - ExecStart = "${cfg.package}/bin/fc-server"; - Restart = "on-failure"; - RestartSec = 5; - User = "fc"; - Group = "fc"; - StateDirectory = "fc"; - LogsDirectory = "fc"; - WorkingDirectory = "/var/lib/fc"; - ReadWritePaths = ["/var/lib/fc"]; - - # Hardening - ProtectSystem = "strict"; - ProtectHome = true; - NoNewPrivileges = true; - PrivateTmp = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictSUIDSGID = true; - }; - - environment = { - FC_CONFIG_FILE = "${settingsFile}"; - }; - }; - - systemd.services.fc-evaluator = lib.mkIf cfg.evaluator.enable { - description = "FC CI Evaluator"; - wantedBy = ["multi-user.target"]; - after = ["network.target" "fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; - requires = ["fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; - - path = with pkgs; [ - nix - git - nix-eval-jobs + systemd = { + tmpfiles.rules = [ + (mkIf cfg.server.enable "d /var/lib/fc/logs 0750 fc fc -") + (mkIf cfg.queueRunner.enable "d /nix/var/nix/gcroots/per-user/fc 0755 fc fc -") ]; - serviceConfig = { - ExecStart = "${cfg.evaluatorPackage}/bin/fc-evaluator"; - Restart = "on-failure"; - RestartSec = 10; - User = "fc"; - Group = "fc"; - StateDirectory = "fc"; - WorkingDirectory = "/var/lib/fc"; - ReadWritePaths = ["/var/lib/fc"]; + services = { + fc-server = mkIf cfg.server.enable { + description = "FC CI Server"; + wantedBy = ["multi-user.target"]; + after = ["network.target"] ++ optional cfg.database.createLocally "postgresql.target"; + requires = optional cfg.database.createLocally "postgresql.target"; - # Hardening - ProtectSystem = "strict"; - ProtectHome = true; - NoNewPrivileges = true; - PrivateTmp = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictSUIDSGID = true; - }; + path = with pkgs; [nix zstd]; - environment = { - FC_CONFIG_FILE = "${settingsFile}"; - FC_EVALUATOR__WORK_DIR = "/var/lib/fc/evaluator"; - FC_EVALUATOR__RESTRICT_EVAL = "true"; - }; - }; + serviceConfig = { + ExecStartPre = "${cfg.migratePackage}/bin/fc-migrate up ${finalSettings.database.url or "postgresql:///fc?host=/run/postgresql"}"; + ExecStart = "${cfg.package}/bin/fc-server"; + Restart = "on-failure"; + RestartSec = 5; + User = "fc"; + Group = "fc"; + StateDirectory = "fc"; + LogsDirectory = "fc"; + WorkingDirectory = "/var/lib/fc"; + ReadWritePaths = ["/var/lib/fc"]; - systemd.services.fc-queue-runner = lib.mkIf cfg.queueRunner.enable { - description = "FC CI Queue Runner"; - wantedBy = ["multi-user.target"]; - after = ["network.target" "fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; - requires = ["fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; + # Hardening + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + }; - path = with pkgs; [ - nix - ]; + environment = { + FC_CONFIG_FILE = "${settingsFile}"; + }; + }; - serviceConfig = { - ExecStart = "${cfg.queueRunnerPackage}/bin/fc-queue-runner"; - Restart = "on-failure"; - RestartSec = 10; - User = "fc"; - Group = "fc"; - StateDirectory = "fc"; - LogsDirectory = "fc"; - WorkingDirectory = "/var/lib/fc"; - ReadWritePaths = [ - "/var/lib/fc" - "/nix/var/nix/gcroots/per-user/fc" - ]; + fc-evaluator = mkIf cfg.evaluator.enable { + description = "FC CI Evaluator"; + wantedBy = ["multi-user.target"]; + after = ["network.target" "fc-server.service"] ++ optional cfg.database.createLocally "postgresql.target"; + requires = ["fc-server.service"] ++ optional cfg.database.createLocally "postgresql.target"; - # Hardening - ProtectSystem = "strict"; - ProtectHome = true; - NoNewPrivileges = true; - PrivateTmp = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictSUIDSGID = true; - }; + path = with pkgs; [ + nix + git + nix-eval-jobs + ]; - environment = { - FC_CONFIG_FILE = "${settingsFile}"; - FC_QUEUE_RUNNER__WORK_DIR = "/var/lib/fc/queue-runner"; + serviceConfig = { + ExecStart = "${cfg.evaluatorPackage}/bin/fc-evaluator"; + Restart = "on-failure"; + RestartSec = 10; + User = "fc"; + Group = "fc"; + StateDirectory = "fc"; + WorkingDirectory = "/var/lib/fc"; + ReadWritePaths = ["/var/lib/fc"]; + + # Hardening + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + }; + + environment = { + FC_CONFIG_FILE = "${settingsFile}"; + FC_EVALUATOR__WORK_DIR = "/var/lib/fc/evaluator"; + FC_EVALUATOR__RESTRICT_EVAL = "true"; + }; + }; + + fc-queue-runner = mkIf cfg.queueRunner.enable { + description = "FC CI Queue Runner"; + wantedBy = ["multi-user.target"]; + after = ["network.target" "fc-server.service"] ++ optional cfg.database.createLocally "postgresql.target"; + requires = ["fc-server.service"] ++ optional cfg.database.createLocally "postgresql.target"; + + path = with pkgs; [ + nix + ]; + + serviceConfig = { + ExecStart = "${cfg.queueRunnerPackage}/bin/fc-queue-runner"; + Restart = "on-failure"; + RestartSec = 10; + User = "fc"; + Group = "fc"; + StateDirectory = "fc"; + LogsDirectory = "fc"; + WorkingDirectory = "/var/lib/fc"; + ReadWritePaths = [ + "/var/lib/fc" + "/nix/var/nix/gcroots/per-user/fc" + ]; + + # Hardening + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + }; + + environment = { + FC_CONFIG_FILE = "${settingsFile}"; + FC_QUEUE_RUNNER__WORK_DIR = "/var/lib/fc/queue-runner"; + }; + }; }; }; }; diff --git a/nix/tests/api-crud.nix b/nix/tests/api-crud.nix index f6a197e..4f9ab59 100644 --- a/nix/tests/api-crud.nix +++ b/nix/tests/api-crud.nix @@ -1,14 +1,17 @@ -# API CRUD tests: dashboard content, project/jobset/evaluation/build/channel/builder CRUD, admin endpoints, pagination, search -{ - pkgs, - fc-packages, - nixosModule, -}: +{pkgs, self}: pkgs.testers.nixosTest { name = "fc-api-crud"; - nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; + nodes.machine = { + imports = [ + self.nixosModules.fc-ci + ../vm-common.nix + ]; + _module.args.self = self; + }; + # API CRUD tests: dashboard content, project/jobset/evaluation/build/channel/builder + # CRUD, admin endpoints, pagination, search testScript = '' import hashlib import json @@ -24,7 +27,7 @@ pkgs.testers.nixosTest { # Wait for the server to start listening machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) - # ---- Seed an API key for write operations ---- + # Seed an API key for write operations # Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table api_token = "fc_testkey123" api_hash = hashlib.sha256(api_token.encode()).hexdigest() @@ -51,11 +54,7 @@ pkgs.testers.nixosTest { ) project_id = result.strip() - # ======================================================================== - # Phase 4: Dashboard Content & Deep Functional Tests - # ======================================================================== - - # ---- 4A: Dashboard content verification ---- + # Dashboard content verification with subtest("Home page contains Dashboard heading"): body = machine.succeed("curl -sf http://127.0.0.1:3000/") assert "Dashboard" in body, "Home page missing 'Dashboard' heading" @@ -111,7 +110,7 @@ pkgs.testers.nixosTest { assert "api_key" in body or "API" in body, "Login page missing API key input" assert "