@rizom/ops 0.2.0-alpha.0

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.
Files changed (38) hide show
  1. package/README.md +24 -0
  2. package/dist/brains-ops.js +174 -0
  3. package/dist/default-user-runner.d.ts +3 -0
  4. package/dist/deploy.js +171 -0
  5. package/dist/entries/deploy.d.ts +2 -0
  6. package/dist/entrypoint.d.ts +2 -0
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.js +173 -0
  9. package/dist/init.d.ts +1 -0
  10. package/dist/load-registry.d.ts +46 -0
  11. package/dist/onboard-user.d.ts +2 -0
  12. package/dist/parse-args.d.ts +9 -0
  13. package/dist/reconcile-all.d.ts +2 -0
  14. package/dist/reconcile-cohort.d.ts +2 -0
  15. package/dist/reconcile-lib.d.ts +16 -0
  16. package/dist/render-users-table.d.ts +5 -0
  17. package/dist/run-command.d.ts +11 -0
  18. package/dist/schema.d.ts +86 -0
  19. package/dist/user-runner.d.ts +6 -0
  20. package/dist/user-secret-names.d.ts +6 -0
  21. package/package.json +65 -0
  22. package/templates/rover-pilot/.env.schema +54 -0
  23. package/templates/rover-pilot/.github/workflows/build.yml +63 -0
  24. package/templates/rover-pilot/.github/workflows/deploy.yml +261 -0
  25. package/templates/rover-pilot/.github/workflows/reconcile.yml +45 -0
  26. package/templates/rover-pilot/.kamal/hooks/pre-deploy +9 -0
  27. package/templates/rover-pilot/README.md +42 -0
  28. package/templates/rover-pilot/cohorts/cohort-1.yaml +2 -0
  29. package/templates/rover-pilot/deploy/Dockerfile +15 -0
  30. package/templates/rover-pilot/deploy/kamal/deploy.yml +39 -0
  31. package/templates/rover-pilot/deploy/scripts/helpers.ts +10 -0
  32. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +59 -0
  33. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +49 -0
  34. package/templates/rover-pilot/docs/onboarding-checklist.md +10 -0
  35. package/templates/rover-pilot/docs/operator-playbook.md +47 -0
  36. package/templates/rover-pilot/package.json +9 -0
  37. package/templates/rover-pilot/pilot.yaml +8 -0
  38. package/templates/rover-pilot/users/alice.yaml +3 -0
