@forgezero/agent 0.1.33 → 0.1.35

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.
@@ -0,0 +1,161 @@
1
+ /** Typed, side-effect-free host runtime plans used by `fz bootstrap platform`. */
2
+ export type PlatformSoftwareProfile = 'platform-db-api' | 'platform-api';
3
+ export type PlatformDatabaseRole = 'master' | 'joiner' | 'none';
4
+ export type ApiSlot = 'blue' | 'green';
5
+ export interface PlatformSharedEnvironment {
6
+ softwareProfile: PlatformSoftwareProfile;
7
+ databaseRole: PlatformDatabaseRole;
8
+ databaseCoordinators: string[];
9
+ databaseAddress?: string;
10
+ databaseMaster?: string;
11
+ databaseNetworkMode: 'private-lan' | 'cloudflare-warp';
12
+ databaseReplicationFactor: number;
13
+ databaseWriteConcern: number;
14
+ databaseUser: string;
15
+ nodeHostname: string;
16
+ nodeRegion: string;
17
+ nodeRole: 'guest';
18
+ appOrigin: string;
19
+ apiOrigin: string;
20
+ publicApiPort: number;
21
+ sharedDirectory: string;
22
+ seedSyncPeers: string[];
23
+ seedSyncMembers: number;
24
+ seedSyncEpoch: string;
25
+ concurrencyLimit: number;
26
+ drainDeadlineMs: number;
27
+ otlpEndpoint: 'http://127.0.0.1:4318';
28
+ otlpCollectorUnit: string;
29
+ agentOtlpEndpoint: string;
30
+ otlpFlushIntervalMs: number;
31
+ otlpTraceSampleRatio: number;
32
+ custodianEmail?: string;
33
+ smtp?: {
34
+ host: string;
35
+ port: number;
36
+ user?: string;
37
+ from: string;
38
+ };
39
+ backup?: {
40
+ endpoint: string;
41
+ region: string;
42
+ bucket: string;
43
+ accessKeyId: string;
44
+ };
45
+ cloudflare?: {
46
+ accountId: string;
47
+ zoneId: string;
48
+ kvNamespaceId: string;
49
+ tunnelId: string;
50
+ tunnelService: string;
51
+ warp?: {
52
+ organization: string;
53
+ virtualNetworkId: string;
54
+ deviceProfileId: string;
55
+ };
56
+ };
57
+ repository: string;
58
+ branch: string;
59
+ deployProfile: string;
60
+ }
61
+ export declare function validatePlatformSharedEnvironment(input: PlatformSharedEnvironment, options?: {
62
+ allowPendingCloudflareHandoff?: boolean;
63
+ }): PlatformSharedEnvironment;
64
+ /** Render only non-secret runtime coordinates. Passwords/tokens have no field in this contract. */
65
+ export declare function renderPlatformSharedEnvironment(input: PlatformSharedEnvironment): string;
66
+ export interface SystemdCredentialSpec {
67
+ name: 'arangodb-jwt' | 'seed-sync-root' | 'bootstrap-smtp-password' | 'cloudflare-kv-token' | 'cloudflare-network-token';
68
+ encryptedPath: string;
69
+ required: boolean;
70
+ }
71
+ export declare function platformApiCredentialSpecs(options: {
72
+ smtp: boolean;
73
+ cloudflareKv: boolean;
74
+ cloudflareNetwork: boolean;
75
+ }): SystemdCredentialSpec[];
76
+ export interface ApiRuntimeRenderOptions {
77
+ serviceUser: string;
78
+ sharedDirectory: string;
79
+ sharedEnvironmentFile: string;
80
+ slotsDirectory: string;
81
+ bluePort: number;
82
+ greenPort: number;
83
+ collectorUnit: string;
84
+ credentials: SystemdCredentialSpec[];
85
+ }
86
+ export declare function renderPlatformApiUnits(input: ApiRuntimeRenderOptions): {
87
+ template: string;
88
+ dropIns: Record<ApiSlot, string>;
89
+ };
90
+ export declare function renderPlatformNginx(input: {
91
+ publicPort: number;
92
+ initialSlotPort: number;
93
+ }): {
94
+ upstream: string;
95
+ site: string;
96
+ };
97
+ export interface ActivationBoundary {
98
+ command: '/usr/local/libexec/forgezero-activate';
99
+ argv: [string];
100
+ runAs: 'root';
101
+ invoker: 'forgezero-runner';
102
+ }
103
+ /** The caller passes an immutable release path, never a shell command. */
104
+ export declare function planPlatformActivation(releasesDirectory: string, releasePath: string): ActivationBoundary;
105
+ export interface PlatformActivationConfig {
106
+ root: string;
107
+ serviceUser: string;
108
+ bluePort: number;
109
+ greenPort: number;
110
+ healthPath: string;
111
+ keepReleases: number;
112
+ }
113
+ /**
114
+ * Render the fixed privilege boundary used by the credential-free deployment
115
+ * runner. Repository data supplies one release path; it never supplies shell.
116
+ */
117
+ export declare function renderPlatformActivationFiles(input: PlatformActivationConfig): {
118
+ environment: string;
119
+ helper: string;
120
+ sudoers: string;
121
+ };
122
+ export interface LocalOtlpProofPlan {
123
+ unitCheck: {
124
+ command: 'systemctl';
125
+ argv: ['is-active', '--quiet', string];
126
+ };
127
+ receiverCheck: {
128
+ command: 'curl';
129
+ argv: string[];
130
+ acceptedStatus: '2xx';
131
+ };
132
+ }
133
+ export declare function planLocalOtlpProof(endpoint: string, collectorUnit: string): LocalOtlpProofPlan;
134
+ export type BringupStage = 'runtime-ready' | 'invite-prepared' | 'deploy-requested' | 'deploy-healthy' | 'invite-presented' | 'complete' | 'failed';
135
+ export interface PlatformBringupState {
136
+ requiresFirstInvite: boolean;
137
+ stage: BringupStage;
138
+ inviteTokenFilePresent: boolean;
139
+ lastError?: string;
140
+ }
141
+ export type PlatformBringupEvent = {
142
+ type: 'invite-prepared';
143
+ tokenFilePresent: boolean;
144
+ } | {
145
+ type: 'deploy-requested';
146
+ } | {
147
+ type: 'deploy-healthy';
148
+ } | {
149
+ type: 'invite-presented';
150
+ } | {
151
+ type: 'complete';
152
+ } | {
153
+ type: 'failed';
154
+ error: string;
155
+ };
156
+ export declare function initialPlatformBringupState(requiresFirstInvite: boolean): PlatformBringupState;
157
+ /** Strict persisted sequencing: genesis invite before deploy, presentation only after health. */
158
+ export declare function advancePlatformBringup(state: PlatformBringupState, event: PlatformBringupEvent): PlatformBringupState;
159
+ export type PlatformBringupAction = 'prepare-invite' | 'request-deploy' | 'await-deploy-health' | 'present-invite' | 'mark-complete' | 'none';
160
+ export declare function nextPlatformBringupAction(state: PlatformBringupState): PlatformBringupAction;
161
+ export declare function repairPlatformBringup(state: PlatformBringupState): PlatformBringupState;
@@ -0,0 +1,462 @@
1
+ // src/platform-bootstrap-runtime.ts
2
+ var safeAtom = (name, value) => {
3
+ if (!value || /[\0\r\n]/.test(value))
4
+ throw new Error(`${name} must be non-empty and single-line.`);
5
+ return value;
6
+ };
7
+ var boundedInteger = (name, value, minimum, maximum) => {
8
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
9
+ throw new Error(`${name} must be an integer from ${minimum} through ${maximum}.`);
10
+ }
11
+ return value;
12
+ };
13
+ var privateCoordinator = (raw) => {
14
+ const url = new URL(raw);
15
+ if (url.protocol !== "http:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
16
+ throw new Error("ArangoDB coordinator URLs must be credential-free private HTTP origins.");
17
+ }
18
+ const host = url.hostname.replace(/^\[|\]$/g, "");
19
+ const privateHost = host === "localhost" || host === "::1" || host.startsWith("fd") || host.startsWith("fc") || /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
20
+ if (!privateHost || url.port && url.port !== "8529") {
21
+ throw new Error("ArangoDB coordinators must use private addresses and port 8529.");
22
+ }
23
+ return url.origin;
24
+ };
25
+ var httpsOrigin = (name, raw) => {
26
+ const value = new URL(raw);
27
+ if (value.protocol !== "https:" || value.username || value.password || value.search || value.hash || value.pathname !== "/") {
28
+ throw new Error(`${name} must be a credential-free HTTPS origin.`);
29
+ }
30
+ return value.origin;
31
+ };
32
+ var systemdValue = (name, raw) => {
33
+ if (/[\0\r\n]/.test(raw))
34
+ throw new Error(`${name} must be single-line.`);
35
+ const value = raw;
36
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$")}"`;
37
+ };
38
+ function validatePlatformSharedEnvironment(input, options = {}) {
39
+ if (input.softwareProfile === "platform-api" !== (input.databaseRole === "none")) {
40
+ throw new Error("platform-api requires database role none; platform-db-api requires master or joiner.");
41
+ }
42
+ if (input.databaseCoordinators.length < 1 || input.databaseCoordinators.length > 16) {
43
+ throw new Error("databaseCoordinators must contain 1 through 16 endpoints.");
44
+ }
45
+ const coordinators = input.databaseCoordinators.map(privateCoordinator);
46
+ if (new Set(coordinators).size !== coordinators.length)
47
+ throw new Error("databaseCoordinators must be unique.");
48
+ if (!["private-lan", "cloudflare-warp"].includes(input.databaseNetworkMode)) {
49
+ throw new Error("Database networking must be private-lan or cloudflare-warp.");
50
+ }
51
+ boundedInteger("databaseReplicationFactor", input.databaseReplicationFactor, 1, 16);
52
+ boundedInteger("databaseWriteConcern", input.databaseWriteConcern, 1, 16);
53
+ if (input.databaseWriteConcern > input.databaseReplicationFactor) {
54
+ throw new Error("databaseWriteConcern cannot exceed databaseReplicationFactor.");
55
+ }
56
+ boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
57
+ boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
58
+ boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
59
+ boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
60
+ boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
61
+ if (!Number.isFinite(input.otlpTraceSampleRatio) || input.otlpTraceSampleRatio < 0 || input.otlpTraceSampleRatio > 1) {
62
+ throw new Error("otlpTraceSampleRatio must be from 0 through 1.");
63
+ }
64
+ if (input.otlpEndpoint !== "http://127.0.0.1:4318")
65
+ throw new Error("OTLP must use the exact local collector endpoint.");
66
+ validateCollectorUnit(input.otlpCollectorUnit);
67
+ httpsOrigin("agentOtlpEndpoint", input.agentOtlpEndpoint);
68
+ for (const [name, value] of Object.entries({
69
+ nodeHostname: input.nodeHostname,
70
+ nodeRegion: input.nodeRegion,
71
+ databaseUser: input.databaseUser,
72
+ sharedDirectory: input.sharedDirectory,
73
+ seedSyncEpoch: input.seedSyncEpoch,
74
+ repository: input.repository,
75
+ branch: input.branch,
76
+ deployProfile: input.deployProfile
77
+ }))
78
+ safeAtom(name, value);
79
+ if (!input.sharedDirectory.startsWith("/"))
80
+ throw new Error("sharedDirectory must be absolute.");
81
+ for (const peer of input.seedSyncPeers) {
82
+ const url = new URL(peer);
83
+ if (url.protocol !== "ws:" && url.protocol !== "wss:")
84
+ throw new Error("Seed peers must be WebSocket URLs.");
85
+ if (url.username || url.password || url.hash)
86
+ throw new Error("Seed peers cannot contain credentials or fragments.");
87
+ }
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
+ if (input.backup) {
96
+ httpsOrigin("backup.endpoint", input.backup.endpoint);
97
+ for (const [name, value] of Object.entries(input.backup))
98
+ safeAtom(`backup.${name}`, value);
99
+ }
100
+ if (input.cloudflare) {
101
+ if (![input.cloudflare.accountId, input.cloudflare.zoneId, input.cloudflare.kvNamespaceId].every((item) => /^[a-f0-9]{32}$/i.test(item)) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.cloudflare.tunnelId)) {
102
+ throw new Error("Cloudflare account, zone, KV and Tunnel ids are malformed.");
103
+ }
104
+ const service = new URL(input.cloudflare.tunnelService);
105
+ if (service.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(service.hostname) || service.username || service.password || service.search || service.hash)
106
+ 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
+ }
113
+ if (!options.allowPendingCloudflareHandoff && input.databaseNetworkMode === "cloudflare-warp" !== Boolean(input.cloudflare?.warp)) {
114
+ throw new Error("cloudflare-warp networking requires its exact enrolled Cloudflare coordinates.");
115
+ }
116
+ return {
117
+ ...input,
118
+ databaseCoordinators: coordinators,
119
+ appOrigin: httpsOrigin("appOrigin", input.appOrigin),
120
+ apiOrigin: httpsOrigin("apiOrigin", input.apiOrigin),
121
+ agentOtlpEndpoint: httpsOrigin("agentOtlpEndpoint", input.agentOtlpEndpoint)
122
+ };
123
+ }
124
+ function renderPlatformSharedEnvironment(input) {
125
+ const value = validatePlatformSharedEnvironment(input);
126
+ const appHost = new URL(value.appOrigin).hostname.split(".").slice(-2).join(".");
127
+ const apiHost = new URL(value.apiOrigin).hostname.split(".").slice(-2).join(".");
128
+ const entries = {
129
+ ARANGO_URL: value.databaseCoordinators[0],
130
+ ARANGO_URLS: value.databaseCoordinators.join(","),
131
+ ARANGO_DB: "fz",
132
+ FZ_DATABASE_MODE: "platform",
133
+ ARANGO_USER: value.databaseUser,
134
+ ARANGO_REPLICATION_FACTOR: String(value.databaseReplicationFactor),
135
+ ARANGO_WRITE_CONCERN: String(value.databaseWriteConcern),
136
+ FZ_DB_ROLE: value.databaseRole,
137
+ FZ_SOFTWARE_PROFILE: value.softwareProfile,
138
+ FZ_ROLE: value.nodeRole,
139
+ FZ_DB_ADDRESS: value.databaseAddress ?? "",
140
+ FZ_DB_MASTER: value.databaseMaster ?? "",
141
+ FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
142
+ FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
143
+ FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
144
+ FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
145
+ FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
146
+ FZ_SHARED_DIR: value.sharedDirectory,
147
+ FZ_PUBLIC_API_PORT: String(value.publicApiPort),
148
+ ORIGIN: value.appOrigin,
149
+ API_ORIGIN: value.apiOrigin,
150
+ HOST: "127.0.0.1",
151
+ APP_ORIGINS: value.appOrigin,
152
+ TRUST_CLOUDFLARE_IP: "1",
153
+ SESSION_COOKIE_SAMESITE: appHost === apiHost ? "lax" : "none",
154
+ SESSION_COOKIE_DOMAIN: "",
155
+ FZ_NODE_HOSTNAME: value.nodeHostname,
156
+ FZ_NODE_REGION: value.nodeRegion,
157
+ FZ_CONCURRENCY_LIMIT: String(value.concurrencyLimit),
158
+ FZ_DRAIN_DEADLINE_MS: String(value.drainDeadlineMs),
159
+ OTEL_EXPORTER_OTLP_ENDPOINT: value.otlpEndpoint,
160
+ FZ_OTLP_COLLECTOR_UNIT: value.otlpCollectorUnit,
161
+ OTEL_SERVICE_NAME: "forgezero-api",
162
+ FZ_OTLP_FLUSH_INTERVAL_MS: String(value.otlpFlushIntervalMs),
163
+ FZ_OTLP_TRACE_SAMPLE_RATIO: String(value.otlpTraceSampleRatio),
164
+ FZ_AGENT_OTLP_ENDPOINT: value.agentOtlpEndpoint,
165
+ FZ_CUSTODIAN_EMAIL: value.custodianEmail ?? "",
166
+ FZ_PROFILE: value.deployProfile,
167
+ FZ_REPO: value.repository,
168
+ FZ_BRANCH: value.branch,
169
+ FZ_SMTP_HOST: value.smtp?.host ?? "",
170
+ FZ_SMTP_PORT: value.smtp ? String(value.smtp.port) : "",
171
+ FZ_SMTP_USER: value.smtp?.user ?? "",
172
+ FZ_SMTP_FROM: value.smtp?.from ?? "",
173
+ BACKUP_S3_ENDPOINT: value.backup?.endpoint ?? "",
174
+ BACKUP_S3_REGION: value.backup?.region ?? "",
175
+ BACKUP_S3_BUCKET: value.backup?.bucket ?? "",
176
+ BACKUP_S3_ACCESS_KEY_ID: value.backup?.accessKeyId ?? "",
177
+ FZ_CF_ACCOUNT_ID: value.cloudflare?.accountId ?? "",
178
+ FZ_CF_ZONE_ID: value.cloudflare?.zoneId ?? "",
179
+ FZ_CF_KV_NAMESPACE_ID: value.cloudflare?.kvNamespaceId ?? "",
180
+ FZ_CF_TUNNEL_ID: value.cloudflare?.tunnelId ?? "",
181
+ FZ_CF_TUNNEL_SERVICE: value.cloudflare?.tunnelService ?? "",
182
+ FZ_WARP_ORGANIZATION: value.cloudflare?.warp?.organization ?? "",
183
+ FZ_CF_VIRTUAL_NETWORK_ID: value.cloudflare?.warp?.virtualNetworkId ?? "",
184
+ FZ_CF_WARP_POLICY_ID: value.cloudflare?.warp?.deviceProfileId ?? ""
185
+ };
186
+ return `# Generated by fz bootstrap platform. Non-secret coordinates only.
187
+ ` + Object.entries(entries).map(([key, entry]) => `${key}=${systemdValue(key, entry)}`).join(`
188
+ `) + `
189
+ `;
190
+ }
191
+ function platformApiCredentialSpecs(options) {
192
+ const optional = [
193
+ ["bootstrap-smtp-password", options.smtp],
194
+ ["cloudflare-kv-token", options.cloudflareKv],
195
+ ["cloudflare-network-token", options.cloudflareNetwork]
196
+ ];
197
+ return [
198
+ { name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
199
+ { name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
200
+ ...optional.filter(([, present]) => present).map(([name]) => ({
201
+ name,
202
+ encryptedPath: `/etc/forgezero/creds/${name}.cred`,
203
+ required: false
204
+ }))
205
+ ];
206
+ }
207
+ function renderPlatformApiUnits(input) {
208
+ for (const path of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
209
+ if (!path.startsWith("/") || /[\r\n]/.test(path))
210
+ throw new Error("Runtime paths must be absolute and single-line.");
211
+ }
212
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
213
+ throw new Error("Invalid service user.");
214
+ validateCollectorUnit(input.collectorUnit);
215
+ boundedInteger("bluePort", input.bluePort, 1024, 65535);
216
+ boundedInteger("greenPort", input.greenPort, 1024, 65535);
217
+ if (input.bluePort === input.greenPort)
218
+ throw new Error("Blue and green ports must differ.");
219
+ const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
220
+ `);
221
+ const template = `[Unit]
222
+ Description=ForgeZero (%i slot)
223
+ After=network-online.target ${input.collectorUnit}
224
+ Wants=network-online.target ${input.collectorUnit}
225
+
226
+ [Service]
227
+ Type=simple
228
+ User=${input.serviceUser}
229
+ WorkingDirectory=${input.slotsDirectory}/%i
230
+ Environment=NODE_ENV=production
231
+ Environment=FZ_SLOT=%i
232
+ EnvironmentFile=${input.sharedEnvironmentFile}
233
+ ${credentials}
234
+ ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
235
+ Restart=always
236
+ RestartSec=2
237
+ TimeoutStopSec=35s
238
+ LimitCORE=0
239
+ UMask=0077
240
+ NoNewPrivileges=yes
241
+ PrivateTmp=yes
242
+ PrivateDevices=yes
243
+ ProtectSystem=strict
244
+ ProtectHome=yes
245
+ ReadOnlyPaths=${input.sharedDirectory}
246
+ ProtectKernelTunables=yes
247
+ ProtectKernelModules=yes
248
+ ProtectControlGroups=yes
249
+ RestrictSUIDSGID=yes
250
+ RestrictRealtime=yes
251
+ LockPersonality=yes
252
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
253
+
254
+ [Install]
255
+ WantedBy=multi-user.target
256
+ `;
257
+ return { template, dropIns: {
258
+ blue: `[Service]
259
+ Environment=PORT=${input.bluePort}
260
+ `,
261
+ green: `[Service]
262
+ Environment=PORT=${input.greenPort}
263
+ `
264
+ } };
265
+ }
266
+ function renderPlatformNginx(input) {
267
+ boundedInteger("publicPort", input.publicPort, 1024, 65535);
268
+ boundedInteger("initialSlotPort", input.initialSlotPort, 1024, 65535);
269
+ if (input.publicPort === input.initialSlotPort)
270
+ throw new Error("Edge and slot ports must differ.");
271
+ return {
272
+ upstream: `upstream forgezero { server 127.0.0.1:${input.initialSlotPort}; }
273
+ `,
274
+ site: `server {
275
+ listen 127.0.0.1:${input.publicPort};
276
+ server_name _;
277
+ location ^~ /api/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
278
+ location ^~ /v1/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
279
+ location / { return 404; }
280
+ }
281
+ `
282
+ };
283
+ }
284
+ function planPlatformActivation(releasesDirectory, releasePath) {
285
+ if (!releasesDirectory.startsWith("/") || !releasePath.startsWith("/") || /[\0\r\n]/.test(releasePath)) {
286
+ throw new Error("Activation paths must be absolute and single-line.");
287
+ }
288
+ const root = releasesDirectory.replace(/\/+$/, "");
289
+ if (!releasePath.startsWith(`${root}/`) || releasePath === root || releasePath.includes("/../")) {
290
+ throw new Error("Release must be an immutable child of the releases directory.");
291
+ }
292
+ return { command: "/usr/local/libexec/forgezero-activate", argv: [releasePath], runAs: "root", invoker: "forgezero-runner" };
293
+ }
294
+ function renderPlatformActivationFiles(input) {
295
+ if (!input.root.startsWith("/") || /[\0\r\n]/.test(input.root))
296
+ throw new Error("Activation root must be absolute and single-line.");
297
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
298
+ throw new Error("Invalid activation service user.");
299
+ boundedInteger("bluePort", input.bluePort, 1024, 65535);
300
+ boundedInteger("greenPort", input.greenPort, 1024, 65535);
301
+ if (input.bluePort === input.greenPort)
302
+ throw new Error("Activation slot ports must differ.");
303
+ boundedInteger("keepReleases", input.keepReleases, 2, 100);
304
+ if (!/^\/[A-Za-z0-9/_-]{1,128}$/.test(input.healthPath) || input.healthPath.includes("..")) {
305
+ throw new Error("Activation health path is malformed.");
306
+ }
307
+ const environment = [
308
+ `FZ_DIR=${input.root}`,
309
+ `FZ_USER=${input.serviceUser}`,
310
+ `FZ_BLUE_PORT=${input.bluePort}`,
311
+ `FZ_GREEN_PORT=${input.greenPort}`,
312
+ `FZ_HEALTH_PATH=${input.healthPath}`,
313
+ `FZ_KEEP_RELEASES=${input.keepReleases}`
314
+ ].join(`
315
+ `) + `
316
+ `;
317
+ const helper = `#!/usr/bin/env bash
318
+ set -Eeuo pipefail
319
+ source /etc/forgezero/deploy.env
320
+ [[ $# == 1 ]] || { echo "usage: forgezero-activate <release>" >&2; exit 2; }
321
+ release="$(realpath -e "$1")"; releases="$(realpath -e "$FZ_DIR/releases")"; slots="$FZ_DIR/slots"
322
+ install -d -o root -g root -m 0755 "$slots"
323
+ case "$release/" in "$releases"/*/) ;; *) echo "release is outside $releases" >&2; exit 2 ;; esac
324
+ [[ -f "$release/.fz/deploy.json" && -s "$release/src/index.ts" && -s "$release/bun.lock" ]] || { echo "release is incomplete" >&2; exit 2; }
325
+ slot_file="$FZ_DIR/.forge-slot"; previous_slot="$(cat "$slot_file" 2>/dev/null || true)"
326
+ if [[ "$previous_slot" == blue ]]; then target=green; port="$FZ_GREEN_PORT"; else target=blue; port="$FZ_BLUE_PORT"; fi
327
+ target_link="$slots/$target"; previous_target_link="$(readlink -f "$target_link" 2>/dev/null || true)"
328
+ 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 {} +
329
+ ln -sfn "$release" "$target_link"; systemctl restart "forgezero@\${target}.service"
330
+ 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
331
+ 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
332
+ upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
333
+ printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
334
+ 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
335
+ rm -f "$backup"; printf '%s\\n' "$target" >"$slot_file"; [[ -n "$previous_slot" && "$previous_slot" != "$target" ]] && systemctl stop "forgezero@\${previous_slot}.service" || true
336
+ 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-)
337
+ for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
338
+ printf 'promoted %s on %s\\n' "$release" "$target"
339
+ `;
340
+ return {
341
+ environment,
342
+ helper,
343
+ sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/libexec/forgezero-activate *
344
+ `
345
+ };
346
+ }
347
+ function validateCollectorUnit(unit) {
348
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}\.service$/.test(unit))
349
+ throw new Error("Invalid OTLP collector service unit.");
350
+ if (/^(forgezero@.*|forgezero-agent|forgezero-metal-agent|forgezero-db)\.service$/.test(unit)) {
351
+ throw new Error("OTLP collector must be independently supervised.");
352
+ }
353
+ }
354
+ function planLocalOtlpProof(endpoint, collectorUnit) {
355
+ if (endpoint !== "http://127.0.0.1:4318")
356
+ throw new Error("OTLP proof requires exact loopback endpoint http://127.0.0.1:4318.");
357
+ validateCollectorUnit(collectorUnit);
358
+ return {
359
+ unitCheck: { command: "systemctl", argv: ["is-active", "--quiet", collectorUnit] },
360
+ receiverCheck: {
361
+ command: "curl",
362
+ acceptedStatus: "2xx",
363
+ argv: [
364
+ "--silent",
365
+ "--show-error",
366
+ "--max-time",
367
+ "5",
368
+ "--output",
369
+ "/dev/null",
370
+ "--write-out",
371
+ "%{http_code}",
372
+ "--request",
373
+ "POST",
374
+ "--header",
375
+ "Content-Type: application/json",
376
+ "--data-binary",
377
+ "{}",
378
+ `${endpoint}/v1/metrics`
379
+ ]
380
+ }
381
+ };
382
+ }
383
+ function initialPlatformBringupState(requiresFirstInvite) {
384
+ return { requiresFirstInvite, stage: "runtime-ready", inviteTokenFilePresent: false };
385
+ }
386
+ function advancePlatformBringup(state, event) {
387
+ if (state.stage === "failed")
388
+ throw new Error("A failed bring-up must be explicitly repaired before resuming.");
389
+ if (event.type === "failed")
390
+ return { ...state, stage: "failed", lastError: safeAtom("error", event.error) };
391
+ if (event.type === state.stage)
392
+ return state;
393
+ if (event.type === "invite-prepared") {
394
+ if (!state.requiresFirstInvite || state.stage !== "runtime-ready" || !event.tokenFilePresent) {
395
+ throw new Error("The first invite must be durably present before it is recorded.");
396
+ }
397
+ return { ...state, stage: "invite-prepared", inviteTokenFilePresent: true };
398
+ }
399
+ if (event.type === "deploy-requested") {
400
+ const expected = state.requiresFirstInvite ? "invite-prepared" : "runtime-ready";
401
+ if (state.stage !== expected || state.requiresFirstInvite && !state.inviteTokenFilePresent) {
402
+ throw new Error("Deployment cannot start before required invite preparation.");
403
+ }
404
+ return { ...state, stage: "deploy-requested" };
405
+ }
406
+ if (event.type === "deploy-healthy") {
407
+ if (state.stage !== "deploy-requested")
408
+ throw new Error("Deployment health requires a requested deployment.");
409
+ return { ...state, stage: "deploy-healthy" };
410
+ }
411
+ if (event.type === "invite-presented") {
412
+ if (!state.requiresFirstInvite || state.stage !== "deploy-healthy" || !state.inviteTokenFilePresent) {
413
+ throw new Error("Invite presentation requires a healthy deployment and the original token file.");
414
+ }
415
+ return { ...state, stage: "invite-presented" };
416
+ }
417
+ if (event.type === "complete") {
418
+ const expected = state.requiresFirstInvite ? "invite-presented" : "deploy-healthy";
419
+ if (state.stage !== expected)
420
+ throw new Error("Bring-up cannot complete before its ordered ceremony.");
421
+ return { ...state, stage: "complete" };
422
+ }
423
+ throw new Error("Unsupported bring-up transition.");
424
+ }
425
+ function nextPlatformBringupAction(state) {
426
+ if (state.stage === "failed" || state.stage === "complete")
427
+ return "none";
428
+ if (state.stage === "runtime-ready")
429
+ return state.requiresFirstInvite ? "prepare-invite" : "request-deploy";
430
+ if (state.stage === "invite-prepared") {
431
+ if (!state.inviteTokenFilePresent)
432
+ throw new Error("Recorded invite is missing; refusing implicit rotation.");
433
+ return "request-deploy";
434
+ }
435
+ if (state.stage === "deploy-requested")
436
+ return "await-deploy-health";
437
+ if (state.stage === "deploy-healthy")
438
+ return state.requiresFirstInvite ? "present-invite" : "mark-complete";
439
+ return "mark-complete";
440
+ }
441
+ function repairPlatformBringup(state) {
442
+ if (state.stage !== "failed")
443
+ return state;
444
+ if (state.requiresFirstInvite && !state.inviteTokenFilePresent) {
445
+ throw new Error("Missing issued invite cannot be repaired by implicit rotation.");
446
+ }
447
+ return { ...state, stage: state.inviteTokenFilePresent ? "invite-prepared" : "runtime-ready", lastError: undefined };
448
+ }
449
+ export {
450
+ validatePlatformSharedEnvironment,
451
+ repairPlatformBringup,
452
+ renderPlatformSharedEnvironment,
453
+ renderPlatformNginx,
454
+ renderPlatformApiUnits,
455
+ renderPlatformActivationFiles,
456
+ platformApiCredentialSpecs,
457
+ planPlatformActivation,
458
+ planLocalOtlpProof,
459
+ nextPlatformBringupAction,
460
+ initialPlatformBringupState,
461
+ advancePlatformBringup
462
+ };
package/dist/provision.js CHANGED
@@ -884,7 +884,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
884
884
  }
885
885
 
886
886
  // src/version.ts
887
- var VERSION3 = "0.1.33";
887
+ var VERSION3 = "0.1.35";
888
888
 
889
889
  // src/egress-policy.ts
890
890
  import { realpathSync } from "node:fs";
@@ -1476,6 +1476,7 @@ function agentUnit(options) {
1476
1476
  options.profile ? `FZ_DEPLOY_PROFILE=${options.profile}` : null,
1477
1477
  options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
1478
1478
  deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
1479
+ deploymentEnabled ? `FZ_CAPACITY_EVIDENCE_DIR=${deployRoot}/capacity` : null,
1479
1480
  deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
1480
1481
  deploymentEnabled ? `FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}` : null,
1481
1482
  Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
@@ -1494,7 +1495,7 @@ function agentUnit(options) {
1494
1495
  ` : "";
1495
1496
  const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
1496
1497
  `);
1497
- const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
1498
+ const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache ${deployRoot}/capacity` : "";
1498
1499
  const supplementaryGroups = [
1499
1500
  AGENT_UPDATE_GROUP,
1500
1501
  deploymentEnabled ? DEPLOYMENT_GROUP : null,
@@ -1742,12 +1743,12 @@ function planProvision(options) {
1742
1743
  },
1743
1744
  {
1744
1745
  label: "encrypted one-time enrolment capability",
1745
- command: `test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
1746
+ command: `test -s ${enrolStatePath} || test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
1746
1747
  }
1747
1748
  ] : [],
1748
1749
  ...deploymentEnabled ? [{
1749
1750
  label: "deployment directories",
1750
- command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
1751
+ command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/capacity && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
1751
1752
  }] : [],
1752
1753
  { label: "reload units", command: "systemctl daemon-reload" },
1753
1754
  ...deploymentEnabled ? [{
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.33";
2
+ export declare const VERSION = "0.1.35";