@auersperg/flare 0.3.2 → 0.3.5
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 +10 -4
- package/dist/cli/{chunk-x14smx1y.js → chunk-5s6zv1h9.js} +1 -1
- package/dist/cli/{chunk-5cq8wgte.js → chunk-5yg1zj5n.js} +48 -29
- package/dist/cli/{chunk-786w6vrj.js → chunk-fv98dfg5.js} +21 -1
- package/dist/cli/chunk-r3ceakrn.js +1366 -0
- package/dist/cli/flare.js +122 -4
- package/dist/platform/admin.js +790 -0
- package/dist/platform/dashboard/_astro/auth-view.UTGGCEHc.js +1 -0
- package/dist/platform/dashboard/_astro/cli-approval.BZAWmpiR.js +1 -0
- package/dist/platform/dashboard/_astro/client.BUUZgEFj.js +1 -0
- package/dist/platform/dashboard/_astro/client.Bcl1NrNO.js +1 -0
- package/dist/platform/dashboard/_astro/client.BeKNsjPN.js +9 -0
- package/dist/platform/dashboard/_astro/common.DPoFQu44.js +1 -0
- package/dist/platform/dashboard/_astro/data-view.BgYyTCMN.js +1 -0
- package/dist/platform/dashboard/_astro/functions-view.PUkng3T1.js +1 -0
- package/dist/platform/dashboard/_astro/geist-cyrillic-ext-wght-normal.DjL33-gN.woff2 +0 -0
- package/dist/platform/dashboard/_astro/geist-cyrillic-wght-normal.BEAKL7Jp.woff2 +0 -0
- package/dist/platform/dashboard/_astro/geist-latin-ext-wght-normal.DC-KSUi6.woff2 +0 -0
- package/dist/platform/dashboard/_astro/geist-latin-wght-normal.BgDaEnEv.woff2 +0 -0
- package/dist/platform/dashboard/_astro/geist-vietnamese-wght-normal.6IgcOCM7.woff2 +0 -0
- package/dist/platform/dashboard/_astro/index.astro_astro_type_script_index_0_lang.BEuO3H0E.js +4 -0
- package/dist/platform/dashboard/_astro/index.lobU7uK5.css +2 -0
- package/dist/platform/dashboard/_astro/input-group.CJxPRHej.js +1 -0
- package/dist/platform/dashboard/_astro/sdk-view.COmbEZrF.js +1 -0
- package/dist/platform/dashboard/_astro/services-view.C34dy0dk.js +1 -0
- package/dist/platform/dashboard/_astro/settings-view.IuD2r5JW.js +3 -0
- package/dist/platform/dashboard/_headers +3 -0
- package/dist/platform/dashboard/bootstrap/action.js +263 -0
- package/dist/platform/dashboard/bootstrap/api.ts +5 -0
- package/dist/platform/dashboard/bootstrap/facet.js +914 -0
- package/dist/platform/dashboard/bootstrap/manifest.json +5 -0
- package/dist/platform/dashboard/bootstrap/openapi.json +18 -0
- package/dist/platform/dashboard/bootstrap/release.json +11 -0
- package/dist/platform/dashboard/bootstrap/schema.json +1 -0
- package/dist/platform/dashboard/index.html +1 -0
- package/dist/platform/manifest.json +195 -0
- package/dist/platform/migrations/0001.sql +8 -0
- package/dist/platform/migrations/0002_project_aliases.sql +6 -0
- package/dist/platform/migrations/0003_cli_login.sql +9 -0
- package/dist/platform/migrations/0004_retention.sql +4 -0
- package/dist/platform/migrations/0005_project_deletion.sql +11 -0
- package/dist/platform/migrations/0006_platform_installation.sql +8 -0
- package/dist/platform/runtime.js +9392 -0
- package/dist/sdk/client.js +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/flare/src/cli/platform.mjs
|
|
3
|
+
import { readFile as readFile3, mkdir as mkdir2, mkdtemp, writeFile as writeFile2, rm, cp } from "fs/promises";
|
|
4
|
+
import { join as join3 } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { randomBytes, randomUUID as randomUUID2 } from "crypto";
|
|
7
|
+
import { createInterface } from "readline/promises";
|
|
8
|
+
import { setTimeout as sleep2 } from "timers/promises";
|
|
9
|
+
|
|
10
|
+
// packages/flare/src/cli/cloudflare-account.mjs
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
13
|
+
|
|
14
|
+
// packages/flare/src/cli/platform-release.mjs
|
|
15
|
+
import { readFile, readdir, lstat } from "fs/promises";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
import { createHash } from "crypto";
|
|
19
|
+
var PLATFORM_FORMAT = 1;
|
|
20
|
+
var WRANGLER_VERSION = "4.149.0";
|
|
21
|
+
var sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
22
|
+
var manifestDigest = ({ digest: _digest, ...manifest }) => sha256(JSON.stringify(manifest));
|
|
23
|
+
async function payloadFiles(directory, prefix = "") {
|
|
24
|
+
const files = [];
|
|
25
|
+
for (const name of (await readdir(join(directory, prefix))).sort()) {
|
|
26
|
+
const path = prefix + name;
|
|
27
|
+
const stat = await lstat(join(directory, path));
|
|
28
|
+
if (stat.isSymbolicLink())
|
|
29
|
+
throw Error("Platform payload contains a symlink");
|
|
30
|
+
if (stat.isDirectory())
|
|
31
|
+
files.push(...await payloadFiles(directory, path + "/"));
|
|
32
|
+
else if (stat.isFile())
|
|
33
|
+
files.push(path);
|
|
34
|
+
else
|
|
35
|
+
throw Error("Unsupported platform payload entry");
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
async function loadPlatformRelease(directory = fileURLToPath(new URL("../platform/", import.meta.url))) {
|
|
40
|
+
let manifest;
|
|
41
|
+
try {
|
|
42
|
+
const bytes = await readFile(join(directory, "manifest.json"));
|
|
43
|
+
if (bytes.length > 512 * 1024)
|
|
44
|
+
throw Error("Oversized manifest");
|
|
45
|
+
manifest = JSON.parse(bytes);
|
|
46
|
+
} catch {
|
|
47
|
+
throw Error("Platform payload missing or invalid. Install a complete Flare npm release.");
|
|
48
|
+
}
|
|
49
|
+
if (manifest.format !== PLATFORM_FORMAT || !/^\d+\.\d+\.\d+$/.test(manifest.version ?? "") || manifest.protocol !== 1 || manifest.runtimeAbi !== 4 || manifest.stateFormat !== 1 || manifest.wranglerVersion !== WRANGLER_VERSION || !/^\d{4}-\d{2}-\d{2}$/.test(manifest.compatibilityDate ?? "") || !Array.isArray(manifest.upgradeFrom) || manifest.upgradeFrom.some((version) => !/^\d+\.\d+\.\d+$/.test(version) || Bun.semver.order(version, manifest.version) >= 0) || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > 1000 || !/^[a-f0-9]{64}$/.test(manifest.digest ?? "") || manifest.digest !== manifestDigest(manifest))
|
|
50
|
+
throw Error("Unsupported or damaged platform manifest");
|
|
51
|
+
const paths = new Set;
|
|
52
|
+
let size = 0;
|
|
53
|
+
for (const file of manifest.files) {
|
|
54
|
+
if (typeof file.path !== "string" || !/^(?:admin\.js|runtime\.js|(?:dashboard|migrations)\/[a-zA-Z0-9_./-]+)$/.test(file.path) || file.path.split("/").some((p) => !p || p === "." || p === ".." || p.startsWith(".")) || paths.has(file.path) || !Number.isSafeInteger(file.bytes) || file.bytes < 1 || file.bytes > 10 * 1024 * 1024 || !/^[a-f0-9]{64}$/.test(file.sha256 ?? ""))
|
|
55
|
+
throw Error("Unsafe platform payload inventory");
|
|
56
|
+
paths.add(file.path);
|
|
57
|
+
size += file.bytes;
|
|
58
|
+
const stat = await lstat(join(directory, file.path));
|
|
59
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
60
|
+
throw Error("Unsafe platform payload file");
|
|
61
|
+
const bytes = await readFile(join(directory, file.path));
|
|
62
|
+
if (bytes.length !== file.bytes || sha256(bytes) !== file.sha256)
|
|
63
|
+
throw Error("Platform payload checksum mismatch: " + file.path);
|
|
64
|
+
}
|
|
65
|
+
if (size > 100 * 1024 * 1024)
|
|
66
|
+
throw Error("Oversized platform payload");
|
|
67
|
+
for (const path of [
|
|
68
|
+
"admin.js",
|
|
69
|
+
"runtime.js",
|
|
70
|
+
"dashboard/index.html",
|
|
71
|
+
"dashboard/bootstrap/release.json",
|
|
72
|
+
"migrations/0006_platform_installation.sql"
|
|
73
|
+
])
|
|
74
|
+
if (!paths.has(path))
|
|
75
|
+
throw Error("Incomplete platform payload: " + path);
|
|
76
|
+
const actual = (await payloadFiles(directory)).filter((p) => p !== "manifest.json");
|
|
77
|
+
if (actual.length !== paths.size || actual.some((p) => !paths.has(p)))
|
|
78
|
+
throw Error("Unlisted platform payload files");
|
|
79
|
+
return { directory, manifest };
|
|
80
|
+
}
|
|
81
|
+
function assertUpgrade(installed, target) {
|
|
82
|
+
if (!installed || installed.runtimeAbi !== target.runtimeAbi || installed.protocol !== target.protocol || !/^\d+\.\d+\.\d+$/.test(installed.version ?? "") || !(installed.version === target.version || target.upgradeFrom?.includes(installed.version)) || Bun.semver.order(installed.version, target.version) > 0)
|
|
83
|
+
throw Error("Unsupported platform upgrade. Downgrades, legacy installs and ABI/protocol changes require a separate migration.");
|
|
84
|
+
if (installed.version === target.version && installed.digest !== target.digest)
|
|
85
|
+
throw Error("Same platform version has different bytes. Publish a new version; refusing an in-place replacement.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// packages/flare/src/cli/cloudflare-account.mjs
|
|
89
|
+
class CloudflareError extends Error {
|
|
90
|
+
constructor(path, status, codes, ambiguous = false) {
|
|
91
|
+
super(`Cloudflare ${path.split("?")[0]} failed (${status || "network"}${codes.length ? "; codes " + codes.join(",") : ""}).${ambiguous ? " Outcome is unknown; inspect platform status before resuming." : " Check account permissions and service activation."}`);
|
|
92
|
+
this.status = status;
|
|
93
|
+
this.ambiguous = ambiguous;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function runWrangler(args, { token, cwd, capture = false } = {}) {
|
|
97
|
+
const env = { ...process.env, WRANGLER_SEND_METRICS: "false", CI: "true" };
|
|
98
|
+
if (token)
|
|
99
|
+
env.CLOUDFLARE_API_TOKEN = token;
|
|
100
|
+
delete env.CLOUDFLARE_API_KEY;
|
|
101
|
+
delete env.CLOUDFLARE_EMAIL;
|
|
102
|
+
delete env.CLOUDFLARE_ACCOUNT_ID;
|
|
103
|
+
delete env.WRANGLER_CI_OVERRIDE_NAME;
|
|
104
|
+
const child = spawn(process.execPath, ["x", `wrangler@${WRANGLER_VERSION}`, ...args], {
|
|
105
|
+
cwd,
|
|
106
|
+
env,
|
|
107
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
108
|
+
});
|
|
109
|
+
let stdout = "", stderr = "", oversized = false, interrupted = false;
|
|
110
|
+
const interrupt = () => {
|
|
111
|
+
interrupted = true;
|
|
112
|
+
child.kill("SIGTERM");
|
|
113
|
+
};
|
|
114
|
+
process.once("SIGINT", interrupt);
|
|
115
|
+
process.once("SIGTERM", interrupt);
|
|
116
|
+
const collect = (which) => (bytes) => {
|
|
117
|
+
if (stdout.length + stderr.length > 1024 * 1024) {
|
|
118
|
+
oversized = true;
|
|
119
|
+
child.kill("SIGTERM");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (which === "out")
|
|
123
|
+
stdout += bytes;
|
|
124
|
+
else
|
|
125
|
+
stderr += bytes;
|
|
126
|
+
};
|
|
127
|
+
child.stdout.on("data", collect("out"));
|
|
128
|
+
child.stderr.on("data", collect("err"));
|
|
129
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 5 * 60 * 1000);
|
|
130
|
+
try {
|
|
131
|
+
const code = await new Promise((resolve, reject) => {
|
|
132
|
+
child.once("error", () => reject(Error("Unable to start pinned Wrangler")));
|
|
133
|
+
child.once("close", resolve);
|
|
134
|
+
});
|
|
135
|
+
if (code !== 0 || oversized || interrupted) {
|
|
136
|
+
throw Error(`Wrangler ${args[0]} failed (${code ?? "terminated"}).${capture ? "" : " Inspect platform status before resuming; no stderr containing possible credentials was printed."}`);
|
|
137
|
+
}
|
|
138
|
+
return stdout;
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
process.removeListener("SIGINT", interrupt);
|
|
142
|
+
process.removeListener("SIGTERM", interrupt);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function cloudflareToken(run = runWrangler) {
|
|
146
|
+
if (process.env.CLOUDFLARE_API_TOKEN?.trim())
|
|
147
|
+
return process.env.CLOUDFLARE_API_TOKEN.trim();
|
|
148
|
+
let credential;
|
|
149
|
+
try {
|
|
150
|
+
credential = JSON.parse(await run(["auth", "token", "--json"], { capture: true }));
|
|
151
|
+
} catch {
|
|
152
|
+
throw Error("Cloudflare authentication required. Set CLOUDFLARE_API_TOKEN with Workers, D1, R2, Queues and Access permissions, or sign in with Wrangler. FLARE_DEPLOY_TOKEN cannot install a platform.");
|
|
153
|
+
}
|
|
154
|
+
if (!["api_token", "oauth"].includes(credential.type) || typeof credential.token !== "string" || !credential.token.trim())
|
|
155
|
+
throw Error("Use a scoped Cloudflare API token or Wrangler OAuth, not a global API key.");
|
|
156
|
+
return credential.token;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class CloudflareAccount {
|
|
160
|
+
constructor(account, token, { fetch: fetcher = globalThis.fetch, run = runWrangler, wait = sleep } = {}) {
|
|
161
|
+
if (!/^[a-f0-9]{32}$/.test(account) || typeof token !== "string" || !token || /[\r\n]/.test(token))
|
|
162
|
+
throw Error("Invalid Cloudflare account or credential");
|
|
163
|
+
this.account = account;
|
|
164
|
+
this.token = token;
|
|
165
|
+
this.fetcher = fetcher;
|
|
166
|
+
this.run = run;
|
|
167
|
+
this.wait = wait;
|
|
168
|
+
}
|
|
169
|
+
async request(path, body, method = body === undefined ? "GET" : "POST", global = false) {
|
|
170
|
+
for (let attempt = 0;; attempt++) {
|
|
171
|
+
try {
|
|
172
|
+
return await this.requestOnce(path, body, method, global);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (method !== "GET" || !(error instanceof CloudflareError) || ![0, 408, 429, 500, 502, 503, 504].includes(error.status) || attempt >= 2)
|
|
175
|
+
throw error;
|
|
176
|
+
await this.wait(250 * 2 ** attempt);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async requestOnce(path, body, method, global) {
|
|
181
|
+
if (!/^[a-zA-Z0-9_/?=&.%:-]+$/.test(path) || path.includes("..") || path.startsWith("/"))
|
|
182
|
+
throw Error("Invalid Cloudflare API path");
|
|
183
|
+
const write = method !== "GET";
|
|
184
|
+
const encoded = body === undefined ? undefined : JSON.stringify(body);
|
|
185
|
+
let response, data;
|
|
186
|
+
try {
|
|
187
|
+
response = await this.fetcher(`https://api.cloudflare.com/client/v4/${global ? "" : `accounts/${this.account}/`}${path}`, {
|
|
188
|
+
method,
|
|
189
|
+
redirect: "error",
|
|
190
|
+
signal: AbortSignal.timeout(30000),
|
|
191
|
+
headers: {
|
|
192
|
+
authorization: `Bearer ${this.token}`,
|
|
193
|
+
"content-type": "application/json"
|
|
194
|
+
},
|
|
195
|
+
body: encoded
|
|
196
|
+
});
|
|
197
|
+
if (response.status === 204 && method === "DELETE")
|
|
198
|
+
return { success: true, result: null };
|
|
199
|
+
data = await response.json();
|
|
200
|
+
} catch {
|
|
201
|
+
throw new CloudflareError(path, response?.status ?? 0, [], write && (!response || response.ok || response.status >= 500));
|
|
202
|
+
}
|
|
203
|
+
if (!response.ok || !data.success) {
|
|
204
|
+
const codes = (Array.isArray(data.errors) ? data.errors : []).map((e) => Number(e.code)).filter(Number.isSafeInteger);
|
|
205
|
+
throw new CloudflareError(path, response.status, codes, write && response.status >= 500);
|
|
206
|
+
}
|
|
207
|
+
return data;
|
|
208
|
+
}
|
|
209
|
+
async api(path, body, method, global = false) {
|
|
210
|
+
return (await this.request(path, body, method, global)).result;
|
|
211
|
+
}
|
|
212
|
+
async list(path, { global = false, perPage = 100 } = {}) {
|
|
213
|
+
if (!Number.isSafeInteger(perPage) || perPage < 1 || perPage > 1000)
|
|
214
|
+
throw Error("Invalid Cloudflare inventory page size");
|
|
215
|
+
const values = [];
|
|
216
|
+
let page = 1;
|
|
217
|
+
while (page <= 100) {
|
|
218
|
+
const data = await this.request(`${path}${path.includes("?") ? "&" : "?"}page=${page}&per_page=${perPage}`, undefined, "GET", global);
|
|
219
|
+
if (!Array.isArray(data.result))
|
|
220
|
+
throw Error("Unexpected Cloudflare resource inventory");
|
|
221
|
+
values.push(...data.result);
|
|
222
|
+
const totalPages = data.result_info?.total_pages ?? (data.result_info?.total_count && data.result_info?.per_page ? Math.ceil(data.result_info.total_count / data.result_info.per_page) : 1);
|
|
223
|
+
if (page >= totalPages)
|
|
224
|
+
return values;
|
|
225
|
+
page++;
|
|
226
|
+
}
|
|
227
|
+
throw Error("Cloudflare resource inventory exceeds supported pagination");
|
|
228
|
+
}
|
|
229
|
+
async sql(database, sql, params = []) {
|
|
230
|
+
if (!/^[a-f0-9-]{36}$/.test(database))
|
|
231
|
+
throw Error("Invalid D1 database ID");
|
|
232
|
+
const result = await this.api(`d1/database/${database}/query`, {
|
|
233
|
+
sql,
|
|
234
|
+
params
|
|
235
|
+
});
|
|
236
|
+
if (!Array.isArray(result) || result.some((r) => r.success === false))
|
|
237
|
+
throw Error("Cloudflare D1 statement failed; no automatic retry was made");
|
|
238
|
+
return result[0] ?? { results: [], meta: { changes: 0 } };
|
|
239
|
+
}
|
|
240
|
+
async deploy(config, context) {
|
|
241
|
+
await this.run([
|
|
242
|
+
"deploy",
|
|
243
|
+
"--config",
|
|
244
|
+
config,
|
|
245
|
+
"--no-bundle",
|
|
246
|
+
"--strict",
|
|
247
|
+
"--tag",
|
|
248
|
+
context.tag,
|
|
249
|
+
"--message",
|
|
250
|
+
context.message,
|
|
251
|
+
...context.secretsFile ? ["--secrets-file", context.secretsFile] : []
|
|
252
|
+
], { token: this.token, cwd: context.cwd });
|
|
253
|
+
}
|
|
254
|
+
async migrations(config, database, cwd) {
|
|
255
|
+
await this.run(["d1", "migrations", "apply", database, "--remote", "--config", config], { token: this.token, cwd });
|
|
256
|
+
}
|
|
257
|
+
async currentVersion(name) {
|
|
258
|
+
const response = await this.api(`workers/scripts/${name}/deployments`);
|
|
259
|
+
const deployments = response.deployments ?? response;
|
|
260
|
+
if (!Array.isArray(deployments) || !deployments.length)
|
|
261
|
+
throw Error("Worker has no deployed version: " + name);
|
|
262
|
+
const latest = [...deployments].sort((a, b) => String(b.created_on).localeCompare(String(a.created_on)))[0];
|
|
263
|
+
if (latest.versions?.length !== 1 || latest.versions[0].percentage !== 100)
|
|
264
|
+
throw Error("Split Worker deployment is not supported by the platform updater");
|
|
265
|
+
return latest.versions[0].version_id;
|
|
266
|
+
}
|
|
267
|
+
async versionTag(name, id) {
|
|
268
|
+
const version = await this.api(`workers/scripts/${name}/versions/${id}`);
|
|
269
|
+
return version.annotations?.["workers/tag"] ?? version.metadata?.annotations?.["workers/tag"];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// packages/flare/src/cli/platform-state.mjs
|
|
274
|
+
import { mkdir, readFile as readFile2, writeFile, rename, chmod } from "fs/promises";
|
|
275
|
+
import { join as join2 } from "path";
|
|
276
|
+
import { homedir } from "os";
|
|
277
|
+
import { randomUUID } from "crypto";
|
|
278
|
+
var leaseMilliseconds = 15 * 60 * 1000;
|
|
279
|
+
var identifier = /^[a-z][a-z0-9-]{0,19}$/;
|
|
280
|
+
var resourceName = /^[a-z][a-z0-9-]{0,62}$/;
|
|
281
|
+
var uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
282
|
+
var validName = (name) => identifier.test(name);
|
|
283
|
+
function hostname(value) {
|
|
284
|
+
if (typeof value !== "string" || value.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value))
|
|
285
|
+
throw Error("Enter a plain lowercase public hostname, without protocol, path or port");
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
function validateState(state, account, name) {
|
|
289
|
+
if (!state || state.format !== 1 || state.account !== account || state.name !== name || !uuid.test(state.id ?? "") || !validName(name) || typeof state.owner !== "string" || !/^\S+@\S+\.\S+$/.test(state.owner) || state.owner.length > 254)
|
|
290
|
+
throw Error("Unsupported installation state or account/name mismatch");
|
|
291
|
+
const prefix = `flare-${name}-${state.id.replaceAll("-", "").slice(0, 12)}`;
|
|
292
|
+
const operator = state.origin === "operator";
|
|
293
|
+
if (state.origin !== undefined && !operator)
|
|
294
|
+
throw Error("Unsupported installation origin");
|
|
295
|
+
if (operator && (name !== "default" || !state.addresses?.custom || !/^\d{1,10}$/.test(state.rateLimitNamespace ?? "")))
|
|
296
|
+
throw Error("Unsupported operator installation identity");
|
|
297
|
+
const legacy = {
|
|
298
|
+
catalog: "flare-catalog",
|
|
299
|
+
bundles: "flare-builds",
|
|
300
|
+
files: "flare-files",
|
|
301
|
+
jobs: "flare-jobs",
|
|
302
|
+
dlq: "flare-jobs-dlq"
|
|
303
|
+
};
|
|
304
|
+
for (const key of ["catalog", "bundles", "files", "jobs", "dlq"])
|
|
305
|
+
if (!resourceName.test(state.resources?.[key]?.name ?? "") || state.resources[key].name !== (operator ? legacy[key] : key === "catalog" ? `flare-${name}-catalog` : `${prefix}-${key}`))
|
|
306
|
+
throw Error("Invalid installation resource identity");
|
|
307
|
+
if (state.resources.catalog.id !== null && !uuid.test(state.resources.catalog.id))
|
|
308
|
+
throw Error("Invalid catalogue identity");
|
|
309
|
+
for (const key of ["admin", "runtime"])
|
|
310
|
+
if (!resourceName.test(state.workers?.[key]?.name ?? "") || state.workers[key].name !== (operator ? `flare-${key}` : `${prefix}-${key}`))
|
|
311
|
+
throw Error("Invalid Worker identity");
|
|
312
|
+
if (typeof state.addresses?.custom !== "boolean" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(state.addresses.subdomain ?? ""))
|
|
313
|
+
throw Error("Invalid installation address configuration");
|
|
314
|
+
for (const key of ["adminHost", "runtimeHost"])
|
|
315
|
+
hostname(state.addresses?.[key]);
|
|
316
|
+
if (!state.addresses.custom && (state.addresses.adminHost !== `${prefix}-admin.${state.addresses.subdomain}.workers.dev` || state.addresses.runtimeHost !== `${prefix}-runtime.${state.addresses.subdomain}.workers.dev`))
|
|
317
|
+
throw Error("workers.dev address does not match Worker identity");
|
|
318
|
+
if (state.addresses.adminHost === state.addresses.runtimeHost)
|
|
319
|
+
throw Error("Admin and Runtime hostnames must differ");
|
|
320
|
+
if (state.access && (!/^[a-f0-9-]{36}$/.test(state.access.id ?? "") || !/^[a-f0-9]{64}$/.test(state.access.aud ?? "") || !/^https:\/\/[a-z0-9-]+\.cloudflareaccess\.com$/.test(state.access.issuer ?? "")))
|
|
321
|
+
throw Error("Invalid installation Access identity");
|
|
322
|
+
if (state.operation && (!uuid.test(state.operation.id ?? "") || !["install", "update", "adopt"].includes(state.operation.kind) || !/^[a-f0-9]{64}$/.test(state.operation.targetDigest ?? "") || !Array.isArray(state.operation.completed) || state.operation.completed.some((step) => ![
|
|
323
|
+
"resources",
|
|
324
|
+
"access",
|
|
325
|
+
"migrations",
|
|
326
|
+
"owner",
|
|
327
|
+
"bootstrap",
|
|
328
|
+
"signing-key",
|
|
329
|
+
"runtime",
|
|
330
|
+
"admin",
|
|
331
|
+
"verify"
|
|
332
|
+
].includes(step))))
|
|
333
|
+
throw Error("Invalid platform operation checkpoint");
|
|
334
|
+
if (state.installed && (!/^[a-f0-9]{64}$/.test(state.installed.digest ?? "") || !Array.isArray(state.installed.migrations)))
|
|
335
|
+
throw Error("Invalid installed release checkpoint");
|
|
336
|
+
return state;
|
|
337
|
+
}
|
|
338
|
+
function newInstallation({
|
|
339
|
+
account,
|
|
340
|
+
name,
|
|
341
|
+
owner,
|
|
342
|
+
subdomain,
|
|
343
|
+
adminHost,
|
|
344
|
+
runtimeHost
|
|
345
|
+
}) {
|
|
346
|
+
if (!/^[a-f0-9]{32}$/.test(account) || !validName(name) || typeof owner !== "string" || owner.length > 254 || !/^\S+@\S+\.\S+$/.test(owner))
|
|
347
|
+
throw Error("Invalid account, installation name or owner email");
|
|
348
|
+
if (!!adminHost !== !!runtimeHost)
|
|
349
|
+
throw Error("Provide both --dashboard-host and --runtime-host, or neither");
|
|
350
|
+
const id = randomUUID(), prefix = `flare-${name}-${id.replaceAll("-", "").slice(0, 12)}`;
|
|
351
|
+
subdomain ??= "flare-" + id.replaceAll("-", "").slice(0, 12);
|
|
352
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(subdomain))
|
|
353
|
+
throw Error("Invalid workers.dev subdomain");
|
|
354
|
+
const resources = Object.fromEntries(["catalog", "bundles", "files", "jobs", "dlq"].map((key) => [
|
|
355
|
+
key,
|
|
356
|
+
{ name: `${prefix}-${key}`, id: null }
|
|
357
|
+
]));
|
|
358
|
+
resources.catalog.name = `flare-${name}-catalog`;
|
|
359
|
+
return validateState({
|
|
360
|
+
format: 1,
|
|
361
|
+
id,
|
|
362
|
+
account,
|
|
363
|
+
name,
|
|
364
|
+
owner: owner.toLowerCase(),
|
|
365
|
+
resources,
|
|
366
|
+
workers: {
|
|
367
|
+
admin: { name: `${prefix}-admin` },
|
|
368
|
+
runtime: { name: `${prefix}-runtime` }
|
|
369
|
+
},
|
|
370
|
+
addresses: {
|
|
371
|
+
custom: !!adminHost,
|
|
372
|
+
subdomain,
|
|
373
|
+
adminHost: adminHost ? hostname(adminHost) : `${prefix}-admin.${subdomain}.workers.dev`,
|
|
374
|
+
runtimeHost: runtimeHost ? hostname(runtimeHost) : `${prefix}-runtime.${subdomain}.workers.dev`
|
|
375
|
+
},
|
|
376
|
+
access: null,
|
|
377
|
+
installed: null,
|
|
378
|
+
operation: null,
|
|
379
|
+
snapshots: {},
|
|
380
|
+
versions: {},
|
|
381
|
+
createdAt: Date.now()
|
|
382
|
+
}, account, name);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
class InstallationStore {
|
|
386
|
+
constructor(cf, name, {
|
|
387
|
+
directory = join2(process.env.FLARE_CONFIG_HOME ?? join2(homedir(), ".config", "flare"), "platform"),
|
|
388
|
+
now = Date.now
|
|
389
|
+
} = {}) {
|
|
390
|
+
this.cf = cf;
|
|
391
|
+
this.name = name;
|
|
392
|
+
this.directory = directory;
|
|
393
|
+
this.now = now;
|
|
394
|
+
this.file = join2(directory, `${cf.account}-${name}.json`);
|
|
395
|
+
this.lockOwner = randomUUID();
|
|
396
|
+
this.revision = 0;
|
|
397
|
+
}
|
|
398
|
+
async cache(state) {
|
|
399
|
+
await mkdir(this.directory, { recursive: true, mode: 448 });
|
|
400
|
+
await chmod(this.directory, 448);
|
|
401
|
+
const temporary = this.file + "." + this.lockOwner + ".tmp";
|
|
402
|
+
await writeFile(temporary, JSON.stringify(state, null, 2) + `
|
|
403
|
+
`, {
|
|
404
|
+
mode: 384
|
|
405
|
+
});
|
|
406
|
+
await rename(temporary, this.file);
|
|
407
|
+
await chmod(this.file, 384);
|
|
408
|
+
}
|
|
409
|
+
async local() {
|
|
410
|
+
const source = await readFile2(this.file, "utf8").catch((error) => {
|
|
411
|
+
if (error.code !== "ENOENT")
|
|
412
|
+
throw error;
|
|
413
|
+
return null;
|
|
414
|
+
});
|
|
415
|
+
return source ? validateState(JSON.parse(source), this.cf.account, this.name) : null;
|
|
416
|
+
}
|
|
417
|
+
async row(database) {
|
|
418
|
+
const table = await this.cf.sql(database, "SELECT name FROM sqlite_master WHERE type='table' AND name='flare_installation'");
|
|
419
|
+
if (!table.results.length)
|
|
420
|
+
return null;
|
|
421
|
+
const result = await this.cf.sql(database, "SELECT * FROM flare_installation WHERE singleton=1");
|
|
422
|
+
const row = result.results[0];
|
|
423
|
+
if (!row)
|
|
424
|
+
return null;
|
|
425
|
+
const state = validateState(JSON.parse(row.state), this.cf.account, this.name);
|
|
426
|
+
if (state.id !== row.installation_id || state.resources.catalog.id !== database)
|
|
427
|
+
throw Error("Catalogue identity does not match installation state");
|
|
428
|
+
return { ...row, state };
|
|
429
|
+
}
|
|
430
|
+
async discover() {
|
|
431
|
+
const local = await this.local();
|
|
432
|
+
if (local?.resources.catalog.id) {
|
|
433
|
+
const row = await this.row(local.resources.catalog.id);
|
|
434
|
+
if (row) {
|
|
435
|
+
this.revision = row.revision;
|
|
436
|
+
return { state: row.state, row };
|
|
437
|
+
}
|
|
438
|
+
if (local.installed)
|
|
439
|
+
throw Error("Managed installation state is missing; refusing to recreate resources");
|
|
440
|
+
return { state: local, row: null };
|
|
441
|
+
}
|
|
442
|
+
const candidates = (await this.cf.list("d1/database")).filter((db) => db.name === `flare-${this.name}-catalog` || this.name === "default" && db.name === "flare-catalog");
|
|
443
|
+
const found = [];
|
|
444
|
+
for (const candidate of candidates) {
|
|
445
|
+
const row = await this.row(candidate.uuid);
|
|
446
|
+
if (row)
|
|
447
|
+
found.push(row);
|
|
448
|
+
else if (candidate.name === "flare-catalog")
|
|
449
|
+
throw Error("Legacy operator installation found. Use explicit platform adopt; do not reinstall over existing data.");
|
|
450
|
+
else if (candidate.name !== local?.resources.catalog.name)
|
|
451
|
+
throw Error("Unmanaged catalogue found. Refusing implicit adoption; use a distinct installation name or an explicit migration.");
|
|
452
|
+
}
|
|
453
|
+
if (found.length > 1)
|
|
454
|
+
throw Error("Multiple installations have this name; refusing ambiguous resource selection");
|
|
455
|
+
if (found.length) {
|
|
456
|
+
this.revision = found[0].revision;
|
|
457
|
+
return { state: found[0].state, row: found[0] };
|
|
458
|
+
}
|
|
459
|
+
return { state: local, row: null };
|
|
460
|
+
}
|
|
461
|
+
async initialize(state, ddl) {
|
|
462
|
+
const db = state.resources.catalog.id;
|
|
463
|
+
await this.cf.sql(db, ddl);
|
|
464
|
+
await this.cf.sql(db, "INSERT OR IGNORE INTO flare_installation(singleton,installation_id,state,revision) VALUES(1,?,?,0)", [state.id, JSON.stringify(state)]);
|
|
465
|
+
const row = await this.row(db);
|
|
466
|
+
if (!row || row.state.id !== state.id)
|
|
467
|
+
throw Error("Installation identity is already claimed");
|
|
468
|
+
this.revision = row.revision;
|
|
469
|
+
}
|
|
470
|
+
async acquire(state, resume = false) {
|
|
471
|
+
const now = this.now();
|
|
472
|
+
const row = await this.row(state.resources.catalog.id);
|
|
473
|
+
if (!row || row.state.id !== state.id)
|
|
474
|
+
throw Error("Installation ownership could not be established");
|
|
475
|
+
if (row.revision !== this.revision)
|
|
476
|
+
throw Error("Installation state changed while preparing the operation; rerun status and the requested command");
|
|
477
|
+
if (row.lock_owner && (row.lock_expires > now || !resume))
|
|
478
|
+
throw Error("Installation operation is locked. After an interrupted process, wait 15 minutes and explicitly use --resume.");
|
|
479
|
+
const result = await this.cf.sql(state.resources.catalog.id, "UPDATE flare_installation SET lock_owner=?,lock_expires=? WHERE singleton=1 AND revision=? AND (lock_owner IS NULL OR lock_expires<=?)", [this.lockOwner, now + leaseMilliseconds, row.revision, now]);
|
|
480
|
+
if (result.meta?.changes !== 1)
|
|
481
|
+
throw Error("Concurrent platform operation; no deployment was started");
|
|
482
|
+
this.revision = row.revision;
|
|
483
|
+
}
|
|
484
|
+
async save(state) {
|
|
485
|
+
const now = this.now();
|
|
486
|
+
const result = await this.cf.sql(state.resources.catalog.id, "UPDATE flare_installation SET state=?,revision=revision+1,lock_expires=? WHERE singleton=1 AND installation_id=? AND revision=? AND lock_owner=? AND lock_expires>?", [
|
|
487
|
+
JSON.stringify(state),
|
|
488
|
+
now + leaseMilliseconds,
|
|
489
|
+
state.id,
|
|
490
|
+
this.revision,
|
|
491
|
+
this.lockOwner,
|
|
492
|
+
now
|
|
493
|
+
]);
|
|
494
|
+
if (result.meta?.changes !== 1)
|
|
495
|
+
throw Error("Platform operation lost its lease; stop and inspect platform status");
|
|
496
|
+
this.revision++;
|
|
497
|
+
await this.cache(state);
|
|
498
|
+
}
|
|
499
|
+
async release(state) {
|
|
500
|
+
await this.cf.sql(state.resources.catalog.id, "UPDATE flare_installation SET lock_owner=NULL,lock_expires=0 WHERE singleton=1 AND lock_owner=?", [this.lockOwner]);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function settingsDigest(settings) {
|
|
504
|
+
const { annotations: _annotations, ...stable } = settings;
|
|
505
|
+
stable.bindings = [...settings.bindings ?? []].sort((a, b) => a.name.localeCompare(b.name));
|
|
506
|
+
return configurationDigest(stable);
|
|
507
|
+
}
|
|
508
|
+
function configurationDigest(value) {
|
|
509
|
+
const canonical = (item) => {
|
|
510
|
+
if (Array.isArray(item))
|
|
511
|
+
return item.map(canonical);
|
|
512
|
+
if (item && typeof item === "object")
|
|
513
|
+
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, canonical(item[key])]));
|
|
514
|
+
return item;
|
|
515
|
+
};
|
|
516
|
+
return sha256(JSON.stringify(canonical(value)));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// packages/flare/src/cli/platform.mjs
|
|
520
|
+
var steps = [
|
|
521
|
+
"resources",
|
|
522
|
+
"access",
|
|
523
|
+
"migrations",
|
|
524
|
+
"owner",
|
|
525
|
+
"bootstrap",
|
|
526
|
+
"signing-key",
|
|
527
|
+
"admin",
|
|
528
|
+
"runtime",
|
|
529
|
+
"verify"
|
|
530
|
+
];
|
|
531
|
+
var publicState = (state, target, row) => ({
|
|
532
|
+
account: state.account,
|
|
533
|
+
installation: state.name,
|
|
534
|
+
id: state.id,
|
|
535
|
+
status: state.operation ? "incomplete" : state.installed ? "installed" : "provisioning",
|
|
536
|
+
installed: state.installed,
|
|
537
|
+
target: { version: target.version, digest: target.digest },
|
|
538
|
+
dashboard: "https://" + state.addresses.adminHost,
|
|
539
|
+
runtime: "https://" + state.addresses.runtimeHost,
|
|
540
|
+
operation: state.operation ? {
|
|
541
|
+
id: state.operation.id,
|
|
542
|
+
kind: state.operation.kind,
|
|
543
|
+
phase: state.operation.phase,
|
|
544
|
+
completed: state.operation.completed
|
|
545
|
+
} : null,
|
|
546
|
+
lockedUntil: row?.lock_owner ? row.lock_expires : null,
|
|
547
|
+
ownerLogin: "Verify owner sign-in in the Access-protected dashboard; the CLI does not impersonate the owner.",
|
|
548
|
+
source: "Installation checkpoint, not a live health check"
|
|
549
|
+
});
|
|
550
|
+
async function domainSnapshot(cf, name) {
|
|
551
|
+
const domains = (await cf.list("workers/domains")).filter((domain) => domain.service === name).map((domain) => ({
|
|
552
|
+
hostname: domain.hostname,
|
|
553
|
+
enabled: domain.enabled,
|
|
554
|
+
previews: domain.previews_enabled,
|
|
555
|
+
zone: domain.zone_id
|
|
556
|
+
}));
|
|
557
|
+
domains.sort((a, b) => a.hostname.localeCompare(b.hostname));
|
|
558
|
+
return configurationDigest(domains);
|
|
559
|
+
}
|
|
560
|
+
async function question(label) {
|
|
561
|
+
if (!process.stdin.isTTY)
|
|
562
|
+
throw Error(label + " Supply the corresponding named option in non-interactive mode.");
|
|
563
|
+
const prompt = createInterface({
|
|
564
|
+
input: process.stdin,
|
|
565
|
+
output: process.stdout
|
|
566
|
+
});
|
|
567
|
+
try {
|
|
568
|
+
return (await prompt.question(label + " ")).trim();
|
|
569
|
+
} finally {
|
|
570
|
+
prompt.close();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function buckets(cf) {
|
|
574
|
+
const result = [];
|
|
575
|
+
let cursor;
|
|
576
|
+
for (let count = 0;count < 100; count++) {
|
|
577
|
+
const response = await cf.request("r2/buckets?per_page=1000" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""));
|
|
578
|
+
const page = response.result;
|
|
579
|
+
if (!Array.isArray(page?.buckets))
|
|
580
|
+
throw Error("Unexpected R2 inventory");
|
|
581
|
+
result.push(...page.buckets);
|
|
582
|
+
const next = response.result_info?.cursor;
|
|
583
|
+
if (!next)
|
|
584
|
+
return result;
|
|
585
|
+
if (typeof next !== "string" || next === cursor)
|
|
586
|
+
throw Error("Invalid R2 pagination");
|
|
587
|
+
cursor = next;
|
|
588
|
+
}
|
|
589
|
+
throw Error("R2 inventory exceeds supported pagination");
|
|
590
|
+
}
|
|
591
|
+
async function preflight(cf) {
|
|
592
|
+
const [
|
|
593
|
+
organization,
|
|
594
|
+
subdomain,
|
|
595
|
+
databases,
|
|
596
|
+
queues,
|
|
597
|
+
r2,
|
|
598
|
+
apps,
|
|
599
|
+
workers,
|
|
600
|
+
domains
|
|
601
|
+
] = await Promise.all([
|
|
602
|
+
cf.api("access/organizations"),
|
|
603
|
+
cf.api("workers/subdomain"),
|
|
604
|
+
cf.list("d1/database"),
|
|
605
|
+
cf.list("queues"),
|
|
606
|
+
buckets(cf),
|
|
607
|
+
cf.list("access/apps"),
|
|
608
|
+
cf.list("workers/scripts"),
|
|
609
|
+
cf.list("workers/domains")
|
|
610
|
+
]);
|
|
611
|
+
const domain = organization?.auth_domain;
|
|
612
|
+
if (typeof domain !== "string" || !/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(domain))
|
|
613
|
+
throw Error("Configure a Cloudflare Zero Trust organization before installing Flare");
|
|
614
|
+
return {
|
|
615
|
+
issuer: "https://" + domain,
|
|
616
|
+
subdomain: subdomain.subdomain,
|
|
617
|
+
databases,
|
|
618
|
+
queues,
|
|
619
|
+
r2,
|
|
620
|
+
apps,
|
|
621
|
+
workers,
|
|
622
|
+
domains
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function workerConfiguration(state, release, role, bootstrap = false) {
|
|
626
|
+
const { manifest, directory } = release;
|
|
627
|
+
const common = {
|
|
628
|
+
name: state.workers[role].name,
|
|
629
|
+
account_id: state.account,
|
|
630
|
+
main: join3(directory, `${role}.js`),
|
|
631
|
+
compatibility_date: manifest.compatibilityDate,
|
|
632
|
+
compatibility_flags: ["nodejs_compat"],
|
|
633
|
+
workers_dev: !state.addresses.custom,
|
|
634
|
+
preview_urls: false,
|
|
635
|
+
...state.addresses.custom ? {
|
|
636
|
+
routes: [
|
|
637
|
+
{
|
|
638
|
+
pattern: state.addresses[role === "admin" ? "adminHost" : "runtimeHost"],
|
|
639
|
+
custom_domain: true
|
|
640
|
+
}
|
|
641
|
+
]
|
|
642
|
+
} : {},
|
|
643
|
+
observability: { enabled: true, head_sampling_rate: 1 },
|
|
644
|
+
limits: { cpu_ms: 1000 },
|
|
645
|
+
vars: {
|
|
646
|
+
RUNTIME_URL: "https://" + state.addresses.runtimeHost,
|
|
647
|
+
DASHBOARD_URL: "https://" + state.addresses.adminHost,
|
|
648
|
+
FLARE_INSTALLATION_ID: state.id,
|
|
649
|
+
FLARE_PLATFORM_PAYLOAD: manifest.digest
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
if (role === "admin")
|
|
653
|
+
return {
|
|
654
|
+
...common,
|
|
655
|
+
assets: {
|
|
656
|
+
directory: join3(directory, "dashboard"),
|
|
657
|
+
binding: "ASSETS",
|
|
658
|
+
run_worker_first: ["/*", "!/_astro/*"]
|
|
659
|
+
},
|
|
660
|
+
d1_databases: [
|
|
661
|
+
{
|
|
662
|
+
binding: "CATALOG",
|
|
663
|
+
database_name: state.resources.catalog.name,
|
|
664
|
+
database_id: state.resources.catalog.id,
|
|
665
|
+
migrations_dir: join3(directory, "migrations")
|
|
666
|
+
}
|
|
667
|
+
],
|
|
668
|
+
services: [
|
|
669
|
+
{
|
|
670
|
+
binding: "RUNTIME",
|
|
671
|
+
service: state.workers.runtime.name,
|
|
672
|
+
entrypoint: "RuntimeControl"
|
|
673
|
+
}
|
|
674
|
+
],
|
|
675
|
+
vars: {
|
|
676
|
+
...common.vars,
|
|
677
|
+
ACCESS_ISSUER: state.access?.issuer ?? "",
|
|
678
|
+
ACCESS_AUD: state.access?.aud ?? ""
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
return {
|
|
682
|
+
...common,
|
|
683
|
+
worker_loaders: [{ binding: "LOADER" }],
|
|
684
|
+
durable_objects: {
|
|
685
|
+
bindings: [{ name: "SUPERVISORS", class_name: "EnvironmentSupervisor" }]
|
|
686
|
+
},
|
|
687
|
+
exports: {
|
|
688
|
+
EnvironmentSupervisor: { type: "durable-object", storage: "sqlite" }
|
|
689
|
+
},
|
|
690
|
+
r2_buckets: [
|
|
691
|
+
{ binding: "BUNDLES", bucket_name: state.resources.bundles.name },
|
|
692
|
+
{ binding: "FILES", bucket_name: state.resources.files.name }
|
|
693
|
+
],
|
|
694
|
+
queues: {
|
|
695
|
+
producers: [{ binding: "JOBS", queue: state.resources.jobs.name }],
|
|
696
|
+
consumers: [
|
|
697
|
+
{
|
|
698
|
+
queue: state.resources.jobs.name,
|
|
699
|
+
max_batch_size: 10,
|
|
700
|
+
max_concurrency: 4,
|
|
701
|
+
max_batch_timeout: 1,
|
|
702
|
+
max_retries: 3,
|
|
703
|
+
dead_letter_queue: state.resources.dlq.name
|
|
704
|
+
}
|
|
705
|
+
]
|
|
706
|
+
},
|
|
707
|
+
ratelimits: [
|
|
708
|
+
{
|
|
709
|
+
name: "RATE_LIMITER",
|
|
710
|
+
namespace_id: state.rateLimitNamespace ?? String(parseInt(state.id.replaceAll("-", "").slice(0, 7), 16)),
|
|
711
|
+
simple: { limit: 600, period: 60 }
|
|
712
|
+
}
|
|
713
|
+
],
|
|
714
|
+
services: bootstrap ? [] : [
|
|
715
|
+
{
|
|
716
|
+
binding: "ADMIN_API",
|
|
717
|
+
service: state.workers.admin.name,
|
|
718
|
+
entrypoint: "AdminControl"
|
|
719
|
+
}
|
|
720
|
+
],
|
|
721
|
+
vars: {
|
|
722
|
+
...common.vars,
|
|
723
|
+
DEV_TOKEN_ISSUER: "https://" + state.addresses.runtimeHost
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
async function confirm(options, state, plan, ask, log) {
|
|
728
|
+
log(JSON.stringify(plan, null, 2));
|
|
729
|
+
const confirmation = options.confirm ?? await ask(`Workers Paid and activated R2/Zero Trust are required; usage may incur charges. Type account ID ${state.account} to authorize ${options.operation}:`);
|
|
730
|
+
if (confirmation !== state.account)
|
|
731
|
+
throw Error("Account confirmation required; no cloud changes were made");
|
|
732
|
+
}
|
|
733
|
+
async function assertAccess(cf, state) {
|
|
734
|
+
const app = await cf.api(`access/apps/${state.access.id}`);
|
|
735
|
+
const coversHost = app.domain === state.addresses.adminHost || app.destinations?.some((d) => d.type === "public" && d.uri === state.addresses.adminHost);
|
|
736
|
+
if (!coversHost || app.aud !== state.access.aud || state.access.snapshot && configurationDigest(app) !== state.access.snapshot)
|
|
737
|
+
throw Error("Access application drift; refusing to change policies or audiences");
|
|
738
|
+
}
|
|
739
|
+
async function remoteWorker(cf, name) {
|
|
740
|
+
try {
|
|
741
|
+
return await cf.api(`workers/scripts/${name}/settings`);
|
|
742
|
+
} catch (e) {
|
|
743
|
+
if (e.status === 404)
|
|
744
|
+
return null;
|
|
745
|
+
throw e;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
async function supervisorNamespace(cf, state) {
|
|
749
|
+
const settings = await cf.api(`workers/scripts/${state.workers.runtime.name}/settings`);
|
|
750
|
+
const binding = settings.bindings?.find((b) => b.name === "SUPERVISORS" && b.type === "durable_object_namespace");
|
|
751
|
+
if (!binding?.namespace_id)
|
|
752
|
+
throw Error("Supervisor namespace could not be verified");
|
|
753
|
+
if (state.supervisorNamespace && binding.namespace_id !== state.supervisorNamespace)
|
|
754
|
+
throw Error("Supervisor namespace changed; platform update must never replace application storage");
|
|
755
|
+
state.supervisorNamespace = binding.namespace_id;
|
|
756
|
+
}
|
|
757
|
+
async function assertResources(cf, state, inventory) {
|
|
758
|
+
if (!inventory.databases.some((db) => db.uuid === state.resources.catalog.id && db.name === state.resources.catalog.name))
|
|
759
|
+
throw Error("Catalogue was removed or renamed; refusing resource recreation");
|
|
760
|
+
for (const key of ["bundles", "files"])
|
|
761
|
+
if (!inventory.r2.some((bucket) => bucket.name === state.resources[key].name))
|
|
762
|
+
throw Error("Missing R2 resource: " + key);
|
|
763
|
+
for (const key of ["jobs", "dlq"])
|
|
764
|
+
if (!inventory.queues.some((q) => q.queue_id === state.resources[key].id && q.queue_name === state.resources[key].name))
|
|
765
|
+
throw Error("Queue resource identity changed: " + key);
|
|
766
|
+
if (state.access.issuer !== inventory.issuer)
|
|
767
|
+
throw Error("Zero Trust issuer changed; an explicit configuration migration is required");
|
|
768
|
+
await assertAccess(cf, state);
|
|
769
|
+
if (state.supervisorNamespace)
|
|
770
|
+
await supervisorNamespace(cf, state);
|
|
771
|
+
}
|
|
772
|
+
async function assertNoDrift(cf, state) {
|
|
773
|
+
for (const role of ["runtime", "admin"]) {
|
|
774
|
+
const actual = await cf.api(`workers/scripts/${state.workers[role].name}/settings`);
|
|
775
|
+
if (!state.snapshots[role] || settingsDigest(actual) !== state.snapshots[role] || await cf.currentVersion(state.workers[role].name) !== state.versions[role])
|
|
776
|
+
throw Error(`Remote ${role} configuration/deployment changed outside Flare. Refusing to overwrite it; explicit reconciliation is required.`);
|
|
777
|
+
if (await domainSnapshot(cf, state.workers[role].name) !== state.domainSnapshots?.[role])
|
|
778
|
+
throw Error("Custom domains changed outside Flare; refusing to overwrite their configuration");
|
|
779
|
+
const subdomain = await cf.api(`workers/scripts/${state.workers[role].name}/subdomain`);
|
|
780
|
+
if (subdomain.enabled !== !state.addresses.custom || subdomain.previews_enabled)
|
|
781
|
+
throw Error("Worker endpoint configuration drift; refusing to reset it");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async function assertCompletedWorkers(cf, state) {
|
|
785
|
+
for (const role of ["runtime", "admin"]) {
|
|
786
|
+
if (state.operation.phase === role && !state.operation.completed.includes(role))
|
|
787
|
+
continue;
|
|
788
|
+
const completed = state.operation.completed.includes(role) || role === "runtime" && state.operation.completed.includes("signing-key");
|
|
789
|
+
if (!completed || !state.versions[role])
|
|
790
|
+
continue;
|
|
791
|
+
const actual = await cf.api(`workers/scripts/${state.workers[role].name}/settings`);
|
|
792
|
+
if (settingsDigest(actual) !== state.snapshots[role] || await cf.currentVersion(state.workers[role].name) !== state.versions[role])
|
|
793
|
+
throw Error(`Completed ${role} deployment changed outside the pending operation; explicit reconciliation is required.`);
|
|
794
|
+
if (await domainSnapshot(cf, state.workers[role].name) !== state.domainSnapshots?.[role])
|
|
795
|
+
throw Error("Completed Worker domains changed outside the pending operation");
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function migrationInventory(manifest) {
|
|
799
|
+
return manifest.files.filter((f) => f.path.startsWith("migrations/")).map((f) => ({
|
|
800
|
+
name: f.path.slice("migrations/".length),
|
|
801
|
+
sha256: f.sha256
|
|
802
|
+
}));
|
|
803
|
+
}
|
|
804
|
+
function assertMigrations(installed, target) {
|
|
805
|
+
for (const old of installed.migrations ?? [])
|
|
806
|
+
if (!target.some((next) => next.name === old.name && next.sha256 === old.sha256))
|
|
807
|
+
throw Error("Previously applied migration removed or changed. Append a new backward-compatible migration instead.");
|
|
808
|
+
}
|
|
809
|
+
async function migrationsApplied(cf, state, manifest) {
|
|
810
|
+
const tables = await cf.sql(state.resources.catalog.id, "SELECT name FROM sqlite_master WHERE type='table' AND name='d1_migrations'");
|
|
811
|
+
if (!tables.results.length)
|
|
812
|
+
return false;
|
|
813
|
+
const rows = (await cf.sql(state.resources.catalog.id, "SELECT name FROM d1_migrations")).results;
|
|
814
|
+
return migrationInventory(manifest).every((f) => rows.some((row) => row.name === f.name));
|
|
815
|
+
}
|
|
816
|
+
async function ensureResources(cf, state, store, inventory) {
|
|
817
|
+
if (!state.addresses.custom && !inventory.subdomain) {
|
|
818
|
+
state.operation.pendingResource = "subdomain";
|
|
819
|
+
await store.save(state);
|
|
820
|
+
await cf.api("workers/subdomain", { subdomain: state.addresses.subdomain }, "PUT");
|
|
821
|
+
if ((await cf.api("workers/subdomain")).subdomain !== state.addresses.subdomain)
|
|
822
|
+
throw Error("workers.dev subdomain setup was not confirmed");
|
|
823
|
+
} else if (!state.addresses.custom && inventory.subdomain !== state.addresses.subdomain)
|
|
824
|
+
throw Error("Account workers.dev subdomain changed; refusing to silently change installation URLs");
|
|
825
|
+
for (const key of ["bundles", "files", "dlq", "jobs"]) {
|
|
826
|
+
const resource = state.resources[key];
|
|
827
|
+
const isBucket = key === "bundles" || key === "files";
|
|
828
|
+
const list = isBucket ? await buckets(cf) : await cf.list("queues");
|
|
829
|
+
let actual = list.find((r) => (isBucket ? r.name : r.queue_name) === resource.name);
|
|
830
|
+
if (actual && !resource.id && state.operation.pendingResource !== key)
|
|
831
|
+
throw Error("Resource already exists without a creation checkpoint; refusing implicit adoption: " + key);
|
|
832
|
+
if (!actual) {
|
|
833
|
+
if (resource.id)
|
|
834
|
+
throw Error("Previously created resource is missing: " + key);
|
|
835
|
+
state.operation.pendingResource = key;
|
|
836
|
+
await store.save(state);
|
|
837
|
+
actual = await cf.api(isBucket ? "r2/buckets" : "queues", isBucket ? { name: resource.name } : { queue_name: resource.name });
|
|
838
|
+
}
|
|
839
|
+
const id = isBucket ? actual.name : actual.queue_id;
|
|
840
|
+
if (!id || resource.id && resource.id !== id)
|
|
841
|
+
throw Error("Resource identity mismatch: " + key);
|
|
842
|
+
resource.id = id;
|
|
843
|
+
state.operation.pendingResource = null;
|
|
844
|
+
await store.save(state);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
async function ensureAccess(cf, state, store, inventory) {
|
|
848
|
+
if (state.access) {
|
|
849
|
+
await assertAccess(cf, state);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const name = `Flare ${state.id}`;
|
|
853
|
+
let app = (await cf.list("access/apps")).find((app) => app.name === name && app.domain === state.addresses.adminHost);
|
|
854
|
+
if (app && state.operation.pendingResource !== "access")
|
|
855
|
+
throw Error("Access app exists without a creation checkpoint");
|
|
856
|
+
if (!app) {
|
|
857
|
+
state.operation.pendingResource = "access";
|
|
858
|
+
await store.save(state);
|
|
859
|
+
app = await cf.api("access/apps", {
|
|
860
|
+
name,
|
|
861
|
+
type: "self_hosted",
|
|
862
|
+
domain: state.addresses.adminHost,
|
|
863
|
+
session_duration: "8h",
|
|
864
|
+
policies: [
|
|
865
|
+
{
|
|
866
|
+
name: "Flare owner",
|
|
867
|
+
decision: "allow",
|
|
868
|
+
include: [{ email: { email: state.owner } }]
|
|
869
|
+
}
|
|
870
|
+
]
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
if (!/^[a-f0-9-]{36}$/.test(app.id ?? "") || !/^[a-f0-9]{64}$/.test(app.aud ?? ""))
|
|
874
|
+
throw Error("Access app identity could not be verified");
|
|
875
|
+
state.access = { id: app.id, aud: app.aud, issuer: inventory.issuer };
|
|
876
|
+
state.operation.pendingResource = null;
|
|
877
|
+
await store.save(state);
|
|
878
|
+
}
|
|
879
|
+
async function verify(cf, state, release, fetcher, wait) {
|
|
880
|
+
await assertAccess(cf, state);
|
|
881
|
+
await supervisorNamespace(cf, state);
|
|
882
|
+
let healthy = false;
|
|
883
|
+
for (let attempt = 0;attempt < 5; attempt++) {
|
|
884
|
+
try {
|
|
885
|
+
const response = await fetcher(`https://${state.addresses.runtimeHost}/health?ready=1`, { redirect: "error", signal: AbortSignal.timeout(15000) });
|
|
886
|
+
const data = await response.json();
|
|
887
|
+
healthy = response.ok && data.readiness === "control-plane-ready" && data.version === release.manifest.version && data.installationId === state.id && data.platformPayload === release.manifest.digest && data.runtimeAbi === 4 && data.protocol === 1;
|
|
888
|
+
} catch {}
|
|
889
|
+
if (healthy)
|
|
890
|
+
break;
|
|
891
|
+
if (attempt < 4)
|
|
892
|
+
await wait(2000);
|
|
893
|
+
}
|
|
894
|
+
if (!healthy)
|
|
895
|
+
throw Error("Runtime health does not match the installed platform payload; operation remains resumable");
|
|
896
|
+
for (const path of ["/", "/api/session"]) {
|
|
897
|
+
const response = await fetcher(`https://${state.addresses.adminHost}${path}`, { redirect: "manual", signal: AbortSignal.timeout(15000) });
|
|
898
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
899
|
+
const destination = new URL(response.headers.get("location") ?? "", `https://${state.addresses.adminHost}`);
|
|
900
|
+
if (destination.origin !== state.access.issuer)
|
|
901
|
+
throw Error("Dashboard does not redirect to the expected Access issuer");
|
|
902
|
+
} else if (![401, 403].includes(response.status))
|
|
903
|
+
throw Error("Unauthenticated dashboard is not verified closed");
|
|
904
|
+
}
|
|
905
|
+
for (const role of ["runtime", "admin"]) {
|
|
906
|
+
const endpoint = await cf.api(`workers/scripts/${state.workers[role].name}/subdomain`);
|
|
907
|
+
if (endpoint.enabled !== !state.addresses.custom || endpoint.previews_enabled)
|
|
908
|
+
throw Error("Unexpected alternate Worker endpoints");
|
|
909
|
+
if (state.addresses.custom) {
|
|
910
|
+
const domains = await cf.list("workers/domains");
|
|
911
|
+
const host = state.addresses[role === "runtime" ? "runtimeHost" : "adminHost"];
|
|
912
|
+
if (!domains.some((domain) => domain.hostname === host && domain.service === state.workers[role].name && domain.enabled === true && domain.previews_enabled === false))
|
|
913
|
+
throw Error("Custom-domain routing was not confirmed; operation remains incomplete");
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function discoverOperator(cf, store, name) {
|
|
918
|
+
if (name !== "default")
|
|
919
|
+
throw Error("Operator adoption supports only --name default");
|
|
920
|
+
const databases = await cf.list("d1/database");
|
|
921
|
+
const matches = databases.filter((db) => db.name === "flare-catalog");
|
|
922
|
+
if (matches.length !== 1 || databases.some((db) => db.name === "flare-default-catalog"))
|
|
923
|
+
throw Error("Expected one legacy flare-catalog and no competing default catalogue");
|
|
924
|
+
const row = await store.row(matches[0].uuid);
|
|
925
|
+
const state = row?.state ?? await store.local();
|
|
926
|
+
if (state && (state.origin !== "operator" || state.resources.catalog.id !== matches[0].uuid))
|
|
927
|
+
throw Error("Catalogue/cache is owned by a different installation");
|
|
928
|
+
if (row)
|
|
929
|
+
store.revision = row.revision;
|
|
930
|
+
return { state, row };
|
|
931
|
+
}
|
|
932
|
+
async function prepareOperator(cf, inventory, release, options, dependencies) {
|
|
933
|
+
const owner = options.owner?.toLowerCase();
|
|
934
|
+
if (!owner || !/^\S+@\S+\.\S+$/.test(owner))
|
|
935
|
+
throw Error("Explicit --owner is required for adoption");
|
|
936
|
+
const database = inventory.databases.find((db) => db.name === "flare-catalog");
|
|
937
|
+
const member = (await cf.sql(database.uuid, "SELECT role FROM members WHERE email=?", [
|
|
938
|
+
owner
|
|
939
|
+
])).results[0];
|
|
940
|
+
if (member?.role !== "owner")
|
|
941
|
+
throw Error("Existing owner membership does not match; roles are never rewritten");
|
|
942
|
+
const state = {
|
|
943
|
+
format: 1,
|
|
944
|
+
origin: "operator",
|
|
945
|
+
id: randomUUID2(),
|
|
946
|
+
account: cf.account,
|
|
947
|
+
name: "default",
|
|
948
|
+
owner,
|
|
949
|
+
resources: {
|
|
950
|
+
catalog: { name: "flare-catalog", id: database.uuid },
|
|
951
|
+
bundles: { name: "flare-builds", id: "flare-builds" },
|
|
952
|
+
files: { name: "flare-files", id: "flare-files" },
|
|
953
|
+
jobs: { name: "flare-jobs", id: null },
|
|
954
|
+
dlq: { name: "flare-jobs-dlq", id: null }
|
|
955
|
+
},
|
|
956
|
+
workers: {
|
|
957
|
+
runtime: { name: "flare-runtime" },
|
|
958
|
+
admin: { name: "flare-admin" }
|
|
959
|
+
},
|
|
960
|
+
addresses: { custom: true, subdomain: inventory.subdomain },
|
|
961
|
+
access: null,
|
|
962
|
+
installed: null,
|
|
963
|
+
operation: null,
|
|
964
|
+
snapshots: {},
|
|
965
|
+
versions: {},
|
|
966
|
+
domainSnapshots: {},
|
|
967
|
+
createdAt: Date.now()
|
|
968
|
+
};
|
|
969
|
+
const remote = {};
|
|
970
|
+
for (const role of ["runtime", "admin"]) {
|
|
971
|
+
remote[role] = await cf.api(`workers/scripts/flare-${role}/settings`);
|
|
972
|
+
state.versions[role] = await cf.currentVersion(`flare-${role}`);
|
|
973
|
+
state.snapshots[role] = settingsDigest(remote[role]);
|
|
974
|
+
state.domainSnapshots[role] = await domainSnapshot(cf, `flare-${role}`);
|
|
975
|
+
const endpoint = await cf.api(`workers/scripts/flare-${role}/subdomain`);
|
|
976
|
+
if (endpoint.enabled || endpoint.previews_enabled)
|
|
977
|
+
throw Error("Adoption requires custom domains with alternate endpoints disabled");
|
|
978
|
+
if (remote[role].logpush || remote[role].tail_consumers?.length || remote[role].tags?.length || Object.keys(remote[role].placement ?? {}).length || remote[role].limits?.cpu_ms !== 1000 || Object.keys(remote[role].limits ?? {}).some((key) => key !== "cpu_ms") || remote[role].observability?.enabled !== true || remote[role].observability?.head_sampling_rate !== 1 || remote[role].observability?.traces?.enabled === true || remote[role].observability?.logs?.enabled === false || remote[role].observability?.logs?.invocation_logs === false || remote[role].observability?.logs?.persist === false || remote[role].observability?.redact_query_string === true || remote[role].compatibility_flags?.join() !== "nodejs_compat")
|
|
979
|
+
throw Error("Unsupported operator Worker configuration; adoption does not silently reconcile custom settings");
|
|
980
|
+
}
|
|
981
|
+
const binding = (role, name, type) => {
|
|
982
|
+
const matches = remote[role].bindings?.filter((b) => b.name === name && b.type === type);
|
|
983
|
+
if (matches?.length !== 1)
|
|
984
|
+
throw Error(`Missing or ambiguous ${role} binding ${name}`);
|
|
985
|
+
return matches[0];
|
|
986
|
+
};
|
|
987
|
+
const allowed = {
|
|
988
|
+
runtime: [
|
|
989
|
+
"ADMIN_API",
|
|
990
|
+
"BUNDLES",
|
|
991
|
+
"FILES",
|
|
992
|
+
"JOBS",
|
|
993
|
+
"LOADER",
|
|
994
|
+
"RATE_LIMITER",
|
|
995
|
+
"SUPERVISORS",
|
|
996
|
+
"DEV_SIGNING_KEY",
|
|
997
|
+
"RUNTIME_URL",
|
|
998
|
+
"DASHBOARD_URL",
|
|
999
|
+
"DEV_TOKEN_ISSUER"
|
|
1000
|
+
],
|
|
1001
|
+
admin: [
|
|
1002
|
+
"RUNTIME",
|
|
1003
|
+
"CATALOG",
|
|
1004
|
+
"ASSETS",
|
|
1005
|
+
"RUNTIME_URL",
|
|
1006
|
+
"DASHBOARD_URL",
|
|
1007
|
+
"ACCESS_ISSUER",
|
|
1008
|
+
"ACCESS_AUD"
|
|
1009
|
+
]
|
|
1010
|
+
};
|
|
1011
|
+
for (const role of ["runtime", "admin"])
|
|
1012
|
+
if (remote[role].bindings.length !== allowed[role].length || remote[role].bindings.some((b) => !allowed[role].includes(b.name)))
|
|
1013
|
+
throw Error("Unsupported extra bindings; adoption must not remove operator capabilities");
|
|
1014
|
+
const runtimeUrl = binding("runtime", "RUNTIME_URL", "plain_text").text;
|
|
1015
|
+
const dashboardUrl = binding("admin", "DASHBOARD_URL", "plain_text").text;
|
|
1016
|
+
for (const value of [runtimeUrl, dashboardUrl]) {
|
|
1017
|
+
const url = new URL(value);
|
|
1018
|
+
if (url.origin !== value || url.protocol !== "https:")
|
|
1019
|
+
throw Error("Operator URL must be a plain HTTPS origin");
|
|
1020
|
+
}
|
|
1021
|
+
state.addresses.runtimeHost = new URL(runtimeUrl).hostname;
|
|
1022
|
+
state.addresses.adminHost = new URL(dashboardUrl).hostname;
|
|
1023
|
+
if (binding("admin", "RUNTIME_URL", "plain_text").text !== runtimeUrl || binding("runtime", "DASHBOARD_URL", "plain_text").text !== dashboardUrl || binding("runtime", "DEV_TOKEN_ISSUER", "plain_text").text !== runtimeUrl)
|
|
1024
|
+
throw Error("Operator URLs/identity issuer do not match; refusing identity migration");
|
|
1025
|
+
for (const role of ["runtime", "admin"]) {
|
|
1026
|
+
const own = inventory.domains.filter((d) => d.service === `flare-${role}`);
|
|
1027
|
+
if (own.length !== 1 || own[0].hostname !== state.addresses[role === "runtime" ? "runtimeHost" : "adminHost"] || !own[0].enabled || own[0].previews_enabled)
|
|
1028
|
+
throw Error("Operator custom domain does not match binding URL");
|
|
1029
|
+
}
|
|
1030
|
+
if (binding("admin", "CATALOG", "d1").id !== database.uuid && binding("admin", "CATALOG", "d1").database_id !== database.uuid)
|
|
1031
|
+
throw Error("Admin catalogue binding mismatch");
|
|
1032
|
+
for (const [role, key, target, entrypoint] of [
|
|
1033
|
+
["admin", "RUNTIME", "flare-runtime", "RuntimeControl"],
|
|
1034
|
+
["runtime", "ADMIN_API", "flare-admin", "AdminControl"]
|
|
1035
|
+
]) {
|
|
1036
|
+
const b = binding(role, key, "service");
|
|
1037
|
+
if (b.service !== target || b.entrypoint !== entrypoint || b.environment && b.environment !== "production")
|
|
1038
|
+
throw Error("Operator service binding mismatch");
|
|
1039
|
+
}
|
|
1040
|
+
for (const [key, bucket] of [
|
|
1041
|
+
["BUNDLES", "flare-builds"],
|
|
1042
|
+
["FILES", "flare-files"]
|
|
1043
|
+
])
|
|
1044
|
+
if (binding("runtime", key, "r2_bucket").bucket_name !== bucket)
|
|
1045
|
+
throw Error("Operator R2 binding mismatch");
|
|
1046
|
+
if (binding("runtime", "JOBS", "queue").queue_name !== "flare-jobs")
|
|
1047
|
+
throw Error("Operator Queue binding mismatch");
|
|
1048
|
+
binding("runtime", "LOADER", "worker_loader");
|
|
1049
|
+
binding("runtime", "DEV_SIGNING_KEY", "secret_text");
|
|
1050
|
+
binding("admin", "ASSETS", "assets");
|
|
1051
|
+
const supervisor = binding("runtime", "SUPERVISORS", "durable_object_namespace");
|
|
1052
|
+
if (supervisor.class_name !== "EnvironmentSupervisor" || supervisor.script_name)
|
|
1053
|
+
throw Error("Operator supervisor must be owned by Runtime");
|
|
1054
|
+
state.supervisorNamespace = supervisor.namespace_id;
|
|
1055
|
+
const rate = binding("runtime", "RATE_LIMITER", "ratelimit");
|
|
1056
|
+
if (rate.simple?.limit !== 600 || rate.simple?.period !== 60)
|
|
1057
|
+
throw Error("Unsupported operator rate-limit configuration");
|
|
1058
|
+
state.rateLimitNamespace = String(rate.namespace_id);
|
|
1059
|
+
const aud = binding("admin", "ACCESS_AUD", "plain_text").text;
|
|
1060
|
+
const app = inventory.apps.find((a) => a.aud === aud);
|
|
1061
|
+
if (!app || binding("admin", "ACCESS_ISSUER", "plain_text").text !== inventory.issuer)
|
|
1062
|
+
throw Error("Access application/issuer mismatch");
|
|
1063
|
+
const access = await cf.api(`access/apps/${app.id}`);
|
|
1064
|
+
state.access = {
|
|
1065
|
+
id: app.id,
|
|
1066
|
+
aud,
|
|
1067
|
+
issuer: inventory.issuer,
|
|
1068
|
+
snapshot: configurationDigest(access)
|
|
1069
|
+
};
|
|
1070
|
+
for (const key of ["jobs", "dlq"]) {
|
|
1071
|
+
const q = inventory.queues.find((q) => q.queue_name === state.resources[key].name);
|
|
1072
|
+
if (!q)
|
|
1073
|
+
throw Error("Operator queue is missing");
|
|
1074
|
+
state.resources[key].id = q.queue_id;
|
|
1075
|
+
}
|
|
1076
|
+
validateState(state, cf.account, "default");
|
|
1077
|
+
await assertResources(cf, state, inventory);
|
|
1078
|
+
const migrations = (await cf.sql(database.uuid, "SELECT name FROM d1_migrations")).results;
|
|
1079
|
+
if (migrations.some((m) => !migrationInventory(release.manifest).some((f) => f.name === m.name)))
|
|
1080
|
+
throw Error("Operator has unknown migrations; an explicit compatibility review is required");
|
|
1081
|
+
const response = await (dependencies.fetch ?? globalThis.fetch)(runtimeUrl + "/health", { redirect: "error", signal: AbortSignal.timeout(15000) });
|
|
1082
|
+
const health = await response.json();
|
|
1083
|
+
if (!response.ok || health.protocol !== release.manifest.protocol || health.runtimeAbi !== release.manifest.runtimeAbi || ![release.manifest.version, ...release.manifest.upgradeFrom].includes(health.version))
|
|
1084
|
+
throw Error("Operator runtime version/ABI is not an admitted adoption source");
|
|
1085
|
+
state.adoptedFrom = { version: health.version, at: Date.now() };
|
|
1086
|
+
return state;
|
|
1087
|
+
}
|
|
1088
|
+
async function platform(options, dependencies = {}) {
|
|
1089
|
+
const source = dependencies.release ?? await loadPlatformRelease();
|
|
1090
|
+
const scratch = await mkdtemp(join3(tmpdir(), "flare-platform-payload-"));
|
|
1091
|
+
try {
|
|
1092
|
+
for (const file of [...source.manifest.files, { path: "manifest.json" }]) {
|
|
1093
|
+
const destination = join3(scratch, file.path);
|
|
1094
|
+
await mkdir2(join3(destination, ".."), { recursive: true });
|
|
1095
|
+
await cp(join3(source.directory, file.path), destination);
|
|
1096
|
+
}
|
|
1097
|
+
const release = await loadPlatformRelease(scratch);
|
|
1098
|
+
return await runPlatform(options, { ...dependencies, release });
|
|
1099
|
+
} finally {
|
|
1100
|
+
await rm(scratch, { recursive: true, force: true });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
async function runPlatform(options, dependencies = {}) {
|
|
1104
|
+
const operation = options.operation;
|
|
1105
|
+
if (!["install", "status", "update", "adopt"].includes(operation))
|
|
1106
|
+
throw Error("Supported: flare platform install|status|update|adopt; no deployment has occurred.");
|
|
1107
|
+
const name = options.name ?? "default";
|
|
1108
|
+
if (!validName(name))
|
|
1109
|
+
throw Error("Installation --name must be 1\u201320 lowercase letters, digits or hyphens, starting with a letter");
|
|
1110
|
+
const release = dependencies.release ?? await loadPlatformRelease();
|
|
1111
|
+
const log = dependencies.log ?? ((message) => console.error(message));
|
|
1112
|
+
const ask = dependencies.question ?? question;
|
|
1113
|
+
let account = options.account;
|
|
1114
|
+
let cf = dependencies.cf;
|
|
1115
|
+
if (!cf) {
|
|
1116
|
+
const token = await cloudflareToken();
|
|
1117
|
+
if (!account) {
|
|
1118
|
+
const reader = new CloudflareAccount("0".repeat(32), token);
|
|
1119
|
+
const accounts = await reader.list("accounts", {
|
|
1120
|
+
global: true,
|
|
1121
|
+
perPage: 50
|
|
1122
|
+
});
|
|
1123
|
+
if (!Array.isArray(accounts) || !accounts.length)
|
|
1124
|
+
throw Error("No accessible Cloudflare accounts");
|
|
1125
|
+
if (accounts.length === 1)
|
|
1126
|
+
account = accounts[0].id;
|
|
1127
|
+
else {
|
|
1128
|
+
log(accounts.map((a) => `${a.id}: ${a.name}`).join(`
|
|
1129
|
+
`));
|
|
1130
|
+
account = await ask("Cloudflare account ID:");
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
cf = new CloudflareAccount(account, token);
|
|
1134
|
+
}
|
|
1135
|
+
account ??= cf.account;
|
|
1136
|
+
if (cf.account !== account)
|
|
1137
|
+
throw Error("Cloudflare account mismatch");
|
|
1138
|
+
const store = new InstallationStore(cf, name, dependencies.storeOptions);
|
|
1139
|
+
const adoption = operation === "adopt";
|
|
1140
|
+
const discovered = adoption ? await discoverOperator(cf, store, name) : await store.discover();
|
|
1141
|
+
let state = discovered.state;
|
|
1142
|
+
if (operation === "status")
|
|
1143
|
+
return state ? publicState(state, release.manifest, discovered.row) : {
|
|
1144
|
+
account,
|
|
1145
|
+
installation: name,
|
|
1146
|
+
status: "not-installed",
|
|
1147
|
+
target: release.manifest.version
|
|
1148
|
+
};
|
|
1149
|
+
const inventory = await preflight(cf);
|
|
1150
|
+
if (adoption && !state)
|
|
1151
|
+
state = await prepareOperator(cf, inventory, release, options, dependencies);
|
|
1152
|
+
if (state) {
|
|
1153
|
+
if (options.owner && options.owner.toLowerCase() !== state.owner || options["dashboard-host"] && options["dashboard-host"] !== state.addresses.adminHost || options["runtime-host"] && options["runtime-host"] !== state.addresses.runtimeHost || options.subdomain && options.subdomain !== state.addresses.subdomain)
|
|
1154
|
+
throw Error("Existing installation identity cannot be changed by install/update");
|
|
1155
|
+
} else {
|
|
1156
|
+
if (operation !== "install")
|
|
1157
|
+
throw Error("No managed installation; run flare platform install first");
|
|
1158
|
+
state = newInstallation({
|
|
1159
|
+
account,
|
|
1160
|
+
name,
|
|
1161
|
+
owner: options.owner ?? await ask("Dashboard owner email:"),
|
|
1162
|
+
subdomain: options.subdomain ?? inventory.subdomain,
|
|
1163
|
+
adminHost: options["dashboard-host"],
|
|
1164
|
+
runtimeHost: options["runtime-host"]
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
if (state.operation && (state.operation.kind !== operation || state.operation.targetDigest !== release.manifest.digest))
|
|
1168
|
+
throw Error("A different platform operation is incomplete. Resume it with the original CLI release before changing versions.");
|
|
1169
|
+
if (state.installed) {
|
|
1170
|
+
assertUpgrade(state.installed, release.manifest);
|
|
1171
|
+
assertMigrations(state.installed, migrationInventory(release.manifest));
|
|
1172
|
+
if (!state.operation) {
|
|
1173
|
+
await assertResources(cf, state, inventory);
|
|
1174
|
+
await assertNoDrift(cf, state);
|
|
1175
|
+
if (state.installed.digest === release.manifest.digest)
|
|
1176
|
+
return { ...publicState(state, release.manifest), unchanged: true };
|
|
1177
|
+
if (operation === "install" || adoption)
|
|
1178
|
+
throw Error("Already installed. Use flare platform update to change the platform version");
|
|
1179
|
+
}
|
|
1180
|
+
} else if (operation !== "install" && !adoption)
|
|
1181
|
+
throw Error("Installation is incomplete; resume platform install with the original release");
|
|
1182
|
+
const plan = {
|
|
1183
|
+
account,
|
|
1184
|
+
installation: name,
|
|
1185
|
+
operation,
|
|
1186
|
+
from: state.installed?.version ?? state.adoptedFrom?.version ?? null,
|
|
1187
|
+
to: release.manifest.version,
|
|
1188
|
+
digest: release.manifest.digest,
|
|
1189
|
+
dashboard: "https://" + state.addresses.adminHost,
|
|
1190
|
+
runtime: "https://" + state.addresses.runtimeHost,
|
|
1191
|
+
steps: state.installed || adoption ? ["migrations", "runtime", "admin", "verify"] : steps,
|
|
1192
|
+
preserve: [
|
|
1193
|
+
"Workers and DO namespace identities",
|
|
1194
|
+
"projects/dev/prod",
|
|
1195
|
+
"application releases/data/files/auth",
|
|
1196
|
+
"signing secrets",
|
|
1197
|
+
"existing Access policies"
|
|
1198
|
+
],
|
|
1199
|
+
requiredPlan: "Workers Paid; billing and write entitlements are not auto-enabled",
|
|
1200
|
+
rollback: "No automatic data rollback or downgrade. Interrupted operations resume from checkpoints."
|
|
1201
|
+
};
|
|
1202
|
+
if (state.addresses.custom)
|
|
1203
|
+
for (const role of ["admin", "runtime"]) {
|
|
1204
|
+
const host = state.addresses[role === "admin" ? "adminHost" : "runtimeHost"];
|
|
1205
|
+
if (inventory.domains.some((domain) => domain.hostname === host && domain.service !== state.workers[role].name))
|
|
1206
|
+
throw Error("Custom domain is owned by another Worker; refusing to replace its route");
|
|
1207
|
+
}
|
|
1208
|
+
if (options["dry-run"])
|
|
1209
|
+
return plan;
|
|
1210
|
+
await confirm(options, state, plan, ask, log);
|
|
1211
|
+
state.operation ??= {
|
|
1212
|
+
id: randomUUID2(),
|
|
1213
|
+
kind: operation,
|
|
1214
|
+
targetDigest: release.manifest.digest,
|
|
1215
|
+
completed: [],
|
|
1216
|
+
phase: "catalog",
|
|
1217
|
+
previousVersions: { ...state.versions },
|
|
1218
|
+
startedAt: Date.now()
|
|
1219
|
+
};
|
|
1220
|
+
if (!state.resources.catalog.id) {
|
|
1221
|
+
const existing = inventory.databases.find((db) => db.name === state.resources.catalog.name);
|
|
1222
|
+
if (existing && !discovered.state)
|
|
1223
|
+
throw Error("Catalogue name is already in use; refusing implicit adoption");
|
|
1224
|
+
await store.cache(state);
|
|
1225
|
+
const database = existing ?? await cf.api("d1/database", { name: state.resources.catalog.name });
|
|
1226
|
+
state.resources.catalog.id = database.uuid;
|
|
1227
|
+
await store.cache(state);
|
|
1228
|
+
}
|
|
1229
|
+
if (!discovered.row) {
|
|
1230
|
+
if (!adoption) {
|
|
1231
|
+
const tables = (await cf.sql(state.resources.catalog.id, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' AND name NOT IN ('flare_installation','d1_migrations')")).results;
|
|
1232
|
+
if (tables.length)
|
|
1233
|
+
throw Error("Unmanaged catalogue contains tables; refusing to claim existing data");
|
|
1234
|
+
} else {
|
|
1235
|
+
await assertResources(cf, state, inventory);
|
|
1236
|
+
await assertNoDrift(cf, state);
|
|
1237
|
+
}
|
|
1238
|
+
await store.initialize(state, await readFile3(join3(release.directory, "migrations/0006_platform_installation.sql"), "utf8"));
|
|
1239
|
+
}
|
|
1240
|
+
await store.acquire(state, options.resume === true);
|
|
1241
|
+
const scratch = await mkdtemp(join3(tmpdir(), "flare-platform-"));
|
|
1242
|
+
const configuration = async (role, bootstrap = false) => {
|
|
1243
|
+
const path = join3(scratch, `${role}${bootstrap ? "-bootstrap" : ""}.json`);
|
|
1244
|
+
await writeFile2(path, JSON.stringify(workerConfiguration(state, release, role, bootstrap), null, 2), { mode: 384 });
|
|
1245
|
+
return path;
|
|
1246
|
+
};
|
|
1247
|
+
const checkpoint = async (phase, work) => {
|
|
1248
|
+
if (state.operation.completed.includes(phase))
|
|
1249
|
+
return;
|
|
1250
|
+
state.operation.phase = phase;
|
|
1251
|
+
await store.save(state);
|
|
1252
|
+
log(`Flare platform: ${phase}`);
|
|
1253
|
+
await work();
|
|
1254
|
+
state.operation.completed.push(phase);
|
|
1255
|
+
await store.save(state);
|
|
1256
|
+
};
|
|
1257
|
+
const deploy = async (role, bootstrap = false) => {
|
|
1258
|
+
const worker = state.workers[role].name;
|
|
1259
|
+
const tag = `flare-${state.id.slice(0, 8)}-${state.operation.id.slice(0, 8)}-${bootstrap ? "boot" : role}-${release.manifest.digest.slice(0, 12)}`;
|
|
1260
|
+
const existing = await remoteWorker(cf, worker);
|
|
1261
|
+
if (existing) {
|
|
1262
|
+
const current = await cf.currentVersion(worker);
|
|
1263
|
+
if (await cf.versionTag(worker, current) === tag) {
|
|
1264
|
+
state.versions[role] = current;
|
|
1265
|
+
state.snapshots[role] = settingsDigest(existing);
|
|
1266
|
+
state.domainSnapshots ??= {};
|
|
1267
|
+
state.domainSnapshots[role] = await domainSnapshot(cf, worker);
|
|
1268
|
+
if (role === "runtime")
|
|
1269
|
+
await supervisorNamespace(cf, state);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (!state.versions[role] || current !== state.versions[role] || state.snapshots[role] && settingsDigest(existing) !== state.snapshots[role] || state.domainSnapshots?.[role] && await domainSnapshot(cf, worker) !== state.domainSnapshots[role])
|
|
1273
|
+
throw Error("Worker changed outside the pending operation; refusing deployment: " + role);
|
|
1274
|
+
} else if (state.versions[role])
|
|
1275
|
+
throw Error("Previously deployed Worker is missing; refusing recreation");
|
|
1276
|
+
let secretsFile;
|
|
1277
|
+
if (bootstrap && !existing) {
|
|
1278
|
+
secretsFile = join3(scratch, "bootstrap-secrets.json");
|
|
1279
|
+
await writeFile2(secretsFile, JSON.stringify({ DEV_SIGNING_KEY: randomBytes(48).toString("hex") }), { mode: 384 });
|
|
1280
|
+
}
|
|
1281
|
+
await cf.deploy(await configuration(role, bootstrap), {
|
|
1282
|
+
tag,
|
|
1283
|
+
message: `Flare ${state.id} ${state.operation.id} ${release.manifest.digest}`,
|
|
1284
|
+
cwd: scratch,
|
|
1285
|
+
secretsFile
|
|
1286
|
+
});
|
|
1287
|
+
const id = await cf.currentVersion(worker);
|
|
1288
|
+
if (await cf.versionTag(worker, id) !== tag)
|
|
1289
|
+
throw Error("Deployment version could not be attributed to this operation");
|
|
1290
|
+
state.versions[role] = id;
|
|
1291
|
+
state.snapshots[role] = settingsDigest(await cf.api(`workers/scripts/${worker}/settings`));
|
|
1292
|
+
state.domainSnapshots ??= {};
|
|
1293
|
+
state.domainSnapshots[role] = await domainSnapshot(cf, worker);
|
|
1294
|
+
if (role === "runtime")
|
|
1295
|
+
await supervisorNamespace(cf, state);
|
|
1296
|
+
};
|
|
1297
|
+
try {
|
|
1298
|
+
if (!state.installed && !adoption) {
|
|
1299
|
+
await checkpoint("resources", () => ensureResources(cf, state, store, inventory));
|
|
1300
|
+
await checkpoint("access", () => ensureAccess(cf, state, store, inventory));
|
|
1301
|
+
}
|
|
1302
|
+
await assertResources(cf, state, await preflight(cf));
|
|
1303
|
+
await assertCompletedWorkers(cf, state);
|
|
1304
|
+
await checkpoint("migrations", async () => {
|
|
1305
|
+
if (!await migrationsApplied(cf, state, release.manifest))
|
|
1306
|
+
await cf.migrations(await configuration("admin"), state.resources.catalog.name, scratch);
|
|
1307
|
+
if (!await migrationsApplied(cf, state, release.manifest))
|
|
1308
|
+
throw Error("D1 migrations did not finish");
|
|
1309
|
+
});
|
|
1310
|
+
if (!state.installed && !adoption) {
|
|
1311
|
+
await checkpoint("owner", async () => {
|
|
1312
|
+
await cf.sql(state.resources.catalog.id, "INSERT OR IGNORE INTO members(email,role) VALUES(?,?)", [state.owner, "owner"]);
|
|
1313
|
+
const row = (await cf.sql(state.resources.catalog.id, "SELECT role FROM members WHERE email=?", [state.owner])).results[0];
|
|
1314
|
+
if (row?.role !== "owner")
|
|
1315
|
+
throw Error("Owner membership was not verified; existing member roles were not overwritten");
|
|
1316
|
+
});
|
|
1317
|
+
await checkpoint("bootstrap", () => deploy("runtime", true));
|
|
1318
|
+
await checkpoint("signing-key", async () => {
|
|
1319
|
+
const secrets = await cf.api(`workers/scripts/${state.workers.runtime.name}/secrets`);
|
|
1320
|
+
if (!secrets.some((s) => s.name === "DEV_SIGNING_KEY"))
|
|
1321
|
+
throw Error("Bootstrap signing secret is missing; refusing to rotate or recreate credentials implicitly");
|
|
1322
|
+
if (!(await cf.api(`workers/scripts/${state.workers.runtime.name}/secrets`)).some((s) => s.name === "DEV_SIGNING_KEY"))
|
|
1323
|
+
throw Error("Signing secret was not confirmed");
|
|
1324
|
+
state.versions.runtime = await cf.currentVersion(state.workers.runtime.name);
|
|
1325
|
+
state.snapshots.runtime = settingsDigest(await cf.api(`workers/scripts/${state.workers.runtime.name}/settings`));
|
|
1326
|
+
});
|
|
1327
|
+
await checkpoint("admin", () => deploy("admin"));
|
|
1328
|
+
await checkpoint("runtime", () => deploy("runtime"));
|
|
1329
|
+
} else {
|
|
1330
|
+
await checkpoint("runtime", () => deploy("runtime"));
|
|
1331
|
+
await checkpoint("admin", () => deploy("admin"));
|
|
1332
|
+
}
|
|
1333
|
+
state.operation.phase = "verify";
|
|
1334
|
+
await store.save(state);
|
|
1335
|
+
log("Flare platform: verify");
|
|
1336
|
+
await verify(cf, state, release, dependencies.fetch ?? globalThis.fetch, dependencies.sleep ?? sleep2);
|
|
1337
|
+
for (const role of ["runtime", "admin"]) {
|
|
1338
|
+
const current = await cf.currentVersion(state.workers[role].name);
|
|
1339
|
+
if (current !== state.versions[role])
|
|
1340
|
+
throw Error("Deployment changed during platform verification");
|
|
1341
|
+
const snapshot = settingsDigest(await cf.api(`workers/scripts/${state.workers[role].name}/settings`));
|
|
1342
|
+
if (snapshot !== state.snapshots[role])
|
|
1343
|
+
throw Error("Worker configuration changed during platform verification");
|
|
1344
|
+
if (await domainSnapshot(cf, state.workers[role].name) !== state.domainSnapshots?.[role])
|
|
1345
|
+
throw Error("Worker domains changed during platform verification");
|
|
1346
|
+
}
|
|
1347
|
+
state.previousVersions = state.operation.previousVersions;
|
|
1348
|
+
state.installed = {
|
|
1349
|
+
version: release.manifest.version,
|
|
1350
|
+
digest: release.manifest.digest,
|
|
1351
|
+
protocol: release.manifest.protocol,
|
|
1352
|
+
runtimeAbi: release.manifest.runtimeAbi,
|
|
1353
|
+
migrations: migrationInventory(release.manifest),
|
|
1354
|
+
verifiedAt: Date.now()
|
|
1355
|
+
};
|
|
1356
|
+
state.operation = null;
|
|
1357
|
+
await store.save(state);
|
|
1358
|
+
return publicState(state, release.manifest);
|
|
1359
|
+
} finally {
|
|
1360
|
+
await store.release(state).catch(() => {});
|
|
1361
|
+
await rm(scratch, { recursive: true, force: true });
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
export {
|
|
1365
|
+
platform
|
|
1366
|
+
};
|