Files
cc-ci-orchestrator/nix/modules/orchestrator-host.nix
T
autonomic-bot 88e5a93f94 orchestrator-host: weekly opencode auto-upgrade (latest-release check, reinstall, restart web)
opencode-install only installs when the binary is missing, so the standalone
CLI aged in place (1.18.29 for weeks while 1.18.33+ was out); opencode's
built-in autoupdate never fires here because every agent runs inside the
long-lived opencode serve. New opencode-upgrade.service + weekly timer
(Tue 02:00 UTC, an hour before the host auto-update) compares the installed
version with the latest GitHub release, reinstalls via the official
installer when they differ, restarts opencode-web so the new binary takes
effect, and verifies the UI answers its 401 challenge. The auto-update.nix
busy gate is replicated so a mid-flight CI run / weekly upgrade / sweep is
never cut (skipped runs retry next week). Deploying this module does not
itself bump opencode: boot installs stay install-if-missing and the upgrade
is timer-driven only. State per run: .cc-ci-logs/opencode-update-state.
2026-10-05 18:39:35 +00:00

370 lines
20 KiB
Nix

# orchestrator-host.nix — the host contract that nix/modules/cc-ci.nix (the orchestrator's
# loops/timers) silently assumes, made explicit and reusable: the `loops` user the agents run as,
# the standalone opencode CLI, the shared opencode web server and its basic-auth web UI,
# nix-ld so foreign binaries run on NixOS, and the tool set agents reach for.
#
# Exported from flake.nix as `nixosModules.orchestrator-host`. A host imports this together with
# `nixosModules.cc-ci-orchestrator`; the combined CI-server + orchestrator host (`#cc-ci`) also
# imports recipe-maintainers/cc-ci's `nixosModules.cc-ci-server`.
#
# History: until 2026-09 this lived (twice, drifting) in nix/hosts/cc-ci-orchestrator-hetzner/
# configuration.nix here and in notplants-nix's hosts/notplants-orchestrator/configuration.nix,
# the shared agent box that also ran lichen + project-orchestrator. The cc-ci half moved to its
# own host; this file is that half.
{ config, lib, pkgs, ... }:
let
cfg = config.cc-ci-orchestrator;
in
{
options.cc-ci-orchestrator = {
ciSshHost = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
example = "100.95.31.88";
description = ''
Where `ssh cc-ci` (used by every skill and script that drives the CI server) connects to,
as root with ~loops/.ssh/cc-ci-local-ed25519 (a key generated ON the host — nothing
copied from another machine). On the combined host the CI server IS this
machine, so the default is loopback; a standalone orchestrator points it at the CI
server's tailnet address.
'';
};
opencodeUiBackendPort = lib.mkOption {
type = lib.types.port;
default = 8090; # not 8080: acme-dns's local API has it on the combined host
description = ''
Plain-HTTP port nginx listens on for the opencode UI, reachable ONLY from the docker
bridge (firewall rule on docker_gwbridge). Traefik — which owns the public 443 on the
combined host — terminates TLS for opencodeUiHost and forwards here; nginx adds the basic
auth and logs failures for fail2ban with the real client IP.
'';
};
opencodeUiTraefikNetwork = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "proxy";
description = ''
Name of the swarm overlay network the cc-ci traefik watches (cc-ci's swarm.nix creates
`proxy`). When set, a one-container swarm stack `ccci-opencode-ui` (a socat TCP relay to
nginx on the docker bridge) is deployed with traefik labels routing opencodeUiHost on
the `web-secure` entrypoint — the same label mechanism every cc-ci service and recipe
uses, so it coexists with the traefik recipe's own file provider (the wildcard cert and
the `security` middleware live there; switching traefik to a file *directory* replaces
that file and takes every front door down — learned 2026-09-07). null = no route.
'';
};
opencodeUiHost = lib.mkOption {
type = lib.types.str;
default = "oc.commoninternet.net";
description = "nginx server_name for the opencode web UI (TLS + basic auth).";
};
opencodeUiExtraHosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Extra hostnames routed to the opencode UI alongside opencodeUiHost, rendered as
`||`-joined Host conditions on the same router. Domain cutover 2026-09: the
legacy oc.ci.commoninternet.net keeps answering here during the bake window.
'';
};
opencodeUiHtpasswdFile = lib.mkOption {
type = lib.types.str;
default = "/secrets/nginx/oc-htpasswd";
description = ''
htpasswd file for the opencode UI (`oc:<bcrypt>`), created out of band — a store path
would be world-readable. Default is under /secrets, the authoritative location for this
host's secrets; it must be readable by the `nginx` user (root:nginx 0640 in a directory
nginx can traverse). **nginx refuses to start if it is missing**, and its config check
runs as the nginx user, so a root-only file fails the check even though the path exists.
'';
};
};
config = {
# ---- the loops user -------------------------------------------------------------------
# claude sessions run as non-root (--dangerously-skip-permissions is refused for root).
users.users.loops = {
isNormalUser = true;
uid = 1000; # fixed: workspace files are rsynced between hosts by uid
home = "/home/loops";
shell = pkgs.bash;
extraGroups = [ "wheel" "docker" ];
};
security.sudo.wheelNeedsPassword = false;
security.sudo.extraRules = [{
users = [ "loops" ];
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
}];
# /home/loops/.local/bin holds the standalone opencode binary; it must be first on every PATH
# (interactive shells, tmux, the systemd units in cc-ci.nix prepend it too).
environment.variables.PATH = lib.mkForce
"/home/loops/.local/bin:/run/current-system/sw/bin:/run/wrappers/bin:/usr/bin:/bin";
# ---- nix-ld: the standalone opencode CLI is a foreign dynamic ELF binary --------------------
programs.nix-ld.enable = true;
programs.nix-ld.libraries = with pkgs; [ stdenv.cc.cc.lib zlib openssl curl glibc ];
# ---- the toolbox every agent (and every operator ssh session) on this box gets ----------
# Bar for adding something: an agent or an operator doing ordinary work would otherwise waste
# a turn discovering it is absent. Installed system-wide, so it is on PATH for BOTH root and
# loops via /run/current-system/sw/bin (which the forced PATH below keeps for every user).
environment.systemPackages = with pkgs; [
git tmux python3 jq curl cacert
gnused gawk coreutils gnugrep findutils util-linux nettools openssh
age sops ssh-to-age
wget gnutar gzip unzip zip xz
ripgrep fd tree file less which
procps psmisc htop lsof strace ncdu
dnsutils socat netcat-gnu iproute2 iputils
openssl gnumake gcc pkg-config
yq-go diffutils patch rsync bubblewrap
# Editors: `vim` ships `vi` too, so both names resolve for anyone who types either.
# `nano` comes from the base system. EDITOR is set below so git/systemctl/visudo agree.
vim nano
# `sqlite` is not a nicety here: Drone's build/step logs live in its sqlite volume and the
# runbook (and /cc-ci-status) tell you to read them there; without it every such check
# needed an ad-hoc `nix-shell -p sqlite`.
sqlite
bat bc moreutils pv man-pages
];
# So `git commit`, `systemctl edit`, `visudo` etc. open something that exists on this host.
environment.variables.EDITOR = "vim";
# ---- ssh config for the loops user: `ssh cc-ci` = the CI server (root) -----------------
# Written only if absent so a manual customisation survives rebuilds.
system.activationScripts.loopsSshConfig = ''
mkdir -p /home/loops/.ssh && chown loops:users /home/loops/.ssh && chmod 700 /home/loops/.ssh
if [ ! -f /home/loops/.ssh/config ]; then
cat > /home/loops/.ssh/config <<'SSHCFG'
Host cc-ci
HostName ${cfg.ciSshHost}
User root
IdentityFile /home/loops/.ssh/cc-ci-local-ed25519
IdentitiesOnly yes
StrictHostKeyChecking accept-new
ServerAliveInterval 30
Host git.autonomic.zone
HostName git.autonomic.zone
Port 2222
User git
IdentityFile /home/loops/.ssh/autonomic-bot-cc-ci-ed25519
IdentitiesOnly yes
SSHCFG
chmod 600 /home/loops/.ssh/config
chown loops:users /home/loops/.ssh/config
fi
'';
# ---- standalone CLIs (idempotent installers; re-run on every activation, no-op if present) --
# No Claude Code on this host (operator 2026-09-07): the orchestrator and the weekly upgrader
# are opencode agents; Claude sessions run on the notplants-orchestrator box and reach this
# host over ssh.
systemd.services.opencode-install = {
description = "Install opencode CLI for loops user (idempotent)";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = "loops"; Group = "users"; };
environment = { HOME = "/home/loops"; };
path = [ pkgs.curl pkgs.bash pkgs.coreutils pkgs.gnutar pkgs.gzip pkgs.unzip ];
script = ''
if [ ! -x "$HOME/.local/bin/opencode" ]; then
echo "installing opencode CLI for loops user..."
curl -fsSL https://opencode.ai/install | bash || echo "install failed — retry on next activation"
# The installer puts the binary in ~/.opencode/bin; every unit here expects ~/.local/bin.
if [ -x "$HOME/.opencode/bin/opencode" ]; then
mkdir -p "$HOME/.local/bin" && ln -sfn "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode"
fi
fi
'';
};
# ---- weekly opencode CLI auto-upgrade ----------------------------------------------------
# opencode is not in nixpkgs: opencode-install (above) only installs the standalone CLI when
# the binary is MISSING, so the installed version just ages in place (it sat on 1.18.29 for
# weeks while 1.18.33 was out). opencode's built-in autoupdate does not cover this host: it
# auto-applies patch releases only and only fires on a fresh interactive TUI start, and every
# agent here lives inside the long-lived `opencode serve` (opencode-web). This unit is the
# mechanism instead. It runs weekly in the Tuesday maintenance window (an hour BEFORE the
# host auto-update at Tue 03:00 UTC, and clear of the Thursday recipe-upgrade run and the
# Sunday canonical sweep), and:
# 1. compares the installed version with the latest GitHub release (no-op when equal);
# 2. reinstalls via the official installer (same code path as opencode-install) when they
# differ, and re-links ~/.local/bin/opencode;
# 3. restarts opencode-web so the new binary actually takes effect — sessions attached to
# it (orchestrator, upgrader, report) drop and their supervisors re-attach/resume, which
# is why the busy gate below must hold: a mid-flight CI/upgrade run is never cut, and a
# skipped run simply retries next week.
# Deploying this module never itself bumps opencode: boot/activation installs stay
# install-if-missing in opencode-install, and this unit is timer-driven only.
# The outcome of each run lands in .cc-ci-logs/opencode-update-state (read by /cc-ci-status).
systemd.services.opencode-upgrade = {
description = "Weekly opencode CLI auto-upgrade (latest release → reinstall → restart opencode-web)";
after = [ "network-online.target" "opencode-install.service" "opencode-web.service" ];
wants = [ "network-online.target" ];
serviceConfig = { Type = "oneshot"; TimeoutStartSec = "30min"; };
path = with pkgs; [ curl bash coreutils gnugrep gnutar gzip unzip systemd util-linux procps ];
environment = { HOME = "/home/loops"; OPENCODE_UI_HOST = cfg.opencodeUiHost; };
script = ''
set -u
BIN=/home/loops/.local/bin/opencode
STATE=/srv/cc-ci-orch/.cc-ci-logs/opencode-update-state
ocver() { "$BIN" --version 2>/dev/null | tail -1 || true; }
state() { printf '%s result=%s installed=%s note=%s\n' "$(date -u +%FT%TZ)" "$1" "$2" "$3" > "$STATE"; chown loops:users "$STATE" 2>/dev/null || true; }
busy() { echo "BUSY: $1 — skipping this week's opencode upgrade (retries next week)"; state skipped "$(ocver)" "$1"; exit 0; }
# Same busy gate as auto-update.nix: never cut a CI run, the weekly upgrade or the sweep.
pgrep -f run_recipe_ci >/dev/null && busy "a CI run is in flight"
systemctl is-active --quiet nightly-sweep.service && busy "the canonical sweep is running"
runuser -u loops -- tmux has-session -t cc-ci-upgrader 2>/dev/null && busy "the weekly recipe-upgrade run is in flight (tmux cc-ci-upgrader)"
runuser -u loops -- tmux has-session -t cc-ci-report 2>/dev/null && busy "the weekly report is being written (tmux cc-ci-report)"
if [ -r /run/secrets/bridge_drone_token ]; then
running=$(curl -s -m 20 -H "Authorization: Bearer $(cat /run/secrets/bridge_drone_token)" \
"https://drone.ci.commoninternet.net/api/repos/recipe-maintainers/cc-ci/builds?per_page=10" \
| grep -o '"status":"running"' | wc -l)
[ "''${running:-0}" -eq 0 ] || busy "$running Drone build(s) running"
fi
INSTALLED=$(ocver)
LATEST=$(curl -fsSL -m 30 https://api.github.com/repos/anomalyco/opencode/releases/latest \
| grep -Po '"tag_name":\s*"v?\K[0-9][0-9.]*' || true)
[ -n "$LATEST" ] || { echo "could not determine the latest opencode release"; state failed "''${INSTALLED:-none}" "latest-unresolved"; exit 1; }
echo "installed: ''${INSTALLED:-none} latest: $LATEST"
if [ "$INSTALLED" = "$LATEST" ]; then
echo "opencode is up to date"
state ok "$INSTALLED" "up-to-date"
exit 0
fi
echo "upgrading opencode: ''${INSTALLED:-none} -> $LATEST"
runuser -u loops -- env HOME=/home/loops bash -c 'curl -fsSL https://opencode.ai/install | bash' \
|| { echo "install failed — the previously installed version is untouched"; state failed "''${INSTALLED:-none}" "install-failed"; exit 1; }
mkdir -p /home/loops/.local/bin
ln -sfn /home/loops/.opencode/bin/opencode /home/loops/.local/bin/opencode
NEWVER=$(ocver)
[ "$NEWVER" = "$LATEST" ] || { echo "install ran but opencode reports $NEWVER (wanted $LATEST) — not restarting"; state failed "$NEWVER" "install-mismatch"; exit 1; }
echo "restarting opencode-web so the new binary takes effect (attached sessions drop; their supervisors resume)"
systemctl restart opencode-web.service
sleep 10
systemctl is-active --quiet opencode-web.service || { echo "opencode-web did not come back after the upgrade"; state failed "$NEWVER" "web-restart-failed"; exit 1; }
code=$(curl -s -m 20 -o /dev/null -w '%{http_code}' --resolve "$OPENCODE_UI_HOST:443:127.0.0.1" "https://$OPENCODE_UI_HOST/")
[ "$code" = "401" ] || { echo "opencode UI answered $code, not the 401 auth challenge"; state failed "$NEWVER" "ui-check-$code"; exit 1; }
echo "opencode upgraded to $NEWVER; opencode-web restarted and healthy"
state ok "$NEWVER" "upgraded; web restarted"
'';
};
systemd.timers.opencode-upgrade = {
wantedBy = [ "timers.target" ];
timerConfig = {
# Weekly slot in the Tuesday maintenance window, an hour BEFORE the host auto-update
# (cc-ci-auto-update.timer: Tue 03:00 UTC) so a restarted opencode-web has settled before
# that run's health check; Persistent=false, like the auto-update — no catch-up at boot.
OnCalendar = "Tue *-*-* 02:00:00 UTC";
Persistent = false;
RandomizedDelaySec = "10min";
};
};
# ---- opencode web server: one shared instance the opencode-backed agents attach to -------
# Provider creds come from /srv/cc-ci/.testenv (out of band, see README).
systemd.services.opencode-web = {
description = "opencode web server for cc-ci agents";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" "opencode-install.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
User = "loops"; Group = "users";
WorkingDirectory = "/srv/cc-ci-orch/cc-ci";
EnvironmentFile = [ "-/srv/cc-ci/cc-ci/.env.public" "/srv/cc-ci/.testenv" ];
ExecStartPre = "${pkgs.coreutils}/bin/rm -rf /tmp/opencode";
ExecStart = "/home/loops/.local/bin/opencode serve --hostname 127.0.0.1 --port 4096";
Restart = "on-failure";
RestartSec = "5s";
};
environment = {
HOME = "/home/loops";
PATH = lib.mkForce "/run/wrappers/bin:/home/loops/.local/bin:/run/current-system/sw/bin:/usr/bin:/bin:/etc/profiles/per-user/loops/bin:/nix/var/nix/profiles/default/bin";
};
path = [ pkgs.bash pkgs.coreutils pkgs.git pkgs.python3 pkgs.openssh pkgs.tmux pkgs.nettools ];
};
# ---- nginx: basic auth for the opencode UI, behind traefik -----------------------------
# Traefik (public 443, the CI wildcard cert) routes opencodeUiHost to this plain-HTTP vhost
# on the docker bridge address. nginx enforces HTTP basic auth (the opencode web UI has no
# auth of its own and can drive agent sessions), and — via real_ip from traefik's
# X-Forwarded-For — logs the CLIENT address on a 401, which is what the fail2ban jail bans.
# The htpasswd is created out of band (a store path would be world readable); nginx FAILS TO
# START without it, and its config check runs as the nginx user:
# /etc/nginx/oc-htpasswd root:nginx 0640 (`oc:<bcrypt>`; plaintext kept in /secrets)
# Rotate with: printf 'oc:%s\n' "$(mkpasswd -m bcrypt "$P")" > /etc/nginx/oc-htpasswd && systemctl reload nginx
services.nginx = {
enable = true;
recommendedProxySettings = true;
virtualHosts.${cfg.opencodeUiHost} = {
listen = [ { addr = "0.0.0.0"; port = cfg.opencodeUiBackendPort; } ];
serverAliases = cfg.opencodeUiExtraHosts;
basicAuthFile = cfg.opencodeUiHtpasswdFile;
extraConfig = ''
# traefik sits on the docker networks (ingress 10.0.0.0/24, gwbridge 172.18.0.0/16)
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
'';
locations."/" = {
proxyPass = "http://127.0.0.1:4096";
proxyWebsockets = true;
};
};
};
# Only docker's bridge may reach the plain-HTTP backend; the public interface stays closed.
networking.firewall.interfaces.docker_gwbridge.allowedTCPPorts = [ cfg.opencodeUiBackendPort ];
# The traefik side of the route: a swarm service carrying the router labels. Named ccci-* so
# the weekly run's orphan sweep (skills/upgrade-all/sweep-orphans.sh keep-list) leaves it be. A plain
# TCP relay (socat) from the overlay network to nginx on the docker bridge; traefik's
# X-Forwarded-For passes through untouched, which is what nginx's real_ip reads.
systemd.services.opencode-ui-route = lib.mkIf (cfg.opencodeUiTraefikNetwork != null) {
description = "swarm stack ccci-opencode-ui: traefik labels ${cfg.opencodeUiHost} -> nginx basic auth";
wantedBy = [ "multi-user.target" ];
after = [ "deploy-proxy.service" "docker.service" "nginx.service" ];
wants = [ "deploy-proxy.service" ];
path = [ pkgs.docker ];
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
script = ''
docker stack deploy --detach=true -c ${pkgs.writeText "opencode-ui-stack.yml" ''
# Deployed by opencode-ui-route.service (nix/modules/orchestrator-host.nix). Do not edit.
version: "3.8"
services:
relay:
image: alpine/socat:1.8.0.3
command: ["TCP-LISTEN:${toString cfg.opencodeUiBackendPort},fork,reuseaddr", "TCP:172.18.0.1:${toString cfg.opencodeUiBackendPort}"]
networks: [ ${cfg.opencodeUiTraefikNetwork} ]
deploy:
replicas: 1
labels:
- "traefik.enable=true"
- "traefik.http.routers.opencode-ui.rule=${lib.concatStringsSep " || " (map (h: "Host(`${h}`)") ([ cfg.opencodeUiHost ] ++ cfg.opencodeUiExtraHosts))}"
- "traefik.http.routers.opencode-ui.entrypoints=web-secure"
- "traefik.http.routers.opencode-ui.tls=true"
- "traefik.http.services.opencode-ui.loadbalancer.server.port=${toString cfg.opencodeUiBackendPort}"
networks:
${cfg.opencodeUiTraefikNetwork}:
external: true
''} ccci-opencode-ui
'';
};
};
}