@rizom/ops 0.2.0-alpha.34 → 0.2.0-alpha.341
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 +61 -2
- package/dist/brains-ops.js +700 -318
- package/dist/capability-bundle-migration.d.ts +22 -0
- package/dist/cert-bootstrap.d.ts +3 -1
- package/dist/content-repo-ref.d.ts +10 -0
- package/dist/content-repo.d.ts +1 -0
- 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/health-watchdog-smoke.d.ts +54 -0
- package/dist/images.d.ts +79 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +776 -308
- package/dist/legacy-pilot-migration.d.ts +11 -0
- package/dist/load-registry.d.ts +60 -6
- package/dist/observed-status.d.ts +1 -1
- package/dist/origin-ca.d.ts +1 -1
- package/dist/parse-args.d.ts +2 -10
- package/dist/preview-domain.d.ts +9 -0
- package/dist/push-secrets.d.ts +2 -9
- package/dist/push-target.d.ts +1 -2
- package/dist/reconcile-dry-run.d.ts +9 -0
- package/dist/run-command.d.ts +15 -3
- package/dist/run-subprocess.d.ts +1 -6
- package/dist/schema.d.ts +123 -162
- package/dist/secrets-encrypt.d.ts +7 -13
- package/dist/ssh-key-bootstrap.d.ts +1 -26
- package/dist/stage-legacy-crossover.d.ts +23 -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/verify-user.d.ts +22 -0
- package/package.json +48 -42
- package/templates/rover-pilot/.env.schema +24 -3
- package/templates/rover-pilot/.github/actions/varlock-env/action.yml +47 -0
- package/templates/rover-pilot/.github/workflows/build.yml +70 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +63 -57
- package/templates/rover-pilot/.github/workflows/directory-sync-stress.yml +119 -0
- package/templates/rover-pilot/.github/workflows/health-watchdog-smoke.yml +95 -0
- package/templates/rover-pilot/.github/workflows/reconcile.yml +10 -4
- package/templates/rover-pilot/.github/workflows/upgrade.yml +104 -0
- package/templates/rover-pilot/README.md +16 -6
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -24
- 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/resolve-missing-images.ts +13 -0
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +44 -9
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
- 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 +107 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +28 -17
- package/templates/rover-pilot/docs/operator-playbook.md +270 -29
- package/templates/rover-pilot/docs/user-onboarding.md +48 -463
- package/templates/rover-pilot/pilot.yaml +7 -4
- package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
- package/templates/rover-pilot/deploy/Dockerfile +0 -30
- package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { FetchLike } from "@brains/deploy-support/origin-ca";
|
|
2
|
+
import { type StressHealthSample, type StressRuntimeSample } from "./directory-sync-stress";
|
|
3
|
+
export interface HealthPayload {
|
|
4
|
+
version: string;
|
|
5
|
+
entities: number;
|
|
6
|
+
entityCounts: {
|
|
7
|
+
entityType: string;
|
|
8
|
+
count: number;
|
|
9
|
+
}[];
|
|
10
|
+
}
|
|
11
|
+
export declare function noteCount(payload: HealthPayload): number;
|
|
12
|
+
export interface StressHealthMonitorOptions {
|
|
13
|
+
domain: string;
|
|
14
|
+
fetchImpl: FetchLike;
|
|
15
|
+
now: () => Date;
|
|
16
|
+
sleep: (milliseconds: number) => Promise<void>;
|
|
17
|
+
/** Take one runtime sample (e.g. docker stats over SSH), if available. */
|
|
18
|
+
sampleRuntime: () => Promise<StressRuntimeSample | undefined>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Samples deployment health and runtime resource usage during a stress run,
|
|
22
|
+
* and answers entity-baseline waits against the operational health endpoint.
|
|
23
|
+
*/
|
|
24
|
+
export declare class StressHealthMonitor {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(options: StressHealthMonitorOptions);
|
|
27
|
+
/** ISO timestamp of the most recent start() call. */
|
|
28
|
+
get startedAt(): string;
|
|
29
|
+
/** The first sampling failure since start(), if any. */
|
|
30
|
+
get error(): unknown;
|
|
31
|
+
get healthSamples(): readonly StressHealthSample[];
|
|
32
|
+
get runtimeSamples(): readonly StressRuntimeSample[];
|
|
33
|
+
/** Health samples between start() and markGateEnd() (or now). */
|
|
34
|
+
gateHealthSamples(): StressHealthSample[];
|
|
35
|
+
/** Exclude samples taken after this point from the gate window. */
|
|
36
|
+
markGateEnd(): void;
|
|
37
|
+
/** Probe optional health endpoints and add responsive ones to the loop. */
|
|
38
|
+
discoverEndpoints(): Promise<void>;
|
|
39
|
+
start(): void;
|
|
40
|
+
/** Stop sampling and wait for the loops; a loop error stays on `error`. */
|
|
41
|
+
stop(): Promise<void>;
|
|
42
|
+
waitForHealthSnapshot(timeoutMs: number): Promise<HealthPayload | undefined>;
|
|
43
|
+
waitForEntityBaseline(expectedNotes: number | undefined, expectedEntities: number | undefined, timeoutMs: number): Promise<HealthPayload | undefined>;
|
|
44
|
+
waitForStableEntityBaseline(expectedNotes: number | undefined, expectedEntities: number | undefined, timeoutMs: number): Promise<HealthPayload | undefined>;
|
|
45
|
+
fetchHealthPayload(): Promise<HealthPayload | undefined>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type UpgradeExec = (command: string[], cwd: string) => Promise<void>;
|
|
2
|
+
export interface UpgradeOptions {
|
|
3
|
+
exec?: UpgradeExec | undefined;
|
|
4
|
+
version?: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
export interface UpgradeResult {
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function upgradePilotRepo(rootDir: string, options?: UpgradeOptions): Promise<UpgradeResult>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface AddPilotUserOptions {
|
|
2
|
+
cohort: string;
|
|
3
|
+
anchorId?: string | undefined;
|
|
4
|
+
}
|
|
5
|
+
export interface AddPilotUserResult {
|
|
6
|
+
handle: string;
|
|
7
|
+
cohort: string;
|
|
8
|
+
userPath: string;
|
|
9
|
+
secretsTemplatePath: string;
|
|
10
|
+
cohortPath: string;
|
|
11
|
+
createdUser: boolean;
|
|
12
|
+
createdSecretsTemplate: boolean;
|
|
13
|
+
addedToCohort: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function addPilotUser(rootDir: string, handle: string, options: AddPilotUserOptions): Promise<AddPilotUserResult>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FetchLike } from "@brains/deploy-support/origin-ca";
|
|
2
|
+
import { type ResolvedUser } from "./load-registry";
|
|
3
|
+
export interface VerifyPilotUserOptions {
|
|
4
|
+
fetchImpl?: FetchLike;
|
|
5
|
+
logger?: (message: string) => void;
|
|
6
|
+
operationalRetryAttempts?: number;
|
|
7
|
+
operationalRetryDelayMs?: number;
|
|
8
|
+
sleep?: (delayMs: number) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export interface FailedCheck {
|
|
11
|
+
name: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
export interface VerifyPilotUserResult {
|
|
15
|
+
handle: string;
|
|
16
|
+
bundles: ResolvedUser["bundles"];
|
|
17
|
+
domain: string;
|
|
18
|
+
contentRepo: string;
|
|
19
|
+
checks: string[];
|
|
20
|
+
failedChecks: FailedCheck[];
|
|
21
|
+
}
|
|
22
|
+
export declare function verifyPilotUser(rootDir: string, handle: string, options?: VerifyPilotUserOptions): Promise<VerifyPilotUserResult>;
|
package/package.json
CHANGED
|
@@ -1,67 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rizom/ops",
|
|
3
|
+
"version": "0.2.0-alpha.341",
|
|
3
4
|
"description": "Operator CLI for managing private brain fleet registry repos",
|
|
4
|
-
"
|
|
5
|
-
"
|
|
5
|
+
"keywords": [
|
|
6
|
+
"brains",
|
|
7
|
+
"ops",
|
|
8
|
+
"cli",
|
|
9
|
+
"deploy",
|
|
10
|
+
"fleet",
|
|
11
|
+
"operator"
|
|
12
|
+
],
|
|
13
|
+
"license": "AGPL-3.0-only",
|
|
14
|
+
"author": "Yeehaa <yeehaa@rizom.ai> (https://rizom.ai)",
|
|
15
|
+
"bin": {
|
|
16
|
+
"brains-ops": "./dist/brains-ops.js"
|
|
17
|
+
},
|
|
18
|
+
"bugs": "https://github.com/rizom-ai/brains/issues",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"age-encryption": "^0.3.0",
|
|
21
|
+
"yaml": "^2.9.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@brains/deploy-support": "workspace:*",
|
|
25
|
+
"@brains/eslint-config": "workspace:*",
|
|
26
|
+
"@brains/profile": "workspace:*",
|
|
27
|
+
"@brains/site-composition": "workspace:*",
|
|
28
|
+
"@brains/test-utils": "workspace:*",
|
|
29
|
+
"@brains/typescript-config": "workspace:*",
|
|
30
|
+
"@brains/utils": "workspace:*",
|
|
31
|
+
"@types/bun": "^1.4.0",
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"bun": ">=1.3.3"
|
|
6
36
|
},
|
|
7
|
-
"version": "0.2.0-alpha.34",
|
|
8
|
-
"type": "module",
|
|
9
37
|
"exports": {
|
|
10
38
|
".": {
|
|
11
39
|
"types": "./dist/index.d.ts",
|
|
12
40
|
"import": "./dist/index.js"
|
|
13
41
|
},
|
|
14
42
|
"./deploy": {
|
|
15
|
-
"bun": "./dist/deploy.js",
|
|
16
43
|
"types": "./dist/deploy.d.ts",
|
|
17
|
-
"import": "./dist/deploy.js"
|
|
44
|
+
"import": "./dist/deploy.js",
|
|
45
|
+
"bun": "./dist/deploy.js"
|
|
18
46
|
}
|
|
19
47
|
},
|
|
20
|
-
"main": "./dist/index.js",
|
|
21
|
-
"types": "./dist/index.d.ts",
|
|
22
|
-
"bin": {
|
|
23
|
-
"brains-ops": "./dist/brains-ops.js"
|
|
24
|
-
},
|
|
25
48
|
"files": [
|
|
26
49
|
"dist",
|
|
27
50
|
"templates"
|
|
28
51
|
],
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"lint": "eslint . --ext .ts",
|
|
34
|
-
"lint:fix": "eslint . --ext .ts --fix",
|
|
35
|
-
"test": "bun test --timeout 20000"
|
|
36
|
-
},
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"age-encryption": "^0.3.0"
|
|
39
|
-
},
|
|
40
|
-
"devDependencies": {
|
|
41
|
-
"@brains/eslint-config": "workspace:*",
|
|
42
|
-
"@brains/typescript-config": "workspace:*",
|
|
43
|
-
"@brains/utils": "workspace:*",
|
|
44
|
-
"@types/bun": "latest",
|
|
45
|
-
"typescript": "^5.3.3"
|
|
52
|
+
"homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
|
|
53
|
+
"main": "./dist/index.js",
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
46
56
|
},
|
|
47
57
|
"repository": {
|
|
48
58
|
"type": "git",
|
|
49
59
|
"url": "https://github.com/rizom-ai/brains.git",
|
|
50
60
|
"directory": "packages/brains-ops"
|
|
51
61
|
},
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "bun scripts/build.ts",
|
|
64
|
+
"lint": "eslint . --max-warnings 0",
|
|
65
|
+
"lint:fix": "eslint . --max-warnings 0 --fix",
|
|
66
|
+
"prepublishOnly": "bun scripts/build.ts",
|
|
67
|
+
"test": "bun run build && bun test --timeout 20000",
|
|
68
|
+
"test:smoke": "bun run build && RUN_SMOKE_TESTS=1 bun test --timeout 60000",
|
|
69
|
+
"typecheck": "tsc --noEmit"
|
|
58
70
|
},
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
"ops",
|
|
62
|
-
"cli",
|
|
63
|
-
"deploy",
|
|
64
|
-
"fleet",
|
|
65
|
-
"operator"
|
|
66
|
-
]
|
|
71
|
+
"type": "module",
|
|
72
|
+
"types": "./dist/index.d.ts"
|
|
67
73
|
}
|
|
@@ -14,16 +14,37 @@ AI_API_KEY=
|
|
|
14
14
|
# @required @sensitive
|
|
15
15
|
GIT_SYNC_TOKEN=
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# Content repo administration
|
|
18
|
+
# Local/operator secret only. Used by brains-ops to create missing GitHub repos;
|
|
19
|
+
# do not deploy it into Rover runtime config.
|
|
19
20
|
# @required @sensitive
|
|
20
|
-
|
|
21
|
+
CONTENT_REPO_ADMIN_TOKEN=
|
|
21
22
|
|
|
22
23
|
# Discord (optional, per-user)
|
|
23
24
|
# Comes from the decrypted users/<handle>.secrets.yaml.age file when enabled.
|
|
24
25
|
# @sensitive
|
|
25
26
|
DISCORD_BOT_TOKEN=
|
|
26
27
|
|
|
28
|
+
# Stored with the per-user Discord credentials and injected at deploy time.
|
|
29
|
+
# @sensitive
|
|
30
|
+
DISCORD_PUBLIC_KEY=
|
|
31
|
+
|
|
32
|
+
# @sensitive
|
|
33
|
+
DISCORD_APPLICATION_ID=
|
|
34
|
+
|
|
35
|
+
# AT Protocol publishing/discovery (optional, per-user)
|
|
36
|
+
# Comes from the decrypted users/<handle>.secrets.yaml.age file when configured.
|
|
37
|
+
# @sensitive
|
|
38
|
+
ATPROTO_APP_PASSWORD=
|
|
39
|
+
|
|
40
|
+
# Setup email delivery (optional, shared)
|
|
41
|
+
# Used when a user file declares setup.delivery: email.
|
|
42
|
+
# @sensitive
|
|
43
|
+
SETUP_EMAIL_API_KEY=
|
|
44
|
+
|
|
45
|
+
# @sensitive
|
|
46
|
+
SETUP_EMAIL_FROM=
|
|
47
|
+
|
|
27
48
|
# ---- deploy/provision vars ----
|
|
28
49
|
|
|
29
50
|
# @required @sensitive
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Load Bitwarden environment through Varlock
|
|
2
|
+
description: Load, mask, and export the pilot environment from Varlock
|
|
3
|
+
|
|
4
|
+
runs:
|
|
5
|
+
using: composite
|
|
6
|
+
steps:
|
|
7
|
+
- name: Load shared environment
|
|
8
|
+
shell: bash
|
|
9
|
+
run: |
|
|
10
|
+
for attempt in 1 2 3; do
|
|
11
|
+
if bunx varlock@1.1.0 load --path .env.schema --format json --compact > /tmp/varlock-env.json; then
|
|
12
|
+
break
|
|
13
|
+
fi
|
|
14
|
+
if [ "$attempt" = "3" ]; then
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
echo "Varlock load failed (attempt $attempt/3); retrying in 5s..."
|
|
18
|
+
sleep 5
|
|
19
|
+
done
|
|
20
|
+
node <<'NODE'
|
|
21
|
+
import { appendFileSync, readFileSync, rmSync } from "node:fs";
|
|
22
|
+
const env = JSON.parse(readFileSync("/tmp/varlock-env.json", "utf8"));
|
|
23
|
+
const output = process.env.GITHUB_ENV;
|
|
24
|
+
if (!output) throw new Error("Missing GITHUB_ENV");
|
|
25
|
+
const newline = "\n";
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for (const [key, value] of Object.entries(env)) {
|
|
28
|
+
if (key === "BWS_ACCESS_TOKEN" || value === null || value === undefined) continue;
|
|
29
|
+
const text = String(value).replace(/\r\n/g, newline);
|
|
30
|
+
const escapedMask = text
|
|
31
|
+
.replace(/%/g, "%25")
|
|
32
|
+
.replace(/\r/g, "%0D")
|
|
33
|
+
.replace(/\n/g, "%0A");
|
|
34
|
+
if (escapedMask.length > 0) {
|
|
35
|
+
process.stdout.write(`::add-mask::${escapedMask}${newline}`);
|
|
36
|
+
}
|
|
37
|
+
if (text.includes(newline)) {
|
|
38
|
+
let delimiter = `VARLOCK_${key}_EOF`;
|
|
39
|
+
while (text.includes(delimiter)) delimiter += "_X";
|
|
40
|
+
chunks.push(`${key}<<${delimiter}${newline}${text}${newline}${delimiter}`);
|
|
41
|
+
} else {
|
|
42
|
+
chunks.push(`${key}=${text}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
appendFileSync(output, `${chunks.join(newline)}${newline}`);
|
|
46
|
+
rmSync("/tmp/varlock-env.json", { force: true });
|
|
47
|
+
NODE
|
|
@@ -2,10 +2,29 @@ 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
|
|
14
|
+
overwrite:
|
|
15
|
+
description: Rebuild an existing tag in place. Published tags are immutable; confirm only when replacing a tag whose containers were never deployed.
|
|
16
|
+
required: false
|
|
17
|
+
type: boolean
|
|
18
|
+
default: false
|
|
5
19
|
push:
|
|
6
20
|
branches: ["main"]
|
|
7
21
|
paths:
|
|
22
|
+
# The image set is a pure function of the declared fleet state: a push
|
|
23
|
+
# that changes it builds exactly what it declares, so the deploy (fired
|
|
24
|
+
# off the same push via Reconcile) finds its tag ready.
|
|
8
25
|
- pilot.yaml
|
|
26
|
+
- cohorts/**
|
|
27
|
+
- users/*.yaml
|
|
9
28
|
- deploy/**
|
|
10
29
|
- .github/workflows/build.yml
|
|
11
30
|
|
|
@@ -14,50 +33,84 @@ permissions:
|
|
|
14
33
|
packages: write
|
|
15
34
|
|
|
16
35
|
jobs:
|
|
17
|
-
|
|
36
|
+
resolve:
|
|
18
37
|
runs-on: ubuntu-latest
|
|
38
|
+
outputs:
|
|
39
|
+
images_json: ${{ steps.resolve.outputs.images_json }}
|
|
19
40
|
steps:
|
|
20
41
|
- uses: actions/checkout@v5
|
|
21
42
|
with:
|
|
22
43
|
ref: ${{ github.sha }}
|
|
23
44
|
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
- uses: oven-sh/setup-bun@v2
|
|
46
|
+
|
|
47
|
+
- name: Install operator tooling
|
|
48
|
+
run: bun install
|
|
49
|
+
|
|
50
|
+
- name: Log in to GHCR
|
|
51
|
+
uses: docker/login-action@v4
|
|
52
|
+
with:
|
|
53
|
+
registry: ghcr.io
|
|
54
|
+
username: ${{ github.actor }}
|
|
55
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
56
|
+
|
|
57
|
+
- name: Resolve images to build
|
|
58
|
+
id: resolve
|
|
59
|
+
env:
|
|
60
|
+
BRAIN_VERSION_INPUT: ${{ inputs.brain_version || '' }}
|
|
61
|
+
SITE_PACKAGES_INPUT: ${{ inputs.site_packages || '' }}
|
|
62
|
+
ALLOW_TAG_OVERWRITE: ${{ inputs.overwrite && 'true' || 'false' }}
|
|
63
|
+
run: bun deploy/scripts/resolve-missing-images.ts
|
|
64
|
+
|
|
65
|
+
no_builds:
|
|
66
|
+
needs: resolve
|
|
67
|
+
if: needs.resolve.outputs.images_json == '[]'
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
steps:
|
|
70
|
+
- name: Skip when every declared image exists
|
|
71
|
+
run: echo "All declared images already exist; nothing to build."
|
|
72
|
+
|
|
73
|
+
build:
|
|
74
|
+
needs: resolve
|
|
75
|
+
if: needs.resolve.outputs.images_json != '[]'
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
strategy:
|
|
78
|
+
fail-fast: false
|
|
79
|
+
matrix:
|
|
80
|
+
image: ${{ fromJson(needs.resolve.outputs.images_json) }}
|
|
81
|
+
steps:
|
|
82
|
+
- uses: actions/checkout@v5
|
|
83
|
+
with:
|
|
84
|
+
ref: ${{ github.sha }}
|
|
33
85
|
|
|
34
86
|
- name: Set up Docker Buildx
|
|
35
|
-
uses: docker/setup-buildx-action@
|
|
87
|
+
uses: docker/setup-buildx-action@v4
|
|
36
88
|
|
|
37
89
|
- name: Extract image metadata
|
|
38
90
|
id: meta
|
|
39
|
-
uses: docker/metadata-action@
|
|
91
|
+
uses: docker/metadata-action@v6
|
|
40
92
|
with:
|
|
41
|
-
images:
|
|
93
|
+
images: ghcr.io/${{ github.repository }}
|
|
42
94
|
tags: |
|
|
43
|
-
type=raw,value
|
|
95
|
+
type=raw,value=${{ matrix.image.tag }}
|
|
44
96
|
|
|
45
97
|
- name: Log in to GHCR
|
|
46
|
-
uses: docker/login-action@
|
|
98
|
+
uses: docker/login-action@v4
|
|
47
99
|
with:
|
|
48
100
|
registry: ghcr.io
|
|
49
101
|
username: ${{ github.actor }}
|
|
50
102
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
51
103
|
|
|
52
104
|
- name: Build and push image
|
|
53
|
-
uses: docker/build-push-action@
|
|
105
|
+
uses: docker/build-push-action@v7
|
|
54
106
|
with:
|
|
55
107
|
context: .
|
|
56
108
|
file: deploy/Dockerfile
|
|
57
109
|
target: fleet
|
|
58
110
|
push: true
|
|
59
111
|
build-args: |
|
|
60
|
-
BRAIN_VERSION=${{
|
|
112
|
+
BRAIN_VERSION=${{ matrix.image.brain_version }}
|
|
113
|
+
SITE_PACKAGES=${{ matrix.image.site_packages }}
|
|
61
114
|
tags: ${{ steps.meta.outputs.tags }}
|
|
62
115
|
labels: |
|
|
63
116
|
${{ steps.meta.outputs.labels }}
|
|
@@ -7,6 +7,11 @@ 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:
|
|
@@ -82,51 +87,29 @@ jobs:
|
|
|
82
87
|
- name: Install operator tooling
|
|
83
88
|
run: bun install
|
|
84
89
|
|
|
90
|
+
- name: Load shared env via Varlock
|
|
91
|
+
uses: ./.github/actions/varlock-env
|
|
92
|
+
env:
|
|
93
|
+
BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }}
|
|
94
|
+
|
|
85
95
|
- name: Decrypt user secrets
|
|
86
96
|
id: user_secrets
|
|
87
|
-
env:
|
|
88
|
-
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
|
89
97
|
run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
|
|
90
98
|
|
|
91
99
|
- name: Reconcile selected user config
|
|
92
|
-
|
|
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"
|
|
100
|
+
run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
|
|
97
101
|
|
|
98
102
|
- name: Resolve generated user config
|
|
99
103
|
id: user_config
|
|
100
104
|
run: bun deploy/scripts/resolve-user-config.ts
|
|
101
105
|
|
|
102
106
|
- name: Validate selected secrets
|
|
103
|
-
|
|
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] }}
|
|
107
|
-
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
|
|
108
|
-
HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
|
|
109
|
-
HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
|
|
110
|
-
HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
|
|
111
|
-
KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
|
|
112
|
-
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
113
|
-
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
114
|
-
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
115
|
-
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
116
|
-
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
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
|
|
107
|
+
run: bun deploy/scripts/validate-secrets.ts
|
|
122
108
|
|
|
123
109
|
- name: Seed content repo
|
|
124
110
|
env:
|
|
125
111
|
CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
|
|
126
|
-
|
|
127
|
-
run: |
|
|
128
|
-
export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
|
|
129
|
-
bun deploy/scripts/sync-content-repo.ts
|
|
112
|
+
run: bun deploy/scripts/sync-content-repo.ts
|
|
130
113
|
|
|
131
114
|
- name: Log in to GHCR
|
|
132
115
|
uses: docker/login-action@v3
|
|
@@ -135,15 +118,18 @@ jobs:
|
|
|
135
118
|
username: ${{ github.actor }}
|
|
136
119
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
137
120
|
|
|
138
|
-
- name: Wait for
|
|
121
|
+
- name: Wait for image tag
|
|
139
122
|
run: |
|
|
140
|
-
|
|
141
|
-
|
|
123
|
+
# The Build workflow fires off the same config push (it derives the
|
|
124
|
+
# declared image set from the registry), so this wait must cover a
|
|
125
|
+
# full image build, not just registry propagation.
|
|
126
|
+
IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:${{ steps.user_config.outputs.image_tag }}"
|
|
127
|
+
for attempt in $(seq 1 60); do
|
|
142
128
|
if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
|
|
143
129
|
exit 0
|
|
144
130
|
fi
|
|
145
|
-
echo "
|
|
146
|
-
sleep
|
|
131
|
+
echo "Image tag not ready yet ($attempt/60): $IMAGE_TAG"
|
|
132
|
+
sleep 20
|
|
147
133
|
done
|
|
148
134
|
echo "Timed out waiting for $IMAGE_TAG" >&2
|
|
149
135
|
exit 1
|
|
@@ -154,8 +140,6 @@ jobs:
|
|
|
154
140
|
ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
|
|
155
141
|
|
|
156
142
|
- name: Write Kamal SSH key
|
|
157
|
-
env:
|
|
158
|
-
KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
|
|
159
143
|
run: bun deploy/scripts/write-ssh-key.ts
|
|
160
144
|
|
|
161
145
|
- name: Configure SSH client
|
|
@@ -172,38 +156,26 @@ jobs:
|
|
|
172
156
|
chmod 600 ~/.ssh/config
|
|
173
157
|
|
|
174
158
|
- name: Write .kamal/secrets
|
|
175
|
-
|
|
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] }}
|
|
179
|
-
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
|
180
|
-
CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
|
|
181
|
-
PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
|
|
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
|
|
159
|
+
run: bun deploy/scripts/write-kamal-secrets.ts
|
|
187
160
|
|
|
188
161
|
- name: Provision server
|
|
189
162
|
id: provision
|
|
190
163
|
env:
|
|
191
|
-
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
|
|
192
|
-
HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
|
|
193
|
-
HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
|
|
194
|
-
HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
|
|
195
164
|
INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
|
|
196
165
|
run: bun deploy/scripts/provision-server.ts
|
|
197
166
|
|
|
198
167
|
- name: Update Cloudflare DNS
|
|
199
168
|
env:
|
|
200
|
-
|
|
201
|
-
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
169
|
+
CF_ZONE_ID: ${{ steps.user_config.outputs.cloudflare_zone_id }}
|
|
202
170
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
171
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
203
172
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
204
173
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
205
174
|
run: |
|
|
206
175
|
bun deploy/scripts/update-dns.ts
|
|
176
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
177
|
+
BRAIN_DOMAIN="$WWW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
178
|
+
fi
|
|
207
179
|
BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
208
180
|
|
|
209
181
|
- name: Validate SSH key
|
|
@@ -224,26 +196,54 @@ jobs:
|
|
|
224
196
|
echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
|
|
225
197
|
exit 1
|
|
226
198
|
|
|
199
|
+
- name: Release operator-confirmed stale deploy lock
|
|
200
|
+
if: ${{ inputs.release_stale_lock }}
|
|
201
|
+
env:
|
|
202
|
+
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
203
|
+
VERSION: ${{ steps.user_config.outputs.image_tag }}
|
|
204
|
+
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
205
|
+
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
206
|
+
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
207
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
208
|
+
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
209
|
+
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
210
|
+
run: kamal lock release -c deploy/kamal/deploy.yml
|
|
211
|
+
|
|
227
212
|
- name: Deploy
|
|
228
213
|
env:
|
|
229
214
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
230
|
-
VERSION:
|
|
215
|
+
VERSION: ${{ steps.user_config.outputs.image_tag }}
|
|
231
216
|
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
232
217
|
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
233
218
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
219
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
234
220
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
235
221
|
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
236
222
|
run: kamal setup --skip-push -c deploy/kamal/deploy.yml
|
|
237
223
|
|
|
224
|
+
- name: Install container health watchdog
|
|
225
|
+
env:
|
|
226
|
+
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
227
|
+
run: |
|
|
228
|
+
SSH_USER="$(ruby -e 'require "yaml"; config = YAML.load_file("deploy/kamal/deploy.yml") || {}; puts(config.dig("ssh", "user") || "root")')" \
|
|
229
|
+
bun deploy/scripts/install-health-watchdog.ts
|
|
230
|
+
|
|
238
231
|
- name: Verify origin TLS
|
|
239
232
|
env:
|
|
240
233
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
241
234
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
235
|
+
WWW_DOMAIN: ${{ steps.user_config.outputs.www_domain }}
|
|
242
236
|
PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
|
|
243
237
|
run: |
|
|
244
|
-
curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
|
|
238
|
+
curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health/live"
|
|
239
|
+
if [ -n "$WWW_DOMAIN" ]; then
|
|
240
|
+
curl -I -k --max-time 20 --resolve "$WWW_DOMAIN:443:$SERVER_IP" "https://$WWW_DOMAIN/"
|
|
241
|
+
fi
|
|
245
242
|
curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
|
|
246
243
|
|
|
244
|
+
- name: Verify operational health
|
|
245
|
+
run: bunx brains-ops verify-user "$GITHUB_WORKSPACE" "$HANDLE"
|
|
246
|
+
|
|
247
247
|
- name: Upload generated config
|
|
248
248
|
uses: actions/upload-artifact@v4
|
|
249
249
|
with:
|
|
@@ -297,6 +297,12 @@ jobs:
|
|
|
297
297
|
|
|
298
298
|
- name: Commit generated config
|
|
299
299
|
run: |
|
|
300
|
+
# A newly added user's generated directory is untracked, and
|
|
301
|
+
# `git diff` cannot see untracked files — without intent-to-add
|
|
302
|
+
# the new config is silently dropped here and every later deploy
|
|
303
|
+
# skips that user (their files never appear in a commit range).
|
|
304
|
+
git add --intent-to-add -- users views
|
|
305
|
+
|
|
300
306
|
if git diff --quiet -- users views; then
|
|
301
307
|
exit 0
|
|
302
308
|
fi
|