Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: npm test
8 changes: 4 additions & 4 deletions .github/workflows/ssh-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ jobs:
cancel-in-progress: false
steps:
- name: Checkout central deployment tools, target registry and pinned SSH hosts
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: profullstack/deploy
ref: main
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .pfdeploy
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: GitHub OIDC to OpenBao to ephemeral SSH certificate to deployed app
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@

Deploy in batches, **3 at a time**, so one box never builds everything at once.

On 2026-10-19 a change rolled out to 38 repos at once on our production box. Every repo installed, tested, type-checked and built together, then every merge's deploy built again; the box ran out of memory and the kernel killed the shared production Postgres. This package makes that impossible from both ends:
On 2026-10-19 a change rolled out to 38 repos at once on our production box. Every repo installed, tested, type-checked and built together, then every merge's deploy built again; the box ran out of memory and the kernel killed the shared production Postgres. When every production deployment entry point is wired through this package, two controls limit this failure mode:

- **`deploy-gate`** (bash + `flock`, on the box): every deploy takes one of N slots before it builds. Merges can land all at once; builds queue and run three at a time.
- **`deploy-gate`** (bash + `flock`, on the box): every wired deploy takes one of N slots before it builds. Merges can land all at once; builds queue and run three at a time. A deploy path that bypasses the gate is not covered.
- **`pfdeploy batch`** (and `batch()`, where the rollout is driven): run one command across many repos, three at a time, stopping at the first failure.

Zero dependencies. Linux (`flock`, `/proc/meminfo`); the batch runner works anywhere Node 18+ does.

## Fleet-wide deployments without per-site SSH keys

**One workflow, one centrally managed SSH certificate authority, zero permanent private deployment keys in app repositories.** On every merge to `main` or `master`, the repository's existing tests/typechecks/ThreatCrush scan run, then the same shared workflow deploys the actual commit. GitHub's signed OIDC assertion authenticates the repo to OpenBao, which returns a five-minute SSH certificate for that repo's restricted account. Target hosts and health endpoints live in `fleet/targets.json` keyed by stable GitHub repository ID; the single signing URL lives in `fleet/config.json`. `fleet/properties.txt` contains the currently supplied property inventory, pending verification of repo/host associations.
**One workflow, one centrally managed SSH certificate authority, zero permanent private deployment keys in app repositories.** Each app must retain its own tests, type checks, builds, and ThreatCrush scan, and make the deploy job depend on all of them. The shared workflow cannot verify those caller-side dependencies; protect the deployment branch with required checks and review rules as well. The workflow deploys only the push SHA on `main` or `master`. GitHub's signed OIDC assertion authenticates the repo to OpenBao, which issues a short-lived SSH certificate for that repo's restricted account. Target hosts and health endpoints live in `fleet/targets.json` keyed by stable GitHub repository ID; the single signing URL lives in `fleet/config.json`. `fleet/properties.txt` contains the currently supplied property inventory, pending verification of repo/host associations.

See **[Full SSH CA rollout and provisioning guide](docs/fleet-ssh-ca.md)**. The new workflow at `.github/workflows/ssh-deploy.yml` is disabled for an app until its central target entry is explicitly `enabled` and OpenBao + the host trust have been bootstrapped. The existing deployment gate remains in place. **No live app deployment is claimed by this code alone.**

## On the box: the gate

```sh
npx @profullstack/deploy install /usr/local/bin # as root: one copy every user's deploys share
install -m 0755 bin/setup-deploy-gate /usr/local/sbin/setup-deploy-gate
sudo /usr/local/sbin/setup-deploy-gate pfdeploy pfdeploy-1423083790 # root: provision three shared slots and group access
deploy-gate --name crawlproof -- ./deploy-app.sh "$SHA"
deploy-gate status
```

