nix: cleanup

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia88656a1d6bb152398a5c4ce83d40a3e6a6a6964
This commit is contained in:
raf 2026-02-07 20:22:47 +03:00
commit 62f8cdf4de
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
13 changed files with 514 additions and 529 deletions

View file

@ -46,12 +46,7 @@
cargoArtifacts = craneLib.buildDepsOnly commonArgs; cargoArtifacts = craneLib.buildDepsOnly commonArgs;
in { in {
demo-vm = pkgs.callPackage ./nix/demo-vm.nix { demo-vm = pkgs.callPackage ./nix/demo-vm.nix {inherit self;};
nixosModule = self.nixosModules.default;
fc-packages = {
inherit (self.packages.${system}) fc-common fc-evaluator fc-migrate-cli fc-queue-runner fc-server;
};
};
# FC Packages # FC Packages
fc-common = pkgs.callPackage ./nix/packages/fc-common.nix { fc-common = pkgs.callPackage ./nix/packages/fc-common.nix {
@ -77,35 +72,26 @@
checks = forAllSystems (system: let checks = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system}; 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 { in {
# Split VM integration tests # Split VM integration tests
service-startup = pkgs.callPackage ./nix/tests/service-startup.nix testArgs; service-startup = pkgs.callPackage ./nix/tests/startup.nix {inherit self;};
basic-api = pkgs.callPackage ./nix/tests/basic-api.nix testArgs; basic-api = pkgs.callPackage ./nix/tests/basic-api.nix {inherit self;};
auth-rbac = pkgs.callPackage ./nix/tests/auth-rbac.nix testArgs; auth-rbac = pkgs.callPackage ./nix/tests/auth-rbac.nix {inherit self;};
api-crud = pkgs.callPackage ./nix/tests/api-crud.nix testArgs; api-crud = pkgs.callPackage ./nix/tests/api-crud.nix {inherit self;};
features = pkgs.callPackage ./nix/tests/features.nix testArgs; features = pkgs.callPackage ./nix/tests/features.nix {inherit self;};
e2e = pkgs.callPackage ./nix/tests/e2e.nix testArgs; webhooks = pkgs.callPackage ./nix/tests/webhooks.nix {inherit self;};
e2e = pkgs.callPackage ./nix/tests/e2e.nix {inherit self;};
# Legacy monolithic test (for reference, can be removed after split tests pass)
vm-test = pkgs.callPackage ./nix/vm-test.nix testArgs;
}); });
devShells = forAllSystems (system: let devShells = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
in { in {
default = pkgs.mkShell { default = pkgs.mkShell {
name = "fc"; name = "fc-dev";
inputsFrom = [self.packages.${system}.fc-server]; inputsFrom = [self.packages.${system}.fc-server];
strictDeps = true; strictDeps = true;
packages = with pkgs; [ packages = with pkgs; [
postgresql
pkg-config pkg-config
openssl openssl
@ -116,5 +102,7 @@
]; ];
}; };
}); });
formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.alejandra);
}; };
} }

View file

