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")