@forgezero/agent 0.1.21 → 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/README.md +24 -0
- package/dist/cli/agent-install.d.ts +6 -0
- package/dist/definition.d.ts +0 -1
- package/dist/definition.js +1 -6
- package/dist/deployment.d.ts +1 -0
- package/dist/fz-agent.js +472 -224
- package/dist/fz.js +191 -17
- package/dist/index.d.ts +5 -0
- package/dist/lifecycle-helper.d.ts +36 -0
- package/dist/lifecycle-helper.js +216 -0
- package/dist/metal-helper-socket.js +8 -532
- package/dist/metal-provision.d.ts +1 -6
- package/dist/metal-provision.js +8 -536
- package/dist/migration-pull.d.ts +55 -0
- package/dist/migration-pull.js +185 -0
- package/dist/provision.d.ts +17 -0
- package/dist/provision.js +189 -15
- package/dist/version.d.ts +1 -1
- package/dist/warp-config.d.ts +20 -0
- package/package.json +11 -3
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
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
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 ?
|
|
385
|
-
const binPath = options.binPath ?
|
|
386
|
-
const gitCredentialPath = options.gitCredentialPath ?
|
|
387
|
-
const gitPublicKeyPath = options.gitPublicKeyPath ?
|
|
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.
|
|
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
|
+
};
|