@forgezero/agent 0.1.30 → 0.1.31
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 +28 -0
- package/dist/agent-heartbeat.js +1 -1
- package/dist/definition.d.ts +5 -2
- package/dist/definition.js +32 -21
- package/dist/deploy-file.d.ts +40 -0
- package/dist/deploy-file.js +378 -0
- package/dist/deployment.d.ts +4 -0
- package/dist/fz-agent.js +59 -28
- package/dist/fz.js +417 -19
- package/dist/provision.js +4 -4
- package/dist/software-helper.js +3 -3
- package/dist/software.d.ts +1 -1
- package/dist/software.js +3 -3
- package/dist/version.d.ts +1 -1
- package/package.json +8 -2
- package/schema/deploy-v2.json +70 -0
package/README.md
CHANGED
|
@@ -145,6 +145,34 @@ file before executing any command, prepares only the selected profile, and gives
|
|
|
145
145
|
step only the vault values it explicitly names. `await` returns the complete
|
|
146
146
|
pipeline result; no polling service or persistent queue is required.
|
|
147
147
|
|
|
148
|
+
Create and validate that file with the same public package that executes it:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
fz deploy catalog --channel production
|
|
152
|
+
fz deploy init --profile app --software bun@1.3.14
|
|
153
|
+
# Replace the explicit safe blockers with this project's release and health commands.
|
|
154
|
+
fz deploy check
|
|
155
|
+
fz deploy sync
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`init` refuses to invent a generic release or health check: both generated steps
|
|
159
|
+
exit non-zero until the project replaces them. `check` validates the published
|
|
160
|
+
v2 schema and active software coordinates, then prints a formatting-independent
|
|
161
|
+
semantic SHA-256 digest. The schema ships at
|
|
162
|
+
`@forgezero/agent/schema/deploy-v2.json` and is served from
|
|
163
|
+
`https://www.forgezero.net/schemas/deploy-v2.json`.
|
|
164
|
+
|
|
165
|
+
Git is the only local-to-live synchronization mechanism. `sync` validates and
|
|
166
|
+
prints that rule; it does not create a second mutable command copy in the API.
|
|
167
|
+
The verified webhook identifies one exact commit, and a successful Agent result
|
|
168
|
+
persists the definition digest so the Applications screen can prove which local
|
|
169
|
+
contract became live.
|
|
170
|
+
|
|
171
|
+
Catalog status is an admission boundary, not a suggestion. `testing` coordinates
|
|
172
|
+
are visible in the development catalog for ForgeZero qualification but cannot be
|
|
173
|
+
selected by any deploy file. A repository cannot promote software by calling
|
|
174
|
+
itself development; only a reviewed catalog change to `active` unlocks it.
|
|
175
|
+
|
|
148
176
|
The daemon owns source checkout and command execution. Bootstrap explicitly
|
|
149
177
|
awaits release one because the API does not exist yet, then the Agent consumes a
|
|
150
178
|
one-use platform enrolment capability. There is no branch watcher. Every normal
|
package/dist/agent-heartbeat.js
CHANGED
package/dist/definition.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Pipeline, PipelineStep } from './pipeline';
|
|
2
|
-
import { type SoftwareRequirement } from './software';
|
|
2
|
+
import { type DeploymentChannel, type SoftwareRequirement } from './software';
|
|
3
3
|
/**
|
|
4
4
|
* Version two separates a repository's deploy recipe from the computes that use
|
|
5
5
|
* it. A target chooses one named profile in the control plane; compute names,
|
|
6
6
|
* counts and cluster leadership never belong in Git.
|
|
7
7
|
*/
|
|
8
8
|
export declare const PIPELINE_VERSION: 2;
|
|
9
|
+
export declare const DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
|
|
9
10
|
export interface PipelineProfile {
|
|
10
11
|
software: readonly SoftwareRequirement[];
|
|
11
12
|
}
|
|
@@ -29,5 +30,7 @@ export declare class DefinitionError extends Error {
|
|
|
29
30
|
constructor(message: string);
|
|
30
31
|
}
|
|
31
32
|
/** Validate parsed JSON before any command from it is allowed to run. */
|
|
32
|
-
export declare function parseDeployDefinition(value: unknown
|
|
33
|
+
export declare function parseDeployDefinition(value: unknown, options?: {
|
|
34
|
+
channel?: DeploymentChannel;
|
|
35
|
+
}): DeployDefinition;
|
|
33
36
|
export declare function phasePipeline(definition: DeployDefinition, phase: DeployStep['phase'], profile: string, executeRelease?: boolean): Pipeline;
|
package/dist/definition.js
CHANGED
|
@@ -51,7 +51,7 @@ function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8")
|
|
|
51
51
|
architecture
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
-
function validateSoftwareRequirements(value,
|
|
54
|
+
function validateSoftwareRequirements(value, _options = {}) {
|
|
55
55
|
if (!Array.isArray(value) || value.length > 32)
|
|
56
56
|
throw new Error("software requirements must be an array of at most 32 entries");
|
|
57
57
|
const seen = new Set;
|
|
@@ -70,8 +70,8 @@ function validateSoftwareRequirements(value, options = {}) {
|
|
|
70
70
|
throw new Error(`duplicate software requirement: ${requirement.id}`);
|
|
71
71
|
seen.add(requirement.id);
|
|
72
72
|
const catalog = SOFTWARE_CATALOG.find((candidate) => candidate.id === requirement.id && candidate.version === requirement.version);
|
|
73
|
-
if (!catalog || catalog.status
|
|
74
|
-
throw new Error(`software requirement is not
|
|
73
|
+
if (!catalog || catalog.status !== "active") {
|
|
74
|
+
throw new Error(`software requirement is not active: ${requirement.id}@${requirement.version}`);
|
|
75
75
|
}
|
|
76
76
|
return requirement;
|
|
77
77
|
});
|
|
@@ -106,6 +106,7 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
106
106
|
|
|
107
107
|
// src/definition.ts
|
|
108
108
|
var PIPELINE_VERSION = 2;
|
|
109
|
+
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
|
|
109
110
|
|
|
110
111
|
class DefinitionError extends Error {
|
|
111
112
|
constructor(message) {
|
|
@@ -143,15 +144,18 @@ var RESERVED_STEP_ENV = new Set([
|
|
|
143
144
|
"GIT_SSH",
|
|
144
145
|
"GIT_SSH_COMMAND"
|
|
145
146
|
]);
|
|
146
|
-
function parseDeployDefinition(value) {
|
|
147
|
+
function parseDeployDefinition(value, options = {}) {
|
|
147
148
|
const root = record(value, "pipeline");
|
|
148
149
|
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
149
|
-
if (root.$schema !== undefined &&
|
|
150
|
-
throw new DefinitionError(
|
|
150
|
+
if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
|
|
151
|
+
throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
|
|
151
152
|
}
|
|
152
153
|
if (root.version !== PIPELINE_VERSION) {
|
|
153
154
|
throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
|
|
154
155
|
}
|
|
156
|
+
if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
|
|
157
|
+
throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
|
|
158
|
+
}
|
|
155
159
|
const rawProfiles = record(root.profiles, "pipeline.profiles");
|
|
156
160
|
const profileEntries = Object.entries(rawProfiles);
|
|
157
161
|
if (profileEntries.length === 0 || profileEntries.length > 32) {
|
|
@@ -161,15 +165,15 @@ function parseDeployDefinition(value) {
|
|
|
161
165
|
throw new DefinitionError("pipeline.steps must contain at least one step.");
|
|
162
166
|
}
|
|
163
167
|
const profiles = {};
|
|
164
|
-
for (const [
|
|
165
|
-
if (!NAME.test(
|
|
166
|
-
throw new DefinitionError(`pipeline profile name is invalid: ${
|
|
167
|
-
const profile = record(raw, `profiles.${
|
|
168
|
-
exactKeys(profile, ["software"], `profiles.${
|
|
168
|
+
for (const [name2, raw] of profileEntries) {
|
|
169
|
+
if (!NAME.test(name2))
|
|
170
|
+
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
171
|
+
const profile = record(raw, `profiles.${name2}`);
|
|
172
|
+
exactKeys(profile, ["software"], `profiles.${name2}`);
|
|
169
173
|
if (!Array.isArray(profile.software)) {
|
|
170
|
-
throw new DefinitionError(`profiles.${
|
|
174
|
+
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
171
175
|
}
|
|
172
|
-
profiles[
|
|
176
|
+
profiles[name2] = { software: validateSoftwareRequirements(profile.software, options) };
|
|
173
177
|
}
|
|
174
178
|
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
175
179
|
const steps = root.steps.map((raw, index) => {
|
|
@@ -181,9 +185,12 @@ function parseDeployDefinition(value) {
|
|
|
181
185
|
if (step.scope !== "target" && step.scope !== "release") {
|
|
182
186
|
throw new DefinitionError(`steps[${index}].scope must be target or release.`);
|
|
183
187
|
}
|
|
188
|
+
if (step.always !== undefined && typeof step.always !== "boolean") {
|
|
189
|
+
throw new DefinitionError(`steps[${index}].always must be a boolean.`);
|
|
190
|
+
}
|
|
184
191
|
let selectedProfiles;
|
|
185
192
|
if (step.profiles !== undefined) {
|
|
186
|
-
if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((
|
|
193
|
+
if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
|
|
187
194
|
throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
|
|
188
195
|
}
|
|
189
196
|
selectedProfiles = [...step.profiles];
|
|
@@ -191,13 +198,13 @@ function parseDeployDefinition(value) {
|
|
|
191
198
|
throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
|
|
192
199
|
}
|
|
193
200
|
}
|
|
194
|
-
if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((
|
|
201
|
+
if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
|
|
195
202
|
throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
|
|
196
203
|
}
|
|
197
204
|
if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
|
|
198
205
|
throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
|
|
199
206
|
}
|
|
200
|
-
if (Array.isArray(step.secrets) && step.secrets.some((
|
|
207
|
+
if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
|
|
201
208
|
throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
|
|
202
209
|
}
|
|
203
210
|
const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
|
|
@@ -208,11 +215,11 @@ function parseDeployDefinition(value) {
|
|
|
208
215
|
if (step.when !== undefined) {
|
|
209
216
|
const conditions = record(step.when, `steps[${index}].when`);
|
|
210
217
|
when = {};
|
|
211
|
-
for (const [
|
|
212
|
-
if (!/^[A-Z_][A-Z0-9_]*$/.test(
|
|
218
|
+
for (const [name2, expected] of Object.entries(conditions)) {
|
|
219
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
|
|
213
220
|
throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
|
|
214
221
|
}
|
|
215
|
-
when[
|
|
222
|
+
when[name2] = expected;
|
|
216
223
|
}
|
|
217
224
|
if (Object.keys(when).length === 0)
|
|
218
225
|
throw new DefinitionError(`steps[${index}].when must not be empty.`);
|
|
@@ -232,9 +239,12 @@ function parseDeployDefinition(value) {
|
|
|
232
239
|
if (new Set(steps.map((step) => step.name)).size !== steps.length) {
|
|
233
240
|
throw new DefinitionError("pipeline.steps must have unique names.");
|
|
234
241
|
}
|
|
242
|
+
const name = text(root.name, "pipeline.name");
|
|
243
|
+
if (name.length > 120)
|
|
244
|
+
throw new DefinitionError("pipeline.name must be at most 120 characters.");
|
|
235
245
|
return {
|
|
236
246
|
version: PIPELINE_VERSION,
|
|
237
|
-
name
|
|
247
|
+
name,
|
|
238
248
|
requireAttestation: root.requireAttestation === true,
|
|
239
249
|
profiles,
|
|
240
250
|
steps
|
|
@@ -254,5 +264,6 @@ export {
|
|
|
254
264
|
phasePipeline,
|
|
255
265
|
parseDeployDefinition,
|
|
256
266
|
PIPELINE_VERSION,
|
|
257
|
-
DefinitionError
|
|
267
|
+
DefinitionError,
|
|
268
|
+
DEPLOY_SCHEMA_URL
|
|
258
269
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type DeployDefinition } from './definition';
|
|
2
|
+
import { type DeploymentChannel, type SoftwareRequirement } from './software';
|
|
3
|
+
export declare const DEPLOY_FILE = ".fz/deploy.json";
|
|
4
|
+
export { DEPLOY_SCHEMA_URL } from './definition';
|
|
5
|
+
/**
|
|
6
|
+
* An initialized file must fail safely until its project-specific promotion and
|
|
7
|
+
* health commands are supplied. A generic initializer cannot guess how a
|
|
8
|
+
* tenant starts a service, which systemd unit it owns, or what "healthy" means.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
|
|
11
|
+
export interface DeployFileSummary {
|
|
12
|
+
path: string;
|
|
13
|
+
digest: string;
|
|
14
|
+
version: number;
|
|
15
|
+
name: string;
|
|
16
|
+
profiles: readonly string[];
|
|
17
|
+
software: Readonly<Record<string, readonly SoftwareRequirement[]>>;
|
|
18
|
+
ready: boolean;
|
|
19
|
+
problems: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export interface InitializedDeployFile {
|
|
22
|
+
path: string;
|
|
23
|
+
definition: DeployDefinition;
|
|
24
|
+
summary: DeployFileSummary;
|
|
25
|
+
}
|
|
26
|
+
/** Semantic digest: formatting and object-key order cannot create false drift. */
|
|
27
|
+
export declare function deployDefinitionDigest(definition: DeployDefinition): string;
|
|
28
|
+
export declare function defaultDeployFile(root: string, options?: {
|
|
29
|
+
name?: string;
|
|
30
|
+
profile?: string;
|
|
31
|
+
software?: readonly SoftwareRequirement[];
|
|
32
|
+
requireAttestation?: boolean;
|
|
33
|
+
channel?: DeploymentChannel;
|
|
34
|
+
}): Record<string, unknown>;
|
|
35
|
+
export declare function inspectDeployFile(root: string, options?: {
|
|
36
|
+
channel?: DeploymentChannel;
|
|
37
|
+
}): InitializedDeployFile;
|
|
38
|
+
export declare function initializeDeployFile(root: string, options?: Parameters<typeof defaultDeployFile>[1] & {
|
|
39
|
+
force?: boolean;
|
|
40
|
+
}): InitializedDeployFile;
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// src/software.ts
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
4
|
+
var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
|
|
5
|
+
var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
|
|
6
|
+
var OS_CATALOG = [
|
|
7
|
+
{ id: "ubuntu", version: "26.04", architecture: "x64", status: "active" }
|
|
8
|
+
];
|
|
9
|
+
var SOFTWARE_CATALOG = [
|
|
10
|
+
{ id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
11
|
+
{ id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
12
|
+
{ id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
13
|
+
{ id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
14
|
+
{ id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
|
|
15
|
+
];
|
|
16
|
+
var UBUNTU_2604_X64 = [
|
|
17
|
+
{
|
|
18
|
+
requirement: { id: "bun", version: "1.3.14" },
|
|
19
|
+
check: 'test "$(/usr/local/bin/bun --version 2>/dev/null)" = 1.3.14',
|
|
20
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL https://bun.sh/install -o "$tmp/install"; ` + `echo "${BUN_INSTALLER_SHA256} $tmp/install" | sha256sum -c -; ` + `BUN_INSTALL="$tmp/bun" BUN_VERSION=1.3.14 bash "$tmp/install" >/dev/null; ` + `install -d -m 0755 /usr/local/lib/forgezero/runtime; ` + `install -m 0755 "$tmp/bun/bin/bun" /usr/local/lib/forgezero/runtime/bun.next; ` + `mv -Tf /usr/local/lib/forgezero/runtime/bun.next /usr/local/lib/forgezero/runtime/bun; ` + `ln -sfn /usr/local/lib/forgezero/runtime/bun /usr/local/bin/bun`
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
requirement: { id: "nginx", version: "ubuntu-26.04" },
|
|
24
|
+
check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
|
|
25
|
+
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
requirement: { id: "arangodb", version: "3.11.14" },
|
|
29
|
+
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
|
|
30
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install`
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
requirement: { id: "cloudflared", version: "2026.7.3" },
|
|
34
|
+
check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
|
|
35
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64' -o "$tmp/cloudflared"; ` + `echo "${CLOUDFLARED_SHA256} $tmp/cloudflared" | sha256sum -c -; ` + `install -m 0755 "$tmp/cloudflared" /usr/local/bin/cloudflared`
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
requirement: { id: "ufw", version: "ubuntu-26.04" },
|
|
39
|
+
check: "command -v ufw >/dev/null",
|
|
40
|
+
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8"), architecture = process.arch) {
|
|
44
|
+
const values = Object.fromEntries(osRelease.split(`
|
|
45
|
+
`).flatMap((line) => {
|
|
46
|
+
const separator = line.indexOf("=");
|
|
47
|
+
return separator > 0 ? [[line.slice(0, separator), line.slice(separator + 1).replace(/^['"]|['"]$/g, "")]] : [];
|
|
48
|
+
}));
|
|
49
|
+
return {
|
|
50
|
+
os: { id: (values.ID ?? "unknown").toLowerCase(), versionId: values.VERSION_ID ?? "unknown" },
|
|
51
|
+
architecture
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function validateSoftwareRequirements(value, _options = {}) {
|
|
55
|
+
if (!Array.isArray(value) || value.length > 32)
|
|
56
|
+
throw new Error("software requirements must be an array of at most 32 entries");
|
|
57
|
+
const seen = new Set;
|
|
58
|
+
return value.map((item) => {
|
|
59
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
60
|
+
throw new Error("software requirement must be an object");
|
|
61
|
+
const row = item;
|
|
62
|
+
if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
|
|
63
|
+
throw new Error("software requirement contains an unknown field");
|
|
64
|
+
}
|
|
65
|
+
if (!["bun", "nginx", "arangodb", "cloudflared", "ufw"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
|
|
66
|
+
throw new Error("software requirement coordinate is invalid");
|
|
67
|
+
}
|
|
68
|
+
const requirement = { id: row.id, version: row.version };
|
|
69
|
+
if (seen.has(requirement.id))
|
|
70
|
+
throw new Error(`duplicate software requirement: ${requirement.id}`);
|
|
71
|
+
seen.add(requirement.id);
|
|
72
|
+
const catalog = SOFTWARE_CATALOG.find((candidate) => candidate.id === requirement.id && candidate.version === requirement.version);
|
|
73
|
+
if (!catalog || catalog.status !== "active") {
|
|
74
|
+
throw new Error(`software requirement is not active: ${requirement.id}@${requirement.version}`);
|
|
75
|
+
}
|
|
76
|
+
return requirement;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
80
|
+
const requirements = validateSoftwareRequirements(requirementsInput);
|
|
81
|
+
const observation = options.observation ?? observeSoftwareHost();
|
|
82
|
+
const os = OS_CATALOG.find((candidate) => candidate.id === observation.os.id && candidate.version === observation.os.versionId && candidate.architecture === observation.architecture);
|
|
83
|
+
if (!os || os.status !== "active") {
|
|
84
|
+
throw new Error(`unsupported software strategy: ${observation.os.id} ${observation.os.versionId} ${observation.architecture}`);
|
|
85
|
+
}
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const requirement of requirements) {
|
|
88
|
+
const strategy = UBUNTU_2604_X64.find(({ requirement: candidate }) => candidate.id === requirement.id && candidate.version === requirement.version);
|
|
89
|
+
if (!strategy)
|
|
90
|
+
throw new Error(`unsupported software requirement: ${requirement.id}@${requirement.version}`);
|
|
91
|
+
const before = await options.exec(strategy.check);
|
|
92
|
+
if (before.exitCode === 0) {
|
|
93
|
+
results.push({ ...requirement, changed: false });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const installed = await options.exec(strategy.install);
|
|
97
|
+
if (installed.exitCode !== 0)
|
|
98
|
+
throw new Error(`could not install ${requirement.id}@${requirement.version}: ${installed.output.trim()}`);
|
|
99
|
+
const after = await options.exec(strategy.check);
|
|
100
|
+
if (after.exitCode !== 0)
|
|
101
|
+
throw new Error(`${requirement.id}@${requirement.version} did not pass its post-install check`);
|
|
102
|
+
results.push({ ...requirement, changed: true });
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/definition.ts
|
|
108
|
+
var PIPELINE_VERSION = 2;
|
|
109
|
+
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v2.json";
|
|
110
|
+
|
|
111
|
+
class DefinitionError extends Error {
|
|
112
|
+
constructor(message) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = "DefinitionError";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
var record = (value, where) => {
|
|
118
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
119
|
+
throw new DefinitionError(`${where} must be an object.`);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
};
|
|
123
|
+
var text = (value, where) => {
|
|
124
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
125
|
+
throw new DefinitionError(`${where} must be a non-empty string.`);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
};
|
|
129
|
+
var exactKeys = (value, allowed, where) => {
|
|
130
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
131
|
+
if (unknown.length > 0)
|
|
132
|
+
throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
|
|
133
|
+
};
|
|
134
|
+
var NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
135
|
+
var RESERVED_STEP_ENV = new Set([
|
|
136
|
+
"PATH",
|
|
137
|
+
"HOME",
|
|
138
|
+
"SHELL",
|
|
139
|
+
"PWD",
|
|
140
|
+
"BUN_INSTALL",
|
|
141
|
+
"NODE_OPTIONS",
|
|
142
|
+
"LD_PRELOAD",
|
|
143
|
+
"LD_LIBRARY_PATH",
|
|
144
|
+
"GIT_SSH",
|
|
145
|
+
"GIT_SSH_COMMAND"
|
|
146
|
+
]);
|
|
147
|
+
function parseDeployDefinition(value, options = {}) {
|
|
148
|
+
const root = record(value, "pipeline");
|
|
149
|
+
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
150
|
+
if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
|
|
151
|
+
throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
|
|
152
|
+
}
|
|
153
|
+
if (root.version !== PIPELINE_VERSION) {
|
|
154
|
+
throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
|
|
155
|
+
}
|
|
156
|
+
if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
|
|
157
|
+
throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
|
|
158
|
+
}
|
|
159
|
+
const rawProfiles = record(root.profiles, "pipeline.profiles");
|
|
160
|
+
const profileEntries = Object.entries(rawProfiles);
|
|
161
|
+
if (profileEntries.length === 0 || profileEntries.length > 32) {
|
|
162
|
+
throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(root.steps) || root.steps.length === 0) {
|
|
165
|
+
throw new DefinitionError("pipeline.steps must contain at least one step.");
|
|
166
|
+
}
|
|
167
|
+
const profiles = {};
|
|
168
|
+
for (const [name2, raw] of profileEntries) {
|
|
169
|
+
if (!NAME.test(name2))
|
|
170
|
+
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
171
|
+
const profile = record(raw, `profiles.${name2}`);
|
|
172
|
+
exactKeys(profile, ["software"], `profiles.${name2}`);
|
|
173
|
+
if (!Array.isArray(profile.software)) {
|
|
174
|
+
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
175
|
+
}
|
|
176
|
+
profiles[name2] = { software: validateSoftwareRequirements(profile.software, options) };
|
|
177
|
+
}
|
|
178
|
+
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
179
|
+
const steps = root.steps.map((raw, index) => {
|
|
180
|
+
const step = record(raw, `steps[${index}]`);
|
|
181
|
+
exactKeys(step, ["name", "run", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
|
|
182
|
+
const phase = text(step.phase, `steps[${index}].phase`);
|
|
183
|
+
if (!phases.has(phase))
|
|
184
|
+
throw new DefinitionError(`steps[${index}].phase is not supported.`);
|
|
185
|
+
if (step.scope !== "target" && step.scope !== "release") {
|
|
186
|
+
throw new DefinitionError(`steps[${index}].scope must be target or release.`);
|
|
187
|
+
}
|
|
188
|
+
if (step.always !== undefined && typeof step.always !== "boolean") {
|
|
189
|
+
throw new DefinitionError(`steps[${index}].always must be a boolean.`);
|
|
190
|
+
}
|
|
191
|
+
let selectedProfiles;
|
|
192
|
+
if (step.profiles !== undefined) {
|
|
193
|
+
if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
|
|
194
|
+
throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
|
|
195
|
+
}
|
|
196
|
+
selectedProfiles = [...step.profiles];
|
|
197
|
+
if (new Set(selectedProfiles).size !== selectedProfiles.length) {
|
|
198
|
+
throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
|
|
202
|
+
throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
|
|
203
|
+
}
|
|
204
|
+
if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
|
|
205
|
+
throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
|
|
208
|
+
throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
|
|
209
|
+
}
|
|
210
|
+
const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
|
|
211
|
+
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
|
|
212
|
+
throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
|
|
213
|
+
}
|
|
214
|
+
let when;
|
|
215
|
+
if (step.when !== undefined) {
|
|
216
|
+
const conditions = record(step.when, `steps[${index}].when`);
|
|
217
|
+
when = {};
|
|
218
|
+
for (const [name2, expected] of Object.entries(conditions)) {
|
|
219
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
|
|
220
|
+
throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
|
|
221
|
+
}
|
|
222
|
+
when[name2] = expected;
|
|
223
|
+
}
|
|
224
|
+
if (Object.keys(when).length === 0)
|
|
225
|
+
throw new DefinitionError(`steps[${index}].when must not be empty.`);
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
name: text(step.name, `steps[${index}].name`),
|
|
229
|
+
run: text(step.run, `steps[${index}].run`),
|
|
230
|
+
phase,
|
|
231
|
+
scope: step.scope,
|
|
232
|
+
profiles: selectedProfiles,
|
|
233
|
+
secrets: step.secrets,
|
|
234
|
+
always: step.always === true,
|
|
235
|
+
timeoutMs,
|
|
236
|
+
when
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
if (new Set(steps.map((step) => step.name)).size !== steps.length) {
|
|
240
|
+
throw new DefinitionError("pipeline.steps must have unique names.");
|
|
241
|
+
}
|
|
242
|
+
const name = text(root.name, "pipeline.name");
|
|
243
|
+
if (name.length > 120)
|
|
244
|
+
throw new DefinitionError("pipeline.name must be at most 120 characters.");
|
|
245
|
+
return {
|
|
246
|
+
version: PIPELINE_VERSION,
|
|
247
|
+
name,
|
|
248
|
+
requireAttestation: root.requireAttestation === true,
|
|
249
|
+
profiles,
|
|
250
|
+
steps
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function phasePipeline(definition, phase, profile, executeRelease = false) {
|
|
254
|
+
if (!Object.hasOwn(definition.profiles, profile)) {
|
|
255
|
+
throw new DefinitionError(`pipeline profile does not exist: ${profile}.`);
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
name: `${definition.name}:${phase}`,
|
|
259
|
+
requireAttestation: definition.requireAttestation,
|
|
260
|
+
steps: definition.steps.filter((step) => step.phase === phase && (!step.profiles || step.profiles.includes(profile)) && (step.scope === "target" || executeRelease))
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/deploy-file.ts
|
|
265
|
+
import { createHash } from "node:crypto";
|
|
266
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
267
|
+
import { basename, join } from "node:path";
|
|
268
|
+
var DEPLOY_FILE = ".fz/deploy.json";
|
|
269
|
+
var DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
|
|
270
|
+
var stable = (value) => {
|
|
271
|
+
if (Array.isArray(value))
|
|
272
|
+
return `[${value.map(stable).join(",")}]`;
|
|
273
|
+
if (value && typeof value === "object") {
|
|
274
|
+
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`).join(",")}}`;
|
|
275
|
+
}
|
|
276
|
+
return JSON.stringify(value);
|
|
277
|
+
};
|
|
278
|
+
function deployDefinitionDigest(definition) {
|
|
279
|
+
return `sha256:${createHash("sha256").update(stable(definition)).digest("hex")}`;
|
|
280
|
+
}
|
|
281
|
+
var safeName = (value) => {
|
|
282
|
+
const normalized = value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
283
|
+
return /^[a-z]/.test(normalized) ? normalized : `app-${normalized || "service"}`.slice(0, 63);
|
|
284
|
+
};
|
|
285
|
+
function packageHints(root) {
|
|
286
|
+
const packagePath = join(root, "package.json");
|
|
287
|
+
if (!existsSync(packagePath))
|
|
288
|
+
return { bun: existsSync(join(root, "bun.lock")) };
|
|
289
|
+
try {
|
|
290
|
+
const manifest = JSON.parse(readFileSync2(packagePath, "utf8"));
|
|
291
|
+
return {
|
|
292
|
+
name: typeof manifest.name === "string" ? manifest.name : undefined,
|
|
293
|
+
build: typeof manifest.scripts?.build === "string" ? "bun run build" : undefined,
|
|
294
|
+
bun: existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))
|
|
295
|
+
};
|
|
296
|
+
} catch {
|
|
297
|
+
return { bun: existsSync(join(root, "bun.lock")) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
var blocker = (instruction) => `printf '%s\\n' '${DEPLOY_TODO_PREFIX} ${instruction}' >&2; exit 78`;
|
|
301
|
+
function defaultDeployFile(root, options = {}) {
|
|
302
|
+
const hints = packageHints(root);
|
|
303
|
+
const software = options.software ?? (hints.bun ? SOFTWARE_CATALOG.filter((entry) => entry.id === "bun" && entry.status === "active").map(({ id, version }) => ({ id, version })) : []);
|
|
304
|
+
validateSoftwareRequirements(software, { channel: options.channel });
|
|
305
|
+
const profile = options.profile ?? "app";
|
|
306
|
+
const build = hints.build ?? blocker("replace the build step with the project build command");
|
|
307
|
+
return {
|
|
308
|
+
$schema: DEPLOY_SCHEMA_URL,
|
|
309
|
+
version: 2,
|
|
310
|
+
name: safeName(options.name ?? hints.name ?? basename(root)),
|
|
311
|
+
...options.requireAttestation ? { requireAttestation: true } : {},
|
|
312
|
+
profiles: { [profile]: { software } },
|
|
313
|
+
steps: [
|
|
314
|
+
{ name: "build", phase: "build", scope: "target", run: build, timeoutMs: 600000 },
|
|
315
|
+
{
|
|
316
|
+
name: "promote release",
|
|
317
|
+
phase: "release",
|
|
318
|
+
scope: "target",
|
|
319
|
+
run: blocker("replace the release step with an atomic promotion command"),
|
|
320
|
+
timeoutMs: 120000
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "health check",
|
|
324
|
+
phase: "health",
|
|
325
|
+
scope: "target",
|
|
326
|
+
run: blocker("replace the health step with a bounded local health check"),
|
|
327
|
+
timeoutMs: 30000
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function inspectDeployFile(root, options = {}) {
|
|
333
|
+
const path = join(root, DEPLOY_FILE);
|
|
334
|
+
if (!existsSync(path))
|
|
335
|
+
throw new Error(`${DEPLOY_FILE} does not exist; run \`fz deploy init\`.`);
|
|
336
|
+
const raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
337
|
+
const definition = parseDeployDefinition(raw, options);
|
|
338
|
+
const problems = definition.steps.filter((step) => step.run.includes(DEPLOY_TODO_PREFIX)).map((step) => `${step.name} still contains the safe initialization blocker`);
|
|
339
|
+
const profiles = Object.keys(definition.profiles).sort();
|
|
340
|
+
return {
|
|
341
|
+
path,
|
|
342
|
+
definition,
|
|
343
|
+
summary: {
|
|
344
|
+
path,
|
|
345
|
+
digest: deployDefinitionDigest(definition),
|
|
346
|
+
version: definition.version,
|
|
347
|
+
name: definition.name,
|
|
348
|
+
profiles,
|
|
349
|
+
software: Object.fromEntries(profiles.map((profile) => [
|
|
350
|
+
profile,
|
|
351
|
+
definition.profiles[profile].software
|
|
352
|
+
])),
|
|
353
|
+
ready: problems.length === 0,
|
|
354
|
+
problems
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function initializeDeployFile(root, options = {}) {
|
|
359
|
+
const path = join(root, DEPLOY_FILE);
|
|
360
|
+
if (existsSync(path) && !options.force) {
|
|
361
|
+
throw new Error(`${DEPLOY_FILE} already exists; use --force only when replacing it deliberately.`);
|
|
362
|
+
}
|
|
363
|
+
const raw = defaultDeployFile(root, options);
|
|
364
|
+
parseDeployDefinition(raw, { channel: options.channel });
|
|
365
|
+
mkdirSync(join(root, ".fz"), { recursive: true });
|
|
366
|
+
writeFileSync(path, `${JSON.stringify(raw, null, 2)}
|
|
367
|
+
`, { mode: 420 });
|
|
368
|
+
return inspectDeployFile(root, { channel: options.channel });
|
|
369
|
+
}
|
|
370
|
+
export {
|
|
371
|
+
inspectDeployFile,
|
|
372
|
+
initializeDeployFile,
|
|
373
|
+
deployDefinitionDigest,
|
|
374
|
+
defaultDeployFile,
|
|
375
|
+
DEPLOY_TODO_PREFIX,
|
|
376
|
+
DEPLOY_SCHEMA_URL,
|
|
377
|
+
DEPLOY_FILE
|
|
378
|
+
};
|
package/dist/deployment.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface DeploymentResult {
|
|
|
24
24
|
repository: string;
|
|
25
25
|
branch: string;
|
|
26
26
|
revision: string;
|
|
27
|
+
/** Semantic digest of the validated definition from this exact commit. */
|
|
28
|
+
definitionDigest: string;
|
|
29
|
+
definitionVersion: number;
|
|
30
|
+
profile: string;
|
|
27
31
|
release: string;
|
|
28
32
|
ok: boolean;
|
|
29
33
|
phases: readonly RunResult[];
|