@rizom/ops 0.2.0-alpha.2 → 0.2.0-alpha.200
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 +9 -3
- package/dist/age-key-bootstrap.d.ts +17 -0
- package/dist/brains-ops.js +288 -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/entries/deploy.d.ts +3 -2
- package/dist/images.d.ts +76 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +289 -173
- package/dist/load-registry.d.ts +38 -3
- 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 +4 -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-lib.d.ts +4 -2
- package/dist/run-command.d.ts +8 -2
- package/dist/run-subprocess.d.ts +1 -0
- package/dist/schema.d.ts +68 -64
- package/dist/secrets-encrypt.d.ts +24 -0
- package/dist/secrets-push.d.ts +2 -5
- package/dist/ssh-key-bootstrap.d.ts +1 -0
- package/dist/user-add.d.ts +15 -0
- package/dist/user-runner.d.ts +5 -0
- package/dist/verify-user.d.ts +19 -0
- package/package.json +44 -40
- package/templates/rover-pilot/.env.schema +21 -2
- package/templates/rover-pilot/.github/workflows/build.yml +65 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +150 -45
- package/templates/rover-pilot/.github/workflows/reconcile.yml +22 -2
- package/templates/rover-pilot/README.md +8 -3
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +119 -0
- package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -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 +68 -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 +1 -1
- package/templates/rover-pilot/docs/onboarding-checklist.md +43 -8
- package/templates/rover-pilot/docs/operator-playbook.md +182 -0
- package/templates/rover-pilot/docs/user-onboarding.md +119 -0
- package/templates/rover-pilot/package.json +3 -0
- package/templates/rover-pilot/pilot.yaml +3 -0
- 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
|
@@ -2,10 +2,24 @@ name: Build
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
brain_version:
|
|
7
|
+
description: Force one explicit build at this brain version (skips the registry-derived resolve)
|
|
8
|
+
required: false
|
|
9
|
+
type: string
|
|
10
|
+
site_packages:
|
|
11
|
+
description: 'Space-separated site packages for a per-instance site image (e.g. "@rizom/site-rizom-ai@0.2.0-alpha.148"). Empty builds the default fleet image.'
|
|
12
|
+
required: false
|
|
13
|
+
type: string
|
|
5
14
|
push:
|
|
6
15
|
branches: ["main"]
|
|
7
16
|
paths:
|
|
17
|
+
# The image set is a pure function of the declared fleet state: a push
|
|
18
|
+
# that changes it builds exactly what it declares, so the deploy (fired
|
|
19
|
+
# off the same push via Reconcile) finds its tag ready.
|
|
8
20
|
- pilot.yaml
|
|
21
|
+
- cohorts/**
|
|
22
|
+
- users/*.yaml
|
|
9
23
|
- deploy/**
|
|
10
24
|
- .github/workflows/build.yml
|
|
11
25
|
|
|
@@ -14,49 +28,83 @@ permissions:
|
|
|
14
28
|
packages: write
|
|
15
29
|
|
|
16
30
|
jobs:
|
|
17
|
-
|
|
31
|
+
resolve:
|
|
18
32
|
runs-on: ubuntu-latest
|
|
33
|
+
outputs:
|
|
34
|
+
images_json: ${{ steps.resolve.outputs.images_json }}
|
|
19
35
|
steps:
|
|
20
36
|
- uses: actions/checkout@v5
|
|
21
37
|
with:
|
|
22
38
|
ref: ${{ github.sha }}
|
|
23
39
|
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
- uses: oven-sh/setup-bun@v2
|
|
41
|
+
|
|
42
|
+
- name: Install operator tooling
|
|
43
|
+
run: bun install
|
|
44
|
+
|
|
45
|
+
- name: Log in to GHCR
|
|
46
|
+
uses: docker/login-action@v4
|
|
47
|
+
with:
|
|
48
|
+
registry: ghcr.io
|
|
49
|
+
username: ${{ github.actor }}
|
|
50
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
51
|
+
|
|
52
|
+
- name: Resolve images to build
|
|
53
|
+
id: resolve
|
|
54
|
+
env:
|
|
55
|
+
BRAIN_VERSION_INPUT: ${{ inputs.brain_version || '' }}
|
|
56
|
+
SITE_PACKAGES_INPUT: ${{ inputs.site_packages || '' }}
|
|
57
|
+
run: bun deploy/scripts/resolve-missing-images.ts
|
|
58
|
+
|
|
59
|
+
no_builds:
|
|
60
|
+
needs: resolve
|
|
61
|
+
if: needs.resolve.outputs.images_json == '[]'
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
steps:
|
|
64
|
+
- name: Skip when every declared image exists
|
|
65
|
+
run: echo "All declared images already exist; nothing to build."
|
|
66
|
+
|
|
67
|
+
build:
|
|
68
|
+
needs: resolve
|
|
69
|
+
if: needs.resolve.outputs.images_json != '[]'
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
strategy:
|
|
72
|
+
fail-fast: false
|
|
73
|
+
matrix:
|
|
74
|
+
image: ${{ fromJson(needs.resolve.outputs.images_json) }}
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/checkout@v5
|
|
77
|
+
with:
|
|
78
|
+
ref: ${{ github.sha }}
|
|
33
79
|
|
|
34
80
|
- name: Set up Docker Buildx
|
|
35
|
-
uses: docker/setup-buildx-action@
|
|
81
|
+
uses: docker/setup-buildx-action@v4
|
|
36
82
|
|
|
37
83
|
- name: Extract image metadata
|
|
38
84
|
id: meta
|
|
39
|
-
uses: docker/metadata-action@
|
|
85
|
+
uses: docker/metadata-action@v6
|
|
40
86
|
with:
|
|
41
|
-
images:
|
|
87
|
+
images: ghcr.io/${{ github.repository }}
|
|
42
88
|
tags: |
|
|
43
|
-
type=raw,value
|
|
89
|
+
type=raw,value=${{ matrix.image.tag }}
|
|
44
90
|
|
|
45
91
|
- name: Log in to GHCR
|
|
46
|
-
uses: docker/login-action@
|
|
92
|
+
uses: docker/login-action@v4
|
|
47
93
|
with:
|
|
48
94
|
registry: ghcr.io
|
|
49
95
|
username: ${{ github.actor }}
|
|
50
96
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
51
97
|
|
|
52
98
|
- name: Build and push image
|
|
53
|
-
uses: docker/build-push-action@
|
|
99
|
+
uses: docker/build-push-action@v7
|
|
54
100
|
with:
|
|
55
101
|
context: .
|
|
56
102
|
file: deploy/Dockerfile
|
|
103
|
+
target: fleet
|
|
57
104
|
push: true
|
|
58
105
|
build-args: |
|
|
59
|
-
BRAIN_VERSION=${{
|
|
106
|
+
BRAIN_VERSION=${{ matrix.image.brain_version }}
|
|
107
|
+
SITE_PACKAGES=${{ matrix.image.site_packages }}
|
|
60
108
|
tags: ${{ steps.meta.outputs.tags }}
|
|
61
109
|
labels: |
|
|
62
110
|
${{ steps.meta.outputs.labels }}
|
|
@@ -7,13 +7,24 @@ on:
|
|
|
7
7
|
description: User handle
|
|
8
8
|
required: true
|
|
9
9
|
type: string
|
|
10
|
+
release_stale_lock:
|
|
11
|
+
description: Release an operator-confirmed stale Kamal deploy lock before retrying
|
|
12
|
+
required: false
|
|
13
|
+
type: boolean
|
|
14
|
+
default: false
|
|
10
15
|
push:
|
|
11
16
|
branches: ["main"]
|
|
12
17
|
paths:
|
|
13
18
|
- users/*/.env
|
|
14
19
|
- users/*/brain.yaml
|
|
20
|
+
- users/*/content/**
|
|
21
|
+
- users/*.secrets.yaml.age
|
|
15
22
|
- deploy/**
|
|
16
23
|
- .github/workflows/deploy.yml
|
|
24
|
+
workflow_run:
|
|
25
|
+
workflows: [Reconcile]
|
|
26
|
+
types: [completed]
|
|
27
|
+
branches: [main]
|
|
17
28
|
|
|
18
29
|
permissions:
|
|
19
30
|
contents: write
|
|
@@ -21,13 +32,16 @@ permissions:
|
|
|
21
32
|
|
|
22
33
|
jobs:
|
|
23
34
|
resolve_handles:
|
|
35
|
+
if: >
|
|
36
|
+
github.event_name != 'workflow_run' ||
|
|
37
|
+
github.event.workflow_run.conclusion == 'success'
|
|
24
38
|
runs-on: ubuntu-latest
|
|
25
39
|
outputs:
|
|
26
40
|
handles_json: ${{ steps.resolve.outputs.handles_json }}
|
|
27
41
|
steps:
|
|
28
42
|
- uses: actions/checkout@v5
|
|
29
43
|
with:
|
|
30
|
-
ref: ${{ github.sha }}
|
|
44
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
31
45
|
fetch-depth: 0
|
|
32
46
|
|
|
33
47
|
- uses: oven-sh/setup-bun@v2
|
|
@@ -39,7 +53,7 @@ jobs:
|
|
|
39
53
|
id: resolve
|
|
40
54
|
env:
|
|
41
55
|
HANDLE_INPUT: ${{ inputs.handle || '' }}
|
|
42
|
-
BEFORE_SHA: ${{ github.event.before || '' }}
|
|
56
|
+
BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
|
|
43
57
|
run: bun deploy/scripts/resolve-deploy-handles.ts
|
|
44
58
|
|
|
45
59
|
no_changes:
|
|
@@ -66,13 +80,72 @@ jobs:
|
|
|
66
80
|
steps:
|
|
67
81
|
- uses: actions/checkout@v5
|
|
68
82
|
with:
|
|
69
|
-
ref: ${{ github.sha }}
|
|
83
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
70
84
|
|
|
71
85
|
- uses: oven-sh/setup-bun@v2
|
|
72
86
|
|
|
73
87
|
- name: Install operator tooling
|
|
74
88
|
run: bun install
|
|
75
89
|
|
|
90
|
+
- name: Load shared env via varlock
|
|
91
|
+
env:
|
|
92
|
+
BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
|
|
93
|
+
run: |
|
|
94
|
+
for attempt in 1 2 3; do
|
|
95
|
+
if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
|
|
96
|
+
break
|
|
97
|
+
fi
|
|
98
|
+
if [ "$attempt" = "3" ]; then
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
101
|
+
echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
|
|
102
|
+
sleep 5
|
|
103
|
+
done
|
|
104
|
+
node <<'NODE'
|
|
105
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
106
|
+
const env = JSON.parse(readFileSync('/tmp/varlock-env.json', 'utf8'));
|
|
107
|
+
const githubEnvPath = process.env.GITHUB_ENV;
|
|
108
|
+
if (!githubEnvPath) {
|
|
109
|
+
throw new Error('Missing GITHUB_ENV');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const newline = String.fromCharCode(10);
|
|
113
|
+
const carriageReturn = String.fromCharCode(13);
|
|
114
|
+
const chunks = [];
|
|
115
|
+
for (const [key, value] of Object.entries(env)) {
|
|
116
|
+
if (key === 'BWS_ACCESS_TOKEN' || value === null || value === undefined) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const text = String(value)
|
|
121
|
+
.split(carriageReturn + newline)
|
|
122
|
+
.join(newline);
|
|
123
|
+
const escapedMask = text
|
|
124
|
+
.replace(/%/g, '%25')
|
|
125
|
+
.replace(/\r/g, '%0D')
|
|
126
|
+
.replace(/\n/g, '%0A');
|
|
127
|
+
if (escapedMask.length > 0) {
|
|
128
|
+
process.stdout.write('::add-mask::' + escapedMask + newline);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (text.includes(newline)) {
|
|
132
|
+
let delimiter = `VARLOCK_${key}_EOF`;
|
|
133
|
+
while (text.includes(delimiter)) {
|
|
134
|
+
delimiter += '_X';
|
|
135
|
+
}
|
|
136
|
+
chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
|
|
137
|
+
} else {
|
|
138
|
+
chunks.push(`${key}=${text}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
appendFileSync(githubEnvPath, chunks.join(newline) + newline);
|
|
143
|
+
NODE
|
|
144
|
+
|
|
145
|
+
- name: Decrypt user secrets
|
|
146
|
+
id: user_secrets
|
|
147
|
+
run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
|
|
148
|
+
|
|
76
149
|
- name: Reconcile selected user config
|
|
77
150
|
run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
|
|
78
151
|
|
|
@@ -81,23 +154,13 @@ jobs:
|
|
|
81
154
|
run: bun deploy/scripts/resolve-user-config.ts
|
|
82
155
|
|
|
83
156
|
- name: Validate selected secrets
|
|
84
|
-
env:
|
|
85
|
-
AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
|
|
86
|
-
GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
|
|
87
|
-
MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
|
|
88
|
-
DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
|
|
89
|
-
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
|
|
90
|
-
HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
|
|
91
|
-
HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
|
|
92
|
-
HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
|
|
93
|
-
KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
|
|
94
|
-
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
95
|
-
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
96
|
-
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
97
|
-
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
98
|
-
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
99
157
|
run: bun deploy/scripts/validate-secrets.ts
|
|
100
158
|
|
|
159
|
+
- name: Seed content repo
|
|
160
|
+
env:
|
|
161
|
+
CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
|
|
162
|
+
run: bun deploy/scripts/sync-content-repo.ts
|
|
163
|
+
|
|
101
164
|
- name: Log in to GHCR
|
|
102
165
|
uses: docker/login-action@v3
|
|
103
166
|
with:
|
|
@@ -105,15 +168,18 @@ jobs:
|
|
|
105
168
|
username: ${{ github.actor }}
|
|
106
169
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
107
170
|
|
|
108
|
-
- name: Wait for
|
|
171
|
+
- name: Wait for image tag
|
|
109
172
|
run: |
|
|
110
|
-
|
|
111
|
-
|
|
173
|
+
# The Build workflow fires off the same config push (it derives the
|
|
174
|
+
# declared image set from the registry), so this wait must cover a
|
|
175
|
+
# full image build, not just registry propagation.
|
|
176
|
+
IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
|
|
177
|
+
for attempt in $(seq 1 60); do
|
|
112
178
|
if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
|
|
113
179
|
exit 0
|
|
114
180
|
fi
|
|
115
|
-
echo "
|
|
116
|
-
sleep
|
|
181
|
+
echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
|
|
182
|
+
sleep 20
|
|
117
183
|
done
|
|
118
184
|
echo "Timed out waiting for $IMAGE_TAG" >&2
|
|
119
185
|
exit 1
|
|
@@ -124,8 +190,6 @@ jobs:
|
|
|
124
190
|
ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
|
|
125
191
|
|
|
126
192
|
- name: Write Kamal SSH key
|
|
127
|
-
env:
|
|
128
|
-
KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
|
|
129
193
|
run: bun deploy/scripts/write-ssh-key.ts
|
|
130
194
|
|
|
131
195
|
- name: Configure SSH client
|
|
@@ -142,33 +206,27 @@ jobs:
|
|
|
142
206
|
chmod 600 ~/.ssh/config
|
|
143
207
|
|
|
144
208
|
- name: Write .kamal/secrets
|
|
145
|
-
env:
|
|
146
|
-
AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
|
|
147
|
-
GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
|
|
148
|
-
MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
|
|
149
|
-
DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
|
|
150
|
-
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
151
|
-
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
152
|
-
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
153
209
|
run: bun deploy/scripts/write-kamal-secrets.ts
|
|
154
210
|
|
|
155
211
|
- name: Provision server
|
|
156
212
|
id: provision
|
|
157
213
|
env:
|
|
158
|
-
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
|
|
159
|
-
HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
|
|
160
|
-
HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
|
|
161
|
-
HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
|
|
162
214
|
INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
|
|
163
215
|
run: bun deploy/scripts/provision-server.ts
|
|
164
216
|
|
|
165
217
|
- name: Update Cloudflare DNS
|
|
166
218
|
env:
|
|
167
|
-
|
|
168
|
-
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
219
|
+
CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
|
|
169
220
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
221
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
222
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
170
223
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
171
|
-
run:
|
|
224
|
+
run: |
|
|
225
|
+
bun deploy/scripts/update-dns.ts
|
|
226
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
227
|
+
BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
228
|
+
fi
|
|
229
|
+
BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
172
230
|
|
|
173
231
|
- name: Validate SSH key
|
|
174
232
|
run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
|
|
@@ -188,17 +246,43 @@ jobs:
|
|
|
188
246
|
echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
|
|
189
247
|
exit 1
|
|
190
248
|
|
|
249
|
+
- name: Release operator-confirmed stale deploy lock
|
|
250
|
+
if: ${{ inputs.release_stale_lock }}
|
|
251
|
+
env:
|
|
252
|
+
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
253
|
+
VERSION: ${{ steps.user_config.outputs.image_tag }}
|
|
254
|
+
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
255
|
+
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
256
|
+
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
257
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
258
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
259
|
+
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
260
|
+
run: kamal lock release -c deploy/kamal/deploy.yml
|
|
261
|
+
|
|
191
262
|
- name: Deploy
|
|
192
263
|
env:
|
|
193
264
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
194
|
-
VERSION:
|
|
265
|
+
VERSION: ${{ steps.user_config.outputs.image_tag }}
|
|
266
|
+
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
267
|
+
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
268
|
+
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
269
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
270
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
271
|
+
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
195
272
|
run: kamal setup --skip-push -c deploy/kamal/deploy.yml
|
|
196
273
|
|
|
197
274
|
- name: Verify origin TLS
|
|
198
275
|
env:
|
|
199
276
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
200
277
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
201
|
-
|
|
278
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
279
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
280
|
+
run: |
|
|
281
|
+
curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
|
|
282
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
283
|
+
curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
|
|
284
|
+
fi
|
|
285
|
+
curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
|
|
202
286
|
|
|
203
287
|
- name: Upload generated config
|
|
204
288
|
uses: actions/upload-artifact@v4
|
|
@@ -207,6 +291,7 @@ jobs:
|
|
|
207
291
|
path: |
|
|
208
292
|
users/${{ matrix.handle }}/brain.yaml
|
|
209
293
|
users/${{ matrix.handle }}/.env
|
|
294
|
+
users/${{ matrix.handle }}/content
|
|
210
295
|
|
|
211
296
|
- name: Dump remote proxy diagnostics
|
|
212
297
|
if: failure()
|
|
@@ -233,7 +318,7 @@ jobs:
|
|
|
233
318
|
steps:
|
|
234
319
|
- uses: actions/checkout@v5
|
|
235
320
|
with:
|
|
236
|
-
ref: ${{ github.sha }}
|
|
321
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
237
322
|
|
|
238
323
|
- uses: oven-sh/setup-bun@v2
|
|
239
324
|
|
|
@@ -252,11 +337,31 @@ jobs:
|
|
|
252
337
|
|
|
253
338
|
- name: Commit generated config
|
|
254
339
|
run: |
|
|
340
|
+
# A newly added user's generated directory is untracked, and
|
|
341
|
+
# `git diff` cannot see untracked files — without intent-to-add
|
|
342
|
+
# the new config is silently dropped here and every later deploy
|
|
343
|
+
# skips that user (their files never appear in a commit range).
|
|
344
|
+
git add --intent-to-add -- users views
|
|
345
|
+
|
|
255
346
|
if git diff --quiet -- users views; then
|
|
256
347
|
exit 0
|
|
257
348
|
fi
|
|
349
|
+
|
|
350
|
+
git fetch origin "${{ github.ref_name }}"
|
|
351
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
|
|
352
|
+
exit 0
|
|
353
|
+
fi
|
|
354
|
+
|
|
355
|
+
patch_file="$(mktemp)"
|
|
356
|
+
git diff --binary -- users views > "$patch_file"
|
|
357
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
358
|
+
git apply --3way --index "$patch_file"
|
|
359
|
+
|
|
360
|
+
if git diff --cached --quiet -- users views; then
|
|
361
|
+
exit 0
|
|
362
|
+
fi
|
|
363
|
+
|
|
258
364
|
git config user.name "github-actions[bot]"
|
|
259
365
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
260
|
-
git add users views
|
|
261
366
|
git commit -m "chore(ops): reconcile generated config"
|
|
262
|
-
git push
|
|
367
|
+
git push origin HEAD:${{ github.ref_name }}
|
|
@@ -35,11 +35,31 @@ jobs:
|
|
|
35
35
|
|
|
36
36
|
- name: Commit generated outputs
|
|
37
37
|
run: |
|
|
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 -- views users
|
|
43
|
+
|
|
38
44
|
if git diff --quiet -- views 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 }}" -- views users; then
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
patch_file="$(mktemp)"
|
|
54
|
+
git diff --binary -- views 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 -- views 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 }}
|
|
@@ -29,6 +29,7 @@ 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
|
+
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.
|
|
32
33
|
The shared pilot image tag is `brain-${brainVersion}` end to end.
|
|
33
34
|
When `pilot.yaml.brainVersion` changes and you push, CI rebuilds the shared tag, refreshes generated user env files, and redeploys affected users.
|
|
34
35
|
When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
|
|
@@ -36,8 +37,12 @@ When a push changes only deploy contract files, CI prints `No affected user conf
|
|
|
36
37
|
## Commands
|
|
37
38
|
|
|
38
39
|
- `brains-ops init <repo>`
|
|
39
|
-
- `brains-ops render <repo>`
|
|
40
|
-
- `brains-ops
|
|
41
|
-
- `brains-ops
|
|
40
|
+
- `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
|
|
41
|
+
- `brains-ops user:add <repo> <handle> --cohort <cohort>` — scaffolds a user file, per-user secrets template, and cohort membership
|
|
42
|
+
- `brains-ops onboard <repo> <handle>` — creates/seeds the user's content repo with separate admin and sync tokens
|
|
43
|
+
- `brains-ops age-key:bootstrap <repo>`
|
|
44
|
+
- `brains-ops ssh-key:bootstrap <repo>`
|
|
45
|
+
- `brains-ops cert:bootstrap <repo>`
|
|
46
|
+
- `brains-ops secrets:encrypt <repo> <handle>`
|
|
42
47
|
- `brains-ops reconcile-cohort <repo> <cohort>`
|
|
43
48
|
- `brains-ops reconcile-all <repo>`
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
// Per-user AT Protocol publishing credential (optional; from the user's
|
|
29
|
+
// encrypted secrets file). The scaffold wires this so a pilot can publish its
|
|
30
|
+
// brain's agent card to its PDS; a deployment that doesn't publish simply
|
|
31
|
+
// leaves it unset.
|
|
32
|
+
writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
|
|
33
|
+
// Per-user custom-domain TLS overrides the shared fleet certificate when a
|
|
34
|
+
// complete certificate/key pair is present in the encrypted user secrets.
|
|
35
|
+
const certificatePem = decodeEscapedSecret(secrets["certificatePem"]);
|
|
36
|
+
const privateKeyPem = decodeEscapedSecret(secrets["privateKeyPem"]);
|
|
37
|
+
if (Boolean(certificatePem) !== Boolean(privateKeyPem)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"Custom-domain TLS secrets require both certificatePem and privateKeyPem",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
|
|
43
|
+
writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
|
|
44
|
+
|
|
45
|
+
writeGitHubOutput(
|
|
46
|
+
"shared_ai_api_key_secret_name",
|
|
47
|
+
requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
|
|
48
|
+
);
|
|
49
|
+
writeGitHubOutput(
|
|
50
|
+
"shared_git_sync_token_secret_name",
|
|
51
|
+
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
52
|
+
);
|
|
53
|
+
writeGitHubOutput(
|
|
54
|
+
"shared_content_repo_admin_token_secret_name",
|
|
55
|
+
requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
function writeSecretGitHubEnv(name: string, value: string | undefined): void {
|
|
59
|
+
if (!value || value.trim().length === 0) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
maskGitHubSecret(value);
|
|
64
|
+
writeGitHubEnv(name, value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function maskGitHubSecret(value: string): void {
|
|
68
|
+
const escaped = value
|
|
69
|
+
.replace(/%/g, "%25")
|
|
70
|
+
.replace(/\r/g, "%0D")
|
|
71
|
+
.replace(/\n/g, "%0A");
|
|
72
|
+
if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
73
|
+
console.log(`::add-mask::${escaped}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractAgeIdentity(contents: string): string {
|
|
78
|
+
const line = contents
|
|
79
|
+
.split(/\r?\n/)
|
|
80
|
+
.map((entry) => entry.trim())
|
|
81
|
+
.find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
|
|
82
|
+
|
|
83
|
+
if (!line) {
|
|
84
|
+
throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return line;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseStringYamlMapping(contents: string): Record<string, string> {
|
|
91
|
+
const parsed: unknown = Bun.YAML.parse(contents);
|
|
92
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
93
|
+
throw new Error("Expected a YAML mapping");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result: Record<string, string> = {};
|
|
97
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
98
|
+
if (typeof value === "string") {
|
|
99
|
+
result[key] = value;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function decodeEscapedSecret(value: string | undefined): string | undefined {
|
|
106
|
+
return value?.replace(/\\n/g, "\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requireFlatValue(
|
|
110
|
+
values: Record<string, string>,
|
|
111
|
+
key: string,
|
|
112
|
+
label: string,
|
|
113
|
+
): string {
|
|
114
|
+
const value = values[key];
|
|
115
|
+
if (!value) {
|
|
116
|
+
throw new Error(`Missing ${key} in ${label}`);
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
@@ -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
|
}
|