M2: Drone server + exec runner up; infra as idempotent-reconcile oneshots

Convert proxy+drone bring-up to writeShellApplication systemd oneshots that
reconcile every activation (orchestrator steer). pkgs.abra overlay. Runner
connected via RPC (polling, capacity=2). install.md = clone + nixos-rebuild switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:59:59 +01:00
parent 62b23e3a41
commit a385148af9
11 changed files with 296 additions and 113 deletions

View File

@ -1,25 +1,6 @@
# abra — the Co-op Cloud CLI used by the harness to deploy/upgrade/backup recipes (M1+).
# Packaged from the upstream release binary, pinned by version + hash for reproducibility (D8).
# abra — the Co-op Cloud CLI used by the harness and the proxy/drone reconcile oneshots.
# The package is defined as an overlay in modules/packages.nix (pkgs.abra), pinned by hash (D8).
{ pkgs, ... }:
let
abra = pkgs.stdenv.mkDerivation rec {
pname = "abra";
version = "0.13.0-beta";
src = pkgs.fetchurl {
url = "https://git.coopcloud.tech/toolshed/abra/releases/download/${version}/abra_${version}_linux_amd64.tar.gz";
sha256 = "12csk6wp1pk9cspzqfl4a6h5jdz8p055sf0ggxw9k7ljhpd5qvc6";
};
# Tarball has files at the root (LICENSE, README.md, abra), no common subdir.
sourceRoot = ".";
nativeBuildInputs = [ pkgs.autoPatchelfHook ];
buildInputs = [ pkgs.stdenv.cc.cc.lib ];
installPhase = ''
runHook preInstall
install -Dm755 abra "$out/bin/abra"
runHook postInstall
'';
};
in
{
environment.systemPackages = [ abra ];
environment.systemPackages = [ pkgs.abra ];
}

42
modules/drone-runner.nix Normal file
View File

@ -0,0 +1,42 @@
# Drone exec runner (M2). Runs on cc-ci itself (not in a container) so CI pipelines can drive
# host `abra` to deploy real recipes onto the swarm (plan §4.2, §8: exec runner). The Drone
# *server* is deployed separately via abra (scripts/deploy-drone.sh) as a swarm service.
#
# The exec runner is drone-runner-exec (the only exec runner upstream ever shipped; see
# DECISIONS.md "CI engine"). It connects to the server over RPC at drone.ci.commoninternet.net,
# sharing DRONE_RPC_SECRET with the server via the sops-rendered EnvironmentFile.
{ pkgs, config, lib, ... }:
{
# Drone ships under the Polyform Small Business license (nixpkgs marks it unfree);
# permitted for our internal CI use. Allow only this package.
nixpkgs.config.allowUnfreePredicate = pkg:
builtins.elem (lib.getName pkg) [ "drone-runner-exec" ];
systemd.services.drone-runner-exec = {
description = "Drone exec runner (drives host abra/swarm)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
DRONE_RPC_PROTO = "https";
DRONE_RPC_HOST = "drone.ci.commoninternet.net";
DRONE_RUNNER_CAPACITY = "2"; # concurrency cap (plan §4.2)
DRONE_RUNNER_NAME = "cc-ci-exec";
# exec runner needs a writable root for build workspaces
DRONE_RUNNER_ROOT = "/var/lib/drone-runner";
# Pipeline commands shell out to abra/docker/git — all live in the system path.
PATH = lib.mkForce "/run/current-system/sw/bin:/run/wrappers/bin";
};
serviceConfig = {
# DRONE_RPC_SECRET comes from the sops-rendered env file (shared with the server).
EnvironmentFile = config.sops.templates."drone-runner.env".path;
ExecStart = "${pkgs.drone-runner-exec}/bin/drone-runner-exec";
Restart = "always";
RestartSec = "5s";
StateDirectory = "drone-runner";
# exec runner runs pipelines as this service's user; root is needed to drive docker/abra
# and to read the abra config under /root/.abra (same as manual deploys).
User = "root";
};
};
}

64
modules/drone.nix Normal file
View File

