@forgezero/agent 0.1.40 → 0.1.42
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 +573 -424
- package/dist/agent-heartbeat.js +6 -3
- package/dist/agent-update-helper.js +5 -2
- package/dist/agent-update.js +5 -2
- package/dist/bootstrap.d.ts +17 -8
- package/dist/bootstrap.js +1504 -512
- package/dist/cli/agent-install.d.ts +6 -5
- package/dist/cli/cloudflare-bootstrap.d.ts +12 -1
- package/dist/cli/maintenance.d.ts +23 -0
- package/dist/cli/run.d.ts +3 -1
- package/dist/cli/session-store.d.ts +5 -0
- package/dist/cloudflare-bootstrap.d.ts +73 -35
- package/dist/cloudflare-bootstrap.js +587 -90
- package/dist/cloudflare-edge.d.ts +64 -12
- package/dist/cloudflare-edge.js +103 -8
- package/dist/community-rehearsal-host.d.ts +51 -0
- package/dist/community-rehearsal-host.js +272 -0
- package/dist/credential-schema.d.ts +54 -0
- package/dist/credential-schema.js +47 -0
- package/dist/definition.d.ts +31 -5
- package/dist/definition.js +271 -44
- package/dist/deploy-file.js +294 -68
- package/dist/deployment-runner.js +18 -5
- package/dist/deployment.d.ts +13 -1
- package/dist/fz-agent.js +9162 -7564
- package/dist/fz-git-ssh.js +122 -0
- package/dist/fz.js +5679 -5077
- package/dist/git-ssh.d.ts +5 -0
- package/dist/guest-enrolment.d.ts +2 -0
- package/dist/guest-enrolment.js +1 -0
- package/dist/host-maintenance.d.ts +39 -0
- package/dist/host-maintenance.js +135 -0
- package/dist/index.d.ts +4 -2
- package/dist/mesh-connector.d.ts +16 -0
- package/dist/mesh-connector.js +46 -0
- package/dist/metal-bootstrap.js +145 -7
- package/dist/metal-helper-socket.js +61 -31
- package/dist/metal-provision.d.ts +2 -2
- package/dist/metal-provision.js +62 -32
- package/dist/operator-bootstrap.d.ts +90 -0
- package/dist/operator-bootstrap.js +5704 -0
- package/dist/otel-collector.d.ts +18 -0
- package/dist/pipeline.d.ts +3 -2
- package/dist/pipeline.js +1 -1
- package/dist/platform-bootstrap-runtime.d.ts +39 -21
- package/dist/platform-bootstrap-runtime.js +182 -59
- package/dist/platform-fleet-verification.d.ts +19 -0
- package/dist/platform-fleet-verification.js +3873 -0
- package/dist/platform-genesis-config.d.ts +7 -0
- package/dist/platform-genesis.d.ts +17 -0
- package/dist/provision.d.ts +76 -3
- package/dist/provision.js +1061 -229
- package/dist/recovery-host.d.ts +7 -0
- package/dist/recovery-host.js +124 -0
- package/dist/service-supervisor.d.ts +42 -0
- package/dist/software-helper.d.ts +4 -0
- package/dist/software-helper.js +865 -63
- package/dist/software.d.ts +14 -3
- package/dist/software.js +163 -37
- package/dist/ssh-bootstrap.d.ts +97 -0
- package/dist/supervised-app.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/package.json +175 -164
- package/schema/{deploy-v2.json → deploy-v3.json} +53 -6
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
|
|
2
|
+
export declare const FORGEZERO_OTEL_COLLECTOR_VERSION = "0.157.0";
|
|
3
|
+
export declare const FORGEZERO_OTEL_COLLECTOR_SHA256 = "2937cf24892af55b143c072fddece17862239cf78280620029276493eb81beae";
|
|
4
|
+
export interface OtelCollectorHost {
|
|
5
|
+
write(path: string, content: string, mode: number): void;
|
|
6
|
+
exec(argv: readonly string[]): Promise<{
|
|
7
|
+
exitCode: number;
|
|
8
|
+
output?: string;
|
|
9
|
+
stdout?: string;
|
|
10
|
+
stderr?: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export declare function renderForgeZeroOtelCollector(exportEndpoint: string): {
|
|
14
|
+
config: string;
|
|
15
|
+
unit: string;
|
|
16
|
+
};
|
|
17
|
+
/** Install one exact official collector release and converge its separate unit. */
|
|
18
|
+
export declare function ensureForgeZeroOtelCollector(host: OtelCollectorHost, exportEndpoint: string): Promise<void>;
|
package/dist/pipeline.d.ts
CHANGED
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
export type StepOutcome = 'ok' | 'failed' | 'skipped';
|
|
37
37
|
export interface PipelineStep {
|
|
38
38
|
name: string;
|
|
39
|
-
|
|
39
|
+
/** Exact executable and arguments. No shell parses, expands or joins these values. */
|
|
40
|
+
exec: readonly string[];
|
|
40
41
|
/** Secret names this step needs. Nothing it does not name is in its env. */
|
|
41
42
|
secrets?: readonly string[];
|
|
42
43
|
/** Run even when an earlier step failed — cleanup, teardown, notifications. */
|
|
@@ -80,7 +81,7 @@ export interface RunOptions {
|
|
|
80
81
|
secret(name: string): Promise<string>;
|
|
81
82
|
/** Spawns a step. Injected, so a pipeline is testable without a shell. */
|
|
82
83
|
exec(input: {
|
|
83
|
-
|
|
84
|
+
argv: readonly string[];
|
|
84
85
|
env: Record<string, string>;
|
|
85
86
|
timeoutMs?: number;
|
|
86
87
|
}): Promise<{
|
package/dist/pipeline.js
CHANGED
|
@@ -54,7 +54,7 @@ async function runPipeline(options) {
|
|
|
54
54
|
let exitCode;
|
|
55
55
|
let output;
|
|
56
56
|
try {
|
|
57
|
-
const result = await options.exec({
|
|
57
|
+
const result = await options.exec({ argv: step.exec, env, timeoutMs: step.timeoutMs });
|
|
58
58
|
exitCode = result.exitCode;
|
|
59
59
|
output = result.output;
|
|
60
60
|
} catch (cause) {
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
/** Typed
|
|
1
|
+
/** Typed host runtime plans used by `fz bootstrap platform`. */
|
|
2
2
|
export type PlatformSoftwareProfile = 'platform-db-api' | 'platform-api';
|
|
3
3
|
export type PlatformDatabaseRole = 'master' | 'joiner' | 'none';
|
|
4
4
|
export type ApiSlot = 'blue' | 'green';
|
|
5
|
+
export type PlatformBootstrapEmail = {
|
|
6
|
+
provider: 'smtp';
|
|
7
|
+
host: string;
|
|
8
|
+
port: number;
|
|
9
|
+
user: string;
|
|
10
|
+
from: string;
|
|
11
|
+
} | {
|
|
12
|
+
provider: 'jetemail';
|
|
13
|
+
from: string;
|
|
14
|
+
eu: boolean;
|
|
15
|
+
};
|
|
5
16
|
export interface PlatformSharedEnvironment {
|
|
6
17
|
softwareProfile: PlatformSoftwareProfile;
|
|
7
18
|
databaseRole: PlatformDatabaseRole;
|
|
8
19
|
databaseCoordinators: string[];
|
|
9
20
|
databaseAddress?: string;
|
|
10
21
|
databaseMaster?: string;
|
|
11
|
-
databaseNetworkMode: 'private-lan'
|
|
22
|
+
databaseNetworkMode: 'private-lan';
|
|
12
23
|
databaseReplicationFactor: number;
|
|
13
24
|
databaseWriteConcern: number;
|
|
14
25
|
databaseUser: string;
|
|
@@ -30,12 +41,7 @@ export interface PlatformSharedEnvironment {
|
|
|
30
41
|
otlpFlushIntervalMs: number;
|
|
31
42
|
otlpTraceSampleRatio: number;
|
|
32
43
|
custodianEmail?: string;
|
|
33
|
-
|
|
34
|
-
host: string;
|
|
35
|
-
port: number;
|
|
36
|
-
user?: string;
|
|
37
|
-
from: string;
|
|
38
|
-
};
|
|
44
|
+
email?: PlatformBootstrapEmail;
|
|
39
45
|
backup?: {
|
|
40
46
|
endpoint: string;
|
|
41
47
|
region: string;
|
|
@@ -48,30 +54,28 @@ export interface PlatformSharedEnvironment {
|
|
|
48
54
|
kvNamespaceId: string;
|
|
49
55
|
tunnelId: string;
|
|
50
56
|
tunnelService: string;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
};
|
|
58
|
+
realtime?: {
|
|
59
|
+
workerScriptName: string;
|
|
60
|
+
endpoint: string;
|
|
61
|
+
producer: string;
|
|
56
62
|
};
|
|
57
63
|
repository: string;
|
|
58
64
|
branch: string;
|
|
59
65
|
deployProfile: string;
|
|
60
66
|
}
|
|
61
|
-
export declare function validatePlatformSharedEnvironment(input: PlatformSharedEnvironment
|
|
62
|
-
allowPendingCloudflareHandoff?: boolean;
|
|
63
|
-
}): PlatformSharedEnvironment;
|
|
67
|
+
export declare function validatePlatformSharedEnvironment(input: PlatformSharedEnvironment): PlatformSharedEnvironment;
|
|
64
68
|
/** Render only non-secret runtime coordinates. Passwords/tokens have no field in this contract. */
|
|
65
69
|
export declare function renderPlatformSharedEnvironment(input: PlatformSharedEnvironment): string;
|
|
66
70
|
export interface SystemdCredentialSpec {
|
|
67
|
-
name: 'arangodb-jwt' | 'seed-sync-root' | 'bootstrap-smtp-password' | '
|
|
71
|
+
name: 'arangodb-jwt' | 'seed-sync-root' | 'bootstrap-smtp-password' | 'bootstrap-jetemail-api-key' | 'CF_API_TOKEN' | 'REALTIME_PUBLISH_SECRET' | 'REALTIME_TICKET_SECRET';
|
|
68
72
|
encryptedPath: string;
|
|
69
73
|
required: boolean;
|
|
70
74
|
}
|
|
71
75
|
export declare function platformApiCredentialSpecs(options: {
|
|
72
|
-
|
|
76
|
+
emailProvider?: PlatformBootstrapEmail['provider'];
|
|
73
77
|
cloudflareKv: boolean;
|
|
74
|
-
|
|
78
|
+
realtime: boolean;
|
|
75
79
|
}): SystemdCredentialSpec[];
|
|
76
80
|
export interface ApiRuntimeRenderOptions {
|
|
77
81
|
serviceUser: string;
|
|
@@ -99,8 +103,8 @@ export declare function renderPlatformNginx(input: {
|
|
|
99
103
|
site: string;
|
|
100
104
|
};
|
|
101
105
|
export interface ActivationBoundary {
|
|
102
|
-
command: '/usr/local/
|
|
103
|
-
argv: [string];
|
|
106
|
+
command: '/usr/local/lib/forgezero/agent/fz-agent';
|
|
107
|
+
argv: ['platform-activate', '--config=/etc/forgezero/deploy-activation.json', string];
|
|
104
108
|
runAs: 'root';
|
|
105
109
|
invoker: 'forgezero-runner';
|
|
106
110
|
}
|
|
@@ -124,6 +128,20 @@ export declare function renderPlatformActivationFiles(input: PlatformActivationC
|
|
|
124
128
|
helper: string;
|
|
125
129
|
sudoers: string;
|
|
126
130
|
};
|
|
131
|
+
export interface PlatformActivationCommandResult {
|
|
132
|
+
exitCode: number;
|
|
133
|
+
output: string;
|
|
134
|
+
}
|
|
135
|
+
export type PlatformActivationExec = (argv: readonly string[]) => Promise<PlatformActivationCommandResult>;
|
|
136
|
+
/** Agent-native blue/green activation with health and nginx rollback. */
|
|
137
|
+
export declare function activatePlatformRelease(config: PlatformActivationConfig, requestedRelease: string, options?: {
|
|
138
|
+
exec?: PlatformActivationExec;
|
|
139
|
+
fetch?: typeof fetch;
|
|
140
|
+
sleep?: (ms: number) => Promise<void>;
|
|
141
|
+
}): Promise<{
|
|
142
|
+
release: string;
|
|
143
|
+
slot: ApiSlot;
|
|
144
|
+
}>;
|
|
127
145
|
export interface LocalOtlpProofPlan {
|
|
128
146
|
unitCheck: {
|
|
129
147
|
command: 'systemctl';
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
// src/platform-bootstrap-runtime.ts
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
chownSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
symlinkSync,
|
|
16
|
+
writeFileSync
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
2
19
|
var safeAtom = (name, value) => {
|
|
3
20
|
if (!value || /[\0\r\n]/.test(value))
|
|
4
21
|
throw new Error(`${name} must be non-empty and single-line.`);
|
|
@@ -35,7 +52,7 @@ var systemdValue = (name, raw) => {
|
|
|
35
52
|
const value = raw;
|
|
36
53
|
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$")}"`;
|
|
37
54
|
};
|
|
38
|
-
function validatePlatformSharedEnvironment(input
|
|
55
|
+
function validatePlatformSharedEnvironment(input) {
|
|
39
56
|
if (input.softwareProfile === "platform-api" !== (input.databaseRole === "none")) {
|
|
40
57
|
throw new Error("platform-api requires database role none; platform-db-api requires master or joiner.");
|
|
41
58
|
}
|
|
@@ -45,14 +62,26 @@ function validatePlatformSharedEnvironment(input, options = {}) {
|
|
|
45
62
|
const coordinators = input.databaseCoordinators.map(privateCoordinator);
|
|
46
63
|
if (new Set(coordinators).size !== coordinators.length)
|
|
47
64
|
throw new Error("databaseCoordinators must be unique.");
|
|
48
|
-
if (
|
|
49
|
-
throw new Error("
|
|
65
|
+
if (input.databaseNetworkMode !== "private-lan") {
|
|
66
|
+
throw new Error("Attended platform bootstrap supports only private-lan database networking.");
|
|
50
67
|
}
|
|
51
68
|
boundedInteger("databaseReplicationFactor", input.databaseReplicationFactor, 1, 16);
|
|
52
69
|
boundedInteger("databaseWriteConcern", input.databaseWriteConcern, 1, 16);
|
|
53
70
|
if (input.databaseWriteConcern > input.databaseReplicationFactor) {
|
|
54
71
|
throw new Error("databaseWriteConcern cannot exceed databaseReplicationFactor.");
|
|
55
72
|
}
|
|
73
|
+
if (input.email?.provider === "smtp") {
|
|
74
|
+
safeAtom("email.host", input.email.host);
|
|
75
|
+
boundedInteger("email.port", input.email.port, 1, 65535);
|
|
76
|
+
safeAtom("email.user", input.email.user);
|
|
77
|
+
safeAtom("email.from", input.email.from);
|
|
78
|
+
} else if (input.email?.provider === "jetemail") {
|
|
79
|
+
safeAtom("email.from", input.email.from);
|
|
80
|
+
if (typeof input.email.eu !== "boolean")
|
|
81
|
+
throw new Error("JetEmail eu must be boolean.");
|
|
82
|
+
} else if (input.email !== undefined) {
|
|
83
|
+
throw new Error("Bootstrap email provider must be smtp or jetemail.");
|
|
84
|
+
}
|
|
56
85
|
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
57
86
|
boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
|
|
58
87
|
boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
|
|
@@ -85,13 +114,6 @@ function validatePlatformSharedEnvironment(input, options = {}) {
|
|
|
85
114
|
if (url.username || url.password || url.hash)
|
|
86
115
|
throw new Error("Seed peers cannot contain credentials or fragments.");
|
|
87
116
|
}
|
|
88
|
-
if (input.smtp) {
|
|
89
|
-
safeAtom("smtp.host", input.smtp.host);
|
|
90
|
-
boundedInteger("smtp.port", input.smtp.port, 1, 65535);
|
|
91
|
-
safeAtom("smtp.from", input.smtp.from);
|
|
92
|
-
if (input.smtp.user)
|
|
93
|
-
safeAtom("smtp.user", input.smtp.user);
|
|
94
|
-
}
|
|
95
117
|
if (input.backup) {
|
|
96
118
|
httpsOrigin("backup.endpoint", input.backup.endpoint);
|
|
97
119
|
for (const [name, value] of Object.entries(input.backup))
|
|
@@ -104,14 +126,12 @@ function validatePlatformSharedEnvironment(input, options = {}) {
|
|
|
104
126
|
const service = new URL(input.cloudflare.tunnelService);
|
|
105
127
|
if (service.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(service.hostname) || service.username || service.password || service.search || service.hash)
|
|
106
128
|
throw new Error("Cloudflare Tunnel service must be loopback HTTP.");
|
|
107
|
-
if (input.cloudflare.warp) {
|
|
108
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(input.cloudflare.warp.organization) || !/^[0-9a-f-]{36}$/i.test(input.cloudflare.warp.virtualNetworkId) || !/^[A-Za-z0-9_-]{1,128}$/.test(input.cloudflare.warp.deviceProfileId)) {
|
|
109
|
-
throw new Error("Cloudflare WARP organization, VNET or device profile is malformed.");
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
129
|
}
|
|
113
|
-
if (
|
|
114
|
-
|
|
130
|
+
if (input.realtime) {
|
|
131
|
+
input.realtime.endpoint = httpsOrigin("realtime.endpoint", input.realtime.endpoint);
|
|
132
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(input.realtime.workerScriptName) || !/^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$/.test(input.realtime.producer)) {
|
|
133
|
+
throw new Error("realtime.producer is invalid.");
|
|
134
|
+
}
|
|
115
135
|
}
|
|
116
136
|
return {
|
|
117
137
|
...input,
|
|
@@ -166,10 +186,12 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
166
186
|
FZ_PROFILE: value.deployProfile,
|
|
167
187
|
FZ_REPO: value.repository,
|
|
168
188
|
FZ_BRANCH: value.branch,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
FZ_EMAIL_PROVIDER: value.email?.provider ?? "",
|
|
190
|
+
FZ_SMTP_HOST: value.email?.provider === "smtp" ? value.email.host : "",
|
|
191
|
+
FZ_SMTP_PORT: value.email?.provider === "smtp" ? String(value.email.port) : "",
|
|
192
|
+
FZ_SMTP_USER: value.email?.provider === "smtp" ? value.email.user : "",
|
|
193
|
+
FZ_EMAIL_FROM: value.email?.from ?? "",
|
|
194
|
+
FZ_JETEMAIL_EU: value.email?.provider === "jetemail" ? String(value.email.eu) : "",
|
|
173
195
|
BACKUP_S3_ENDPOINT: value.backup?.endpoint ?? "",
|
|
174
196
|
BACKUP_S3_REGION: value.backup?.region ?? "",
|
|
175
197
|
BACKUP_S3_BUCKET: value.backup?.bucket ?? "",
|
|
@@ -179,9 +201,9 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
179
201
|
FZ_CF_KV_NAMESPACE_ID: value.cloudflare?.kvNamespaceId ?? "",
|
|
180
202
|
FZ_CF_TUNNEL_ID: value.cloudflare?.tunnelId ?? "",
|
|
181
203
|
FZ_CF_TUNNEL_SERVICE: value.cloudflare?.tunnelService ?? "",
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
204
|
+
FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
|
|
205
|
+
FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
|
|
206
|
+
FZ_REALTIME_PRODUCER: value.realtime?.producer ?? ""
|
|
185
207
|
};
|
|
186
208
|
return `# Generated by fz bootstrap platform. Non-secret coordinates only.
|
|
187
209
|
` + Object.entries(entries).map(([key, entry]) => `${key}=${systemdValue(key, entry)}`).join(`
|
|
@@ -190,9 +212,11 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
190
212
|
}
|
|
191
213
|
function platformApiCredentialSpecs(options) {
|
|
192
214
|
const optional = [
|
|
193
|
-
["bootstrap-smtp-password", options.smtp],
|
|
194
|
-
["
|
|
195
|
-
["
|
|
215
|
+
["bootstrap-smtp-password", options.emailProvider === "smtp"],
|
|
216
|
+
["bootstrap-jetemail-api-key", options.emailProvider === "jetemail"],
|
|
217
|
+
["CF_API_TOKEN", options.cloudflareKv],
|
|
218
|
+
["REALTIME_PUBLISH_SECRET", options.realtime],
|
|
219
|
+
["REALTIME_TICKET_SECRET", options.realtime]
|
|
196
220
|
];
|
|
197
221
|
return [
|
|
198
222
|
{ name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
|
|
@@ -299,7 +323,12 @@ function planPlatformActivation(releasesDirectory, releasePath) {
|
|
|
299
323
|
if (!releasePath.startsWith(`${root}/`) || releasePath === root || releasePath.includes("/../")) {
|
|
300
324
|
throw new Error("Release must be an immutable child of the releases directory.");
|
|
301
325
|
}
|
|
302
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
command: "/usr/local/lib/forgezero/agent/fz-agent",
|
|
328
|
+
argv: ["platform-activate", "--config=/etc/forgezero/deploy-activation.json", releasePath],
|
|
329
|
+
runAs: "root",
|
|
330
|
+
invoker: "forgezero-runner"
|
|
331
|
+
};
|
|
303
332
|
}
|
|
304
333
|
function renderPlatformActivationFiles(input) {
|
|
305
334
|
if (!input.root.startsWith("/") || /[\0\r\n]/.test(input.root))
|
|
@@ -326,43 +355,136 @@ function renderPlatformActivationFiles(input) {
|
|
|
326
355
|
].join(`
|
|
327
356
|
`) + `
|
|
328
357
|
`;
|
|
329
|
-
const helper =
|
|
330
|
-
set -Eeuo pipefail
|
|
331
|
-
source /etc/forgezero/deploy.env
|
|
332
|
-
[[ $# == 1 ]] || { echo "usage: forgezero-activate <release>" >&2; exit 2; }
|
|
333
|
-
release="$(realpath -e "$1")"; releases="$(realpath -e "$FZ_DIR/releases")"; slots="$FZ_DIR/slots"
|
|
334
|
-
install -d -o root -g root -m 0755 "$slots"
|
|
335
|
-
case "$release/" in "$releases"/*/) ;; *) echo "release is outside $releases" >&2; exit 2 ;; esac
|
|
336
|
-
[[ -f "$release/.fz/deploy.json" && -s "$release/src/index.ts" && -s "$release/bun.lock" ]] || { echo "release is incomplete" >&2; exit 2; }
|
|
337
|
-
slot_file="$FZ_DIR/.forge-slot"; previous_slot="$(cat "$slot_file" 2>/dev/null || true)"
|
|
338
|
-
if [[ "$previous_slot" == blue ]]; then target=green; port="$FZ_GREEN_PORT"; else target=blue; port="$FZ_BLUE_PORT"; fi
|
|
339
|
-
target_link="$slots/$target"; previous_target_link="$(readlink -f "$target_link" 2>/dev/null || true)"
|
|
340
|
-
chown -R root:"$FZ_USER" "$release"; chmod -R a-w "$release"; find "$release" -type d -exec chmod a+rx {} +; find "$release" -type f -exec chmod a+r {} +
|
|
341
|
-
ln -sfn "$release" "$target_link"; systemctl restart "forgezero@\${target}.service"
|
|
342
|
-
healthy=0; for _ in $(seq 1 30); do curl -fsS --max-time 2 "http://127.0.0.1:\${port}\${FZ_HEALTH_PATH}" >/dev/null 2>&1 && { healthy=1; break; }; sleep 1; done
|
|
343
|
-
if (( ! healthy )); then systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; exit 1; fi
|
|
344
|
-
upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
|
|
345
|
-
printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
|
|
346
|
-
if ! nginx -t || ! nginx -s reload; then [[ -s "$backup" ]] && cp "$backup" "$upstream" || rm -f "$upstream"; rm -f "$backup"; systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; nginx -t >/dev/null 2>&1 && nginx -s reload || true; exit 1; fi
|
|
347
|
-
rm -f "$backup"; printf '%s\\n' "$target" >"$slot_file"
|
|
348
|
-
# New nginx workers select the new slot after reload. Keep the old slot alive
|
|
349
|
-
# while old workers drain in-flight requests and upgraded connections.
|
|
350
|
-
if [[ -n "$previous_slot" && "$previous_slot" != "$target" ]]; then
|
|
351
|
-
sleep_seconds="$(( (FZ_DRAIN_DEADLINE_MS + 999) / 1000 ))"
|
|
352
|
-
sleep "$sleep_seconds"
|
|
353
|
-
systemctl stop "forgezero@\${previous_slot}.service" || true
|
|
354
|
-
fi
|
|
355
|
-
mapfile -t old < <(find "$releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -rn | tail -n "+$((FZ_KEEP_RELEASES + 1))" | cut -d' ' -f2-)
|
|
356
|
-
for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
|
|
357
|
-
printf 'promoted %s on %s\\n' "$release" "$target"
|
|
358
|
+
const helper = `${JSON.stringify({ ...input, drainDeadlineMs }, null, 2)}
|
|
358
359
|
`;
|
|
359
360
|
return {
|
|
360
361
|
environment,
|
|
361
362
|
helper,
|
|
362
|
-
sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/
|
|
363
|
+
sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/lib/forgezero/agent/fz-agent platform-activate --config=/etc/forgezero/deploy-activation.json *
|
|
363
364
|
`
|
|
364
365
|
};
|
|
365
366
|
}
|
|
367
|
+
var platformExec = async (argv) => {
|
|
368
|
+
const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } });
|
|
369
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
370
|
+
new Response(child.stdout).text(),
|
|
371
|
+
new Response(child.stderr).text(),
|
|
372
|
+
child.exited
|
|
373
|
+
]);
|
|
374
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
375
|
+
};
|
|
376
|
+
var replaceLink = (path, target) => {
|
|
377
|
+
const pending = `${path}.next`;
|
|
378
|
+
rmSync(pending, { force: true });
|
|
379
|
+
if (!target) {
|
|
380
|
+
rmSync(path, { force: true });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
symlinkSync(target, pending);
|
|
384
|
+
renameSync(pending, path);
|
|
385
|
+
};
|
|
386
|
+
var secureRelease = (path, uid, gid) => {
|
|
387
|
+
const visit = (current) => {
|
|
388
|
+
const metadata = lstatSync(current);
|
|
389
|
+
if (metadata.isSymbolicLink())
|
|
390
|
+
throw new Error("release contains a symbolic link");
|
|
391
|
+
chownSync(current, uid, gid);
|
|
392
|
+
chmodSync(current, metadata.isDirectory() ? 365 : 292);
|
|
393
|
+
if (metadata.isDirectory())
|
|
394
|
+
for (const name of readdirSync(current))
|
|
395
|
+
visit(join(current, name));
|
|
396
|
+
};
|
|
397
|
+
visit(path);
|
|
398
|
+
};
|
|
399
|
+
async function activatePlatformRelease(config, requestedRelease, options = {}) {
|
|
400
|
+
const rendered = renderPlatformActivationFiles(config);
|
|
401
|
+
const normalized = JSON.parse(rendered.helper);
|
|
402
|
+
const releases = realpathSync(join(normalized.root, "releases"));
|
|
403
|
+
const release = realpathSync(requestedRelease);
|
|
404
|
+
if (!release.startsWith(`${releases}/`) || release === releases)
|
|
405
|
+
throw new Error("release is outside configured releases directory");
|
|
406
|
+
for (const required of [".fz/deploy.json", "src/index.ts", "bun.lock"]) {
|
|
407
|
+
const metadata = statSync(join(release, required));
|
|
408
|
+
if (!metadata.isFile() || metadata.size < 1)
|
|
409
|
+
throw new Error(`release is incomplete: ${required}`);
|
|
410
|
+
}
|
|
411
|
+
const exec = options.exec ?? platformExec;
|
|
412
|
+
const request = options.fetch ?? fetch;
|
|
413
|
+
const sleep = options.sleep ?? Bun.sleep;
|
|
414
|
+
const slots = join(normalized.root, "slots");
|
|
415
|
+
mkdirSync(slots, { recursive: true, mode: 493 });
|
|
416
|
+
const slotFile = join(normalized.root, ".forge-slot");
|
|
417
|
+
const previousSlot = existsSync(slotFile) ? readFileSync(slotFile, "utf8").trim() : undefined;
|
|
418
|
+
const target = previousSlot === "blue" ? "green" : "blue";
|
|
419
|
+
const port = target === "blue" ? normalized.bluePort : normalized.greenPort;
|
|
420
|
+
const targetLink = join(slots, target);
|
|
421
|
+
let previousTarget;
|
|
422
|
+
try {
|
|
423
|
+
previousTarget = realpathSync(targetLink);
|
|
424
|
+
} catch {}
|
|
425
|
+
const user = await exec(["/usr/bin/id", "-u", normalized.serviceUser]);
|
|
426
|
+
const group = await exec(["/usr/bin/id", "-g", normalized.serviceUser]);
|
|
427
|
+
if (user.exitCode !== 0 || group.exitCode !== 0)
|
|
428
|
+
throw new Error("activation service identity does not exist");
|
|
429
|
+
secureRelease(release, Number(user.output.trim()), Number(group.output.trim()));
|
|
430
|
+
replaceLink(targetLink, release);
|
|
431
|
+
const service = `forgezero@${target}.service`;
|
|
432
|
+
const restart = await exec(["/usr/bin/systemctl", "restart", service]);
|
|
433
|
+
if (restart.exitCode !== 0) {
|
|
434
|
+
replaceLink(targetLink, previousTarget);
|
|
435
|
+
throw new Error("target slot failed to start");
|
|
436
|
+
}
|
|
437
|
+
let healthy = false;
|
|
438
|
+
for (let attempt = 0;attempt < 30; attempt += 1) {
|
|
439
|
+
try {
|
|
440
|
+
const response = await request(`http://127.0.0.1:${port}${normalized.healthPath}`, { signal: AbortSignal.timeout(2000) });
|
|
441
|
+
healthy = response.ok;
|
|
442
|
+
} catch {}
|
|
443
|
+
if (healthy)
|
|
444
|
+
break;
|
|
445
|
+
await sleep(1000);
|
|
446
|
+
}
|
|
447
|
+
const stopTarget = async () => {
|
|
448
|
+
await exec(["/usr/bin/systemctl", "stop", service]);
|
|
449
|
+
replaceLink(targetLink, previousTarget);
|
|
450
|
+
};
|
|
451
|
+
if (!healthy) {
|
|
452
|
+
await stopTarget();
|
|
453
|
+
throw new Error("target slot failed its health deadline");
|
|
454
|
+
}
|
|
455
|
+
const upstream = "/etc/nginx/conf.d/forgezero-upstream.conf";
|
|
456
|
+
const backup = `${upstream}.forgezero-backup`;
|
|
457
|
+
if (existsSync(upstream))
|
|
458
|
+
copyFileSync(upstream, backup);
|
|
459
|
+
else
|
|
460
|
+
rmSync(backup, { force: true });
|
|
461
|
+
writeFileSync(upstream, `upstream forgezero { server 127.0.0.1:${port}; }
|
|
462
|
+
`, { mode: 420 });
|
|
463
|
+
const test = await exec(["/usr/sbin/nginx", "-t"]);
|
|
464
|
+
const reload = test.exitCode === 0 ? await exec(["/usr/sbin/nginx", "-s", "reload"]) : test;
|
|
465
|
+
if (reload.exitCode !== 0) {
|
|
466
|
+
if (existsSync(backup))
|
|
467
|
+
renameSync(backup, upstream);
|
|
468
|
+
else
|
|
469
|
+
rmSync(upstream, { force: true });
|
|
470
|
+
await exec(["/usr/sbin/nginx", "-t"]);
|
|
471
|
+
await exec(["/usr/sbin/nginx", "-s", "reload"]);
|
|
472
|
+
await stopTarget();
|
|
473
|
+
throw new Error("nginx refused the promoted upstream");
|
|
474
|
+
}
|
|
475
|
+
rmSync(backup, { force: true });
|
|
476
|
+
writeFileSync(slotFile, `${target}
|
|
477
|
+
`, { mode: 420 });
|
|
478
|
+
if (previousSlot && previousSlot !== target) {
|
|
479
|
+
await sleep(normalized.drainDeadlineMs);
|
|
480
|
+
await exec(["/usr/bin/systemctl", "stop", `forgezero@${previousSlot}.service`]);
|
|
481
|
+
}
|
|
482
|
+
const old = readdirSync(releases, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(releases, entry.name)).sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs).slice(normalized.keepReleases);
|
|
483
|
+
for (const path of old)
|
|
484
|
+
if (path !== release)
|
|
485
|
+
rmSync(path, { recursive: true, force: true });
|
|
486
|
+
return { release, slot: target };
|
|
487
|
+
}
|
|
366
488
|
function validateCollectorUnit(unit) {
|
|
367
489
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}\.service$/.test(unit))
|
|
368
490
|
throw new Error("Invalid OTLP collector service unit.");
|
|
@@ -407,5 +529,6 @@ export {
|
|
|
407
529
|
renderPlatformActivationFiles,
|
|
408
530
|
platformApiCredentialSpecs,
|
|
409
531
|
planPlatformActivation,
|
|
410
|
-
planLocalOtlpProof
|
|
532
|
+
planLocalOtlpProof,
|
|
533
|
+
activatePlatformRelease
|
|
411
534
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type BootstrapStatus } from './bootstrap';
|
|
2
|
+
export interface PlatformFleetVerificationRuntime {
|
|
3
|
+
uid(): number;
|
|
4
|
+
bootstrapStatus(): Promise<BootstrapStatus>;
|
|
5
|
+
characterDevice(path: string): boolean;
|
|
6
|
+
exec(argv: readonly string[]): Promise<{
|
|
7
|
+
exitCode: number;
|
|
8
|
+
output: string;
|
|
9
|
+
}>;
|
|
10
|
+
}
|
|
11
|
+
export interface PlatformFleetVerificationResult {
|
|
12
|
+
ok: true;
|
|
13
|
+
agentVersion: string;
|
|
14
|
+
bootstrap: BootstrapStatus;
|
|
15
|
+
sevGuest: true;
|
|
16
|
+
failedUnits: readonly never[];
|
|
17
|
+
}
|
|
18
|
+
/** One closed, read-only host proof used by the operator fleet rehearsal. */
|
|
19
|
+
export declare function verifyPlatformFleetHost(expectedVersion: string, runtime?: PlatformFleetVerificationRuntime): Promise<PlatformFleetVerificationResult>;
|