@forgezero/agent 0.1.52 → 0.1.55
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 +118 -9
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.js +110 -69
- package/dist/community-rehearsal-host.js +40 -8
- package/dist/container-supervisor.d.ts +36 -0
- package/dist/definition.js +38 -6
- package/dist/deploy-actions.d.ts +87 -0
- package/dist/deploy-compiler.d.ts +26 -0
- package/dist/deploy-compiler.js +1050 -0
- package/dist/deploy-file.js +45 -13
- package/dist/deploy-plan-runner.d.ts +65 -0
- package/dist/deploy-plan-runner.js +1205 -0
- package/dist/deploy-plan.d.ts +41 -0
- package/dist/deploy-plan.js +925 -0
- package/dist/deploy-providers.d.ts +85 -0
- package/dist/deploy.d.ts +439 -0
- package/dist/deploy.js +169 -0
- package/dist/deployment.d.ts +5 -0
- package/dist/fz-agent.js +2116 -329
- package/dist/fz.js +1444 -313
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.js +167 -126
- package/dist/platform-bootstrap-runtime.js +1 -1
- package/dist/platform-fleet-verification.js +500 -121
- package/dist/provision.js +400 -21
- package/dist/software-helper.d.ts +5 -0
- package/dist/software-helper.js +400 -19
- package/dist/software.d.ts +1 -1
- package/dist/software.js +38 -6
- package/dist/version.d.ts +1 -1
- package/package.json +17 -1
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/deploy-plan.ts
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
|
|
5
|
+
// src/deploy-actions.ts
|
|
6
|
+
var row = (value, where) => {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
8
|
+
throw new Error(`${where} must be an object`);
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
var exact = (value, keys, where) => {
|
|
12
|
+
const unknown = Object.keys(value).filter((key) => !keys.includes(key));
|
|
13
|
+
if (unknown.length)
|
|
14
|
+
throw new Error(`${where} contains unknown field(s): ${unknown.join(", ")}`);
|
|
15
|
+
for (const key of keys)
|
|
16
|
+
if (value[key] === undefined)
|
|
17
|
+
throw new Error(`${where} requires ${key}`);
|
|
18
|
+
};
|
|
19
|
+
var component = (value, components, where) => {
|
|
20
|
+
if (typeof value !== "string" || !components[value])
|
|
21
|
+
throw new Error(`${where}.component must name an existing component`);
|
|
22
|
+
return components[value];
|
|
23
|
+
};
|
|
24
|
+
var exactArgv = (value, where) => {
|
|
25
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 256 || value.some((entry) => typeof entry !== "string" || entry.length < 1 || entry.length > 16384 || entry.includes("\x00")))
|
|
26
|
+
throw new Error(`${where} requires a bounded exact argv array`);
|
|
27
|
+
const executable = value[0].split("/").at(-1).toLowerCase();
|
|
28
|
+
if (["sh", "bash", "dash", "zsh", "ksh", "fish", "busybox", "env"].includes(executable))
|
|
29
|
+
throw new Error(`${where} cannot invoke a command dispatcher`);
|
|
30
|
+
};
|
|
31
|
+
function validateBuiltInAction(definition, context, where) {
|
|
32
|
+
if (!definition.uses.startsWith("forgezero."))
|
|
33
|
+
return;
|
|
34
|
+
const withValue = row(definition.with, `${where}.with`);
|
|
35
|
+
if (definition.uses === "forgezero.software/ensure@1") {
|
|
36
|
+
exact(withValue, ["requirements"], `${where}.with`);
|
|
37
|
+
if (!Array.isArray(withValue.requirements) || withValue.requirements.length < 1 || withValue.requirements.length > 64 || withValue.requirements.some((name) => typeof name !== "string" || !context.requirements[name]))
|
|
38
|
+
throw new Error(`${where}.with.requirements must name existing requirements`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (definition.uses === "forgezero.exec/argv@1") {
|
|
42
|
+
const allowed = ["component", "argv", "credentials"];
|
|
43
|
+
const unknown = Object.keys(withValue).filter((key) => !allowed.includes(key));
|
|
44
|
+
if (unknown.length)
|
|
45
|
+
throw new Error(`${where}.with contains unknown field(s): ${unknown.join(", ")}`);
|
|
46
|
+
component(withValue.component, context.components, `${where}.with`);
|
|
47
|
+
exactArgv(withValue.argv, `${where}.with.argv`);
|
|
48
|
+
if (withValue.credentials !== undefined) {
|
|
49
|
+
const bindings = row(withValue.credentials, `${where}.with.credentials`);
|
|
50
|
+
for (const [environment, credentialName] of Object.entries(bindings))
|
|
51
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(environment) || typeof credentialName !== "string" || !context.credentials[credentialName])
|
|
52
|
+
throw new Error(`${where}.with.credentials contains an invalid binding`);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (["forgezero.oci/build@1", "forgezero.oci/pull@1"].includes(definition.uses)) {
|
|
57
|
+
const allowed = definition.uses.endsWith("/build@1") ? ["component", "noCache"] : ["component"];
|
|
58
|
+
const unknown = Object.keys(withValue).filter((key) => !allowed.includes(key));
|
|
59
|
+
if (unknown.length)
|
|
60
|
+
throw new Error(`${where}.with contains unknown field(s): ${unknown.join(", ")}`);
|
|
61
|
+
const selected = component(withValue.component, context.components, `${where}.with`);
|
|
62
|
+
if (selected.kind !== "application" || selected.runtime.kind !== "container")
|
|
63
|
+
throw new Error(`${where}.with.component must use a container runtime`);
|
|
64
|
+
if (withValue.noCache !== undefined && typeof withValue.noCache !== "boolean")
|
|
65
|
+
throw new Error(`${where}.with.noCache must be a boolean`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (["forgezero.service/deploy@1", "forgezero.service/promote@1", "forgezero.health/http@1", "forgezero.storage/verify@1"].includes(definition.uses)) {
|
|
69
|
+
const allowed = definition.uses === "forgezero.service/deploy@1" || definition.uses === "forgezero.service/promote@1" ? ["component", "imageDigest"] : ["component"];
|
|
70
|
+
const unknown = Object.keys(withValue).filter((key) => !allowed.includes(key));
|
|
71
|
+
if (unknown.length)
|
|
72
|
+
throw new Error(`${where}.with contains unknown field(s): ${unknown.join(", ")}`);
|
|
73
|
+
component(withValue.component, context.components, `${where}.with`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`${where}.uses is an unknown ForgeZero action`);
|
|
77
|
+
}
|
|
78
|
+
// src/deploy-providers.ts
|
|
79
|
+
var plain = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
80
|
+
var exact2 = (row2, keys, provider) => {
|
|
81
|
+
const unknown = Object.keys(row2).filter((key) => !keys.includes(key));
|
|
82
|
+
if (unknown.length)
|
|
83
|
+
throw new Error(`${provider} config contains unknown field(s): ${unknown.join(", ")}`);
|
|
84
|
+
for (const key of keys)
|
|
85
|
+
if (row2[key] === undefined)
|
|
86
|
+
throw new Error(`${provider} config requires ${key}`);
|
|
87
|
+
};
|
|
88
|
+
var absolute = (value) => typeof value === "string" && /^\/(?:[A-Za-z0-9._-]+\/?)*$/.test(value) && !value.includes("..");
|
|
89
|
+
function validateBuiltInProvider(requirement2, where) {
|
|
90
|
+
const row2 = plain(requirement2.config);
|
|
91
|
+
if (!requirement2.provider.startsWith("forgezero."))
|
|
92
|
+
return;
|
|
93
|
+
const expected = {
|
|
94
|
+
"forgezero.bun": { capability: "runtime.javascript", versions: ["1.3.14"], keys: ["installScope"] },
|
|
95
|
+
"forgezero.docker": { capability: "runtime.container", versions: ["ubuntu-26.04"], keys: ["installScope", "storageDriver", "dataRoot", "liveRestore", "logDriver", "defaultNetwork", "rootless"] },
|
|
96
|
+
"forgezero.nginx": { capability: "proxy.http", versions: ["ubuntu-26.04"], keys: ["installScope", "websocket", "maximumBodyMiB", "workerProcesses", "workerConnections"] },
|
|
97
|
+
"forgezero.arangodb": { capability: "database.arangodb", versions: ["3.11.14"], keys: ["installScope", "runtime", "dataPath", "bind"] },
|
|
98
|
+
"forgezero.cloudflared": { capability: "network.public-tunnel", versions: ["2026.7.3"], keys: ["installScope", "mode"] },
|
|
99
|
+
"forgezero.warp": { capability: "network.private", versions: ["2026.6.822.0-min"], keys: ["installScope", "mode"] },
|
|
100
|
+
"forgezero.ufw": { capability: "security.firewall", versions: ["ubuntu-26.04"], keys: ["installScope", "defaultIncoming", "defaultOutgoing"] },
|
|
101
|
+
"forgezero.openssh": { capability: "transport.ssh", versions: ["ubuntu-26.04"], keys: ["installScope", "mode"] },
|
|
102
|
+
"forgezero.systemd": { capability: "service.manager", versions: ["ubuntu-26.04"], keys: ["installScope", "credentials", "serviceSandbox"] }
|
|
103
|
+
};
|
|
104
|
+
const contract = expected[requirement2.provider];
|
|
105
|
+
if (!contract)
|
|
106
|
+
throw new Error(`${where} uses unknown built-in provider ${requirement2.provider}`);
|
|
107
|
+
if (requirement2.contract !== 1 || requirement2.capability !== contract.capability || !contract.versions.includes(requirement2.version))
|
|
108
|
+
throw new Error(`${where} provider coordinate is unsupported`);
|
|
109
|
+
exact2(row2, contract.keys, requirement2.provider);
|
|
110
|
+
if (row2.installScope !== "host")
|
|
111
|
+
throw new Error(`${where}.config.installScope must be host`);
|
|
112
|
+
if (requirement2.provider === "forgezero.docker") {
|
|
113
|
+
if (row2.storageDriver !== "overlay2" || row2.dataRoot !== "/var/lib/docker" || row2.liveRestore !== true || row2.logDriver !== "local" || row2.defaultNetwork !== "bridge" || row2.rootless !== false)
|
|
114
|
+
throw new Error(`${where} Docker config is unsupported by the fixed host strategy`);
|
|
115
|
+
}
|
|
116
|
+
if (requirement2.provider === "forgezero.nginx" && (typeof row2.websocket !== "boolean" || !Number.isInteger(row2.maximumBodyMiB) || Number(row2.maximumBodyMiB) < 1 || Number(row2.maximumBodyMiB) > 1024 || !(row2.workerProcesses === "auto" || Number.isInteger(row2.workerProcesses)) || !Number.isInteger(row2.workerConnections)))
|
|
117
|
+
throw new Error(`${where} Nginx config is invalid`);
|
|
118
|
+
if (requirement2.provider === "forgezero.arangodb" && (row2.runtime !== "native" || !absolute(row2.dataPath) || row2.bind !== "private"))
|
|
119
|
+
throw new Error(`${where} ArangoDB config is invalid`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/deploy-plan.ts
|
|
123
|
+
var DEPLOY_PLAN_FORMAT = "forgezero-deployment-plan";
|
|
124
|
+
var DEPLOY_PLAN_VERSION = 1;
|
|
125
|
+
|
|
126
|
+
class DeploymentPlanError extends Error {
|
|
127
|
+
constructor(message) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.name = "DeploymentPlanError";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
var NAME = /^[a-z][A-Za-z0-9-]{0,62}$/;
|
|
133
|
+
var COORDINATE = /^[a-z][a-z0-9.-]{0,127}$/;
|
|
134
|
+
var ACTION = /^[a-z][a-z0-9.-]{0,127}\/[a-z][a-z0-9.-]{0,127}@[1-9][0-9]{0,5}$/;
|
|
135
|
+
var VERSION = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/;
|
|
136
|
+
var ABSOLUTE_PATH = /^\/(?:[A-Za-z0-9._-]+\/?)*$/;
|
|
137
|
+
var HEALTH_PATH = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,255}$/;
|
|
138
|
+
var fail = (where, message) => {
|
|
139
|
+
throw new DeploymentPlanError(`${where} ${message}`);
|
|
140
|
+
};
|
|
141
|
+
var object = (value, where) => {
|
|
142
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
143
|
+
fail(where, "must be an object.");
|
|
144
|
+
return value;
|
|
145
|
+
};
|
|
146
|
+
var exact3 = (value, allowed, where) => {
|
|
147
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
148
|
+
if (unknown.length)
|
|
149
|
+
fail(where, `contains unknown field(s): ${unknown.join(", ")}.`);
|
|
150
|
+
};
|
|
151
|
+
var named = (value, where) => {
|
|
152
|
+
if (typeof value !== "string" || !NAME.test(value))
|
|
153
|
+
fail(where, "must be a lowercase typed name.");
|
|
154
|
+
return value;
|
|
155
|
+
};
|
|
156
|
+
var boundedString = (value, where, maximum = 256) => {
|
|
157
|
+
if (typeof value !== "string" || value.length < 1 || value.length > maximum || value.includes("\x00"))
|
|
158
|
+
fail(where, `must be a non-empty string of at most ${maximum} characters.`);
|
|
159
|
+
return value;
|
|
160
|
+
};
|
|
161
|
+
var integer = (value, where, minimum, maximum) => {
|
|
162
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum)
|
|
163
|
+
fail(where, `must be an integer from ${minimum} to ${maximum}.`);
|
|
164
|
+
return value;
|
|
165
|
+
};
|
|
166
|
+
var number = (value, where, minimum, maximum) => {
|
|
167
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum)
|
|
168
|
+
fail(where, `must be a finite number from ${minimum} to ${maximum}.`);
|
|
169
|
+
return value;
|
|
170
|
+
};
|
|
171
|
+
function canonicalJson(value) {
|
|
172
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
173
|
+
return JSON.stringify(value);
|
|
174
|
+
if (typeof value === "number") {
|
|
175
|
+
if (!Number.isFinite(value))
|
|
176
|
+
throw new DeploymentPlanError("deployment data contains a non-finite number.");
|
|
177
|
+
return JSON.stringify(value);
|
|
178
|
+
}
|
|
179
|
+
if (Array.isArray(value))
|
|
180
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
181
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
182
|
+
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
183
|
+
}
|
|
184
|
+
throw new DeploymentPlanError("deployment data must contain only plain JSON values.");
|
|
185
|
+
}
|
|
186
|
+
function deploymentSourceDigest(source) {
|
|
187
|
+
return `sha256:${createHash("sha256").update(canonicalJson(source)).digest("hex")}`;
|
|
188
|
+
}
|
|
189
|
+
function reference(value, where) {
|
|
190
|
+
const row2 = object(value, where);
|
|
191
|
+
exact3(row2, ["$ref"], where);
|
|
192
|
+
const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
|
|
193
|
+
if (!/^(inputs\.[a-z][A-Za-z0-9-]{0,62}|steps\.[a-z][A-Za-z0-9-]{0,62}\.(status|outputs\.[a-z][A-Za-z0-9-]{0,62})|components\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|targets\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|deployment\.[a-z][A-Za-z0-9-]{0,62})$/.test(coordinate)) {
|
|
194
|
+
fail(where, "contains an unsupported reference.");
|
|
195
|
+
}
|
|
196
|
+
return { $ref: coordinate };
|
|
197
|
+
}
|
|
198
|
+
function operand(value, where) {
|
|
199
|
+
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
|
200
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
201
|
+
fail(where, "must be finite.");
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
return reference(value, where);
|
|
205
|
+
}
|
|
206
|
+
function condition(value, where, depth = 0) {
|
|
207
|
+
if (depth > 16)
|
|
208
|
+
fail(where, "is nested too deeply.");
|
|
209
|
+
const row2 = object(value, where);
|
|
210
|
+
if (Object.keys(row2).length !== 1)
|
|
211
|
+
fail(where, "must contain exactly one operator.");
|
|
212
|
+
const [operator, body] = Object.entries(row2)[0];
|
|
213
|
+
if (operator === "all" || operator === "any") {
|
|
214
|
+
if (!Array.isArray(body) || body.length < 1 || body.length > 32)
|
|
215
|
+
fail(where, `${operator} must contain 1 to 32 conditions.`);
|
|
216
|
+
const entries = body;
|
|
217
|
+
return { [operator]: entries.map((entry, index) => condition(entry, `${where}.${operator}[${index}]`, depth + 1)) };
|
|
218
|
+
}
|
|
219
|
+
if (operator === "not")
|
|
220
|
+
return { not: condition(body, `${where}.not`, depth + 1) };
|
|
221
|
+
if (["equals", "notEquals", "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual", "contains", "startsWith"].includes(operator)) {
|
|
222
|
+
if (!Array.isArray(body) || body.length !== 2)
|
|
223
|
+
fail(where, `${operator} must contain two operands.`);
|
|
224
|
+
const entries = body;
|
|
225
|
+
return { [operator]: [operand(entries[0], `${where}.${operator}[0]`), operand(entries[1], `${where}.${operator}[1]`)] };
|
|
226
|
+
}
|
|
227
|
+
if (operator === "exists")
|
|
228
|
+
return { exists: reference(body, `${where}.exists`) };
|
|
229
|
+
if (["succeeded", "failed", "changed"].includes(operator))
|
|
230
|
+
return { [operator]: named(body, `${where}.${operator}`) };
|
|
231
|
+
return fail(where, `uses unsupported operator ${operator}.`);
|
|
232
|
+
}
|
|
233
|
+
function jsonValue(value, where, depth = 0) {
|
|
234
|
+
if (depth > 16)
|
|
235
|
+
fail(where, "is nested too deeply.");
|
|
236
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
237
|
+
return value;
|
|
238
|
+
if (typeof value === "number") {
|
|
239
|
+
if (!Number.isFinite(value))
|
|
240
|
+
fail(where, "must be finite.");
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
if (value.length > 1000)
|
|
245
|
+
fail(where, "contains more than 1000 entries.");
|
|
246
|
+
return value.map((entry, index) => jsonValue(entry, `${where}[${index}]`, depth + 1));
|
|
247
|
+
}
|
|
248
|
+
const row2 = object(value, where);
|
|
249
|
+
if (Object.keys(row2).length > 1000)
|
|
250
|
+
fail(where, "contains more than 1000 fields.");
|
|
251
|
+
return Object.fromEntries(Object.entries(row2).map(([key, entry]) => [key, jsonValue(entry, `${where}.${key}`, depth + 1)]));
|
|
252
|
+
}
|
|
253
|
+
function validateInput(value, where) {
|
|
254
|
+
const row2 = object(value, where);
|
|
255
|
+
const type = row2.type;
|
|
256
|
+
const common = ["type", "required", "default"];
|
|
257
|
+
if (row2.required !== undefined && typeof row2.required !== "boolean")
|
|
258
|
+
fail(`${where}.required`, "must be a boolean.");
|
|
259
|
+
if (type === "string") {
|
|
260
|
+
exact3(row2, [...common, "minimumLength", "maximumLength", "pattern"], where);
|
|
261
|
+
if (row2.default !== undefined)
|
|
262
|
+
boundedString(row2.default, `${where}.default`, 16384);
|
|
263
|
+
if (row2.minimumLength !== undefined)
|
|
264
|
+
integer(row2.minimumLength, `${where}.minimumLength`, 0, 16384);
|
|
265
|
+
if (row2.maximumLength !== undefined)
|
|
266
|
+
integer(row2.maximumLength, `${where}.maximumLength`, 1, 16384);
|
|
267
|
+
if (row2.pattern !== undefined) {
|
|
268
|
+
const pattern = boundedString(row2.pattern, `${where}.pattern`, 512);
|
|
269
|
+
try {
|
|
270
|
+
new RegExp(pattern);
|
|
271
|
+
} catch {
|
|
272
|
+
fail(`${where}.pattern`, "must be a valid regular expression.");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} else if (type === "integer" || type === "number") {
|
|
276
|
+
exact3(row2, [...common, "minimum", "maximum"], where);
|
|
277
|
+
if (row2.default !== undefined)
|
|
278
|
+
number(row2.default, `${where}.default`, -Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
279
|
+
if (row2.minimum !== undefined)
|
|
280
|
+
number(row2.minimum, `${where}.minimum`, -Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
281
|
+
if (row2.maximum !== undefined)
|
|
282
|
+
number(row2.maximum, `${where}.maximum`, -Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
|
283
|
+
if (type === "integer" && row2.default !== undefined && !Number.isInteger(row2.default))
|
|
284
|
+
fail(`${where}.default`, "must be an integer.");
|
|
285
|
+
} else if (type === "boolean") {
|
|
286
|
+
exact3(row2, common, where);
|
|
287
|
+
if (row2.default !== undefined && typeof row2.default !== "boolean")
|
|
288
|
+
fail(`${where}.default`, "must be a boolean.");
|
|
289
|
+
} else if (type === "enum") {
|
|
290
|
+
exact3(row2, [...common, "values"], where);
|
|
291
|
+
if (!Array.isArray(row2.values) || row2.values.length < 1 || row2.values.length > 64)
|
|
292
|
+
fail(`${where}.values`, "must contain 1 to 64 values.");
|
|
293
|
+
const values = row2.values.map((entry, index) => boundedString(entry, `${where}.values[${index}]`, 128));
|
|
294
|
+
if (new Set(values).size !== values.length)
|
|
295
|
+
fail(`${where}.values`, "must be unique.");
|
|
296
|
+
if (row2.default !== undefined && !values.includes(row2.default))
|
|
297
|
+
fail(`${where}.default`, "must be an enum value.");
|
|
298
|
+
} else if (type === "hostname") {
|
|
299
|
+
exact3(row2, common, where);
|
|
300
|
+
if (row2.default !== undefined && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(String(row2.default)))
|
|
301
|
+
fail(`${where}.default`, "must be a hostname.");
|
|
302
|
+
} else
|
|
303
|
+
fail(`${where}.type`, "is unsupported.");
|
|
304
|
+
return row2;
|
|
305
|
+
}
|
|
306
|
+
function validateRequirement(value, where) {
|
|
307
|
+
const row2 = object(value, where);
|
|
308
|
+
exact3(row2, ["capability", "provider", "contract", "version", "config", "when"], where);
|
|
309
|
+
if (!COORDINATE.test(String(row2.capability)))
|
|
310
|
+
fail(`${where}.capability`, "is invalid.");
|
|
311
|
+
if (!COORDINATE.test(String(row2.provider)))
|
|
312
|
+
fail(`${where}.provider`, "is invalid.");
|
|
313
|
+
integer(row2.contract, `${where}.contract`, 1, 65535);
|
|
314
|
+
if (typeof row2.version !== "string" || !VERSION.test(row2.version))
|
|
315
|
+
fail(`${where}.version`, "is invalid.");
|
|
316
|
+
jsonValue(row2.config, `${where}.config`);
|
|
317
|
+
if (row2.when !== undefined)
|
|
318
|
+
condition(row2.when, `${where}.when`);
|
|
319
|
+
const requirement2 = row2;
|
|
320
|
+
validateBuiltInProvider(requirement2, where);
|
|
321
|
+
return requirement2;
|
|
322
|
+
}
|
|
323
|
+
function validateArgv(value, where) {
|
|
324
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 256)
|
|
325
|
+
fail(where, "must contain 1 to 256 arguments.");
|
|
326
|
+
let bytes = 0;
|
|
327
|
+
const values = value.map((entry, index) => {
|
|
328
|
+
const result = boundedString(entry, `${where}[${index}]`, 16384);
|
|
329
|
+
bytes += Buffer.byteLength(result);
|
|
330
|
+
return result;
|
|
331
|
+
});
|
|
332
|
+
if (bytes > 64 * 1024)
|
|
333
|
+
fail(where, "is larger than 64 KiB.");
|
|
334
|
+
const executable = values[0].split("/").at(-1).toLowerCase();
|
|
335
|
+
if (["sh", "bash", "dash", "zsh", "ksh", "fish", "busybox", "env"].includes(executable))
|
|
336
|
+
fail(`${where}[0]`, "may not invoke a command dispatcher.");
|
|
337
|
+
return values;
|
|
338
|
+
}
|
|
339
|
+
function validateResources(value, where) {
|
|
340
|
+
const row2 = object(value, where);
|
|
341
|
+
exact3(row2, ["cpu", "memory", "pids", "io"], where);
|
|
342
|
+
if (row2.cpu !== undefined) {
|
|
343
|
+
const cpu = object(row2.cpu, `${where}.cpu`);
|
|
344
|
+
exact3(cpu, ["limit", "weight", "pinning"], `${where}.cpu`);
|
|
345
|
+
number(cpu.limit, `${where}.cpu.limit`, 0.01, 1024);
|
|
346
|
+
if (cpu.weight !== undefined)
|
|
347
|
+
integer(cpu.weight, `${where}.cpu.weight`, 1, 1e4);
|
|
348
|
+
if (cpu.pinning !== undefined && !["automatic", "dedicated"].includes(String(cpu.pinning)))
|
|
349
|
+
fail(`${where}.cpu.pinning`, "is unsupported.");
|
|
350
|
+
}
|
|
351
|
+
if (row2.memory !== undefined) {
|
|
352
|
+
const memory = object(row2.memory, `${where}.memory`);
|
|
353
|
+
exact3(memory, ["limitMiB", "reservationMiB", "swap"], `${where}.memory`);
|
|
354
|
+
integer(memory.limitMiB, `${where}.memory.limitMiB`, 16, 16777216);
|
|
355
|
+
if (memory.reservationMiB !== undefined && integer(memory.reservationMiB, `${where}.memory.reservationMiB`, 1, memory.limitMiB) > memory.limitMiB)
|
|
356
|
+
fail(`${where}.memory.reservationMiB`, "must not exceed limitMiB.");
|
|
357
|
+
if (memory.swap !== undefined && !["disabled", "bounded"].includes(String(memory.swap)))
|
|
358
|
+
fail(`${where}.memory.swap`, "is unsupported.");
|
|
359
|
+
}
|
|
360
|
+
if (row2.pids !== undefined) {
|
|
361
|
+
const pids = object(row2.pids, `${where}.pids`);
|
|
362
|
+
exact3(pids, ["limit"], `${where}.pids`);
|
|
363
|
+
integer(pids.limit, `${where}.pids.limit`, 1, 1048576);
|
|
364
|
+
}
|
|
365
|
+
if (row2.io !== undefined) {
|
|
366
|
+
const io = object(row2.io, `${where}.io`);
|
|
367
|
+
exact3(io, ["weight", "readBps", "writeBps", "readIops", "writeIops"], `${where}.io`);
|
|
368
|
+
for (const key of Object.keys(io))
|
|
369
|
+
integer(io[key], `${where}.io.${key}`, 1, Number.MAX_SAFE_INTEGER);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function validateStorage(value, where) {
|
|
373
|
+
const row2 = object(value, where);
|
|
374
|
+
if (row2.class === "ephemeral") {
|
|
375
|
+
exact3(row2, ["class", "path", "type", "sizeMiB"], where);
|
|
376
|
+
if (row2.type !== "tmpfs")
|
|
377
|
+
fail(`${where}.type`, "must be tmpfs.");
|
|
378
|
+
integer(row2.sizeMiB, `${where}.sizeMiB`, 1, 1048576);
|
|
379
|
+
} else if (row2.class === "persistent") {
|
|
380
|
+
exact3(row2, ["class", "name", "path", "minimumFreePercent"], where);
|
|
381
|
+
named(row2.name, `${where}.name`);
|
|
382
|
+
if (row2.minimumFreePercent !== undefined)
|
|
383
|
+
integer(row2.minimumFreePercent, `${where}.minimumFreePercent`, 1, 95);
|
|
384
|
+
} else if (row2.class === "database") {
|
|
385
|
+
exact3(row2, ["class", "name", "path", "minimumFreePercent", "minimumIops", "latencyTargetMs"], where);
|
|
386
|
+
named(row2.name, `${where}.name`);
|
|
387
|
+
integer(row2.minimumFreePercent, `${where}.minimumFreePercent`, 1, 95);
|
|
388
|
+
if (row2.minimumIops !== undefined)
|
|
389
|
+
integer(row2.minimumIops, `${where}.minimumIops`, 1, 1e8);
|
|
390
|
+
if (row2.latencyTargetMs !== undefined)
|
|
391
|
+
number(row2.latencyTargetMs, `${where}.latencyTargetMs`, 0.01, 60000);
|
|
392
|
+
} else
|
|
393
|
+
fail(`${where}.class`, "is unsupported.");
|
|
394
|
+
if (typeof row2.path !== "string" || !ABSOLUTE_PATH.test(row2.path) || row2.path.includes(".."))
|
|
395
|
+
fail(`${where}.path`, "must be a normalized absolute path.");
|
|
396
|
+
}
|
|
397
|
+
function validateNetwork(value, where) {
|
|
398
|
+
const row2 = object(value, where);
|
|
399
|
+
exact3(row2, ["ingress", "private", "public", "container"], where);
|
|
400
|
+
if (row2.ingress !== undefined) {
|
|
401
|
+
const ingress = object(row2.ingress, `${where}.ingress`);
|
|
402
|
+
exact3(ingress, ["exposure", "stablePort"], `${where}.ingress`);
|
|
403
|
+
if (!["loopback", "private", "public"].includes(String(ingress.exposure)))
|
|
404
|
+
fail(`${where}.ingress.exposure`, "is unsupported.");
|
|
405
|
+
if (ingress.stablePort !== undefined)
|
|
406
|
+
integer(ingress.stablePort, `${where}.ingress.stablePort`, 1, 65535);
|
|
407
|
+
}
|
|
408
|
+
if (row2.private !== undefined) {
|
|
409
|
+
const privateNetwork = object(row2.private, `${where}.private`);
|
|
410
|
+
exact3(privateNetwork, ["mode", "preferences"], `${where}.private`);
|
|
411
|
+
if (!["private-lan", "cloudflare-warp", "auto"].includes(String(privateNetwork.mode)))
|
|
412
|
+
fail(`${where}.private.mode`, "is unsupported.");
|
|
413
|
+
if (privateNetwork.preferences !== undefined && (!Array.isArray(privateNetwork.preferences) || privateNetwork.preferences.length < 1 || privateNetwork.preferences.length > 2 || privateNetwork.preferences.some((entry) => !["private-lan", "cloudflare-warp"].includes(String(entry))) || new Set(privateNetwork.preferences).size !== privateNetwork.preferences.length))
|
|
414
|
+
fail(`${where}.private.preferences`, "is invalid.");
|
|
415
|
+
}
|
|
416
|
+
if (row2.public !== undefined) {
|
|
417
|
+
const publicNetwork = object(row2.public, `${where}.public`);
|
|
418
|
+
exact3(publicNetwork, ["mode", "optional"], `${where}.public`);
|
|
419
|
+
if (!["disabled", "cloudflare-tunnel"].includes(String(publicNetwork.mode)))
|
|
420
|
+
fail(`${where}.public.mode`, "is unsupported.");
|
|
421
|
+
if (publicNetwork.optional !== undefined && typeof publicNetwork.optional !== "boolean")
|
|
422
|
+
fail(`${where}.public.optional`, "must be a boolean.");
|
|
423
|
+
}
|
|
424
|
+
if (row2.container !== undefined) {
|
|
425
|
+
const container = object(row2.container, `${where}.container`);
|
|
426
|
+
exact3(container, ["mode", "network"], `${where}.container`);
|
|
427
|
+
if (!["bridge", "host"].includes(String(container.mode)))
|
|
428
|
+
fail(`${where}.container.mode`, "is unsupported.");
|
|
429
|
+
if (container.network !== undefined)
|
|
430
|
+
named(container.network, `${where}.container.network`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function validateComponent(value, where, targetNames, requirements) {
|
|
434
|
+
const row2 = object(value, where);
|
|
435
|
+
if (!targetNames.has(String(row2.target)))
|
|
436
|
+
fail(`${where}.target`, "must name an existing target.");
|
|
437
|
+
if (row2.kind === "application") {
|
|
438
|
+
exact3(row2, ["kind", "target", "runtime", "service", "resources", "storage", "network", "rollout"], where);
|
|
439
|
+
const runtime = object(row2.runtime, `${where}.runtime`);
|
|
440
|
+
const runtimeRequirement = requirements.get(String(runtime.requirement));
|
|
441
|
+
if (!runtimeRequirement)
|
|
442
|
+
fail(`${where}.runtime.requirement`, "must name an existing requirement.");
|
|
443
|
+
if (runtime.kind === "native") {
|
|
444
|
+
exact3(runtime, ["kind", "provider", "requirement", "argv"], `${where}.runtime`);
|
|
445
|
+
validateArgv(runtime.argv, `${where}.runtime.argv`);
|
|
446
|
+
} else if (runtime.kind === "container") {
|
|
447
|
+
exact3(runtime, ["kind", "provider", "requirement", "image", "entrypoint", "security"], `${where}.runtime`);
|
|
448
|
+
const image = object(runtime.image, `${where}.runtime.image`);
|
|
449
|
+
exact3(image, ["source"], `${where}.runtime.image`);
|
|
450
|
+
const source = object(image.source, `${where}.runtime.image.source`);
|
|
451
|
+
if (source.kind === "build") {
|
|
452
|
+
exact3(source, ["kind", "context", "dockerfile"], `${where}.runtime.image.source`);
|
|
453
|
+
boundedString(source.context, `${where}.runtime.image.source.context`, 512);
|
|
454
|
+
boundedString(source.dockerfile, `${where}.runtime.image.source.dockerfile`, 512);
|
|
455
|
+
} else if (source.kind === "registry") {
|
|
456
|
+
exact3(source, ["kind", "reference"], `${where}.runtime.image.source`);
|
|
457
|
+
if (!/^[-a-zA-Z0-9./:_@]+$/.test(boundedString(source.reference, `${where}.runtime.image.source.reference`, 512)))
|
|
458
|
+
fail(`${where}.runtime.image.source.reference`, "is invalid.");
|
|
459
|
+
} else
|
|
460
|
+
fail(`${where}.runtime.image.source.kind`, "is unsupported.");
|
|
461
|
+
if (runtime.entrypoint !== undefined)
|
|
462
|
+
validateArgv(runtime.entrypoint, `${where}.runtime.entrypoint`);
|
|
463
|
+
const security = object(runtime.security, `${where}.runtime.security`);
|
|
464
|
+
exact3(security, ["privileged", "noNewPrivileges", "root", "dropCapabilities"], `${where}.runtime.security`);
|
|
465
|
+
if (security.privileged !== false || security.noNewPrivileges !== true)
|
|
466
|
+
fail(`${where}.runtime.security`, "must disable privileged mode and enable noNewPrivileges.");
|
|
467
|
+
if (!["read-only", "writable"].includes(String(security.root)))
|
|
468
|
+
fail(`${where}.runtime.security.root`, "is unsupported.");
|
|
469
|
+
if (!Array.isArray(security.dropCapabilities) || !security.dropCapabilities.includes("ALL"))
|
|
470
|
+
fail(`${where}.runtime.security.dropCapabilities`, "must include ALL.");
|
|
471
|
+
} else
|
|
472
|
+
fail(`${where}.runtime.kind`, "is unsupported.");
|
|
473
|
+
if (!COORDINATE.test(String(runtime.provider)))
|
|
474
|
+
fail(`${where}.runtime.provider`, "is invalid.");
|
|
475
|
+
if (runtimeRequirement && runtime.provider !== runtimeRequirement.provider)
|
|
476
|
+
fail(`${where}.runtime.provider`, "must match its requirement provider.");
|
|
477
|
+
if (runtimeRequirement && runtime.kind === "container" && runtimeRequirement.capability !== "runtime.container")
|
|
478
|
+
fail(`${where}.runtime.requirement`, "must provide runtime.container.");
|
|
479
|
+
if (runtimeRequirement && runtime.kind === "native" && !runtimeRequirement.capability.startsWith("runtime."))
|
|
480
|
+
fail(`${where}.runtime.requirement`, "must provide a runtime capability.");
|
|
481
|
+
const service = object(row2.service, `${where}.service`);
|
|
482
|
+
exact3(service, ["protocol", "port", "health", "websocket", "maximumConnections"], `${where}.service`);
|
|
483
|
+
if (service.protocol !== "http")
|
|
484
|
+
fail(`${where}.service.protocol`, "must be http.");
|
|
485
|
+
integer(service.port, `${where}.service.port`, 1, 65535);
|
|
486
|
+
const health = object(service.health, `${where}.service.health`);
|
|
487
|
+
exact3(health, ["protocol", "method", "path", "expectedStatus", "timeoutMs", "intervalMs", "attempts"], `${where}.service.health`);
|
|
488
|
+
if (health.protocol !== "http" || !["GET", "HEAD"].includes(String(health.method)) || typeof health.path !== "string" || !HEALTH_PATH.test(health.path) || health.path.includes("..") || health.path.includes("//"))
|
|
489
|
+
fail(`${where}.service.health`, "is invalid.");
|
|
490
|
+
if (!Array.isArray(health.expectedStatus) || health.expectedStatus.length < 1 || health.expectedStatus.length > 16 || health.expectedStatus.some((status) => !Number.isInteger(status) || status < 100 || status > 599))
|
|
491
|
+
fail(`${where}.service.health.expectedStatus`, "is invalid.");
|
|
492
|
+
integer(health.timeoutMs, `${where}.service.health.timeoutMs`, 100, 300000);
|
|
493
|
+
if (health.intervalMs !== undefined)
|
|
494
|
+
integer(health.intervalMs, `${where}.service.health.intervalMs`, 100, 300000);
|
|
495
|
+
if (health.attempts !== undefined)
|
|
496
|
+
integer(health.attempts, `${where}.service.health.attempts`, 1, 1000);
|
|
497
|
+
if (service.websocket !== undefined && typeof service.websocket !== "boolean")
|
|
498
|
+
fail(`${where}.service.websocket`, "must be a boolean.");
|
|
499
|
+
if (service.maximumConnections !== undefined)
|
|
500
|
+
integer(service.maximumConnections, `${where}.service.maximumConnections`, 1, 1e7);
|
|
501
|
+
validateResources(row2.resources, `${where}.resources`);
|
|
502
|
+
if (runtime.kind === "container") {
|
|
503
|
+
const resources = object(row2.resources, `${where}.resources`);
|
|
504
|
+
for (const required of ["cpu", "memory", "pids"])
|
|
505
|
+
if (resources[required] === undefined)
|
|
506
|
+
fail(`${where}.resources.${required}`, "is required for a bounded container.");
|
|
507
|
+
if (row2.network === undefined)
|
|
508
|
+
fail(`${where}.network`, "is required for a container.");
|
|
509
|
+
const network = object(row2.network, `${where}.network`);
|
|
510
|
+
if (network.container === undefined)
|
|
511
|
+
fail(`${where}.network.container`, "must select bridge or reviewed host networking.");
|
|
512
|
+
}
|
|
513
|
+
if (row2.storage !== undefined) {
|
|
514
|
+
if (!Array.isArray(row2.storage) || row2.storage.length > 32)
|
|
515
|
+
fail(`${where}.storage`, "must contain at most 32 mounts.");
|
|
516
|
+
row2.storage.forEach((entry, index) => validateStorage(entry, `${where}.storage[${index}]`));
|
|
517
|
+
}
|
|
518
|
+
if (row2.network !== undefined)
|
|
519
|
+
validateNetwork(row2.network, `${where}.network`);
|
|
520
|
+
const rollout = object(row2.rollout, `${where}.rollout`);
|
|
521
|
+
exact3(rollout, ["strategy", "proxy", "drainMs", "automaticRollback"], `${where}.rollout`);
|
|
522
|
+
if (!["direct", "blue-green", "rolling", "canary"].includes(String(rollout.strategy)))
|
|
523
|
+
fail(`${where}.rollout.strategy`, "is unsupported.");
|
|
524
|
+
if (rollout.proxy !== undefined)
|
|
525
|
+
named(rollout.proxy, `${where}.rollout.proxy`);
|
|
526
|
+
if (rollout.strategy !== "direct") {
|
|
527
|
+
const proxyRequirement = requirements.get(String(rollout.proxy));
|
|
528
|
+
if (!proxyRequirement || proxyRequirement.capability !== "proxy.http")
|
|
529
|
+
fail(`${where}.rollout.proxy`, "must name a proxy.http requirement for managed rollout.");
|
|
530
|
+
}
|
|
531
|
+
if (rollout.drainMs !== undefined)
|
|
532
|
+
integer(rollout.drainMs, `${where}.rollout.drainMs`, 0, 600000);
|
|
533
|
+
if (rollout.automaticRollback !== undefined && typeof rollout.automaticRollback !== "boolean")
|
|
534
|
+
fail(`${where}.rollout.automaticRollback`, "must be a boolean.");
|
|
535
|
+
} else if (row2.kind === "database") {
|
|
536
|
+
exact3(row2, ["kind", "target", "runtime", "topology", "resources", "storage", "network"], where);
|
|
537
|
+
const runtime = object(row2.runtime, `${where}.runtime`);
|
|
538
|
+
exact3(runtime, ["provider", "requirement", "mode"], `${where}.runtime`);
|
|
539
|
+
const databaseRequirement = requirements.get(String(runtime.requirement));
|
|
540
|
+
if (!databaseRequirement)
|
|
541
|
+
fail(`${where}.runtime.requirement`, "must name an existing requirement.");
|
|
542
|
+
if (databaseRequirement && (runtime.provider !== databaseRequirement.provider || databaseRequirement.capability !== "database.arangodb"))
|
|
543
|
+
fail(`${where}.runtime.requirement`, "must match a database.arangodb provider requirement.");
|
|
544
|
+
if (databaseRequirement && databaseRequirement.config.runtime !== runtime.mode)
|
|
545
|
+
fail(`${where}.runtime.mode`, "must match the provider runtime mode.");
|
|
546
|
+
if (runtime.mode !== "native")
|
|
547
|
+
fail(`${where}.runtime.mode`, "must be native in deployment plan v1.");
|
|
548
|
+
const topology = object(row2.topology, `${where}.topology`);
|
|
549
|
+
if (topology.mode === "standalone")
|
|
550
|
+
exact3(topology, ["mode"], `${where}.topology`);
|
|
551
|
+
else if (topology.mode === "cluster") {
|
|
552
|
+
exact3(topology, ["mode", "bootstrapMembers", "replicationFactor", "writeConcern", "additionalJoiners"], `${where}.topology`);
|
|
553
|
+
integer(topology.bootstrapMembers, `${where}.topology.bootstrapMembers`, 3, 9);
|
|
554
|
+
integer(topology.replicationFactor, `${where}.topology.replicationFactor`, 1, 16);
|
|
555
|
+
integer(topology.writeConcern, `${where}.topology.writeConcern`, 1, topology.replicationFactor);
|
|
556
|
+
if (!["allowed", "disabled"].includes(String(topology.additionalJoiners)))
|
|
557
|
+
fail(`${where}.topology.additionalJoiners`, "is unsupported.");
|
|
558
|
+
} else
|
|
559
|
+
fail(`${where}.topology.mode`, "is unsupported.");
|
|
560
|
+
if (row2.resources !== undefined)
|
|
561
|
+
validateResources(row2.resources, `${where}.resources`);
|
|
562
|
+
validateStorage(row2.storage, `${where}.storage`);
|
|
563
|
+
if (row2.storage.class !== "database")
|
|
564
|
+
fail(`${where}.storage.class`, "must be database.");
|
|
565
|
+
validateNetwork(row2.network, `${where}.network`);
|
|
566
|
+
} else
|
|
567
|
+
fail(`${where}.kind`, "is unsupported.");
|
|
568
|
+
return row2;
|
|
569
|
+
}
|
|
570
|
+
function validateStep(value, where, targets, context) {
|
|
571
|
+
const row2 = object(value, where);
|
|
572
|
+
exact3(row2, ["uses", "with", "if", "scope", "timeoutMs", "retry", "failure", "compensate"], where);
|
|
573
|
+
if (typeof row2.uses !== "string" || !ACTION.test(row2.uses))
|
|
574
|
+
fail(`${where}.uses`, "must be a versioned action coordinate.");
|
|
575
|
+
jsonValue(row2.with, `${where}.with`);
|
|
576
|
+
if (row2.if !== undefined)
|
|
577
|
+
condition(row2.if, `${where}.if`);
|
|
578
|
+
const scope = object(row2.scope, `${where}.scope`);
|
|
579
|
+
if (scope.kind === "release-executor")
|
|
580
|
+
exact3(scope, ["kind"], `${where}.scope`);
|
|
581
|
+
else if (scope.kind === "elected-one") {
|
|
582
|
+
exact3(scope, ["kind", "group"], `${where}.scope`);
|
|
583
|
+
named(scope.group, `${where}.scope.group`);
|
|
584
|
+
} else if (scope.kind === "each-target") {
|
|
585
|
+
exact3(scope, ["kind", "target"], `${where}.scope`);
|
|
586
|
+
if (!targets.has(String(scope.target)))
|
|
587
|
+
fail(`${where}.scope.target`, "must name an existing target.");
|
|
588
|
+
} else if (scope.kind === "target-batches") {
|
|
589
|
+
exact3(scope, ["kind", "target", "size"], `${where}.scope`);
|
|
590
|
+
if (!targets.has(String(scope.target)))
|
|
591
|
+
fail(`${where}.scope.target`, "must name an existing target.");
|
|
592
|
+
integer(scope.size, `${where}.scope.size`, 1, 32);
|
|
593
|
+
} else
|
|
594
|
+
fail(`${where}.scope.kind`, "is unsupported.");
|
|
595
|
+
if (row2.timeoutMs !== undefined)
|
|
596
|
+
integer(row2.timeoutMs, `${where}.timeoutMs`, 1, 86400000);
|
|
597
|
+
if (row2.retry !== undefined) {
|
|
598
|
+
const retry = object(row2.retry, `${where}.retry`);
|
|
599
|
+
exact3(retry, ["attempts", "backoff", "retryOn"], `${where}.retry`);
|
|
600
|
+
integer(retry.attempts, `${where}.retry.attempts`, 1, 20);
|
|
601
|
+
const backoff = object(retry.backoff, `${where}.retry.backoff`);
|
|
602
|
+
exact3(backoff, ["kind", "initialMs", "maximumMs"], `${where}.retry.backoff`);
|
|
603
|
+
if (!["fixed", "linear", "exponential"].includes(String(backoff.kind)))
|
|
604
|
+
fail(`${where}.retry.backoff.kind`, "is unsupported.");
|
|
605
|
+
integer(backoff.initialMs, `${where}.retry.backoff.initialMs`, 0, 300000);
|
|
606
|
+
integer(backoff.maximumMs, `${where}.retry.backoff.maximumMs`, backoff.initialMs, 3600000);
|
|
607
|
+
if (retry.retryOn !== undefined && (!Array.isArray(retry.retryOn) || retry.retryOn.length > 8 || retry.retryOn.some((entry) => !["network", "timeout", "provider-unavailable", "rate-limited", "conflict"].includes(String(entry))) || new Set(retry.retryOn).size !== retry.retryOn.length))
|
|
608
|
+
fail(`${where}.retry.retryOn`, "is invalid.");
|
|
609
|
+
}
|
|
610
|
+
if (row2.failure !== undefined) {
|
|
611
|
+
const failure = object(row2.failure, `${where}.failure`);
|
|
612
|
+
exact3(failure, ["policy"], `${where}.failure`);
|
|
613
|
+
if (!["continue", "stop", "rollback-stage", "rollback-deployment", "compensate", "manual-intervention"].includes(String(failure.policy)))
|
|
614
|
+
fail(`${where}.failure.policy`, "is unsupported.");
|
|
615
|
+
}
|
|
616
|
+
if (row2.compensate !== undefined) {
|
|
617
|
+
const compensate = object(row2.compensate, `${where}.compensate`);
|
|
618
|
+
exact3(compensate, ["uses", "with"], `${where}.compensate`);
|
|
619
|
+
if (typeof compensate.uses !== "string" || !ACTION.test(compensate.uses))
|
|
620
|
+
fail(`${where}.compensate.uses`, "must be a versioned action coordinate.");
|
|
621
|
+
jsonValue(compensate.with, `${where}.compensate.with`);
|
|
622
|
+
if (row2.failure?.policy !== "compensate")
|
|
623
|
+
fail(`${where}.compensate`, "requires failure.policy compensate.");
|
|
624
|
+
}
|
|
625
|
+
const definition = row2;
|
|
626
|
+
validateBuiltInAction(definition, context, where);
|
|
627
|
+
if (definition.compensate)
|
|
628
|
+
validateBuiltInAction({ uses: definition.compensate.uses, with: definition.compensate.with, scope: definition.scope }, context, `${where}.compensate`);
|
|
629
|
+
return definition;
|
|
630
|
+
}
|
|
631
|
+
function topologicalStages(stages, workflow) {
|
|
632
|
+
const names = Object.keys(stages);
|
|
633
|
+
const nameSet = new Set(names);
|
|
634
|
+
for (const name of names)
|
|
635
|
+
for (const dependency of stages[name].dependsOn ?? []) {
|
|
636
|
+
if (!nameSet.has(dependency))
|
|
637
|
+
fail(`workflows.${workflow}.stages.${name}.dependsOn`, `names missing stage ${dependency}.`);
|
|
638
|
+
if (dependency === name)
|
|
639
|
+
fail(`workflows.${workflow}.stages.${name}.dependsOn`, "cannot depend on itself.");
|
|
640
|
+
}
|
|
641
|
+
const visiting = new Set;
|
|
642
|
+
const visited = new Set;
|
|
643
|
+
const ordered = [];
|
|
644
|
+
const visit = (name) => {
|
|
645
|
+
if (visiting.has(name))
|
|
646
|
+
fail(`workflows.${workflow}.stages`, "contains a dependency cycle.");
|
|
647
|
+
if (visited.has(name))
|
|
648
|
+
return;
|
|
649
|
+
visiting.add(name);
|
|
650
|
+
for (const dependency of stages[name].dependsOn ?? [])
|
|
651
|
+
visit(dependency);
|
|
652
|
+
visiting.delete(name);
|
|
653
|
+
visited.add(name);
|
|
654
|
+
ordered.push(name);
|
|
655
|
+
};
|
|
656
|
+
for (const name of names.sort())
|
|
657
|
+
visit(name);
|
|
658
|
+
return ordered;
|
|
659
|
+
}
|
|
660
|
+
function validateStage(value, where, targets, context) {
|
|
661
|
+
const row2 = object(value, where);
|
|
662
|
+
exact3(row2, ["dependsOn", "if", "strategy", "steps"], where);
|
|
663
|
+
if (row2.dependsOn !== undefined && (!Array.isArray(row2.dependsOn) || row2.dependsOn.length > 64 || row2.dependsOn.some((entry) => typeof entry !== "string" || !NAME.test(entry)) || new Set(row2.dependsOn).size !== row2.dependsOn.length))
|
|
664
|
+
fail(`${where}.dependsOn`, "is invalid.");
|
|
665
|
+
if (row2.if !== undefined)
|
|
666
|
+
condition(row2.if, `${where}.if`);
|
|
667
|
+
const strategy = object(row2.strategy, `${where}.strategy`);
|
|
668
|
+
exact3(strategy, ["mode", "maximumConcurrency", "batchSize", "minimumHealthy", "increments", "observationMs"], `${where}.strategy`);
|
|
669
|
+
if (!["sequential", "parallel", "rolling", "blue-green", "canary"].includes(String(strategy.mode)))
|
|
670
|
+
fail(`${where}.strategy.mode`, "is unsupported.");
|
|
671
|
+
if (strategy.maximumConcurrency !== undefined)
|
|
672
|
+
integer(strategy.maximumConcurrency, `${where}.strategy.maximumConcurrency`, 1, 1024);
|
|
673
|
+
if (strategy.batchSize !== undefined)
|
|
674
|
+
integer(strategy.batchSize, `${where}.strategy.batchSize`, 1, 1024);
|
|
675
|
+
if (strategy.minimumHealthy !== undefined)
|
|
676
|
+
integer(strategy.minimumHealthy, `${where}.strategy.minimumHealthy`, 0, 1024);
|
|
677
|
+
if (strategy.increments !== undefined && (!Array.isArray(strategy.increments) || strategy.increments.length < 1 || strategy.increments.length > 20 || strategy.increments.some((entry) => !Number.isInteger(entry) || entry < 1 || entry > 100) || strategy.increments.at(-1) !== 100))
|
|
678
|
+
fail(`${where}.strategy.increments`, "must end at 100 and contain bounded percentages.");
|
|
679
|
+
if (strategy.observationMs !== undefined)
|
|
680
|
+
integer(strategy.observationMs, `${where}.strategy.observationMs`, 1000, 86400000);
|
|
681
|
+
if (strategy.mode === "canary" && strategy.increments === undefined)
|
|
682
|
+
fail(`${where}.strategy.increments`, "is required for canary mode.");
|
|
683
|
+
if (strategy.mode === "rolling" && strategy.batchSize === undefined)
|
|
684
|
+
fail(`${where}.strategy.batchSize`, "is required for rolling mode.");
|
|
685
|
+
const steps = object(row2.steps, `${where}.steps`);
|
|
686
|
+
const entries = Object.entries(steps);
|
|
687
|
+
if (entries.length < 1 || entries.length > 256)
|
|
688
|
+
fail(`${where}.steps`, "must contain 1 to 256 steps.");
|
|
689
|
+
for (const [name, stepValue] of entries) {
|
|
690
|
+
named(name, `${where}.steps key`);
|
|
691
|
+
validateStep(stepValue, `${where}.steps.${name}`, targets, context);
|
|
692
|
+
}
|
|
693
|
+
return row2;
|
|
694
|
+
}
|
|
695
|
+
function compileDeployment(sourceValue) {
|
|
696
|
+
const source = object(sourceValue, "deployment");
|
|
697
|
+
exact3(source, ["apiVersion", "kind", "metadata", "spec"], "deployment");
|
|
698
|
+
if (source.apiVersion !== "deploy.forgezero.net/v1")
|
|
699
|
+
fail("deployment.apiVersion", "must be deploy.forgezero.net/v1.");
|
|
700
|
+
if (source.kind !== "Deployment")
|
|
701
|
+
fail("deployment.kind", "must be Deployment.");
|
|
702
|
+
const metadata = object(source.metadata, "deployment.metadata");
|
|
703
|
+
exact3(metadata, ["name", "description"], "deployment.metadata");
|
|
704
|
+
const name = named(metadata.name, "deployment.metadata.name");
|
|
705
|
+
if (metadata.description !== undefined)
|
|
706
|
+
boundedString(metadata.description, "deployment.metadata.description", 1024);
|
|
707
|
+
const spec = object(source.spec, "deployment.spec");
|
|
708
|
+
exact3(spec, ["security", "inputs", "credentials", "targets", "requirements", "components", "workflows"], "deployment.spec");
|
|
709
|
+
if (spec.security !== undefined) {
|
|
710
|
+
const security = object(spec.security, "deployment.spec.security");
|
|
711
|
+
exact3(security, ["attestation"], "deployment.spec.security");
|
|
712
|
+
if (security.attestation !== undefined && !["required", "preferred", "disabled"].includes(String(security.attestation)))
|
|
713
|
+
fail("deployment.spec.security.attestation", "is unsupported.");
|
|
714
|
+
}
|
|
715
|
+
const inputs = object(spec.inputs ?? {}, "deployment.spec.inputs");
|
|
716
|
+
if (Object.keys(inputs).length > 128)
|
|
717
|
+
fail("deployment.spec.inputs", "contains more than 128 inputs.");
|
|
718
|
+
for (const [inputName, value] of Object.entries(inputs)) {
|
|
719
|
+
named(inputName, "deployment.spec.inputs key");
|
|
720
|
+
validateInput(value, `deployment.spec.inputs.${inputName}`);
|
|
721
|
+
}
|
|
722
|
+
const credentials = object(spec.credentials ?? {}, "deployment.spec.credentials");
|
|
723
|
+
if (Object.keys(credentials).length > 128)
|
|
724
|
+
fail("deployment.spec.credentials", "contains more than 128 credentials.");
|
|
725
|
+
for (const [credentialName, value] of Object.entries(credentials)) {
|
|
726
|
+
named(credentialName, "deployment.spec.credentials key");
|
|
727
|
+
const credential = object(value, `deployment.spec.credentials.${credentialName}`);
|
|
728
|
+
exact3(credential, ["schema", "source"], `deployment.spec.credentials.${credentialName}`);
|
|
729
|
+
if (!ACTION.test(String(credential.schema)))
|
|
730
|
+
fail(`deployment.spec.credentials.${credentialName}.schema`, "must be a versioned schema coordinate.");
|
|
731
|
+
const credentialSource = object(credential.source, `deployment.spec.credentials.${credentialName}.source`);
|
|
732
|
+
exact3(credentialSource, ["kind", "name", "fallback"], `deployment.spec.credentials.${credentialName}.source`);
|
|
733
|
+
if (!["vault", "systemd"].includes(String(credentialSource.kind)))
|
|
734
|
+
fail(`deployment.spec.credentials.${credentialName}.source.kind`, "is unsupported.");
|
|
735
|
+
named(credentialSource.name, `deployment.spec.credentials.${credentialName}.source.name`);
|
|
736
|
+
if (credentialSource.fallback !== undefined && (credentialSource.kind !== "vault" || credentialSource.fallback !== "systemd"))
|
|
737
|
+
fail(`deployment.spec.credentials.${credentialName}.source.fallback`, "may only be systemd behind Vault.");
|
|
738
|
+
}
|
|
739
|
+
const targets = object(spec.targets, "deployment.spec.targets");
|
|
740
|
+
const targetEntries = Object.entries(targets);
|
|
741
|
+
if (targetEntries.length < 1 || targetEntries.length > 64)
|
|
742
|
+
fail("deployment.spec.targets", "must contain 1 to 64 targets.");
|
|
743
|
+
const targetNames = new Set(targetEntries.map(([targetName]) => named(targetName, "deployment.spec.targets key")));
|
|
744
|
+
for (const [targetName, value] of targetEntries) {
|
|
745
|
+
const target = object(value, `deployment.spec.targets.${targetName}`);
|
|
746
|
+
exact3(target, ["kind", "selector", "cardinality", "placement"], `deployment.spec.targets.${targetName}`);
|
|
747
|
+
if (target.kind !== "compute")
|
|
748
|
+
fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
|
|
749
|
+
const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
|
|
750
|
+
exact3(selector, ["profiles", "confidentialCompute", "labels"], `deployment.spec.targets.${targetName}.selector`);
|
|
751
|
+
if (!Array.isArray(selector.profiles) || selector.profiles.length < 1 || selector.profiles.length > 32 || selector.profiles.some((profile) => typeof profile !== "string" || !NAME.test(profile)) || new Set(selector.profiles).size !== selector.profiles.length)
|
|
752
|
+
fail(`deployment.spec.targets.${targetName}.selector.profiles`, "is invalid.");
|
|
753
|
+
if (selector.confidentialCompute !== undefined && !["required", "preferred", "disabled"].includes(String(selector.confidentialCompute)))
|
|
754
|
+
fail(`deployment.spec.targets.${targetName}.selector.confidentialCompute`, "is unsupported.");
|
|
755
|
+
if (selector.labels !== undefined)
|
|
756
|
+
jsonValue(selector.labels, `deployment.spec.targets.${targetName}.selector.labels`);
|
|
757
|
+
const cardinality = object(target.cardinality, `deployment.spec.targets.${targetName}.cardinality`);
|
|
758
|
+
exact3(cardinality, ["minimum", "desired", "maximum"], `deployment.spec.targets.${targetName}.cardinality`);
|
|
759
|
+
const minimum = integer(cardinality.minimum, `deployment.spec.targets.${targetName}.cardinality.minimum`, 1, 1024);
|
|
760
|
+
const maximum = integer(cardinality.maximum, `deployment.spec.targets.${targetName}.cardinality.maximum`, minimum, 1024);
|
|
761
|
+
if (typeof cardinality.desired === "number")
|
|
762
|
+
integer(cardinality.desired, `deployment.spec.targets.${targetName}.cardinality.desired`, minimum, maximum);
|
|
763
|
+
else
|
|
764
|
+
reference(cardinality.desired, `deployment.spec.targets.${targetName}.cardinality.desired`);
|
|
765
|
+
if (target.placement !== undefined)
|
|
766
|
+
jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
|
|
767
|
+
}
|
|
768
|
+
const requirements = object(spec.requirements ?? {}, "deployment.spec.requirements");
|
|
769
|
+
if (Object.keys(requirements).length > 64)
|
|
770
|
+
fail("deployment.spec.requirements", "contains more than 64 requirements.");
|
|
771
|
+
const requirementDefinitions = new Map;
|
|
772
|
+
const requiredProviders = new Map;
|
|
773
|
+
for (const [requirementName, value] of Object.entries(requirements)) {
|
|
774
|
+
named(requirementName, "deployment.spec.requirements key");
|
|
775
|
+
const requirementValue = validateRequirement(value, `deployment.spec.requirements.${requirementName}`);
|
|
776
|
+
requirementDefinitions.set(requirementName, requirementValue);
|
|
777
|
+
const key = `${requirementValue.provider}@${requirementValue.contract}:${requirementValue.capability}`;
|
|
778
|
+
requiredProviders.set(key, { provider: requirementValue.provider, contract: requirementValue.contract, capability: requirementValue.capability });
|
|
779
|
+
}
|
|
780
|
+
const components = object(spec.components, "deployment.spec.components");
|
|
781
|
+
const componentEntries = Object.entries(components);
|
|
782
|
+
if (componentEntries.length < 1 || componentEntries.length > 128)
|
|
783
|
+
fail("deployment.spec.components", "must contain 1 to 128 components.");
|
|
784
|
+
for (const [componentName, value] of componentEntries) {
|
|
785
|
+
named(componentName, "deployment.spec.components key");
|
|
786
|
+
validateComponent(value, `deployment.spec.components.${componentName}`, targetNames, requirementDefinitions);
|
|
787
|
+
}
|
|
788
|
+
const actionContext = {
|
|
789
|
+
components,
|
|
790
|
+
requirements,
|
|
791
|
+
credentials
|
|
792
|
+
};
|
|
793
|
+
const workflows = object(spec.workflows, "deployment.spec.workflows");
|
|
794
|
+
const workflowEntries = Object.entries(workflows);
|
|
795
|
+
if (workflowEntries.length < 1 || workflowEntries.length > 32)
|
|
796
|
+
fail("deployment.spec.workflows", "must contain 1 to 32 workflows.");
|
|
797
|
+
const plannedWorkflows = [];
|
|
798
|
+
for (const [workflowName, value] of workflowEntries.sort(([left], [right]) => left.localeCompare(right))) {
|
|
799
|
+
named(workflowName, "deployment.spec.workflows key");
|
|
800
|
+
const workflowValue = object(value, `deployment.spec.workflows.${workflowName}`);
|
|
801
|
+
exact3(workflowValue, ["concurrency", "stages"], `deployment.spec.workflows.${workflowName}`);
|
|
802
|
+
if (workflowValue.concurrency !== undefined) {
|
|
803
|
+
const concurrency = object(workflowValue.concurrency, `deployment.spec.workflows.${workflowName}.concurrency`);
|
|
804
|
+
exact3(concurrency, ["group", "limit"], `deployment.spec.workflows.${workflowName}.concurrency`);
|
|
805
|
+
boundedString(concurrency.group, `deployment.spec.workflows.${workflowName}.concurrency.group`, 256);
|
|
806
|
+
integer(concurrency.limit, `deployment.spec.workflows.${workflowName}.concurrency.limit`, 1, 1024);
|
|
807
|
+
}
|
|
808
|
+
const stages = object(workflowValue.stages, `deployment.spec.workflows.${workflowName}.stages`);
|
|
809
|
+
if (Object.keys(stages).length < 1 || Object.keys(stages).length > 64)
|
|
810
|
+
fail(`deployment.spec.workflows.${workflowName}.stages`, "must contain 1 to 64 stages.");
|
|
811
|
+
for (const [stageName, stageValue] of Object.entries(stages)) {
|
|
812
|
+
named(stageName, `deployment.spec.workflows.${workflowName}.stages key`);
|
|
813
|
+
validateStage(stageValue, `deployment.spec.workflows.${workflowName}.stages.${stageName}`, targetNames, actionContext);
|
|
814
|
+
}
|
|
815
|
+
const stepIds = new Set;
|
|
816
|
+
const plannedStages = topologicalStages(stages, workflowName).map((stageName) => {
|
|
817
|
+
const stageValue = stages[stageName];
|
|
818
|
+
const plannedSteps = Object.entries(stageValue.steps).map(([stepName, stepValue]) => {
|
|
819
|
+
if (stepIds.has(stepName))
|
|
820
|
+
fail(`deployment.spec.workflows.${workflowName}`, `contains duplicate step id ${stepName}.`);
|
|
821
|
+
stepIds.add(stepName);
|
|
822
|
+
return { id: stepName, ...stepValue };
|
|
823
|
+
});
|
|
824
|
+
return { id: stageName, ...stageValue.dependsOn ? { dependsOn: [...stageValue.dependsOn].sort() } : {}, ...stageValue.if ? { if: stageValue.if } : {}, strategy: stageValue.strategy, steps: plannedSteps };
|
|
825
|
+
});
|
|
826
|
+
plannedWorkflows.push({ id: workflowName, ...workflowValue.concurrency ? { concurrency: workflowValue.concurrency } : {}, stages: plannedStages });
|
|
827
|
+
}
|
|
828
|
+
const typedSource = source;
|
|
829
|
+
return {
|
|
830
|
+
format: DEPLOY_PLAN_FORMAT,
|
|
831
|
+
version: DEPLOY_PLAN_VERSION,
|
|
832
|
+
sourceDigest: deploymentSourceDigest(typedSource),
|
|
833
|
+
name,
|
|
834
|
+
...metadata.description ? { description: metadata.description } : {},
|
|
835
|
+
requiredProviders: [...requiredProviders.values()].sort((left, right) => `${left.provider}:${left.contract}:${left.capability}`.localeCompare(`${right.provider}:${right.contract}:${right.capability}`)),
|
|
836
|
+
spec: {
|
|
837
|
+
...spec.security ? { security: spec.security } : {},
|
|
838
|
+
inputs,
|
|
839
|
+
credentials,
|
|
840
|
+
targets,
|
|
841
|
+
requirements,
|
|
842
|
+
components,
|
|
843
|
+
workflows: plannedWorkflows
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
function deploymentPlanDigest(plan) {
|
|
848
|
+
return `sha256:${createHash("sha256").update(canonicalJson(plan)).digest("hex")}`;
|
|
849
|
+
}
|
|
850
|
+
function parseDeploymentPlan(value) {
|
|
851
|
+
const row2 = object(value, "plan");
|
|
852
|
+
exact3(row2, ["format", "version", "sourceDigest", "name", "description", "requiredProviders", "spec"], "plan");
|
|
853
|
+
if (row2.format !== DEPLOY_PLAN_FORMAT || row2.version !== DEPLOY_PLAN_VERSION)
|
|
854
|
+
fail("plan", "uses an unsupported format or version.");
|
|
855
|
+
if (typeof row2.sourceDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(row2.sourceDigest))
|
|
856
|
+
fail("plan.sourceDigest", "is invalid.");
|
|
857
|
+
named(row2.name, "plan.name");
|
|
858
|
+
if (row2.description !== undefined)
|
|
859
|
+
boundedString(row2.description, "plan.description", 1024);
|
|
860
|
+
if (!Array.isArray(row2.requiredProviders) || row2.requiredProviders.length > 64)
|
|
861
|
+
fail("plan.requiredProviders", "must be a bounded array.");
|
|
862
|
+
for (const [index, provider] of row2.requiredProviders.entries()) {
|
|
863
|
+
const item = object(provider, `plan.requiredProviders[${index}]`);
|
|
864
|
+
exact3(item, ["provider", "contract", "capability"], `plan.requiredProviders[${index}]`);
|
|
865
|
+
if (!COORDINATE.test(String(item.provider)) || !COORDINATE.test(String(item.capability)))
|
|
866
|
+
fail(`plan.requiredProviders[${index}]`, "contains an invalid coordinate.");
|
|
867
|
+
integer(item.contract, `plan.requiredProviders[${index}].contract`, 1, 65535);
|
|
868
|
+
}
|
|
869
|
+
const spec = object(row2.spec, "plan.spec");
|
|
870
|
+
const workflows = spec.workflows;
|
|
871
|
+
if (!Array.isArray(workflows))
|
|
872
|
+
fail("plan.spec.workflows", "must be an array.");
|
|
873
|
+
const sourceWorkflows = {};
|
|
874
|
+
for (const [workflowIndex, workflow] of workflows.entries()) {
|
|
875
|
+
const item = object(workflow, `plan.spec.workflows[${workflowIndex}]`);
|
|
876
|
+
exact3(item, ["id", "concurrency", "stages"], `plan.spec.workflows[${workflowIndex}]`);
|
|
877
|
+
const workflowId = named(item.id, `plan.spec.workflows[${workflowIndex}].id`);
|
|
878
|
+
if (sourceWorkflows[workflowId])
|
|
879
|
+
fail("plan.spec.workflows", `contains duplicate workflow ${workflowId}.`);
|
|
880
|
+
if (!Array.isArray(item.stages))
|
|
881
|
+
fail(`plan.spec.workflows[${workflowIndex}].stages`, "must be an array.");
|
|
882
|
+
const sourceStages = {};
|
|
883
|
+
for (const [stageIndex, stage] of item.stages.entries()) {
|
|
884
|
+
const stageItem = object(stage, `plan.spec.workflows[${workflowIndex}].stages[${stageIndex}]`);
|
|
885
|
+
exact3(stageItem, ["id", "dependsOn", "if", "strategy", "steps"], `plan.spec.workflows[${workflowIndex}].stages[${stageIndex}]`);
|
|
886
|
+
const stageId = named(stageItem.id, `plan.spec.workflows[${workflowIndex}].stages[${stageIndex}].id`);
|
|
887
|
+
if (sourceStages[stageId])
|
|
888
|
+
fail(`plan.spec.workflows[${workflowIndex}].stages`, `contains duplicate stage ${stageId}.`);
|
|
889
|
+
if (!Array.isArray(stageItem.steps))
|
|
890
|
+
fail(`plan.spec.workflows[${workflowIndex}].stages[${stageIndex}].steps`, "must be an array.");
|
|
891
|
+
const sourceSteps = {};
|
|
892
|
+
for (const [stepIndex, step2] of stageItem.steps.entries()) {
|
|
893
|
+
const stepItem = object(step2, `plan.spec.workflows[${workflowIndex}].stages[${stageIndex}].steps[${stepIndex}]`);
|
|
894
|
+
const stepId = named(stepItem.id, `plan.spec.workflows[${workflowIndex}].stages[${stageIndex}].steps[${stepIndex}].id`);
|
|
895
|
+
if (sourceSteps[stepId])
|
|
896
|
+
fail(`plan.spec.workflows[${workflowIndex}].stages[${stageIndex}].steps`, `contains duplicate step ${stepId}.`);
|
|
897
|
+
const { id: _id3, ...stepWithoutId } = stepItem;
|
|
898
|
+
sourceSteps[stepId] = stepWithoutId;
|
|
899
|
+
}
|
|
900
|
+
const { id: _id2, steps: _steps, ...stageWithoutId } = stageItem;
|
|
901
|
+
sourceStages[stageId] = { ...stageWithoutId, steps: sourceSteps };
|
|
902
|
+
}
|
|
903
|
+
const { id: _id, stages: _stages, ...workflowWithoutId } = item;
|
|
904
|
+
sourceWorkflows[workflowId] = { ...workflowWithoutId, stages: sourceStages };
|
|
905
|
+
}
|
|
906
|
+
const { workflows: _workflows, ...specWithoutWorkflows } = spec;
|
|
907
|
+
const reconstructed = {
|
|
908
|
+
apiVersion: "deploy.forgezero.net/v1",
|
|
909
|
+
kind: "Deployment",
|
|
910
|
+
metadata: { name: row2.name, ...row2.description ? { description: row2.description } : {} },
|
|
911
|
+
spec: { ...specWithoutWorkflows, workflows: sourceWorkflows }
|
|
912
|
+
};
|
|
913
|
+
const compiled = compileDeployment(reconstructed);
|
|
914
|
+
return { ...compiled, sourceDigest: row2.sourceDigest };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/deploy-compiler.ts
|
|
918
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
919
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "path";
|
|
920
|
+
import { pathToFileURL } from "url";
|
|
921
|
+
var DEPLOY_SOURCE_FILE = "forgezero.deploy.ts";
|
|
922
|
+
var DEPLOY_PLAN_FILE = ".fz/deploy.plan.json";
|
|
923
|
+
function safeName(value) {
|
|
924
|
+
const result = value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
925
|
+
return /^[a-z]/.test(result) ? result : `app-${result || "service"}`;
|
|
926
|
+
}
|
|
927
|
+
function defaultTypeScriptDeployment(name) {
|
|
928
|
+
return `import { actions, application, defineDeployment, input, providers, stage, target, workflow } from '@forgezero/agent/deploy';
|
|
929
|
+
|
|
930
|
+
export default defineDeployment({
|
|
931
|
+
apiVersion: 'deploy.forgezero.net/v1',
|
|
932
|
+
kind: 'Deployment',
|
|
933
|
+
metadata: { name: '${safeName(name)}' },
|
|
934
|
+
spec: {
|
|
935
|
+
security: { attestation: 'preferred' },
|
|
936
|
+
inputs: { replicas: input.integer({ minimum: 1, maximum: 32, default: 1 }) },
|
|
937
|
+
targets: {
|
|
938
|
+
app: target.compute({
|
|
939
|
+
selector: { profiles: ['app'] },
|
|
940
|
+
cardinality: { minimum: 1, desired: input.ref('replicas'), maximum: 32 }
|
|
941
|
+
})
|
|
942
|
+
},
|
|
943
|
+
requirements: { bun: providers.bun.require() },
|
|
944
|
+
components: {
|
|
945
|
+
app: application({
|
|
946
|
+
target: 'app',
|
|
947
|
+
runtime: { kind: 'native', provider: 'forgezero.bun', requirement: 'bun', argv: ['/usr/local/bin/bun', 'run', 'start'] },
|
|
948
|
+
service: { protocol: 'http', port: 3000, health: { protocol: 'http', method: 'GET', path: '/health', expectedStatus: [200], timeoutMs: 5_000 } },
|
|
949
|
+
resources: {},
|
|
950
|
+
rollout: { strategy: 'direct' }
|
|
951
|
+
})
|
|
952
|
+
},
|
|
953
|
+
workflows: {
|
|
954
|
+
deploy: workflow({
|
|
955
|
+
stages: {
|
|
956
|
+
build: stage({ strategy: { mode: 'sequential' }, steps: {
|
|
957
|
+
build: actions.exec.argv(
|
|
958
|
+
{ component: 'app', argv: ['/usr/local/bin/fz-agent', 'pipeline-todo', 'replace with the project build argv'] },
|
|
959
|
+
{ scope: { kind: 'each-target', target: 'app' }, timeoutMs: 600_000 }
|
|
960
|
+
)
|
|
961
|
+
} }),
|
|
962
|
+
release: stage({ dependsOn: ['build'], strategy: { mode: 'sequential' }, steps: {
|
|
963
|
+
start: actions.exec.argv(
|
|
964
|
+
{ component: 'app', argv: ['/usr/local/bin/fz-agent', 'pipeline-todo', 'replace with the project release argv'] },
|
|
965
|
+
{ scope: { kind: 'each-target', target: 'app' }, timeoutMs: 120_000 }
|
|
966
|
+
)
|
|
967
|
+
} }),
|
|
968
|
+
verify: stage({ dependsOn: ['release'], strategy: { mode: 'parallel' }, steps: {
|
|
969
|
+
health: actions.service.health({ component: 'app' }, { scope: { kind: 'each-target', target: 'app' }, timeoutMs: 30_000 })
|
|
970
|
+
} })
|
|
971
|
+
}
|
|
972
|
+
})
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
`;
|
|
977
|
+
}
|
|
978
|
+
function initializeTypeScriptDeployment(rootValue, options) {
|
|
979
|
+
const path = localPath(rootValue, DEPLOY_SOURCE_FILE, "deployment source");
|
|
980
|
+
if (existsSync(path) && !options.force)
|
|
981
|
+
throw new Error(`${DEPLOY_SOURCE_FILE} already exists; use --force only when replacing it deliberately`);
|
|
982
|
+
writeFileSync(path, defaultTypeScriptDeployment(options.name), { mode: 420, flag: options.force ? "w" : "wx" });
|
|
983
|
+
return path;
|
|
984
|
+
}
|
|
985
|
+
function inside(root, path) {
|
|
986
|
+
const nested = relative(root, path);
|
|
987
|
+
return nested === "" || !nested.startsWith(`..${sep}`) && nested !== ".." && !isAbsolute(nested);
|
|
988
|
+
}
|
|
989
|
+
function localPath(rootValue, value, label) {
|
|
990
|
+
const root = resolve(rootValue);
|
|
991
|
+
const path = resolve(root, value);
|
|
992
|
+
if (!inside(root, path))
|
|
993
|
+
throw new Error(`${label} must remain inside the project root`);
|
|
994
|
+
return path;
|
|
995
|
+
}
|
|
996
|
+
async function loadDeploymentSource(root, sourceFile = DEPLOY_SOURCE_FILE) {
|
|
997
|
+
const source = localPath(root, sourceFile, "deployment source");
|
|
998
|
+
if (!existsSync(source))
|
|
999
|
+
throw new Error(`${sourceFile} does not exist`);
|
|
1000
|
+
const status = lstatSync(source);
|
|
1001
|
+
if (!status.isFile() || status.isSymbolicLink() || status.size > 2 * 1024 * 1024)
|
|
1002
|
+
throw new Error("deployment source must be one bounded regular file");
|
|
1003
|
+
const module = await import(`${pathToFileURL(source).href}?forgezero=${status.mtimeMs}`);
|
|
1004
|
+
if (module.default === undefined)
|
|
1005
|
+
throw new Error(`${sourceFile} must export one default deployment definition`);
|
|
1006
|
+
return module.default;
|
|
1007
|
+
}
|
|
1008
|
+
async function compileDeploymentProject(rootValue, options = {}) {
|
|
1009
|
+
const root = resolve(rootValue);
|
|
1010
|
+
const sourceFile = options.sourceFile ?? DEPLOY_SOURCE_FILE;
|
|
1011
|
+
const outputFile = options.outputFile ?? DEPLOY_PLAN_FILE;
|
|
1012
|
+
const source = localPath(root, sourceFile, "deployment source");
|
|
1013
|
+
const output = localPath(root, outputFile, "deployment output");
|
|
1014
|
+
const plan = compileDeployment(await loadDeploymentSource(root, sourceFile));
|
|
1015
|
+
const bytes = `${canonicalJson(plan)}
|
|
1016
|
+
`;
|
|
1017
|
+
parseDeploymentPlan(JSON.parse(bytes));
|
|
1018
|
+
const previous = existsSync(output) ? readFileSync(output, "utf8") : undefined;
|
|
1019
|
+
const changed = previous !== bytes;
|
|
1020
|
+
if (options.write !== false && changed) {
|
|
1021
|
+
mkdirSync(dirname(output), { recursive: true, mode: 493 });
|
|
1022
|
+
const temporary = `${output}.new-${process.pid}`;
|
|
1023
|
+
try {
|
|
1024
|
+
writeFileSync(temporary, bytes, { mode: 420, flag: "wx" });
|
|
1025
|
+
renameSync(temporary, output);
|
|
1026
|
+
} finally {
|
|
1027
|
+
rmSync(temporary, { force: true });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return { source, output, plan, digest: deploymentPlanDigest(plan), changed };
|
|
1031
|
+
}
|
|
1032
|
+
function inspectCompiledDeployment(rootValue, outputFile = DEPLOY_PLAN_FILE) {
|
|
1033
|
+
const path = localPath(rootValue, outputFile, "deployment output");
|
|
1034
|
+
if (!existsSync(path))
|
|
1035
|
+
throw new Error(`${outputFile} does not exist; run \`fz deploy compile\``);
|
|
1036
|
+
const status = lstatSync(path);
|
|
1037
|
+
if (!status.isFile() || status.isSymbolicLink() || status.size > 4194304)
|
|
1038
|
+
throw new Error("deployment plan must be one bounded regular file");
|
|
1039
|
+
const plan = parseDeploymentPlan(JSON.parse(readFileSync(path, "utf8")));
|
|
1040
|
+
return { path, plan, digest: deploymentPlanDigest(plan) };
|
|
1041
|
+
}
|
|
1042
|
+
export {
|
|
1043
|
+
loadDeploymentSource,
|
|
1044
|
+
inspectCompiledDeployment,
|
|
1045
|
+
initializeTypeScriptDeployment,
|
|
1046
|
+
defaultTypeScriptDeployment,
|
|
1047
|
+
compileDeploymentProject,
|
|
1048
|
+
DEPLOY_SOURCE_FILE,
|
|
1049
|
+
DEPLOY_PLAN_FILE
|
|
1050
|
+
};
|