@rizom/ops 0.2.0-alpha.0 → 0.2.0-alpha.10
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 +4 -1
- package/dist/brains-ops.js +114 -108
- package/dist/cert-bootstrap.d.ts +22 -0
- package/dist/deploy.d.ts +28 -0
- package/dist/deploy.js +48 -48
- package/dist/index.d.ts +3 -0
- package/dist/index.js +114 -108
- package/dist/observed-status.d.ts +12 -0
- package/dist/origin-ca.d.ts +1 -0
- package/dist/parse-args.d.ts +2 -0
- package/dist/push-secrets.d.ts +9 -0
- package/dist/push-target.d.ts +2 -0
- package/dist/run-command.d.ts +11 -0
- package/dist/run-subprocess.d.ts +5 -0
- package/dist/schema.d.ts +6 -6
- package/dist/secrets-push.d.ts +13 -0
- package/dist/ssh-key-bootstrap.d.ts +26 -0
- package/package.json +2 -2
- package/templates/rover-pilot/.github/workflows/build.yml +1 -0
- package/templates/rover-pilot/.github/workflows/deploy.yml +9 -2
- package/templates/rover-pilot/README.md +4 -1
- package/templates/rover-pilot/deploy/Caddyfile +67 -0
- package/templates/rover-pilot/deploy/Dockerfile +30 -7
- package/templates/rover-pilot/deploy/kamal/deploy.yml +3 -2
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +109 -0
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +55 -0
- package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +19 -0
- package/templates/rover-pilot/deploy/scripts/write-kamal-secrets.ts +18 -0
- package/templates/rover-pilot/deploy/scripts/write-ssh-key.ts +17 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +10 -3
- package/templates/rover-pilot/docs/operator-playbook.md +21 -0
- package/templates/rover-pilot/docs/user-onboarding.md +356 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FetchLike } from "@brains/utils/origin-ca";
|
|
2
|
+
import type { ObservedUserStatus, ResolvedUserIdentity } from "./load-registry";
|
|
3
|
+
export interface LookupResult {
|
|
4
|
+
address: string;
|
|
5
|
+
family: number;
|
|
6
|
+
}
|
|
7
|
+
export type LookupHost = (hostname: string) => Promise<LookupResult>;
|
|
8
|
+
export interface CreateObservedStatusResolverOptions {
|
|
9
|
+
fetchImpl?: FetchLike;
|
|
10
|
+
lookupHost?: LookupHost;
|
|
11
|
+
}
|
|
12
|
+
export declare function createObservedStatusResolver(options?: CreateObservedStatusResolverOptions): (user: ResolvedUserIdentity) => Promise<ObservedUserStatus>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createOriginCertificateRequest, generateOriginKeyPair, issueCloudflareOriginCertificate, setCloudflareZoneSslStrict, type CloudflareOriginCaResult, type FetchLike, type OriginCertificateRequest, type OriginKeyPair, } from "@brains/utils/origin-ca";
|
package/dist/parse-args.d.ts
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { normalizePushTarget, type PushTarget } from "./push-target";
|
|
2
|
+
import { type RunCommand } from "./run-subprocess";
|
|
3
|
+
export type SecretPair = readonly [name: string, value: string];
|
|
4
|
+
export interface PushSecretsOptions {
|
|
5
|
+
runCommand?: RunCommand | undefined;
|
|
6
|
+
logger?: ((message: string) => void) | undefined;
|
|
7
|
+
}
|
|
8
|
+
export declare function pushSecretsToBackend(target: PushTarget, secrets: readonly SecretPair[], options?: PushSecretsOptions): Promise<void>;
|
|
9
|
+
export { normalizePushTarget };
|
package/dist/run-command.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import type { FetchLike } from "@brains/utils/origin-ca";
|
|
1
2
|
import type { LoadPilotRegistryOptions } from "./load-registry";
|
|
3
|
+
import { type LookupHost } from "./observed-status";
|
|
2
4
|
import type { ParsedArgs } from "./parse-args";
|
|
5
|
+
import { type RunCommand as OpsRunCommand } from "./run-subprocess";
|
|
6
|
+
import { type SshKeygen } from "./ssh-key-bootstrap";
|
|
3
7
|
import type { UserRunner } from "./user-runner";
|
|
4
8
|
export interface CommandResult {
|
|
5
9
|
success: boolean;
|
|
@@ -7,5 +11,12 @@ export interface CommandResult {
|
|
|
7
11
|
}
|
|
8
12
|
export interface CommandDependencies extends LoadPilotRegistryOptions {
|
|
9
13
|
runner?: UserRunner;
|
|
14
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
15
|
+
logger?: ((message: string) => void) | undefined;
|
|
16
|
+
fetchImpl?: FetchLike | undefined;
|
|
17
|
+
lookupHost?: LookupHost | undefined;
|
|
18
|
+
secretRunCommand?: OpsRunCommand | undefined;
|
|
19
|
+
bootstrapRunCommand?: OpsRunCommand | undefined;
|
|
20
|
+
sshKeygen?: SshKeygen | undefined;
|
|
10
21
|
}
|
|
11
22
|
export declare function runCommand(parsed: ParsedArgs, dependencies?: CommandDependencies): Promise<CommandResult>;
|
package/dist/schema.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare const pilotSchema: z.ZodObject<{
|
|
|
19
19
|
githubOrg: string;
|
|
20
20
|
contentRepoPrefix: string;
|
|
21
21
|
domainSuffix: string;
|
|
22
|
-
preset: "
|
|
22
|
+
preset: "default" | "core" | "pro";
|
|
23
23
|
aiApiKey: string;
|
|
24
24
|
}, {
|
|
25
25
|
schemaVersion: 1;
|
|
@@ -28,7 +28,7 @@ export declare const pilotSchema: z.ZodObject<{
|
|
|
28
28
|
githubOrg: string;
|
|
29
29
|
contentRepoPrefix: string;
|
|
30
30
|
domainSuffix: string;
|
|
31
|
-
preset: "
|
|
31
|
+
preset: "default" | "core" | "pro";
|
|
32
32
|
aiApiKey: string;
|
|
33
33
|
}>;
|
|
34
34
|
export declare const userSchema: z.ZodObject<{
|
|
@@ -63,22 +63,22 @@ export declare const cohortSchema: z.ZodEffects<z.ZodObject<{
|
|
|
63
63
|
members: string[];
|
|
64
64
|
aiApiKeyOverride?: string | undefined;
|
|
65
65
|
brainVersionOverride?: string | undefined;
|
|
66
|
-
presetOverride?: "
|
|
66
|
+
presetOverride?: "default" | "core" | "pro" | undefined;
|
|
67
67
|
}, {
|
|
68
68
|
members: string[];
|
|
69
69
|
aiApiKeyOverride?: string | undefined;
|
|
70
70
|
brainVersionOverride?: string | undefined;
|
|
71
|
-
presetOverride?: "
|
|
71
|
+
presetOverride?: "default" | "core" | "pro" | undefined;
|
|
72
72
|
}>, {
|
|
73
73
|
members: string[];
|
|
74
74
|
aiApiKeyOverride?: string | undefined;
|
|
75
75
|
brainVersionOverride?: string | undefined;
|
|
76
|
-
presetOverride?: "
|
|
76
|
+
presetOverride?: "default" | "core" | "pro" | undefined;
|
|
77
77
|
}, {
|
|
78
78
|
members: string[];
|
|
79
79
|
aiApiKeyOverride?: string | undefined;
|
|
80
80
|
brainVersionOverride?: string | undefined;
|
|
81
|
-
presetOverride?: "
|
|
81
|
+
presetOverride?: "default" | "core" | "pro" | undefined;
|
|
82
82
|
}>;
|
|
83
83
|
export type PilotConfig = z.infer<typeof pilotSchema>;
|
|
84
84
|
export type UserConfig = z.infer<typeof userSchema>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type RunCommand } from "./run-subprocess";
|
|
2
|
+
export interface SecretsPushOptions {
|
|
3
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
4
|
+
logger?: ((message: string) => void) | undefined;
|
|
5
|
+
dryRun?: boolean | undefined;
|
|
6
|
+
runCommand?: RunCommand | undefined;
|
|
7
|
+
}
|
|
8
|
+
export interface SecretsPushResult {
|
|
9
|
+
pushedKeys: string[];
|
|
10
|
+
skippedKeys: string[];
|
|
11
|
+
dryRun?: boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare function pushPilotSecrets(rootDir: string, handle: string, options?: SecretsPushOptions): Promise<SecretsPushResult>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type FetchLike } from "@brains/utils/origin-ca";
|
|
2
|
+
import { type RunCommand } from "./run-subprocess";
|
|
3
|
+
export interface SshKeyBootstrapOptions {
|
|
4
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
5
|
+
fetchImpl?: FetchLike | undefined;
|
|
6
|
+
logger?: (message: string) => void;
|
|
7
|
+
pushTo?: string | undefined;
|
|
8
|
+
runCommand?: RunCommand | undefined;
|
|
9
|
+
sshKeygen?: SshKeygen | undefined;
|
|
10
|
+
}
|
|
11
|
+
export interface SshKeyBootstrapResult {
|
|
12
|
+
createdHetznerKey: boolean;
|
|
13
|
+
createdLocalKey: boolean;
|
|
14
|
+
privateKeyPath: string;
|
|
15
|
+
publicKeyPath: string;
|
|
16
|
+
sshKeyName: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SshKeygen {
|
|
19
|
+
createEd25519KeyPair: (privateKeyPath: string, comment: string) => void;
|
|
20
|
+
derivePublicKey: (privateKeyPath: string) => string;
|
|
21
|
+
}
|
|
22
|
+
export declare function runPilotSshKeyBootstrap(rootDir: string, options?: SshKeyBootstrapOptions): Promise<{
|
|
23
|
+
success: boolean;
|
|
24
|
+
message?: string;
|
|
25
|
+
}>;
|
|
26
|
+
export declare function bootstrapPilotSshKey(rootDir: string, options?: SshKeyBootstrapOptions): Promise<SshKeyBootstrapResult>;
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.0-alpha.
|
|
7
|
+
"version": "0.2.0-alpha.10",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"import": "./dist/index.js"
|
|
13
13
|
},
|
|
14
14
|
"./deploy": {
|
|
15
|
-
"bun": "./
|
|
15
|
+
"bun": "./dist/deploy.js",
|
|
16
16
|
"types": "./dist/deploy.d.ts",
|
|
17
17
|
"import": "./dist/deploy.js"
|
|
18
18
|
}
|
|
@@ -28,6 +28,7 @@ jobs:
|
|
|
28
28
|
- uses: actions/checkout@v5
|
|
29
29
|
with:
|
|
30
30
|
ref: ${{ github.sha }}
|
|
31
|
+
fetch-depth: 0
|
|
31
32
|
|
|
32
33
|
- uses: oven-sh/setup-bun@v2
|
|
33
34
|
|
|
@@ -167,7 +168,9 @@ jobs:
|
|
|
167
168
|
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
|
168
169
|
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
169
170
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
170
|
-
run:
|
|
171
|
+
run: |
|
|
172
|
+
bun deploy/scripts/update-dns.ts
|
|
173
|
+
BRAIN_DOMAIN="preview.$BRAIN_DOMAIN" bun deploy/scripts/update-dns.ts
|
|
171
174
|
|
|
172
175
|
- name: Validate SSH key
|
|
173
176
|
run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
|
|
@@ -191,6 +194,10 @@ jobs:
|
|
|
191
194
|
env:
|
|
192
195
|
SERVER_IP: ${{ steps.provision.outputs.server_ip }}
|
|
193
196
|
VERSION: brain-${{ steps.user_config.outputs.brain_version }}
|
|
197
|
+
IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
|
|
198
|
+
REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
|
|
199
|
+
BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
|
|
200
|
+
BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
|
|
194
201
|
run: kamal setup --skip-push -c deploy/kamal/deploy.yml
|
|
195
202
|
|
|
196
203
|
- name: Verify origin TLS
|
|
@@ -258,4 +265,4 @@ jobs:
|
|
|
258
265
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
259
266
|
git add users views
|
|
260
267
|
git commit -m "chore(ops): reconcile generated config"
|
|
261
|
-
git push
|
|
268
|
+
git push origin HEAD:${{ github.ref_name }}
|
|
@@ -36,7 +36,10 @@ When a push changes only deploy contract files, CI prints `No affected user conf
|
|
|
36
36
|
## Commands
|
|
37
37
|
|
|
38
38
|
- `brains-ops init <repo>`
|
|
39
|
-
- `brains-ops render <repo>`
|
|
39
|
+
- `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
|
|
40
40
|
- `brains-ops onboard <repo> <handle>`
|
|
41
|
+
- `brains-ops ssh-key:bootstrap <repo>`
|
|
42
|
+
- `brains-ops cert:bootstrap <repo> <handle>`
|
|
43
|
+
- `brains-ops secrets:push <repo> <handle>`
|
|
41
44
|
- `brains-ops reconcile-cohort <repo> <cohort>`
|
|
42
45
|
- `brains-ops reconcile-all <repo>`
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Internal Caddy — path-based routing to brain services.
|
|
2
|
+
# kamal-proxy terminates TLS externally; this runs inside the container.
|
|
3
|
+
:80 {
|
|
4
|
+
@preview host preview.*
|
|
5
|
+
handle @preview {
|
|
6
|
+
reverse_proxy localhost:4321
|
|
7
|
+
|
|
8
|
+
header {
|
|
9
|
+
X-Frame-Options "SAMEORIGIN"
|
|
10
|
+
X-Content-Type-Options "nosniff"
|
|
11
|
+
Referrer-Policy "strict-origin-when-cross-origin"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Health endpoint
|
|
16
|
+
handle /health {
|
|
17
|
+
reverse_proxy localhost:3333
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# MCP endpoint
|
|
21
|
+
handle /mcp* {
|
|
22
|
+
reverse_proxy localhost:3333
|
|
23
|
+
|
|
24
|
+
header {
|
|
25
|
+
X-Content-Type-Options "nosniff"
|
|
26
|
+
Access-Control-Allow-Origin "*"
|
|
27
|
+
Access-Control-Allow-Methods "GET, POST, DELETE, OPTIONS"
|
|
28
|
+
Access-Control-Allow-Headers "Content-Type, Authorization, MCP-Session-Id"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# A2A endpoints
|
|
33
|
+
handle /.well-known/agent-card.json {
|
|
34
|
+
reverse_proxy localhost:3334
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
handle /a2a {
|
|
38
|
+
reverse_proxy localhost:3334
|
|
39
|
+
|
|
40
|
+
header {
|
|
41
|
+
X-Content-Type-Options "nosniff"
|
|
42
|
+
Access-Control-Allow-Origin "*"
|
|
43
|
+
Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
|
44
|
+
Access-Control-Allow-Headers "Content-Type, Authorization"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Plugin API routes
|
|
49
|
+
handle /api/* {
|
|
50
|
+
reverse_proxy localhost:3335
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Production site: prefer the webserver when present; otherwise fall back
|
|
54
|
+
# to the A2A interface so core-only deployments never return a bare 502.
|
|
55
|
+
handle {
|
|
56
|
+
reverse_proxy localhost:8080 localhost:3334 {
|
|
57
|
+
lb_policy first
|
|
58
|
+
lb_retries 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
header {
|
|
62
|
+
X-Frame-Options "SAMEORIGIN"
|
|
63
|
+
X-Content-Type-Options "nosniff"
|
|
64
|
+
Referrer-Policy "strict-origin-when-cross-origin"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
ARG BUN_VERSION=1.3.10
|
|
2
|
-
FROM oven/bun:${BUN_VERSION}-slim
|
|
2
|
+
FROM oven/bun:${BUN_VERSION}-slim AS runtime
|
|
3
3
|
|
|
4
|
-
ARG BRAIN_VERSION
|
|
5
4
|
WORKDIR /app
|
|
6
5
|
|
|
7
|
-
RUN
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
7
|
+
curl ca-certificates git gnupg debian-keyring debian-archive-keyring apt-transport-https \
|
|
8
|
+
&& curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg \
|
|
9
|
+
&& curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list \
|
|
10
|
+
&& apt-get update && apt-get install -y --no-install-recommends caddy libcap2-bin \
|
|
11
|
+
&& setcap cap_net_bind_service=+ep $(which caddy) \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
13
|
+
|
|
14
|
+
COPY deploy/Caddyfile /etc/caddy/Caddyfile
|
|
15
|
+
|
|
16
|
+
RUN mkdir -p /srv/fallback && \
|
|
17
|
+
printf '<!doctype html><html><head><meta charset="utf-8"><title>brain</title></head><body></body></html>\n' \
|
|
18
|
+
> /srv/fallback/index.html
|
|
10
19
|
|
|
11
20
|
ENV XDG_DATA_HOME=/data
|
|
12
21
|
ENV XDG_CONFIG_HOME=/config
|
|
13
|
-
RUN mkdir -p /app/
|
|
22
|
+
RUN mkdir -p /app/data /app/cache /app/brain-data && \
|
|
23
|
+
chmod -R 777 /app/data /app/cache /app/brain-data
|
|
14
24
|
|
|
15
|
-
CMD ["sh", "-c", "exec ./node_modules/.bin/brain start"]
|
|
25
|
+
CMD ["sh", "-c", "caddy start --config /etc/caddy/Caddyfile && exec ./node_modules/.bin/brain start"]
|
|
26
|
+
|
|
27
|
+
# --- standalone: bake full project into image (brain-cli deploy) ---
|
|
28
|
+
FROM runtime AS standalone
|
|
29
|
+
COPY package.json ./package.json
|
|
30
|
+
RUN bun install --production --ignore-scripts
|
|
31
|
+
COPY . .
|
|
32
|
+
|
|
33
|
+
# --- fleet: install published brain at pinned version (ops deploy) ---
|
|
34
|
+
FROM runtime AS fleet
|
|
35
|
+
ARG BRAIN_VERSION
|
|
36
|
+
RUN test -n "$BRAIN_VERSION" \
|
|
37
|
+
&& printf '{"name":"rover-pilot-runtime","private":true}\n' > package.json \
|
|
38
|
+
&& bun add @rizom/brain@$BRAIN_VERSION
|
|
@@ -2,7 +2,7 @@ service: rover
|
|
|
2
2
|
image: <%= ENV['IMAGE_REPOSITORY'] %>
|
|
3
3
|
|
|
4
4
|
servers:
|
|
5
|
-
|
|
5
|
+
web:
|
|
6
6
|
hosts:
|
|
7
7
|
- <%= ENV['SERVER_IP'] %>
|
|
8
8
|
|
|
@@ -12,7 +12,8 @@ proxy:
|
|
|
12
12
|
private_key_pem: PRIVATE_KEY_PEM
|
|
13
13
|
hosts:
|
|
14
14
|
- <%= ENV['BRAIN_DOMAIN'] %>
|
|
15
|
-
|
|
15
|
+
- preview.<%= ENV['BRAIN_DOMAIN'] %>
|
|
16
|
+
app_port: 80
|
|
16
17
|
healthcheck:
|
|
17
18
|
path: /health
|
|
18
19
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readJsonResponse,
|
|
3
|
+
requireEnv,
|
|
4
|
+
writeGitHubOutput,
|
|
5
|
+
writeGitHubEnv,
|
|
6
|
+
} from "./helpers";
|
|
7
|
+
|
|
8
|
+
const token = requireEnv("HCLOUD_TOKEN");
|
|
9
|
+
const instanceName = requireEnv("INSTANCE_NAME");
|
|
10
|
+
const sshKeyName = requireEnv("HCLOUD_SSH_KEY_NAME");
|
|
11
|
+
const serverType = requireEnv("HCLOUD_SERVER_TYPE");
|
|
12
|
+
const location = requireEnv("HCLOUD_LOCATION");
|
|
13
|
+
|
|
14
|
+
const headers: Record<string, string> = {
|
|
15
|
+
Authorization: `Bearer ${token}`,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
};
|
|
18
|
+
const baseUrl = "https://api.hetzner.cloud/v1";
|
|
19
|
+
const labelSelector = `brain=${instanceName}`;
|
|
20
|
+
const MAX_POLLS = 30;
|
|
21
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
interface HetznerServer {
|
|
24
|
+
id: number;
|
|
25
|
+
status: string;
|
|
26
|
+
public_net?: { ipv4?: { ip?: string } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sleep(ms: number): Promise<void> {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function listServers(): Promise<HetznerServer[]> {
|
|
34
|
+
const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
|
|
35
|
+
const response = await fetch(url, { headers });
|
|
36
|
+
const payload = (await readJsonResponse(
|
|
37
|
+
response,
|
|
38
|
+
"Hetzner server lookup",
|
|
39
|
+
)) as {
|
|
40
|
+
servers?: HetznerServer[];
|
|
41
|
+
};
|
|
42
|
+
if (!response.ok || !payload.servers) {
|
|
43
|
+
throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
|
|
44
|
+
}
|
|
45
|
+
return payload.servers;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function createServer(): Promise<HetznerServer> {
|
|
49
|
+
const response = await fetch(`${baseUrl}/servers`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers,
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
name: instanceName,
|
|
54
|
+
server_type: serverType,
|
|
55
|
+
image: "ubuntu-22.04",
|
|
56
|
+
location,
|
|
57
|
+
ssh_keys: [sshKeyName],
|
|
58
|
+
labels: { brain: instanceName },
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
const payload = (await readJsonResponse(
|
|
62
|
+
response,
|
|
63
|
+
"Hetzner server create",
|
|
64
|
+
)) as {
|
|
65
|
+
server?: HetznerServer;
|
|
66
|
+
};
|
|
67
|
+
if (!response.ok || !payload.server) {
|
|
68
|
+
throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
|
|
69
|
+
}
|
|
70
|
+
return payload.server;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function getServer(id: number): Promise<HetznerServer> {
|
|
74
|
+
const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
|
|
75
|
+
const payload = (await readJsonResponse(response, "Hetzner server poll")) as {
|
|
76
|
+
server?: HetznerServer;
|
|
77
|
+
};
|
|
78
|
+
if (!response.ok || !payload.server) {
|
|
79
|
+
throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
|
|
80
|
+
}
|
|
81
|
+
return payload.server;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let server: HetznerServer | undefined = (await listServers())[0];
|
|
85
|
+
server ??= await createServer();
|
|
86
|
+
|
|
87
|
+
let polls = 0;
|
|
88
|
+
while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
|
|
89
|
+
if (++polls > MAX_POLLS) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Server ${server.id} did not become ready after ${(MAX_POLLS * POLL_INTERVAL_MS) / 1000}s (status: ${server.status})`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (server.status === "error") {
|
|
95
|
+
throw new Error(`Server ${server.id} entered error state`);
|
|
96
|
+
}
|
|
97
|
+
console.log(
|
|
98
|
+
`Waiting for server ${server.id} (status: ${server.status}, poll ${polls}/${MAX_POLLS})...`,
|
|
99
|
+
);
|
|
100
|
+
await sleep(POLL_INTERVAL_MS);
|
|
101
|
+
server = await getServer(server.id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const serverIp = server.public_net?.ipv4?.ip;
|
|
105
|
+
if (!serverIp) {
|
|
106
|
+
throw new Error(`Server ${server.id} running but has no IPv4 address`);
|
|
107
|
+
}
|
|
108
|
+
writeGitHubOutput("server_ip", serverIp);
|
|
109
|
+
writeGitHubEnv("SERVER_IP", serverIp);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readJsonResponse, requireEnv } from "./helpers";
|
|
2
|
+
|
|
3
|
+
const token = requireEnv("CF_API_TOKEN");
|
|
4
|
+
const zoneId = requireEnv("CF_ZONE_ID");
|
|
5
|
+
const domain = requireEnv("BRAIN_DOMAIN");
|
|
6
|
+
const serverIp = requireEnv("SERVER_IP");
|
|
7
|
+
|
|
8
|
+
const headers: Record<string, string> = {
|
|
9
|
+
Authorization: `Bearer ${token}`,
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
};
|
|
12
|
+
const baseUrl = "https://api.cloudflare.com/client/v4";
|
|
13
|
+
|
|
14
|
+
interface CloudflareResult {
|
|
15
|
+
success: boolean;
|
|
16
|
+
result?: Array<{ id: string }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function upsertRecord(name: string): Promise<void> {
|
|
20
|
+
const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=A&name=${encodeURIComponent(name)}`;
|
|
21
|
+
const lookup = await fetch(lookupUrl, { headers });
|
|
22
|
+
const payload = (await readJsonResponse(
|
|
23
|
+
lookup,
|
|
24
|
+
"Cloudflare DNS lookup",
|
|
25
|
+
)) as CloudflareResult;
|
|
26
|
+
if (!lookup.ok || !payload.success) {
|
|
27
|
+
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const existing = payload.result?.[0];
|
|
31
|
+
const url = existing
|
|
32
|
+
? `${baseUrl}/zones/${zoneId}/dns_records/${existing.id}`
|
|
33
|
+
: `${baseUrl}/zones/${zoneId}/dns_records`;
|
|
34
|
+
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
method: existing ? "PUT" : "POST",
|
|
37
|
+
headers,
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
type: "A",
|
|
40
|
+
name,
|
|
41
|
+
content: serverIp,
|
|
42
|
+
ttl: 1,
|
|
43
|
+
proxied: true,
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
const result = (await readJsonResponse(
|
|
47
|
+
response,
|
|
48
|
+
"Cloudflare DNS upsert",
|
|
49
|
+
)) as CloudflareResult;
|
|
50
|
+
if (!response.ok || !result.success) {
|
|
51
|
+
throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(result)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await upsertRecord(domain);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseEnvSchema } from "./helpers";
|
|
3
|
+
|
|
4
|
+
const envSchemaPath = ".env.schema";
|
|
5
|
+
const schema = parseEnvSchema(readFileSync(envSchemaPath, "utf8"));
|
|
6
|
+
const requiredKeys = schema
|
|
7
|
+
.filter((entry) => entry.required)
|
|
8
|
+
.map((entry) => entry.key);
|
|
9
|
+
|
|
10
|
+
const missing: string[] = [];
|
|
11
|
+
for (const key of requiredKeys) {
|
|
12
|
+
if (!process.env[key]) {
|
|
13
|
+
missing.push(key);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (missing.length > 0) {
|
|
18
|
+
throw new Error(`Missing required secrets: ${missing.join(", ")}`);
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { parseEnvSchema } from "./helpers";
|
|
3
|
+
|
|
4
|
+
const envSchemaPath = ".env.schema";
|
|
5
|
+
const schema = parseEnvSchema(readFileSync(envSchemaPath, "utf8"));
|
|
6
|
+
const sensitiveKeys = schema
|
|
7
|
+
.filter((entry) => entry.sensitive)
|
|
8
|
+
.map((entry) => entry.key);
|
|
9
|
+
|
|
10
|
+
const lines: string[] = [];
|
|
11
|
+
for (const name of sensitiveKeys) {
|
|
12
|
+
const value = process.env[name] ?? "";
|
|
13
|
+
const escaped = String(value).replace(/'/g, "'\\''");
|
|
14
|
+
lines.push(`${name}='${escaped}'`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
mkdirSync(".kamal", { recursive: true });
|
|
18
|
+
writeFileSync(".kamal/secrets", lines.join("\n") + "\n");
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { requireEnv } from "./helpers";
|
|
4
|
+
|
|
5
|
+
const privateKey = requireEnv("KAMAL_SSH_PRIVATE_KEY");
|
|
6
|
+
|
|
7
|
+
let normalized = privateKey.replace(/\r\n/g, "\n").replace(/\\n/g, "\n");
|
|
8
|
+
if (!normalized.endsWith("\n")) {
|
|
9
|
+
normalized += "\n";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const sshDir = join(process.env["HOME"] ?? "/root", ".ssh");
|
|
13
|
+
mkdirSync(sshDir, { recursive: true });
|
|
14
|
+
writeFileSync(join(sshDir, "id_ed25519"), normalized, {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
mode: 0o600,
|
|
17
|
+
});
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
3. Add or edit `users/<handle>.yaml`.
|
|
6
6
|
4. Add the user to a cohort in `cohorts/*.yaml`.
|
|
7
7
|
5. Run `bunx brains-ops render <repo>`.
|
|
8
|
-
6. Run `bunx brains-ops
|
|
9
|
-
7.
|
|
10
|
-
8.
|
|
8
|
+
6. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
|
|
9
|
+
7. Run `bunx brains-ops cert:bootstrap <repo> <handle> --push-to gh`.
|
|
10
|
+
8. Run `bunx brains-ops secrets:push <repo> <handle>`.
|
|
11
|
+
9. Run `bunx brains-ops onboard <repo> <handle>`.
|
|
12
|
+
10. Verify the deployed rover core contract:
|
|
13
|
+
- `https://<handle>.rizom.ai/health` returns `200`
|
|
14
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` returns `401`
|
|
15
|
+
11. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
|
|
16
|
+
12. Hand the MCP connection details to the user.
|
|
17
|
+
13. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
|
|
@@ -33,6 +33,17 @@ When a push changes only deploy contract files and no generated `users/<handle>/
|
|
|
33
33
|
|
|
34
34
|
They are scaffolded from `@rizom/ops`, then versioned in this repo like any other deploy contract.
|
|
35
35
|
|
|
36
|
+
## Bootstrap flow
|
|
37
|
+
|
|
38
|
+
For a new pilot user, the operator bootstrap order is:
|
|
39
|
+
|
|
40
|
+
1. `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`
|
|
41
|
+
2. `bunx brains-ops cert:bootstrap <repo> <handle> --push-to gh`
|
|
42
|
+
3. `bunx brains-ops secrets:push <repo> <handle>`
|
|
43
|
+
4. `bunx brains-ops onboard <repo> <handle>`
|
|
44
|
+
|
|
45
|
+
`brains-ops cert:bootstrap` writes local cert artifacts under `.brains-ops/`, which stays repo-local and ignored by git.
|
|
46
|
+
|
|
36
47
|
## Upgrading operator behavior
|
|
37
48
|
|
|
38
49
|
When `@rizom/ops` changes the scaffolded deploy contract:
|
|
@@ -42,6 +53,16 @@ When `@rizom/ops` changes the scaffolded deploy contract:
|
|
|
42
53
|
3. review the resulting changes to `.env.schema`, `deploy/scripts/`, and workflows in git
|
|
43
54
|
4. commit the updated deploy artifacts together
|
|
44
55
|
|
|
56
|
+
## Rover-core verification notes
|
|
57
|
+
|
|
58
|
+
Rover core is MCP-only. Do not expect the bare domain to serve a website.
|
|
59
|
+
|
|
60
|
+
Use these checks after deploy:
|
|
61
|
+
|
|
62
|
+
- `https://<handle>.rizom.ai/health` should return `200`
|
|
63
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` should return `401 Unauthorized: Bearer token required`
|
|
64
|
+
- a bare `GET /` may also return `401`; that is expected for rover core and does not indicate a bad deploy
|
|
65
|
+
|
|
45
66
|
## Recovery notes
|
|
46
67
|
|
|
47
68
|
Document known failure modes, recovery steps, and operator notes here.
|