@forgezero/agent 0.1.22 → 0.1.23

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/dist/fz.js CHANGED
@@ -101,7 +101,8 @@ async function spawnWith(command, env, report = () => {}) {
101
101
  }
102
102
 
103
103
  // src/cli/index.ts
104
- import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
104
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
105
+ import { dirname } from "path";
105
106
  import { fileURLToPath } from "url";
106
107
  import { DEFAULT_SOCKET } from "@forgezero/vault";
107
108
 
@@ -149,9 +150,104 @@ var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, s
149
150
  var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
150
151
  var DEPLOYMENT_GROUP = "forgezero-deploy";
151
152
  var VAULT_GROUP = "forgezero-vault";
153
+ var LIFECYCLE_GROUP = "forgezero-lifecycle";
152
154
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
153
155
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
154
156
  var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
157
+ var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
158
+ var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
159
+ var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
160
+ var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
161
+ var systemdPath = (value, label) => {
162
+ if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
163
+ throw new Error(`invalid ${label} path`);
164
+ return value;
165
+ };
166
+ function warpConfigUnit(options) {
167
+ if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
168
+ throw new Error("WARP organization is invalid");
169
+ }
170
+ if (!options.warpClientIdCredentialPath || !options.warpClientSecretCredentialPath) {
171
+ throw new Error("WARP service-token credential paths are required");
172
+ }
173
+ const clientIdPath = systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential");
174
+ const clientSecretPath = systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential");
175
+ const bin = options.binPath ?? "fz-agent";
176
+ return `[Unit]
177
+ Description=Materialize Cloudflare One enrollment in tmpfs
178
+ Documentation=https://www.forgezero.net/docs/agent
179
+ Before=warp-svc.service
180
+
181
+ [Service]
182
+ Type=oneshot
183
+ RemainAfterExit=yes
184
+ LoadCredentialEncrypted=warp-auth-client-id:${clientIdPath}
185
+ LoadCredentialEncrypted=warp-auth-client-secret:${clientSecretPath}
186
+ Environment=FZ_WARP_CLIENT_ID_CREDENTIAL=warp-auth-client-id
187
+ Environment=FZ_WARP_CLIENT_SECRET_CREDENTIAL=warp-auth-client-secret
188
+ ExecStart=${bin} warp-config --organization=${options.warpOrganization}
189
+ RuntimeDirectory=forgezero-warp
190
+ RuntimeDirectoryMode=0700
191
+ RuntimeDirectoryPreserve=yes
192
+ UMask=0077
193
+ LimitCORE=0
194
+ NoNewPrivileges=true
195
+ PrivateTmp=true
196
+ ProtectSystem=strict
197
+ ProtectHome=true
198
+ ReadWritePaths=/var/lib/cloudflare-warp
199
+
200
+ [Install]
201
+ WantedBy=multi-user.target
202
+ `;
203
+ }
204
+ function warpServiceDropIn() {
205
+ return `[Unit]
206
+ Requires=forgezero-warp-config.service
207
+ After=forgezero-warp-config.service
208
+ `;
209
+ }
210
+ function lifecycleHelperUnit(options) {
211
+ if (!options.lifecycleProfilePath)
212
+ throw new Error("lifecycle helper needs a root-owned profile");
213
+ const bin = options.binPath ?? "fz-agent";
214
+ const profile = systemdPath(options.lifecycleProfilePath, "lifecycle profile");
215
+ const socket = systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket");
216
+ return `[Unit]
217
+ Description=ForgeZero fixed-operation compute lifecycle helper
218
+ Documentation=https://www.forgezero.net/docs/agent
219
+ After=network-online.target
220
+ Wants=network-online.target
221
+
222
+ [Service]
223
+ Type=simple
224
+ User=root
225
+ Group=${LIFECYCLE_GROUP}
226
+ Environment=FZ_LIFECYCLE_HELPER_SOCKET=${socket}
227
+ ExecStart=${bin} lifecycle-helper --profile=${profile}
228
+ Restart=always
229
+ RestartSec=2
230
+ RuntimeDirectory=forgezero-lifecycle
231
+ RuntimeDirectoryMode=0750
232
+ UMask=0007
233
+ LimitCORE=0
234
+ NoNewPrivileges=true
235
+ PrivateTmp=true
236
+ ProtectSystem=strict
237
+ ProtectHome=true
238
+ ProtectKernelTunables=true
239
+ ProtectKernelModules=true
240
+ ProtectControlGroups=true
241
+ RestrictSUIDSGID=true
242
+ RestrictRealtime=true
243
+ MemoryDenyWriteExecute=true
244
+ LockPersonality=true
245
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
246
+
247
+ [Install]
248
+ WantedBy=multi-user.target
249
+ `;
250
+ }
155
251
  function agentEnrolmentUnit(options) {
156
252
  if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
157
253
  throw new Error("direct enrolment needs API, credential and state paths");
@@ -246,6 +342,19 @@ function agentUnit(options) {
246
342
  const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
247
343
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
248
344
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
345
+ const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
346
+ if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
347
+ throw new Error("migration pull and lifecycle profile must be supplied together");
348
+ }
349
+ const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
350
+ const warpValues = [
351
+ options.warpOrganization,
352
+ options.warpClientIdCredentialPath,
353
+ options.warpClientSecretCredentialPath
354
+ ];
355
+ const warpEnabled = warpValues.every(Boolean);
356
+ if (warpValues.some(Boolean) && !warpEnabled)
357
+ throw new Error("WARP configuration must be supplied together");
249
358
  const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
250
359
  const deploymentEnvironment = options.deploymentEnvironment ?? {};
251
360
  const deploymentCredentials = options.deploymentCredentials ?? {};
@@ -280,7 +389,9 @@ function agentUnit(options) {
280
389
  Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
281
390
  ...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
282
391
  options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
283
- options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null
392
+ options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null,
393
+ options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
394
+ options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
284
395
  ].filter((line) => line !== null);
285
396
  if (deploymentEnabled) {
286
397
  environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
@@ -290,14 +401,22 @@ function agentUnit(options) {
290
401
  const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
291
402
  `);
292
403
  const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
293
- const deploymentGroup = deploymentEnabled ? `SupplementaryGroups=${DEPLOYMENT_GROUP}` : "";
404
+ const supplementaryGroups = [
405
+ deploymentEnabled ? DEPLOYMENT_GROUP : null,
406
+ lifecycleEnabled ? LIFECYCLE_GROUP : null
407
+ ].filter((value) => value !== null);
408
+ const deploymentGroup = supplementaryGroups.length > 0 ? `SupplementaryGroups=${supplementaryGroups.join(" ")}` : "";
294
409
  const after = [
295
410
  "network-online.target",
296
411
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
412
+ lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
413
+ warpEnabled ? "warp-svc.service" : null,
297
414
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
298
415
  ].filter((value) => value !== null);
299
416
  const requires = [
300
417
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
418
+ lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
419
+ warpEnabled ? "warp-svc.service" : null,
301
420
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
302
421
  ].filter((value) => value !== null);
303
422
  const deploymentDependency = [
@@ -369,37 +488,63 @@ function planProvision(options) {
369
488
  const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
370
489
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
371
490
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
491
+ const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
492
+ if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
493
+ throw new Error("migration pull and lifecycle profile must be supplied together");
494
+ }
495
+ const warpValues = [
496
+ options.warpOrganization,
497
+ options.warpClientIdCredentialPath,
498
+ options.warpClientSecretCredentialPath
499
+ ];
500
+ const warpEnabled = warpValues.every(Boolean);
501
+ if (warpValues.some(Boolean) && !warpEnabled)
502
+ throw new Error("WARP configuration must be supplied together");
372
503
  const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
373
504
  if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
374
505
  throw new Error("direct enrolment paths must be supplied together");
375
- const safePath = (value, label) => {
376
- if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
377
- throw new Error(`invalid ${label} path`);
378
- return value;
379
- };
380
- const enrolTokenSourcePath = enrolmentEnabled ? safePath(options.enrolTokenSourcePath, "enrolment source") : undefined;
381
- const enrolTokenCredentialPath = enrolmentEnabled ? safePath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
382
- const enrolStatePath = enrolmentEnabled ? safePath(options.enrolStatePath, "enrolment state") : undefined;
506
+ const enrolTokenSourcePath = enrolmentEnabled ? systemdPath(options.enrolTokenSourcePath, "enrolment source") : undefined;
507
+ const enrolTokenCredentialPath = enrolmentEnabled ? systemdPath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
508
+ const enrolStatePath = enrolmentEnabled ? systemdPath(options.enrolStatePath, "enrolment state") : undefined;
383
509
  const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
384
- const sourceBinPath = options.sourceBinPath ? safePath(options.sourceBinPath, "agent source binary") : undefined;
385
- const binPath = options.binPath ? safePath(options.binPath, "agent binary") : undefined;
386
- const gitCredentialPath = options.gitCredentialPath ? safePath(options.gitCredentialPath, "Git credential") : undefined;
387
- const gitPublicKeyPath = options.gitPublicKeyPath ? safePath(options.gitPublicKeyPath, "Git public key") : undefined;
510
+ const sourceBinPath = options.sourceBinPath ? systemdPath(options.sourceBinPath, "agent source binary") : undefined;
511
+ const binPath = options.binPath ? systemdPath(options.binPath, "agent binary") : undefined;
512
+ const gitCredentialPath = options.gitCredentialPath ? systemdPath(options.gitCredentialPath, "Git credential") : undefined;
513
+ const gitPublicKeyPath = options.gitPublicKeyPath ? systemdPath(options.gitPublicKeyPath, "Git public key") : undefined;
388
514
  if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
389
515
  throw new Error("generated Git identity needs credential and public-key paths");
390
516
  }
391
517
  const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
518
+ const lifecycleProfilePath = lifecycleEnabled ? systemdPath(options.lifecycleProfilePath, "lifecycle profile") : undefined;
519
+ const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
520
+ const warpClientIdCredentialPath = warpEnabled ? systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential") : undefined;
521
+ const warpClientSecretCredentialPath = warpEnabled ? systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential") : undefined;
392
522
  return {
393
523
  mode,
394
524
  reason: reasonFor(mode),
395
525
  unitPath: UNIT_PATH,
396
- unit: agentUnit({ ...options, mode }),
526
+ unit: agentUnit({ ...options, mode, lifecycleProfilePath, lifecycleHelperSocketPath }),
397
527
  auxiliaryUnits: [
398
528
  ...deploymentEnabled ? [
399
529
  { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
400
530
  ] : [],
401
531
  ...enrolmentEnabled ? [
402
532
  { path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
533
+ ] : [],
534
+ ...lifecycleEnabled ? [
535
+ { path: LIFECYCLE_HELPER_UNIT_PATH, unit: lifecycleHelperUnit({
536
+ ...options,
537
+ lifecycleProfilePath,
538
+ lifecycleHelperSocketPath
539
+ }) }
540
+ ] : [],
541
+ ...warpEnabled ? [
542
+ { path: WARP_CONFIG_UNIT_PATH, unit: warpConfigUnit({
543
+ ...options,
544
+ warpClientIdCredentialPath,
545
+ warpClientSecretCredentialPath
546
+ }) },
547
+ { path: WARP_SERVICE_DROP_IN_PATH, unit: warpServiceDropIn() }
403
548
  ] : []
404
549
  ],
405
550
  socketPath: options.socketPath,
@@ -413,10 +558,18 @@ function planProvision(options) {
413
558
  label: "root-owned agent runtime",
414
559
  command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
415
560
  }] : [],
561
+ ...warpEnabled ? [{
562
+ label: "Cloudflare One client for Ubuntu 26.04",
563
+ command: `. /etc/os-release; test "$ID" = ubuntu && test "$VERSION_ID" = 26.04; ` + `install -d -m 0755 /usr/share/keyrings /etc/apt/sources.list.d /etc/systemd/system/warp-svc.service.d; ` + `curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg -o /run/cloudflare-warp-key.gpg; ` + `gpg --batch --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg /run/cloudflare-warp-key.gpg; ` + `rm -f /run/cloudflare-warp-key.gpg; ` + `printf 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ %s main\\n' "$VERSION_CODENAME" > /etc/apt/sources.list.d/cloudflare-client.list; ` + `apt-get update -qq; DEBIAN_FRONTEND=noninteractive apt-get install -y cloudflare-warp`
564
+ }] : [],
416
565
  ...deploymentEnabled ? [{
417
566
  label: "deployment isolation group",
418
567
  command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
419
568
  }] : [],
569
+ ...lifecycleEnabled ? [{
570
+ label: "lifecycle helper access group",
571
+ command: `groupadd --system ${LIFECYCLE_GROUP} || true`
572
+ }] : [],
420
573
  {
421
574
  label: "service account",
422
575
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
@@ -425,6 +578,10 @@ function planProvision(options) {
425
578
  label: "bind service account to vault group",
426
579
  command: `usermod -g ${VAULT_GROUP} ${user}`
427
580
  },
581
+ ...lifecycleEnabled ? [{
582
+ label: "grant lifecycle helper socket access",
583
+ command: `usermod -a -G ${LIFECYCLE_GROUP} ${user}`
584
+ }] : [],
428
585
  ...deploymentEnabled ? [{
429
586
  label: "credential-free deployment account",
430
587
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
@@ -470,6 +627,8 @@ function planProvision(options) {
470
627
  label: "enable and start",
471
628
  command: `systemctl enable --now ${[
472
629
  ...deploymentEnabled ? ["forgezero-deploy-runner.service"] : [],
630
+ ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
631
+ ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
473
632
  ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
474
633
  "forgezero-agent.service"
475
634
  ].join(" ")}`
@@ -484,6 +643,14 @@ function planProvision(options) {
484
643
  label: "prove the deployment runner socket exists",
485
644
  command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
486
645
  }] : [],
646
+ ...lifecycleEnabled ? [{
647
+ label: "prove the lifecycle helper socket exists",
648
+ command: `test -S ${lifecycleHelperSocketPath}`
649
+ }] : [],
650
+ ...warpEnabled ? [{
651
+ label: "prove Cloudflare WARP is connected",
652
+ command: `warp-cli --accept-tos status | grep -Eiq '(^|[[:space:]])Connected([[:space:]]|$)'`
653
+ }] : [],
487
654
  ...options.repository ? [{
488
655
  label: "prove the deployment control socket exists",
489
656
  command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
@@ -774,7 +941,7 @@ async function resolveIdentity(selector, socketPath) {
774
941
  }
775
942
 
776
943
  // src/version.ts
777
- var VERSION = "0.1.22";
944
+ var VERSION = "0.1.23";
778
945
 
779
946
  // src/cli/index.ts
780
947
  var DEFAULT_MODE = THRESHOLD_MODES[0].id;
@@ -997,6 +1164,12 @@ async function cmdAgent(options, args) {
997
1164
  deploymentEnvironment: parseAssignments(process.env.FZ_DEPLOY_ENV),
998
1165
  deploymentCredentials: parseAssignments(process.env.FZ_DEPLOY_CREDENTIALS),
999
1166
  pullDeployments: process.env.FZ_DEPLOY_PULL === "true",
1167
+ pullMigrations: process.env.FZ_MIGRATION_PULL === "true",
1168
+ lifecycleProfilePath: process.env.FZ_LIFECYCLE_PROFILE,
1169
+ lifecycleHelperSocketPath: process.env.FZ_LIFECYCLE_HELPER_SOCKET,
1170
+ warpOrganization: process.env.FZ_WARP_ORGANIZATION,
1171
+ warpClientIdCredentialPath: process.env.FZ_WARP_CLIENT_ID_CREDENTIAL_PATH,
1172
+ warpClientSecretCredentialPath: process.env.FZ_WARP_CLIENT_SECRET_CREDENTIAL_PATH,
1000
1173
  ...options.enrol ? {
1001
1174
  pullDeployments: true,
1002
1175
  enrolTokenSourcePath,
@@ -1033,6 +1206,7 @@ async function cmdAgent(options, args) {
1033
1206
  writeFileSync(plan.unitPath, plan.unit, { mode: 420 });
1034
1207
  out.ok(`Wrote ${plan.unitPath}`);
1035
1208
  for (const auxiliary of plan.auxiliaryUnits) {
1209
+ mkdirSync(dirname(auxiliary.path), { recursive: true, mode: 493 });
1036
1210
  writeFileSync(auxiliary.path, auxiliary.unit, { mode: 420 });
1037
1211
  out.ok(`Wrote ${auxiliary.path}`);
1038
1212
  }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export { pullDeploymentOnce, startDeploymentPull } from './deployment-pull';
14
14
  export type { DeploymentPullOptions, RemoteDeploymentClaim, PullResult } from './deployment-pull';
15
15
  export { pullProvisioningOnce, startProvisioningPull } from './provisioning-pull';
16
16
  export type { CreateRemoteProvisionClaim, ProvisioningPullOptions, RemoteProvisionClaim, GuestAccess, ProvisionPullResult, ProvisionRunner, ProvisionResult } from './provisioning-pull';
17
+ export { pullMigrationOnce, startMigrationPull } from './migration-pull';
18
+ export type { MigrationAction, MigrationEvidence, MigrationNetwork, MigrationPullOptions, MigrationPullResult, RemoteMigrationClaim } from './migration-pull';
17
19
  export { enrolGuestIdentity, loadGuestBinding } from './guest-enrolment';
18
20
  export type { GuestBinding, GuestEnrolmentOptions } from './guest-enrolment';
19
21
  export { createNodeVaultCache, projectVaultCacheKey, projectVaultCoordinate, startNodeVaultSync, tenantNodeApiUrl } from './node-vault';
@@ -22,6 +24,9 @@ export { allocateAddress, allocateCpuPool, cloudInit, guestNameFor, provisionMet
22
24
  export { validateMetalProfile } from './metal-provision';
23
25
  export type { GuestManifest, MetalCommandResult, MetalCpuPool, MetalExec, MetalProvisionProfile } from './metal-provision';
24
26
  export { DEFAULT_METAL_HELPER_SOCKET, requestMetalProvision, startMetalHelper } from './metal-helper-socket';
27
+ export { DEFAULT_LIFECYCLE_HELPER_SOCKET, executeLifecycleAction, loadLifecycleProfile, requestLifecycleAction, startLifecycleHelper, validateLifecycleProfile } from './lifecycle-helper';
28
+ export type { LifecycleCommandResult, LifecycleExec, LifecycleProfile } from './lifecycle-helper';
29
+ export { materializeWarpMdm, renderWarpMdm } from './warp-config';
25
30
  export { DEFAULT_DEPLOYMENT_RUNNER_SOCKET, requestDeploymentCommand, startDeploymentRunner } from './deployment-runner';
26
31
  export { createSnpAttestationSource } from './snp-attestation';
27
32
  export type { SnpAttestationOptions } from './snp-attestation';
@@ -0,0 +1,36 @@
1
+ import { type Server } from 'node:net';
2
+ import type { MigrationEvidence, RemoteMigrationClaim } from './migration-pull';
3
+ export declare const DEFAULT_LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
4
+ export interface LifecycleProfile {
5
+ /** Root-owned systemd units whose normal stop path performs application drain. */
6
+ apiUnits: readonly string[];
7
+ /** Root-owned database service stopped only at the final retirement stage. */
8
+ databaseUnit: string;
9
+ /** Loopback health endpoint for the API on this compute. */
10
+ apiHealthUrl: string;
11
+ /** Loopback health endpoint for the database member on this compute. */
12
+ databaseHealthUrl: string;
13
+ /** Ports that must be reachable on every signed controller-provided private peer. */
14
+ databasePorts: readonly number[];
15
+ }
16
+ export interface LifecycleCommandResult {
17
+ exitCode: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ }
21
+ export type LifecycleExec = (argv: readonly string[]) => Promise<LifecycleCommandResult>;
22
+ export declare function validateLifecycleProfile(profile: LifecycleProfile): void;
23
+ export declare function loadLifecycleProfile(path: string): LifecycleProfile;
24
+ export declare const spawnLifecycleCommand: LifecycleExec;
25
+ export declare function executeLifecycleAction(profile: LifecycleProfile, claim: RemoteMigrationClaim, exec?: LifecycleExec, tcpProbe?: (host: string, port: number) => Promise<void>, fetcher?: typeof fetch): Promise<MigrationEvidence>;
26
+ export declare function startLifecycleHelper(options: {
27
+ profile: LifecycleProfile;
28
+ socketPath?: string;
29
+ exec?: LifecycleExec;
30
+ tcpProbe?: (host: string, port: number) => Promise<void>;
31
+ fetch?: typeof fetch;
32
+ }): {
33
+ server: Server;
34
+ stop(): Promise<void>;
35
+ };
36
+ export declare function requestLifecycleAction(claim: RemoteMigrationClaim, socketPath?: string): Promise<MigrationEvidence>;
@@ -0,0 +1,216 @@
1
+ // src/lifecycle-helper.ts
2
+ import { chmodSync, existsSync, readFileSync, unlinkSync } from "node:fs";
3
+ import { connect, createConnection, createServer, isIP } from "node:net";
4
+ var DEFAULT_LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
5
+ var MAX_REQUEST_BYTES = 16 * 1024;
6
+ var REQUEST_TIMEOUT_MS = 5000;
7
+ var ACTION_TIMEOUT_MS = 10 * 60000;
8
+ var unitPattern = /^[A-Za-z0-9_.@-]+\.service$/;
9
+ var privateIp = (value) => {
10
+ const address = value.replace(/^\[|\]$/g, "").toLowerCase();
11
+ if (isIP(address) === 4) {
12
+ const [a, b] = address.split(".").map(Number);
13
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
14
+ }
15
+ if (isIP(address) === 6) {
16
+ const first = Number.parseInt(address.split(":", 1)[0], 16);
17
+ return Number.isFinite(first) && (first & 65024) === 64512;
18
+ }
19
+ return false;
20
+ };
21
+ function validateLifecycleProfile(profile) {
22
+ if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
23
+ throw new Error("lifecycle profile needs one or more valid API service units");
24
+ }
25
+ if (!unitPattern.test(profile.databaseUnit))
26
+ throw new Error("lifecycle profile database unit is invalid");
27
+ const apiUrl = new URL(profile.apiHealthUrl);
28
+ if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
29
+ throw new Error("API health URL must be loopback HTTP");
30
+ }
31
+ const databaseUrl = new URL(profile.databaseHealthUrl);
32
+ if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
33
+ throw new Error("database health URL must be loopback or private HTTP");
34
+ }
35
+ if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
36
+ throw new Error("lifecycle profile database ports are invalid");
37
+ }
38
+ }
39
+ function loadLifecycleProfile(path) {
40
+ const profile = JSON.parse(readFileSync(path, "utf8"));
41
+ validateLifecycleProfile(profile);
42
+ return profile;
43
+ }
44
+ var spawnLifecycleCommand = async (argv) => {
45
+ const child = Bun.spawn([...argv], {
46
+ stdout: "pipe",
47
+ stderr: "pipe",
48
+ env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" }
49
+ });
50
+ let timedOut = false;
51
+ const timer = setTimeout(() => {
52
+ timedOut = true;
53
+ child.kill("SIGTERM");
54
+ }, ACTION_TIMEOUT_MS);
55
+ const [stdout, stderr, exitCode] = await Promise.all([
56
+ new Response(child.stdout).text(),
57
+ new Response(child.stderr).text(),
58
+ child.exited
59
+ ]);
60
+ clearTimeout(timer);
61
+ return { exitCode: timedOut ? 124 : exitCode, stdout, stderr };
62
+ };
63
+ var requireSuccess = async (exec, argv, label) => {
64
+ const result = await exec(argv);
65
+ if (result.exitCode !== 0)
66
+ throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
67
+ return result.stdout;
68
+ };
69
+ var probeTcp = (host, port, timeoutMs = 5000) => new Promise((resolve, reject) => {
70
+ const socket = createConnection({ host, port });
71
+ socket.setTimeout(timeoutMs);
72
+ socket.once("connect", () => {
73
+ socket.destroy();
74
+ resolve();
75
+ });
76
+ socket.once("timeout", () => {
77
+ socket.destroy();
78
+ reject(new Error(`private peer ${host}:${port} timed out`));
79
+ });
80
+ socket.once("error", reject);
81
+ });
82
+ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleCommand, tcpProbe = probeTcp, fetcher = fetch) {
83
+ validateLifecycleProfile(profile);
84
+ switch (claim.action) {
85
+ case "network-ready": {
86
+ if (!claim.peerPrivateAddresses?.length)
87
+ throw new Error("network claim has no private peers");
88
+ if (claim.network === "cloudflare-warp") {
89
+ const status = await requireSuccess(exec, ["/usr/bin/warp-cli", "--accept-tos", "status"], "WARP status");
90
+ if (!/\bconnected\b/i.test(status) || /\bdisconnected\b/i.test(status))
91
+ throw new Error("WARP is not connected");
92
+ }
93
+ for (const address of claim.peerPrivateAddresses) {
94
+ for (const port of profile.databasePorts)
95
+ await tcpProbe(address, port);
96
+ }
97
+ return {
98
+ targetAgentReady: true,
99
+ privateNetworkReady: true,
100
+ ...claim.network === "cloudflare-warp" ? { warpConnected: true } : {}
101
+ };
102
+ }
103
+ case "database-member-ready": {
104
+ await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
105
+ const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
106
+ if (!response.ok && response.status !== 401)
107
+ throw new Error(`database health returned HTTP ${response.status}`);
108
+ return { databaseMemberHealthy: true };
109
+ }
110
+ case "api-ready": {
111
+ const response = await fetcher(profile.apiHealthUrl, { signal: AbortSignal.timeout(5000) });
112
+ if (!response.ok)
113
+ throw new Error(`API health returned HTTP ${response.status}`);
114
+ return { apiHealthy: true };
115
+ }
116
+ case "source-drained":
117
+ await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
118
+ return { sourceDrained: true };
119
+ case "source-stopped":
120
+ await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
121
+ return { sourceStopped: true };
122
+ default:
123
+ throw new Error("unknown lifecycle action");
124
+ }
125
+ }
126
+ function startLifecycleHelper(options) {
127
+ validateLifecycleProfile(options.profile);
128
+ const socketPath = options.socketPath ?? DEFAULT_LIFECYCLE_HELPER_SOCKET;
129
+ if (existsSync(socketPath))
130
+ unlinkSync(socketPath);
131
+ let tail = Promise.resolve();
132
+ const server = createServer((socket) => {
133
+ let buffer = "";
134
+ socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request timed out" } })}
135
+ `));
136
+ socket.on("data", (chunk) => {
137
+ buffer += chunk.toString("utf8");
138
+ if (buffer.length > MAX_REQUEST_BYTES)
139
+ return void socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
140
+ `);
141
+ const newline = buffer.indexOf(`
142
+ `);
143
+ if (newline < 0)
144
+ return;
145
+ socket.setTimeout(0);
146
+ const line = buffer.slice(0, newline);
147
+ buffer = "";
148
+ const work = async () => {
149
+ let request;
150
+ try {
151
+ request = JSON.parse(line);
152
+ } catch {
153
+ return { ok: false, error: { code: "REFUSED", message: "invalid request" } };
154
+ }
155
+ if (request?.op !== "apply" || !request.claim)
156
+ return { ok: false, error: { code: "REFUSED", message: "unknown operation" } };
157
+ try {
158
+ return { ok: true, evidence: await executeLifecycleAction(options.profile, request.claim, options.exec, options.tcpProbe, options.fetch) };
159
+ } catch (cause) {
160
+ return { ok: false, error: { code: "FAILED", message: cause instanceof Error ? cause.message : "lifecycle action failed" } };
161
+ }
162
+ };
163
+ const response = tail.then(work, work);
164
+ tail = response;
165
+ response.then((value) => socket.end(`${JSON.stringify(value)}
166
+ `));
167
+ });
168
+ socket.on("error", () => socket.destroy());
169
+ });
170
+ server.listen(socketPath, () => chmodSync(socketPath, 432));
171
+ return {
172
+ server,
173
+ async stop() {
174
+ await new Promise((resolve) => server.close(() => resolve()));
175
+ await tail;
176
+ }
177
+ };
178
+ }
179
+ function requestLifecycleAction(claim, socketPath = DEFAULT_LIFECYCLE_HELPER_SOCKET) {
180
+ return new Promise((resolve, reject) => {
181
+ const socket = connect(socketPath, () => socket.write(`${JSON.stringify({ op: "apply", claim })}
182
+ `));
183
+ socket.setTimeout(ACTION_TIMEOUT_MS + 1e4, () => {
184
+ socket.destroy();
185
+ reject(new Error("lifecycle helper response timed out"));
186
+ });
187
+ let buffer = "";
188
+ socket.on("data", (chunk) => {
189
+ buffer += chunk.toString("utf8");
190
+ const newline = buffer.indexOf(`
191
+ `);
192
+ if (newline < 0)
193
+ return;
194
+ socket.end();
195
+ try {
196
+ const response = JSON.parse(buffer.slice(0, newline));
197
+ if (response.ok)
198
+ resolve(response.evidence);
199
+ else
200
+ reject(new Error(response.error.message));
201
+ } catch (cause) {
202
+ reject(cause);
203
+ }
204
+ });
205
+ socket.on("error", reject);
206
+ });
207
+ }
208
+ export {
209
+ validateLifecycleProfile,
210
+ startLifecycleHelper,
211
+ spawnLifecycleCommand,
212
+ requestLifecycleAction,
213
+ loadLifecycleProfile,
214
+ executeLifecycleAction,
215
+ DEFAULT_LIFECYCLE_HELPER_SOCKET
216
+ };