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;
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);
};
}

View file

@ -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;

View file

@ -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";
};
};
};
};
};

View file

@ -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 "<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"):
body = machine.succeed(
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
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"):
code = machine.succeed(
"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()}"
# ---- 4D: Jobset CRUD ----
# Jobset CRUD
with subtest("Create jobset for test-project"):
result = machine.succeed(
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"
# ---- 4E: Evaluation trigger and lifecycle ----
# Evaluation trigger and lifecycle
with subtest("Trigger evaluation via API"):
result = machine.succeed(
"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()}"
# ---- 4E2: Build lifecycle (restart, bump) ----
# Build lifecycle (restart, bump)
# Create a build via SQL since builds are normally created by the evaluator
with subtest("Create test build via SQL"):
machine.succeed(
@ -284,7 +283,7 @@ pkgs.testers.nixosTest {
)
assert "cancelled" in result.strip().lower(), f"Expected cancelled, got: {result.strip()}"
# ---- 4E3: Evaluation comparison ----
# Evaluation comparison ----
with subtest("Trigger second evaluation for comparison"):
result = machine.succeed(
"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 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"):
result = machine.succeed(
"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()}"
# ---- 4G: Remote builder CRUD lifecycle ----
# Remote builder CRUD lifecycle
with subtest("List remote builders"):
result = machine.succeed(
"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()}"
# ---- 4H: Admin system status endpoint ----
# Admin system status endpoint
with subtest("System status endpoint requires admin"):
code = machine.succeed(
"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()}"
# ---- 4I: API key listing ----
# API key listing
with subtest("List API keys requires admin"):
code = machine.succeed(
"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()}"
# ---- 4J: Badge endpoints ----
# Badge endpoints
with subtest("Badge endpoint returns SVG for unknown project"):
code = machine.succeed(
"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()}"
# ---- 4K: Pagination tests ----
# 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)
@ -555,7 +554,7 @@ pkgs.testers.nixosTest {
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']}"
# ---- 4L: Build sub-resources ----
# Build sub-resources
with subtest("Build steps endpoint returns empty array for nonexistent build"):
result = machine.succeed(
"curl -sf "
@ -579,7 +578,7 @@ pkgs.testers.nixosTest {
)
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"):
result = machine.succeed(
"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()}"
# ---- 4N: Content-Type verification for API endpoints ----
# Content-Type verification for API endpoints
with subtest("API endpoints return application/json"):
ct = machine.succeed(
"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}"
# ---- 4O: Session/Cookie auth for dashboard ----
# Session/Cookie auth for dashboard
with subtest("Login with valid API key sets session cookie"):
result = machine.succeed(
"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(), \
"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_hash = hashlib.sha256(cp_token.encode()).hexdigest()
machine.succeed(
@ -709,7 +708,7 @@ pkgs.testers.nixosTest {
)
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"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "

View file

@ -1,14 +1,16 @@
# Authentication and RBAC tests
{
pkgs,
fc-packages,
nixosModule,
}:
{pkgs, self}:
pkgs.testers.nixosTest {
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 = ''
import hashlib
import json

View file

@ -1,12 +1,14 @@
{
pkgs,
fc-packages,
nixosModule,
}:
{pkgs, self}:
pkgs.testers.nixosTest {
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 = ''
import hashlib
@ -22,7 +24,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()
@ -31,7 +33,7 @@ pkgs.testers.nixosTest {
)
auth_header = f"-H 'Authorization: Bearer {api_token}'"
# ---- Health endpoint ----
# Health endpoint
with subtest("Health endpoint returns OK"):
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()}'"
@ -40,13 +42,13 @@ pkgs.testers.nixosTest {
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()}'"
# ---- Cache endpoint: nix-cache-info ----
# Cache endpoint: nix-cache-info
with subtest("Cache info endpoint returns correct data"):
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 "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"):
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"):
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"):
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"):
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"):
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()}"
@ -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'")
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"):
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()}"
# ---- 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"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/evaluations")
assert "Page 1 of 0" not in body, \
@ -107,7 +109,7 @@ pkgs.testers.nixosTest {
assert "table-wrap" in body, \
"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"):
result = machine.succeed(
"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'")
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"):
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"):
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"):
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_projects_total" in result, "Missing fc_projects_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"):
result = machine.succeed(
"curl -s -D - "
@ -153,7 +155,7 @@ pkgs.testers.nixosTest {
assert "access-control-allow-origin: http://evil.example.com" not in result.lower(), \
f"CORS should not allow arbitrary origins: {result}"
# ---- Systemd hardening ----
# Systemd hardening
with subtest("fc-server runs as fc user"):
result = machine.succeed("systemctl show fc-server --property=User --value")
assert result.strip() == "fc", f"Expected fc user, got '{result.strip()}'"
@ -168,7 +170,7 @@ pkgs.testers.nixosTest {
with subtest("Log directory exists"):
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"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/api/v1/builds/stats | jq '.total_builds'")
# 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,
fc-packages,
nixosModule,
}:
{pkgs, self}:
pkgs.testers.nixosTest {
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 = ''
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()
@ -41,11 +44,7 @@ pkgs.testers.nixosTest {
)
ro_header = f"-H 'Authorization: Bearer {ro_token}'"
# ========================================================================
# Phase E2E-1: End-to-End Evaluator Integration Test
# ========================================================================
# ---- Create a test flake inside the VM ----
# Create a test flake inside the VM
with subtest("Create bare git repo with test flake"):
machine.succeed("mkdir -p /var/lib/fc/test-repos")
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
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"):
result = machine.succeed(
"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()
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"):
# The evaluator is already running (started in Phase 1)
# Poll for evaluation to appear with status "completed"
# The evaluator is already running, poll for evaluation to appear
# with status "completed"
machine.wait_until_succeeds(
f"curl -sf 'http://127.0.0.1:3000/api/v1/evaluations?jobset_id={e2e_jobset_id}' "
"| 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'"
).strip()
# ---- Test evaluation caching ----
# Test evaluation caching
with subtest("Same commit does not trigger a new evaluation"):
# Get current evaluation count
before_count = machine.succeed(
@ -149,7 +148,7 @@ pkgs.testers.nixosTest {
).strip()
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"):
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'"
@ -181,10 +180,6 @@ pkgs.testers.nixosTest {
timeout=60
)
# ========================================================================
# Phase E2E-2: End-to-End Queue Runner Integration Test
# ========================================================================
with subtest("Queue runner builds pending derivation"):
# Poll the E2E build until completed (queue-runner is already running)
machine.wait_until_succeeds(
@ -230,10 +225,6 @@ pkgs.testers.nixosTest {
).strip()
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"):
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 "
@ -285,10 +276,6 @@ pkgs.testers.nixosTest {
).strip()
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).
# Verify run_command notifications work:
with subtest("Notification run_command is invoked on build completion"):
@ -300,10 +287,6 @@ pkgs.testers.nixosTest {
).strip()
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"):
# Create a channel tracking the E2E jobset
result = machine.succeed(
@ -324,10 +307,6 @@ pkgs.testers.nixosTest {
timeout=30
)
# ========================================================================
# Phase E2E-6: Binary Cache NARinfo Test
# ========================================================================
with subtest("Binary cache serves NARinfo for built output"):
# Get the build output path
output_path = machine.succeed(
@ -353,10 +332,6 @@ pkgs.testers.nixosTest {
assert "StorePath:" in narinfo, f"NARinfo missing StorePath: {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"):
# Insert a build with an invalid drv_path via SQL
machine.succeed(
@ -378,10 +353,6 @@ pkgs.testers.nixosTest {
).strip()
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"):
# Write a notification script
machine.succeed("mkdir -p /var/lib/fc")
@ -458,10 +429,6 @@ pkgs.testers.nixosTest {
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}"
# ========================================================================
# Phase E2E-9: Nix Signing
# ========================================================================
with subtest("Generate signing key and configure signing"):
# Generate a Nix signing key
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
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"):
# Enable GC in config
machine.succeed("""
@ -607,18 +570,14 @@ pkgs.testers.nixosTest {
found_root = True
break
# We might have GC roots - this is expected behavior
# The key is that the build output exists and is protected from GC
# We might have GC roots, this is expected behavior
# The key thing is that the build output exists and is protected from GC
machine.succeed(f"test -e {gc_build_output}")
else:
# If no GC roots yet, at least verify the build output exists
# GC roots might be created asynchronously
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"):
# Add .fc.toml to the test repo with a new jobset definition
machine.succeed("""
@ -641,10 +600,6 @@ pkgs.testers.nixosTest {
timeout=60
)
# ========================================================================
# Phase E2E-12: Webhook Endpoint
# ========================================================================
with subtest("Webhook endpoint accepts valid GitHub push"):
# Create a webhook config via SQL (no REST endpoint for creation)
machine.succeed(
@ -697,7 +652,7 @@ pkgs.testers.nixosTest {
).strip()
assert code == "401", f"Expected 401 for invalid webhook signature, got {code}"
# ---- Cleanup: Delete project ----
# Cleanup: Delete project
with subtest("Delete E2E project"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "

View file

@ -1,14 +1,16 @@
# Feature tests: logging, CSS, setup wizard, probe, metrics improvements
{
pkgs,
fc-packages,
nixosModule,
}:
{pkgs, self}:
pkgs.testers.nixosTest {
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 = ''
import hashlib
@ -23,7 +25,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()
@ -40,11 +42,7 @@ pkgs.testers.nixosTest {
)
ro_header = f"-H 'Authorization: Bearer {ro_token}'"
# ========================================================================
# Phase 5: New Feature Tests (Structured Logging, Flake Probe, Setup Wizard, Dashboard)
# ========================================================================
# ---- 5A: Structured logging ----
# Structured logging ----
with subtest("Server produces structured log output"):
# The server should log via tracing with the configured format
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, \
"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"):
code = machine.succeed(
"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}"
# ---- 5C: Setup wizard page ----
# Setup wizard page
with subtest("Setup wizard page returns 200"):
code = machine.succeed(
"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"
# ---- 5D: Flake probe endpoint ----
# Flake probe endpoint
with subtest("Probe endpoint exists and requires POST"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
@ -116,7 +114,7 @@ pkgs.testers.nixosTest {
assert code.strip() in ("200", "408", "422", "500"), \
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"):
code = machine.succeed(
"curl -s -o /dev/null -w '%{http_code}' "
@ -157,7 +155,7 @@ pkgs.testers.nixosTest {
f"{auth_header}"
)
# ---- 5F: Dashboard improvements ----
# Dashboard improvements
with subtest("Home page has dashboard-grid two-column layout"):
body = machine.succeed("curl -sf http://127.0.0.1:3000/")
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"
# ---- 4R: Metrics reflect actual data ----
# Metrics reflect actual data
with subtest("Metrics fc_projects_total reflects created projects"):
result = machine.succeed("curl -sf http://127.0.0.1:3000/metrics")
for line in result.split("\n"):

View file

@ -1,12 +1,14 @@
{
pkgs,
fc-packages,
nixosModule,
}:
{pkgs, self}:
pkgs.testers.nixosTest {
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 = ''
machine.start()

View file

@ -1,14 +1,16 @@
# Webhook and PR integration tests
{
pkgs,
fc-packages,
nixosModule,
}:
{pkgs, self}:
pkgs.testers.nixosTest {
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 = ''
import hashlib
import json
@ -24,7 +26,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
api_token = "fc_testkey123"
api_hash = hashlib.sha256(api_token.encode()).hexdigest()
machine.succeed(
@ -32,7 +34,7 @@ pkgs.testers.nixosTest {
)
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"):
result = machine.succeed(
"curl -sf -X POST http://127.0.0.1:3000/api/v1/projects "
@ -44,7 +46,7 @@ pkgs.testers.nixosTest {
project_id = result.strip()
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"):
result = machine.succeed(
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()
assert len(jobset_id) == 36, f"Expected UUID, got '{jobset_id}'"
# ========================================================================
# GitHub Webhook Tests
# ========================================================================
with subtest("GitHub webhook returns 404 when not configured"):
code = machine.succeed(
"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}"
# ========================================================================
# GitLab Webhook Tests
# ========================================================================
## GitLab Webhook Tests
# Create a GitLab project
with subtest("Create GitLab test project"):
result = machine.succeed(
@ -314,10 +310,7 @@ pkgs.testers.nixosTest {
assert "draft" in result.lower() or "wip" in result.lower(), \
f"Expected draft MR to be skipped, got: {result}"
# ========================================================================
# Gitea/Forgejo Webhook Tests
# ========================================================================
with subtest("Create Gitea test project"):
result = machine.succeed(
"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), \
"Expected new evaluation from Gitea push"
# ========================================================================
# OAuth Routes Existence Tests
# ========================================================================
with subtest("GitHub OAuth login route exists"):
# Should redirect or return 404 if not configured
code = machine.succeed(
@ -384,10 +374,7 @@ pkgs.testers.nixosTest {
# Should fail gracefully (no OAuth configured)
assert code.strip() in ("400", "404", "500"), f"Expected error code, got {code.strip()}"
# ========================================================================
# Cleanup
# ========================================================================
with subtest("Cleanup test projects"):
machine.succeed(
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";
}
];
};
};
};
};
}