@ -1,15 +1,15 @@
{ {
pkgs, pkgs,
fc-packages, self,
nixosModule,
}: let }: let
fc-packages = self.packages.${pkgs.stdenv.hostPlatform.system};
nixos = pkgs.nixos ({ nixos = pkgs.nixos ({
modulesPath, modulesPath,
pkgs, pkgs,
... ...
}: { }: {
imports = [ imports = [
nixosModule self.nixosModules.fc-ci
(modulesPath + "/virtualisation/qemu-vm.nix") (modulesPath + "/virtualisation/qemu-vm.nix")
]; ];
@ -36,6 +36,7 @@
evaluatorPackage = fc-packages.fc-evaluator; evaluatorPackage = fc-packages.fc-evaluator;
queueRunnerPackage = fc-packages.fc-queue-runner; queueRunnerPackage = fc-packages.fc-queue-runner;
migratePackage = fc-packages.fc-migrate-cli; migratePackage = fc-packages.fc-migrate-cli;
server.enable = true; server.enable = true;
evaluator.enable = true; evaluator.enable = true;
queueRunner.enable = true; queueRunner.enable = true;

View file

@ -4,60 +4,73 @@
lib, lib,
... ...
}: let }: let
inherit (lib.modules) mkIf mkDefault;
inherit (lib.options) mkOption mkEnableOption; inherit (lib.options) mkOption mkEnableOption;
inherit (lib.types) bool str int package listOf submodule nullOr; inherit (lib.types) bool str int package listOf submodule nullOr;
inherit (lib.attrsets) recursiveUpdate optionalAttrs;
inherit (lib.lists) optional map;
cfg = config.services.fc; cfg = config.services.fc;
settingsFormat = pkgs.formats.toml {}; settingsFormat = pkgs.formats.toml {};
settingsType = settingsFormat.type; settingsType = settingsFormat.type;
# Build the final settings by merging declarative config into settings # 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 = { declarative = {
projects = map (p: { projects =
name = p.name; map (p: {
repository_url = p.repositoryUrl; name = p.name;
description = p.description or null; repository_url = p.repositoryUrl;
jobsets = map (j: { description = p.description or null;
name = j.name; jobsets =
nix_expression = j.nixExpression; map (j: {
enabled = j.enabled; name = j.name;
flake_mode = j.flakeMode; nix_expression = j.nixExpression;
check_interval = j.checkInterval; enabled = j.enabled;
}) p.jobsets; flake_mode = j.flakeMode;
}) cfg.declarative.projects; check_interval = j.checkInterval;
api_keys = map (k: { })
name = k.name; p.jobsets;
key = k.key; })
role = k.role; cfg.declarative.projects;
}) cfg.declarative.apiKeys;
api_keys =
map (k: {
name = k.name;
key = k.key;
role = k.role;
})
cfg.declarative.apiKeys;
}; };
}); });
settingsFile = settingsFormat.generate "fc.toml" finalSettings; settingsFile = settingsFormat.generate "fc.toml" finalSettings;
inherit (builtins) map;
jobsetOpts = { jobsetOpts = {
options = { options = {
name = mkOption {
type = str;
description = "Jobset name.";
};
nixExpression = mkOption {
type = str;
description = "Nix expression to evaluate (e.g. 'packages', 'checks', 'hydraJobs').";
};
enabled = mkOption { enabled = mkOption {
type = bool; type = bool;
default = true; default = true;
description = "Whether this jobset is enabled for evaluation."; 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 { flakeMode = mkOption {
type = bool; type = bool;
default = true; default = true;
description = "Whether to evaluate as a flake."; description = "Whether to evaluate as a flake.";
}; };
checkInterval = mkOption { checkInterval = mkOption {
type = int; type = int;
default = 60; default = 60;
@ -72,15 +85,18 @@
type = str; type = str;
description = "Project name (unique identifier)."; description = "Project name (unique identifier).";
}; };
repositoryUrl = mkOption { repositoryUrl = mkOption {
type = str; type = str;
description = "Git repository URL."; description = "Git repository URL.";
}; };
description = mkOption { description = mkOption {
type = nullOr str; type = nullOr str;
default = null; default = null;
description = "Optional project description."; description = "Optional project description.";
}; };
jobsets = mkOption { jobsets = mkOption {
type = listOf (submodule jobsetOpts); type = listOf (submodule jobsetOpts);
default = []; default = [];
@ -95,6 +111,7 @@
type = str; type = str;
description = "Human-readable name for this API key."; description = "Human-readable name for this API key.";
}; };
key = mkOption { key = mkOption {
type = str; type = str;
description = '' description = ''
@ -102,10 +119,21 @@
Will be hashed before storage. Consider using a secrets manager. Will be hashed before storage. Consider using a secrets manager.
''; '';
}; };
role = mkOption { role = mkOption {
type = str; type = str;
default = "admin"; 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 { evaluatorPackage = mkOption {
type = package; type = package;
default = cfg.package; default = cfg.package;
description = "The FC evaluator package. Defaults to cfg.package."; defaultText = "cfg.package";
description = "The FC evaluator package.";
}; };
queueRunnerPackage = mkOption { queueRunnerPackage = mkOption {
type = package; type = package;
default = cfg.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 { migratePackage = mkOption {
@ -139,8 +169,8 @@ in {
type = settingsType; type = settingsType;
default = {}; default = {};
description = '' description = ''
FC configuration as a Nix attribute set. FC configuration as a Nix attribute set. Will be converted to TOML
Will be converted to TOML and written to fc.toml. and written to {file}`fc.toml`.
''; '';
}; };
@ -152,19 +182,23 @@ in {
Declarative project definitions. These are upserted on every Declarative project definitions. These are upserted on every
server startup, ensuring the database matches this configuration. server startup, ensuring the database matches this configuration.
''; '';
example = lib.literalExpression '' example = [
[ {
{ name = "my-project";
name = "my-project"; repositoryUrl = "https://github.com/user/repo";
repositoryUrl = "https://github.com/user/repo"; description = "My Nix project";
description = "My Nix project"; jobsets = [
jobsets = [ {
{ name = "packages"; nixExpression = "packages"; } name = "packages";
{ name = "checks"; nixExpression = "checks"; } nixExpression = "packages";
]; }
} {
] name = "checks";
''; nixExpression = "checks";
}
];
}
];
}; };
apiKeys = mkOption { apiKeys = mkOption {
@ -174,12 +208,18 @@ in {
Declarative API key definitions. Keys are upserted on every Declarative API key definitions. Keys are upserted on every
server startup. Use a secrets manager for production deployments. server startup. Use a secrets manager for production deployments.
''; '';
example = lib.literalExpression '' example = [
[ {
{ name = "admin"; key = "fc_admin_secret"; role = "admin"; } name = "admin";
{ name = "ci-bot"; key = "fc_ci_bot_key"; role = "eval-jobset"; } 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 = { users.users.fc = {
isSystemUser = true; isSystemUser = true;
group = "fc"; group = "fc";
@ -214,7 +254,7 @@ in {
users.groups.fc = {}; users.groups.fc = {};
services.postgresql = lib.mkIf cfg.database.createLocally { services.postgresql = mkIf cfg.database.createLocally {
enable = true; enable = true;
ensureDatabases = ["fc"]; ensureDatabases = ["fc"];
ensureUsers = [ ensureUsers = [
@ -225,14 +265,18 @@ in {
]; ];
}; };
services.fc.settings = lib.mkDefault { services.fc.settings = mkDefault {
database.url = "postgresql:///fc?host=/run/postgresql"; database.url = "postgresql:///fc?host=/run/postgresql";
server.host = "127.0.0.1"; server.host = "127.0.0.1";
server.port = 3000; server.port = 3000;
gc.gc_roots_dir = "/nix/var/nix/gcroots/per-user/fc/fc-roots";
gc.enabled = true; gc = {
gc.max_age_days = 30; gc_roots_dir = "/nix/var/nix/gcroots/per-user/fc/fc-roots";
gc.cleanup_interval = 3600; enabled = true;
max_age_days = 30;
cleanup_interval = 3600;
};
logs.log_dir = "/var/lib/fc/logs"; logs.log_dir = "/var/lib/fc/logs";
cache.enabled = true; cache.enabled = true;
evaluator.restrict_eval = true; evaluator.restrict_eval = true;
@ -240,125 +284,129 @@ in {
signing.enabled = false; signing.enabled = false;
}; };
systemd.tmpfiles.rules = [ systemd = {
(lib.mkIf cfg.server.enable "d /var/lib/fc/logs 0750 fc fc -") tmpfiles.rules = [
(lib.mkIf cfg.queueRunner.enable "d /nix/var/nix/gcroots/per-user/fc 0755 fc fc -") (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 -")
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
]; ];
serviceConfig = { services = {
ExecStart = "${cfg.evaluatorPackage}/bin/fc-evaluator"; fc-server = mkIf cfg.server.enable {
Restart = "on-failure"; description = "FC CI Server";
RestartSec = 10; wantedBy = ["multi-user.target"];
User = "fc"; after = ["network.target"] ++ optional cfg.database.createLocally "postgresql.target";
Group = "fc"; requires = optional cfg.database.createLocally "postgresql.target";
StateDirectory = "fc";
WorkingDirectory = "/var/lib/fc";
ReadWritePaths = ["/var/lib/fc"];
# Hardening path = with pkgs; [nix zstd];
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
environment = { serviceConfig = {
FC_CONFIG_FILE = "${settingsFile}"; ExecStartPre = "${cfg.migratePackage}/bin/fc-migrate up ${finalSettings.database.url or "postgresql:///fc?host=/run/postgresql"}";
FC_EVALUATOR__WORK_DIR = "/var/lib/fc/evaluator"; ExecStart = "${cfg.package}/bin/fc-server";
FC_EVALUATOR__RESTRICT_EVAL = "true"; 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 { # Hardening
description = "FC CI Queue Runner"; ProtectSystem = "strict";
wantedBy = ["multi-user.target"]; ProtectHome = true;
after = ["network.target" "fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; NoNewPrivileges = true;
requires = ["fc-server.service"] ++ lib.optional cfg.database.createLocally "postgresql.target"; PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
path = with pkgs; [ environment = {
nix FC_CONFIG_FILE = "${settingsFile}";
]; };
};
serviceConfig = { fc-evaluator = mkIf cfg.evaluator.enable {
ExecStart = "${cfg.queueRunnerPackage}/bin/fc-queue-runner"; description = "FC CI Evaluator";
Restart = "on-failure"; wantedBy = ["multi-user.target"];
RestartSec = 10; after = ["network.target" "fc-server.service"] ++ optional cfg.database.createLocally "postgresql.target";
User = "fc"; requires = ["fc-server.service"] ++ optional cfg.database.createLocally "postgresql.target";
Group = "fc";
StateDirectory = "fc";
LogsDirectory = "fc";
WorkingDirectory = "/var/lib/fc";
ReadWritePaths = [
"/var/lib/fc"
"/nix/var/nix/gcroots/per-user/fc"
];
# Hardening path = with pkgs; [
ProtectSystem = "strict"; nix
ProtectHome = true; git
NoNewPrivileges = true; nix-eval-jobs
PrivateTmp = true; ];
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
environment = { serviceConfig = {
FC_CONFIG_FILE = "${settingsFile}"; ExecStart = "${cfg.evaluatorPackage}/bin/fc-evaluator";
FC_QUEUE_RUNNER__WORK_DIR = "/var/lib/fc/queue-runner"; 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";
};
};
}; };
}; };
}; };

View file

@ -1,14 +1,17 @@
# API CRUD tests: dashboard content, project/jobset/evaluation/build/channel/builder CRUD, admin endpoints, pagination, search {pkgs, self}:
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "fc-api-crud"; 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 = '' testScript = ''
import hashlib import hashlib
import json import json
@ -24,7 +27,7 @@ pkgs.testers.nixosTest {
# Wait for the server to start listening # Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) 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 # Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123" api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest() api_hash = hashlib.sha256(api_token.encode()).hexdigest()
@ -51,11 +54,7 @@ pkgs.testers.nixosTest {
) )
project_id = result.strip() project_id = result.strip()
# ======================================================================== # Dashboard content verification
# Phase 4: Dashboard Content & Deep Functional Tests
# ========================================================================
# ---- 4A: Dashboard content verification ----
with subtest("Home page contains Dashboard heading"): with subtest("Home page contains Dashboard heading"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/") body = machine.succeed("curl -sf http://127.0.0.1:3000/")
assert "Dashboard" in body, "Home page missing 'Dashboard' heading" 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 "api_key" in body or "API" in body, "Login page missing API key input"
assert "<form" in body.lower(), "Login page missing form element" assert "<form" in body.lower(), "Login page missing form element"
# ---- 4B: Dashboard page for specific entities ---- # Dashboard page for specific entities
with subtest("Project detail page renders for existing project"): with subtest("Project detail page renders for existing project"):
body = machine.succeed( body = machine.succeed(
f"curl -sf http://127.0.0.1:3000/project/{project_id}" f"curl -sf http://127.0.0.1:3000/project/{project_id}"
@ -126,7 +125,7 @@ pkgs.testers.nixosTest {
# Should return 200 with "not found" message or similar, not crash # Should return 200 with "not found" message or similar, not crash
assert code.strip() == "200", f"Expected 200 for missing project detail, got {code.strip()}" assert code.strip() == "200", f"Expected 200 for missing project detail, got {code.strip()}"
# ---- 4C: Project update via PUT ---- # Project update via PUT
with subtest("Update project description via PUT"): with subtest("Update project description via PUT"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "
@ -153,7 +152,7 @@ pkgs.testers.nixosTest {
) )
assert code.strip() == "403", f"Expected 403 for read-only PUT, got {code.strip()}" assert code.strip() == "403", f"Expected 403 for read-only PUT, got {code.strip()}"
# ---- 4D: Jobset CRUD ---- # Jobset CRUD
with subtest("Create jobset for test-project"): with subtest("Create jobset for test-project"):
result = machine.succeed( result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets " f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets "
@ -177,7 +176,7 @@ pkgs.testers.nixosTest {
) )
assert "main" in body, "Jobset detail page should show jobset name" assert "main" in body, "Jobset detail page should show jobset name"
# ---- 4E: Evaluation trigger and lifecycle ---- # Evaluation trigger and lifecycle
with subtest("Trigger evaluation via API"): with subtest("Trigger evaluation via API"):
result = machine.succeed( result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/evaluations/trigger " "curl -sf -X POST http://127.0.0.1:3000/api/v1/evaluations/trigger "
@ -217,7 +216,7 @@ pkgs.testers.nixosTest {
) )
assert code.strip() == "403", f"Expected 403 for read-only eval trigger, got {code.strip()}" assert code.strip() == "403", f"Expected 403 for read-only eval trigger, got {code.strip()}"
# ---- 4E2: Build lifecycle (restart, bump) ---- # Build lifecycle (restart, bump)
# Create a build via SQL since builds are normally created by the evaluator # Create a build via SQL since builds are normally created by the evaluator
with subtest("Create test build via SQL"): with subtest("Create test build via SQL"):
machine.succeed( machine.succeed(
@ -284,7 +283,7 @@ pkgs.testers.nixosTest {
) )
assert "cancelled" in result.strip().lower(), f"Expected cancelled, got: {result.strip()}" assert "cancelled" in result.strip().lower(), f"Expected cancelled, got: {result.strip()}"
# ---- 4E3: Evaluation comparison ---- # Evaluation comparison ----
with subtest("Trigger second evaluation for comparison"): with subtest("Trigger second evaluation for comparison"):
result = machine.succeed( result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/evaluations/trigger " "curl -sf -X POST http://127.0.0.1:3000/api/v1/evaluations/trigger "
@ -318,7 +317,7 @@ pkgs.testers.nixosTest {
assert len(data["new_jobs"]) >= 1, f"Expected at least 1 new job, got {data['new_jobs']}" assert len(data["new_jobs"]) >= 1, f"Expected at least 1 new job, got {data['new_jobs']}"
assert any(j["job_name"] == "new-pkg" for j in data["new_jobs"]), "new-pkg should be in new_jobs" assert any(j["job_name"] == "new-pkg" for j in data["new_jobs"]), "new-pkg should be in new_jobs"
# ---- 4F: Channel CRUD lifecycle ---- # Channel CRUD lifecycle ----
with subtest("Create channel via API"): with subtest("Create channel via API"):
result = machine.succeed( result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/channels " "curl -sf -X POST http://127.0.0.1:3000/api/v1/channels "
@ -382,7 +381,7 @@ pkgs.testers.nixosTest {
) )
assert code.strip() == "200", f"Expected 200 for channel delete, got {code.strip()}" assert code.strip() == "200", f"Expected 200 for channel delete, got {code.strip()}"
# ---- 4G: Remote builder CRUD lifecycle ---- # Remote builder CRUD lifecycle
with subtest("List remote builders"): with subtest("List remote builders"):
result = machine.succeed( result = machine.succeed(
"curl -sf http://127.0.0.1:3000/api/v1/admin/builders | jq 'length'" "curl -sf http://127.0.0.1:3000/api/v1/admin/builders | jq 'length'"
@ -456,7 +455,7 @@ pkgs.testers.nixosTest {
) )
assert code.strip() == "200", f"Expected 200 for builder delete, got {code.strip()}" assert code.strip() == "200", f"Expected 200 for builder delete, got {code.strip()}"
# ---- 4H: Admin system status endpoint ---- # Admin system status endpoint
with subtest("System status endpoint requires admin"): with subtest("System status endpoint requires admin"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "
@ -473,7 +472,7 @@ pkgs.testers.nixosTest {
) )
assert int(result.strip()) >= 1, f"Expected at least 1 project in system status, got {result.strip()}" assert int(result.strip()) >= 1, f"Expected at least 1 project in system status, got {result.strip()}"
# ---- 4I: API key listing ---- # API key listing
with subtest("List API keys requires admin"): with subtest("List API keys requires admin"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "
@ -490,7 +489,7 @@ pkgs.testers.nixosTest {
) )
assert int(result.strip()) >= 1, f"Expected at least 1 API key, got {result.strip()}" assert int(result.strip()) >= 1, f"Expected at least 1 API key, got {result.strip()}"
# ---- 4J: Badge endpoints ---- # Badge endpoints
with subtest("Badge endpoint returns SVG for unknown project"): with subtest("Badge endpoint returns SVG for unknown project"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "
@ -523,7 +522,7 @@ pkgs.testers.nixosTest {
) )
assert code.strip() in ("404", "500"), f"Expected 404/500 for latest build, got {code.strip()}" assert code.strip() in ("404", "500"), f"Expected 404/500 for latest build, got {code.strip()}"
# ---- 4K: Pagination tests ---- # Pagination tests
# Re-verify server is healthy before pagination tests # Re-verify server is healthy before pagination tests
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=15) machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=15)
@ -555,7 +554,7 @@ pkgs.testers.nixosTest {
assert "limit" in data, f"Expected paginated response with 'limit' field, got: {result[:300]}" assert "limit" in data, f"Expected paginated response with 'limit' field, got: {result[:300]}"
assert data["limit"] == 2, f"Expected limit=2, got {data['limit']}" assert data["limit"] == 2, f"Expected limit=2, got {data['limit']}"
# ---- 4L: Build sub-resources ---- # Build sub-resources
with subtest("Build steps endpoint returns empty array for nonexistent build"): with subtest("Build steps endpoint returns empty array for nonexistent build"):
result = machine.succeed( result = machine.succeed(
"curl -sf " "curl -sf "
@ -579,7 +578,7 @@ pkgs.testers.nixosTest {
) )
assert code.strip() == "404", f"Expected 404 for nonexistent build log, got {code.strip()}" assert code.strip() == "404", f"Expected 404 for nonexistent build log, got {code.strip()}"
# ---- 4M: Search functionality ---- # Search functionality
with subtest("Search returns matching projects"): with subtest("Search returns matching projects"):
result = machine.succeed( result = machine.succeed(
"curl -sf 'http://127.0.0.1:3000/api/v1/search?q=test-project' | jq '.projects | length'" "curl -sf 'http://127.0.0.1:3000/api/v1/search?q=test-project' | jq '.projects | length'"
@ -592,7 +591,7 @@ pkgs.testers.nixosTest {
) )
assert result.strip() == "0", f"Expected 0, got {result.strip()}" assert result.strip() == "0", f"Expected 0, got {result.strip()}"
# ---- 4N: Content-Type verification for API endpoints ---- # Content-Type verification for API endpoints
with subtest("API endpoints return application/json"): with subtest("API endpoints return application/json"):
ct = machine.succeed( ct = machine.succeed(
"curl -s -D - -o /dev/null http://127.0.0.1:3000/api/v1/projects | grep -i content-type" "curl -s -D - -o /dev/null http://127.0.0.1:3000/api/v1/projects | grep -i content-type"
@ -611,7 +610,7 @@ pkgs.testers.nixosTest {
) )
assert "text/plain" in ct.lower() or "text/" in ct.lower(), f"Expected text content type for metrics, got: {ct}" assert "text/plain" in ct.lower() or "text/" in ct.lower(), f"Expected text content type for metrics, got: {ct}"
# ---- 4O: Session/Cookie auth for dashboard ---- # Session/Cookie auth for dashboard
with subtest("Login with valid API key sets session cookie"): with subtest("Login with valid API key sets session cookie"):
result = machine.succeed( result = machine.succeed(
"curl -s -D - -o /dev/null " "curl -s -D - -o /dev/null "
@ -661,7 +660,7 @@ pkgs.testers.nixosTest {
assert "Max-Age=0" in result or "max-age=0" in result.lower(), \ assert "Max-Age=0" in result or "max-age=0" in result.lower(), \
"Logout should set Max-Age=0 to clear cookie" "Logout should set Max-Age=0 to clear cookie"
# ---- 4P: RBAC with create-projects role ---- # RBAC with create-projects role
cp_token = "fc_createprojects_key" cp_token = "fc_createprojects_key"
cp_hash = hashlib.sha256(cp_token.encode()).hexdigest() cp_hash = hashlib.sha256(cp_token.encode()).hexdigest()
machine.succeed( machine.succeed(
@ -709,7 +708,7 @@ pkgs.testers.nixosTest {
) )
assert code.strip() == "403", f"Expected 403 for create-projects system status, got {code.strip()}" assert code.strip() == "403", f"Expected 403 for create-projects system status, got {code.strip()}"
# ---- 4Q: Additional security tests ---- # Additional security tests
with subtest("DELETE project without auth returns 401"): with subtest("DELETE project without auth returns 401"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "

View file

@ -1,14 +1,16 @@
# Authentication and RBAC tests {pkgs, self}:
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "fc-auth-rbac"; name = "fc-auth-rbac";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; nodes.machine = {
imports = [
self.nixosModules.fc-ci
../vm-common.nix
];
_module.args.self = self;
};
# Authentication and RBAC tests
testScript = '' testScript = ''
import hashlib import hashlib
import json import json

View file

@ -1,12 +1,14 @@
{ {pkgs, self}:
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "fc-basic-api"; name = "fc-basic-api";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; nodes.machine = {
imports = [
self.nixosModules.fc-ci
../vm-common.nix
];
_module.args.self = self;
};
testScript = '' testScript = ''
import hashlib import hashlib
@ -22,7 +24,7 @@ pkgs.testers.nixosTest {
# Wait for the server to start listening # Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) 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 # Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123" api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest() api_hash = hashlib.sha256(api_token.encode()).hexdigest()
@ -31,7 +33,7 @@ pkgs.testers.nixosTest {
) )
auth_header = f"-H 'Authorization: Bearer {api_token}'" auth_header = f"-H 'Authorization: Bearer {api_token}'"
# ---- Health endpoint ---- # Health endpoint
with subtest("Health endpoint returns OK"): with subtest("Health endpoint returns OK"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/health | jq -r .status") result = machine.succeed("curl -sf http://127.0.0.1:3000/health | jq -r .status")
assert result.strip() == "ok", f"Expected 'ok', got '{result.strip()}'" assert result.strip() == "ok", f"Expected 'ok', got '{result.strip()}'"
@ -40,13 +42,13 @@ pkgs.testers.nixosTest {
result = machine.succeed("curl -sf http://127.0.0.1:3000/health | jq -r .database") result = machine.succeed("curl -sf http://127.0.0.1:3000/health | jq -r .database")
assert result.strip() == "true", f"Expected 'true', got '{result.strip()}'" assert result.strip() == "true", f"Expected 'true', got '{result.strip()}'"
# ---- Cache endpoint: nix-cache-info ---- # Cache endpoint: nix-cache-info
with subtest("Cache info endpoint returns correct data"): with subtest("Cache info endpoint returns correct data"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/nix-cache/nix-cache-info") result = machine.succeed("curl -sf http://127.0.0.1:3000/nix-cache/nix-cache-info")
assert "StoreDir: /nix/store" in result, f"Missing StoreDir in: {result}" assert "StoreDir: /nix/store" in result, f"Missing StoreDir in: {result}"
assert "WantMassQuery: 1" in result, f"Missing WantMassQuery in: {result}" assert "WantMassQuery: 1" in result, f"Missing WantMassQuery in: {result}"
# ---- Cache endpoint: invalid hash rejection ---- # Cache endpoint: invalid hash rejection
with subtest("Cache rejects short hash"): with subtest("Cache rejects short hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/tooshort.narinfo | grep -q 404") machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/tooshort.narinfo | grep -q 404")
@ -59,14 +61,14 @@ pkgs.testers.nixosTest {
with subtest("Cache returns 404 for valid but nonexistent hash"): with subtest("Cache returns 404 for valid but nonexistent hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo | grep -q 404") machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo | grep -q 404")
# ---- NAR endpoints: invalid hash rejection ---- # NAR endpoints: invalid hash rejection
with subtest("NAR zst rejects invalid hash"): with subtest("NAR zst rejects invalid hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/nar/INVALID.nar.zst | grep -q 404") machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/nar/INVALID.nar.zst | grep -q 404")
with subtest("NAR plain rejects invalid hash"): with subtest("NAR plain rejects invalid hash"):
machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/nar/INVALID.nar | grep -q 404") machine.succeed("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/nix-cache/nar/INVALID.nar | grep -q 404")
# ---- Search endpoint: length validation ---- # Search endpoint: length validation
with subtest("Search rejects empty query"): with subtest("Search rejects empty query"):
result = machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/search?q=' | jq '.projects | length'") result = machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/search?q=' | jq '.projects | length'")
assert result.strip() == "0", f"Expected 0 projects, got {result.strip()}" assert result.strip() == "0", f"Expected 0 projects, got {result.strip()}"
@ -76,12 +78,12 @@ pkgs.testers.nixosTest {
result = machine.succeed(f"curl -sf 'http://127.0.0.1:3000/api/v1/search?q={long_q}' | jq '.projects | length'") result = machine.succeed(f"curl -sf 'http://127.0.0.1:3000/api/v1/search?q={long_q}' | jq '.projects | length'")
assert result.strip() == "0", f"Expected 0 projects for long query, got {result.strip()}" assert result.strip() == "0", f"Expected 0 projects for long query, got {result.strip()}"
# ---- Error response format ---- # Error response format
with subtest("404 error response includes error_code field"): with subtest("404 error response includes error_code field"):
json_result = machine.succeed("curl -s http://127.0.0.1:3000/api/v1/projects/00000000-0000-0000-0000-000000000000 | jq -r .error_code") json_result = machine.succeed("curl -s http://127.0.0.1:3000/api/v1/projects/00000000-0000-0000-0000-000000000000 | jq -r .error_code")
assert json_result.strip() == "NOT_FOUND", f"Expected NOT_FOUND, got {json_result.strip()}" assert json_result.strip() == "NOT_FOUND", f"Expected NOT_FOUND, got {json_result.strip()}"
# ---- Empty page states (before any data is created) ---- # Empty page states (before any data is created)
with subtest("Empty evaluations page has proper empty state"): with subtest("Empty evaluations page has proper empty state"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/evaluations") body = machine.succeed("curl -sf http://127.0.0.1:3000/evaluations")
assert "Page 1 of 0" not in body, \ assert "Page 1 of 0" not in body, \
@ -107,7 +109,7 @@ pkgs.testers.nixosTest {
assert "table-wrap" in body, \ assert "table-wrap" in body, \
"Projects page should wrap tables in .table-wrap class" "Projects page should wrap tables in .table-wrap class"
# ---- API CRUD: create and list projects ---- # API CRUD: create and list projects
with subtest("Create a project via API"): with subtest("Create a project via API"):
result = machine.succeed( result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects " "curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
@ -123,7 +125,7 @@ pkgs.testers.nixosTest {
result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/projects | jq '.items[0].name'") result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/projects | jq '.items[0].name'")
assert "test-project" in result, f"Expected test-project in: {result}" assert "test-project" in result, f"Expected test-project in: {result}"
# ---- Builds list with filters ---- # Builds list with filters
with subtest("Builds list with system filter returns 200"): with subtest("Builds list with system filter returns 200"):
machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/builds?system=x86_64-linux' | jq '.items'") machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/builds?system=x86_64-linux' | jq '.items'")
@ -133,14 +135,14 @@ pkgs.testers.nixosTest {
with subtest("Builds list with combined filters returns 200"): with subtest("Builds list with combined filters returns 200"):
machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/builds?system=x86_64-linux&status=pending&job_name=test' | jq '.items'") machine.succeed("curl -sf 'http://127.0.0.1:3000/api/v1/builds?system=x86_64-linux&status=pending&job_name=test' | jq '.items'")
# ---- Metrics endpoint ---- # Metrics endpoint
with subtest("Metrics endpoint returns prometheus format"): with subtest("Metrics endpoint returns prometheus format"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics") result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics")
assert "fc_builds_total" in result, "Missing fc_builds_total in metrics" assert "fc_builds_total" in result, "Missing fc_builds_total in metrics"
assert "fc_projects_total" in result, "Missing fc_projects_total in metrics" assert "fc_projects_total" in result, "Missing fc_projects_total in metrics"
assert "fc_evaluations_total" in result, "Missing fc_evaluations_total in metrics" assert "fc_evaluations_total" in result, "Missing fc_evaluations_total in metrics"
# ---- CORS: default restrictive (no Access-Control-Allow-Origin for cross-origin) ---- # CORS: default restrictive (no Access-Control-Allow-Origin for cross-origin)
with subtest("Default CORS does not allow arbitrary origins"): with subtest("Default CORS does not allow arbitrary origins"):
result = machine.succeed( result = machine.succeed(
"curl -s -D - " "curl -s -D - "
@ -153,7 +155,7 @@ pkgs.testers.nixosTest {
assert "access-control-allow-origin: http://evil.example.com" not in result.lower(), \ assert "access-control-allow-origin: http://evil.example.com" not in result.lower(), \
f"CORS should not allow arbitrary origins: {result}" f"CORS should not allow arbitrary origins: {result}"
# ---- Systemd hardening ---- # Systemd hardening
with subtest("fc-server runs as fc user"): with subtest("fc-server runs as fc user"):
result = machine.succeed("systemctl show fc-server --property=User --value") result = machine.succeed("systemctl show fc-server --property=User --value")
assert result.strip() == "fc", f"Expected fc user, got '{result.strip()}'" assert result.strip() == "fc", f"Expected fc user, got '{result.strip()}'"
@ -168,7 +170,7 @@ pkgs.testers.nixosTest {
with subtest("Log directory exists"): with subtest("Log directory exists"):
machine.succeed("test -d /var/lib/fc/logs || mkdir -p /var/lib/fc/logs") machine.succeed("test -d /var/lib/fc/logs || mkdir -p /var/lib/fc/logs")
# ---- Stats endpoint ---- # Stats endpoint
with subtest("Build stats endpoint returns data"): with subtest("Build stats endpoint returns data"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/builds/stats | jq '.total_builds'") result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/builds/stats | jq '.total_builds'")
# Should be a number (possibly 0) # Should be a number (possibly 0)

View file

@ -1,80 +0,0 @@
# Common machine configuration for all FC integration tests
{
pkgs,
fc-packages,
nixosModule,
}: {
imports = [nixosModule];
programs.git.enable = true;
security.sudo.enable = true;
# Ensure nix and zstd are available for cache endpoints
environment.systemPackages = with pkgs; [nix nix-eval-jobs zstd curl jq openssl];
services.fc = {
enable = true;
package = fc-packages.fc-server;
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;
settings = {
database.url = "postgresql:///fc?host=/run/postgresql";
server = {
host = "127.0.0.1";
port = 3000;
cors_permissive = false;
};
gc.enabled = false;
logs.log_dir = "/var/lib/fc/logs";
cache.enabled = true;
signing.enabled = false;
tracing = {
level = "info";
format = "compact";
show_targets = true;
show_timestamps = true;
};
evaluator.poll_interval = 5;
evaluator.work_dir = "/var/lib/fc/evaluator";
queue_runner.poll_interval = 3;
queue_runner.work_dir = "/var/lib/fc/queue-runner";
# Declarative bootstrap: project + API key created on startup
declarative = {
projects = [
{
name = "declarative-project";
repository_url = "https://github.com/test/declarative";
description = "Bootstrap test project";
jobsets = [
{
name = "packages";
nix_expression = "packages";
enabled = true;
flake_mode = true;
check_interval = 300;
}
];
}
];
api_keys = [
{
name = "bootstrap-admin";
key = "fc_bootstrap_key";
role = "admin";
}
];
};
};
};
}

View file

@ -1,14 +1,17 @@
# End-to-end tests: flake creation, evaluation, queue runner, notification, signing, GC, declarative, webhooks {pkgs, self}:
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "fc-e2e"; name = "fc-e2e";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; nodes.machine = {
imports = [
self.nixosModules.fc-ci
../vm-common.nix
];
_module.args.self = self;
};
# End-to-end tests: flake creation, evaluation, queue runner, notification,
# signing, GC, declarative, webhooks
testScript = '' testScript = ''
import hashlib import hashlib
import json import json
@ -24,7 +27,7 @@ pkgs.testers.nixosTest {
# Wait for the server to start listening # Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) 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 # Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123" api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest() api_hash = hashlib.sha256(api_token.encode()).hexdigest()
@ -41,11 +44,7 @@ pkgs.testers.nixosTest {
) )
ro_header = f"-H 'Authorization: Bearer {ro_token}'" ro_header = f"-H 'Authorization: Bearer {ro_token}'"
# ======================================================================== # Create a test flake inside the VM
# Phase E2E-1: End-to-End Evaluator Integration Test
# ========================================================================
# ---- Create a test flake inside the VM ----
with subtest("Create bare git repo with test flake"): with subtest("Create bare git repo with test flake"):
machine.succeed("mkdir -p /var/lib/fc/test-repos") machine.succeed("mkdir -p /var/lib/fc/test-repos")
machine.succeed("git init --bare /var/lib/fc/test-repos/test-flake.git") machine.succeed("git init --bare /var/lib/fc/test-repos/test-flake.git")
@ -78,7 +77,7 @@ pkgs.testers.nixosTest {
# Set ownership for fc user # Set ownership for fc user
machine.succeed("chown -R fc:fc /var/lib/fc/test-repos") machine.succeed("chown -R fc:fc /var/lib/fc/test-repos")
# ---- Create project + jobset pointing to the local repo via API ---- # Create project + jobset pointing to the local repo via API
with subtest("Create E2E project and jobset via API"): with subtest("Create E2E project and jobset via API"):
result = machine.succeed( result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects " "curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
@ -100,10 +99,10 @@ pkgs.testers.nixosTest {
e2e_jobset_id = result.strip() e2e_jobset_id = result.strip()
assert len(e2e_jobset_id) == 36, f"Expected UUID for jobset, got '{e2e_jobset_id}'" assert len(e2e_jobset_id) == 36, f"Expected UUID for jobset, got '{e2e_jobset_id}'"
# ---- Wait for evaluator to pick it up and create an evaluation ---- # Wait for evaluator to pick it up and create an evaluation
with subtest("Evaluator discovers and evaluates the flake"): with subtest("Evaluator discovers and evaluates the flake"):
# The evaluator is already running (started in Phase 1) # The evaluator is already running, poll for evaluation to appear
# Poll for evaluation to appear with status "completed" # with status "completed"
machine.wait_until_succeeds( machine.wait_until_succeeds(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' " f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' "
"| jq -e '.items[] | select(.status==\"completed\")'", "| jq -e '.items[] | select(.status==\"completed\")'",
@ -136,7 +135,7 @@ pkgs.testers.nixosTest {
f"curl -sf 'http://127.0.0.1:3000/api/v1/builds?evaluation_id={e2e_eval_id}' | jq -r '.items[0].id'" f"curl -sf 'http://127.0.0.1:3000/api/v1/builds?evaluation_id={e2e_eval_id}' | jq -r '.items[0].id'"
).strip() ).strip()
# ---- Test evaluation caching ---- # Test evaluation caching
with subtest("Same commit does not trigger a new evaluation"): with subtest("Same commit does not trigger a new evaluation"):
# Get current evaluation count # Get current evaluation count
before_count = machine.succeed( before_count = machine.succeed(
@ -149,7 +148,7 @@ pkgs.testers.nixosTest {
).strip() ).strip()
assert before_count == after_count, f"Evaluation count changed from {before_count} to {after_count} (should be cached)" assert before_count == after_count, f"Evaluation count changed from {before_count} to {after_count} (should be cached)"
# ---- Test new commit triggers new evaluation ---- # Test new commit triggers new evaluation
with subtest("New commit triggers new evaluation"): with subtest("New commit triggers new evaluation"):
before_count_int = int(machine.succeed( before_count_int = int(machine.succeed(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' | jq '.items | length'" f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' | jq '.items | length'"
@ -181,10 +180,6 @@ pkgs.testers.nixosTest {
timeout=60 timeout=60
) )
# ========================================================================
# Phase E2E-2: End-to-End Queue Runner Integration Test
# ========================================================================
with subtest("Queue runner builds pending derivation"): with subtest("Queue runner builds pending derivation"):
# Poll the E2E build until completed (queue-runner is already running) # Poll the E2E build until completed (queue-runner is already running)
machine.wait_until_succeeds( machine.wait_until_succeeds(
@ -230,10 +225,6 @@ pkgs.testers.nixosTest {
).strip() ).strip()
assert code == "200", f"Expected 200 for build log, got {code}" assert code == "200", f"Expected 200 for build log, got {code}"
# ========================================================================
# Phase E2E-3: Jobset Input Management API
# ========================================================================
with subtest("Create jobset input via API"): with subtest("Create jobset input via API"):
result = machine.succeed( result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs " f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{e2e_project_id}/jobsets/{e2e_jobset_id}/inputs "
@ -285,10 +276,6 @@ pkgs.testers.nixosTest {
).strip() ).strip()
assert code == "403", f"Expected 403 for read-only input delete, got {code}" assert code == "403", f"Expected 403 for read-only input delete, got {code}"
# ========================================================================
# Phase E2E-4: Notification Dispatch
# ========================================================================
# Notifications are dispatched after builds complete (already tested above). # Notifications are dispatched after builds complete (already tested above).
# Verify run_command notifications work: # Verify run_command notifications work:
with subtest("Notification run_command is invoked on build completion"): with subtest("Notification run_command is invoked on build completion"):
@ -300,10 +287,6 @@ pkgs.testers.nixosTest {
).strip() ).strip()
assert result == "completed", f"Expected completed after notification, got {result}" assert result == "completed", f"Expected completed after notification, got {result}"
# ========================================================================
# Phase E2E-5: Channel Auto-Promotion
# ========================================================================
with subtest("Channel auto-promotion after all builds complete"): with subtest("Channel auto-promotion after all builds complete"):
# Create a channel tracking the E2E jobset # Create a channel tracking the E2E jobset
result = machine.succeed( result = machine.succeed(
@ -324,10 +307,6 @@ pkgs.testers.nixosTest {
timeout=30 timeout=30
) )
# ========================================================================
# Phase E2E-6: Binary Cache NARinfo Test
# ========================================================================
with subtest("Binary cache serves NARinfo for built output"): with subtest("Binary cache serves NARinfo for built output"):
# Get the build output path # Get the build output path
output_path = machine.succeed( output_path = machine.succeed(
@ -353,10 +332,6 @@ pkgs.testers.nixosTest {
assert "StorePath:" in narinfo, f"NARinfo missing StorePath: {narinfo}" assert "StorePath:" in narinfo, f"NARinfo missing StorePath: {narinfo}"
assert "NarHash:" in narinfo, f"NARinfo missing NarHash: {narinfo}" assert "NarHash:" in narinfo, f"NARinfo missing NarHash: {narinfo}"
# ========================================================================
# Phase E2E-7: Build Retry on Failure
# ========================================================================
with subtest("Build with invalid drv_path fails and retries"): with subtest("Build with invalid drv_path fails and retries"):
# Insert a build with an invalid drv_path via SQL # Insert a build with an invalid drv_path via SQL
machine.succeed( machine.succeed(
@ -378,10 +353,6 @@ pkgs.testers.nixosTest {
).strip() ).strip()
assert result == "failed", f"Expected failed for bad build, got '{result}'" assert result == "failed", f"Expected failed for bad build, got '{result}'"
# ========================================================================
# Phase E2E-8: Notification Dispatch (run_command)
# ========================================================================
with subtest("Notification run_command invoked on build completion"): with subtest("Notification run_command invoked on build completion"):
# Write a notification script # Write a notification script
machine.succeed("mkdir -p /var/lib/fc") machine.succeed("mkdir -p /var/lib/fc")
@ -458,10 +429,6 @@ pkgs.testers.nixosTest {
f"Expected BUILD_STATUS in notification output, got: {output}" f"Expected BUILD_STATUS in notification output, got: {output}"
assert notify_build_id in output, f"Expected build ID {notify_build_id} in output, got: {output}" assert notify_build_id in output, f"Expected build ID {notify_build_id} in output, got: {output}"
# ========================================================================
# Phase E2E-9: Nix Signing
# ========================================================================
with subtest("Generate signing key and configure signing"): with subtest("Generate signing key and configure signing"):
# Generate a Nix signing key # Generate a Nix signing key
machine.succeed("mkdir -p /var/lib/fc/keys") machine.succeed("mkdir -p /var/lib/fc/keys")
@ -537,10 +504,6 @@ pkgs.testers.nixosTest {
# The verify command should succeed (exit 0) if signatures are valid # The verify command should succeed (exit 0) if signatures are valid
machine.succeed(f"nix store verify --sigs-needed 1 {output_path}") machine.succeed(f"nix store verify --sigs-needed 1 {output_path}")
# ========================================================================
# Phase E2E-10: GC Roots
# ========================================================================
with subtest("GC roots are created for build products"): with subtest("GC roots are created for build products"):
# Enable GC in config # Enable GC in config
machine.succeed(""" machine.succeed("""
@ -607,18 +570,14 @@ pkgs.testers.nixosTest {
found_root = True found_root = True
break break
# We might have GC roots - this is expected behavior # We might have GC roots, this is expected behavior
# The key is that the build output exists and is protected from GC # The key thing is that the build output exists and is protected from GC
machine.succeed(f"test -e {gc_build_output}") machine.succeed(f"test -e {gc_build_output}")
else: else:
# If no GC roots yet, at least verify the build output exists # If no GC roots yet, at least verify the build output exists
# GC roots might be created asynchronously # GC roots might be created asynchronously
machine.succeed(f"test -e {gc_build_output}") machine.succeed(f"test -e {gc_build_output}")
# ========================================================================
# Phase E2E-11: Declarative In-Repo Config
# ========================================================================
with subtest("Declarative .fc.toml in repo auto-creates jobset"): with subtest("Declarative .fc.toml in repo auto-creates jobset"):
# Add .fc.toml to the test repo with a new jobset definition # Add .fc.toml to the test repo with a new jobset definition
machine.succeed(""" machine.succeed("""
@ -641,10 +600,6 @@ pkgs.testers.nixosTest {
timeout=60 timeout=60
) )
# ========================================================================
# Phase E2E-12: Webhook Endpoint
# ========================================================================
with subtest("Webhook endpoint accepts valid GitHub push"): with subtest("Webhook endpoint accepts valid GitHub push"):
# Create a webhook config via SQL (no REST endpoint for creation) # Create a webhook config via SQL (no REST endpoint for creation)
machine.succeed( machine.succeed(
@ -697,7 +652,7 @@ pkgs.testers.nixosTest {
).strip() ).strip()
assert code == "401", f"Expected 401 for invalid webhook signature, got {code}" assert code == "401", f"Expected 401 for invalid webhook signature, got {code}"
# ---- Cleanup: Delete project ---- # Cleanup: Delete project
with subtest("Delete E2E project"): with subtest("Delete E2E project"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "

View file

@ -1,14 +1,16 @@
# Feature tests: logging, CSS, setup wizard, probe, metrics improvements {pkgs, self}:
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "fc-features"; name = "fc-features";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; nodes.machine = {
imports = [
self.nixosModules.fc-ci
../vm-common.nix
];
_module.args.self = self;
};
# Feature tests: logging, CSS, setup wizard, probe, metrics improvements
testScript = '' testScript = ''
import hashlib import hashlib
@ -23,7 +25,7 @@ pkgs.testers.nixosTest {
# Wait for the server to start listening # Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) 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 # Token: fc_testkey123 -> SHA-256 hash inserted into api_keys table
api_token = "fc_testkey123" api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest() api_hash = hashlib.sha256(api_token.encode()).hexdigest()
@ -40,11 +42,7 @@ pkgs.testers.nixosTest {
) )
ro_header = f"-H 'Authorization: Bearer {ro_token}'" ro_header = f"-H 'Authorization: Bearer {ro_token}'"
# ======================================================================== # Structured logging ----
# Phase 5: New Feature Tests (Structured Logging, Flake Probe, Setup Wizard, Dashboard)
# ========================================================================
# ---- 5A: Structured logging ----
with subtest("Server produces structured log output"): with subtest("Server produces structured log output"):
# The server should log via tracing with the configured format # The server should log via tracing with the configured format
result = machine.succeed("journalctl -u fc-server --no-pager -n 50 2>&1") result = machine.succeed("journalctl -u fc-server --no-pager -n 50 2>&1")
@ -52,7 +50,7 @@ pkgs.testers.nixosTest {
assert "INFO" in result or "info" in result, \ assert "INFO" in result or "info" in result, \
"Expected structured log lines with INFO level in journalctl output" "Expected structured log lines with INFO level in journalctl output"
# ---- 5B: Static CSS serving ---- # Static CSS serving ----
with subtest("Static CSS endpoint returns 200 with correct content type"): with subtest("Static CSS endpoint returns 200 with correct content type"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/static/style.css" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/static/style.css"
@ -63,7 +61,7 @@ pkgs.testers.nixosTest {
) )
assert "text/css" in ct.lower(), f"Expected text/css, got: {ct}" assert "text/css" in ct.lower(), f"Expected text/css, got: {ct}"
# ---- 5C: Setup wizard page ---- # Setup wizard page
with subtest("Setup wizard page returns 200"): with subtest("Setup wizard page returns 200"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/projects/new" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/projects/new"
@ -92,7 +90,7 @@ pkgs.testers.nixosTest {
) )
assert '/projects/new' in body, "Projects page should link to /projects/new wizard" assert '/projects/new' in body, "Projects page should link to /projects/new wizard"
# ---- 5D: Flake probe endpoint ---- # Flake probe endpoint
with subtest("Probe endpoint exists and requires POST"): with subtest("Probe endpoint exists and requires POST"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "
@ -116,7 +114,7 @@ pkgs.testers.nixosTest {
assert code.strip() in ("200", "408", "422", "500"), \ assert code.strip() in ("200", "408", "422", "500"), \
f"Expected 200/408/422/500 for probe of unreachable repo, got {code.strip()}" f"Expected 200/408/422/500 for probe of unreachable repo, got {code.strip()}"
# ---- 5E: Setup endpoint ---- # Setup endpoint
with subtest("Setup endpoint exists and requires POST"): with subtest("Setup endpoint exists and requires POST"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "
@ -157,7 +155,7 @@ pkgs.testers.nixosTest {
f"{auth_header}" f"{auth_header}"
) )
# ---- 5F: Dashboard improvements ---- # Dashboard improvements
with subtest("Home page has dashboard-grid two-column layout"): with subtest("Home page has dashboard-grid two-column layout"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/") body = machine.succeed("curl -sf http://127.0.0.1:3000/")
assert "dashboard-grid" in body, "Home page should have dashboard-grid class" assert "dashboard-grid" in body, "Home page should have dashboard-grid class"
@ -179,7 +177,7 @@ pkgs.testers.nixosTest {
) )
assert "escapeHtml" in body, "Admin page JS should use escapeHtml" assert "escapeHtml" in body, "Admin page JS should use escapeHtml"
# ---- 4R: Metrics reflect actual data ---- # Metrics reflect actual data
with subtest("Metrics fc_projects_total reflects created projects"): with subtest("Metrics fc_projects_total reflects created projects"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics") result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics")
for line in result.split("\n"): for line in result.split("\n"):

View file

@ -1,12 +1,14 @@
{ {pkgs, self}:
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "fc-service-startup"; name = "fc-service-startup";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; nodes.machine = {
imports = [
self.nixosModules.fc-ci
../vm-common.nix
];
_module.args.self = self;
};
testScript = '' testScript = ''
machine.start() machine.start()

View file

@ -1,14 +1,16 @@
# Webhook and PR integration tests {pkgs, self}:
{
pkgs,
fc-packages,
nixosModule,
}:
pkgs.testers.nixosTest { pkgs.testers.nixosTest {
name = "fc-webhooks"; name = "fc-webhooks";
nodes.machine = import ./common.nix {inherit pkgs fc-packages nixosModule;}; nodes.machine = {
imports = [
self.nixosModules.fc-ci
../vm-common.nix
];
_module.args.self = self;
};
# Webhook and PR integration tests
testScript = '' testScript = ''
import hashlib import hashlib
import json import json
@ -24,7 +26,7 @@ pkgs.testers.nixosTest {
# Wait for the server to start listening # Wait for the server to start listening
machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/health", timeout=30) 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
api_token = "fc_testkey123" api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest() api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed( machine.succeed(
@ -32,7 +34,7 @@ pkgs.testers.nixosTest {
) )
auth_header = f"-H 'Authorization: Bearer {api_token}'" auth_header = f"-H 'Authorization: Bearer {api_token}'"
# ---- Create a test project for webhook tests ---- # Create a test project for webhook tests
with subtest("Create test project for webhooks"): with subtest("Create test project for webhooks"):
result = machine.succeed( result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects " "curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
@ -44,7 +46,7 @@ pkgs.testers.nixosTest {
project_id = result.strip() project_id = result.strip()
assert len(project_id) == 36, f"Expected UUID, got '{project_id}'" assert len(project_id) == 36, f"Expected UUID, got '{project_id}'"
# ---- Create a jobset for the project ---- # Create a jobset for the project
with subtest("Create jobset for webhook project"): with subtest("Create jobset for webhook project"):
result = machine.succeed( result = machine.succeed(
f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets " f"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects/{project_id}/jobsets "
@ -56,10 +58,7 @@ pkgs.testers.nixosTest {
jobset_id = result.strip() jobset_id = result.strip()
assert len(jobset_id) == 36, f"Expected UUID, got '{jobset_id}'" assert len(jobset_id) == 36, f"Expected UUID, got '{jobset_id}'"
# ========================================================================
# GitHub Webhook Tests # GitHub Webhook Tests
# ========================================================================
with subtest("GitHub webhook returns 404 when not configured"): with subtest("GitHub webhook returns 404 when not configured"):
code = machine.succeed( code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' " "curl -s -o /dev/null -w '%{http_code}' "
@ -196,10 +195,7 @@ pkgs.testers.nixosTest {
) )
assert "draft" in result.lower(), f"Expected draft PR to be skipped, got: {result}" assert "draft" in result.lower(), f"Expected draft PR to be skipped, got: {result}"
# ======================================================================== ## GitLab Webhook Tests
# GitLab Webhook Tests
# ========================================================================
# Create a GitLab project # Create a GitLab project
with subtest("Create GitLab test project"): with subtest("Create GitLab test project"):
result = machine.succeed( result = machine.succeed(
@ -314,10 +310,7 @@ pkgs.testers.nixosTest {
assert "draft" in result.lower() or "wip" in result.lower(), \ assert "draft" in result.lower() or "wip" in result.lower(), \
f"Expected draft MR to be skipped, got: {result}" f"Expected draft MR to be skipped, got: {result}"
# ========================================================================
# Gitea/Forgejo Webhook Tests # Gitea/Forgejo Webhook Tests
# ========================================================================
with subtest("Create Gitea test project"): with subtest("Create Gitea test project"):
result = machine.succeed( result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects " "curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
@ -365,10 +358,7 @@ pkgs.testers.nixosTest {
assert int(count_after) > int(count_before), \ assert int(count_after) > int(count_before), \
"Expected new evaluation from Gitea push" "Expected new evaluation from Gitea push"
# ========================================================================
# OAuth Routes Existence Tests # OAuth Routes Existence Tests
# ========================================================================
with subtest("GitHub OAuth login route exists"): with subtest("GitHub OAuth login route exists"):
# Should redirect or return 404 if not configured # Should redirect or return 404 if not configured
code = machine.succeed( code = machine.succeed(
@ -384,10 +374,7 @@ pkgs.testers.nixosTest {
# Should fail gracefully (no OAuth configured) # Should fail gracefully (no OAuth configured)
assert code.strip() in ("400", "404", "500"), f"Expected error code, got {code.strip()}" assert code.strip() in ("400", "404", "500"), f"Expected error code, got {code.strip()}"
# ========================================================================
# Cleanup # Cleanup
# ========================================================================
with subtest("Cleanup test projects"): with subtest("Cleanup test projects"):
machine.succeed( machine.succeed(
f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{project_id} {auth_header}" f"curl -sf -X DELETE http://127.0.0.1:3000/api/v1/projects/{project_id} {auth_header}"

83
nix/vm-common.nix Normal file
View file

@ -0,0 +1,83 @@
# Common VM configuration for FC integration tests
{
self,
pkgs,
...
}: let
fc-packages = self.packages.${pkgs.stdenv.hostPlatform.system};
in {
# Common machine configuration for all FC integration tests
config = {
programs.git.enable = true;
security.sudo.enable = true;
# Ensure nix and zstd are available for cache endpoints
environment.systemPackages = with pkgs; [nix nix-eval-jobs zstd curl jq openssl];
services.fc = {
enable = true;
package = fc-packages.fc-server;
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;
settings = {
database.url = "postgresql:///fc?host=/run/postgresql";
server = {
host = "127.0.0.1";
port = 3000;
cors_permissive = false;
};
gc.enabled = false;
logs.log_dir = "/var/lib/fc/logs";
cache.enabled = true;
signing.enabled = false;
tracing = {
level = "info";
format = "compact";
show_targets = true;
show_timestamps = true;
};
evaluator.poll_interval = 5;
evaluator.work_dir = "/var/lib/fc/evaluator";
queue_runner.poll_interval = 3;
queue_runner.work_dir = "/var/lib/fc/queue-runner";
# Declarative bootstrap: project + API key created on startup
declarative = {
projects = [
{
name = "declarative-project";
repository_url = "https://github.com/test/declarative";
description = "Bootstrap test project";
jobsets = [
{
name = "packages";
nix_expression = "packages";
enabled = true;
flake_mode = true;
check_interval = 300;
}
];
}
];
api_keys = [
{
name = "bootstrap-admin";
key = "fc_bootstrap_key";
role = "admin";
}
];
};
};
};
};
}