@forgezero/agent 0.1.40 → 0.1.42

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 (64) hide show
  1. package/README.md +573 -424
  2. package/dist/agent-heartbeat.js +6 -3
  3. package/dist/agent-update-helper.js +5 -2
  4. package/dist/agent-update.js +5 -2
  5. package/dist/bootstrap.d.ts +17 -8
  6. package/dist/bootstrap.js +1504 -512
  7. package/dist/cli/agent-install.d.ts +6 -5
  8. package/dist/cli/cloudflare-bootstrap.d.ts +12 -1
  9. package/dist/cli/maintenance.d.ts +23 -0
  10. package/dist/cli/run.d.ts +3 -1
  11. package/dist/cli/session-store.d.ts +5 -0
  12. package/dist/cloudflare-bootstrap.d.ts +73 -35
  13. package/dist/cloudflare-bootstrap.js +587 -90
  14. package/dist/cloudflare-edge.d.ts +64 -12
  15. package/dist/cloudflare-edge.js +103 -8
  16. package/dist/community-rehearsal-host.d.ts +51 -0
  17. package/dist/community-rehearsal-host.js +272 -0
  18. package/dist/credential-schema.d.ts +54 -0
  19. package/dist/credential-schema.js +47 -0
  20. package/dist/definition.d.ts +31 -5
  21. package/dist/definition.js +271 -44
  22. package/dist/deploy-file.js +294 -68
  23. package/dist/deployment-runner.js +18 -5
  24. package/dist/deployment.d.ts +13 -1
  25. package/dist/fz-agent.js +9162 -7564
  26. package/dist/fz-git-ssh.js +122 -0
  27. package/dist/fz.js +5679 -5077
  28. package/dist/git-ssh.d.ts +5 -0
  29. package/dist/guest-enrolment.d.ts +2 -0
  30. package/dist/guest-enrolment.js +1 -0
  31. package/dist/host-maintenance.d.ts +39 -0
  32. package/dist/host-maintenance.js +135 -0
  33. package/dist/index.d.ts +4 -2
  34. package/dist/mesh-connector.d.ts +16 -0
  35. package/dist/mesh-connector.js +46 -0
  36. package/dist/metal-bootstrap.js +145 -7
  37. package/dist/metal-helper-socket.js +61 -31
  38. package/dist/metal-provision.d.ts +2 -2
  39. package/dist/metal-provision.js +62 -32
  40. package/dist/operator-bootstrap.d.ts +90 -0
  41. package/dist/operator-bootstrap.js +5704 -0
  42. package/dist/otel-collector.d.ts +18 -0
  43. package/dist/pipeline.d.ts +3 -2
  44. package/dist/pipeline.js +1 -1
  45. package/dist/platform-bootstrap-runtime.d.ts +39 -21
  46. package/dist/platform-bootstrap-runtime.js +182 -59
  47. package/dist/platform-fleet-verification.d.ts +19 -0
  48. package/dist/platform-fleet-verification.js +3873 -0
  49. package/dist/platform-genesis-config.d.ts +7 -0
  50. package/dist/platform-genesis.d.ts +17 -0
  51. package/dist/provision.d.ts +76 -3
  52. package/dist/provision.js +1061 -229
  53. package/dist/recovery-host.d.ts +7 -0
  54. package/dist/recovery-host.js +124 -0
  55. package/dist/service-supervisor.d.ts +42 -0
  56. package/dist/software-helper.d.ts +4 -0
  57. package/dist/software-helper.js +865 -63
  58. package/dist/software.d.ts +14 -3
  59. package/dist/software.js +163 -37
  60. package/dist/ssh-bootstrap.d.ts +97 -0
  61. package/dist/supervised-app.d.ts +2 -0
  62. package/dist/version.d.ts +1 -1
  63. package/package.json +175 -164
  64. package/schema/{deploy-v2.json → deploy-v3.json} +53 -6
