@forgezero/agent 0.1.0 → 0.1.9

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 (46) hide show
  1. package/README.md +45 -2
  2. package/dist/attestation-client.d.ts +22 -0
  3. package/dist/attestation-client.test.d.ts +1 -0
  4. package/dist/compute.d.ts +122 -0
  5. package/dist/compute.js +150 -0
  6. package/dist/compute.test.d.ts +1 -0
  7. package/dist/control.d.ts +57 -0
  8. package/dist/control.test.d.ts +1 -0
  9. package/dist/definition.d.ts +34 -0
  10. package/dist/definition.js +159 -0
  11. package/dist/definition.test.d.ts +1 -0
  12. package/dist/deployment-pull.d.ts +60 -0
  13. package/dist/deployment-pull.test.d.ts +1 -0
  14. package/dist/deployment-runner.d.ts +23 -0
  15. package/dist/deployment-runner.js +199 -0
  16. package/dist/deployment-runner.test.d.ts +1 -0
  17. package/dist/deployment-watch.d.ts +36 -0
  18. package/dist/deployment-watch.test.d.ts +1 -0
  19. package/dist/deployment.d.ts +86 -0
  20. package/dist/deployment.test.d.ts +1 -0
  21. package/dist/fz-agent.js +2901 -155
  22. package/dist/guest-enrolment.d.ts +29 -0
  23. package/dist/guest-enrolment.js +88 -0
  24. package/dist/guest-enrolment.test.d.ts +1 -0
  25. package/dist/index.d.ts +50 -4
  26. package/dist/metal-helper-socket.d.ts +15 -0
  27. package/dist/metal-helper-socket.js +1123 -0
  28. package/dist/metal-helper-socket.test.d.ts +1 -0
  29. package/dist/metal-isolation.d.ts +14 -0
  30. package/dist/metal-isolation.test.d.ts +1 -0
  31. package/dist/metal-provision.d.ts +85 -0
  32. package/dist/metal-provision.js +1014 -0
  33. package/dist/metal-provision.test.d.ts +1 -0
  34. package/dist/node-vault.d.ts +24 -0
  35. package/dist/node-vault.js +211 -0
  36. package/dist/node-vault.test.d.ts +1 -0
  37. package/dist/provision.d.ts +50 -2
  38. package/dist/provision.js +286 -12
  39. package/dist/provisioning-pull.d.ts +75 -0
  40. package/dist/provisioning-pull.js +188 -0
  41. package/dist/provisioning-pull.test.d.ts +1 -0
  42. package/dist/signed-node-http.d.ts +14 -0
  43. package/dist/snp-attestation.d.ts +18 -0
  44. package/dist/snp-attestation.test.d.ts +1 -0
  45. package/dist/socket.d.ts +4 -23
  46. package/package.json +91 -71
package/README.md CHANGED
@@ -78,13 +78,56 @@ sync what changed since a cursor
78
78
  held what this guest is holding, by name only