@ -0,0 +1,64 @@
# Drone CI server = coop-cloud `drone` recipe via abra (swarm, traefik-routed at
# drone.ci.commoninternet.net, Gitea SSO, wildcard cert / no ACME). The exec *runner* is a
# separate host systemd service (modules/drone-runner.nix). See DECISIONS.md "CI engine"/"Drone
# deployment shape".
#
# Idempotent-RECONCILE oneshot (same pattern as proxy/swarm-init): converges every boot/activation.
# RPC + OAuth-client secrets come from sops (/run/secrets), inserted as swarm secrets here.
{ pkgs, ... }:
let
giteaClientId = "ab4cdb9d-ee96-4867-875f-87384505fc52";
reconcile = pkgs.writeShellApplication {
name = "cc-ci-reconcile-drone";
runtimeInputs = with pkgs; [ abra docker jq gnused gnugrep coreutils git ];
text = ''
DRONE_DOMAIN="drone.ci.commoninternet.net"
ENV_FILE="$HOME/.abra/servers/default/$DRONE_DOMAIN.env"
if [ ! -r /run/secrets/drone_rpc_secret ] || [ ! -r /run/secrets/drone_gitea_client_secret ]; then
echo "FATAL: drone sops secrets missing at /run/secrets (rebuild ordering?)" >&2
exit 1
fi
abra server ls -m -n >/dev/null 2>&1 || abra server add --local -n || true
abra recipe fetch drone -n >/dev/null
[ -f "$ENV_FILE" ] || abra app new drone -s default -D "$DRONE_DOMAIN" -n
set_env() {
sed -i -E "/^[[:space:]]*#?[[:space:]]*$1=/d" "$ENV_FILE"
printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"
}
set_env LETS_ENCRYPT_ENV ""
set_env EXTRA_DOMAINS ""
set_env DRONE_USER_CREATE "username:autonomic-bot,admin:true"
set_env GITEA_DOMAIN "git.autonomic.zone"
set_env GITEA_CLIENT_ID "${giteaClientId}"
set_env RPC_SECRET_VERSION "v1"
set_env CLIENT_SECRET_VERSION "v1"
set_env DRONE_ENV_VERSION "v1"
set_env COMPOSE_FILE '"compose.yml:compose.gitea.yml"'
have_secret() { docker secret ls --format '{{.Name}}' | grep -q "_$1_v1$"; }
have_secret rpc_secret || abra app secret insert "$DRONE_DOMAIN" rpc_secret v1 /run/secrets/drone_rpc_secret -f -n
have_secret client_secret || abra app secret insert "$DRONE_DOMAIN" client_secret v1 /run/secrets/drone_gitea_client_secret -f -n
abra app deploy "$DRONE_DOMAIN" -n -C
'';
};
in
{
systemd.services.deploy-drone = {
description = "Reconcile the Drone CI server (coop-cloud recipe, Gitea SSO) via abra";
after = [ "deploy-proxy.service" "swarm-init.service" "docker.service" "network-online.target" ];
requires = [ "swarm-init.service" "docker.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment.HOME = "/root";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${reconcile}/bin/cc-ci-reconcile-drone";
};
};
}

25
modules/packages.nix Normal file
View File

@ -0,0 +1,25 @@
# Project package overlay. `abra` (the Co-op Cloud CLI) is exposed as `pkgs.abra` so every
# module (systemPackages, the proxy/drone reconcile oneshots) can use the same pinned build.
{ ... }:
{
nixpkgs.overlays = [
(final: prev: {
abra = prev.stdenv.mkDerivation rec {
pname = "abra";
version = "0.13.0-beta";
src = prev.fetchurl {
url = "https://git.coopcloud.tech/toolshed/abra/releases/download/${version}/abra_${version}_linux_amd64.tar.gz";
sha256 = "12csk6wp1pk9cspzqfl4a6h5jdz8p055sf0ggxw9k7ljhpd5qvc6";
};
sourceRoot = ".";
nativeBuildInputs = [ prev.autoPatchelfHook ];
buildInputs = [ prev.stdenv.cc.cc.lib ];
installPhase = ''
runHook preInstall
install -Dm755 abra "$out/bin/abra"
runHook postInstall
'';
};
})
];
}

63
modules/proxy.nix Normal file
View File

