@rizom/ops 0.2.0-alpha.0 → 0.2.0-alpha.2

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.
@@ -4,6 +4,7 @@ export interface ParsedArgs {
4
4
  flags: {
5
5
  help?: boolean | undefined;
6
6
  version?: boolean | undefined;
7
+ dryRun?: boolean | undefined;
7
8
  };
8
9
  }
9
10
  export declare function parseArgs(argv: string[]): ParsedArgs;
@@ -1,5 +1,6 @@
1
1
  import type { LoadPilotRegistryOptions } from "./load-registry";
2
2
  import type { ParsedArgs } from "./parse-args";
3
+ import { type RunCommand as SecretRunCommand } from "./secrets-push";
3
4
  import type { UserRunner } from "./user-runner";
4
5
  export interface CommandResult {
5
6
  success: boolean;
@@ -7,5 +8,8 @@ export interface CommandResult {
7
8
  }
8
9
  export interface CommandDependencies extends LoadPilotRegistryOptions {
9
10
  runner?: UserRunner;
11
+ env?: NodeJS.ProcessEnv | undefined;
12
+ logger?: ((message: string) => void) | undefined;
13
+ secretRunCommand?: SecretRunCommand | undefined;
10
14
  }
11
15
  export declare function runCommand(parsed: ParsedArgs, dependencies?: CommandDependencies): Promise<CommandResult>;
@@ -0,0 +1,16 @@
1
+ export interface SecretsPushOptions {
2
+ env?: NodeJS.ProcessEnv | undefined;
3
+ logger?: ((message: string) => void) | undefined;
4
+ dryRun?: boolean | undefined;
5
+ runCommand?: RunCommand | undefined;
6
+ }
7
+ export interface SecretsPushResult {
8
+ pushedKeys: string[];
9
+ skippedKeys: string[];
10
+ dryRun?: boolean | undefined;
11
+ }
12
+ export type RunCommand = (command: string, args: string[], options?: {
13
+ stdin?: string;
14
+ env?: NodeJS.ProcessEnv;
15
+ }) => Promise<void>;
16
+ export declare function pushPilotSecrets(rootDir: string, handle: string, options?: SecretsPushOptions): Promise<SecretsPushResult>;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.0-alpha.0",
7
+ "version": "0.2.0-alpha.2",
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": "./src/entries/deploy.ts",
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
 
@@ -38,5 +38,6 @@ When a push changes only deploy contract files, CI prints `No affected user conf
38
38
  - `brains-ops init <repo>`
39
39
  - `brains-ops render <repo>`
40
40
  - `brains-ops onboard <repo> <handle>`
41
+ - `brains-ops secrets:push <repo> <handle>`
41
42
  - `brains-ops reconcile-cohort <repo> <cohort>`
42
43
  - `brains-ops reconcile-all <repo>`
@@ -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,7 @@
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 onboard <repo> <handle>`.
9
- 7. 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.
10
- 8. Hand the MCP connection details to the user.
8
+ 6. Run `bunx brains-ops secrets:push <repo> <handle>`.
9
+ 7. Run `bunx brains-ops onboard <repo> <handle>`.
10
+ 8. 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.
11
+ 9. Hand the MCP connection details to the user.