@rizom/ops 0.2.0-alpha.3 → 0.2.0-alpha.300
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -3
- package/dist/age-key-bootstrap.d.ts +17 -0
- package/dist/brains-ops.js +453 -173
- package/dist/cert-bootstrap.d.ts +24 -0
- package/dist/content-repo-ref.d.ts +10 -0
- package/dist/content-repo.d.ts +13 -0
- package/dist/default-user-runner.d.ts +1 -1
- package/dist/deploy.js +99 -166
- package/dist/directory-sync-stress-system.d.ts +75 -0
- package/dist/directory-sync-stress.d.ts +107 -0
- package/dist/entries/deploy.d.ts +3 -2
- package/dist/images.d.ts +79 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +523 -152
- package/dist/legacy-pilot-migration.d.ts +11 -0
- package/dist/load-registry.d.ts +74 -7
- package/dist/observed-status.d.ts +12 -0
- package/dist/onboard-user.d.ts +2 -2
- package/dist/origin-ca.d.ts +1 -0
- package/dist/parse-args.d.ts +2 -9
- package/dist/preview-domain.d.ts +9 -0
- package/dist/push-secrets.d.ts +2 -0
- package/dist/push-target.d.ts +1 -0
- package/dist/reconcile-all.d.ts +2 -2
- package/dist/reconcile-cohort.d.ts +2 -2
- package/dist/reconcile-dry-run.d.ts +9 -0
- package/dist/reconcile-lib.d.ts +4 -2
- package/dist/run-command.d.ts +19 -4
- package/dist/run-subprocess.d.ts +1 -0
- package/dist/schema.d.ts +119 -70
- package/dist/secrets-encrypt.d.ts +26 -0
- package/dist/secrets-push.d.ts +2 -5
- package/dist/ssh-key-bootstrap.d.ts +1 -0
- package/dist/stage-legacy-crossover.d.ts +20 -0
- package/dist/stress-command.d.ts +14 -0
- package/dist/stress-git-checkout.d.ts +21 -0
- package/dist/stress-health-monitor.d.ts +46 -0
- package/dist/upgrade.d.ts +10 -0
- package/dist/user-add.d.ts +15 -0
- package/dist/user-runner.d.ts +5 -0
- package/dist/verify-user.d.ts +22 -0
- package/package.json +47 -40
- package/templates/rover-pilot/.env.schema +28 -2
- package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
- package/templates/rover-pilot/.github/workflows/build.yml +71 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +110 -45
- package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
- package/templates/rover-pilot/.github/workflows/reconcile.yml +23 -3
- package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
- package/templates/rover-pilot/README.md +20 -7
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +139 -0
- package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
- package/templates/rover-pilot/deploy/scripts/install-health-watchdog.ts +144 -0
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +15 -4
- package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +49 -14
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +183 -0
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
- package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +12 -1
- package/templates/rover-pilot/docs/canonical-crossover-record.md +104 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +39 -8
- package/templates/rover-pilot/docs/operator-playbook.md +298 -9
- package/templates/rover-pilot/docs/user-onboarding.md +90 -0
- package/templates/rover-pilot/package.json +3 -0
- package/templates/rover-pilot/pilot.yaml +5 -3
- package/templates/rover-pilot/users/alice.yaml +5 -1
- package/dist/user-secret-names.d.ts +0 -6
- package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
- package/templates/rover-pilot/deploy/Dockerfile +0 -15
- package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -39
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
name: Directory Sync Stress
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: "23 4 * * 1"
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
handle:
|
|
9
|
+
description: Smoke handle only
|
|
10
|
+
required: true
|
|
11
|
+
type: string
|
|
12
|
+
default: smoke
|
|
13
|
+
profile:
|
|
14
|
+
description: Workload profile
|
|
15
|
+
required: true
|
|
16
|
+
type: choice
|
|
17
|
+
default: regression
|
|
18
|
+
options:
|
|
19
|
+
- regression
|
|
20
|
+
- load
|
|
21
|
+
- stress
|
|
22
|
+
confirm:
|
|
23
|
+
description: Type stress:<handle> to confirm reversible content writes
|
|
24
|
+
required: true
|
|
25
|
+
type: string
|
|
26
|
+
verify_only:
|
|
27
|
+
description: Verify the content credential path without remote writes or a workload
|
|
28
|
+
required: false
|
|
29
|
+
type: boolean
|
|
30
|
+
default: false
|
|
31
|
+
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
|
|
35
|
+
concurrency:
|
|
36
|
+
group: directory-sync-stress-${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}
|
|
37
|
+
cancel-in-progress: false
|
|
38
|
+
|
|
39
|
+
jobs:
|
|
40
|
+
stress:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
timeout-minutes: 75
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v5
|
|
45
|
+
|
|
46
|
+
- uses: oven-sh/setup-bun@v2
|
|
47
|
+
|
|
48
|
+
- name: Install operator tooling
|
|
49
|
+
run: bun install
|
|
50
|
+
|
|
51
|
+
- name: Load Bitwarden environment through Varlock
|
|
52
|
+
uses: ./.github/actions/varlock-env
|
|
53
|
+
env:
|
|
54
|
+
BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
|
|
55
|
+
|
|
56
|
+
- name: Verify smoke directory-sync credential path
|
|
57
|
+
if: ${{ github.event_name != 'schedule' && inputs.verify_only }}
|
|
58
|
+
env:
|
|
59
|
+
HANDLE_INPUT: ${{ inputs.handle }}
|
|
60
|
+
CONFIRM_INPUT: ${{ inputs.confirm }}
|
|
61
|
+
run: |
|
|
62
|
+
bunx brains-ops stress:directory-sync:verify-access "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
|
|
63
|
+
--confirm "$CONFIRM_INPUT" \
|
|
64
|
+
--artifacts-dir "$RUNNER_TEMP/directory-sync-stress"
|
|
65
|
+
|
|
66
|
+
- name: Run smoke directory-sync stress profile
|
|
67
|
+
if: ${{ github.event_name == 'schedule' || !inputs.verify_only }}
|
|
68
|
+
env:
|
|
69
|
+
HANDLE_INPUT: ${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}
|
|
70
|
+
PROFILE_INPUT: ${{ github.event_name == 'schedule' && 'regression' || inputs.profile }}
|
|
71
|
+
CONFIRM_INPUT: ${{ github.event_name == 'schedule' && 'stress:smoke' || inputs.confirm }}
|
|
72
|
+
run: |
|
|
73
|
+
bunx brains-ops stress:directory-sync "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
|
|
74
|
+
--profile "$PROFILE_INPUT" \
|
|
75
|
+
--confirm "$CONFIRM_INPUT" \
|
|
76
|
+
--artifacts-dir "$RUNNER_TEMP/directory-sync-stress"
|
|
77
|
+
|
|
78
|
+
- name: Upload stress evidence
|
|
79
|
+
if: always()
|
|
80
|
+
uses: actions/upload-artifact@v4
|
|
81
|
+
with:
|
|
82
|
+
name: directory-sync-stress-${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}-${{ github.event_name == 'schedule' && 'regression' || inputs.profile }}-${{ github.run_id }}
|
|
83
|
+
path: ${{ runner.temp }}/directory-sync-stress
|
|
84
|
+
if-no-files-found: warn
|
|
85
|
+
|
|
86
|
+
cleanup:
|
|
87
|
+
needs: stress
|
|
88
|
+
if: ${{ always() && (github.event_name == 'schedule' || !inputs.verify_only) }}
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
timeout-minutes: 20
|
|
91
|
+
steps:
|
|
92
|
+
- uses: actions/checkout@v5
|
|
93
|
+
|
|
94
|
+
- uses: oven-sh/setup-bun@v2
|
|
95
|
+
|
|
96
|
+
- name: Install operator tooling
|
|
97
|
+
run: bun install
|
|
98
|
+
|
|
99
|
+
- name: Load Bitwarden environment through Varlock
|
|
100
|
+
uses: ./.github/actions/varlock-env
|
|
101
|
+
env:
|
|
102
|
+
BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
|
|
103
|
+
|
|
104
|
+
- name: Remove residual stress probes
|
|
105
|
+
env:
|
|
106
|
+
HANDLE_INPUT: ${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}
|
|
107
|
+
CONFIRM_INPUT: ${{ github.event_name == 'schedule' && 'stress:smoke' || inputs.confirm }}
|
|
108
|
+
run: |
|
|
109
|
+
bunx brains-ops stress:directory-sync:cleanup "$GITHUB_WORKSPACE" "$HANDLE_INPUT" \
|
|
110
|
+
--confirm "$CONFIRM_INPUT" \
|
|
111
|
+
--artifacts-dir "$RUNNER_TEMP/directory-sync-stress-cleanup"
|
|
112
|
+
|
|
113
|
+
- name: Upload cleanup evidence
|
|
114
|
+
if: always()
|
|
115
|
+
uses: actions/upload-artifact@v4
|
|
116
|
+
with:
|
|
117
|
+
name: directory-sync-stress-cleanup-${{ github.event_name == 'schedule' && 'smoke' || inputs.handle }}-${{ github.run_id }}
|
|
118
|
+
path: ${{ runner.temp }}/directory-sync-stress-cleanup
|
|
119
|
+
if-no-files-found: warn
|
|
@@ -35,11 +35,31 @@ jobs:
|
|
|
35
35
|
|
|
36
36
|
- name: Commit generated outputs
|
|
37
37
|
run: |
|
|
38
|
-
|
|
38
|
+
# A newly added user's generated directory is untracked, and
|
|
39
|
+
# `git diff` cannot see untracked files — without intent-to-add
|
|
40
|
+
# the new config is silently dropped here and every later deploy
|
|
41
|
+
# skips that user (their files never appear in a commit range).
|
|
42
|
+
git add --intent-to-add -- users
|
|
43
|
+
|
|
44
|
+
if git diff --quiet -- users; then
|
|
39
45
|
exit 0
|
|
40
46
|
fi
|
|
47
|
+
|
|
48
|
+
git fetch origin "${{ github.ref_name }}"
|
|
49
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- users; then
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
patch_file="$(mktemp)"
|
|
54
|
+
git diff --binary -- users > "$patch_file"
|
|
55
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
56
|
+
git apply --3way --index "$patch_file"
|
|
57
|
+
|
|
58
|
+
if git diff --cached --quiet -- users; then
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
|
|
41
62
|
git config user.name "github-actions[bot]"
|
|
42
63
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
43
|
-
git add views users
|
|
44
64
|
git commit -m "chore(ops): reconcile pilot outputs"
|
|
45
|
-
git push
|
|
65
|
+
git push origin HEAD:${{ github.ref_name }}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
name: Upgrade
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
version:
|
|
7
|
+
description: Explicit @rizom/ops version (empty means latest)
|
|
8
|
+
required: false
|
|
9
|
+
type: string
|
|
10
|
+
schedule:
|
|
11
|
+
# Weekday morning check for new operator tooling releases.
|
|
12
|
+
- cron: "17 6 * * 1-5"
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
concurrency:
|
|
18
|
+
group: upgrade
|
|
19
|
+
cancel-in-progress: false
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
upgrade:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
# Check only presence here; the private key itself is never exposed to
|
|
26
|
+
# this step. A missing private-key secret fails before checkout.
|
|
27
|
+
- name: Require upgrade App configuration
|
|
28
|
+
env:
|
|
29
|
+
APP_ID: ${{ vars.OPS_UPGRADE_APP_ID }}
|
|
30
|
+
HAS_PRIVATE_KEY: ${{ secrets.OPS_UPGRADE_APP_PRIVATE_KEY != '' }}
|
|
31
|
+
run: |
|
|
32
|
+
missing=""
|
|
33
|
+
if [ -z "$APP_ID" ]; then
|
|
34
|
+
missing="OPS_UPGRADE_APP_ID"
|
|
35
|
+
fi
|
|
36
|
+
if [ "$HAS_PRIVATE_KEY" != "true" ]; then
|
|
37
|
+
missing="${missing:+$missing and }OPS_UPGRADE_APP_PRIVATE_KEY"
|
|
38
|
+
fi
|
|
39
|
+
if [ -n "$missing" ]; then
|
|
40
|
+
echo "$missing is not set for this repository." >&2
|
|
41
|
+
echo "Configure the upgrade GitHub App as described in docs/operator-playbook.md." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# No credential is persisted in .git/config while the workflow installs
|
|
46
|
+
# and executes a freshly published @rizom/ops package.
|
|
47
|
+
- uses: actions/checkout@v5
|
|
48
|
+
with:
|
|
49
|
+
persist-credentials: false
|
|
50
|
+
|
|
51
|
+
- uses: oven-sh/setup-bun@v2
|
|
52
|
+
|
|
53
|
+
- name: Install operator tooling
|
|
54
|
+
run: bun install
|
|
55
|
+
|
|
56
|
+
- name: Upgrade operator tooling
|
|
57
|
+
env:
|
|
58
|
+
VERSION_INPUT: ${{ inputs.version }}
|
|
59
|
+
run: bunx brains-ops upgrade . --to "${VERSION_INPUT:-latest}"
|
|
60
|
+
|
|
61
|
+
- name: Detect upgrade changes
|
|
62
|
+
id: upgrade-changes
|
|
63
|
+
run: |
|
|
64
|
+
if [ -z "$(git status --porcelain)" ]; then
|
|
65
|
+
echo "Already up to date."
|
|
66
|
+
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
67
|
+
else
|
|
68
|
+
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
- name: Create repository-scoped upgrade token
|
|
72
|
+
if: steps.upgrade-changes.outputs.changed == 'true'
|
|
73
|
+
id: upgrade-token
|
|
74
|
+
uses: actions/create-github-app-token@v2
|
|
75
|
+
with:
|
|
76
|
+
app-id: ${{ vars.OPS_UPGRADE_APP_ID }}
|
|
77
|
+
private-key: ${{ secrets.OPS_UPGRADE_APP_PRIVATE_KEY }}
|
|
78
|
+
permission-contents: write
|
|
79
|
+
permission-pull-requests: write
|
|
80
|
+
permission-workflows: write
|
|
81
|
+
|
|
82
|
+
- name: Open upgrade PR
|
|
83
|
+
if: steps.upgrade-changes.outputs.changed == 'true'
|
|
84
|
+
env:
|
|
85
|
+
GH_TOKEN: ${{ steps.upgrade-token.outputs.token }}
|
|
86
|
+
run: |
|
|
87
|
+
VERSION=$(bun -e 'console.log(JSON.parse(await Bun.file("package.json").text()).devDependencies["@rizom/ops"])')
|
|
88
|
+
BRANCH="ops/upgrade-rizom-ops-$VERSION"
|
|
89
|
+
git config user.name "github-actions[bot]"
|
|
90
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
91
|
+
git checkout -B "$BRANCH"
|
|
92
|
+
git add -A
|
|
93
|
+
git commit -m "chore(ops): upgrade @rizom/ops to $VERSION"
|
|
94
|
+
git push --force \
|
|
95
|
+
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
|
|
96
|
+
"$BRANCH"
|
|
97
|
+
if [ -n "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
|
|
98
|
+
echo "PR already open for $BRANCH"
|
|
99
|
+
exit 0
|
|
100
|
+
fi
|
|
101
|
+
gh pr create \
|
|
102
|
+
--title "chore(ops): upgrade @rizom/ops to $VERSION" \
|
|
103
|
+
--body "Automated operator tooling upgrade. Review the scaffold diff, then merge; deploys stay operator-dispatched." \
|
|
104
|
+
--head "$BRANCH"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# private brain pilot
|
|
2
2
|
|
|
3
|
-
Private desired-state
|
|
3
|
+
Private desired-state repository for the hosted brain pilot. The generated repository/service name may remain `rover-pilot` for deployment identity compatibility.
|
|
4
4
|
|
|
5
5
|
This is a single operator-owned repo. Pilot users do not get their own brain repos.
|
|
6
6
|
Per-user deploy config lives under `users/<handle>/`, while content stays in per-user content repos.
|
|
@@ -29,15 +29,28 @@ The repo also checks in its deploy contract:
|
|
|
29
29
|
- `.github/workflows/*`
|
|
30
30
|
|
|
31
31
|
`.env.schema` is the single source of truth for required and sensitive deploy vars.
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
Use separate GitHub tokens: `CONTENT_REPO_ADMIN_TOKEN` for operator-side content repo creation/checks, and `GIT_SYNC_TOKEN` for runtime directory-sync git access.
|
|
33
|
+
The default pilot image tag is `brain-${brainVersion}` end to end. A user with `siteOverride` gets an isolated `brain-${brainVersion}-sites-${packageHash}` image instead.
|
|
34
|
+
When the effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI rebuilds the required default/site tags, refreshes generated user env files, and redeploys affected users. Every external site and theme package uses its own required exact version pin; ops never infers either version from the brain or from another package.
|
|
34
35
|
When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
|
|
35
36
|
|
|
36
37
|
## Commands
|
|
37
38
|
|
|
38
39
|
- `brains-ops init <repo>`
|
|
39
|
-
- `brains-ops
|
|
40
|
-
- `brains-ops
|
|
41
|
-
- `brains-ops
|
|
40
|
+
- `brains-ops upgrade <repo>` — bumps `@rizom/ops` (or `--to <version>`) and refreshes the generated scaffold; the Upgrade workflow runs this on a schedule and opens a PR
|
|
41
|
+
- `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health/ready`, and unauthenticated `/mcp` status checks
|
|
42
|
+
- `brains-ops user:add <repo> <handle> --cohort <cohort>` — scaffolds a user file, per-user secrets template, and cohort membership
|
|
43
|
+
- `brains-ops onboard <repo> <handle>` — creates/seeds the user's content repo with separate admin and sync tokens
|
|
44
|
+
- `brains-ops age-key:bootstrap <repo>`
|
|
45
|
+
- `brains-ops ssh-key:bootstrap <repo>`
|
|
46
|
+
- `brains-ops cert:bootstrap <repo>`
|
|
47
|
+
- `brains-ops secrets:encrypt <repo> <handle>`
|
|
42
48
|
- `brains-ops reconcile-cohort <repo> <cohort>`
|
|
43
49
|
- `brains-ops reconcile-all <repo>`
|
|
50
|
+
- `brains-ops reconcile-all <repo> --dry-run` (isolated, no external content-repository access; lists both passes' changed files)
|
|
51
|
+
|
|
52
|
+
`render` owns the observational `views/users.md` projection. Reconcile owns generated per-user config and never rewrites observed status rows.
|
|
53
|
+
|
|
54
|
+
The Upgrade workflow uses a repository-scoped GitHub App token because an Actions `GITHUB_TOKEN` cannot update generated files under `.github/workflows/`. Checkout persists no credential, and the App token is minted only after the freshly published tooling finishes and only when there is a change to push. Configure the App and its `OPS_UPGRADE_APP_ID` Actions variable and `OPS_UPGRADE_APP_PRIVATE_KEY` Actions secret as described in `docs/operator-playbook.md`. If token creation or the upgrade push fails, stop; do not substitute an operator's personal credentials.
|
|
55
|
+
|
|
56
|
+
Use `docs/canonical-crossover-record.md` to record exact forward and rollback artifact pins before an approved crossover window.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { Decrypter, armor } from "age-encryption";
|
|
4
|
+
|
|
5
|
+
import { requireEnv, writeGitHubEnv, writeGitHubOutput } from "./helpers";
|
|
6
|
+
|
|
7
|
+
const handle = process.argv[2] ?? requireEnv("HANDLE");
|
|
8
|
+
const ageSecretKey = extractAgeIdentity(requireEnv("AGE_SECRET_KEY"));
|
|
9
|
+
const encryptedPath = `users/${handle}.secrets.yaml.age`;
|
|
10
|
+
|
|
11
|
+
const armored = readFileSync(encryptedPath, "utf8");
|
|
12
|
+
const decoded = armor.decode(armored);
|
|
13
|
+
|
|
14
|
+
const decrypter = new Decrypter();
|
|
15
|
+
decrypter.addIdentity(ageSecretKey);
|
|
16
|
+
|
|
17
|
+
const plaintext = await decrypter.decrypt(decoded, "text");
|
|
18
|
+
const secrets = parseStringYamlMapping(plaintext);
|
|
19
|
+
const pilot = parseStringYamlMapping(readFileSync("pilot.yaml", "utf8"));
|
|
20
|
+
|
|
21
|
+
writeSecretGitHubEnv("AI_API_KEY", secrets["aiApiKey"]);
|
|
22
|
+
writeSecretGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"]);
|
|
23
|
+
writeSecretGitHubEnv(
|
|
24
|
+
"CMS_CONTENT_REPO_PAT",
|
|
25
|
+
secrets["cmsContentRepoPat"] ?? secrets["gitSyncToken"],
|
|
26
|
+
);
|
|
27
|
+
writeSecretGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"]);
|
|
28
|
+
writeSecretGitHubEnv("DISCORD_PUBLIC_KEY", secrets["discordPublicKey"]);
|
|
29
|
+
writeSecretGitHubEnv("DISCORD_APPLICATION_ID", secrets["discordApplicationId"]);
|
|
30
|
+
// Per-user AT Protocol publishing credential (optional; from the user's
|
|
31
|
+
// encrypted secrets file). The scaffold wires this so a pilot can publish its
|
|
32
|
+
// brain's agent card to its PDS; a deployment that doesn't publish simply
|
|
33
|
+
// leaves it unset.
|
|
34
|
+
writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
|
|
35
|
+
// Per-user custom-domain TLS overrides the shared fleet certificate when a
|
|
36
|
+
// complete certificate/key pair is present in the encrypted user secrets.
|
|
37
|
+
const certificatePem = decodeEscapedSecret(secrets["certificatePem"]);
|
|
38
|
+
const privateKeyPem = decodeEscapedSecret(secrets["privateKeyPem"]);
|
|
39
|
+
if (Boolean(certificatePem) !== Boolean(privateKeyPem)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Custom-domain TLS secrets require both certificatePem and privateKeyPem",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
// A corrupted stored value would otherwise only surface as a kamal-proxy
|
|
45
|
+
// "unable to load certificate" failure after the container has already booted.
|
|
46
|
+
assertPemShape("certificatePem", certificatePem);
|
|
47
|
+
assertPemShape("privateKeyPem", privateKeyPem);
|
|
48
|
+
writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
|
|
49
|
+
writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
|
|
50
|
+
|
|
51
|
+
function assertPemShape(name: string, value: string | undefined): void {
|
|
52
|
+
if (value === undefined || value.trim().length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
!/-----BEGIN [A-Z0-9 ]+-----/.test(value) ||
|
|
57
|
+
!/-----END [A-Z0-9 ]+-----/.test(value)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Secret ${name} is not PEM-shaped after unescaping; the stored value is corrupt`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
writeGitHubOutput(
|
|
66
|
+
"shared_ai_api_key_secret_name",
|
|
67
|
+
requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
|
|
68
|
+
);
|
|
69
|
+
writeGitHubOutput(
|
|
70
|
+
"shared_git_sync_token_secret_name",
|
|
71
|
+
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
72
|
+
);
|
|
73
|
+
writeGitHubOutput(
|
|
74
|
+
"shared_content_repo_admin_token_secret_name",
|
|
75
|
+
requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function writeSecretGitHubEnv(name: string, value: string | undefined): void {
|
|
79
|
+
if (!value || value.trim().length === 0) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
maskGitHubSecret(value);
|
|
84
|
+
writeGitHubEnv(name, value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function maskGitHubSecret(value: string): void {
|
|
88
|
+
const escaped = value
|
|
89
|
+
.replace(/%/g, "%25")
|
|
90
|
+
.replace(/\r/g, "%0D")
|
|
91
|
+
.replace(/\n/g, "%0A");
|
|
92
|
+
if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
93
|
+
console.log(`::add-mask::${escaped}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractAgeIdentity(contents: string): string {
|
|
98
|
+
const line = contents
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.map((entry) => entry.trim())
|
|
101
|
+
.find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
|
|
102
|
+
|
|
103
|
+
if (!line) {
|
|
104
|
+
throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return line;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseStringYamlMapping(contents: string): Record<string, string> {
|
|
111
|
+
const parsed: unknown = Bun.YAML.parse(contents);
|
|
112
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
113
|
+
throw new Error("Expected a YAML mapping");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const result: Record<string, string> = {};
|
|
117
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
result[key] = value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function decodeEscapedSecret(value: string | undefined): string | undefined {
|
|
126
|
+
return value?.replace(/\\n/g, "\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function requireFlatValue(
|
|
130
|
+
values: Record<string, string>,
|
|
131
|
+
key: string,
|
|
132
|
+
label: string,
|
|
133
|
+
): string {
|
|
134
|
+
const value = values[key];
|
|
135
|
+
if (!value) {
|
|
136
|
+
throw new Error(`Missing ${key} in ${label}`);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { requireEnv } from "./helpers";
|
|
2
|
+
|
|
3
|
+
const WATCHDOG_PATH = "/usr/local/sbin/brains-health-watchdog";
|
|
4
|
+
const SERVICE_PATH = "/etc/systemd/system/brains-health-watchdog.service";
|
|
5
|
+
const TIMER_PATH = "/etc/systemd/system/brains-health-watchdog.timer";
|
|
6
|
+
|
|
7
|
+
export const BRAIN_WATCHDOG_LABEL = "ai.rizom.brain.watchdog";
|
|
8
|
+
export const BRAIN_WATCHDOG_LABEL_VALUE = "true";
|
|
9
|
+
export const BRAIN_WATCHDOG_LABEL_FILTER: string = `${BRAIN_WATCHDOG_LABEL}=${BRAIN_WATCHDOG_LABEL_VALUE}`;
|
|
10
|
+
|
|
11
|
+
export const healthWatchdogScript: string = `#!/usr/bin/env bash
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
umask 077
|
|
14
|
+
|
|
15
|
+
lock_path="\${BRAIN_WATCHDOG_LOCK_PATH:-/run/brains-health-watchdog.lock}"
|
|
16
|
+
exec 9>"$lock_path"
|
|
17
|
+
flock -n 9 || exit 0
|
|
18
|
+
|
|
19
|
+
incident_dir="\${BRAIN_WATCHDOG_INCIDENT_DIR:-/var/log/brains-health-watchdog}"
|
|
20
|
+
state_dir="\${BRAIN_WATCHDOG_STATE_DIR:-/var/lib/brains-health-watchdog}"
|
|
21
|
+
MAX_RESTARTS=3
|
|
22
|
+
WINDOW_SECONDS=3600
|
|
23
|
+
mkdir -p "$incident_dir" "$state_dir"
|
|
24
|
+
|
|
25
|
+
write_safe_inspect() {
|
|
26
|
+
docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"state":{{json .State}},"restartPolicy":{{json .HostConfig.RestartPolicy}}}' "$1"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
while read -r container_id; do
|
|
30
|
+
[ -n "$container_id" ] || continue
|
|
31
|
+
service_name=$(docker inspect --format '{{ index .Config.Labels "service" }}' "$container_id" 2>/dev/null || true)
|
|
32
|
+
container_name=$(docker inspect --format '{{ .Name }}' "$container_id" 2>/dev/null | tr -d '/' || true)
|
|
33
|
+
state_key=$(printf '%s-%s-%s' "$service_name" "$container_name" "$container_id" | tr -c 'A-Za-z0-9_.-' '_')
|
|
34
|
+
state_path="$state_dir/$state_key.restarts"
|
|
35
|
+
recent_path="$state_path.tmp"
|
|
36
|
+
now_epoch=$(date -u +%s)
|
|
37
|
+
cutoff_epoch=$((now_epoch - WINDOW_SECONDS))
|
|
38
|
+
touch "$state_path"
|
|
39
|
+
awk -v cutoff="$cutoff_epoch" '$1 >= cutoff' "$state_path" >"$recent_path"
|
|
40
|
+
restart_count=$(wc -l <"$recent_path")
|
|
41
|
+
|
|
42
|
+
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
|
43
|
+
incident_path="$incident_dir/$timestamp-$state_key.log"
|
|
44
|
+
temporary_path="$incident_path.tmp"
|
|
45
|
+
|
|
46
|
+
if [ "$restart_count" -ge "$MAX_RESTARTS" ]; then
|
|
47
|
+
{
|
|
48
|
+
printf '{"timestamp":"%s","event":"restart-suppressed","reason":"restart-budget-exhausted","service":"%s","container":"%s","restartsInWindow":%s}\\n' "$timestamp" "$service_name" "$container_name" "$restart_count"
|
|
49
|
+
write_safe_inspect "$container_id"
|
|
50
|
+
docker logs --timestamps --tail 1000 "$container_id"
|
|
51
|
+
} >"$temporary_path" 2>&1
|
|
52
|
+
mv "$temporary_path" "$incident_path"
|
|
53
|
+
mv "$recent_path" "$state_path"
|
|
54
|
+
logger -t brains-health-watchdog "restart budget exhausted container=$container_name service=$service_name incident=$incident_path"
|
|
55
|
+
continue
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
printf '{"timestamp":"%s","event":"container-restart","reason":"docker-health-unhealthy","service":"%s","container":"%s"}\\n' "$timestamp" "$service_name" "$container_name"
|
|
60
|
+
write_safe_inspect "$container_id"
|
|
61
|
+
docker logs --timestamps --tail 1000 "$container_id"
|
|
62
|
+
} >"$temporary_path" 2>&1
|
|
63
|
+
mv "$temporary_path" "$incident_path"
|
|
64
|
+
printf '%s\\n' "$now_epoch" >>"$recent_path"
|
|
65
|
+
mv "$recent_path" "$state_path"
|
|
66
|
+
|
|
67
|
+
logger -t brains-health-watchdog "restarting unhealthy container=$container_name service=$service_name incident=$incident_path"
|
|
68
|
+
docker restart "$container_id"
|
|
69
|
+
done < <(docker ps --filter health=unhealthy --filter label=${BRAIN_WATCHDOG_LABEL_FILTER} --format '{{.ID}}')
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
export const healthWatchdogServiceUnit: string = `[Unit]
|
|
73
|
+
Description=Restart persistently unhealthy Brain containers
|
|
74
|
+
After=docker.service
|
|
75
|
+
Requires=docker.service
|
|
76
|
+
|
|
77
|
+
[Service]
|
|
78
|
+
Type=oneshot
|
|
79
|
+
ExecStart=${WATCHDOG_PATH}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
export const healthWatchdogTimerUnit: string = `[Unit]
|
|
83
|
+
Description=Monitor Brain container liveness
|
|
84
|
+
|
|
85
|
+
[Timer]
|
|
86
|
+
OnBootSec=2min
|
|
87
|
+
OnUnitActiveSec=30s
|
|
88
|
+
RandomizedDelaySec=5s
|
|
89
|
+
Persistent=true
|
|
90
|
+
Unit=brains-health-watchdog.service
|
|
91
|
+
|
|
92
|
+
[Install]
|
|
93
|
+
WantedBy=timers.target
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
function installCommand(path: string, content: string, mode: string): string {
|
|
97
|
+
const encoded = Buffer.from(content, "utf8").toString("base64");
|
|
98
|
+
return `printf '%s' '${encoded}' | $SUDO base64 --decode | $SUDO tee '${path}' >/dev/null\n$SUDO chmod ${mode} '${path}'`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildHealthWatchdogInstallScript(): string {
|
|
102
|
+
return `#!/usr/bin/env bash
|
|
103
|
+
set -euo pipefail
|
|
104
|
+
SUDO=""
|
|
105
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
106
|
+
SUDO=sudo
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
${installCommand(WATCHDOG_PATH, healthWatchdogScript, "0755")}
|
|
110
|
+
${installCommand(SERVICE_PATH, healthWatchdogServiceUnit, "0644")}
|
|
111
|
+
${installCommand(TIMER_PATH, healthWatchdogTimerUnit, "0644")}
|
|
112
|
+
|
|
113
|
+
$SUDO systemctl daemon-reload
|
|
114
|
+
$SUDO systemctl enable --now brains-health-watchdog.timer
|
|
115
|
+
|
|
116
|
+
labelled_containers=""
|
|
117
|
+
if ! labelled_containers=$($SUDO docker ps --filter label=${BRAIN_WATCHDOG_LABEL_FILTER} --format '{{.ID}}' 2>/dev/null); then
|
|
118
|
+
echo "Warning: unable to verify running Brain containers with label ${BRAIN_WATCHDOG_LABEL_FILTER}." >&2
|
|
119
|
+
elif [ -z "$labelled_containers" ]; then
|
|
120
|
+
echo "Warning: no running Brain containers found with label ${BRAIN_WATCHDOG_LABEL_FILTER}." >&2
|
|
121
|
+
fi
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function installHealthWatchdog(): Promise<void> {
|
|
126
|
+
const serverIp = requireEnv("SERVER_IP");
|
|
127
|
+
const sshUser = requireEnv("SSH_USER").trim();
|
|
128
|
+
if (!sshUser) throw new Error("Missing SSH_USER");
|
|
129
|
+
const child = Bun.spawn(["ssh", `${sshUser}@${serverIp}`, "bash -s"], {
|
|
130
|
+
stdin: new Blob([buildHealthWatchdogInstallScript()]),
|
|
131
|
+
stdout: "inherit",
|
|
132
|
+
stderr: "inherit",
|
|
133
|
+
});
|
|
134
|
+
const exitCode = await child.exited;
|
|
135
|
+
if (exitCode !== 0) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Health watchdog installation failed with exit ${exitCode}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (import.meta.main) {
|
|
143
|
+
await installHealthWatchdog();
|
|
144
|
+
}
|
|
@@ -101,7 +101,7 @@ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
|
|
|
101
101
|
server = await getServer(server.id);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const serverIp = server.public_net
|
|
104
|
+
const serverIp = server.public_net.ipv4.ip;
|
|
105
105
|
if (!serverIp) {
|
|
106
106
|
throw new Error(`Server ${server.id} running but has no IPv4 address`);
|
|
107
107
|
}
|
|
@@ -9,14 +9,23 @@ if (eventName === "workflow_dispatch") {
|
|
|
9
9
|
process.exit(0);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
if (eventName !== "push") {
|
|
12
|
+
if (eventName !== "push" && eventName !== "workflow_run") {
|
|
13
13
|
throw new Error(`Unsupported GITHUB_EVENT_NAME: ${eventName}`);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const beforeSha = requireEnv("BEFORE_SHA");
|
|
17
|
-
const currentSha =
|
|
17
|
+
const currentSha =
|
|
18
|
+
eventName === "workflow_run"
|
|
19
|
+
? execFileSync("git", ["rev-parse", "HEAD"], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
}).trim()
|
|
22
|
+
: requireEnv("GITHUB_SHA");
|
|
18
23
|
|
|
19
|
-
if (
|
|
24
|
+
if (
|
|
25
|
+
!isUsableGitRevision(beforeSha) ||
|
|
26
|
+
!isUsableGitRevision(currentSha) ||
|
|
27
|
+
beforeSha === currentSha
|
|
28
|
+
) {
|
|
20
29
|
writeGitHubOutput("handles_json", JSON.stringify([]));
|
|
21
30
|
process.exit(0);
|
|
22
31
|
}
|
|
@@ -32,7 +41,9 @@ const handles = [
|
|
|
32
41
|
diffOutput
|
|
33
42
|
.split(/\r?\n/)
|
|
34
43
|
.map((path) => {
|
|
35
|
-
const match =
|
|
44
|
+
const match =
|
|
45
|
+
path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
|
|
46
|
+
path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
|
|
36
47
|
return match?.[1] ?? null;
|
|
37
48
|
})
|
|
38
49
|
.filter((handle): handle is string => handle !== null)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireEnv,
|
|
3
|
+
runResolveMissingImages,
|
|
4
|
+
writeGitHubOutput,
|
|
5
|
+
} from "./helpers";
|
|
6
|
+
|
|
7
|
+
// The Build workflow's resolve step — the logic lives in @rizom/ops; this
|
|
8
|
+
// pilot repo only supplies its own paths and repository.
|
|
9
|
+
await runResolveMissingImages({
|
|
10
|
+
rootDir: process.cwd(),
|
|
11
|
+
imageRepository: `ghcr.io/${requireEnv("GITHUB_REPOSITORY")}`,
|
|
12
|
+
writeOutput: writeGitHubOutput,
|
|
13
|
+
});
|