@ -0,0 +1,63 @@
# Reverse proxy = the canonical Co-op Cloud `traefik` recipe, deployed via abra in
# wildcard / file-provider mode (operator's pre-issued cert as ssl_cert/ssl_key swarm secrets,
# LETS_ENCRYPT_ENV empty => NO ACME, no DNS token). See DECISIONS.md "Proxy: real coop-cloud/traefik".
#
# Declared as an idempotent-RECONCILE systemd oneshot (like swarm-init): it inspects current
# state and converges every activation/boot, self-healing drift (redeploys if the stack is gone,
# re-inserts secrets if missing). No run-once sentinel. So a from-scratch install is just
# `nixos-rebuild switch` + operator preconditions (D8) — no manual post-steps.
{ pkgs, ... }:
let
reconcile = pkgs.writeShellApplication {
name = "cc-ci-reconcile-proxy";
runtimeInputs = with pkgs; [ abra docker jq gnused gnugrep coreutils git ];
text = ''
PROXY_DOMAIN="traefik.ci.commoninternet.net"
CERT_DIR="/var/lib/ci-certs/live"
ENV_FILE="$HOME/.abra/servers/default/$PROXY_DOMAIN.env"
# Fail visibly (failed unit) if the operator cert is missing do NOT silently skip.
if [ ! -r "$CERT_DIR/fullchain.pem" ] || [ ! -r "$CERT_DIR/privkey.pem" ]; then
echo "FATAL: wildcard cert missing at $CERT_DIR (operator precondition)" >&2
exit 1
fi
abra server ls -m -n >/dev/null 2>&1 || abra server add --local -n || true
abra recipe fetch traefik -n >/dev/null
[ -f "$ENV_FILE" ] || abra app new traefik -s default -D "$PROXY_DOMAIN" -n
set_env() {
sed -i -E "/^[[:space:]]*#?[[:space:]]*$1=/d" "$ENV_FILE"
printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"
}
set_env LETS_ENCRYPT_ENV ""
set_env WILDCARDS_ENABLED "1"
set_env SECRET_WILDCARD_CERT_VERSION "v1"
set_env SECRET_WILDCARD_KEY_VERSION "v1"
set_env COMPOSE_FILE '"compose.yml:compose.wildcard.yml"'
have_secret() { docker secret ls --format '{{.Name}}' | grep -q "_$1_v1$"; }
have_secret ssl_cert || abra app secret insert "$PROXY_DOMAIN" ssl_cert v1 "$CERT_DIR/fullchain.pem" -f -n
have_secret ssl_key || abra app secret insert "$PROXY_DOMAIN" ssl_key v1 "$CERT_DIR/privkey.pem" -f -n
# Converge the stack (idempotent: no-op if already at desired state).
abra app deploy "$PROXY_DOMAIN" -n -C
'';
};
in
{
systemd.services.deploy-proxy = {
description = "Reconcile the Co-op Cloud traefik proxy (wildcard/no-ACME) via abra";
after = [ "swarm-init.service" "docker.service" "network-online.target" ];
requires = [ "swarm-init.service" "docker.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment.HOME = "/root";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${reconcile}/bin/cc-ci-reconcile-proxy";
};
};
}

View File

@ -2,7 +2,7 @@
# ed25519 SSH host key as the age identity (no separate key file to manage on the box).
# Encrypted material lives in ../secrets/*.yaml, committed and readable only by recipients
# listed in /.sops.yaml (host key + off-box master recovery key).
{ ... }:
{ config, ... }:
{
sops = {
defaultSopsFile = ../secrets/secrets.yaml;
@ -11,8 +11,19 @@
# Do not also look for a GPG key.
gnupg.sshKeyPaths = [ ];
# M0 proof secret — confirms the decrypt path works end to end. Real infra secrets
# (Drone RPC, webhook HMAC, OAuth, registry creds) are added in their milestones.
# M0 proof secret — confirms the decrypt path works end to end.
secrets.test_secret = { };
# M2 Drone (A2 internal secrets). drone_rpc_secret is shared between the swarm-deployed
# Drone server (inserted as the `rpc_secret` swarm secret by scripts/deploy-drone.sh) and
# the host exec runner (read via the env template below). drone_gitea_client_secret is the
# Gitea OAuth app secret, inserted as the server's `client_secret` swarm secret.
secrets.drone_rpc_secret = { };
secrets.drone_gitea_client_secret = { };
# EnvironmentFile for the host exec runner: DRONE_RPC_SECRET rendered from the sops secret.
templates."drone-runner.env".content = ''
DRONE_RPC_SECRET=${config.sops.placeholder.drone_rpc_secret}
'';
};
}