package/dist/init.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function initPilotRepo(rootDir: string): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { type PilotConfig, type PilotPreset } from "./schema";
2
+ export type ExternalStatus = "unknown" | "ready" | "failed";
3
+ export type SnapshotStatus = "present" | "missing";
4
+ export interface ObservedUserStatus {
5
+ serverStatus?: ExternalStatus;
6
+ deployStatus?: ExternalStatus;
7
+ dnsStatus?: ExternalStatus;
8
+ mcpStatus?: ExternalStatus;
9
+ }
10
+ export interface ResolvedCohort {
11
+ id: string;
12
+ members: string[];
13
+ brainVersionOverride?: string;
14
+ presetOverride?: PilotPreset;
15
+ aiApiKeyOverride?: string;
16
+ }
17
+ export interface ResolvedUserIdentity {
18
+ handle: string;
19
+ cohort: string;
20
+ brainVersion: string;
21
+ model: "rover";
22
+ preset: PilotPreset;
23
+ domain: string;
24
+ contentRepo: string;
25
+ discordEnabled: boolean;
26
+ effectiveAiApiKey: string;
27
+ snapshotStatus: SnapshotStatus;
28
+ }
29
+ export interface ResolvedUser extends ResolvedUserIdentity {
30
+ serverStatus: ExternalStatus;
31
+ deployStatus: ExternalStatus;
32
+ dnsStatus: ExternalStatus;
33
+ mcpStatus: ExternalStatus;
34
+ }
35
+ export interface LoadPilotRegistryOptions {
36
+ resolveStatus?: (user: ResolvedUserIdentity) => Promise<ObservedUserStatus | undefined>;
37
+ }
38
+ export interface PilotRegistry {
39
+ pilot: PilotConfig;
40
+ cohorts: ResolvedCohort[];
41
+ users: ResolvedUser[];
42
+ }
43
+ declare class PilotRegistryError extends Error {
44
+ }
45
+ export declare function loadPilotRegistry(rootDir: string, options?: LoadPilotRegistryOptions): Promise<PilotRegistry>;
46
+ export { PilotRegistryError };
@@ -0,0 +1,2 @@
1
+ import { type UserRunner } from "./reconcile-lib";
2
+ export declare function onboardUser(rootDir: string, handle: string, runner?: UserRunner): Promise<void>;
@@ -0,0 +1,9 @@
1
+ export interface ParsedArgs {
2
+ command: string;
3
+ args: string[];
4
+ flags: {
5
+ help?: boolean | undefined;
6
+ version?: boolean | undefined;
7
+ };
8
+ }
9
+ export declare function parseArgs(argv: string[]): ParsedArgs;
@@ -0,0 +1,2 @@
1
+ import { type UserRunner } from "./reconcile-lib";
2
+ export declare function reconcileAll(rootDir: string, runner?: UserRunner): Promise<void>;
@@ -0,0 +1,2 @@
1
+ import { type UserRunner } from "./reconcile-lib";
2
+ export declare function reconcileCohort(rootDir: string, cohortId: string, runner?: UserRunner): Promise<void>;
@@ -0,0 +1,16 @@
1
+ import { type PilotRegistry, type ResolvedUser } from "./load-registry";
2
+ import type { UserRunner } from "./user-runner";
3
+ export type { UserRunResult, UserRunner } from "./user-runner";
4
+ export declare function runUsers(rootDir: string, registry: PilotRegistry, users: ResolvedUser[], runner?: UserRunner): Promise<void>;
5
+ export declare function findUser(rootDir: string, handle: string): Promise<{
6
+ registry: PilotRegistry;
7
+ user: ResolvedUser;
8
+ }>;
9
+ export declare function findCohortUsers(rootDir: string, cohortId: string): Promise<{
10
+ registry: PilotRegistry;
11
+ users: ResolvedUser[];
12
+ }>;
13
+ export declare function findAllUsers(rootDir: string): Promise<{
14
+ registry: PilotRegistry;
15
+ users: ResolvedUser[];
16
+ }>;
@@ -0,0 +1,5 @@
1
+ import { type LoadPilotRegistryOptions, type PilotRegistry } from "./load-registry";
2
+ export interface WriteUsersTableOptions extends LoadPilotRegistryOptions {
3
+ registry?: PilotRegistry;
4
+ }
5
+ export declare function writeUsersTable(rootDir: string, options?: WriteUsersTableOptions): Promise<void>;
@@ -0,0 +1,11 @@
1
+ import type { LoadPilotRegistryOptions } from "./load-registry";
2
+ import type { ParsedArgs } from "./parse-args";
3
+ import type { UserRunner } from "./user-runner";
4
+ export interface CommandResult {
5
+ success: boolean;
6
+ message?: string;
7
+ }
8
+ export interface CommandDependencies extends LoadPilotRegistryOptions {
9
+ runner?: UserRunner;
10
+ }
11
+ export declare function runCommand(parsed: ParsedArgs, dependencies?: CommandDependencies): Promise<CommandResult>;
@@ -0,0 +1,86 @@
1
+ import { z } from "@brains/utils";
2
+ export declare const presetSchema: z.ZodEnum<["core", "default", "pro"]>;
3
+ export declare const exactVersionSchema: z.ZodString;
4
+ export declare const handleSchema: z.ZodString;
5
+ export declare const secretNameSchema: z.ZodString;
6
+ export declare const pilotSchema: z.ZodObject<{
7
+ schemaVersion: z.ZodLiteral<1>;
8
+ brainVersion: z.ZodString;
9
+ model: z.ZodLiteral<"rover">;
10
+ githubOrg: z.ZodString;
11
+ contentRepoPrefix: z.ZodString;
12
+ domainSuffix: z.ZodString;
13
+ preset: z.ZodEnum<["core", "default", "pro"]>;
14
+ aiApiKey: z.ZodString;
15
+ }, "strict", z.ZodTypeAny, {
16
+ schemaVersion: 1;
17
+ brainVersion: string;
18
+ model: "rover";
19
+ githubOrg: string;
20
+ contentRepoPrefix: string;
21
+ domainSuffix: string;
22
+ preset: "core" | "default" | "pro";
23
+ aiApiKey: string;
24
+ }, {
25
+ schemaVersion: 1;
26
+ brainVersion: string;
27
+ model: "rover";
28
+ githubOrg: string;
29
+ contentRepoPrefix: string;
30
+ domainSuffix: string;
31
+ preset: "core" | "default" | "pro";
32
+ aiApiKey: string;
33
+ }>;
34
+ export declare const userSchema: z.ZodObject<{
35
+ handle: z.ZodString;
36
+ discord: z.ZodObject<{
37
+ enabled: z.ZodBoolean;
38
+ }, "strict", z.ZodTypeAny, {
39
+ enabled: boolean;
40
+ }, {
41
+ enabled: boolean;
42
+ }>;
43
+ aiApiKeyOverride: z.ZodOptional<z.ZodString>;
44
+ }, "strict", z.ZodTypeAny, {
45
+ handle: string;
46
+ discord: {
47
+ enabled: boolean;
48
+ };
49
+ aiApiKeyOverride?: string | undefined;
50
+ }, {
51
+ handle: string;
52
+ discord: {
53
+ enabled: boolean;
54
+ };
55
+ aiApiKeyOverride?: string | undefined;
56
+ }>;
57
+ export declare const cohortSchema: z.ZodEffects<z.ZodObject<{
58
+ members: z.ZodArray<z.ZodString, "many">;
59
+ brainVersionOverride: z.ZodOptional<z.ZodString>;
60
+ presetOverride: z.ZodOptional<z.ZodEnum<["core", "default", "pro"]>>;
61
+ aiApiKeyOverride: z.ZodOptional<z.ZodString>;
62
+ }, "strict", z.ZodTypeAny, {
63
+ members: string[];
64
+ aiApiKeyOverride?: string | undefined;
65
+ brainVersionOverride?: string | undefined;
66
+ presetOverride?: "core" | "default" | "pro" | undefined;
67
+ }, {
68
+ members: string[];
69
+ aiApiKeyOverride?: string | undefined;
70
+ brainVersionOverride?: string | undefined;
71
+ presetOverride?: "core" | "default" | "pro" | undefined;
72
+ }>, {
73
+ members: string[];
74
+ aiApiKeyOverride?: string | undefined;
75
+ brainVersionOverride?: string | undefined;
76
+ presetOverride?: "core" | "default" | "pro" | undefined;
77
+ }, {
78
+ members: string[];
79
+ aiApiKeyOverride?: string | undefined;
80
+ brainVersionOverride?: string | undefined;
81
+ presetOverride?: "core" | "default" | "pro" | undefined;
82
+ }>;
83
+ export type PilotConfig = z.infer<typeof pilotSchema>;
84
+ export type UserConfig = z.infer<typeof userSchema>;
85
+ export type CohortConfig = z.infer<typeof cohortSchema>;
86
+ export type PilotPreset = z.infer<typeof presetSchema>;
@@ -0,0 +1,6 @@
1
+ import type { ResolvedUser } from "./load-registry";
2
+ export interface UserRunResult {
3
+ brainYaml?: string;
4
+ envFile?: string;
5
+ }
6
+ export type UserRunner = (user: ResolvedUser) => Promise<UserRunResult | void>;
@@ -0,0 +1,6 @@
1
+ export interface UserSecretNames {
2
+ gitSyncTokenSecretName: string;
3
+ mcpAuthTokenSecretName: string;
4
+ discordBotTokenSecretName: string;
5
+ }
6
+ export declare function deriveUserSecretNames(handle: string): UserSecretNames;
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@rizom/ops",
3
+ "description": "Operator CLI for managing private brain fleet registry repos",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "version": "0.2.0-alpha.0",
8
+ "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./deploy": {
15
+ "bun": "./src/entries/deploy.ts",
16
+ "types": "./dist/deploy.d.ts",
17
+ "import": "./dist/deploy.js"
18
+ }
19
+ },
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "bin": {
23
+ "brains-ops": "./dist/brains-ops.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "templates"
28
+ ],
29
+ "scripts": {
30
+ "build": "bun scripts/build.ts",
31
+ "prepublishOnly": "bun scripts/build.ts",
32
+ "typecheck": "tsc --noEmit",
33
+ "lint": "eslint . --ext .ts",
34
+ "lint:fix": "eslint . --ext .ts --fix",
35
+ "test": "bun test"
36
+ },
37
+ "dependencies": {},
38
+ "devDependencies": {
39
+ "@brains/eslint-config": "workspace:*",
40
+ "@brains/typescript-config": "workspace:*",
41
+ "@brains/utils": "workspace:*",
42
+ "@types/bun": "latest",
43
+ "typescript": "^5.3.3"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/rizom-ai/brains.git",
48
+ "directory": "packages/brains-ops"
49
+ },
50
+ "license": "Apache-2.0",
51
+ "author": "Yeehaa <yeehaa@rizom.ai> (https://rizom.ai)",
52
+ "homepage": "https://github.com/rizom-ai/brains/tree/main/packages/brains-ops#readme",
53
+ "bugs": "https://github.com/rizom-ai/brains/issues",
54
+ "engines": {
55
+ "bun": ">=1.3.3"
56
+ },
57
+ "keywords": [
58
+ "brains",
59
+ "ops",
60
+ "cli",
61
+ "deploy",
62
+ "fleet",
63
+ "operator"
64
+ ]
65
+ }
@@ -0,0 +1,54 @@
1
+ # Rover pilot instance env schema
2
+ # This file is the single source of truth for required and sensitive deploy vars.
3
+ # @defaultRequired=false @defaultSensitive=false
4
+ # ----------
5
+
6
+ # AI provider
7
+ # @required @sensitive
8
+ AI_API_KEY=
9
+
10
+ # Git sync
11
+ # @required @sensitive
12
+ GIT_SYNC_TOKEN=
13
+
14
+ # MCP interface
15
+ # @required @sensitive
16
+ MCP_AUTH_TOKEN=
17
+
18
+ # Discord (optional, per-user)
19
+ # @sensitive
20
+ DISCORD_BOT_TOKEN=
21
+
22
+ # ---- deploy/provision vars ----
23
+
24
+ # @required @sensitive
25
+ HCLOUD_TOKEN=
26
+
27
+ # @required
28
+ HCLOUD_SSH_KEY_NAME=
29
+
30
+ # @required
31
+ HCLOUD_SERVER_TYPE=
32
+
33
+ # @required
34
+ HCLOUD_LOCATION=
35
+
36
+ # @required @sensitive
37
+ KAMAL_SSH_PRIVATE_KEY=
38
+
39
+ # @required @sensitive
40
+ KAMAL_REGISTRY_PASSWORD=
41
+
42
+ # @required @sensitive
43
+ CF_API_TOKEN=
44
+
45
+ # @required
46
+ CF_ZONE_ID=
47
+
48
+ # ---- TLS cert vars ----
49
+
50
+ # @required @sensitive
51
+ CERTIFICATE_PEM=
52
+
53
+ # @required @sensitive
54
+ PRIVATE_KEY_PEM=
@@ -0,0 +1,63 @@
1
+ name: Build
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches: ["main"]
7
+ paths:
8
+ - pilot.yaml
9
+ - deploy/**
10
+ - .github/workflows/build.yml
11
+
12
+ permissions:
13
+ contents: read
14
+ packages: write
15
+
16
+ jobs:
17
+ build:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v5
21
+ with:
22
+ ref: ${{ github.sha }}
23
+
24
+ - name: Extract image metadata inputs
25
+ run: |
26
+ BRAIN_VERSION="$(grep '^brainVersion:' pilot.yaml | sed 's/^brainVersion:[[:space:]]*//' | tr -d '"' | tr -d "'")"
27
+ if [ -z "$BRAIN_VERSION" ]; then
28
+ echo "Missing brainVersion in pilot.yaml" >&2
29
+ exit 1
30
+ fi
31
+ echo "BRAIN_VERSION=$BRAIN_VERSION" >> "$GITHUB_ENV"
32
+ echo "IMAGE_REPOSITORY=ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}" >> "$GITHUB_ENV"
33
+
34
+ - name: Set up Docker Buildx
35
+ uses: docker/setup-buildx-action@v3
36
+
37
+ - name: Extract image metadata
38
+ id: meta
39
+ uses: docker/metadata-action@v5
40
+ with:
41
+ images: ${{ env.IMAGE_REPOSITORY }}
42
+ tags: |
43
+ type=raw,value=brain-${{ env.BRAIN_VERSION }}
44
+
45
+ - name: Log in to GHCR
46
+ uses: docker/login-action@v3
47
+ with:
48
+ registry: ghcr.io
49
+ username: ${{ github.actor }}
50
+ password: ${{ secrets.GITHUB_TOKEN }}
51
+
52
+ - name: Build and push image
53
+ uses: docker/build-push-action@v6
54
+ with:
55
+ context: .
56
+ file: deploy/Dockerfile
57
+ push: true
58
+ build-args: |
59
+ BRAIN_VERSION=${{ env.BRAIN_VERSION }}
60
+ tags: ${{ steps.meta.outputs.tags }}
61
+ labels: |
62
+ ${{ steps.meta.outputs.labels }}
63
+ service=rover
@@ -0,0 +1,261 @@
1
+ name: Deploy
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ handle:
7
+ description: User handle
8
+ required: true
9
+ type: string
10
+ push:
11
+ branches: ["main"]
12
+ paths:
13
+ - users/*/.env
14
+ - users/*/brain.yaml
15
+ - deploy/**
16
+ - .github/workflows/deploy.yml
17
+
18
+ permissions:
19
+ contents: write
20
+ packages: read
21
+
22
+ jobs:
23
+ resolve_handles:
24
+ runs-on: ubuntu-latest
25
+ outputs:
26
+ handles_json: ${{ steps.resolve.outputs.handles_json }}
27
+ steps:
28
+ - uses: actions/checkout@v5
29
+ with:
30
+ ref: ${{ github.sha }}
31
+
32
+ - uses: oven-sh/setup-bun@v2
33
+
34
+ - name: Install operator tooling
35
+ run: bun install
36
+
37
+ - name: Resolve deploy handles
38
+ id: resolve
39
+ env:
40
+ HANDLE_INPUT: ${{ inputs.handle || '' }}
41
+ BEFORE_SHA: ${{ github.event.before || '' }}
42
+ run: bun deploy/scripts/resolve-deploy-handles.ts
43
+
44
+ no_changes:
45
+ needs: resolve_handles
46
+ if: needs.resolve_handles.outputs.handles_json == '[]'
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - name: Skip when no user configs changed
50
+ run: echo "No affected user configs; skipping deploy."
51
+
52
+ deploy:
53
+ needs: resolve_handles
54
+ if: needs.resolve_handles.outputs.handles_json != '[]'
55
+ runs-on: ubuntu-latest
56
+ strategy:
57
+ fail-fast: false
58
+ matrix:
59
+ handle: ${{ fromJson(needs.resolve_handles.outputs.handles_json) }}
60
+ concurrency:
61
+ group: deploy-${{ matrix.handle }}
62
+ cancel-in-progress: false
63
+ env:
64
+ HANDLE: ${{ matrix.handle }}
65
+ steps:
66
+ - uses: actions/checkout@v5
67
+ with:
68
+ ref: ${{ github.sha }}
69
+
70
+ - uses: oven-sh/setup-bun@v2
71
+
72
+ - name: Install operator tooling
73
+ run: bun install
74
+
75
+ - name: Reconcile selected user config
76
+ run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
77
+
78
+ - name: Resolve generated user config
79
+ id: user_config
80
+ run: bun deploy/scripts/resolve-user-config.ts
81
+
82
+ - name: Validate selected secrets
83
+ env:
84
+ AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
85
+ GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
86
+ MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
87
+ DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
88
+ HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
89
+ HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
90
+ HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
91
+ HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
92
+ KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
93
+ KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
94
+ CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
95
+ CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
96
+ CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
97
+ PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
98
+ run: bun deploy/scripts/validate-secrets.ts
99
+
100
+ - name: Log in to GHCR
101
+ uses: docker/login-action@v3
102
+ with:
103
+ registry: ghcr.io
104
+ username: ${{ github.actor }}
105
+ password: ${{ secrets.GITHUB_TOKEN }}
106
+
107
+ - name: Wait for shared image tag
108
+ run: |
109
+ IMAGE_TAG="${{ steps.user_config.outputs.image_repository }}:brain-${{ steps.user_config.outputs.brain_version }}"
110
+ for attempt in $(seq 1 24); do
111
+ if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then
112
+ exit 0
113
+ fi
114
+ echo "Shared image tag not ready yet ($attempt/24): $IMAGE_TAG"
115
+ sleep 10
116
+ done
117
+ echo "Timed out waiting for $IMAGE_TAG" >&2
118
+ exit 1
119
+
120
+ - name: Install Kamal
121
+ run: |
122
+ gem install --user-install kamal
123
+ ruby -r rubygems -e 'puts Gem.user_dir + "/bin"' >> "$GITHUB_PATH"
124
+
125
+ - name: Write Kamal SSH key
126
+ env:
127
+ KAMAL_SSH_PRIVATE_KEY: ${{ secrets.KAMAL_SSH_PRIVATE_KEY }}
128
+ run: bun deploy/scripts/write-ssh-key.ts
129
+
130
+ - name: Configure SSH client
131
+ run: |
132
+ mkdir -p ~/.ssh
133
+ cat > ~/.ssh/config <<'EOF'
134
+ Host *
135
+ IdentityFile ~/.ssh/id_ed25519
136
+ IdentitiesOnly yes
137
+ BatchMode yes
138
+ StrictHostKeyChecking no
139
+ UserKnownHostsFile /dev/null
140
+ EOF
141
+ chmod 600 ~/.ssh/config
142
+
143
+ - name: Write .kamal/secrets
144
+ env:
145
+ AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
146
+ GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
147
+ MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
148
+ DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
149
+ KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
150
+ CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
151
+ PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
152
+ run: bun deploy/scripts/write-kamal-secrets.ts
153
+
154
+ - name: Provision server
155
+ id: provision
156
+ env:
157
+ HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
158
+ HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
159
+ HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
160
+ HCLOUD_LOCATION: ${{ secrets.HCLOUD_LOCATION }}
161
+ INSTANCE_NAME: ${{ steps.user_config.outputs.instance_name }}
162
+ run: bun deploy/scripts/provision-server.ts
163
+
164
+ - name: Update Cloudflare DNS
165
+ env:
166
+ CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
167
+ CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
168
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
169
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
170
+ run: bun deploy/scripts/update-dns.ts
171
+
172
+ - name: Validate SSH key
173
+ run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
174
+
175
+ - name: Wait for SSH access
176
+ env:
177
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
178
+ run: |
179
+ SSH_USER="$(ruby -e 'require "yaml"; config = YAML.load_file("deploy/kamal/deploy.yml") || {}; puts(config.dig("ssh", "user") || "root")')"
180
+ for attempt in $(seq 1 18); do
181
+ if ssh "$SSH_USER@$SERVER_IP" true >/dev/null 2>&1; then
182
+ exit 0
183
+ fi
184
+ echo "SSH not ready yet (attempt $attempt/18); retrying in 5s..."
185
+ sleep 5
186
+ done
187
+ echo "SSH never became ready for $SSH_USER@$SERVER_IP" >&2
188
+ exit 1
189
+
190
+ - name: Deploy
191
+ env:
192
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
193
+ VERSION: brain-${{ steps.user_config.outputs.brain_version }}
194
+ run: kamal setup --skip-push -c deploy/kamal/deploy.yml
195
+
196
+ - name: Verify origin TLS
197
+ env:
198
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
199
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
200
+ run: curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
201
+
202
+ - name: Upload generated config
203
+ uses: actions/upload-artifact@v4
204
+ with:
205
+ name: generated-${{ matrix.handle }}-config
206
+ path: |
207
+ users/${{ matrix.handle }}/brain.yaml
208
+ users/${{ matrix.handle }}/.env
209
+
210
+ - name: Dump remote proxy diagnostics
211
+ if: failure()
212
+ env:
213
+ SERVER_IP: ${{ steps.provision.outputs.server_ip }}
214
+ run: |
215
+ if [ -z "$SERVER_IP" ]; then
216
+ echo "No server IP; skipping diagnostics"
217
+ exit 0
218
+ fi
219
+ SSH_USER="$(ruby -e 'require "yaml"; config = YAML.load_file("deploy/kamal/deploy.yml") || {}; puts(config.dig("ssh", "user") || "root")')"
220
+ ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SSH_USER@$SERVER_IP" '
221
+ docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
222
+ echo "--- kamal-proxy logs ---"
223
+ docker logs kamal-proxy --tail 200 || true
224
+ echo "--- kamal-proxy inspect ---"
225
+ docker inspect kamal-proxy || true
226
+ '
227
+
228
+ finalize_generated_config:
229
+ needs: [resolve_handles, deploy]
230
+ if: needs.resolve_handles.outputs.handles_json != '[]'
231
+ runs-on: ubuntu-latest
232
+ steps:
233
+ - uses: actions/checkout@v5
234
+ with:
235
+ ref: ${{ github.sha }}
236
+
237
+ - uses: oven-sh/setup-bun@v2
238
+
239
+ - name: Install operator tooling
240
+ run: bun install
241
+
242
+ - name: Finalize generated config
243
+ uses: actions/download-artifact@v4
244
+ with:
245
+ pattern: generated-*-config
246
+ merge-multiple: true
247
+ path: .
248
+
249
+ - name: Regenerate users table
250
+ run: bunx brains-ops render "$GITHUB_WORKSPACE"
251
+
252
+ - name: Commit generated config
253
+ run: |
254
+ if git diff --quiet -- users views; then
255
+ exit 0
256
+ fi
257
+ git config user.name "github-actions[bot]"
258
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
259
+ git add users views
260
+ git commit -m "chore(ops): reconcile generated config"
261
+ git push