@forgezero/agent 0.1.34 → 0.1.36
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 +50 -0
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +133 -0
- package/dist/bootstrap.js +3416 -0
- package/dist/cli/agent-install.d.ts +3 -0
- package/dist/cli/cloudflare-bootstrap.d.ts +11 -0
- package/dist/cloudflare-bootstrap.d.ts +218 -0
- package/dist/cloudflare-bootstrap.js +1196 -0
- package/dist/cloudflare-edge.d.ts +265 -0
- package/dist/cloudflare-edge.js +424 -0
- package/dist/definition.js +9 -2
- package/dist/deploy-file.js +9 -2
- package/dist/fz-agent.js +492 -150
- package/dist/fz.js +3833 -262
- package/dist/index.d.ts +2 -0
- package/dist/metal-bootstrap.d.ts +52 -0
- package/dist/metal-bootstrap.js +942 -0
- package/dist/platform-bootstrap-runtime.d.ts +161 -0
- package/dist/platform-bootstrap-runtime.js +462 -0
- package/dist/provision.d.ts +4 -0
- package/dist/provision.js +34 -6
- package/dist/software-helper.js +9 -2
- package/dist/software.d.ts +3 -1
- package/dist/software.js +12 -3
- package/dist/ssh-bootstrap.d.ts +72 -0
- package/dist/version.d.ts +1 -1
- package/package.json +22 -2
- package/schema/deploy-v2.json +1 -1
|
@@ -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.d.ts
CHANGED
|
@@ -100,6 +100,10 @@ export interface UnitOptions {
|
|
|
100
100
|
runnerPublicTcpPorts?: readonly number[];
|
|
101
101
|
/** Enable PQ-authenticated lifecycle claims through a constrained root helper. */
|
|
102
102
|
pullMigrations?: boolean;
|
|
103
|
+
/** Claim API-owned SSH bootstrap jobs; the private key stays a systemd credential on this runner. */
|
|
104
|
+
pullBootstrap?: boolean;
|
|
105
|
+
bootstrapSshCredentialPath?: string;
|
|
106
|
+
bootstrapTargetTelemetryEndpoint?: string;
|
|
103
107
|
/** Root-owned declarative lifecycle profile; never supplied by a migration claim. */
|
|
104
108
|
lifecycleProfilePath?: string;
|
|
105
109
|
lifecycleHelperSocketPath?: string;
|