@rizom/ops 0.2.0-alpha.7 → 0.2.0-alpha.71
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 +6 -3
- package/dist/age-key-bootstrap.d.ts +17 -0
- package/dist/brains-ops.js +262 -156
- package/dist/cert-bootstrap.d.ts +3 -3
- package/dist/content-repo.d.ts +13 -0
- package/dist/default-user-runner.d.ts +1 -1
- package/dist/deploy.js +3 -170
- package/dist/entries/deploy.d.ts +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +262 -156
- package/dist/load-registry.d.ts +19 -3
- package/dist/observed-status.d.ts +1 -1
- package/dist/onboard-user.d.ts +2 -2
- package/dist/origin-ca.d.ts +1 -1
- package/dist/parse-args.d.ts +2 -0
- package/dist/push-secrets.d.ts +1 -1
- 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 +1 -2
- package/dist/run-subprocess.d.ts +1 -0
- package/dist/schema.d.ts +100 -0
- package/dist/secrets-encrypt.d.ts +32 -0
- package/dist/secrets-push.d.ts +1 -1
- package/dist/ssh-key-bootstrap.d.ts +1 -1
- package/dist/user-add.d.ts +15 -0
- package/dist/user-runner.d.ts +5 -0
- package/package.json +7 -3
- package/templates/rover-pilot/.env.schema +11 -0
- package/templates/rover-pilot/.github/workflows/build.yml +4 -4
- package/templates/rover-pilot/.github/workflows/deploy.yml +75 -20
- package/templates/rover-pilot/.github/workflows/reconcile.yml +16 -2
- package/templates/rover-pilot/README.md +6 -3
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +83 -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-user-config.ts +12 -12
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +179 -0
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
- package/templates/rover-pilot/docs/onboarding-checklist.md +28 -12
- package/templates/rover-pilot/docs/operator-playbook.md +43 -5
- package/templates/rover-pilot/docs/user-onboarding.md +292 -99
- package/templates/rover-pilot/package.json +3 -0
- package/templates/rover-pilot/pilot.yaml +4 -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/Caddyfile +0 -66
- package/templates/rover-pilot/deploy/Dockerfile +0 -38
- package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -39
|
@@ -12,8 +12,14 @@ on:
|
|
|
12
12
|
paths:
|
|
13
13
|
- users/*/.env
|
|
14
14
|
- users/*/brain.yaml
|
|
15
|
+
- users/*/content/**
|
|
16
|
+
- users/*.secrets.yaml.age
|
|
15
17
|
- deploy/**
|
|
16
18
|
- .github/workflows/deploy.yml
|
|
19
|
+
workflow_run:
|
|
20
|
+
workflows: [Reconcile]
|
|
21
|
+
types: [completed]
|
|
22
|
+
branches: [main]
|
|
17
23
|
|
|
18
24
|
permissions:
|
|
19
25
|
contents: write
|
|
@@ -21,13 +27,16 @@ permissions:
|
|
|
21
27
|
|
|
22
28
|
jobs:
|
|
23
29
|
resolve_handles:
|
|
30
|
+
if: >
|
|
31
|
+
github.event_name != 'workflow_run' ||
|
|
32
|
+
github.event.workflow_run.conclusion == 'success'
|
|
24
33
|
runs-on: ubuntu-latest
|
|
25
34
|
outputs:
|
|
26
35
|
handles_json: ${{ steps.resolve.outputs.handles_json }}
|
|
27
36
|
steps:
|
|
28
37
|
- uses: actions/checkout@v5
|
|
29
38
|
with:
|
|
30
|
-
ref: ${{ github.sha }}
|
|
39
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
31
40
|
fetch-depth: 0
|
|
32
41
|
|
|
33
42
|
- uses: oven-sh/setup-bun@v2
|
|
@@ -39,7 +48,7 @@ jobs:
|
|
|
39
48
|
id: resolve
|
|
40
49
|
env:
|
|
41
50
|
HANDLE_INPUT: ${{ inputs.handle || '' }}
|
|
42
|
-
BEFORE_SHA: ${{ github.event.before || '' }}
|
|
51
|
+
BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
|
|
43
52
|
run: bun deploy/scripts/resolve-deploy-handles.ts
|
|
44
53
|
|
|
45
54
|
no_changes:
|
|
@@ -66,15 +75,25 @@ jobs:
|
|
|
66
75
|
steps:
|
|
67
76
|
- uses: actions/checkout@v5
|
|
68
77
|
with:
|
|
69
|
-
ref: ${{ github.sha }}
|
|
78
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
70
79
|
|
|
71
80
|
- uses: oven-sh/setup-bun@v2
|
|
72
81
|
|
|
73
82
|
- name: Install operator tooling
|
|
74
83
|
run: bun install
|
|
75
84
|
|
|
85
|
+
- name: Decrypt user secrets
|
|
86
|
+
id: user_secrets
|
|
87
|
+
env:
|
|
88
|
+
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
|
89
|
+
run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
|
|
90
|
+
|
|
76
91
|
- name: Reconcile selected user config
|
|
77
|
-
|
|
92
|
+
env:
|
|
93
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
94
|
+
run: |
|
|
95
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
96
|
+
bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
|
|
78
97
|
|
|
79
98
|
- name: Resolve generated user config
|
|
80
99
|
id: user_config
|
|
@@ -82,10 +101,9 @@ jobs:
|
|
|
82
101
|
|
|
83
102
|
- name: Validate selected secrets
|
|
84
103
|
env:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
|
|
104
|
+
SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
|
|
105
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
106
|
+
SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
|
|
89
107
|
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
|
|
90
108
|
HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
|
|
91
109
|
HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
|
|
@@ -96,10 +114,22 @@ jobs:
|
|
|
96
114
|
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
97
115
|
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
98
116
|
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
99
|
-
run:
|
|
117
|
+
run: |
|
|
118
|
+
export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
|
|
119
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
120
|
+
export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
|
|
121
|
+
bun deploy/scripts/validate-secrets.ts
|
|
122
|
+
|
|
123
|
+
- name: Seed content repo
|
|
124
|
+
env:
|
|
125
|
+
CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
|
|
126
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
127
|
+
run: |
|
|
128
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
129
|
+
bun deploy/scripts/sync-content-repo.ts
|
|
100
130
|
|
|
101
131
|
- name: Log in to GHCR
|
|
102
|
-
uses: docker/login-action@
|
|
132
|
+
uses: docker/login-action@v4
|
|
103
133
|
with:
|
|
104
134
|
registry: ghcr.io
|
|
105
135
|
username: ${{ github.actor }}
|
|
@@ -143,14 +173,17 @@ jobs:
|
|
|
143
173
|
|
|
144
174
|
- name: Write .kamal/secrets
|
|
145
175
|
env:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
|
|
176
|
+
SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
|
|
177
|
+
SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
|
|
178
|
+
SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
|
|
150
179
|
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
151
180
|
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
152
181
|
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
153
|
-
run:
|
|
182
|
+
run: |
|
|
183
|
+
export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
|
|
184
|
+
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
185
|
+
export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
|
|
186
|
+
bun deploy/scripts/write-kamal-secrets.ts
|
|
154
187
|
|
|
155
188
|
- name: Provision server
|
|
156
189
|
id: provision
|
|
@@ -167,8 +200,11 @@ jobs:
|
|
|
167
200
|
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
168
201
|
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
169
202
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
203
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
170
204
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
171
|
-
run:
|
|
205
|
+
run: |
|
|
206
|
+
bun deploy/scripts/update-dns.ts
|
|
207
|
+
BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
172
208
|
|
|
173
209
|
- name: Validate SSH key
|
|
174
210
|
run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
|
|
@@ -195,6 +231,7 @@ jobs:
|
|
|
195
231
|
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
196
232
|
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
197
233
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
234
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
198
235
|
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
199
236
|
run: kamal setup --skip-push -c deploy/kamal/deploy.yml
|
|
200
237
|
|
|
@@ -202,7 +239,10 @@ jobs:
|
|
|
202
239
|
env:
|
|
203
240
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
204
241
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
205
|
-
|
|
242
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
243
|
+
run: |
|
|
244
|
+
curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
|
|
245
|
+
curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
|
|
206
246
|
|
|
207
247
|
- name: Upload generated config
|
|
208
248
|
uses: actions/upload-artifact@v4
|
|
@@ -211,6 +251,7 @@ jobs:
|
|
|
211
251
|
path: |
|
|
212
252
|
users/${{ matrix.handle }}/brain.yaml
|
|
213
253
|
users/${{ matrix.handle }}/.env
|
|
254
|
+
users/${{ matrix.handle }}/content
|
|
214
255
|
|
|
215
256
|
- name: Dump remote proxy diagnostics
|
|
216
257
|
if: failure()
|
|
@@ -237,7 +278,7 @@ jobs:
|
|
|
237
278
|
steps:
|
|
238
279
|
- uses: actions/checkout@v5
|
|
239
280
|
with:
|
|
240
|
-
ref: ${{ github.sha }}
|
|
281
|
+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
|
|
241
282
|
|
|
242
283
|
- uses: oven-sh/setup-bun@v2
|
|
243
284
|
|
|
@@ -259,8 +300,22 @@ jobs:
|
|
|
259
300
|
if git diff --quiet -- users views; then
|
|
260
301
|
exit 0
|
|
261
302
|
fi
|
|
303
|
+
|
|
304
|
+
git fetch origin "${{ github.ref_name }}"
|
|
305
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
|
|
306
|
+
exit 0
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
patch_file="$(mktemp)"
|
|
310
|
+
git diff --binary -- users views > "$patch_file"
|
|
311
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
312
|
+
git apply --3way --index "$patch_file"
|
|
313
|
+
|
|
314
|
+
if git diff --cached --quiet -- users views; then
|
|
315
|
+
exit 0
|
|
316
|
+
fi
|
|
317
|
+
|
|
262
318
|
git config user.name "github-actions[bot]"
|
|
263
319
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
264
|
-
git add users views
|
|
265
320
|
git commit -m "chore(ops): reconcile generated config"
|
|
266
|
-
git push
|
|
321
|
+
git push origin HEAD:${{ github.ref_name }}
|
|
@@ -38,8 +38,22 @@ jobs:
|
|
|
38
38
|
if git diff --quiet -- views users; then
|
|
39
39
|
exit 0
|
|
40
40
|
fi
|
|
41
|
+
|
|
42
|
+
git fetch origin "${{ github.ref_name }}"
|
|
43
|
+
if git diff --quiet "origin/${{ github.ref_name }}" -- views users; then
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
patch_file="$(mktemp)"
|
|
48
|
+
git diff --binary -- views users > "$patch_file"
|
|
49
|
+
git reset --hard "origin/${{ github.ref_name }}"
|
|
50
|
+
git apply --3way --index "$patch_file"
|
|
51
|
+
|
|
52
|
+
if git diff --cached --quiet -- views users; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
41
56
|
git config user.name "github-actions[bot]"
|
|
42
57
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
43
|
-
git add views users
|
|
44
58
|
git commit -m "chore(ops): reconcile pilot outputs"
|
|
45
|
-
git push
|
|
59
|
+
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.
|
|
@@ -37,9 +38,11 @@ When a push changes only deploy contract files, CI prints `No affected user conf
|
|
|
37
38
|
|
|
38
39
|
- `brains-ops init <repo>`
|
|
39
40
|
- `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
|
|
40
|
-
- `brains-ops
|
|
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>`
|
|
41
44
|
- `brains-ops ssh-key:bootstrap <repo>`
|
|
42
|
-
- `brains-ops cert:bootstrap <repo
|
|
43
|
-
- `brains-ops secrets:
|
|
45
|
+
- `brains-ops cert:bootstrap <repo>`
|
|
46
|
+
- `brains-ops secrets:encrypt <repo> <handle>`
|
|
44
47
|
- `brains-ops reconcile-cohort <repo> <cohort>`
|
|
45
48
|
- `brains-ops reconcile-all <repo>`
|
|
@@ -0,0 +1,83 @@
|
|
|
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 = parseFlatYaml(plaintext);
|
|
19
|
+
const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
|
|
20
|
+
|
|
21
|
+
writeGitHubEnv("AI_API_KEY", secrets["aiApiKey"] ?? "");
|
|
22
|
+
writeGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"] ?? "");
|
|
23
|
+
writeGitHubEnv("MCP_AUTH_TOKEN", secrets["mcpAuthToken"] ?? "");
|
|
24
|
+
writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
|
|
25
|
+
|
|
26
|
+
writeGitHubOutput(
|
|
27
|
+
"shared_ai_api_key_secret_name",
|
|
28
|
+
requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
|
|
29
|
+
);
|
|
30
|
+
writeGitHubOutput(
|
|
31
|
+
"shared_git_sync_token_secret_name",
|
|
32
|
+
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
33
|
+
);
|
|
34
|
+
writeGitHubOutput(
|
|
35
|
+
"shared_mcp_auth_token_secret_name",
|
|
36
|
+
requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function extractAgeIdentity(contents: string): string {
|
|
40
|
+
const line = contents
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map((entry) => entry.trim())
|
|
43
|
+
.find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
|
|
44
|
+
|
|
45
|
+
if (!line) {
|
|
46
|
+
throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseFlatYaml(contents: string): Record<string, string> {
|
|
53
|
+
const result: Record<string, string> = {};
|
|
54
|
+
|
|
55
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
56
|
+
const line = rawLine.trim();
|
|
57
|
+
if (!line || line.startsWith("#")) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
62
|
+
if (!match) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const [, key, rawValue] = match;
|
|
67
|
+
result[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireFlatValue(
|
|
74
|
+
values: Record<string, string>,
|
|
75
|
+
key: string,
|
|
76
|
+
label: string,
|
|
77
|
+
): string {
|
|
78
|
+
const value = values[key];
|
|
79
|
+
if (!value) {
|
|
80
|
+
throw new Error(`Missing ${key} in ${label}`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
@@ -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)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
2
3
|
import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
|
|
3
4
|
|
|
4
5
|
const handle = requireEnv("HANDLE");
|
|
@@ -12,32 +13,31 @@ const repositoryOwner = repository.split("/")[0] ?? "";
|
|
|
12
13
|
const brainYaml = readFileSync(brainYamlPath, "utf8");
|
|
13
14
|
const domainMatch = brainYaml.match(/^domain:\s*(.+)$/m);
|
|
14
15
|
const brainDomain = domainMatch?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
15
|
-
|
|
16
16
|
if (!brainDomain) {
|
|
17
17
|
throw new Error(`Missing domain in ${brainYamlPath}`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const zone =
|
|
21
|
+
brainDomain.startsWith(`${handle}.`) && brainDomain.length > handle.length + 1
|
|
22
|
+
? brainDomain.slice(handle.length + 1)
|
|
23
|
+
: "";
|
|
24
|
+
if (!zone) {
|
|
25
|
+
throw new Error(`Could not derive preview domain from ${brainDomain}`);
|
|
26
|
+
}
|
|
27
|
+
const previewDomain = `${handle}-preview.${zone}`;
|
|
28
|
+
|
|
20
29
|
const outputs: Record<string, string> = {
|
|
21
30
|
brain_version: envEntries["BRAIN_VERSION"] ?? "",
|
|
22
|
-
ai_api_key_secret_name: envEntries["AI_API_KEY_SECRET"] ?? "",
|
|
23
|
-
git_sync_token_secret_name: envEntries["GIT_SYNC_TOKEN_SECRET"] ?? "",
|
|
24
|
-
mcp_auth_token_secret_name: envEntries["MCP_AUTH_TOKEN_SECRET"] ?? "",
|
|
25
|
-
discord_bot_token_secret_name: envEntries["DISCORD_BOT_TOKEN_SECRET"] ?? "",
|
|
26
31
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
27
32
|
brain_domain: brainDomain,
|
|
33
|
+
preview_domain: previewDomain,
|
|
28
34
|
brain_yaml_path: brainYamlPath,
|
|
29
35
|
instance_name: `rover-${handle}`,
|
|
30
36
|
image_repository: `ghcr.io/${repository}`,
|
|
31
37
|
registry_username: repositoryOwner,
|
|
32
38
|
};
|
|
33
39
|
|
|
34
|
-
const required = [
|
|
35
|
-
"brain_version",
|
|
36
|
-
"ai_api_key_secret_name",
|
|
37
|
-
"git_sync_token_secret_name",
|
|
38
|
-
"mcp_auth_token_secret_name",
|
|
39
|
-
"registry_username",
|
|
40
|
-
];
|
|
40
|
+
const required = ["brain_version", "registry_username"];
|
|
41
41
|
for (const key of required) {
|
|
42
42
|
if (!outputs[key]) {
|
|
43
43
|
throw new Error(`Missing ${key} (derived from ${envPath})`);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { cp, mkdtemp, mkdir, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { readJsonResponse, requireEnv } from "./helpers";
|
|
7
|
+
|
|
8
|
+
const handle = requireEnv("HANDLE");
|
|
9
|
+
const contentRepo = requireEnv("CONTENT_REPO");
|
|
10
|
+
const token = requireEnv("GIT_SYNC_TOKEN");
|
|
11
|
+
const sourceDir = join("users", handle, "content");
|
|
12
|
+
|
|
13
|
+
if (!existsSync(sourceDir)) {
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { owner, repo } = parseRepoSlug(contentRepo);
|
|
18
|
+
await ensureGitHubRepo({ owner, repo, token });
|
|
19
|
+
|
|
20
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
|
|
21
|
+
const checkoutDir = join(tempRoot, "repo");
|
|
22
|
+
const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
|
|
23
|
+
|
|
24
|
+
runGit(["clone", remoteUrl, checkoutDir]);
|
|
25
|
+
runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
|
|
26
|
+
|
|
27
|
+
const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
|
|
28
|
+
if (copiedFiles === 0) {
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
|
|
33
|
+
runGit([
|
|
34
|
+
"-C",
|
|
35
|
+
checkoutDir,
|
|
36
|
+
"config",
|
|
37
|
+
"user.email",
|
|
38
|
+
"41898282+github-actions[bot]@users.noreply.github.com",
|
|
39
|
+
]);
|
|
40
|
+
runGit(["-C", checkoutDir, "add", "."]);
|
|
41
|
+
|
|
42
|
+
if (hasNoStagedChanges(checkoutDir)) {
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
runGit([
|
|
47
|
+
"-C",
|
|
48
|
+
checkoutDir,
|
|
49
|
+
"commit",
|
|
50
|
+
"-m",
|
|
51
|
+
`chore(content): seed ${handle} anchor profile`,
|
|
52
|
+
]);
|
|
53
|
+
runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
|
|
54
|
+
|
|
55
|
+
const STALE_ANCHOR_PROFILE_MARKERS = [
|
|
56
|
+
"name: Your Name Here",
|
|
57
|
+
"Delete this and write your own",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
interface EnsureGitHubRepoOptions {
|
|
61
|
+
owner: string;
|
|
62
|
+
repo: string;
|
|
63
|
+
token: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface GitHubRepoResponse {
|
|
67
|
+
clone_url?: string;
|
|
68
|
+
private?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function ensureGitHubRepo(
|
|
72
|
+
options: EnsureGitHubRepoOptions,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const headers = {
|
|
75
|
+
Authorization: `Bearer ${options.token}`,
|
|
76
|
+
Accept: "application/vnd.github+json",
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
};
|
|
79
|
+
const repoUrl = `https://api.github.com/repos/${options.owner}/${options.repo}`;
|
|
80
|
+
const repoResponse = await fetch(repoUrl, { headers });
|
|
81
|
+
|
|
82
|
+
if (repoResponse.ok) {
|
|
83
|
+
await readJsonResponse(repoResponse, "GitHub repo lookup");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (repoResponse.status !== 404) {
|
|
88
|
+
const payload = await readJsonResponse(repoResponse, "GitHub repo lookup");
|
|
89
|
+
throw new Error(`GitHub repo lookup failed: ${JSON.stringify(payload)}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const createResponse = await fetch(
|
|
93
|
+
`https://api.github.com/orgs/${options.owner}/repos`,
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers,
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
name: options.repo,
|
|
99
|
+
private: true,
|
|
100
|
+
auto_init: false,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
const payload = (await readJsonResponse(
|
|
105
|
+
createResponse,
|
|
106
|
+
"GitHub repo create",
|
|
107
|
+
)) as GitHubRepoResponse;
|
|
108
|
+
|
|
109
|
+
if (!createResponse.ok) {
|
|
110
|
+
throw new Error(`GitHub repo create failed: ${JSON.stringify(payload)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseRepoSlug(contentRepo: string): { owner: string; repo: string } {
|
|
115
|
+
const [owner, repo] = contentRepo.split("/");
|
|
116
|
+
if (!owner || !repo) {
|
|
117
|
+
throw new Error(`Invalid CONTENT_REPO: ${contentRepo}`);
|
|
118
|
+
}
|
|
119
|
+
return { owner, repo };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildAuthenticatedRemoteUrl(
|
|
123
|
+
owner: string,
|
|
124
|
+
repo: string,
|
|
125
|
+
token: string,
|
|
126
|
+
): string {
|
|
127
|
+
return `https://x-access-token:${encodeURIComponent(token)}@github.com/${owner}/${repo}.git`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runGit(args: string[]): void {
|
|
131
|
+
execFileSync("git", args, { stdio: "inherit" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasNoStagedChanges(checkoutDir: string): boolean {
|
|
135
|
+
try {
|
|
136
|
+
execFileSync("git", ["-C", checkoutDir, "diff", "--cached", "--quiet"], {
|
|
137
|
+
stdio: "ignore",
|
|
138
|
+
});
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function copyMissingFiles(
|
|
146
|
+
sourceDir: string,
|
|
147
|
+
targetDir: string,
|
|
148
|
+
): Promise<number> {
|
|
149
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
150
|
+
let copiedFiles = 0;
|
|
151
|
+
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
154
|
+
const targetPath = join(targetDir, entry.name);
|
|
155
|
+
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
await mkdir(targetPath, { recursive: true });
|
|
158
|
+
copiedFiles += await copyMissingFiles(sourcePath, targetPath);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const existing = await readFile(targetPath, "utf8").catch(() => undefined);
|
|
163
|
+
if (existing !== undefined && !isStaleAnchorProfile(existing)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
168
|
+
await cp(sourcePath, targetPath, { force: true });
|
|
169
|
+
copiedFiles += existing === (await readFile(sourcePath, "utf8")) ? 0 : 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return copiedFiles;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isStaleAnchorProfile(content: string): boolean {
|
|
176
|
+
return STALE_ANCHOR_PROFILE_MARKERS.some((marker) =>
|
|
177
|
+
content.includes(marker),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -16,8 +16,11 @@ interface CloudflareResult {
|
|
|
16
16
|
result?: Array<{ id: string }>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function
|
|
20
|
-
|
|
19
|
+
async function findRecordId(
|
|
20
|
+
name: string,
|
|
21
|
+
type: "A" | "CNAME",
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
|
|
21
24
|
const lookup = await fetch(lookupUrl, { headers });
|
|
22
25
|
const payload = (await readJsonResponse(
|
|
23
26
|
lookup,
|
|
@@ -27,9 +30,16 @@ async function upsertRecord(name: string): Promise<void> {
|
|
|
27
30
|
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
return payload.result?.[0]?.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function upsertRecord(name: string): Promise<void> {
|
|
37
|
+
// Prefer an existing A record. If the hostname currently has a CNAME,
|
|
38
|
+
// replace that CNAME in-place so deploys can claim legacy www aliases.
|
|
39
|
+
const existing =
|
|
40
|
+
(await findRecordId(name, "A")) ?? (await findRecordId(name, "CNAME"));
|
|
31
41
|
const url = existing
|
|
32
|
-
? `${baseUrl}/zones/${zoneId}/dns_records/${existing
|
|
42
|
+
? `${baseUrl}/zones/${zoneId}/dns_records/${existing}`
|
|
33
43
|
: `${baseUrl}/zones/${zoneId}/dns_records`;
|
|
34
44
|
|
|
35
45
|
const response = await fetch(url, {
|