@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.
- package/README.md +45 -2
- package/dist/attestation-client.d.ts +22 -0
- package/dist/attestation-client.test.d.ts +1 -0
- package/dist/compute.d.ts +122 -0
- package/dist/compute.js +150 -0
- package/dist/compute.test.d.ts +1 -0
- package/dist/control.d.ts +57 -0
- package/dist/control.test.d.ts +1 -0
- package/dist/definition.d.ts +34 -0
- package/dist/definition.js +159 -0
- package/dist/definition.test.d.ts +1 -0
- package/dist/deployment-pull.d.ts +60 -0
- package/dist/deployment-pull.test.d.ts +1 -0
- package/dist/deployment-runner.d.ts +23 -0
- package/dist/deployment-runner.js +199 -0
- package/dist/deployment-runner.test.d.ts +1 -0
- package/dist/deployment-watch.d.ts +36 -0
- package/dist/deployment-watch.test.d.ts +1 -0
- package/dist/deployment.d.ts +86 -0
- package/dist/deployment.test.d.ts +1 -0
- package/dist/fz-agent.js +2901 -155
- package/dist/guest-enrolment.d.ts +29 -0
- package/dist/guest-enrolment.js +88 -0
- package/dist/guest-enrolment.test.d.ts +1 -0
- package/dist/index.d.ts +50 -4
- package/dist/metal-helper-socket.d.ts +15 -0
- package/dist/metal-helper-socket.js +1123 -0
- package/dist/metal-helper-socket.test.d.ts +1 -0
- package/dist/metal-isolation.d.ts +14 -0
- package/dist/metal-isolation.test.d.ts +1 -0
- package/dist/metal-provision.d.ts +85 -0
- package/dist/metal-provision.js +1014 -0
- package/dist/metal-provision.test.d.ts +1 -0
- package/dist/node-vault.d.ts +24 -0
- package/dist/node-vault.js +211 -0
- package/dist/node-vault.test.d.ts +1 -0
- package/dist/provision.d.ts +50 -2
- package/dist/provision.js +286 -12
- package/dist/provisioning-pull.d.ts +75 -0
- package/dist/provisioning-pull.js +188 -0
- package/dist/provisioning-pull.test.d.ts +1 -0
- package/dist/signed-node-http.d.ts +14 -0
- package/dist/snp-attestation.d.ts +18 -0
- package/dist/snp-attestation.test.d.ts +1 -0
- package/dist/socket.d.ts +4 -23
- 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
|
-
|
|
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;
|
package/dist/compute.js
ADDED
|
@@ -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 {};
|