@@ -0,0 +1,18 @@
1
+ export declare const FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
2
+ export declare const FORGEZERO_OTEL_COLLECTOR_VERSION = "0.157.0";
3
+ export declare const FORGEZERO_OTEL_COLLECTOR_SHA256 = "2937cf24892af55b143c072fddece17862239cf78280620029276493eb81beae";
4
+ export interface OtelCollectorHost {
5
+ write(path: string, content: string, mode: number): void;
6
+ exec(argv: readonly string[]): Promise<{
7
+ exitCode: number;
8
+ output?: string;
9
+ stdout?: string;
10
+ stderr?: string;
11
+ }>;
12
+ }
13
+ export declare function renderForgeZeroOtelCollector(exportEndpoint: string): {
14
+ config: string;
15
+ unit: string;
16
+ };
17
+ /** Install one exact official collector release and converge its separate unit. */
18
+ export declare function ensureForgeZeroOtelCollector(host: OtelCollectorHost, exportEndpoint: string): Promise<void>;
@@ -36,7 +36,8 @@
36
36
  export type StepOutcome = 'ok' | 'failed' | 'skipped';
37
37
  export interface PipelineStep {
38
38
  name: string;
39
- run: string;
39
+ /** Exact executable and arguments. No shell parses, expands or joins these values. */
40
+ exec: readonly string[];
40
41
  /** Secret names this step needs. Nothing it does not name is in its env. */
41
42
  secrets?: readonly string[];
42
43
  /** Run even when an earlier step failed — cleanup, teardown, notifications. */
@@ -80,7 +81,7 @@ export interface RunOptions {
80
81
  secret(name: string): Promise<string>;
81
82
  /** Spawns a step. Injected, so a pipeline is testable without a shell. */
82
83
  exec(input: {
83
- command: string;
84
+ argv: readonly string[];
84
85
  env: Record<string, string>;
85
86
  timeoutMs?: number;
86
87
  }): Promise<{
package/dist/pipeline.js CHANGED
@@ -54,7 +54,7 @@ async function runPipeline(options) {
54
54
  let exitCode;
55
55
  let output;
56
56
  try {
57
- const result = await options.exec({ command: step.run, env, timeoutMs: step.timeoutMs });
57
+ const result = await options.exec({ argv: step.exec, env, timeoutMs: step.timeoutMs });
58
58
  exitCode = result.exitCode;
59
59
  output = result.output;
60
60
  } catch (cause) {
@@ -1,14 +1,25 @@
1
- /** Typed, side-effect-free host runtime plans used by `fz bootstrap platform`. */
1
+ /** Typed host runtime plans used by `fz bootstrap platform`. */
2
2
  export type PlatformSoftwareProfile = 'platform-db-api' | 'platform-api';
3
3
  export type PlatformDatabaseRole = 'master' | 'joiner' | 'none';
4
4
  export type ApiSlot = 'blue' | 'green';
5
+ export type PlatformBootstrapEmail = {
6
+ provider: 'smtp';
7
+ host: string;
8
+ port: number;
9
+ user: string;
10
+ from: string;
11
+ } | {
12
+ provider: 'jetemail';
13
+ from: string;
14
+ eu: boolean;
15
+ };
5
16
  export interface PlatformSharedEnvironment {
6
17
  softwareProfile: PlatformSoftwareProfile;
7
18
  databaseRole: PlatformDatabaseRole;
8
19
  databaseCoordinators: string[];
9
20
  databaseAddress?: string;
10
21
  databaseMaster?: string;
11
- databaseNetworkMode: 'private-lan' | 'cloudflare-warp';
22
+ databaseNetworkMode: 'private-lan';
12
23
  databaseReplicationFactor: number;
13
24
  databaseWriteConcern: number;
14
25
  databaseUser: string;
@@ -30,12 +41,7 @@ export interface PlatformSharedEnvironment {
30
41
  otlpFlushIntervalMs: number;
31
42
  otlpTraceSampleRatio: number;
32
43
  custodianEmail?: string;
33
- smtp?: {
34
- host: string;
35
- port: number;
36
- user?: string;
37
- from: string;
38
- };
44
+ email?: PlatformBootstrapEmail;
39
45
  backup?: {
40
46
  endpoint: string;
41
47
  region: string;
@@ -48,30 +54,28 @@ export interface PlatformSharedEnvironment {
48
54
  kvNamespaceId: string;
49
55
  tunnelId: string;
50
56
  tunnelService: string;
51
- warp?: {
52
- organization: string;
53
- virtualNetworkId: string;
54
- deviceProfileId: string;
55
- };
57
+ };
58
+ realtime?: {
59
+ workerScriptName: string;
60
+ endpoint: string;
61
+ producer: string;
56
62
  };
57
63
  repository: string;
58
64
  branch: string;
59
65
  deployProfile: string;
60
66
  }
61
- export declare function validatePlatformSharedEnvironment(input: PlatformSharedEnvironment, options?: {
62
- allowPendingCloudflareHandoff?: boolean;
63
- }): PlatformSharedEnvironment;
67
+ export declare function validatePlatformSharedEnvironment(input: PlatformSharedEnvironment): PlatformSharedEnvironment;
64
68
  /** Render only non-secret runtime coordinates. Passwords/tokens have no field in this contract. */
65
69
  export declare function renderPlatformSharedEnvironment(input: PlatformSharedEnvironment): string;
66
70
  export interface SystemdCredentialSpec {
67
- name: 'arangodb-jwt' | 'seed-sync-root' | 'bootstrap-smtp-password' | 'cloudflare-kv-token' | 'cloudflare-network-token';
71
+ name: 'arangodb-jwt' | 'seed-sync-root' | 'bootstrap-smtp-password' | 'bootstrap-jetemail-api-key' | 'CF_API_TOKEN' | 'REALTIME_PUBLISH_SECRET' | 'REALTIME_TICKET_SECRET';
68
72
  encryptedPath: string;
69
73
  required: boolean;
70
74
  }
71
75
  export declare function platformApiCredentialSpecs(options: {
72
- smtp: boolean;
76
+ emailProvider?: PlatformBootstrapEmail['provider'];
73
77
  cloudflareKv: boolean;
74
- cloudflareNetwork: boolean;
78
+ realtime: boolean;
75
79
  }): SystemdCredentialSpec[];
76
80
  export interface ApiRuntimeRenderOptions {
77
81
  serviceUser: string;
@@ -99,8 +103,8 @@ export declare function renderPlatformNginx(input: {
99
103
  site: string;
100
104
  };
101
105
  export interface ActivationBoundary {
102
- command: '/usr/local/libexec/forgezero-activate';
103
- argv: [string];
106
+ command: '/usr/local/lib/forgezero/agent/fz-agent';
107
+ argv: ['platform-activate', '--config=/etc/forgezero/deploy-activation.json', string];
104
108
  runAs: 'root';
105
109
  invoker: 'forgezero-runner';
106
110
  }
@@ -124,6 +128,20 @@ export declare function renderPlatformActivationFiles(input: PlatformActivationC
124
128
  helper: string;
125
129
  sudoers: string;
126
130
  };
131
+ export interface PlatformActivationCommandResult {
132
+ exitCode: number;
133
+ output: string;
134
+ }
135
+ export type PlatformActivationExec = (argv: readonly string[]) => Promise<PlatformActivationCommandResult>;
136
+ /** Agent-native blue/green activation with health and nginx rollback. */
137
+ export declare function activatePlatformRelease(config: PlatformActivationConfig, requestedRelease: string, options?: {
138
+ exec?: PlatformActivationExec;
139
+ fetch?: typeof fetch;
140
+ sleep?: (ms: number) => Promise<void>;
141
+ }): Promise<{
142
+ release: string;
143
+ slot: ApiSlot;
144
+ }>;
127
145
  export interface LocalOtlpProofPlan {
128
146
  unitCheck: {
129
147
  command: 'systemctl';
@@ -1,4 +1,21 @@
1
1
  // src/platform-bootstrap-runtime.ts
2
+ import {
3
+ chmodSync,
4
+ chownSync,
5
+ copyFileSync,
6
+ existsSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ realpathSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ symlinkSync,
16
+ writeFileSync
17
+ } from "node:fs";
18
+ import { join } from "node:path";
2
19
  var safeAtom = (name, value) => {
3
20
  if (!value || /[\0\r\n]/.test(value))
4
21
  throw new Error(`${name} must be non-empty and single-line.`);
@@ -35,7 +52,7 @@ var systemdValue = (name, raw) => {
35
52
  const value = raw;
36
53
  return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$")}"`;
37
54
  };
38
- function validatePlatformSharedEnvironment(input, options = {}) {
55
+ function validatePlatformSharedEnvironment(input) {
39
56
  if (input.softwareProfile === "platform-api" !== (input.databaseRole === "none")) {
40
57
  throw new Error("platform-api requires database role none; platform-db-api requires master or joiner.");
41
58
  }
@@ -45,14 +62,26 @@ function validatePlatformSharedEnvironment(input, options = {}) {
45
62
  const coordinators = input.databaseCoordinators.map(privateCoordinator);
46
63
  if (new Set(coordinators).size !== coordinators.length)
47
64
  throw new Error("databaseCoordinators must be unique.");
48
- if (!["private-lan", "cloudflare-warp"].includes(input.databaseNetworkMode)) {
49
- throw new Error("Database networking must be private-lan or cloudflare-warp.");
65
+ if (input.databaseNetworkMode !== "private-lan") {
66
+ throw new Error("Attended platform bootstrap supports only private-lan database networking.");
50
67
  }
51
68
  boundedInteger("databaseReplicationFactor", input.databaseReplicationFactor, 1, 16);
52
69
  boundedInteger("databaseWriteConcern", input.databaseWriteConcern, 1, 16);
53
70
  if (input.databaseWriteConcern > input.databaseReplicationFactor) {
54
71
  throw new Error("databaseWriteConcern cannot exceed databaseReplicationFactor.");
55
72
  }
73
+ if (input.email?.provider === "smtp") {
74
+ safeAtom("email.host", input.email.host);
75
+ boundedInteger("email.port", input.email.port, 1, 65535);
76
+ safeAtom("email.user", input.email.user);
77
+ safeAtom("email.from", input.email.from);
78
+ } else if (input.email?.provider === "jetemail") {
79
+ safeAtom("email.from", input.email.from);
80
+ if (typeof input.email.eu !== "boolean")
81
+ throw new Error("JetEmail eu must be boolean.");
82
+ } else if (input.email !== undefined) {
83
+ throw new Error("Bootstrap email provider must be smtp or jetemail.");
84
+ }
56
85
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
57
86
  boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
58
87
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
@@ -85,13 +114,6 @@ function validatePlatformSharedEnvironment(input, options = {}) {
85
114
  if (url.username || url.password || url.hash)
86
115
  throw new Error("Seed peers cannot contain credentials or fragments.");
87
116
  }
88
- if (input.smtp) {
89
- safeAtom("smtp.host", input.smtp.host);
90
- boundedInteger("smtp.port", input.smtp.port, 1, 65535);
91
- safeAtom("smtp.from", input.smtp.from);
92
- if (input.smtp.user)
93
- safeAtom("smtp.user", input.smtp.user);
94
- }
95
117
  if (input.backup) {
96
118
  httpsOrigin("backup.endpoint", input.backup.endpoint);
97
119
  for (const [name, value] of Object.entries(input.backup))
@@ -104,14 +126,12 @@ function validatePlatformSharedEnvironment(input, options = {}) {
104
126
  const service = new URL(input.cloudflare.tunnelService);
105
127
  if (service.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(service.hostname) || service.username || service.password || service.search || service.hash)
106
128
  throw new Error("Cloudflare Tunnel service must be loopback HTTP.");
107
- if (input.cloudflare.warp) {
108
- if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(input.cloudflare.warp.organization) || !/^[0-9a-f-]{36}$/i.test(input.cloudflare.warp.virtualNetworkId) || !/^[A-Za-z0-9_-]{1,128}$/.test(input.cloudflare.warp.deviceProfileId)) {
109
- throw new Error("Cloudflare WARP organization, VNET or device profile is malformed.");
110
- }
111
- }
112
129
  }
113
- if (!options.allowPendingCloudflareHandoff && input.databaseNetworkMode === "cloudflare-warp" !== Boolean(input.cloudflare?.warp)) {
114
- throw new Error("cloudflare-warp networking requires its exact enrolled Cloudflare coordinates.");
130
+ if (input.realtime) {
131
+ input.realtime.endpoint = httpsOrigin("realtime.endpoint", input.realtime.endpoint);
132
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(input.realtime.workerScriptName) || !/^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$/.test(input.realtime.producer)) {
133
+ throw new Error("realtime.producer is invalid.");
134
+ }
115
135
  }
116
136
  return {
117
137
  ...input,
@@ -166,10 +186,12 @@ function renderPlatformSharedEnvironment(input) {
166
186
  FZ_PROFILE: value.deployProfile,
167
187
  FZ_REPO: value.repository,
168
188
  FZ_BRANCH: value.branch,
169
- FZ_SMTP_HOST: value.smtp?.host ?? "",
170
- FZ_SMTP_PORT: value.smtp ? String(value.smtp.port) : "",
171
- FZ_SMTP_USER: value.smtp?.user ?? "",
172
- FZ_SMTP_FROM: value.smtp?.from ?? "",
189
+ FZ_EMAIL_PROVIDER: value.email?.provider ?? "",
190
+ FZ_SMTP_HOST: value.email?.provider === "smtp" ? value.email.host : "",
191
+ FZ_SMTP_PORT: value.email?.provider === "smtp" ? String(value.email.port) : "",
192
+ FZ_SMTP_USER: value.email?.provider === "smtp" ? value.email.user : "",
193
+ FZ_EMAIL_FROM: value.email?.from ?? "",
194
+ FZ_JETEMAIL_EU: value.email?.provider === "jetemail" ? String(value.email.eu) : "",
173
195
  BACKUP_S3_ENDPOINT: value.backup?.endpoint ?? "",
174
196
  BACKUP_S3_REGION: value.backup?.region ?? "",
175
197
  BACKUP_S3_BUCKET: value.backup?.bucket ?? "",
@@ -179,9 +201,9 @@ function renderPlatformSharedEnvironment(input) {
179
201
  FZ_CF_KV_NAMESPACE_ID: value.cloudflare?.kvNamespaceId ?? "",
180
202
  FZ_CF_TUNNEL_ID: value.cloudflare?.tunnelId ?? "",
181
203
  FZ_CF_TUNNEL_SERVICE: value.cloudflare?.tunnelService ?? "",
182
- FZ_WARP_ORGANIZATION: value.cloudflare?.warp?.organization ?? "",
183
- FZ_CF_VIRTUAL_NETWORK_ID: value.cloudflare?.warp?.virtualNetworkId ?? "",
184
- FZ_CF_WARP_POLICY_ID: value.cloudflare?.warp?.deviceProfileId ?? ""
204
+ FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
205
+ FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
206
+ FZ_REALTIME_PRODUCER: value.realtime?.producer ?? ""
185
207
  };
186
208
  return `# Generated by fz bootstrap platform. Non-secret coordinates only.
187
209
  ` + Object.entries(entries).map(([key, entry]) => `${key}=${systemdValue(key, entry)}`).join(`
@@ -190,9 +212,11 @@ function renderPlatformSharedEnvironment(input) {
190
212
  }
191
213
  function platformApiCredentialSpecs(options) {
192
214
  const optional = [
193
- ["bootstrap-smtp-password", options.smtp],
194
- ["cloudflare-kv-token", options.cloudflareKv],
195
- ["cloudflare-network-token", options.cloudflareNetwork]
215
+ ["bootstrap-smtp-password", options.emailProvider === "smtp"],
216
+ ["bootstrap-jetemail-api-key", options.emailProvider === "jetemail"],
217
+ ["CF_API_TOKEN", options.cloudflareKv],
218
+ ["REALTIME_PUBLISH_SECRET", options.realtime],
219
+ ["REALTIME_TICKET_SECRET", options.realtime]
196
220
  ];
197
221
  return [
198
222
  { name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
@@ -299,7 +323,12 @@ function planPlatformActivation(releasesDirectory, releasePath) {
299
323
  if (!releasePath.startsWith(`${root}/`) || releasePath === root || releasePath.includes("/../")) {
300
324
  throw new Error("Release must be an immutable child of the releases directory.");
301
325
  }
302
- return { command: "/usr/local/libexec/forgezero-activate", argv: [releasePath], runAs: "root", invoker: "forgezero-runner" };
326
+ return {
327
+ command: "/usr/local/lib/forgezero/agent/fz-agent",
328
+ argv: ["platform-activate", "--config=/etc/forgezero/deploy-activation.json", releasePath],
329
+ runAs: "root",
330
+ invoker: "forgezero-runner"
331
+ };
303
332
  }
304
333
  function renderPlatformActivationFiles(input) {
305
334
  if (!input.root.startsWith("/") || /[\0\r\n]/.test(input.root))
@@ -326,43 +355,136 @@ function renderPlatformActivationFiles(input) {
326
355
  ].join(`
327
356
  `) + `
328
357
  `;
329
- const helper = `#!/usr/bin/env bash
330
- set -Eeuo pipefail
331
- source /etc/forgezero/deploy.env
332
- [[ $# == 1 ]] || { echo "usage: forgezero-activate <release>" >&2; exit 2; }
333
- release="$(realpath -e "$1")"; releases="$(realpath -e "$FZ_DIR/releases")"; slots="$FZ_DIR/slots"
334
- install -d -o root -g root -m 0755 "$slots"
335
- case "$release/" in "$releases"/*/) ;; *) echo "release is outside $releases" >&2; exit 2 ;; esac
336
- [[ -f "$release/.fz/deploy.json" && -s "$release/src/index.ts" && -s "$release/bun.lock" ]] || { echo "release is incomplete" >&2; exit 2; }
337
- slot_file="$FZ_DIR/.forge-slot"; previous_slot="$(cat "$slot_file" 2>/dev/null || true)"
338
- if [[ "$previous_slot" == blue ]]; then target=green; port="$FZ_GREEN_PORT"; else target=blue; port="$FZ_BLUE_PORT"; fi
339
- target_link="$slots/$target"; previous_target_link="$(readlink -f "$target_link" 2>/dev/null || true)"
340
- chown -R root:"$FZ_USER" "$release"; chmod -R a-w "$release"; find "$release" -type d -exec chmod a+rx {} +; find "$release" -type f -exec chmod a+r {} +
341
- ln -sfn "$release" "$target_link"; systemctl restart "forgezero@\${target}.service"
342
- healthy=0; for _ in $(seq 1 30); do curl -fsS --max-time 2 "http://127.0.0.1:\${port}\${FZ_HEALTH_PATH}" >/dev/null 2>&1 && { healthy=1; break; }; sleep 1; done
343
- if (( ! healthy )); then systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; exit 1; fi
344
- upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
345
- printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
346
- if ! nginx -t || ! nginx -s reload; then [[ -s "$backup" ]] && cp "$backup" "$upstream" || rm -f "$upstream"; rm -f "$backup"; systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; nginx -t >/dev/null 2>&1 && nginx -s reload || true; exit 1; fi
347
- rm -f "$backup"; printf '%s\\n' "$target" >"$slot_file"
348
- # New nginx workers select the new slot after reload. Keep the old slot alive
349
- # while old workers drain in-flight requests and upgraded connections.
350
- if [[ -n "$previous_slot" && "$previous_slot" != "$target" ]]; then
351
- sleep_seconds="$(( (FZ_DRAIN_DEADLINE_MS + 999) / 1000 ))"
352
- sleep "$sleep_seconds"
353
- systemctl stop "forgezero@\${previous_slot}.service" || true
354
- fi
355
- mapfile -t old < <(find "$releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -rn | tail -n "+$((FZ_KEEP_RELEASES + 1))" | cut -d' ' -f2-)
356
- for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
357
- printf 'promoted %s on %s\\n' "$release" "$target"
358
+ const helper = `${JSON.stringify({ ...input, drainDeadlineMs }, null, 2)}
358
359
  `;
359
360
  return {
360
361
  environment,
361
362
  helper,
362
- sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/libexec/forgezero-activate *
363
+ sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/lib/forgezero/agent/fz-agent platform-activate --config=/etc/forgezero/deploy-activation.json *
363
364
  `
364
365
  };
365
366
  }
367
+ var platformExec = async (argv) => {
368
+ const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } });
369
+ const [stdout, stderr, exitCode] = await Promise.all([
370
+ new Response(child.stdout).text(),
371
+ new Response(child.stderr).text(),
372
+ child.exited
373
+ ]);
374
+ return { exitCode, output: `${stdout}${stderr}` };
375
+ };
376
+ var replaceLink = (path, target) => {
377
+ const pending = `${path}.next`;
378
+ rmSync(pending, { force: true });
379
+ if (!target) {
380
+ rmSync(path, { force: true });
381
+ return;
382
+ }
383
+ symlinkSync(target, pending);
384
+ renameSync(pending, path);
385
+ };
386
+ var secureRelease = (path, uid, gid) => {
387
+ const visit = (current) => {
388
+ const metadata = lstatSync(current);
389
+ if (metadata.isSymbolicLink())
390
+ throw new Error("release contains a symbolic link");
391
+ chownSync(current, uid, gid);
392
+ chmodSync(current, metadata.isDirectory() ? 365 : 292);
393
+ if (metadata.isDirectory())
394
+ for (const name of readdirSync(current))
395
+ visit(join(current, name));
396
+ };
397
+ visit(path);
398
+ };
399
+ async function activatePlatformRelease(config, requestedRelease, options = {}) {
400
+ const rendered = renderPlatformActivationFiles(config);
401
+ const normalized = JSON.parse(rendered.helper);
402
+ const releases = realpathSync(join(normalized.root, "releases"));
403
+ const release = realpathSync(requestedRelease);
404
+ if (!release.startsWith(`${releases}/`) || release === releases)
405
+ throw new Error("release is outside configured releases directory");
406
+ for (const required of [".fz/deploy.json", "src/index.ts", "bun.lock"]) {
407
+ const metadata = statSync(join(release, required));
408
+ if (!metadata.isFile() || metadata.size < 1)
409
+ throw new Error(`release is incomplete: ${required}`);
410
+ }
411
+ const exec = options.exec ?? platformExec;
412
+ const request = options.fetch ?? fetch;
413
+ const sleep = options.sleep ?? Bun.sleep;
414
+ const slots = join(normalized.root, "slots");
415
+ mkdirSync(slots, { recursive: true, mode: 493 });
416
+ const slotFile = join(normalized.root, ".forge-slot");
417
+ const previousSlot = existsSync(slotFile) ? readFileSync(slotFile, "utf8").trim() : undefined;
418
+ const target = previousSlot === "blue" ? "green" : "blue";
419
+ const port = target === "blue" ? normalized.bluePort : normalized.greenPort;
420
+ const targetLink = join(slots, target);
421
+ let previousTarget;
422
+ try {
423
+ previousTarget = realpathSync(targetLink);
424
+ } catch {}
425
+ const user = await exec(["/usr/bin/id", "-u", normalized.serviceUser]);
426
+ const group = await exec(["/usr/bin/id", "-g", normalized.serviceUser]);
427
+ if (user.exitCode !== 0 || group.exitCode !== 0)
428
+ throw new Error("activation service identity does not exist");
429
+ secureRelease(release, Number(user.output.trim()), Number(group.output.trim()));
430
+ replaceLink(targetLink, release);
431
+ const service = `forgezero@${target}.service`;
432
+ const restart = await exec(["/usr/bin/systemctl", "restart", service]);
433
+ if (restart.exitCode !== 0) {
434
+ replaceLink(targetLink, previousTarget);
435
+ throw new Error("target slot failed to start");
436
+ }
437
+ let healthy = false;
438
+ for (let attempt = 0;attempt < 30; attempt += 1) {
439
+ try {
440
+ const response = await request(`http://127.0.0.1:${port}${normalized.healthPath}`, { signal: AbortSignal.timeout(2000) });
441
+ healthy = response.ok;
442
+ } catch {}
443
+ if (healthy)
444
+ break;
445
+ await sleep(1000);
446
+ }
447
+ const stopTarget = async () => {
448
+ await exec(["/usr/bin/systemctl", "stop", service]);
449
+ replaceLink(targetLink, previousTarget);
450
+ };
451
+ if (!healthy) {
452
+ await stopTarget();
453
+ throw new Error("target slot failed its health deadline");
454
+ }
455
+ const upstream = "/etc/nginx/conf.d/forgezero-upstream.conf";
456
+ const backup = `${upstream}.forgezero-backup`;
457
+ if (existsSync(upstream))
458
+ copyFileSync(upstream, backup);
459
+ else
460
+ rmSync(backup, { force: true });
461
+ writeFileSync(upstream, `upstream forgezero { server 127.0.0.1:${port}; }
462
+ `, { mode: 420 });
463
+ const test = await exec(["/usr/sbin/nginx", "-t"]);
464
+ const reload = test.exitCode === 0 ? await exec(["/usr/sbin/nginx", "-s", "reload"]) : test;
465
+ if (reload.exitCode !== 0) {
466
+ if (existsSync(backup))
467
+ renameSync(backup, upstream);
468
+ else
469
+ rmSync(upstream, { force: true });
470
+ await exec(["/usr/sbin/nginx", "-t"]);
471
+ await exec(["/usr/sbin/nginx", "-s", "reload"]);
472
+ await stopTarget();
473
+ throw new Error("nginx refused the promoted upstream");
474
+ }
475
+ rmSync(backup, { force: true });
476
+ writeFileSync(slotFile, `${target}
477
+ `, { mode: 420 });
478
+ if (previousSlot && previousSlot !== target) {
479
+ await sleep(normalized.drainDeadlineMs);
480
+ await exec(["/usr/bin/systemctl", "stop", `forgezero@${previousSlot}.service`]);
481
+ }
482
+ const old = readdirSync(releases, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(releases, entry.name)).sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs).slice(normalized.keepReleases);
483
+ for (const path of old)
484
+ if (path !== release)
485
+ rmSync(path, { recursive: true, force: true });
486
+ return { release, slot: target };
487
+ }
366
488
  function validateCollectorUnit(unit) {
367
489
  if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}\.service$/.test(unit))
368
490
  throw new Error("Invalid OTLP collector service unit.");
@@ -407,5 +529,6 @@ export {
407
529
  renderPlatformActivationFiles,
408
530
  platformApiCredentialSpecs,
409
531
  planPlatformActivation,
410
- planLocalOtlpProof
532
+ planLocalOtlpProof,
533
+ activatePlatformRelease
411
534
  };
@@ -0,0 +1,19 @@
1
+ import { type BootstrapStatus } from './bootstrap';
2
+ export interface PlatformFleetVerificationRuntime {
3
+ uid(): number;
4
+ bootstrapStatus(): Promise<BootstrapStatus>;
5
+ characterDevice(path: string): boolean;
6
+ exec(argv: readonly string[]): Promise<{
7
+ exitCode: number;
8
+ output: string;
9
+ }>;
10
+ }
11
+ export interface PlatformFleetVerificationResult {
12
+ ok: true;
13
+ agentVersion: string;
14
+ bootstrap: BootstrapStatus;
15
+ sevGuest: true;
16
+ failedUnits: readonly never[];
17
+ }
18
+ /** One closed, read-only host proof used by the operator fleet rehearsal. */
19
+ export declare function verifyPlatformFleetHost(expectedVersion: string, runtime?: PlatformFleetVerificationRuntime): Promise<PlatformFleetVerificationResult>;