@rizom/ops 0.2.0-alpha.350 → 0.2.0-alpha.351
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 -0
- package/dist/brains-ops.js +176 -176
- package/dist/deploy.js +24 -24
- package/dist/entries/deploy.d.ts +2 -1
- package/dist/images.d.ts +12 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +199 -199
- package/dist/schema.d.ts +8 -0
- package/package.json +1 -1
- package/templates/rover-pilot/.github/workflows/build.yml +10 -1
- package/templates/rover-pilot/README.md +2 -2
- package/templates/rover-pilot/deploy/scripts/create-predeploy-backup.ts +25 -14
- package/templates/rover-pilot/deploy/scripts/helpers.ts +1 -0
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +51 -21
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +9 -6
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +35 -12
- package/templates/rover-pilot/docs/operator-playbook.md +14 -10
- package/templates/rover-pilot/pilot.yaml +1 -0
package/dist/schema.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export declare const handleSchema: z.ZodString;
|
|
|
4
4
|
export declare const secretNameSchema: z.ZodString;
|
|
5
5
|
export declare const agePublicKeySchema: z.ZodString;
|
|
6
6
|
export declare const CAPABILITY_BUNDLE_CONTRACT: "capability-bundles-v1";
|
|
7
|
+
export declare const SHARED_FLEET_IMAGE_CONTRACT: "shared-fleet-v1";
|
|
8
|
+
export declare const ISOLATED_SITE_IMAGE_CONTRACT: "isolated-sites-v1";
|
|
9
|
+
export declare const imageContractSchema: z.ZodEnum<{
|
|
10
|
+
"shared-fleet-v1": "shared-fleet-v1";
|
|
11
|
+
"isolated-sites-v1": "isolated-sites-v1";
|
|
12
|
+
}>;
|
|
13
|
+
export type ImageContract = z.output<typeof imageContractSchema>;
|
|
7
14
|
export declare const canonicalBundleIdSchema: z.ZodEnum<{
|
|
8
15
|
core: "core";
|
|
9
16
|
media: "media";
|
|
@@ -31,6 +38,7 @@ export declare const profileKindSchema: z.ZodEnum<{
|
|
|
31
38
|
export interface PilotConfig {
|
|
32
39
|
brainVersion: string;
|
|
33
40
|
bundleContract: typeof CAPABILITY_BUNDLE_CONTRACT;
|
|
41
|
+
imageContract: ImageContract;
|
|
34
42
|
bundles: CanonicalBundleId[];
|
|
35
43
|
add?: string[] | undefined;
|
|
36
44
|
remove?: string[] | undefined;
|
package/package.json
CHANGED
|
@@ -8,9 +8,17 @@ on:
|
|
|
8
8
|
required: false
|
|
9
9
|
type: string
|
|
10
10
|
site_packages:
|
|
11
|
-
description:
|
|
11
|
+
description: "Space-separated exact site/theme packages for one explicit image build. Shared-fleet automatic builds derive the version-wide package union from desired state."
|
|
12
12
|
required: false
|
|
13
13
|
type: string
|
|
14
|
+
image_contract:
|
|
15
|
+
description: Image contract for an explicit version build
|
|
16
|
+
required: false
|
|
17
|
+
type: choice
|
|
18
|
+
options:
|
|
19
|
+
- isolated-sites-v1
|
|
20
|
+
- shared-fleet-v1
|
|
21
|
+
default: isolated-sites-v1
|
|
14
22
|
overwrite:
|
|
15
23
|
description: Rebuild an existing tag in place. Published tags are immutable; confirm only when replacing a tag whose containers were never deployed.
|
|
16
24
|
required: false
|
|
@@ -59,6 +67,7 @@ jobs:
|
|
|
59
67
|
env:
|
|
60
68
|
BRAIN_VERSION_INPUT: ${{ inputs.brain_version || '' }}
|
|
61
69
|
SITE_PACKAGES_INPUT: ${{ inputs.site_packages || '' }}
|
|
70
|
+
IMAGE_CONTRACT_INPUT: ${{ inputs.image_contract || 'isolated-sites-v1' }}
|
|
62
71
|
ALLOW_TAG_OVERWRITE: ${{ inputs.overwrite && 'true' || 'false' }}
|
|
63
72
|
run: bun deploy/scripts/resolve-missing-images.ts
|
|
64
73
|
|
|
@@ -30,8 +30,8 @@ The repo also checks in its deploy contract:
|
|
|
30
30
|
|
|
31
31
|
`.env.schema` is the single source of truth for required and sensitive deploy vars.
|
|
32
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.
|
|
33
|
-
The default
|
|
34
|
-
When
|
|
33
|
+
`pilot.yaml.imageContract` makes image topology explicit. The default `shared-fleet-v1` contract publishes one `brain-${brainVersion}` image per effective Brain version and installs the union of exact site/theme package pins required by instances on that version. `isolated-sites-v1` remains available only for fleets that deliberately require package-isolated images.
|
|
34
|
+
When an effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI builds every missing shared version image, refreshes generated user env files, and redeploys affected users. Every external site and theme package keeps its own exact version pin; under the shared contract, change that package set only together with a fresh Brain version so published tags remain immutable.
|
|
35
35
|
When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
|
|
36
36
|
|
|
37
37
|
## Commands
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
3
|
import { appendFileSync } from "node:fs";
|
|
4
|
+
import { isPlainRecord } from "@brains/utils/predicates";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The slice of `@libsql/client` this script uses.
|
|
8
|
+
*
|
|
9
|
+
* Declared here because the import is dynamic — the package is optional, and
|
|
10
|
+
* only the libsql quick-check driver needs it.
|
|
11
|
+
*/
|
|
12
|
+
interface LibsqlModule {
|
|
13
|
+
createClient(options: { url: string }): {
|
|
14
|
+
execute(sql: string): Promise<{
|
|
15
|
+
rows: Array<Record<string | number, unknown>>;
|
|
16
|
+
}>;
|
|
17
|
+
close(): void;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
export const DEFAULT_PREDEPLOY_BACKUP_RETENTION_COUNT = 5;
|
|
6
22
|
export const PREDEPLOY_BACKUP_TOOL_VERSION = "brains-predeploy-backup-v1";
|
|
@@ -157,10 +173,8 @@ async function quickCheck(
|
|
|
157
173
|
if (driver === "bun") {
|
|
158
174
|
const database = new Database(path, { readonly: true });
|
|
159
175
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
unknown
|
|
163
|
-
> | null;
|
|
176
|
+
const result: unknown = database.query("PRAGMA quick_check").get();
|
|
177
|
+
const row = isPlainRecord(result) ? result : null;
|
|
164
178
|
return String(row?.["quick_check"] ?? row?.["0"] ?? "");
|
|
165
179
|
} finally {
|
|
166
180
|
database.close(false);
|
|
@@ -168,14 +182,10 @@ async function quickCheck(
|
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
const moduleName = "@libsql/client";
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}>;
|
|
176
|
-
close(): void;
|
|
177
|
-
};
|
|
178
|
-
};
|
|
185
|
+
// Annotated rather than asserted: a dynamic import of an optional dependency
|
|
186
|
+
// resolves to `any`, so naming the slice this script uses is a checked
|
|
187
|
+
// assignment instead of a claim about the whole module.
|
|
188
|
+
const libsql: LibsqlModule = await import(moduleName);
|
|
179
189
|
const client = libsql.createClient({ url: `file:${path}` });
|
|
180
190
|
try {
|
|
181
191
|
const result = await client.execute("PRAGMA quick_check");
|
|
@@ -198,9 +208,10 @@ function vectorDigestDatabase(database: Database): {
|
|
|
198
208
|
const counts: Record<string, number> = {};
|
|
199
209
|
for (const [table, order] of tables) {
|
|
200
210
|
hasher.update(`table:${table}\n`);
|
|
201
|
-
const
|
|
211
|
+
const selected: unknown[] = database
|
|
202
212
|
.query(`SELECT * FROM ${table} ORDER BY ${order}`)
|
|
203
|
-
.all()
|
|
213
|
+
.all();
|
|
214
|
+
const rows = selected.filter(isPlainRecord);
|
|
204
215
|
counts[table] = rows.length;
|
|
205
216
|
for (const row of rows) {
|
|
206
217
|
for (const [key, value] of Object.entries(row)) {
|
|
@@ -30,19 +30,54 @@ function sleep(ms: number): Promise<void> {
|
|
|
30
30
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* These scripts are copied verbatim into generated projects, so they stay
|
|
35
|
+
* dependency-free: the API payloads are checked by hand rather than with a
|
|
36
|
+
* schema library. readJsonResponse returns unknown; asserting the Hetzner
|
|
37
|
+
* response shape onto it meant a changed or error payload surfaced as an
|
|
38
|
+
* undefined field later instead of a failed lookup here.
|
|
39
|
+
*/
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isHetznerServer(value: unknown): value is HetznerServer {
|
|
45
|
+
if (!isRecord(value)) return false;
|
|
46
|
+
if (typeof value["id"] !== "number") return false;
|
|
47
|
+
if (typeof value["status"] !== "string") return false;
|
|
48
|
+
const publicNet = value["public_net"];
|
|
49
|
+
if (publicNet === undefined) return true;
|
|
50
|
+
if (!isRecord(publicNet)) return false;
|
|
51
|
+
const ipv4 = publicNet["ipv4"];
|
|
52
|
+
if (ipv4 === undefined) return true;
|
|
53
|
+
if (!isRecord(ipv4)) return false;
|
|
54
|
+
const ip = ipv4["ip"];
|
|
55
|
+
return ip === undefined || typeof ip === "string";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readServer(payload: unknown): HetznerServer | undefined {
|
|
59
|
+
if (!isRecord(payload)) return undefined;
|
|
60
|
+
const server = payload["server"];
|
|
61
|
+
return isHetznerServer(server) ? server : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readServers(payload: unknown): HetznerServer[] | undefined {
|
|
65
|
+
if (!isRecord(payload)) return undefined;
|
|
66
|
+
const servers = payload["servers"];
|
|
67
|
+
return Array.isArray(servers) && servers.every(isHetznerServer)
|
|
68
|
+
? servers
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
async function listServers(): Promise<HetznerServer[]> {
|
|
34
73
|
const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
|
|
35
74
|
const response = await fetch(url, { headers });
|
|
36
|
-
const payload =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)) as {
|
|
40
|
-
servers?: HetznerServer[];
|
|
41
|
-
};
|
|
42
|
-
if (!response.ok || !payload.servers) {
|
|
75
|
+
const payload = await readJsonResponse(response, "Hetzner server lookup");
|
|
76
|
+
const servers = readServers(payload);
|
|
77
|
+
if (!response.ok || !servers) {
|
|
43
78
|
throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
|
|
44
79
|
}
|
|
45
|
-
return
|
|
80
|
+
return servers;
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
async function createServer(): Promise<HetznerServer> {
|
|
@@ -58,27 +93,22 @@ async function createServer(): Promise<HetznerServer> {
|
|
|
58
93
|
labels: { brain: instanceName },
|
|
59
94
|
}),
|
|
60
95
|
});
|
|
61
|
-
const payload =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)) as {
|
|
65
|
-
server?: HetznerServer;
|
|
66
|
-
};
|
|
67
|
-
if (!response.ok || !payload.server) {
|
|
96
|
+
const payload = await readJsonResponse(response, "Hetzner server create");
|
|
97
|
+
const server = readServer(payload);
|
|
98
|
+
if (!response.ok || !server) {
|
|
68
99
|
throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
|
|
69
100
|
}
|
|
70
|
-
return
|
|
101
|
+
return server;
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
async function getServer(id: number): Promise<HetznerServer> {
|
|
74
105
|
const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
|
|
75
|
-
const payload =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!response.ok || !payload.server) {
|
|
106
|
+
const payload = await readJsonResponse(response, "Hetzner server poll");
|
|
107
|
+
const server = readServer(payload);
|
|
108
|
+
if (!response.ok || !server) {
|
|
79
109
|
throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
|
|
80
110
|
}
|
|
81
|
-
return
|
|
111
|
+
return server;
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
let server: HetznerServer | undefined = (await listServers())[0];
|
|
@@ -5,8 +5,8 @@ import { derivePreviewDomain, loadPilotRegistry } from "@rizom/ops";
|
|
|
5
5
|
import {
|
|
6
6
|
parseEnvFile,
|
|
7
7
|
requireEnv,
|
|
8
|
+
runtimeImageTag,
|
|
8
9
|
sitePackagesFor,
|
|
9
|
-
siteImageTag,
|
|
10
10
|
writeGitHubOutput,
|
|
11
11
|
} from "./helpers";
|
|
12
12
|
|
|
@@ -43,12 +43,15 @@ const wwwDomain = isFleetDomain(
|
|
|
43
43
|
|
|
44
44
|
const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
// `brain-{version}
|
|
48
|
-
//
|
|
49
|
-
// build uses so the tag we wait for and run matches exactly what was pushed.
|
|
46
|
+
// Build and deploy share one tag function. A shared fleet always resolves to
|
|
47
|
+
// `brain-{version}`; an explicitly isolated fleet may include the instance's
|
|
48
|
+
// exact site package set in its tag.
|
|
50
49
|
const sitePackages = sitePackagesFor(user.siteOverride);
|
|
51
|
-
const imageTag =
|
|
50
|
+
const imageTag = runtimeImageTag(
|
|
51
|
+
registry.pilot.imageContract,
|
|
52
|
+
brainVersion,
|
|
53
|
+
sitePackages,
|
|
54
|
+
);
|
|
52
55
|
|
|
53
56
|
const outputs: Record<string, string> = {
|
|
54
57
|
brain_version: brainVersion,
|
|
@@ -16,18 +16,43 @@ interface CloudflareResult {
|
|
|
16
16
|
result?: Array<{ id: string }>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Copied verbatim into generated projects, so this stays dependency-free and
|
|
21
|
+
* checks the payload by hand. readJsonResponse returns unknown; asserting
|
|
22
|
+
* CloudflareResult onto it meant an error body read as `success: undefined`
|
|
23
|
+
* rather than failing here.
|
|
24
|
+
*/
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readCloudflareResult(payload: unknown): CloudflareResult | undefined {
|
|
30
|
+
if (!isRecord(payload)) return undefined;
|
|
31
|
+
if (typeof payload["success"] !== "boolean") return undefined;
|
|
32
|
+
const result = payload["result"];
|
|
33
|
+
if (result === undefined) return { success: payload["success"] };
|
|
34
|
+
if (
|
|
35
|
+
!Array.isArray(result) ||
|
|
36
|
+
!result.every(
|
|
37
|
+
(entry): entry is { id: string } =>
|
|
38
|
+
isRecord(entry) && typeof entry["id"] === "string",
|
|
39
|
+
)
|
|
40
|
+
) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return { success: payload["success"], result };
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
async function findRecordId(
|
|
20
47
|
name: string,
|
|
21
48
|
type: "A" | "CNAME",
|
|
22
49
|
): Promise<string | undefined> {
|
|
23
50
|
const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
|
|
24
51
|
const lookup = await fetch(lookupUrl, { headers });
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!lookup.ok || !payload.success) {
|
|
30
|
-
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
|
|
52
|
+
const raw = await readJsonResponse(lookup, "Cloudflare DNS lookup");
|
|
53
|
+
const payload = readCloudflareResult(raw);
|
|
54
|
+
if (!lookup.ok || !payload?.success) {
|
|
55
|
+
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(raw)}`);
|
|
31
56
|
}
|
|
32
57
|
|
|
33
58
|
return payload.result?.[0]?.id;
|
|
@@ -53,12 +78,10 @@ async function upsertRecord(name: string): Promise<void> {
|
|
|
53
78
|
proxied: true,
|
|
54
79
|
}),
|
|
55
80
|
});
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!response.ok || !result.success) {
|
|
61
|
-
throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(result)}`);
|
|
81
|
+
const raw = await readJsonResponse(response, "Cloudflare DNS upsert");
|
|
82
|
+
const result = readCloudflareResult(raw);
|
|
83
|
+
if (!response.ok || !result?.success) {
|
|
84
|
+
throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(raw)}`);
|
|
62
85
|
}
|
|
63
86
|
}
|
|
64
87
|
|
|
@@ -16,10 +16,12 @@ Treat these as checked-in deploy artifacts in the pilot repo:
|
|
|
16
16
|
`.env.schema` is the single source of truth for required and sensitive deploy vars.
|
|
17
17
|
The deploy scripts and workflows should read from that contract instead of inventing a second list.
|
|
18
18
|
|
|
19
|
-
The
|
|
19
|
+
The pilot declares its image topology with `pilot.yaml.imageContract`:
|
|
20
20
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
21
|
+
- `shared-fleet-v1` publishes one `brain-${brainVersion}` image for each effective Brain version
|
|
22
|
+
- each shared image contains the union of exact site/theme package pins required by instances on that version
|
|
23
|
+
- conflicting versions of one package fail image resolution before build
|
|
24
|
+
- `isolated-sites-v1` is an explicit alternative for fleets that require package-isolated tags
|
|
23
25
|
- generated `users/<handle>/.env` carries `BRAIN_VERSION=<brainVersion>`
|
|
24
26
|
- build and deploy derive the same effective image tag from the resolved registry
|
|
25
27
|
|
|
@@ -27,7 +29,7 @@ The default pilot image tag is `brain-${brainVersion}`:
|
|
|
27
29
|
|
|
28
30
|
When `pilot.yaml.brainVersion` changes and you push:
|
|
29
31
|
|
|
30
|
-
1. build publishes
|
|
32
|
+
1. build publishes each missing version image with its declared package union
|
|
31
33
|
2. reconcile refreshes generated `users/<handle>/.env`
|
|
32
34
|
3. deploy runs for handles whose generated config changed
|
|
33
35
|
4. generated file commits happen once in a final aggregation step after the deploy matrix finishes
|
|
@@ -260,16 +262,18 @@ siteOverride:
|
|
|
260
262
|
themeVersion: <exact-theme-version>
|
|
261
263
|
```
|
|
262
264
|
|
|
263
|
-
Missing external package versions fail desired-state validation.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
Missing external package versions fail desired-state validation. Under
|
|
266
|
+
`shared-fleet-v1`, every instance on one Brain version uses the same image and exact
|
|
267
|
+
package union. Change a site/theme package pin only together with a fresh Brain version;
|
|
268
|
+
published `brain-${brainVersion}` tags remain immutable. Conflicting pins for one package
|
|
269
|
+
on the same Brain version fail before build. Bundled `@brains/*` themes omit
|
|
270
|
+
`themeVersion` because they are not installed as separate packages.
|
|
267
271
|
|
|
268
272
|
### Custom-package canary and rollback
|
|
269
273
|
|
|
270
274
|
1. Confirm the exact site/theme versions are public-installable without npm credentials.
|
|
271
|
-
2. Apply the exact package names and versions to one healthy canonical site canary.
|
|
272
|
-
3.
|
|
275
|
+
2. Apply the exact package names and versions to one healthy canonical site canary and select a fresh Brain version for that package set.
|
|
276
|
+
3. Push desired state and let the normal Build → Reconcile → Deploy chain create the shared version image and update the canary.
|
|
273
277
|
4. Run `bunx brains-ops verify-user . <handle>`.
|
|
274
278
|
5. Manually verify the site, theme, Studio, content sync, and passkey sign-in before adding more users.
|
|
275
279
|
|