diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index e8d53ac85c4bb..7144f0c58e2c1 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -146,6 +146,8 @@ - [perses](https://perses.dev/), the open dashboard tool for Prometheus and other data sources. Available as [services.perses](#opt-services.perses.enable). +- [Forgejo Runner](https://code.forgejo.org/forgejo/runner), a dedicated Forgejo Actions runner module for multi-instance deployments is now available as [services.forgejo.runner.instances](#opt-services.forgejo.runner.instances). + - [Drasl](https://github.com/unmojang/drasl), an alternative authentication server for Minecraft. Available as [services.drasl](#opt-services.drasl.enable). - [tabbyAPI](https://github.com/theroyallab/tabbyAPI), the official OpenAI compatible API server for Exllama. Available as [services.tabbyapi](#opt-services.tabbyapi.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 718fb29d4d8d9..37dea0821a9d5 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -509,6 +509,7 @@ ./services/continuous-integration/buildbot/master.nix ./services/continuous-integration/buildbot/worker.nix ./services/continuous-integration/buildkite-agents.nix + ./services/continuous-integration/forgejo-runner.nix ./services/continuous-integration/gitea-actions-runner.nix ./services/continuous-integration/github-runners.nix ./services/continuous-integration/gitlab-runner/runner.nix diff --git a/nixos/modules/services/continuous-integration/forgejo-runner.nix b/nixos/modules/services/continuous-integration/forgejo-runner.nix new file mode 100644 index 0000000000000..47e80f4191649 --- /dev/null +++ b/nixos/modules/services/continuous-integration/forgejo-runner.nix @@ -0,0 +1,338 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: + +let + inherit (lib) + any + concatStringsSep + escapeShellArg + hasInfix + hasSuffix + literalExpression + mapAttrs' + mkEnableOption + mkIf + mkOption + mkPackageOption + nameValuePair + optionalAttrs + optionals + teams + types + ; + + inherit (utils) + escapeSystemdPath + ; + + cfg = config.services.forgejo.runner; + + settingsFormat = pkgs.formats.yaml { }; + + instanceLabels = + instance: + instance.labels + ++ lib.concatMap (connection: connection.labels or [ ]) ( + lib.attrValues (instance.settings.server.connections or { }) + ); + + hasDockerScheme = labels: any (label: hasInfix ":docker:" label) labels; + hasHostScheme = labels: any (label: hasSuffix ":host" label) labels; + + wantsContainerRuntime = any (instance: hasDockerScheme (instanceLabels instance)) ( + lib.attrValues cfg.instances + ); + + hasDocker = config.virtualisation.docker.enable; + hasPodman = config.virtualisation.podman.enable; +in +{ + meta.maintainers = teams.forgejo.members; + + options.services.forgejo.runner = with types; { + package = mkPackageOption pkgs "forgejo-runner" { }; + + instances = mkOption { + default = { }; + description = '' + Forgejo Actions Runner instances. + ''; + type = attrsOf ( + submodule ( + { name, ... }: + { + options = { + enable = mkEnableOption "Forgejo Actions Runner instance"; + + name = mkOption { + type = str; + example = literalExpression "config.networking.hostName"; + description = '' + The name identifying the runner instance towards the Forgejo instance. + ''; + default = name; + }; + + url = mkOption { + type = nullOr str; + default = null; + example = "https://forge.example.com"; + description = '' + Base URL of your Forgejo instance. + + Can also be specified in `settings.servier.connections` + ''; + }; + + registrationTokenFile = mkOption { + type = nullOr (either str path); + default = null; + description = '' + Path to a file containing only the token that will be used to register + on start with the the configured Forgejo instance. + + **Deprecated** Replaced by `settings.server.connections` + + + ''; + }; + + credentials = lib.mkOption { + type = attrsOf lib.types.path; + default = { }; + example = { + WORKER1_TOKEN = "/run/keys/worker1"; + }; + description = '' + Environment variables with absolute paths to credentials files to load + on runner startup. + ''; + }; + + labels = mkOption { + type = listOf str; + default = [ ]; + example = literalExpression '' + [ + # provide a debian base with nodejs for actions + "debian-latest:docker://node:18-bullseye" + # fake the ubuntu name, because node provides no ubuntu builds + "ubuntu-latest:docker://node:18-bullseye" + # provide native execution on the host + #"native:host" + ] + ''; + description = '' + Labels used to map jobs to their runtime environment. Changing these + labels currently requires a new registration token. + + Many common actions require bash, git and nodejs, as well as a filesystem + that follows the filesystem hierarchy standard. + ''; + }; + + settings = mkOption { + description = '' + Configuration for `forgejo-runner daemon`. + See for an example configuration. + ''; + + type = types.submodule { + freeformType = settingsFormat.type; + }; + + default = { }; + }; + + hostPackages = mkOption { + type = listOf package; + default = with pkgs; [ + bash + coreutils + curl + gawk + gitMinimal + gnused + nodejs + wget + ]; + defaultText = literalExpression '' + with pkgs; [ + bash + coreutils + curl + gawk + gitMinimal + gnused + nodejs + wget + ] + ''; + description = '' + List of packages that are available to actions, when the runner is configured + with a host execution label. + ''; + }; + }; + } + ) + ); + }; + }; + + config = mkIf (cfg.instances != { }) { + assertions = [ + { + assertion = wantsContainerRuntime -> hasDocker || hasPodman; + message = "Label configuration on forgejo.runner instance requires either docker or podman."; + } + ] + ++ (lib.foldlAttrs ( + acc: _: instance: + acc + ++ [ + { + assertion = instance.registrationTokenFile != null -> instance.url != null; + message = "forgejo.runner.instances.${instance.name}.registrationTokenFile requires `url` to be set."; + } + ] + ) [ ] cfg.instances); + + systemd.services = + let + mkRunnerInstance = + _: instance: + let + allLabels = instanceLabels instance; + escapedName = escapeSystemdPath instance.name; + wantsContainer = hasDockerScheme allLabels; + wantsHost = hasHostScheme allLabels; + wantsDocker = wantsContainer && hasDocker; + wantsPodman = wantsContainer && hasPodman; + configFile = settingsFormat.generate "forgejo-runner-${escapedName}.yaml" instance.settings; + in + nameValuePair "forgejo-runner@${escapedName}" { + overrideStrategy = "asDropin"; + inherit (instance) enable; + wants = [ + "network-online.target" + ] + ++ optionals wantsDocker [ "docker.service" ] + ++ optionals wantsPodman [ "podman.service" ]; + after = [ + "network-online.target" + ] + ++ optionals wantsDocker [ "docker.service" ] + ++ optionals wantsPodman [ "podman.service" ]; + wantedBy = [ "multi-user.target" ]; + + environment = optionalAttrs wantsPodman { + DOCKER_HOST = "unix:///run/podman/podman.sock"; + }; + + path = [ pkgs.coreutils ] ++ lib.optionals wantsHost instance.hostPackages; + + serviceConfig = { + MemoryDenyWriteExecute = !wantsHost; + + LoadCredential = + lib.optionals (instance.registrationTokenFile != null) [ + "REGISTRATION_TOKEN:${instance.registrationTokenFile}" + ] + ++ lib.mapAttrsToList (name: value: "${name}:${value}") instance.credentials; + + SupplementaryGroups = optionals wantsDocker [ "docker" ] ++ optionals wantsPodman [ "podman" ]; + ExecPaths = lib.optionals wantsHost [ "/var/lib/forgejo-runner/${escapedName}" ]; + + ExecStartPre = lib.optionals (instance.registrationTokenFile != null) [ + (lib.getExe ( + pkgs.writeShellApplication { + name = "forgejo-register-runner-${escapedName}"; + text = '' + INSTANCE_DIR="$STATE_DIRECTORY" + mkdir -vp "$INSTANCE_DIR" + cd "$INSTANCE_DIR" + + LABELS_FILE="$INSTANCE_DIR/.labels.sha256" + LABELS_WANTED="$(echo ${escapeShellArg (concatStringsSep "\n" instance.labels)} | sort)" + LABELS_WANTED_HASH="$(printf '%s' "$LABELS_WANTED" | sha256sum | cut -d' ' -f1)" + LABELS_CURRENT_HASH="$(cat "$LABELS_FILE" 2>/dev/null || true)" + + if [ ! -e "$INSTANCE_DIR/.runner" ] || [ "$LABELS_WANTED_HASH" != "$LABELS_CURRENT_HASH" ]; then + rm -vf "$INSTANCE_DIR/.runner" || true + + ${cfg.package}/bin/forgejo-runner register \ + --no-interactive \ + --instance ${escapeShellArg instance.url} \ + --token "$(cat "$CREDENTIALS_DIRECTORY/REGISTRATION_TOKEN")" \ + --name ${escapeShellArg instance.name} \ + --labels ${escapeShellArg (concatStringsSep "," instance.labels)} \ + --config ${configFile} + + printf '%s' "$LABELS_WANTED_HASH" > "$LABELS_FILE" + fi + ''; + } + )) + ]; + + ExecStart = lib.mkForce "${cfg.package}/bin/forgejo-runner daemon --config ${configFile}"; + }; + }; + in + { + "forgejo-runner@" = { + description = "Forgejo Actions Runner (%I)"; + + environment = { + HOME = "/var/lib/forgejo-runner/%i"; + }; + + serviceConfig = { + DynamicUser = true; + User = "forgejo-runner-%i"; + StateDirectory = "forgejo-runner/%i"; + WorkingDirectory = "/var/lib/forgejo-runner/%i"; + + Restart = "on-failure"; + RestartSec = 2; + + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" ]; + UMask = "0077"; + }; + }; + } + // mapAttrs' mkRunnerInstance cfg.instances; + }; +} diff --git a/nixos/tests/forgejo.nix b/nixos/tests/forgejo.nix index 1dc3f5a538434..85c6d5feba16f 100644 --- a/nixos/tests/forgejo.nix +++ b/nixos/tests/forgejo.nix @@ -60,28 +60,47 @@ let ]; services.openssh.enable = true; - specialisation.runner = { - inheritParentConfig = true; - configuration.services.gitea-actions-runner = { - package = pkgs.forgejo-runner; - instances."test" = { - enable = true; - name = "ci"; - url = "http://localhost:3000"; - labels = [ - # type ":host" does not depend on docker/podman/lxc - "native:host" - ]; - tokenFile = "/var/lib/forgejo/runner_token"; + specialisation = { + runner = { + inheritParentConfig = true; + configuration.services.forgejo.runner = { + package = pkgs.forgejo-runner; + instances."test" = { + enable = true; + url = "http://localhost:3000"; + labels = [ + # type ":host" does not depend on docker/podman/lxc + "native:host" + ]; + registrationTokenFile = "/var/lib/forgejo/runner_token"; + }; }; }; - }; - specialisation.dump = { - inheritParentConfig = true; - configuration.services.forgejo.dump = { - enable = true; - type = "tar.zst"; - file = "dump.tar.zst"; + + giteaRunner = { + inheritParentConfig = true; + configuration.services.gitea-actions-runner = { + package = pkgs.forgejo-runner; + instances."test" = { + enable = true; + name = "ci"; + url = "http://localhost:3000"; + labels = [ + # type ":host" does not depend on docker/podman/lxc + "native:host" + ]; + tokenFile = "/var/lib/forgejo/gitea_runner_token"; + }; + }; + }; + + dump = { + inheritParentConfig = true; + configuration.services.forgejo.dump = { + enable = true; + type = "tar.zst"; + file = "dump.tar.zst"; + }; }; }; }; @@ -125,7 +144,15 @@ let steps: - uses: http://localhost:3000/test/checkout@main - run: cat testfile + - run: ./exec-test.sh + ''; + + execTestScript = pkgs.writeScript "exec-test.sh" '' + #!${lib.getExe pkgs.bash} + echo "Running exec test script ensures we can execute files from checkout" + true ''; + # https://github.com/actions/checkout/releases checkoutActionSource = pkgs.fetchFromGitHub { owner = "actions"; @@ -172,7 +199,7 @@ let + "Please contact your site administrator.'" ) server.succeed( - "su -l forgejo -c 'GITEA_WORK_DIR=/var/lib/forgejo forgejo admin user create " + "su -l forgejo -c 'FORGEJO_WORK_DIR=/var/lib/forgejo forgejo admin user create " + "--username test --password totallysafe --email test@localhost --must-change-password=false'" ) @@ -217,13 +244,33 @@ let server.fail("curl --fail http://localhost:3000/metrics") server.succeed('curl --fail http://localhost:3000/metrics -H "Authorization: Bearer ${metricSecret}"') - with subtest("Testing runner registration and action workflow"): + def wait_for_workflow_id(id): + def poll_workflow_action_status(_) -> bool: + try: + response = server.succeed("curl --fail http://localhost:3000/api/v1/repos/test/repo/actions/tasks") + status = json.loads(response).get("workflow_runs")[id].get("status") + + except IndexError: + status = "???" + + server.log(f"Workflow status: {status}") + + if status == "failure": + raise Exception("Workflow failed") + + return status == "success" + + with server.nested("Waiting for the workflow run to be successful"): + retry(poll_workflow_action_status, 60) + + with subtest("Testing forgejo runner registration and action workflow"): server.succeed( - "su -l forgejo -c 'GITEA_WORK_DIR=/var/lib/forgejo forgejo actions generate-runner-token' | sed 's/^/TOKEN=/' | tee /var/lib/forgejo/runner_token" + "su -l forgejo -c 'FORGEJO_WORK_DIR=/var/lib/forgejo forgejo actions generate-runner-token' | tee /var/lib/forgejo/runner_token" ) + server.succeed("${serverSystem}/specialisation/runner/bin/switch-to-configuration test") - server.wait_for_unit("gitea-runner-test.service") - server.succeed("journalctl -o cat -u gitea-runner-test.service | grep -q 'Runner registered successfully'") + server.wait_for_unit("forgejo-runner@test.service") + server.succeed("journalctl -o cat -u forgejo-runner@test.service | grep -q 'Runner registered successfully'") # enable actions feature for this repository, defaults to disabled server.succeed( @@ -244,27 +291,30 @@ let # push workflow to initial repo client.succeed("mkdir -p /tmp/repo/.forgejo/workflows") client.succeed("cp ${pkgs.writeText "dummy-workflow.yml" actionsWorkflowYaml} /tmp/repo/.forgejo/workflows/") + client.succeed("cp ${execTestScript} /tmp/repo/exec-test.sh") client.succeed("git -C /tmp/repo add .") client.succeed("git -C /tmp/repo commit -m 'Add dummy workflow'") client.succeed("git -C /tmp/repo push origin main") - def poll_workflow_action_status(_) -> bool: - try: - response = server.succeed("curl --fail http://localhost:3000/api/v1/repos/test/repo/actions/tasks") - status = json.loads(response).get("workflow_runs")[0].get("status") + wait_for_workflow_id(0) - except IndexError: - status = "???" - server.log(f"Workflow status: {status}") + with subtest("Testing gitea runner registration and action workflow"): + server.succeed( + "su -l forgejo -c 'FORGEJO_WORK_DIR=/var/lib/forgejo forgejo actions generate-runner-token' | sed 's/^/TOKEN=/' | tee /var/lib/forgejo/gitea_runner_token" + ) - if status == "failure": - raise Exception("Workflow failed") + server.succeed("${serverSystem}/specialisation/giteaRunner/bin/switch-to-configuration test") + server.wait_for_unit("gitea-runner-test.service") + server.succeed("journalctl -o cat -u gitea-runner-test.service | grep -q 'Runner registered successfully'") - return status == "success" + # push workflow to initial repo + client.succeed("touch /tmp/repo/gitea-change-file") + client.succeed("git -C /tmp/repo add gitea-change-file") + client.succeed("git -C /tmp/repo commit -m 'Add gitea-change-file'") + client.succeed("git -C /tmp/repo push origin main") - with server.nested("Waiting for the workflow run to be successful"): - retry(poll_workflow_action_status, 60) + wait_for_workflow_id(1) with subtest("Testing backup service"): server.succeed("${serverSystem}/specialisation/dump/bin/switch-to-configuration test")