2026-07-10 20:40:07 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
|
|
|
|
|
|
|
|
|
usage() {
|
|
|
|
|
cat <<'EOF'
|
2026-07-11 08:25:48 +00:00
|
|
|
usage: rig <command> [args]
|
2026-07-10 20:40:07 +00:00
|
|
|
|
|
|
|
|
commands:
|
2026-07-17 19:15:06 +00:00
|
|
|
bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
|
|
|
|
|
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
|
|
|
|
|
[--join <authkey|login>]
|
2026-07-10 20:40:07 +00:00
|
|
|
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
|
2026-07-17 19:15:06 +00:00
|
|
|
tailscale join. Roles are presets over the three traits; any flag
|
|
|
|
|
overrides its trait, and custom states all of them. Prompts for a
|
|
|
|
|
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
|
|
|
|
|
prompt); the key's tags are the tailnet tag, verified after join —
|
|
|
|
|
only control-plane and workload may carry tag:server. join=login
|
|
|
|
|
(workstation) needs no key: interactive login, node must come up
|
|
|
|
|
untagged. Run as root.
|
2026-07-10 20:40:07 +00:00
|
|
|
coolify install --version <pin>
|
|
|
|
|
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
feat(coolify): install the control-plane dump as a systemd timer
The Coolify control-plane database holds the GitHub App private key, every
registered server's SSH key, and every environment value for every environment
it manages. Backing it up was a manual runbook step, and the dump script lived
in cast — the off-box tool, whose src never references it. It runs on the box,
as root, under a scheduler: that is rig's job description.
It matters beyond tidiness. The dump is forensics, not a restore path — a lost
control plane is rebuilt fresh and reconciled from the manifest. So there will
be a next control-plane box, and as a runbook step it was born un-backed-up,
depending on someone remembering mid-incident. Now it is backed up from birth.
rig installs the machinery and templates /etc/coolify-dump.env empty at 0600,
never reading it back — no credential passes through rig. The script's own
guards make an unfilled file fail the unit loudly rather than ship plaintext.
systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets,
failures surface in systemctl status instead of being mailed into the void, and
Persistent=true catches a run missed while the box was down.
Two hazards the cast script missed, carried into the unit:
- aws-cli >= 2.23 enables default upload checksums that S3-compatible backends
reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to
when_required.
- A failed pg_dump piped into age still yields a valid, tiny, encrypted file
that uploads cleanly every night and looks exactly like a working backup. The
script now refuses to upload an empty artifact.
Closes #8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
|
|
|
coolify backup install [options]
|
|
|
|
|
Nightly age-encrypted dump of the control-plane database, as a
|
|
|
|
|
systemd timer. rig installs the machinery and templates an empty
|
|
|
|
|
0600 bindings file; you fill in the age recipient and S3 details.
|
|
|
|
|
Control-plane box only. Run as root.
|
feat(db): bring ad-hoc dump/restore on-box as `rig db`
Add `rig db dump <container> [outfile]` and
`rig db restore <artifact> <container> [db] [--yes]` — imperative on-box
PostgreSQL tooling, the interactive counterpart to the scheduled,
declarative `coolify backup install`.
Key decisions:
- Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner
--no-acl` is mandatory for cross-instance restores: the target's
superuser differs (Coolify randomizes it), so a plain dump aborts under
ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role.
- $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted
`sh -c`), never hardcoded to `postgres` on the host.
- restore connects as the container's own superuser and runs with
ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a
shared container, passed in via a container env var rather than string
splicing.
- restore overwrites the target, so it prompts y/N; --yes/--force is the
automation bypass. Artifact existence/non-emptiness is checked before
the confirm gate and before anything touches the DB.
- dump uses pipefail + a sibling temp promoted only on success, and
refuses to keep an empty artifact — a failed pg_dump must never leave a
plausible-looking .gz behind.
Args are validated before the root check (testable without root); guards
are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and
a `### rig db` README section.
Closes #15
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:16:35 +00:00
|
|
|
db <dump|restore> ...
|
|
|
|
|
Ad-hoc PostgreSQL dump/restore for a container on this box. `dump`
|
|
|
|
|
writes a gzipped SQL artifact (--no-owner --no-acl, so it restores
|
|
|
|
|
onto a different instance); `restore` loads one back, connecting as
|
|
|
|
|
the container's own superuser, behind a confirm gate. Run as root.
|
feat(runner): status, remove, and repoint — the runner lifecycle verbs
runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.
- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
reusing the binary already on the box.
The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.
repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.
Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
2026-07-13 13:25:27 +00:00
|
|
|
runner install --repo <owner/repo> [options]
|
2026-07-11 17:41:09 +00:00
|
|
|
GitHub Actions runner as a systemd service under an unprivileged
|
|
|
|
|
user — outbound-only, no Docker. Prompts for the short-lived
|
|
|
|
|
registration token (RUNNER_TOKEN env overrides). Run as root.
|
feat(runner): status, remove, and repoint — the runner lifecycle verbs
runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.
- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
reusing the binary already on the box.
The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.
repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.
Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
2026-07-13 13:25:27 +00:00
|
|
|
runner status [--user <name>]
|
|
|
|
|
What this box's runner is registered to: repo, name, labels, unit.
|
|
|
|
|
Reads the box only — no token, no network call. Run as root.
|
|
|
|
|
runner remove [--local] [--user <name>]
|
|
|
|
|
Take the service down and deregister the runner. Prompts for the
|
|
|
|
|
short-lived removal token (RUNNER_REMOVE_TOKEN env overrides).
|
|
|
|
|
Run as root.
|
|
|
|
|
runner repoint --repo <owner/repo> [options]
|
|
|
|
|
Move an installed runner to another repository — deregister, then
|
|
|
|
|
re-register, reusing the binary already on the box. Needs a removal
|
|
|
|
|
token for the old repo and a registration token for the new one.
|
|
|
|
|
Run as root.
|
feat(users): declarative operators — apply/status over a users file, every class
Operators become a declared fact, not an accumulation of adduser runs: a
line-based, bash-parseable users file (no YAML, no jq — a rig box has
neither) names each user, their roles, and their keys, and apply converges
the box to exactly that. Roles map to groups (admin→rig-admin with full
NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no
sudo — box's setup-host owns Incus, rig only asserts the group). Every
password stays locked always; the SSH key at the door is the
authentication. A user dropped from the file is found via the /etc/rig/users
ledger and locked, never deleted — deleting frees the uid and rots
attribution. The sudoers drop-in lands only after visudo -c passes, because
a bad file under sudoers.d takes down all of sudo. Class never gates apply
(#26: a shared root login is unattributable, so operators belong on every
class); the marker only colors what root SSH does next. The whole file is
validated in one pass before the root check, every error named with its
line, so refusals are provable in the non-root harness through the sourced
parser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
|
|
|
users apply --file <path>
|
|
|
|
|
Converge named operator accounts from a declarative users file, on
|
|
|
|
|
every class: groups by role (admin/rig/box), passwords locked always,
|
|
|
|
|
authorized_keys made exact, visudo-gated sudoers rules. Users dropped
|
|
|
|
|
from the file are locked, never deleted. '-' reads stdin. Run as root.
|
|
|
|
|
users status
|
|
|
|
|
Roles (derived from actual group membership), key counts and lock
|
|
|
|
|
state for the rig-managed users. Reads the box only. Run as root.
|
|
|
|
|
users close-root
|
|
|
|
|
Shut root SSH on a class=human box once an admin key works. Refuses
|
|
|
|
|
on class=server — root there is the control plane's automation door —
|
|
|
|
|
and while no admin holds a key. Run as root.
|
2026-07-10 20:40:07 +00:00
|
|
|
|
|
|
|
|
install/upgrade:
|
2026-07-11 08:25:48 +00:00
|
|
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
2026-07-10 20:40:07 +00:00
|
|
|
EOF
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd="${1:-}"
|
|
|
|
|
case "$cmd" in
|
|
|
|
|
bootstrap)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/bootstrap.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
coolify)
|
|
|
|
|
shift
|
|
|
|
|
sub="${1:-}"
|
feat(coolify): install the control-plane dump as a systemd timer
The Coolify control-plane database holds the GitHub App private key, every
registered server's SSH key, and every environment value for every environment
it manages. Backing it up was a manual runbook step, and the dump script lived
in cast — the off-box tool, whose src never references it. It runs on the box,
as root, under a scheduler: that is rig's job description.
It matters beyond tidiness. The dump is forensics, not a restore path — a lost
control plane is rebuilt fresh and reconciled from the manifest. So there will
be a next control-plane box, and as a runbook step it was born un-backed-up,
depending on someone remembering mid-incident. Now it is backed up from birth.
rig installs the machinery and templates /etc/coolify-dump.env empty at 0600,
never reading it back — no credential passes through rig. The script's own
guards make an unfilled file fail the unit loudly rather than ship plaintext.
systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets,
failures surface in systemctl status instead of being mailed into the void, and
Persistent=true catches a run missed while the box was down.
Two hazards the cast script missed, carried into the unit:
- aws-cli >= 2.23 enables default upload checksums that S3-compatible backends
reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to
when_required.
- A failed pg_dump piped into age still yields a valid, tiny, encrypted file
that uploads cleanly every night and looks exactly like a working backup. The
script now refuses to upload an empty artifact.
Closes #8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
|
|
|
case "$sub" in
|
|
|
|
|
install)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/coolify-install.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
backup)
|
|
|
|
|
shift
|
|
|
|
|
if [ "${1:-}" != "install" ]; then
|
|
|
|
|
usage >&2
|
|
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/coolify-backup-install.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
usage >&2
|
|
|
|
|
exit 2
|
|
|
|
|
;;
|
|
|
|
|
esac
|
2026-07-10 20:40:07 +00:00
|
|
|
;;
|
feat(db): bring ad-hoc dump/restore on-box as `rig db`
Add `rig db dump <container> [outfile]` and
`rig db restore <artifact> <container> [db] [--yes]` — imperative on-box
PostgreSQL tooling, the interactive counterpart to the scheduled,
declarative `coolify backup install`.
Key decisions:
- Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner
--no-acl` is mandatory for cross-instance restores: the target's
superuser differs (Coolify randomizes it), so a plain dump aborts under
ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role.
- $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted
`sh -c`), never hardcoded to `postgres` on the host.
- restore connects as the container's own superuser and runs with
ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a
shared container, passed in via a container env var rather than string
splicing.
- restore overwrites the target, so it prompts y/N; --yes/--force is the
automation bypass. Artifact existence/non-emptiness is checked before
the confirm gate and before anything touches the DB.
- dump uses pipefail + a sibling temp promoted only on success, and
refuses to keep an empty artifact — a failed pg_dump must never leave a
plausible-looking .gz behind.
Args are validated before the root check (testable without root); guards
are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and
a `### rig db` README section.
Closes #15
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:16:35 +00:00
|
|
|
db)
|
|
|
|
|
shift
|
|
|
|
|
case "${1:-}" in
|
|
|
|
|
dump|restore|-h|--help)
|
|
|
|
|
exec "$ROOT/commands/db.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
usage >&2
|
|
|
|
|
exit 2
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
;;
|
2026-07-11 17:41:09 +00:00
|
|
|
runner)
|
|
|
|
|
shift
|
|
|
|
|
sub="${1:-}"
|
feat(runner): status, remove, and repoint — the runner lifecycle verbs
runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.
- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
reusing the binary already on the box.
The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.
repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.
Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
2026-07-13 13:25:27 +00:00
|
|
|
case "$sub" in
|
|
|
|
|
install)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/runner-install.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
status)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/runner-status.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
remove)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/runner-remove.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
repoint)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/runner-repoint.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
usage >&2
|
|
|
|
|
exit 2
|
|
|
|
|
;;
|
|
|
|
|
esac
|
2026-07-11 17:41:09 +00:00
|
|
|
;;
|
feat(users): declarative operators — apply/status over a users file, every class
Operators become a declared fact, not an accumulation of adduser runs: a
line-based, bash-parseable users file (no YAML, no jq — a rig box has
neither) names each user, their roles, and their keys, and apply converges
the box to exactly that. Roles map to groups (admin→rig-admin with full
NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no
sudo — box's setup-host owns Incus, rig only asserts the group). Every
password stays locked always; the SSH key at the door is the
authentication. A user dropped from the file is found via the /etc/rig/users
ledger and locked, never deleted — deleting frees the uid and rots
attribution. The sudoers drop-in lands only after visudo -c passes, because
a bad file under sudoers.d takes down all of sudo. Class never gates apply
(#26: a shared root login is unattributable, so operators belong on every
class); the marker only colors what root SSH does next. The whole file is
validated in one pass before the root check, every error named with its
line, so refusals are provable in the non-root harness through the sourced
parser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
|
|
|
users)
|
|
|
|
|
shift
|
|
|
|
|
sub="${1:-}"
|
|
|
|
|
case "$sub" in
|
|
|
|
|
apply)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/users-apply.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
status)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/users-status.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
close-root)
|
|
|
|
|
shift
|
|
|
|
|
exec "$ROOT/commands/users-close-root.sh" "$@"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
usage >&2
|
|
|
|
|
exit 2
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
;;
|
2026-07-10 20:40:07 +00:00
|
|
|
-h|--help|help)
|
|
|
|
|
usage
|
|
|
|
|
exit 0
|
|
|
|
|
;;
|
|
|
|
|
"")
|
|
|
|
|
usage >&2
|
|
|
|
|
exit 2
|
|
|
|
|
;;
|
|
|
|
|
*)
|
2026-07-11 08:25:48 +00:00
|
|
|
printf 'rig: unknown command: %s\n' "$cmd" >&2
|
2026-07-10 20:40:07 +00:00
|
|
|
usage >&2
|
|
|
|
|
exit 2
|
|
|
|
|
;;
|
|
|
|
|
esac
|