Provision the gate before creating an SSH deployment account or allowing it to deploy. The setup command creates a root-owned sticky directory, three root-owned group-writable slot files, and a `systemd-tmpfiles` rule so the locks return safely after reboot. Add every per-repository deploy account on that host as an argument. It refuses to adopt an unsafe existing directory; drain deployments and inspect old lock state before replacing an earlier world-writable setup. New SSH sessions pick up the shared group membership.

```
slot 0 busy crawlproof pid 41233 since 2026-10-19T13:20:02Z
slot 1 busy smshub pid 41290 since 2026-10-19T13:20:09Z
Expand All @@ -33,22 +37,24 @@ slot 2 free
How it decides:

1. Take one of `DEPLOY_SLOTS` (3) lock files in `/run/lock/profullstack-deploy` (shared by root and every user). All taken: wait, saying who holds them every 30 s.
2. Wait up to `DEPLOY_MEM_WAIT` (600 s) for `DEPLOY_MIN_FREE_GB` (8) of available memory, then go on.
2. Wait up to `DEPLOY_MEM_WAIT` (600 s) for `DEPLOY_MIN_FREE_GB` (8) of available memory, then fail with exit 75 if the host is still below the floor.
3. Run the deploy with the lock descriptor closed, so a server it starts never keeps the slot. Signals are forwarded; the exit code is the deploy's.
4. Still no slot after `DEPLOY_WAIT` (3600 s): exit 75 (try again), or run anyway with `DEPLOY_GATE_ON_TIMEOUT=run`.
4. Still no slot after `DEPLOY_WAIT` (3600 s): exit 75 (try again). Missing or unsafe shared lock files also fail closed; they never start an ungated deploy.

A deploy already inside the gate (`DEPLOY_GATED=1`) passes straight through, so a gated script that calls another gated script never waits on itself. Without `flock` the gate runs the deploy ungated rather than blocking it.
A deploy already inside the gate passes straight through, so a gated script that calls another gated script never waits on itself. The SSH receiver clears inherited `DEPLOY_GATED` values before entering the gate.

### Wiring a deploy script

Put this right after `set -euo pipefail`; if the gate is not installed the script runs exactly as before:
Put this right after `set -euo pipefail`. A deploy script wired to the shared gate fails closed if the gate has not been installed:

```sh
# Three deploys at a time on this box (@profullstack/deploy).
if [ -z "${DEPLOY_GATED:-}" ]; then
for gate in /usr/local/bin/deploy-gate "$HOME/.local/bin/deploy-gate"; do
[ -x "$gate" ] && exec "$gate" --name "<site>" -- "$0" "$@"
done
echo "deploy-gate is not installed; refusing to deploy ungated" >&2
exit 75
fi
```

Expand Down
114 changes: 91 additions & 23 deletions bin/deploy-gate
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
# the production database (dev2, 2026-10-19).
#
# The command runs with the lock's descriptor closed, so a daemon it starts never
# holds the slot. A deploy already inside the gate (DEPLOY_GATED=1) passes
# straight through, so nested scripts never wait on themselves.
# holds the slot. A deploy already inside the gate passes straight through, so
# nested scripts never wait on themselves. The default lock directory and its
# root-owned files must be provisioned before deploy users are admitted.
#
# Environment (flags win): DEPLOY_SLOTS=3, DEPLOY_WAIT=3600 (seconds for a slot;
# then exit 75, or run anyway with DEPLOY_GATE_ON_TIMEOUT=run), DEPLOY_MIN_FREE_GB=8,
# DEPLOY_MEM_WAIT=600 (seconds to wait for memory, then go on), DEPLOY_POLL=5, DEPLOY_LOCK_DIR.
# Environment (flags win): DEPLOY_SLOTS=3, DEPLOY_WAIT=3600, DEPLOY_MIN_FREE_GB=8,
# DEPLOY_MEM_WAIT=600 (seconds before failing for low memory), DEPLOY_POLL=5,
# DEPLOY_LOCK_DIR (test/custom-user mode only; default host path is root provisioned).
set -uo pipefail

SLOTS=${DEPLOY_SLOTS:-3}
Expand All @@ -26,29 +27,65 @@ MIN_FREE_GB=${DEPLOY_MIN_FREE_GB:-8}
MEM_WAIT=${DEPLOY_MEM_WAIT:-600}
POLL=${DEPLOY_POLL:-5}
LOCK_DIR=${DEPLOY_LOCK_DIR:-/run/lock/profullstack-deploy}
DEFAULT_LOCK_DIR=/run/lock/profullstack-deploy
NAME=""

say() { printf '[deploy-gate] %s\n' "$*" >&2; }
usage() { sed -n '4,6p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 64; }

lock_dir() {
if [ ! -d "$LOCK_DIR" ]; then
(umask 000; mkdir -p "$LOCK_DIR") 2>/dev/null || { say "cannot make $LOCK_DIR"; return 1; }
chmod 1777 "$LOCK_DIR" 2>/dev/null || true
local owner group mode
[ -d "$LOCK_DIR" ] && [ ! -L "$LOCK_DIR" ] || { say "$LOCK_DIR must be a pre-provisioned, non-symlink directory"; return 1; }
owner=$(stat -c %u -- "$LOCK_DIR" 2>/dev/null) || return 1
group=$(stat -c %g -- "$LOCK_DIR" 2>/dev/null) || return 1
mode=$(stat -c %a -- "$LOCK_DIR" 2>/dev/null) || return 1
if [ "$LOCK_DIR" = "$DEFAULT_LOCK_DIR" ]; then
[ "$owner" = 0 ] && [ "$group" != 0 ] && [ "$mode" = 3770 ] || {
say "$LOCK_DIR must be root-owned, group-shared, sticky, and mode 3770"
return 1
}
else
[ "$owner" = "$(id -u)" ] || { say "custom lock directory must be owned by this user"; return 1; }
[ "$mode" = 700 ] || { say "custom lock directory must have mode 700"; return 1; }
fi
}

slot_file() { printf '%s/slot-%s' "$LOCK_DIR" "$1"; }

slot_file_ready() {
local f="$1" owner group mode dir_group
[ -f "$f" ] && [ ! -L "$f" ] || { say "$(basename "$f") must be a pre-provisioned regular file"; return 1; }
owner=$(stat -c %u -- "$f" 2>/dev/null) || return 1
group=$(stat -c %g -- "$f" 2>/dev/null) || return 1
mode=$(stat -c %a -- "$f" 2>/dev/null) || return 1
if [ "$LOCK_DIR" = "$DEFAULT_LOCK_DIR" ]; then
dir_group=$(stat -c %g -- "$LOCK_DIR" 2>/dev/null) || return 1
[ "$owner" = 0 ] && [ "$group" = "$dir_group" ] && [ "$mode" = 660 ] || {
say "$(basename "$f") must be root-owned by the lock group and mode 660"
return 1
}
else
[ "$owner" = "$(id -u)" ] && [ "$mode" = 600 ] || {
say "custom slot files must be owned by this user and mode 600"
return 1
}
fi
}

validate_slots() {
local i
for ((i = 0; i < SLOTS; i++)); do slot_file_ready "$(slot_file "$i")" || return 1; done
}

# The holder line written into a slot: who, since when.
holder() { head -c 200 "$(slot_file "$1")" 2>/dev/null | head -1; }

status() {
lock_dir || exit 1
command -v flock >/dev/null || { say "flock is unavailable"; exit 1; }
lock_dir && validate_slots || exit 1
local i f busy=0
for ((i = 0; i < SLOTS; i++)); do
f=$(slot_file "$i")
[ -e "$f" ] || (umask 000; : > "$f")
if flock -n "$f" true 2>/dev/null; then
printf 'slot %d free\n' "$i"
else
Expand All @@ -61,9 +98,12 @@ status() {

free_gb() { awk '/^MemAvailable:/ { printf "%d", $2 / 1048576 }' /proc/meminfo 2>/dev/null || echo 0; }

[ "${1:-}" = status ] && { shift; [ "${1:-}" = --slots ] && SLOTS=$2; status; exit 0; }
[ "${1:-}" = status ] && { shift; [ "${1:-}" = --slots ] && { [ "$#" -ge 2 ] || usage; SLOTS=$2; }; status; exit 0; }

while [ $# -gt 0 ]; do
case "$1" in
--slots|--name|--wait|--min-free-gb) [ "$#" -ge 2 ] || usage ;;
esac
case "$1" in
--slots) SLOTS=$2; shift 2 ;;
--name) NAME=$2; shift 2 ;;
Expand All @@ -76,15 +116,44 @@ while [ $# -gt 0 ]; do
done
[ $# -gt 0 ] || usage
case "$SLOTS" in ''|*[!0-9]*|0) say "--slots must be a positive number"; exit 64 ;; esac
SLOTS=$((10#$SLOTS))
[ "$SLOTS" -le 256 ] || { say "--slots must not exceed 256"; exit 64; }
for value in "$WAIT" "$MIN_FREE_GB" "$MEM_WAIT"; do
[[ "$value" =~ ^[0-9]+$ ]] || { say "wait and memory limits must be non-negative integers"; exit 64; }
done
[[ "$POLL" =~ ^[0-9]+([.][0-9]+)?$ ]] || { say "poll interval must be a non-negative number"; exit 64; }
WAIT=$((10#$WAIT)); MIN_FREE_GB=$((10#$MIN_FREE_GB)); MEM_WAIT=$((10#$MEM_WAIT))

already_gated() {
local target="${DEPLOY_GATED:-}" pid parent command
[[ "$target" =~ ^[1-9][0-9]*$ ]] || return 1
pid=$$
while [[ "$pid" =~ ^[1-9][0-9]*$ ]]; do
if [ "$pid" = "$target" ]; then
command=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)
[[ "$command" == *deploy-gate* ]]
return
fi
parent=$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)
[ -n "$parent" ] || return 1
pid=$parent
done
return 1
}

# Already inside the gate: never take a second slot.
if [ "${DEPLOY_GATED:-}" = 1 ]; then exec "$@"; fi
if already_gated; then exec "$@"; fi
unset DEPLOY_GATED
NAME=${NAME:-$(basename "$PWD")}
export DEPLOY_GATED=1
export DEPLOY_GATED=$$

if ! command -v flock >/dev/null || ! lock_dir; then
say "no flock or lock dir: running ungated"
exec "$@"
if ! command -v flock >/dev/null; then
say "flock is unavailable; refusing to run ungated"
exit 75
fi
if ! lock_dir || ! validate_slots; then
say "shared deployment locks are unavailable; refusing to run ungated"
exit 75
fi

# Take a slot. Start at a random slot so waiters spread out; poll every DEPLOY_POLL (5) s.
Expand All @@ -96,7 +165,6 @@ while :; do
for ((n = 0; n < SLOTS; n++)); do
i=$(((first + n) % SLOTS))
f=$(slot_file "$i")
[ -e "$f" ] || (umask 000; : > "$f") 2>/dev/null
{ exec {fd}<>"$f"; } 2>/dev/null || continue
if flock -n "$fd"; then
slot=$i
Expand All @@ -107,10 +175,6 @@ while :; do
done
now=$(date +%s)
if [ $((now - start)) -ge "$WAIT" ]; then
if [ "${DEPLOY_GATE_ON_TIMEOUT:-fail}" = run ]; then
say "waited ${WAIT}s for a slot; running anyway (DEPLOY_GATE_ON_TIMEOUT=run)"
exec "$@"
fi
say "waited ${WAIT}s for one of $SLOTS deploy slots; giving up (exit 75). Busy: $(for ((i = 0; i < SLOTS; i++)); do printf '%s; ' "$(holder "$i")"; done)"
exit 75
fi
Expand All @@ -130,15 +194,19 @@ say "$NAME took deploy slot $slot of $SLOTS$([ "$waited" -gt 0 ] && printf ' aft
mem_start=$(date +%s)
while [ "$(free_gb)" -lt "$MIN_FREE_GB" ]; do
if [ $(($(date +%s) - mem_start)) -ge "$MEM_WAIT" ]; then
say "only $(free_gb) GB available after ${MEM_WAIT}s; going on"
break
say "only $(free_gb) GB available after ${MEM_WAIT}s; refusing to start a build"
: > "$(slot_file "$slot")" 2>/dev/null || true
exec {fd}>&-
exit 75
fi
say "$(free_gb) GB available, want $MIN_FREE_GB; waiting"
sleep 15
done

# Run with the lock descriptor closed, forwarding signals, and release when it ends.
child=""
# Signal traps invoke this handler by name.
# shellcheck disable=SC2329
forward() { [ -n "$child" ] && kill "-$1" "$child" 2>/dev/null; }
trap 'forward TERM' TERM
trap 'forward INT' INT
Expand Down
12 changes: 8 additions & 4 deletions bin/enroll-repo-openbao.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
# Central operator enrollment. One OpenBao role and one policy per GitHub repo,
# automatically generated; NEVER one long-lived SSH key per repo.
# Run only from an authenticated OpenBao administrator workstation.
# Usage: ./bin/enroll-repo-openbao.sh OWNER/REPO GITHUB_REPO_ID main|master
# Usage: ./bin/enroll-repo-openbao.sh OWNER/REPO GITHUB_REPO_ID main|master WORKFLOW_SHA
set -euo pipefail

repo="${1:-}"
id="${2:-}"
branch="${3:-}"
workflow_sha="${4:-}"
[[ "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || { echo "invalid owner/repo" >&2; exit 2; }
[[ "$id" =~ ^[1-9][0-9]{0,19}$ ]] || { echo "GitHub repo ID must be numeric" >&2; exit 2; }
[[ "$branch" = main || "$branch" = master ]] || { echo "branch must be main or master" >&2; exit 2; }
[[ "$workflow_sha" =~ ^[a-f0-9]{40}$ ]] || { echo "workflow SHA must be a full lowercase commit SHA" >&2; exit 2; }
command -v bao >/dev/null || { echo "Install the OpenBao CLI first" >&2; exit 2; }

policy="fleet-deploy-repo-$id"
workflow_ref="profullstack/deploy/.github/workflows/ssh-deploy.yml@$workflow_sha"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp" <<EOF
Expand All @@ -24,7 +27,7 @@ EOF
bao policy write "$policy" "$tmp"

# Bound to THIS repo identity, this protected branch, GitHub push event,
# and only the centrally maintained reusable workflow (not an arbitrary Action).
# and only the reviewed immutable central reusable workflow commit.
bao write "auth/github-actions/role/repo-$id" - <<EOF
{
"role_type": "jwt",
Expand All @@ -36,7 +39,8 @@ bao write "auth/github-actions/role/repo-$id" - <<EOF
"repository_id": ["$id"],
"ref": ["refs/heads/$branch"],
"event_name": ["push"],
"job_workflow_ref": ["profullstack/deploy/.github/workflows/ssh-deploy.yml@refs/heads/main"]
"job_workflow_ref": ["$workflow_ref"],
"job_workflow_sha": ["$workflow_sha"]
},
"token_policies": ["$policy"],
"token_no_default_policy": true,
Expand All @@ -59,5 +63,5 @@ bao write "ssh-client-signer/roles/repo-$id" - <<EOF
"allowed_critical_options": ""
}
EOF
echo "Enrolled $repo@$branch: OpenBao roles repo-$id, principal pfdeploy-$id"
echo "Enrolled $repo@$branch with workflow $workflow_sha: OpenBao roles repo-$id, principal pfdeploy-$id"
echo "Next: enroll pfdeploy-$id on its target host and trust the central USER CA public key."
Loading
Loading