79
79
  ```
80
80
 
81
+ The vault socket never accepts commands or deployment definitions. Deployment
82
+ enters through the root-only control socket or the signed outbound claim path,
83
+ then the deployment manager validates the checked-in definition and submits it
84
+ to the common keyed queue. Keeping execution off the vault socket prevents a
85
+ compromised application from turning secret-read access into a local shell.
86
+
81
87
  `attest` **refuses** when no source is configured rather than returning
82
88
  something attestation-shaped. An operator who believes they have an attestation
83
89
  when nothing produced one is worse off than one told plainly it is unavailable.
84
90
 
85
- Full documentation: **https://forgezero.net/docs/agent**
91
+ ## Deployment definitions
92
+
93
+ A repository may commit `.fz/deploy.yaml` with its own roles, prerequisite
94
+ checks, commands, and the exact secret names each step needs. ForgeZero does not
95
+ choose a tenant's database, framework, or deploy shape. The agent validates the
96
+ file before executing any command, prepares only the selected role, and gives a
97
+ step only the vault values it explicitly names. `await` returns the complete
98
+ pipeline result; no polling service or persistent queue is required.
99
+
100
+ The daemon owns source checkout and command execution. On a platform node it
101
+ watches the configured branch after bootstrap, resolves every change to an
102
+ exact commit, and writes `pending` then `running` before submitting that commit
103
+ to the keyed queue. A restart in either state resumes the exact commit; only an
104
+ awaited successful pipeline writes `deployed`. Tenant nodes receive the same
105
+ exact-commit request from the signed API claim path.
106
+
107
+ An operator may also force a deployment and await the complete result over the
108
+ private control socket:
109
+
110
+ ```bash
111
+ fz-agent deploy --revision=<full-40-character-commit> --coordinator
112
+ fz-agent status
113
+ fz-agent pause
114
+ fz-agent pause-key --key=project:production
115
+ fz-agent stop-key --key=project:production # running finishes; pending work is removed
116
+ fz-agent start-key --key=project:production
117
+ fz-agent cancel --id=q_42
118
+ ```
119
+
120
+ The node seed and read-only Git private key are loaded with
121
+ `LoadCredentialEncrypted=`. Their encrypted, host-bound blobs persist under
122
+ `/etc/forgezero/creds`; decrypted values exist only in the service's private
123
+ `$CREDENTIALS_DIRECTORY`. Platform bootstrap has no second clone/build path.
124
+ Bootstrap records the forge's SSH host key during the attended access check;
125
+ the daemon uses `StrictHostKeyChecking=yes` and never accepts a new host key by
126
+ itself.
127
+
128
+ Full documentation: **https://www.forgezero.net/docs/agent**
86
129
 
87
130
  ## Licence
88
131
 
89
- MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
132
+ MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
90
133
  deploys.
@@ -0,0 +1,22 @@
1
+ import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
+ import type { AttestationSource } from './socket';
3
+ import { type SignedNodeHttpOptions } from './signed-node-http';
4
+ export interface NodeAttestationOptions extends SignedNodeHttpOptions {
5
+ keys: NodeKeyPair;
6
+ source: AttestationSource;
7
+ intervalMs?: number;
8
+ /** Defaults to true. Set false after an explicitly awaited first attestation. */
9
+ immediate?: boolean;
10
+ setTimer?: (callback: () => void, ms: number) => unknown;
11
+ clearTimer?: (handle: unknown) => void;
12
+ onEvent?: (event: string, detail?: unknown) => void;
13
+ }
14
+ /** One challenge, one PSP report, one signed presentation. */
15
+ export declare function attestNodeOnce(options: NodeAttestationOptions): Promise<{
16
+ measurement: string;
17
+ ageMs: number;
18
+ }>;
19
+ /** Re-attest without overlap; shutdown waits for the in-flight PSP/KDS round trip. */
20
+ export declare function startNodeAttestation(options: NodeAttestationOptions): {
21
+ stop(): Promise<void>;
22
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Guest lifecycle on a machine — ours or a tenant's, identically.
3
+ *
4
+ * ## Why each guest is its own systemd unit
5
+ *
6
+ * The obvious shape is for the agent to launch qemu and keep track of it. That
7
+ * makes every guest a CHILD of the agent, and it means upgrading the agent —
8
+ * `systemctl restart fz-agent` — kills every tenant compute on the box. A host
9
+ * you cannot patch without an outage is a host that does not get patched.
10
+ *
11
+ * So the agent never owns a running guest. It writes a unit, asks systemd to
12
+ * start it, and walks away. systemd owns the process, restarts it after a host
13
+ * reboot, and survives the agent being replaced underneath it.
14
+ *
15
+ * ## Why the inventory lives on the root host
16
+ *
17
+ * The API is authoritative for what SHOULD exist. The host is authoritative for
18
+ * what DOES. Those disagree during an outage, a partial provision, or an
19
+ * operator's manual `kill` — and the host's answer is the one that matters when
20
+ * somebody is about to overwrite a disk.
21
+ *
22
+ * Persisting it here also means the host can bring its guests back with no
23
+ * control plane at all: the units are on disk, systemd starts them, and the
24
+ * platform reconciles later. A machine that needs the API to boot its tenants'
25
+ * computes is a machine that takes them down with it.
26
+ */
27
+ export interface GuestSpec {
28
+ /** Stable name. Becomes the unit name, so it must be a valid identifier. */
29
+ name: string;
30
+ vcpu: number;
31
+ /** Exclusive host CPUs assigned to this guest's whole QEMU service. */
32
+ allowedCpus?: string;
33
+ /** NUMA memory nodes paired with allowedCpus, when the host exposes them. */
34
+ allowedMemoryNodes?: string;
35
+ memoryGib: number;
36
+ /** Block device or image path. Raw LVM on our metal; a file is allowed. */
37
+ disk: string;
38
+ /** cloud-init seed, attached read-only. */
39
+ seed: string;
40
+ bridge: string;
41
+ mac: string;
42
+ /** Stable tap owned by this unit. Required when egress is shaped. */
43
+ tap?: string;
44
+ /** Sustained guarantee and borrowable ceiling, in Mbit/s. */
45
+ egress?: {
46
+ guaranteedMbps: number;
47
+ burstMbps: number;
48
+ };
49
+ /**
50
+ * SEV-SNP, when the host can prove it.
51
+ *
52
+ * `cbitpos` is a property of the CPU, not a preference — it is read from the
53
+ * host at enrolment and carried here, because guessing it produces a guest
54
+ * that boots without memory encryption while claiming to have it.
55
+ */
56
+ confidential?: {
57
+ cbitpos: number;
58
+ reducedPhysBits: number;
59
+ policy: string;
60
+ };
61
+ consoleLog?: string;
62
+ }
63
+ export declare class ComputeError extends Error {
64
+ readonly code: 'BAD_NAME' | 'BAD_SPEC';
65
+ constructor(code: 'BAD_NAME' | 'BAD_SPEC', message: string);
66
+ }
67
+ export declare const unitName: (name: string) => string;
68
+ /** The qemu command, as an argv array. Never a shell string. */
69
+ export declare function qemuArgv(spec: GuestSpec): string[];
70
+ /**
71
+ * The unit file.
72
+ *
73
+ * `Restart=always` is the point of using systemd at all: a guest that dies at
74
+ * 3am comes back without anybody being paged, and a host reboot brings every
75
+ * tenant's compute up in the same state it was in.
76
+ */
77
+ export declare function guestUnit(spec: GuestSpec): string;
78
+ export interface RunningGuest {
79
+ name: string;
80
+ pid: number;
81
+ disks: string[];
82
+ }
83
+ /**
84
+ * What is ACTUALLY running, read from process arguments.
85
+ *
86
+ * Not from libvirt, and not from the inventory. SEV-SNP guests are launched
87
+ * with raw qemu because libvirt on most hosts cannot express `sev-snp-guest`,
88
+ * so `virsh list` reports an empty machine while three confidential guests are
89
+ * running — and an operator who trusts it will overwrite their disks. That is
90
+ * not hypothetical; it is how this function came to exist.
91
+ */
92
+ export declare function parseCensus(psOutput: string): RunningGuest[];
93
+ /** Whether a device is in use by any running guest. */
94
+ export declare const deviceInUse: (census: RunningGuest[], device: string) => boolean;
95
+ /**
96
+ * Rate-limit a guest's egress on its tap device.
97
+ *
98
+ * Without this every guest can saturate the host's whole uplink — 25 Gb/s on
99
+ * our metal — so one tenant's runaway job starves the other guests and the
100
+ * hypervisor with them. It is also what makes bandwidth SELLABLE: a compute
101
+ * plan cannot state a number the machine does not enforce.
102
+ *
103
+ * Shaped on EGRESS only. Ingress arrives whether we want it or not; policing it
104
+ * at the tap drops packets that already crossed the expensive link, which costs
105
+ * the same and adds retransmits. Ingress belongs upstream, at the edge.
106
+ *
107
+ * The tap receives packets emitted by the guest, so the limiter belongs on the
108
+ * tap's INGRESS hook. A root qdisc there shapes traffic going into the guest and
109
+ * looks correct in `tc` while enforcing the opposite direction.
110
+ *
111
+ * The guaranteed rate is a placement promise: the host reserves enough uplink
112
+ * for every admitted guest. The per-tap policer enforces the burst ceiling.
113
+ */
114
+ export declare function shapeEgressCommands(tap: string, mbps: number, burstMbps?: number): string[];
115
+ /**
116
+ * The same contract as `shapeEgressCommands`, expressed without a shell for a
117
+ * systemd guest unit. Every argument is generated from validated numbers and a
118
+ * validated device name; no tenant-controlled command text is evaluated.
119
+ */
120
+ export declare function shapeEgressUnitDirectives(tap: string, guaranteedMbps: number, burstMbps: number): string[];
121
+ /** The tap a guest's NIC is attached to, from the census. */
122
+ export declare const tapFor: (guestIndex: number) => string;
@@ -0,0 +1,150 @@
1
+ // src/compute.ts
2
+ class ComputeError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "ComputeError";
8
+ }
9
+ }
10
+ var NAME = /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/;
11
+ var unitName = (name) => {
12
+ if (!NAME.test(name)) {
13
+ throw new ComputeError("BAD_NAME", `"${name}" is not a usable guest name — lower case, digits and hyphens, 3-32 characters.`);
14
+ }
15
+ return `forgezero-guest@${name}.service`;
16
+ };
17
+ function qemuArgv(spec) {
18
+ if (spec.vcpu < 1 || spec.memoryGib < 1) {
19
+ throw new ComputeError("BAD_SPEC", "a guest needs at least 1 vCPU and 1 GiB");
20
+ }
21
+ const argv = [
22
+ "/usr/bin/qemu-system-x86_64",
23
+ "-name",
24
+ spec.name,
25
+ "-accel",
26
+ "kvm",
27
+ "-cpu",
28
+ "host",
29
+ "-m",
30
+ `${spec.memoryGib}G`,
31
+ "-smp",
32
+ String(spec.vcpu)
33
+ ];
34
+ if (spec.confidential) {
35
+ const { cbitpos, reducedPhysBits, policy } = spec.confidential;
36
+ argv.push("-machine", `q35,confidential-guest-support=snp,memory-backend=ram`, "-object", `memory-backend-memfd,id=ram,size=${spec.memoryGib}G,share=true`, "-object", `sev-snp-guest,id=snp,cbitpos=${cbitpos},reduced-phys-bits=${reducedPhysBits},policy=${policy}`, "-bios", "/usr/share/ovmf/OVMF.fd");
37
+ } else {
38
+ argv.push("-machine", "q35");
39
+ }
40
+ argv.push("-drive", `file=${spec.disk},format=raw,if=none,id=disk0,cache=none,aio=native`, "-device", "virtio-blk-pci,drive=disk0,iommu_platform=on", "-drive", `file=${spec.seed},format=raw,if=none,id=seed0,readonly=on`, "-device", "virtio-blk-pci,drive=seed0,iommu_platform=on", "-netdev", spec.tap ? `tap,id=net0,ifname=${spec.tap},script=no,downscript=no` : `bridge,id=net0,br=${spec.bridge}`, "-device", `virtio-net-pci,netdev=net0,mac=${spec.mac},iommu_platform=on`, "-display", "none");
41
+ if (spec.consoleLog)
42
+ argv.push("-serial", `file:${spec.consoleLog}`);
43
+ return argv;
44
+ }
45
+ function guestUnit(spec) {
46
+ const argv = qemuArgv(spec).map((part) => /[\s"']/.test(part) ? JSON.stringify(part) : part).join(" ");
47
+ if (spec.egress && !spec.tap) {
48
+ throw new ComputeError("BAD_SPEC", "shaped egress needs a stable tap device");
49
+ }
50
+ const tapSetup = spec.tap ? [
51
+ `ExecStartPre=-/usr/sbin/ip link del ${spec.tap}`,
52
+ `ExecStartPre=/usr/sbin/ip tuntap add dev ${spec.tap} mode tap`,
53
+ `ExecStartPre=/usr/sbin/ip link set ${spec.tap} master ${spec.bridge}`,
54
+ `ExecStartPre=/usr/sbin/ip link set ${spec.tap} up`,
55
+ ...spec.egress ? shapeEgressUnitDirectives(spec.tap, spec.egress.guaranteedMbps, spec.egress.burstMbps) : [],
56
+ `ExecStopPost=-/usr/sbin/ip link del ${spec.tap}`
57
+ ].join(`
58
+ `) : "";
59
+ return `[Unit]
60
+ Description=ForgeZero guest ${spec.name}
61
+ Documentation=https://www.forgezero.net
62
+ After=network-online.target
63
+ Wants=network-online.target
64
+
65
+ [Service]
66
+ Type=simple
67
+ Slice=forgezero-guests.slice
68
+ ${tapSetup}
69
+ ExecStart=${argv}
70
+ Restart=always
71
+ RestartSec=5
72
+ ${spec.allowedCpus ? `AllowedCPUs=${spec.allowedCpus}
73
+ ` : ""}${spec.allowedMemoryNodes ? `AllowedMemoryNodes=${spec.allowedMemoryNodes}
74
+ ` : ""}# The affinity belongs to the unit rather than only QEMU's vCPU threads. Its
75
+ # emulator and IO threads can otherwise run on a different tenant's cores.
76
+ # The agent is NOT the parent. Restarting or upgrading fz-agent must never stop
77
+ # a tenant's compute, which is the whole reason this is a unit rather than a
78
+ # child process.
79
+ KillMode=mixed
80
+ TimeoutStopSec=120
81
+
82
+ [Install]
83
+ WantedBy=multi-user.target
84
+ `;
85
+ }
86
+ function parseCensus(psOutput) {
87
+ const guests = [];
88
+ for (const line of psOutput.split(`
89
+ `)) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed || !trimmed.includes("qemu-system"))
92
+ continue;
93
+ const pid = Number(trimmed.split(/\s+/)[0]);
94
+ const name = /-name\s+([^\s]+)/.exec(trimmed)?.[1];
95
+ if (!Number.isFinite(pid) || !name)
96
+ continue;
97
+ const disks = [...trimmed.matchAll(/file=([^,\s]+)/g)].map((match) => match[1]);
98
+ guests.push({ name, pid, disks });
99
+ }
100
+ return guests;
101
+ }
102
+ var deviceInUse = (census, device) => census.some((guest) => guest.disks.includes(device));
103
+ function shapeEgressCommands(tap, mbps, burstMbps = mbps * 2) {
104
+ if (!/^[a-z][a-z0-9]{0,14}$/.test(tap)) {
105
+ throw new ComputeError("BAD_SPEC", `"${tap}" is not a device name`);
106
+ }
107
+ if (mbps <= 0)
108
+ return [
109
+ `tc qdisc del dev ${tap} root 2>/dev/null || true`,
110
+ `tc qdisc del dev ${tap} ingress 2>/dev/null || true`
111
+ ];
112
+ const ceiling = `${Math.floor(Math.max(burstMbps, mbps))}mbit`;
113
+ return [
114
+ `tc qdisc del dev ${tap} root 2>/dev/null || true`,
115
+ `tc qdisc del dev ${tap} ingress 2>/dev/null || true`,
116
+ `tc qdisc add dev ${tap} handle ffff: ingress`,
117
+ `tc filter add dev ${tap} parent ffff: protocol all u32 match u32 0 0 action police rate ${ceiling} burst 16mb conform-exceed drop`
118
+ ];
119
+ }
120
+ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
121
+ if (!/^[a-z][a-z0-9]{0,14}$/.test(tap)) {
122
+ throw new ComputeError("BAD_SPEC", `"${tap}" is not a device name`);
123
+ }
124
+ if (!Number.isInteger(guaranteedMbps) || guaranteedMbps < 0 || !Number.isInteger(burstMbps) || burstMbps < guaranteedMbps) {
125
+ throw new ComputeError("BAD_SPEC", "egress rates must be whole numbers and burst must cover the guarantee");
126
+ }
127
+ const clear = [
128
+ `ExecStartPre=-/usr/sbin/tc qdisc del dev ${tap} root`,
129
+ `ExecStartPre=-/usr/sbin/tc qdisc del dev ${tap} ingress`
130
+ ];
131
+ if (guaranteedMbps === 0)
132
+ return clear;
133
+ return [
134
+ ...clear,
135
+ `ExecStartPre=/usr/sbin/tc qdisc add dev ${tap} handle ffff: ingress`,
136
+ `ExecStartPre=/usr/sbin/tc filter add dev ${tap} parent ffff: protocol all u32 match u32 0 0 action police rate ${burstMbps}mbit burst 16mb conform-exceed drop`
137
+ ];
138
+ }
139
+ var tapFor = (guestIndex) => `tap${guestIndex}`;
140
+ export {
141
+ unitName,
142
+ tapFor,
143
+ shapeEgressUnitDirectives,
144
+ shapeEgressCommands,
145
+ qemuArgv,
146
+ parseCensus,
147
+ guestUnit,
148
+ deviceInUse,
149
+ ComputeError
150
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { type Server } from 'node:net';
2
+ import type { DeploymentManager, DeploymentRequest, DeploymentResult } from './deployment';
3
+ export declare const DEFAULT_CONTROL_SOCKET = "/run/forgezero/control.sock";
4
+ export type ControlRequest = {
5
+ op: 'deploy';
6
+ request?: DeploymentRequest;
7
+ } | {
8
+ op: 'status';
9
+ } | {
10
+ op: 'pause';
11
+ } | {
12
+ op: 'resume';
13
+ } | {
14
+ op: 'pause-key';
15
+ key: string;
16
+ } | {
17
+ op: 'resume-key';
18
+ key: string;
19
+ } | {
20
+ op: 'stop-key';
21
+ key: string;
22
+ } | {
23
+ op: 'start-key';
24
+ key: string;
25
+ } | {
26
+ op: 'cancel';
27
+ id: string;
28
+ };
29
+ export type ControlResponse = {
30
+ ok: true;
31
+ op: 'deploy';
32
+ taskId: string;
33
+ result: DeploymentResult;
34
+ } | {
35
+ ok: true;
36
+ op: 'status';
37
+ queue: ReturnType<DeploymentManager['snapshot']>;
38
+ } | {
39
+ ok: true;
40
+ op: Exclude<ControlRequest['op'], 'deploy' | 'status' | 'stop-key'>;
41
+ changed: boolean;
42
+ } | {
43
+ ok: true;
44
+ op: 'stop-key';
45
+ changed: boolean;
46
+ removed: number;
47
+ } | {
48
+ ok: false;
49
+ error: {
50
+ code: string;
51
+ message: string;
52
+ };
53
+ };
54
+ export declare function handleControl(manager: DeploymentManager, request: ControlRequest): Promise<ControlResponse>;
55
+ /** Root/operator control is deliberately separate from the tenant vault socket. */
56
+ export declare function startControlServer(manager: DeploymentManager, socketPath?: string): Server;
57
+ export declare function requestControl(request: ControlRequest, socketPath?: string): Promise<ControlResponse>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import type { Pipeline, PipelineStep } from './pipeline';
2
+ export declare const PIPELINE_VERSION: 1;
3
+ export interface SoftwareRequirement {
4
+ name: string;
5
+ check: string;
6
+ install: string;
7
+ }
8
+ export interface PipelineRole {
9
+ name: string;
10
+ count: number;
11
+ software: readonly SoftwareRequirement[];
12
+ }
13
+ export interface DeployStep extends PipelineStep {
14
+ phase: 'build' | 'release' | 'migrate' | 'health';
15
+ /** The coordinator runs this on one selected node, never on every replica. */
16
+ once?: boolean;
17
+ /** Run only when every named non-secret deployment coordinate has this value. */
18
+ when?: Readonly<Record<string, string>>;
19
+ }
20
+ export interface DeployDefinition {
21
+ version: typeof PIPELINE_VERSION;
22
+ name: string;
23
+ requireAttestation?: boolean;
24
+ roles: readonly PipelineRole[];
25
+ steps: readonly DeployStep[];
26
+ }
27
+ export declare class DefinitionError extends Error {
28
+ constructor(message: string);
29
+ }
30
+ /** Validate parsed YAML before any command from it is allowed to run. */
31
+ export declare function parseDeployDefinition(value: unknown): DeployDefinition;
32
+ export declare function phasePipeline(definition: DeployDefinition, phase: DeployStep['phase']): Pipeline;
33
+ /** Turn one role's declared software checks into the same executable pipeline shape. */
34
+ export declare function prerequisitePipeline(definition: DeployDefinition, roleName: string): Pipeline;
@@ -0,0 +1,159 @@
1
+ // src/definition.ts
2
+ var PIPELINE_VERSION = 1;
3
+
4
+ class DefinitionError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "DefinitionError";
8
+ }
9
+ }
10
+ var record = (value, where) => {
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
12
+ throw new DefinitionError(`${where} must be an object.`);
13
+ }
14
+ return value;
15
+ };
16
+ var text = (value, where) => {
17
+ if (typeof value !== "string" || value.trim() === "") {
18
+ throw new DefinitionError(`${where} must be a non-empty string.`);
19
+ }
20
+ return value;
21
+ };
22
+ var exactKeys = (value, allowed, where) => {
23
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
24
+ if (unknown.length > 0)
25
+ throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
26
+ };
27
+ var RESERVED_STEP_ENV = new Set([
28
+ "PATH",
29
+ "HOME",
30
+ "SHELL",
31
+ "PWD",
32
+ "BUN_INSTALL",
33
+ "NODE_OPTIONS",
34
+ "LD_PRELOAD",
35
+ "LD_LIBRARY_PATH",
36
+ "GIT_SSH",
37
+ "GIT_SSH_COMMAND"
38
+ ]);
39
+ function parseDeployDefinition(value) {
40
+ const root = record(value, "pipeline");
41
+ exactKeys(root, ["version", "name", "requireAttestation", "roles", "steps"], "pipeline");
42
+ if (root.version !== PIPELINE_VERSION) {
43
+ throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
44
+ }
45
+ if (!Array.isArray(root.roles) || root.roles.length === 0) {
46
+ throw new DefinitionError("pipeline.roles must contain at least one role.");
47
+ }
48
+ if (!Array.isArray(root.steps) || root.steps.length === 0) {
49
+ throw new DefinitionError("pipeline.steps must contain at least one step.");
50
+ }
51
+ const roles = root.roles.map((raw, index) => {
52
+ const role = record(raw, `roles[${index}]`);
53
+ exactKeys(role, ["name", "count", "software"], `roles[${index}]`);
54
+ const count = Number(role.count);
55
+ if (!Number.isSafeInteger(count) || count < 1) {
56
+ throw new DefinitionError(`roles[${index}].count must be a positive integer.`);
57
+ }
58
+ if (!Array.isArray(role.software)) {
59
+ throw new DefinitionError(`roles[${index}].software must be an array.`);
60
+ }
61
+ return {
62
+ name: text(role.name, `roles[${index}].name`),
63
+ count,
64
+ software: role.software.map((rawSoftware, softwareIndex) => {
65
+ const software = record(rawSoftware, `roles[${index}].software[${softwareIndex}]`);
66
+ exactKeys(software, ["name", "check", "install"], `roles[${index}].software[${softwareIndex}]`);
67
+ return {
68
+ name: text(software.name, `roles[${index}].software[${softwareIndex}].name`),
69
+ check: text(software.check, `roles[${index}].software[${softwareIndex}].check`),
70
+ install: text(software.install, `roles[${index}].software[${softwareIndex}].install`)
71
+ };
72
+ })
73
+ };
74
+ });
75
+ if (new Set(roles.map((role) => role.name)).size !== roles.length) {
76
+ throw new DefinitionError("pipeline.roles must have unique names.");
77
+ }
78
+ const phases = new Set(["build", "release", "migrate", "health"]);
79
+ const steps = root.steps.map((raw, index) => {
80
+ const step = record(raw, `steps[${index}]`);
81
+ exactKeys(step, ["name", "run", "phase", "secrets", "once", "always", "timeoutMs", "when"], `steps[${index}]`);
82
+ const phase = text(step.phase, `steps[${index}].phase`);
83
+ if (!phases.has(phase))
84
+ throw new DefinitionError(`steps[${index}].phase is not supported.`);
85
+ if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name) => typeof name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name)))) {
86
+ throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
87
+ }
88
+ if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
89
+ throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
90
+ }
91
+ if (Array.isArray(step.secrets) && step.secrets.some((name) => RESERVED_STEP_ENV.has(String(name)))) {
92
+ throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
93
+ }
94
+ const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
95
+ if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
96
+ throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
97
+ }
98
+ let when;
99
+ if (step.when !== undefined) {
100
+ const conditions = record(step.when, `steps[${index}].when`);
101
+ when = {};
102
+ for (const [name, expected] of Object.entries(conditions)) {
103
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || typeof expected !== "string" || expected.length === 0) {
104
+ throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
105
+ }
106
+ when[name] = expected;
107
+ }
108
+ if (Object.keys(when).length === 0)
109
+ throw new DefinitionError(`steps[${index}].when must not be empty.`);
110
+ }
111
+ return {
112
+ name: text(step.name, `steps[${index}].name`),
113
+ run: text(step.run, `steps[${index}].run`),
114
+ phase,
115
+ secrets: step.secrets,
116
+ once: step.once === true,
117
+ always: step.always === true,
118
+ timeoutMs,
119
+ when
120
+ };
121
+ });
122
+ if (new Set(steps.map((step) => step.name)).size !== steps.length) {
123
+ throw new DefinitionError("pipeline.steps must have unique names.");
124
+ }
125
+ return {
126
+ version: PIPELINE_VERSION,
127
+ name: text(root.name, "pipeline.name"),
128
+ requireAttestation: root.requireAttestation === true,
129
+ roles,
130
+ steps
131
+ };
132
+ }
133
+ function phasePipeline(definition, phase) {
134
+ return {
135
+ name: `${definition.name}:${phase}`,
136
+ requireAttestation: definition.requireAttestation,
137
+ steps: definition.steps.filter((step) => step.phase === phase)
138
+ };
139
+ }
140
+ function prerequisitePipeline(definition, roleName) {
141
+ const role = definition.roles.find((candidate) => candidate.name === roleName);
142
+ if (!role)
143
+ throw new DefinitionError(`pipeline role does not exist: ${roleName}.`);
144
+ return {
145
+ name: `${definition.name}:prerequisites:${role.name}`,
146
+ requireAttestation: definition.requireAttestation,
147
+ steps: role.software.map((software) => ({
148
+ name: `prepare ${software.name}`,
149
+ run: `${software.check} >/dev/null 2>&1 || { ${software.install}; ${software.check}; }`
150
+ }))
151
+ };
152
+ }
153
+ export {
154
+ prerequisitePipeline,
155
+ phasePipeline,
156
+ parseDeployDefinition,
157
+ PIPELINE_VERSION,
158
+ DefinitionError
159
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
+ import type { DeploymentManager, DeploymentResult } from './deployment';
3
+ export interface RemoteDeploymentClaim {
4
+ runKey: string;
5
+ pipelineKey: string;
6
+ revision: string;
7
+ claimToken: string;
8
+ claimExpiresAtTs: number;
9
+ attempt: number;
10
+ source: {
11
+ repository: string;
12
+ branch: string;
13
+ role: string;
14
+ knownHosts?: string;
15
+ };
16
+ }
17
+ export interface DeploymentPullOptions {
18
+ apiUrl: string;
19
+ nodeKey: string;
20
+ keys: NodeKeyPair;
21
+ manager?: DeploymentManager;
22
+ /** Automatic computes construct one manager from the server-owned source coordinates. */
23
+ managerFor?: (claim: RemoteDeploymentClaim) => DeploymentManager;
24
+ fetch?: (input: URL, init: RequestInit) => Promise<Response>;
25
+ intervalMs?: number;
26
+ /** Concurrent claim workers. Same-key execution still serializes in the manager queue. */
27
+ parallelism?: number;
28
+ requestTimeoutMs?: number;
29
+ completionAttempts?: number;
30
+ sleep?: (ms: number) => Promise<void>;
31
+ now?: () => number;
32
+ renewRetryMs?: number;
33
+ setTimer?: (callback: () => void, ms: number) => unknown;
34
+ clearTimer?: (handle: unknown) => void;
35
+ onEvent?: (event: string, detail?: unknown) => void;
36
+ }
37
+ export type PullResult = {
38
+ status: 'idle';
39
+ } | {
40
+ status: 'deployed';
41
+ claim: RemoteDeploymentClaim;
42
+ result: DeploymentResult;
43
+ } | {
44
+ status: 'failed';
45
+ claim: RemoteDeploymentClaim;
46
+ reason: string;
47
+ };
48
+ /** Claim at most one job and await its exact deployment result before reporting. */
49
+ export declare function pullDeploymentOnce(options: DeploymentPullOptions): Promise<PullResult>;
50
+ /**
51
+ * Bounded outbound intake for the deployment manager.
52
+ *
53
+ * Claim workers may receive different pipeline keys concurrently. Managers
54
+ * still own execution ordering: a single pipeline key is serial even when two
55
+ * of its claims were fetched by different workers.
56
+ */
57
+ export declare function startDeploymentPull(options: DeploymentPullOptions): {
58
+ stop(): Promise<void>;
59
+ readonly active: boolean;
60
+ };
@@ -0,0 +1 @@
1
+ export {};