@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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Server } from 'node:net';
|
|
2
|
+
import type { CommandInput, CommandResult } from './deployment';
|
|
3
|
+
export declare const DEFAULT_DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
4
|
+
/**
|
|
5
|
+
* Execute tenant-controlled project commands under a separate Unix identity.
|
|
6
|
+
*
|
|
7
|
+
* This daemon has no LoadCredential directive and cannot open the owner-only
|
|
8
|
+
* agent socket. The credential-bearing agent may submit an exact command and
|
|
9
|
+
* only the secrets named for that step; the subprocess can steal those values,
|
|
10
|
+
* because it is the intended consumer, but cannot steal the node or Git key.
|
|
11
|
+
*/
|
|
12
|
+
export declare function startDeploymentRunner(options: {
|
|
13
|
+
root: string;
|
|
14
|
+
home: string;
|
|
15
|
+
socketPath?: string;
|
|
16
|
+
/** systemd socket activation passes the agent-owned listener as fd 3. */
|
|
17
|
+
listenFd?: number;
|
|
18
|
+
exec?: (input: CommandInput, home: string) => Promise<CommandResult>;
|
|
19
|
+
}): {
|
|
20
|
+
server: Server;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export declare function requestDeploymentCommand(input: CommandInput, socketPath?: string): Promise<CommandResult>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/deployment-runner.ts
|
|
2
|
+
import { chmodSync, existsSync, realpathSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
4
|
+
import { connect, createServer } from "node:net";
|
|
5
|
+
var DEFAULT_DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
6
|
+
var MAX_REQUEST_BYTES = 256 * 1024;
|
|
7
|
+
var MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
var MAX_RESPONSE_BYTES = 2 * MAX_OUTPUT_BYTES + MAX_REQUEST_BYTES;
|
|
9
|
+
var READ_TIMEOUT_MS = 5000;
|
|
10
|
+
var MAX_COMMAND_TIMEOUT_MS = 60 * 60000;
|
|
11
|
+
function within(root, candidate) {
|
|
12
|
+
return candidate === root || candidate.startsWith(`${root}${sep}`);
|
|
13
|
+
}
|
|
14
|
+
function validate(root, input) {
|
|
15
|
+
if (!input || typeof input.command !== "string" || input.command.length < 1 || input.command.length > 64 * 1024) {
|
|
16
|
+
throw new Error("invalid deployment command");
|
|
17
|
+
}
|
|
18
|
+
if (!input.cwd || !isAbsolute(input.cwd))
|
|
19
|
+
throw new Error("deployment command needs an absolute working directory");
|
|
20
|
+
const realRoot = realpathSync(root);
|
|
21
|
+
const realCwd = realpathSync(input.cwd);
|
|
22
|
+
if (!within(realRoot, realCwd))
|
|
23
|
+
throw new Error("deployment command escaped the release root");
|
|
24
|
+
const timeoutMs = Math.trunc(input.timeoutMs ?? 10 * 60000);
|
|
25
|
+
if (timeoutMs < 1000 || timeoutMs > MAX_COMMAND_TIMEOUT_MS)
|
|
26
|
+
throw new Error("invalid deployment command timeout");
|
|
27
|
+
const env = {};
|
|
28
|
+
let environmentBytes = 0;
|
|
29
|
+
for (const [name, value] of Object.entries(input.env ?? {})) {
|
|
30
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof value !== "string") {
|
|
31
|
+
throw new Error("invalid deployment environment");
|
|
32
|
+
}
|
|
33
|
+
environmentBytes += Buffer.byteLength(name) + Buffer.byteLength(value);
|
|
34
|
+
if (environmentBytes > 128 * 1024)
|
|
35
|
+
throw new Error("deployment environment is too large");
|
|
36
|
+
env[name] = value;
|
|
37
|
+
}
|
|
38
|
+
return { command: input.command, cwd: realCwd, env, timeoutMs };
|
|
39
|
+
}
|
|
40
|
+
async function limited(stream) {
|
|
41
|
+
const reader = stream.getReader();
|
|
42
|
+
const decoder = new TextDecoder;
|
|
43
|
+
let output = "";
|
|
44
|
+
let remaining = MAX_OUTPUT_BYTES;
|
|
45
|
+
for (;; ) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done)
|
|
48
|
+
break;
|
|
49
|
+
if (remaining > 0) {
|
|
50
|
+
const chunk = value.byteLength <= remaining ? value : value.subarray(0, remaining);
|
|
51
|
+
output += decoder.decode(chunk, { stream: true });
|
|
52
|
+
remaining -= chunk.byteLength;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
output += decoder.decode();
|
|
56
|
+
return remaining === 0 ? `${output}
|
|
57
|
+
[output truncated at ${MAX_OUTPUT_BYTES} bytes]` : output;
|
|
58
|
+
}
|
|
59
|
+
async function execute(input, home) {
|
|
60
|
+
const child = Bun.spawn(["bash", "-Eeuo", "pipefail", "-c", input.command], {
|
|
61
|
+
cwd: input.cwd,
|
|
62
|
+
detached: true,
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
65
|
+
env: {
|
|
66
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
67
|
+
HOME: home,
|
|
68
|
+
XDG_CACHE_HOME: `${home}/cache`,
|
|
69
|
+
LANG: "C.UTF-8",
|
|
70
|
+
...input.env ?? {}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
let timedOut = false;
|
|
74
|
+
let forceTimer;
|
|
75
|
+
const killGroup = (signal) => {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(-child.pid, signal);
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
if (cause.code !== "ESRCH")
|
|
80
|
+
throw cause;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
timedOut = true;
|
|
85
|
+
killGroup("SIGTERM");
|
|
86
|
+
forceTimer = setTimeout(() => killGroup("SIGKILL"), 2000);
|
|
87
|
+
}, input.timeoutMs);
|
|
88
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
89
|
+
limited(child.stdout),
|
|
90
|
+
limited(child.stderr),
|
|
91
|
+
child.exited
|
|
92
|
+
]);
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (forceTimer)
|
|
95
|
+
clearTimeout(forceTimer);
|
|
96
|
+
return {
|
|
97
|
+
exitCode: timedOut ? 124 : exitCode,
|
|
98
|
+
output: `${stdout}${stderr}${timedOut ? `
|
|
99
|
+
command exceeded ${input.timeoutMs}ms` : ""}`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function startDeploymentRunner(options) {
|
|
103
|
+
const root = resolve(options.root);
|
|
104
|
+
const home = resolve(options.home);
|
|
105
|
+
const socketPath = options.socketPath ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET;
|
|
106
|
+
if (options.listenFd === undefined && existsSync(socketPath))
|
|
107
|
+
unlinkSync(socketPath);
|
|
108
|
+
const active = new Set;
|
|
109
|
+
const server = createServer((socket) => {
|
|
110
|
+
let buffer = "";
|
|
111
|
+
socket.setTimeout(READ_TIMEOUT_MS, () => socket.end(`${JSON.stringify({
|
|
112
|
+
ok: false,
|
|
113
|
+
error: { code: "REFUSED", message: "request timed out" }
|
|
114
|
+
})}
|
|
115
|
+
`));
|
|
116
|
+
socket.on("data", (chunk) => {
|
|
117
|
+
buffer += chunk.toString("utf8");
|
|
118
|
+
if (Buffer.byteLength(buffer) > MAX_REQUEST_BYTES) {
|
|
119
|
+
socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
|
|
120
|
+
`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const newline = buffer.indexOf(`
|
|
124
|
+
`);
|
|
125
|
+
if (newline < 0)
|
|
126
|
+
return;
|
|
127
|
+
socket.setTimeout(0);
|
|
128
|
+
const line = buffer.slice(0, newline);
|
|
129
|
+
buffer = "";
|
|
130
|
+
const work = (async () => {
|
|
131
|
+
try {
|
|
132
|
+
const input = validate(root, JSON.parse(line));
|
|
133
|
+
return { ok: true, result: await (options.exec ?? execute)(input, home) };
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: { code: "REFUSED", message: cause instanceof Error ? cause.message : "deployment command refused" }
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
active.add(work);
|
|
142
|
+
work.finally(() => active.delete(work));
|
|
143
|
+
work.then((response) => socket.end(`${JSON.stringify(response)}
|
|
144
|
+
`));
|
|
145
|
+
});
|
|
146
|
+
socket.on("error", () => socket.destroy());
|
|
147
|
+
});
|
|
148
|
+
if (options.listenFd !== undefined)
|
|
149
|
+
server.listen({ fd: options.listenFd });
|
|
150
|
+
else
|
|
151
|
+
server.listen(socketPath, () => chmodSync(socketPath, 432));
|
|
152
|
+
return {
|
|
153
|
+
server,
|
|
154
|
+
async stop() {
|
|
155
|
+
await new Promise((resolveStop) => server.close(() => resolveStop()));
|
|
156
|
+
await Promise.all(active);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function requestDeploymentCommand(input, socketPath = DEFAULT_DEPLOYMENT_RUNNER_SOCKET) {
|
|
161
|
+
return new Promise((resolveRequest, reject) => {
|
|
162
|
+
const socket = connect(socketPath, () => socket.write(`${JSON.stringify(input)}
|
|
163
|
+
`));
|
|
164
|
+
const timeout = Math.max(1000, input.timeoutMs ?? 10 * 60000) + 1e4;
|
|
165
|
+
socket.setTimeout(timeout, () => {
|
|
166
|
+
socket.destroy();
|
|
167
|
+
reject(new Error("deployment runner response timed out"));
|
|
168
|
+
});
|
|
169
|
+
let buffer = "";
|
|
170
|
+
socket.on("data", (chunk) => {
|
|
171
|
+
buffer += chunk.toString("utf8");
|
|
172
|
+
if (Buffer.byteLength(buffer) > MAX_RESPONSE_BYTES) {
|
|
173
|
+
socket.destroy();
|
|
174
|
+
reject(new Error("deployment runner response is too large"));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const newline = buffer.indexOf(`
|
|
178
|
+
`);
|
|
179
|
+
if (newline < 0)
|
|
180
|
+
return;
|
|
181
|
+
socket.end();
|
|
182
|
+
try {
|
|
183
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
184
|
+
if (response.ok)
|
|
185
|
+
resolveRequest(response.result);
|
|
186
|
+
else
|
|
187
|
+
reject(new Error(response.error.message));
|
|
188
|
+
} catch (cause) {
|
|
189
|
+
reject(cause);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
socket.on("error", reject);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
export {
|
|
196
|
+
startDeploymentRunner,
|
|
197
|
+
requestDeploymentCommand,
|
|
198
|
+
DEFAULT_DEPLOYMENT_RUNNER_SOCKET
|
|
199
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DeploymentManager } from './deployment';
|
|
2
|
+
export type StaticDeploymentOutcome = 'pending' | 'running' | 'deployed' | 'failed';
|
|
3
|
+
export interface StaticDeploymentState {
|
|
4
|
+
revision: string;
|
|
5
|
+
outcome: StaticDeploymentOutcome;
|
|
6
|
+
updatedAtTs: number;
|
|
7
|
+
detail?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface StaticDeploymentWatchOptions {
|
|
10
|
+
manager: DeploymentManager;
|
|
11
|
+
statePath: string;
|
|
12
|
+
coordinator?: boolean;
|
|
13
|
+
intervalMs?: number;
|
|
14
|
+
readState?: () => StaticDeploymentState | null;
|
|
15
|
+
writeState?: (state: StaticDeploymentState) => void;
|
|
16
|
+
/** Active release on the first watcher boot, before a state file exists. */
|
|
17
|
+
currentRevision?: () => string | undefined;
|
|
18
|
+
setTimer?: (callback: () => void, ms: number) => unknown;
|
|
19
|
+
clearTimer?: (handle: unknown) => void;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
onEvent?: (event: string, detail?: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function readStaticDeploymentState(path: string): StaticDeploymentState | null;
|
|
24
|
+
export declare function writeStaticDeploymentState(path: string, state: StaticDeploymentState): void;
|
|
25
|
+
/**
|
|
26
|
+
* Git is only an intake adapter. Every discovered revision is still executed
|
|
27
|
+
* by DeploymentManager and therefore by the common keyed async queue.
|
|
28
|
+
*
|
|
29
|
+
* The state file is business recovery state, not queue persistence: pending
|
|
30
|
+
* and running are written before execution, so a process crash re-submits the
|
|
31
|
+
* exact commit on boot; deployed is written only after the awaited result.
|
|
32
|
+
*/
|
|
33
|
+
export declare function startStaticDeploymentWatch(options: StaticDeploymentWatchOptions): {
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
readonly active: boolean;
|
|
36
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type DrainReport, type QueueTask } from '@forgezero/runtime/queue';
|
|
2
|
+
import { type RunResult } from './pipeline';
|
|
3
|
+
import type { SecretCache } from './cache';
|
|
4
|
+
import type { AttestationSource } from './socket';
|
|
5
|
+
export interface CommandInput {
|
|
6
|
+
command: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface CommandResult {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
output: string;
|
|
14
|
+
}
|
|
15
|
+
export interface DeploymentRequest {
|
|
16
|
+
/** Optional exact commit from a verified webhook. Never a branch name. */
|
|
17
|
+
revision?: string;
|
|
18
|
+
/** Whether this node is the coordinator allowed to execute `once` steps. */
|
|
19
|
+
coordinator?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface DeploymentResult {
|
|
22
|
+
key: string;
|
|
23
|
+
repository: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
revision: string;
|
|
26
|
+
release: string;
|
|
27
|
+
ok: boolean;
|
|
28
|
+
phases: readonly RunResult[];
|
|
29
|
+
}
|
|
30
|
+
export interface DeploymentOptions {
|
|
31
|
+
/** Queue key. Same project/environment is serial; other managers may run in parallel. */
|
|
32
|
+
key: string;
|
|
33
|
+
repository: string;
|
|
34
|
+
branch: string;
|
|
35
|
+
role: string;
|
|
36
|
+
root: string;
|
|
37
|
+
publicApiUrl?: string;
|
|
38
|
+
gitCredentialPath?: string;
|
|
39
|
+
knownHostsPath?: string;
|
|
40
|
+
/** Server-owned, operator-pinned host keys for a dynamically assigned source. */
|
|
41
|
+
knownHostsContent?: string;
|
|
42
|
+
cache?: Pick<SecretCache, 'get'>;
|
|
43
|
+
/** Non-secret values explicitly passed to every project phase. */
|
|
44
|
+
environment?: Record<string, string>;
|
|
45
|
+
attestation?: AttestationSource;
|
|
46
|
+
width?: number;
|
|
47
|
+
/** Project commands run through a credential-free Unix identity in production. */
|
|
48
|
+
projectExec?: (input: CommandInput) => Promise<CommandResult>;
|
|
49
|
+
exec?: (input: CommandInput) => Promise<CommandResult>;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
readDefinition?: (path: string) => unknown;
|
|
52
|
+
}
|
|
53
|
+
export declare class DeploymentError extends Error {
|
|
54
|
+
readonly code: 'BAD_REVISION' | 'SOURCE_FAILED' | 'PIPELINE_FAILED' | 'SECRET_MISSING';
|
|
55
|
+
constructor(code: 'BAD_REVISION' | 'SOURCE_FAILED' | 'PIPELINE_FAILED' | 'SECRET_MISSING', message: string);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One source and one pipeline owner.
|
|
59
|
+
*
|
|
60
|
+
* A request may choose an exact commit and whether this assigned node is the
|
|
61
|
+
* coordinator. It cannot choose a repository, branch, working directory or
|
|
62
|
+
* command: those are sealed into the agent unit and the checked-out definition.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createDeploymentManager(options: DeploymentOptions): {
|
|
65
|
+
latestRevision(): Promise<string>;
|
|
66
|
+
deploy(request?: DeploymentRequest): QueueTask<DeploymentResult>;
|
|
67
|
+
snapshot: () => {
|
|
68
|
+
running: number;
|
|
69
|
+
queued: number;
|
|
70
|
+
keys: number;
|
|
71
|
+
paused: boolean;
|
|
72
|
+
pausedKeys: string[];
|
|
73
|
+
stoppedKeys: string[];
|
|
74
|
+
completed: number;
|
|
75
|
+
failed: number;
|
|
76
|
+
};
|
|
77
|
+
pause: () => void;
|
|
78
|
+
resume: () => void;
|
|
79
|
+
pauseKey: (key: string) => void;
|
|
80
|
+
resumeKey: (key: string) => void;
|
|
81
|
+
stopKey: (key: string) => number;
|
|
82
|
+
startKey: (key: string) => boolean;
|
|
83
|
+
cancel: (id: string) => boolean;
|
|
84
|
+
stop(deadlineMs?: number): Promise<DrainReport>;
|
|
85
|
+
};
|
|
86
|
+
export type DeploymentManager = ReturnType<typeof createDeploymentManager>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|