From 48f87539d9cfc5ca4f7f6913dfcf51d24e849fcb Mon Sep 17 00:00:00 2001 From: emilylange Date: Mon, 8 Jun 2026 19:59:01 +0200 Subject: [PATCH 1/2] nixos/forgejo-runner: init --- .../manual/release-notes/rl-2611.section.md | 2 + nixos/modules/module-list.nix | 1 + .../continuous-integration/forgejo-runner.nix | 476 ++++++++++++++++++ 3 files changed, 479 insertions(+) create mode 100644 nixos/modules/services/continuous-integration/forgejo-runner.nix diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 939b86f7d2364..80e735bd40df2 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -14,6 +14,8 @@ - [scx_loader](https://github.com/sched-ext/scx-loader), a system daemon and DBus-based loader for sched_ext schedulers. `scxctl` is the command-line client for interacting with the loader, allowing users to switch schedulers, modes, and arguments dynamically. Available as [services.scx-loader](#opt-services.scx-loader.enable) +- [Forgejo Runner](https://forgejo.org/docs/latest/admin/actions/), a daemon for Forgejo Actions. Available as [services.forgejo-runner](#opt-services.forgejo-runner.instances). + - [FlapAlerted](https://github.com/Kioubit/FlapAlerted), detects BGP flapping events and provides statistics based on BGP update messages. Available as [services.flap-alerted](#opt-services.flap-alerted.enable). ## Backward Incompatibilities {#sec-release-26.11-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index a96e30cac837c..169a4744e3288 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -508,6 +508,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..7f5f15acd1f75 --- /dev/null +++ b/nixos/modules/services/continuous-integration/forgejo-runner.nix @@ -0,0 +1,476 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: + +let + inherit (lib) + foldlAttrs + literalExpression + mkEnableOption + mkIf + mkOption + mkPackageOption + mkRemovedOptionModule + mkRenamedOptionModule + nameValuePair + optionalAttrs + optionals + types + ; + + cfg = config.services.forgejo-runner; + settingsFormat = pkgs.formats.yaml { }; + + # An option type for cfg.secrets that is like cfg.settings (free-form yaml), but limited + # to just path and attrsOf path. It uses the same implementation primivites as + # pkgs.formats.yaml (serializableValueWith). + # The alternative would be something like types.any, which is too weak, or a hard-coded + # max-depth by chaining multiple types.oneOf and types.attrsOf together, which is both + # longer in code and less future-proof. + pathType = types.pathWith { + inStore = false; + absolute = true; + }; + secretsTypeBase = types.oneOf [ + pathType + (types.attrsOf secretsTypeBase) + ]; + secretsType = secretsTypeBase // { + description = "nested attribute set of ${pathType.description}"; + }; + + # provide shorthands for whether container runtimes are enabled + hasDocker = config.virtualisation.docker.enable; + hasPodman = config.virtualisation.podman.enable; + hasContainerRuntime = hasDocker || hasPodman; + labels = + instance: + instance.settings.runner.labels + ++ (lib.flatten ( + lib.mapAttrsToList (_: value: value.labels or [ ]) instance.settings.server.connections + )); +in +{ + meta.maintainers = pkgs.forgejo-runner.meta.maintainers; + + options.services.forgejo-runner = { + package = mkPackageOption pkgs "forgejo-runner" { }; + + instances = mkOption { + default = { }; + description = '' + Forgejo Runner instances. + ''; + type = types.attrsOf ( + types.submodule ( + { + options, + config, + name, + ... + }: + + { + imports = [ + ../../misc/assertions.nix + + # compat for users coming from nixos/gitea-actions-runner + (mkRenamedOptionModule [ "url" ] [ "settings" "server" "connections" "default" "url" ]) + (mkRenamedOptionModule [ "labels" ] [ "settings" "runner" "labels" ]) + (mkRemovedOptionModule [ "name" ] '' + The option `${options.name}' has been removed, because it has no longer + any effect, as runners no longer self-report their name to Forgejo. + '') + (mkRemovedOptionModule [ "token" ] '' + The option `${options.token}' has been renamed to + `${options.settings}.server.connections.default.token' + but additional attention is required. + + Assuming you are migrating from `services.gitea-actions-runner', you will need to: + + 1. Find the old `.runner' file of your previously registered runner. You will need + to extract two values from it. Given the instance name of "${name}", it should + be able to find it at `/var/lib/gitea-runner/${name}/.runner'. + + 2. Read the contents of it, for example using `cat /var/lib/gitea-runner/native/.runner'. + + 3. Take note of the "uuid" and set the option `${options.settings}.server.connections.default.uuid' + to that value. For example "c9e50be9-a7c3-4aee-ba35-624c4ff8c519". + + 4. Take note of the "token" and set the option `${options.settings}.server.connections.default.token' + to that value. For example "6634bb58be0db23cc013a2e72dd1828ae0257cf". + + 5. Remove option `${options.token}'. + '') + (mkRemovedOptionModule [ "tokenFile" ] '' + The option `${options.tokenFile}' has been renamed to + `${options.secrets}.server.connections.default.token_url' + but additional attention is required. + + Assuming you are migrating from `services.gitea-actions-runner', you will need to: + + 1. Find the old `.runner' file of your previously registered runner. You will need + to extract two values from it. Given the instance name of "${name}", it should + be able to find it at `/var/lib/gitea-runner/${name}/.runner'. + + 2. Read the contents of it, for example using `cat /var/lib/gitea-runner/native/.runner'. + + 3. Take note of the "uuid" and set the option `${options.settings}.server.connections.default.uuid' + to that value. For example "c9e50be9-a7c3-4aee-ba35-624c4ff8c519". + + 4. Take note of the "token" and replace the contents of your existing token file with it. + You no longer need to prefix the token with `TOKEN='. Put just the token in that file + and nothing else. + + 5. Rename `${options.tokenFile}' to `${options.secrets}.server.connections.default.token_url'. + '') + ]; + + config = { + assertions = [ + { + assertion = config.isDockerRunner -> hasContainerRuntime; + message = '' + The option `${options.settings}' has at least one label of + type `:docker:' configured, but no compatible container runtime enabled. + + You need to enable either + `config.virtualisation.docker.enable' or + `config.virtualisation.podman.enable'. + ''; + } + ] + ++ (foldlAttrs ( + assertions: _: connection: + assertions ++ connection.assertions + ) [ ] config.settings.server.connections); + }; + + options = { + enable = mkEnableOption "this Forgejo Runner instance"; + + settings = mkOption { + default = { }; + description = '' + Free-form settings written directly to the {file}`config.yaml` file. + Refer to [`config.example.yaml`] or run {command}`forgejo-runner generation-config` for supported values. + + [`config.example.yaml`]: https://code.forgejo.org/forgejo/runner/src/branch/main/internal/pkg/config/config.example.yaml + ''; + type = types.submodule { + freeformType = settingsFormat.type; + + config = lib.mapAttrsRecursive ( + path: _: "file:$CREDENTIALS_DIRECTORY/${lib.join "__" path}" + ) config.secrets; + + options = { + runner = { + labels = mkOption { + type = types.listOf types.str; + example = literalExpression '' + [ + # provide a debian base with nodejs for actions + "debian-latest:docker://node:current" + # fake the ubuntu name, because node provides no ubuntu builds + "ubuntu-latest:docker://node:current" + # provide native execution on the host + #"native:host" + ] + ''; + description = '' + Labels used to map jobs to their runtime environment. + + Many common actions require {command}`bash`, {command}`git` and {command}`node`, + as well as a filesystem that follows the filesystem hierarchy standard. + + If you specify a label of type `:docker:`, the resulting runner service + will be automatically added to the *Podman* or *Docker* group. + + See . + ''; + }; + }; + + server = { + connections = mkOption { + default = { }; + description = '' + One or more connections to Forgejo instances, each with a UUID and Token pair. + + See . + + ::: {.note} + Ephemeral runner mode is not yet supported by this module. + ::: + ''; + example = literalExpression '' + { + default = { + url = "https://example.com/"; + uuid = "c9e50be9-a7c3-4aee-ba35-624c4ff8c519"; + + # Also see ${options.secrets}.server.connections..token_url + token = "6634bb58be0db23cc013a2e72dd1828ae0257cf"; + }; + } + ''; + type = types.attrsOf ( + types.submodule ( + { name, config, ... }: + + { + freeformType = settingsFormat.type; + + imports = [ + ../../misc/assertions.nix + ]; + + config = { + assertions = [ + { + assertion = + (config.token == null && config ? token_url && config.token_url != null) + || (config.token != null && config ? token_url && config.token_url == null) + || (config.token != null && !config ? token_url); + message = '' + The option `${options.settings}' needs to have exactly one of + `server.connections.${name}.token_url': ${ + if config ? token_url then lib.toJSON config.token_url else "" + } or + `server.connections.${name}.token': ${lib.toJSON config.token} + that is *not* null. + + Hint: + `${options.secrets}.server.connections.${name}.token_url' will set + `${options.settings}.settings.server.connections.${name}.token_url' for you. + ''; + } + ]; + }; + + options = { + url = mkOption { + type = types.str; + example = "https://example.com/"; + description = '' + Base URL of your Forgejo instance. + ''; + }; + uuid = mkOption { + type = types.str; + example = "c9e50be9-a7c3-4aee-ba35-624c4ff8c519"; + description = '' + UUID of this runner. + + See . + ''; + }; + token = mkOption { + type = types.nullOr types.str; + example = "6634bb58be0db23cc013a2e72dd1828ae0257cf"; + description = '' + Token of this runner. + + See . + + ::: {.note} + The deprecated "Registration Token" is not supported. + You need a UUID and Token pair. + ::: + + ::: {.warning} + The value will be stored unencrypted in the world-readable Nix store. + To store the secret securely, see {option}`${options.secrets}.server.connections..token_url`. + ::: + ''; + }; + }; + } + ) + ); + }; + }; + }; + }; + }; + + secrets = mkOption { + type = secretsType; + default = { }; + description = '' + This follows the same structure as {option}`${options.settings}` + but the value of each key is a path. + + The specified secret path is then read by systemd via [`LoadCredential=`] + and templated into {option}`${options.settings}` for you. + + [`LoadCredential=`]: https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Credentials + ''; + example = literalExpression '' + { + server.connections.example = { + token_url = "/run/keys/forgejo-runner_token"; + }; + + cache = { + secret_url = "/run/keys/forgejo-runner_cache-secret"; + }; + } + ''; + }; + + hostPackages = mkOption { + type = types.listOf types.package; + default = with pkgs; [ + bash + coreutils + curl + gawk + gnused + nodejs + wget + ]; + defaultText = literalExpression '' + with pkgs; [ + bash + coreutils + curl + gawk + gnused + nodejs + wget + ] + ''; + description = '' + List of packages, that are available to your workflow and actions, when the + runner is configured with a label of type `:host`. + + ::: {.note} + {command}`gitMinimal` is always part of the environment because {command}`forgejo-runner` + depends on it. If you need a different variant of {command}`git`, e.g. {command}`gitFull`, + add it here. Your package will take priority over {command}`gitMinimal`. + ::: + ''; + }; + + isDockerRunner = mkOption { + internal = true; + readOnly = true; + type = types.bool; + default = lib.any (label: lib.hasInfix ":docker:" label) (labels config); + description = "Whether this instance has at least one label of type `:docker:`."; + }; + + isHostRunner = mkOption { + internal = true; + readOnly = true; + type = types.bool; + default = lib.any (label: lib.hasSuffix ":host" label) (labels config); + description = "Whether this instance has at least one label of type `:host`."; + }; + + configFile = mkOption { + internal = true; + readOnly = true; + type = types.path; + default = settingsFormat.generate "config.yaml" ( + # Filter out remains of ../misc/assertions.nix. + # Note: This is for optics only, as forgejo-runner simply ignores settings it does not know. + lib.filterAttrsRecursive (n: _: n != "assertions" && n != "warnings") config.settings + ); + description = '' + Implementation detail for use in {file}`nixos/tests/forgejo.nix`. + + FIXME: Offload into top-level config once upstream supports {var}`uuid_url`. + ''; + }; + }; + } + ) + ); + }; + }; + + config = mkIf (cfg.instances != { }) { + assertions = ( + foldlAttrs ( + assertions: _: instance: + assertions ++ instance.assertions + ) [ ] cfg.instances + ); + + warnings = ( + foldlAttrs ( + warnings: _: instance: + warnings ++ instance.warnings + ) [ ] cfg.instances + ); + + systemd.services = lib.mapAttrs' ( + name: instance: + nameValuePair "forgejo-runner-${utils.escapeSystemdPath name}" { + inherit (instance) enable; + description = "Forgejo Runner"; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + ] + ++ optionals (instance.isDockerRunner && hasDocker) [ + "docker.service" + ] + ++ optionals (instance.isDockerRunner && hasPodman) [ + "podman.service" + ]; + wantedBy = [ + "multi-user.target" + ]; + environment = { + HOME = "/var/lib/forgejo-runner/${name}"; + } + // optionalAttrs (instance.isDockerRunner && hasPodman) { + # TODO: Add support for rootless Podman + DOCKER_HOST = "unix:///run/podman/podman.sock"; + }; + path = optionals instance.isHostRunner instance.hostPackages ++ [ pkgs.gitMinimal ]; + + serviceConfig = { + DynamicUser = true; + StateDirectory = "forgejo-runner/${name}"; + WorkingDirectory = "/var/lib/forgejo-runner/${name}"; + + # DynamicUser will try to use id-mapped mounts for exec directories, + # which has the side-effect of setting nosuid and noexec as mount option. + # Users of host runners expect to be able to execute scripts in their + # pipeline, so we override the noexec mount option by setting ExecPaths. + ExecPaths = optionals instance.isHostRunner [ "/var/lib/forgejo-runner/${name}" ]; + ExecStart = toString [ + (lib.getExe cfg.package) + "daemon" + "--config" + instance.configFile + ]; + + Restart = "on-failure"; + RestartSec = 10; + + LoadCredential = lib.mapAttrsToListRecursive ( + path: value: "${lib.join "__" path}:${value}" + ) instance.secrets; + + SupplementaryGroups = + optionals (instance.isDockerRunner && hasDocker) [ + "docker" + ] + ++ optionals (instance.isDockerRunner && hasPodman) [ + "podman" + ]; + }; + } + ) cfg.instances; + }; +} From ebbed83ea980369df17fef7c6820c8f5d26fc1e5 Mon Sep 17 00:00:00 2001 From: emilylange Date: Mon, 8 Jun 2026 19:59:46 +0200 Subject: [PATCH 2/2] nixos/tests/forgejo: test `nixos/forgejo-runner` --- nixos/tests/forgejo.nix | 113 ++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 27 deletions(-) diff --git a/nixos/tests/forgejo.nix b/nixos/tests/forgejo.nix index 1dc3f5a538434..6bc767340d722 100644 --- a/nixos/tests/forgejo.nix +++ b/nixos/tests/forgejo.nix @@ -60,7 +60,7 @@ let ]; services.openssh.enable = true; - specialisation.runner = { + specialisation.gitea-actions-runner = { inheritParentConfig = true; configuration.services.gitea-actions-runner = { package = pkgs.forgejo-runner; @@ -76,6 +76,45 @@ let }; }; }; + specialisation.forgejo-runner = { + inheritParentConfig = true; + configuration = ( + { config, ... }: + + { + services.forgejo-runner = { + instances."test" = { + enable = true; + settings = { + runner.labels = [ + # type ":host" does not depend on docker/podman/lxc + "native:host" + ]; + server.connections.default = { + url = "http://localhost:3000"; + uuid = "@UUID@"; + }; + }; + secrets.server.connections.default.token_url = "/forgejo-runner_token"; + }; + }; + + # FIXME: Remove once upstream supports uuid_url just like token_url + systemd.services.forgejo-runner-test = { + preStart = '' + cp -v ${config.services.forgejo-runner.instances."test".configFile} ./config.yaml + chmod u+w ./config.yaml + ${lib.getExe pkgs.replace-secret} "@UUID@" "$CREDENTIALS_DIRECTORY/UUID" ./config.yaml + chmod u-w ./config.yaml + ''; + serviceConfig = { + ExecStart = lib.mkForce "${lib.getExe config.services.forgejo-runner.package} daemon --config ./config.yaml"; + LoadCredential = [ "UUID:/forgejo-runner_uuid" ]; + }; + }; + } + ); + }; specialisation.dump = { inheritParentConfig = true; configuration.services.forgejo.dump = { @@ -172,7 +211,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 'GITEA_WORK_DIR=/var/lib/forgejo forgejo admin user create --admin " + "--username test --password totallysafe --email test@localhost --must-change-password=false'" ) @@ -217,22 +256,22 @@ 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"): - 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" - ) - 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'") + def poll_workflow_action_status(id: int) -> 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") - # enable actions feature for this repository, defaults to disabled - server.succeed( - "curl --fail -X PATCH http://localhost:3000/api/v1/repos/test/repo " - + "-H 'Accept: application/json' -H 'Content-Type: application/json' " - + f"-H 'Authorization: token {api_token}'" - + ' -d \'{"has_actions":true}\''' - ) + except IndexError: + status = "???" + + server.log(f"Workflow status: {status}") + + if status == "failure": + raise Exception("Workflow failed") + + return status == "success" + with subtest("Testing deprecated gitea-actions-runner registration and action workflow"): # mirror "actions/checkout" action client.succeed("cp -R ${checkoutActionSource}/ /tmp/checkout") client.succeed("git -C /tmp/checkout init") @@ -248,23 +287,43 @@ let 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") + # enable actions feature for this repository, defaults to disabled + server.succeed( + "curl --fail -X PATCH http://localhost:3000/api/v1/repos/test/repo " + + "-H 'Accept: application/json' -H 'Content-Type: application/json' " + + f"-H 'Authorization: token {api_token}'" + + ' -d \'{"has_actions":true}\''' + ) + 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" + ) + server.succeed("${serverSystem}/specialisation/gitea-actions-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'") - except IndexError: - status = "???" + with server.nested("Waiting for the workflow run to be successful"): + retry(lambda _: poll_workflow_action_status(0), 180) + + with subtest("Testing forgejo-runner registration and action workflow"): + runner_registration_response = server.succeed( + "curl --fail http://localhost:3000/api/v1/admin/actions/runners " + + f"-H 'Authorization: token {api_token}' " + + '--json \'{"name":"NixOS", "ephemeral":false}\''' + ) - server.log(f"Workflow status: {status}") + runner_registration = json.loads(runner_registration_response) + server.succeed(f"echo {runner_registration.get("token")} > /forgejo-runner_token") + server.succeed(f"echo {runner_registration.get("uuid")} > /forgejo-runner_uuid") - if status == "failure": - raise Exception("Workflow failed") + server.succeed("${serverSystem}/specialisation/forgejo-runner/bin/switch-to-configuration test") + server.wait_for_unit("forgejo-runner-test.service") + server.succeed("journalctl -o cat -u forgejo-runner-test.service | grep -q 'declared successfully'") - return status == "success" + client.succeed("git -C /tmp/repo commit --allow-empty -m 'Retrigger dummy workflow'") + 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) + retry(lambda _: poll_workflow_action_status(1), 180) with subtest("Testing backup service"): server.succeed("${serverSystem}/specialisation/dump/bin/switch-to-configuration test")