@forgezero/agent 0.1.2 → 0.1.10
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 +70 -4
- package/dist/attestation-client.d.ts +22 -0
- package/dist/attestation-client.test.d.ts +1 -0
- package/dist/cli/agent-install.d.ts +82 -0
- package/dist/cli/agent-install.test.d.ts +1 -0
- package/dist/cli/custody.d.ts +35 -0
- package/dist/cli/genesis.d.ts +79 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/options.test.d.ts +1 -0
- package/dist/cli/run.d.ts +92 -0
- package/dist/cli/run.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 +61 -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 +100 -0
- package/dist/deployment.test.d.ts +1 -0
- package/dist/fz-agent.js +2934 -182
- package/dist/fz.js +1270 -0
- 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 +27 -9
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @forgezero/agent
|
|
2
2
|
|
|
3
|
-
**`fz
|
|
3
|
+
**`fz` controls the machine; `fz-agent` runs its managed work.**
|
|
4
4
|
|
|
5
5
|
The agent keeps a project-scoped copy of your vault in RAM and answers over a
|
|
6
6
|
unix socket. `@forgezero/vault` prefers that socket over an API key whenever it
|
|
@@ -10,9 +10,24 @@ from it.
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
bun add -g @forgezero/agent # or let `fz agent install` do it
|
|
13
|
+
fz --help
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
The one public package installs both commands. Keeping bootstrap and the daemon
|
|
17
|
+
in one version prevents a newly installed `fz` from provisioning a different
|
|
18
|
+
agent protocol.
|
|
19
|
+
|
|
20
|
+
The operator command launches a platform, runs custody ceremonies, inspects
|
|
21
|
+
status, wraps processes, and installs the daemon:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
fz keys
|
|
25
|
+
fz status
|
|
26
|
+
fz genesis --mode 2-of-3
|
|
27
|
+
fz unlock
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Install the service explicitly:
|
|
16
31
|
|
|
17
32
|
```bash
|
|
18
33
|
fz agent install --apply # writes a hardened systemd unit
|
|
@@ -78,13 +93,64 @@ sync what changed since a cursor
|
|
|
78
93
|
held what this guest is holding, by name only
|
|
79
94
|
```
|
|
80
95
|
|
|
96
|
+
The vault socket never accepts commands or deployment definitions. Deployment
|
|
97
|
+
enters through the root-only control socket or the signed outbound claim path,
|
|
98
|
+
then the deployment manager validates the checked-in definition and submits it
|
|
99
|
+
to the common keyed queue. Keeping execution off the vault socket prevents a
|
|
100
|
+
compromised application from turning secret-read access into a local shell.
|
|
101
|
+
|
|
81
102
|
`attest` **refuses** when no source is configured rather than returning
|
|
82
103
|
something attestation-shaped. An operator who believes they have an attestation
|
|
83
104
|
when nothing produced one is worse off than one told plainly it is unavailable.
|
|
84
105
|
|
|
85
|
-
|
|
106
|
+
## Deployment definitions
|
|
107
|
+
|
|
108
|
+
A repository may commit `.fz/deploy.yaml` with its own roles, prerequisite
|
|
109
|
+
checks, commands, and the exact secret names each step needs. ForgeZero does not
|
|
110
|
+
choose a tenant's database, framework, or deploy shape. The agent validates the
|
|
111
|
+
file before executing any command, prepares only the selected role, and gives a
|
|
112
|
+
step only the vault values it explicitly names. `await` returns the complete
|
|
113
|
+
pipeline result; no polling service or persistent queue is required.
|
|
114
|
+
|
|
115
|
+
The daemon owns source checkout and command execution. On a platform node it
|
|
116
|
+
watches the configured branch after bootstrap, resolves every change to an
|
|
117
|
+
exact commit, and writes `pending` then `running` before submitting that commit
|
|
118
|
+
to the keyed queue. A restart in either state resumes the exact commit; only an
|
|
119
|
+
awaited successful pipeline writes `deployed`. Tenant nodes receive the same
|
|
120
|
+
exact-commit request from the signed API claim path.
|
|
121
|
+
|
|
122
|
+
Repository read authorization is explicit per pipeline: public HTTPS, the
|
|
123
|
+
compute's systemd-sealed SSH deploy key, or a fine-grained HTTPS token selected
|
|
124
|
+
by a project-vault secret name. A signed claim contains the mode and secret name
|
|
125
|
+
only. The agent resolves the value from its scoped memory cache, limits the Git
|
|
126
|
+
header to the repository origin, never writes the token into a command, and
|
|
127
|
+
never exposes it to tenant pipeline steps. Automatic GitHub App token minting
|
|
128
|
+
can feed the same short-lived token mode later; it is not implemented today.
|
|
129
|
+
|
|
130
|
+
An operator may also force a deployment and await the complete result over the
|
|
131
|
+
private control socket:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
fz-agent deploy --revision=<full-40-character-commit> --coordinator
|
|
135
|
+
fz-agent status
|
|
136
|
+
fz-agent pause
|
|
137
|
+
fz-agent pause-key --key=project:production
|
|
138
|
+
fz-agent stop-key --key=project:production # running finishes; pending work is removed
|
|
139
|
+
fz-agent start-key --key=project:production
|
|
140
|
+
fz-agent cancel --id=q_42
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The node seed and read-only Git private key are loaded with
|
|
144
|
+
`LoadCredentialEncrypted=`. Their encrypted, host-bound blobs persist under
|
|
145
|
+
`/etc/forgezero/creds`; decrypted values exist only in the service's private
|
|
146
|
+
`$CREDENTIALS_DIRECTORY`. Platform bootstrap has no second clone/build path.
|
|
147
|
+
Bootstrap records the forge's SSH host key during the attended access check;
|
|
148
|
+
the daemon uses `StrictHostKeyChecking=yes` and never accepts a new host key by
|
|
149
|
+
itself.
|
|
150
|
+
|
|
151
|
+
Full documentation: **https://www.forgezero.net/docs/agent**
|
|
86
152
|
|
|
87
153
|
## Licence
|
|
88
154
|
|
|
89
|
-
MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
|
|
155
|
+
MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
|
|
90
156
|
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,82 @@
|
|
|
1
|
+
import { type Capabilities, type ProvisionPlan } from '../provision';
|
|
2
|
+
/**
|
|
3
|
+
* `fz agent install` — run the provision plan on THIS machine.
|
|
4
|
+
*
|
|
5
|
+
* The plan itself is the agent's public `provision` subpath, with no transport, and
|
|
6
|
+
* that is the whole point: the platform runs the same steps over SSH when it
|
|
7
|
+
* enrols a compute. This file is the local executor and nothing else — it
|
|
8
|
+
* decides how to run a command here, never what the commands are.
|
|
9
|
+
*
|
|
10
|
+
* It was briefly the other thing. This module had its own machine detection
|
|
11
|
+
* calling `existsSync` directly and its own unit renderer, while the API had a
|
|
12
|
+
* `Check`-based preflight designed to run remotely. Two answers to "is this box
|
|
13
|
+
* ready" is one answer nobody can trust, and the local one drifts first because
|
|
14
|
+
* it is the one somebody runs while debugging.
|
|
15
|
+
*/
|
|
16
|
+
export type { ProvisionPlan };
|
|
17
|
+
/** Parse NAME=value pairs used only for non-secret unit coordinates and credential paths. */
|
|
18
|
+
export declare function parseAssignments(value: string | undefined): Record<string, string> | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Answer the capability checks by running their commands.
|
|
21
|
+
*
|
|
22
|
+
* Running the command rather than calling `existsSync` is deliberate even
|
|
23
|
+
* though this is the local path: it means the answer here and the answer over
|
|
24
|
+
* SSH come from the same test, so a box that reports `attested` to an operator
|
|
25
|
+
* cannot report `enrolled` to the platform.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readCapabilities(run: (command: string) => Promise<{
|
|
28
|
+
stdout: string;
|
|
29
|
+
exitCode: number;
|
|
30
|
+
}>): Promise<Capabilities>;
|
|
31
|
+
/** Run a command locally. The SSH executor is the platform's half. */
|
|
32
|
+
export declare function localRunner(command: string): Promise<{
|
|
33
|
+
stdout: string;
|
|
34
|
+
exitCode: number;
|
|
35
|
+
}>;
|
|
36
|
+
export interface InstallOptions {
|
|
37
|
+
capabilities: Capabilities;
|
|
38
|
+
socketPath: string;
|
|
39
|
+
seedPath: string;
|
|
40
|
+
seedCredentialPath?: string;
|
|
41
|
+
gitCredentialPath?: string;
|
|
42
|
+
gitPublicKeyPath?: string;
|
|
43
|
+
generateGitIdentity?: boolean;
|
|
44
|
+
controlSocketPath?: string;
|
|
45
|
+
repository?: string;
|
|
46
|
+
branch?: string;
|
|
47
|
+
role?: string;
|
|
48
|
+
deployRoot?: string;
|
|
49
|
+
publicApiUrl?: string;
|
|
50
|
+
deploymentEnvironment?: Record<string, string>;
|
|
51
|
+
deploymentCredentials?: Record<string, string>;
|
|
52
|
+
pullDeployments?: boolean;
|
|
53
|
+
binPath?: string;
|
|
54
|
+
sourceBinPath?: string;
|
|
55
|
+
user?: string;
|
|
56
|
+
apiUrl?: string;
|
|
57
|
+
project?: string;
|
|
58
|
+
environment?: string;
|
|
59
|
+
enrolTokenSourcePath?: string;
|
|
60
|
+
enrolTokenCredentialPath?: string;
|
|
61
|
+
enrolStatePath?: string;
|
|
62
|
+
nodeLabel?: string;
|
|
63
|
+
}
|
|
64
|
+
export declare function planInstall(options: InstallOptions): ProvisionPlan;
|
|
65
|
+
/** Execute the same ordered plan the control plane executes over SSH. */
|
|
66
|
+
export declare function applyPlan(plan: ProvisionPlan, run: (command: string) => Promise<{
|
|
67
|
+
stdout: string;
|
|
68
|
+
exitCode: number;
|
|
69
|
+
}>): Promise<readonly {
|
|
70
|
+
label: string;
|
|
71
|
+
command: string;
|
|
72
|
+
exitCode: number;
|
|
73
|
+
}[]>;
|
|
74
|
+
/**
|
|
75
|
+
* What a reader needs to see before running any of it.
|
|
76
|
+
*
|
|
77
|
+
* Printed rather than executed by default. Installing a system service that
|
|
78
|
+
* holds key material should not happen because somebody typed a subcommand, and
|
|
79
|
+
* an operator who reads the unit first is one who can notice it is about to run
|
|
80
|
+
* as the wrong user.
|
|
81
|
+
*/
|
|
82
|
+
export declare function renderPlan(plan: ProvisionPlan): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type AgentIdentity } from '@forgezero/runtime/ssh-agent';
|
|
2
|
+
/**
|
|
3
|
+
* Choosing an SSH key for custody, safely.
|
|
4
|
+
*
|
|
5
|
+
* A real agent is not a clean room. It commonly holds forwarded keys whose
|
|
6
|
+
* upstream connection is gone, and confirm-on-use keys that wait for a human at
|
|
7
|
+
* a terminal nobody is sitting at. Neither fails — both **hang**, which during a
|
|
8
|
+
* genesis ceremony looks exactly like the platform being broken.
|
|
9
|
+
*
|
|
10
|
+
* So every key is probed under a timeout before it is offered, and the one
|
|
11
|
+
* actually chosen is proved deterministic before it is trusted with a share. An
|
|
12
|
+
* agent that signs differently twice would seal a share that can never be
|
|
13
|
+
* reopened, and that failure would surface only during recovery.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PROBE_TIMEOUT_MS = 2000;
|
|
16
|
+
export interface UsableIdentity extends AgentIdentity {
|
|
17
|
+
/** Milliseconds the agent took to sign. Slow keys are usually forwarded. */
|
|
18
|
+
responseMs: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Every Ed25519 key in the agent that actually responds.
|
|
22
|
+
*
|
|
23
|
+
* Returns an empty array rather than throwing when the agent holds nothing
|
|
24
|
+
* usable — the caller has a better error to give than this function does.
|
|
25
|
+
*/
|
|
26
|
+
export declare function usableIdentities(socketPath?: string): Promise<UsableIdentity[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Derive the custody key for a chosen identity, having proved it reproduces.
|
|
29
|
+
*
|
|
30
|
+
* `assertDeterministic` signs twice and compares. It costs one extra signature
|
|
31
|
+
* and removes the only failure mode that is invisible until recovery.
|
|
32
|
+
*/
|
|
33
|
+
export declare function custodyKeyFor(identity: AgentIdentity, socketPath?: string): Promise<Uint8Array>;
|
|
34
|
+
/** Match a key by fingerprint prefix, comment, or 1-based index. */
|
|
35
|
+
export declare function selectIdentity(identities: UsableIdentity[], selector: string): UsableIdentity | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type SealedShare } from '@forgezero/runtime/custody-share';
|
|
2
|
+
import type { UsableIdentity } from './custody';
|
|
3
|
+
/**
|
|
4
|
+
* The genesis ceremony, driven from a terminal.
|
|
5
|
+
*
|
|
6
|
+
* Every value that matters is produced HERE and only sealed material crosses
|
|
7
|
+
* the wire:
|
|
8
|
+
*
|
|
9
|
+
* phrase generated locally, printed once, never transmitted or stored
|
|
10
|
+
* ssh key derived locally from an agent signature, never transmitted
|
|
11
|
+
*
|
|
12
|
+
* The API receives the derived 32-byte keys because that is what seals a share,
|
|
13
|
+
* and it holds them for exactly the length of one request. Nothing here gives
|
|
14
|
+
* the CLI a privileged path — it calls the same endpoints the browser does, and
|
|
15
|
+
* a bypass that existed for bootstrapping would exist for an attacker too.
|
|
16
|
+
*/
|
|
17
|
+
export interface Transport {
|
|
18
|
+
(path: string, init?: {
|
|
19
|
+
method?: string;
|
|
20
|
+
body?: unknown;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
status: number;
|
|
23
|
+
body: unknown;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface GenesisResult {
|
|
27
|
+
ceremonyKey: string;
|
|
28
|
+
phrase: string[];
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
activated: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run a single-custodian genesis.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately limited to the operator running the command. A multi-custodian
|
|
36
|
+
* genesis needs each person at their own terminal with their own agent, which
|
|
37
|
+
* is a coordination problem rather than a code one — and pretending to do it
|
|
38
|
+
* from one shell would mean one machine briefly holding every share.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runGenesis(args: {
|
|
41
|
+
api: Transport;
|
|
42
|
+
modeId: string;
|
|
43
|
+
userKey: string;
|
|
44
|
+
email: string;
|
|
45
|
+
identity: UsableIdentity;
|
|
46
|
+
/** The `plt_` token setup.sh wrote. Required — see below. */
|
|
47
|
+
token: string;
|
|
48
|
+
displayName?: string;
|
|
49
|
+
socketPath?: string;
|
|
50
|
+
onStep?: (message: string) => void;
|
|
51
|
+
}): Promise<GenesisResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Reconstruct the seed and unlock.
|
|
54
|
+
*
|
|
55
|
+
* A restart genuinely locks the vault — residency is derived from memory, never
|
|
56
|
+
* a stored flag — so this is the routine every reboot needs, not an exceptional
|
|
57
|
+
* recovery path.
|
|
58
|
+
*/
|
|
59
|
+
export declare function runUnlock(args: {
|
|
60
|
+
api: Transport;
|
|
61
|
+
userKey: string;
|
|
62
|
+
identity?: UsableIdentity;
|
|
63
|
+
phrase?: string[];
|
|
64
|
+
socketPath?: string;
|
|
65
|
+
/**
|
|
66
|
+
* This custodian's sealed envelope, read from the ceremony.
|
|
67
|
+
*
|
|
68
|
+
* Supplied by the caller because only they know which ceremony they are
|
|
69
|
+
* unlocking. Opening it is a local step now, so no factor travels.
|
|
70
|
+
*/
|
|
71
|
+
sealed?: SealedShare;
|
|
72
|
+
/** The salt the phrase wrapping key was derived over. */
|
|
73
|
+
phraseSalt?: string;
|
|
74
|
+
onStep?: (message: string) => void;
|
|
75
|
+
}): Promise<{
|
|
76
|
+
fingerprint: string;
|
|
77
|
+
}>;
|
|
78
|
+
/** Resolve `--key` to a usable identity, with an actionable error if it cannot. */
|
|
79
|
+
export declare function resolveIdentity(selector: string | undefined, socketPath?: string): Promise<UsableIdentity>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { type AgentIdentity } from '@forgezero/runtime/ssh-agent';
|
|
3
|
+
/**
|
|
4
|
+
* What every CLI request carries.
|
|
5
|
+
*
|
|
6
|
+
* Extracted and exported because the `origin` header below is load-bearing and
|
|
7
|
+
* was invisible: `fz --help` proved the binary starts, and every command that
|
|
8
|
+
* CHANGED anything was answered 403 by the CSRF rule with nothing in between to
|
|
9
|
+
* notice. A header nobody can see is a header nobody maintains.
|
|
10
|
+
*/
|
|
11
|
+
export declare function requestHeaders(apiBase: string, cookie: string | null): Record<string, string>;
|
|
12
|
+
export declare function useCustodyIdentity(identity: AgentIdentity, socketPath?: string): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator process injection, shipped beside the agent command.
|
|
3
|
+
*
|
|
4
|
+
* `fz run -- <command>` — hand secrets to a process that cannot ask for them.
|
|
5
|
+
*
|
|
6
|
+
* This is the FALLBACK, and it is worth saying so at the top of the file. The
|
|
7
|
+
* agent serves secrets over a unix socket and anything using `@forgezero/vault`
|
|
8
|
+
* reads them there, which means a rotation reaches a running process. An
|
|
9
|
+
* injected environment cannot rotate: a process started before a rotation runs
|
|
10
|
+
* on the old value until somebody restarts it, and nothing tells them.
|
|
11
|
+
*
|
|
12
|
+
* It exists because a great deal of software will never read a socket, and
|
|
13
|
+
* "rewrite your build tool" is not an adoption path.
|
|
14
|
+
*
|
|
15
|
+
* ## What an environment variable costs
|
|
16
|
+
*
|
|
17
|
+
* On Linux `/proc/<pid>/environ` is readable by the same user, and any child
|
|
18
|
+
* inherits everything. That is the standard trade and it is not hidden here:
|
|
19
|
+
* the whole point of the socket path is that it does not make that trade.
|
|
20
|
+
*
|
|
21
|
+
* Values never reach argv, because argv is world-readable in `ps` — which is a
|
|
22
|
+
* different and much worse exposure than the environment.
|
|
23
|
+
*/
|
|
24
|
+
export declare class RunError extends Error {
|
|
25
|
+
readonly code: 'NO_COMMAND' | 'NO_SEPARATOR' | 'BAD_NAME';
|
|
26
|
+
constructor(code: 'NO_COMMAND' | 'NO_SEPARATOR' | 'BAD_NAME', message: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Split fz's own arguments from the child's.
|
|
30
|
+
*
|
|
31
|
+
* `--` is the boundary and it is not optional. Without it `fz run node --watch`
|
|
32
|
+
* would have fz eat `--watch`, and the failure is a flag that silently does not
|
|
33
|
+
* reach the program somebody is debugging.
|
|
34
|
+
*
|
|
35
|
+
* Everything after the FIRST separator belongs to the child, separators
|
|
36
|
+
* included: `fz run -- sh -c 'x -- y'` has to pass the inner one through.
|
|
37
|
+
*/
|
|
38
|
+
export declare function splitAtSeparator(argv: readonly string[]): {
|
|
39
|
+
own: string[];
|
|
40
|
+
command: string[];
|
|
41
|
+
};
|
|
42
|
+
export interface MergeOptions {
|
|
43
|
+
/** Keep an existing value when both sides have the name. Default false. */
|
|
44
|
+
preserveEnv?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface MergeResult {
|
|
47
|
+
env: Record<string, string>;
|
|
48
|
+
/** Names present in both. Reported so a collision is never silent. */
|
|
49
|
+
collisions: string[];
|
|
50
|
+
/** Names the shell cannot express, refused rather than mangled. */
|
|
51
|
+
refused: string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Merge vault values into an environment.
|
|
55
|
+
*
|
|
56
|
+
* The vault wins by default: a caller running `fz run` is asking for vault
|
|
57
|
+
* values, and quietly preferring a stale variable already in the shell is how
|
|
58
|
+
* somebody debugs a wrong credential for an hour.
|
|
59
|
+
*
|
|
60
|
+
* `--preserve-env` inverts it for the case where a wrapper genuinely wants to
|
|
61
|
+
* override. Either way the COLLISIONS are returned so the caller can say which
|
|
62
|
+
* names were affected. Silence is what makes this confusing, not the direction.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mergeEnvironment(base: Record<string, string | undefined>, secrets: Record<string, string>, options?: MergeOptions): MergeResult;
|
|
65
|
+
/**
|
|
66
|
+
* What to print before handing over.
|
|
67
|
+
*
|
|
68
|
+
* Names only. A wrapper that echoed values would put every secret into whatever
|
|
69
|
+
* captured the build log, which is usually the one place they are kept longest.
|
|
70
|
+
*/
|
|
71
|
+
export declare function describeInjection(result: MergeResult, count: number): string;
|
|
72
|
+
/**
|
|
73
|
+
* Turn a child's exit into this process's exit.
|
|
74
|
+
*
|
|
75
|
+
* A wrapper that always exits 0 breaks every CI pipeline it is put in front of,
|
|
76
|
+
* silently, by turning a failed build into a passing one. A signal death is
|
|
77
|
+
* reported the way a shell reports it — 128 plus the signal number — so
|
|
78
|
+
* `fz run -- make` behaves like `make` for anything reading the code.
|
|
79
|
+
*/
|
|
80
|
+
export declare function exitCodeFor(status: {
|
|
81
|
+
code: number | null;
|
|
82
|
+
signal: string | null;
|
|
83
|
+
}): number;
|
|
84
|
+
/**
|
|
85
|
+
* Start the child and become its exit code.
|
|
86
|
+
*
|
|
87
|
+
* Separated from the command handler so it can be driven by a test with real
|
|
88
|
+
* processes. The interesting behaviour is entirely here — inherited stdio,
|
|
89
|
+
* forwarded signals, propagated status — and none of it is provable against a
|
|
90
|
+
* mock.
|
|
91
|
+
*/
|
|
92
|
+
export declare function spawnWith(command: readonly string[], env: Record<string, string>, report?: (line: string) => void): Promise<number>;
|
|
@@ -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;
|