@treeseed/sdk 0.5.3 → 0.6.0
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/dist/index.d.ts +2 -0
- package/dist/index.js +46 -0
- package/dist/operations/providers/default.js +1 -1
- package/dist/operations/services/config-runtime.d.ts +49 -42
- package/dist/operations/services/config-runtime.js +449 -136
- package/dist/operations/services/deploy.d.ts +298 -0
- package/dist/operations/services/deploy.js +381 -137
- package/dist/operations/services/git-workflow.d.ts +9 -0
- package/dist/operations/services/git-workflow.js +32 -0
- package/dist/operations/services/github-api.d.ts +115 -0
- package/dist/operations/services/github-api.js +455 -0
- package/dist/operations/services/github-automation.d.ts +19 -33
- package/dist/operations/services/github-automation.js +44 -131
- package/dist/operations/services/key-agent.d.ts +20 -1
- package/dist/operations/services/key-agent.js +267 -102
- package/dist/operations/services/knowledge-coop-launch.d.ts +2 -3
- package/dist/operations/services/knowledge-coop-launch.js +26 -12
- package/dist/operations/services/project-platform.d.ts +157 -150
- package/dist/operations/services/project-platform.js +129 -26
- package/dist/operations/services/railway-api.d.ts +244 -0
- package/dist/operations/services/railway-api.js +882 -0
- package/dist/operations/services/railway-deploy.d.ts +171 -27
- package/dist/operations/services/railway-deploy.js +672 -172
- package/dist/operations/services/runtime-tools.d.ts +18 -0
- package/dist/operations/services/runtime-tools.js +19 -6
- package/dist/operations/services/workspace-preflight.js +2 -2
- package/dist/platform/contracts.d.ts +7 -0
- package/dist/platform/deploy-config.js +23 -0
- package/dist/platform/deploy-runtime.d.ts +1 -0
- package/dist/platform/deploy-runtime.js +7 -9
- package/dist/platform/env.yaml +10 -9
- package/dist/platform/environment.js +4 -0
- package/dist/platform/plugin.d.ts +6 -0
- package/dist/platform/plugins/constants.d.ts +1 -0
- package/dist/platform/plugins/constants.js +1 -0
- package/dist/platform/plugins/runtime.d.ts +4 -0
- package/dist/platform/plugins/runtime.js +8 -1
- package/dist/platform/published-content.js +27 -4
- package/dist/platform/tenant/runtime-config.js +33 -24
- package/dist/plugin-default.d.ts +1 -0
- package/dist/plugin-default.js +1 -0
- package/dist/reconcile/builtin-adapters.d.ts +3 -0
- package/dist/reconcile/builtin-adapters.js +2093 -0
- package/dist/reconcile/contracts.d.ts +155 -0
- package/dist/reconcile/contracts.js +0 -0
- package/dist/reconcile/desired-state.d.ts +179 -0
- package/dist/reconcile/desired-state.js +319 -0
- package/dist/reconcile/engine.d.ts +405 -0
- package/dist/reconcile/engine.js +356 -0
- package/dist/reconcile/errors.d.ts +5 -0
- package/dist/reconcile/errors.js +13 -0
- package/dist/reconcile/index.d.ts +7 -0
- package/dist/reconcile/index.js +7 -0
- package/dist/reconcile/registry.d.ts +7 -0
- package/dist/reconcile/registry.js +64 -0
- package/dist/reconcile/state.d.ts +7 -0
- package/dist/reconcile/state.js +303 -0
- package/dist/reconcile/units.d.ts +6 -0
- package/dist/reconcile/units.js +68 -0
- package/dist/scripts/config-treeseed.js +27 -19
- package/dist/scripts/tenant-deploy.js +35 -14
- package/dist/workflow/operations.js +127 -22
- package/dist/workflow-support.d.ts +3 -1
- package/dist/workflow-support.js +50 -0
- package/dist/workflow.d.ts +2 -0
- package/package.json +7 -1
|
@@ -0,0 +1,2093 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { collectTreeseedEnvironmentContext, resolveTreeseedMachineEnvironmentValues } from "../operations/services/config-runtime.js";
|
|
4
|
+
import {
|
|
5
|
+
buildPublicVars,
|
|
6
|
+
buildProvisioningSummary,
|
|
7
|
+
buildSecretMap,
|
|
8
|
+
cloudflareApiRequest,
|
|
9
|
+
createBranchPreviewDeployTarget,
|
|
10
|
+
createPersistentDeployTarget,
|
|
11
|
+
destroyCloudflareResources,
|
|
12
|
+
hasProvisionedCloudflareResources,
|
|
13
|
+
isWranglerAlreadyExistsError,
|
|
14
|
+
listD1Databases,
|
|
15
|
+
listKvNamespaces,
|
|
16
|
+
listPagesProjects,
|
|
17
|
+
listQueues,
|
|
18
|
+
listR2Buckets,
|
|
19
|
+
loadDeployState,
|
|
20
|
+
queueId,
|
|
21
|
+
queueName,
|
|
22
|
+
reconcileCloudflareWebCacheRules,
|
|
23
|
+
resolveConfiguredCloudflareAccountId,
|
|
24
|
+
resolveCloudflareZoneIdForHost,
|
|
25
|
+
runWrangler,
|
|
26
|
+
scopeFromTarget,
|
|
27
|
+
writeDeployState
|
|
28
|
+
} from "../operations/services/deploy.js";
|
|
29
|
+
import {
|
|
30
|
+
configuredRailwayServices,
|
|
31
|
+
ensureRailwayProjectContext,
|
|
32
|
+
runRailway,
|
|
33
|
+
validateRailwayDeployPrerequisites
|
|
34
|
+
} from "../operations/services/railway-deploy.js";
|
|
35
|
+
import {
|
|
36
|
+
ensureRailwayEnvironment,
|
|
37
|
+
ensureRailwayProject,
|
|
38
|
+
ensureRailwayService,
|
|
39
|
+
ensureRailwayServiceInstanceConfiguration,
|
|
40
|
+
getRailwayServiceInstance,
|
|
41
|
+
getRailwayProject,
|
|
42
|
+
listRailwayCustomDomains,
|
|
43
|
+
listRailwayProjects,
|
|
44
|
+
listRailwayVariables,
|
|
45
|
+
resolveRailwayWorkspaceContext,
|
|
46
|
+
upsertRailwayVariables
|
|
47
|
+
} from "../operations/services/railway-api.js";
|
|
48
|
+
function toDeployTarget(target) {
|
|
49
|
+
return target.kind === "persistent" ? createPersistentDeployTarget(target.scope) : createBranchPreviewDeployTarget(target.branchName);
|
|
50
|
+
}
|
|
51
|
+
function nowIso() {
|
|
52
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
53
|
+
}
|
|
54
|
+
function sleepMs(durationMs) {
|
|
55
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, durationMs);
|
|
56
|
+
}
|
|
57
|
+
function isTransientCloudflareReconcileError(error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
59
|
+
return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|connectivity issue/iu.test(message);
|
|
60
|
+
}
|
|
61
|
+
function isTransientRailwayReconcileError(error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
63
|
+
return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|connectivity issue|rate limit|too many requests|429|5\d\d/iu.test(message);
|
|
64
|
+
}
|
|
65
|
+
function syntheticQueueLocator(name) {
|
|
66
|
+
return `queue-name:${name}`;
|
|
67
|
+
}
|
|
68
|
+
function isSyntheticQueueLocator(value) {
|
|
69
|
+
return typeof value === "string" && value.startsWith("queue-name:");
|
|
70
|
+
}
|
|
71
|
+
function noopObservedState(input) {
|
|
72
|
+
return {
|
|
73
|
+
exists: true,
|
|
74
|
+
status: "ready",
|
|
75
|
+
live: {
|
|
76
|
+
unitId: input.unit.unitId,
|
|
77
|
+
dependencies: input.unit.dependencies
|
|
78
|
+
},
|
|
79
|
+
locators: {},
|
|
80
|
+
warnings: []
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function noopDiff() {
|
|
84
|
+
return {
|
|
85
|
+
action: "noop",
|
|
86
|
+
reasons: ["composite unit"],
|
|
87
|
+
before: {},
|
|
88
|
+
after: {}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function buildCompositeAdapter(unitType) {
|
|
92
|
+
return {
|
|
93
|
+
providerId: "treeseed",
|
|
94
|
+
unitTypes: [unitType],
|
|
95
|
+
supports(candidateUnitType, providerId) {
|
|
96
|
+
return candidateUnitType === unitType && providerId === "treeseed";
|
|
97
|
+
},
|
|
98
|
+
observe(input) {
|
|
99
|
+
return noopObservedState(input);
|
|
100
|
+
},
|
|
101
|
+
plan() {
|
|
102
|
+
return noopDiff();
|
|
103
|
+
},
|
|
104
|
+
requiredPostconditions({ unit }) {
|
|
105
|
+
return unit.dependencies.map((dependency) => ({
|
|
106
|
+
key: dependency,
|
|
107
|
+
description: `Dependency ${dependency} is verified`
|
|
108
|
+
}));
|
|
109
|
+
},
|
|
110
|
+
reconcile({ unit, observed, diff }) {
|
|
111
|
+
return {
|
|
112
|
+
unit,
|
|
113
|
+
observed,
|
|
114
|
+
diff,
|
|
115
|
+
action: diff.action,
|
|
116
|
+
warnings: [],
|
|
117
|
+
resourceLocators: {},
|
|
118
|
+
state: {
|
|
119
|
+
unitId: unit.unitId,
|
|
120
|
+
reconciledAt: nowIso()
|
|
121
|
+
},
|
|
122
|
+
verification: null
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
verify({ context, unit, postconditions }) {
|
|
126
|
+
const dependencyResults = context.session.get("treeseed:verification-results");
|
|
127
|
+
const checks = postconditions.map((condition) => {
|
|
128
|
+
const dependency = dependencyResults?.get(condition.key);
|
|
129
|
+
const verified = dependency?.verified === true;
|
|
130
|
+
return {
|
|
131
|
+
key: condition.key,
|
|
132
|
+
description: condition.description,
|
|
133
|
+
source: "derived",
|
|
134
|
+
exists: verified,
|
|
135
|
+
configured: verified,
|
|
136
|
+
ready: verified,
|
|
137
|
+
verified,
|
|
138
|
+
expected: true,
|
|
139
|
+
observed: dependency?.verified ?? false,
|
|
140
|
+
issues: verified ? [] : [`Dependency ${condition.key} is not verified.`]
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return summarizeVerification(unit.unitId, checks);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function providerCache(input, key, loader, forceRefresh = false) {
|
|
148
|
+
if (forceRefresh) {
|
|
149
|
+
input.context.session.delete(key);
|
|
150
|
+
}
|
|
151
|
+
if (input.context.session.has(key)) {
|
|
152
|
+
return input.context.session.get(key);
|
|
153
|
+
}
|
|
154
|
+
const value = loader();
|
|
155
|
+
input.context.session.set(key, value);
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
function buildCloudflareEnv(input) {
|
|
159
|
+
const scope = scopeFromTarget(toDeployTarget(input.context.target));
|
|
160
|
+
const machineValues = resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope);
|
|
161
|
+
return {
|
|
162
|
+
CLOUDFLARE_ACCOUNT_ID: machineValues.CLOUDFLARE_ACCOUNT_ID ?? input.context.launchEnv.CLOUDFLARE_ACCOUNT_ID ?? process.env.CLOUDFLARE_ACCOUNT_ID ?? resolveConfiguredCloudflareAccountId(input.context.deployConfig),
|
|
163
|
+
CLOUDFLARE_API_TOKEN: machineValues.CLOUDFLARE_API_TOKEN ?? input.context.launchEnv.CLOUDFLARE_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN ?? ""
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function hasLiveResourceId(value) {
|
|
167
|
+
return typeof value === "string" && value.length > 0 && !value.startsWith("dryrun-") && !value.startsWith("local-") && !value.endsWith("-id") && !value.endsWith("-preview-id");
|
|
168
|
+
}
|
|
169
|
+
function buildRailwayEnv(input, scope) {
|
|
170
|
+
const machineValues = resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope);
|
|
171
|
+
const token = [
|
|
172
|
+
machineValues.RAILWAY_API_TOKEN,
|
|
173
|
+
input.context.launchEnv.RAILWAY_API_TOKEN,
|
|
174
|
+
process.env.RAILWAY_API_TOKEN
|
|
175
|
+
].find((value) => typeof value === "string" && value.trim().length > 0)?.trim() ?? "";
|
|
176
|
+
return {
|
|
177
|
+
RAILWAY_API_TOKEN: token,
|
|
178
|
+
TREESEED_RAILWAY_API_URL: machineValues.TREESEED_RAILWAY_API_URL ?? input.context.launchEnv.TREESEED_RAILWAY_API_URL ?? process.env.TREESEED_RAILWAY_API_URL ?? "",
|
|
179
|
+
TREESEED_RAILWAY_WORKSPACE: machineValues.TREESEED_RAILWAY_WORKSPACE ?? input.context.launchEnv.TREESEED_RAILWAY_WORKSPACE ?? process.env.TREESEED_RAILWAY_WORKSPACE ?? ""
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function findCloudflareQueueByName(input, env, expectedName, { attempts = 6, delayMs = 350 } = {}) {
|
|
183
|
+
if (!expectedName) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
187
|
+
const match = listCloudflareQueuesViaApi(env).find((entry) => queueName(entry) === expectedName) ?? null;
|
|
188
|
+
if (match) {
|
|
189
|
+
return match;
|
|
190
|
+
}
|
|
191
|
+
if (attempt < attempts - 1) {
|
|
192
|
+
sleepMs(delayMs);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
function findCloudflareD1ByName(input, env, expectedName, { attempts = 6, delayMs = 350 } = {}) {
|
|
198
|
+
if (!expectedName) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
202
|
+
const match = listD1Databases(input.context.tenantRoot, env).find((entry) => entry?.name === expectedName) ?? null;
|
|
203
|
+
if (match) {
|
|
204
|
+
return match;
|
|
205
|
+
}
|
|
206
|
+
if (attempt < attempts - 1) {
|
|
207
|
+
sleepMs(delayMs);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function getCloudflareD1ById(env, databaseId) {
|
|
213
|
+
if (!databaseId) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
|
|
217
|
+
if (!accountId) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const payload = cloudflareApiRequest(
|
|
221
|
+
`/accounts/${encodeURIComponent(accountId)}/d1/database/${encodeURIComponent(databaseId)}`,
|
|
222
|
+
{ env, allowFailure: true }
|
|
223
|
+
);
|
|
224
|
+
return payload?.result ?? null;
|
|
225
|
+
}
|
|
226
|
+
function getCloudflareKvById(env, namespaceId) {
|
|
227
|
+
if (!namespaceId) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
|
|
231
|
+
if (!accountId) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const payload = cloudflareApiRequest(
|
|
235
|
+
`/accounts/${encodeURIComponent(accountId)}/storage/kv/namespaces/${encodeURIComponent(namespaceId)}`,
|
|
236
|
+
{ env, allowFailure: true }
|
|
237
|
+
);
|
|
238
|
+
return payload?.result ?? null;
|
|
239
|
+
}
|
|
240
|
+
function listCloudflareQueuesViaApi(env) {
|
|
241
|
+
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
|
|
242
|
+
if (!accountId) {
|
|
243
|
+
throw new Error("Configure CLOUDFLARE_ACCOUNT_ID before reconciling Cloudflare queues.");
|
|
244
|
+
}
|
|
245
|
+
const payload = cloudflareApiRequest(`/accounts/${encodeURIComponent(accountId)}/queues`, { env });
|
|
246
|
+
return Array.isArray(payload?.result) ? payload.result : [];
|
|
247
|
+
}
|
|
248
|
+
function cloudflareObservationSnapshot(input, forceRefresh = false) {
|
|
249
|
+
const cacheKey = `cloudflare:observe:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
|
|
250
|
+
return providerCache(input, cacheKey, () => {
|
|
251
|
+
const target = toDeployTarget(input.context.target);
|
|
252
|
+
const env = buildCloudflareEnv(input);
|
|
253
|
+
return {
|
|
254
|
+
target,
|
|
255
|
+
env,
|
|
256
|
+
state: loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target }),
|
|
257
|
+
kvNamespaces: listKvNamespaces(input.context.tenantRoot, env),
|
|
258
|
+
d1Databases: listD1Databases(input.context.tenantRoot, env),
|
|
259
|
+
queues: listCloudflareQueuesViaApi(env),
|
|
260
|
+
buckets: listR2Buckets(input.context.tenantRoot, env),
|
|
261
|
+
pagesProjects: listPagesProjects(input.context.tenantRoot, env)
|
|
262
|
+
};
|
|
263
|
+
}, forceRefresh);
|
|
264
|
+
}
|
|
265
|
+
function customDomainStateKey(provider, domain) {
|
|
266
|
+
return `custom-domain:${provider}:${domain}`;
|
|
267
|
+
}
|
|
268
|
+
function storeCustomDomainState(input, provider, domain, value) {
|
|
269
|
+
input.context.session.set(customDomainStateKey(provider, domain), value);
|
|
270
|
+
}
|
|
271
|
+
function getCustomDomainState(input, provider, domain) {
|
|
272
|
+
return input.context.session.get(customDomainStateKey(provider, domain));
|
|
273
|
+
}
|
|
274
|
+
function listCloudflareDnsRecords(env, zoneId, recordName) {
|
|
275
|
+
const query = recordName ? `?name=${encodeURIComponent(recordName)}` : "";
|
|
276
|
+
const payload = cloudflareApiRequest(`/zones/${encodeURIComponent(zoneId)}/dns_records${query}`, {
|
|
277
|
+
env,
|
|
278
|
+
allowFailure: true
|
|
279
|
+
});
|
|
280
|
+
return Array.isArray(payload?.result) ? payload.result : [];
|
|
281
|
+
}
|
|
282
|
+
function ensureCloudflareDnsRecord(env, zoneId, record) {
|
|
283
|
+
const existing = listCloudflareDnsRecords(env, zoneId, record.name).find((entry) => entry?.name === record.name && entry?.type === record.type);
|
|
284
|
+
if (existing?.id) {
|
|
285
|
+
const unchanged = existing.content === record.content && (record.proxied === void 0 || Boolean(existing.proxied) === Boolean(record.proxied));
|
|
286
|
+
if (unchanged) {
|
|
287
|
+
return existing;
|
|
288
|
+
}
|
|
289
|
+
return cloudflareApiRequest(`/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(existing.id)}`, {
|
|
290
|
+
method: "PATCH",
|
|
291
|
+
env,
|
|
292
|
+
body: {
|
|
293
|
+
type: record.type,
|
|
294
|
+
name: record.name,
|
|
295
|
+
content: record.content,
|
|
296
|
+
...record.proxied === void 0 ? {} : { proxied: record.proxied },
|
|
297
|
+
ttl: record.ttl ?? 1
|
|
298
|
+
}
|
|
299
|
+
})?.result ?? existing;
|
|
300
|
+
}
|
|
301
|
+
return cloudflareApiRequest(`/zones/${encodeURIComponent(zoneId)}/dns_records`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
env,
|
|
304
|
+
body: {
|
|
305
|
+
type: record.type,
|
|
306
|
+
name: record.name,
|
|
307
|
+
content: record.content,
|
|
308
|
+
...record.proxied === void 0 ? {} : { proxied: record.proxied },
|
|
309
|
+
ttl: record.ttl ?? 1
|
|
310
|
+
}
|
|
311
|
+
})?.result ?? null;
|
|
312
|
+
}
|
|
313
|
+
function getCloudflarePagesDomain(env, projectName, domain) {
|
|
314
|
+
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
|
|
315
|
+
if (!accountId) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const payload = cloudflareApiRequest(
|
|
319
|
+
`/accounts/${encodeURIComponent(accountId)}/pages/projects/${encodeURIComponent(projectName)}/domains/${encodeURIComponent(domain)}`,
|
|
320
|
+
{ env, allowFailure: true }
|
|
321
|
+
);
|
|
322
|
+
return payload?.result ?? null;
|
|
323
|
+
}
|
|
324
|
+
function ensureCloudflarePagesDomain(env, projectName, domain) {
|
|
325
|
+
const existing = getCloudflarePagesDomain(env, projectName, domain);
|
|
326
|
+
if (existing?.name || existing?.domain) {
|
|
327
|
+
return existing;
|
|
328
|
+
}
|
|
329
|
+
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
|
|
330
|
+
if (!accountId) {
|
|
331
|
+
throw new Error("Configure CLOUDFLARE_ACCOUNT_ID before reconciling Pages custom domains.");
|
|
332
|
+
}
|
|
333
|
+
const created = cloudflareApiRequest(
|
|
334
|
+
`/accounts/${encodeURIComponent(accountId)}/pages/projects/${encodeURIComponent(projectName)}/domains`,
|
|
335
|
+
{
|
|
336
|
+
method: "POST",
|
|
337
|
+
env,
|
|
338
|
+
body: { name: domain }
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
return created?.result ?? null;
|
|
342
|
+
}
|
|
343
|
+
function normalizeRailwayDomainDnsRecord(value) {
|
|
344
|
+
if (!value || typeof value !== "object") {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
const record = value;
|
|
348
|
+
const rawType = typeof record.recordType === "string" ? record.recordType.trim().toUpperCase() : typeof record.type === "string" ? record.type.trim().toUpperCase() : "";
|
|
349
|
+
const type = rawType.startsWith("DNS_RECORD_TYPE_") ? rawType.replace(/^DNS_RECORD_TYPE_/u, "") : rawType;
|
|
350
|
+
const host = typeof record.fqdn === "string" ? record.fqdn.trim() : typeof record.hostname === "string" ? record.hostname.trim() : typeof record.name === "string" ? record.name.trim() : "";
|
|
351
|
+
const valueText = typeof record.requiredValue === "string" ? record.requiredValue.trim() : typeof record.currentValue === "string" ? record.currentValue.trim() : typeof record.value === "string" ? record.value.trim() : typeof record.link === "string" ? record.link.trim() : "";
|
|
352
|
+
if (!type || !host || !valueText) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
type,
|
|
357
|
+
name: host,
|
|
358
|
+
content: valueText,
|
|
359
|
+
status: typeof record.status === "string" ? record.status.trim().toUpperCase() : ""
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function normalizeRailwayDomainPayload(value) {
|
|
363
|
+
if (!value || typeof value !== "object") {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const record = value;
|
|
367
|
+
const domain = typeof record.domain === "string" ? record.domain.trim() : typeof record.name === "string" ? record.name.trim() : "";
|
|
368
|
+
const dnsRecordCandidates = Array.isArray(record.dnsRecords) ? record.dnsRecords : Array.isArray(record.status?.dnsRecords) ? record.status.dnsRecords : [];
|
|
369
|
+
const dnsRecords = dnsRecordCandidates.map((entry) => normalizeRailwayDomainDnsRecord(entry)).filter(Boolean);
|
|
370
|
+
return {
|
|
371
|
+
id: typeof record.id === "string" ? record.id.trim() : null,
|
|
372
|
+
domain,
|
|
373
|
+
serviceDomain: typeof record.serviceDomain === "string" ? record.serviceDomain.trim() : typeof record.target === "string" ? record.target.trim() : null,
|
|
374
|
+
certificateStatus: typeof record.status?.certificateStatus === "string" ? String(record.status.certificateStatus).trim().toUpperCase() : null,
|
|
375
|
+
verificationDnsHost: typeof record.verificationDnsHost === "string" ? record.verificationDnsHost.trim() : typeof record.status?.verificationDnsHost === "string" ? String(record.status.verificationDnsHost).trim() : null,
|
|
376
|
+
verificationToken: typeof record.verificationToken === "string" ? record.verificationToken.trim() : typeof record.status?.verificationToken === "string" ? String(record.status.verificationToken).trim() : null,
|
|
377
|
+
dnsRecords
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async function ensureRailwayCustomDomain(input, service, domain, env, identifiers) {
|
|
381
|
+
if (identifiers?.projectId && identifiers?.environmentId && identifiers?.serviceId) {
|
|
382
|
+
const existing = await listRailwayCustomDomains({
|
|
383
|
+
projectId: identifiers.projectId,
|
|
384
|
+
environmentId: identifiers.environmentId,
|
|
385
|
+
serviceId: identifiers.serviceId,
|
|
386
|
+
env
|
|
387
|
+
});
|
|
388
|
+
const matched = existing.find((entry) => entry.domain === domain) ?? null;
|
|
389
|
+
if (matched) {
|
|
390
|
+
return matched;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
ensureRailwayProjectContext(service, { env, capture: true });
|
|
394
|
+
const result = runRailway(["domain", domain, "--service", service.serviceName ?? service.serviceId, "--json"], {
|
|
395
|
+
cwd: service.rootDir,
|
|
396
|
+
capture: true,
|
|
397
|
+
allowFailure: true,
|
|
398
|
+
env
|
|
399
|
+
});
|
|
400
|
+
const output = `${result.stderr ?? ""}
|
|
401
|
+
${result.stdout ?? ""}`;
|
|
402
|
+
if (identifiers?.projectId && identifiers?.environmentId && identifiers?.serviceId) {
|
|
403
|
+
const refreshed = await listRailwayCustomDomains({
|
|
404
|
+
projectId: identifiers.projectId,
|
|
405
|
+
environmentId: identifiers.environmentId,
|
|
406
|
+
serviceId: identifiers.serviceId,
|
|
407
|
+
env
|
|
408
|
+
});
|
|
409
|
+
const matched = refreshed.find((entry) => entry.domain === domain) ?? null;
|
|
410
|
+
if (matched) {
|
|
411
|
+
return matched;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (result.status !== 0 && !/already exists|already assigned|taken|has already been taken|not available/iu.test(output)) {
|
|
415
|
+
throw new Error(result.stderr?.trim() || result.stdout?.trim() || `railway domain ${domain} failed`);
|
|
416
|
+
}
|
|
417
|
+
let parsedJson = {};
|
|
418
|
+
if (result.stdout?.trim()) {
|
|
419
|
+
try {
|
|
420
|
+
parsedJson = JSON.parse(result.stdout);
|
|
421
|
+
} catch {
|
|
422
|
+
parsedJson = {};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const parsed = normalizeRailwayDomainPayload(parsedJson);
|
|
426
|
+
return parsed ?? {
|
|
427
|
+
id: null,
|
|
428
|
+
domain,
|
|
429
|
+
serviceDomain: null,
|
|
430
|
+
certificateStatus: null,
|
|
431
|
+
dnsRecords: []
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function collectCloudflareEnvironmentSync(input) {
|
|
435
|
+
const target = toDeployTarget(input.context.target);
|
|
436
|
+
const scope = scopeFromTarget(target);
|
|
437
|
+
const values = resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope);
|
|
438
|
+
const registry = collectTreeseedEnvironmentContext(input.context.tenantRoot);
|
|
439
|
+
const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target });
|
|
440
|
+
const generatedSecrets = buildSecretMap(input.context.deployConfig, state);
|
|
441
|
+
const publicVars = buildPublicVars(input.context.deployConfig);
|
|
442
|
+
const secrets = {};
|
|
443
|
+
const vars = { ...publicVars };
|
|
444
|
+
const secretNames = /* @__PURE__ */ new Set();
|
|
445
|
+
const varNames = new Set(Object.keys(publicVars));
|
|
446
|
+
for (const entry of registry.entries) {
|
|
447
|
+
if (!entry.scopes.includes(scope)) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const value = typeof values[entry.id] === "string" ? values[entry.id] : "";
|
|
451
|
+
if (entry.targets.includes("cloudflare-secret")) {
|
|
452
|
+
const secretValue = value || (typeof generatedSecrets[entry.id] === "string" ? generatedSecrets[entry.id] : "");
|
|
453
|
+
if (secretValue) {
|
|
454
|
+
secrets[entry.id] = secretValue;
|
|
455
|
+
secretNames.add(entry.id);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (entry.targets.includes("cloudflare-var") && value) {
|
|
459
|
+
vars[entry.id] = value;
|
|
460
|
+
varNames.add(entry.id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const [key, value] of Object.entries(generatedSecrets)) {
|
|
464
|
+
if (typeof value === "string" && value.length > 0) {
|
|
465
|
+
secrets[key] = value;
|
|
466
|
+
secretNames.add(key);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return { scope, state, secrets, vars, secretNames: [...secretNames], varNames: [...varNames] };
|
|
470
|
+
}
|
|
471
|
+
function verificationCheck(key, description, source, options) {
|
|
472
|
+
return {
|
|
473
|
+
key,
|
|
474
|
+
description,
|
|
475
|
+
source,
|
|
476
|
+
exists: options.exists,
|
|
477
|
+
configured: options.configured ?? options.exists,
|
|
478
|
+
ready: options.ready ?? options.exists,
|
|
479
|
+
verified: options.verified ?? (options.exists && (options.configured ?? true) && (options.ready ?? true) && (options.issues?.length ?? 0) === 0),
|
|
480
|
+
expected: options.expected,
|
|
481
|
+
observed: options.observed,
|
|
482
|
+
issues: options.issues ?? []
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function summarizeVerification(unitId, checks, warnings = []) {
|
|
486
|
+
const missing = checks.flatMap((check) => !check.exists ? [`${check.key}: ${check.description}`] : []);
|
|
487
|
+
const drifted = checks.flatMap(
|
|
488
|
+
(check) => check.exists && (!check.configured || !check.ready || !check.verified || check.issues.length > 0) ? [`${check.key}: ${check.issues.join("; ") || "verification failed"}`] : []
|
|
489
|
+
);
|
|
490
|
+
return {
|
|
491
|
+
unitId,
|
|
492
|
+
supported: true,
|
|
493
|
+
exists: checks.every((check) => check.exists),
|
|
494
|
+
configured: checks.every((check) => check.configured),
|
|
495
|
+
ready: checks.every((check) => check.ready),
|
|
496
|
+
verified: checks.every((check) => check.verified),
|
|
497
|
+
checks,
|
|
498
|
+
missing,
|
|
499
|
+
drifted,
|
|
500
|
+
warnings
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function unsupportedVerification(unitId, message) {
|
|
504
|
+
return {
|
|
505
|
+
unitId,
|
|
506
|
+
supported: false,
|
|
507
|
+
exists: false,
|
|
508
|
+
configured: false,
|
|
509
|
+
ready: false,
|
|
510
|
+
verified: false,
|
|
511
|
+
checks: [verificationCheck("unsupported", message, "sdk", {
|
|
512
|
+
exists: false,
|
|
513
|
+
configured: false,
|
|
514
|
+
ready: false,
|
|
515
|
+
verified: false,
|
|
516
|
+
issues: [message]
|
|
517
|
+
})],
|
|
518
|
+
missing: [message],
|
|
519
|
+
drifted: [],
|
|
520
|
+
warnings: []
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function syncPagesEnvironmentVariablesForTarget(input, { dryRun = false } = {}) {
|
|
524
|
+
const target = toDeployTarget(input.context.target);
|
|
525
|
+
if (target.kind !== "persistent") {
|
|
526
|
+
return { vars: [], secrets: [] };
|
|
527
|
+
}
|
|
528
|
+
const env = buildCloudflareEnv(input);
|
|
529
|
+
const accountId = env.CLOUDFLARE_ACCOUNT_ID;
|
|
530
|
+
const { state, vars, secrets } = collectCloudflareEnvironmentSync(input);
|
|
531
|
+
if (!accountId || !state.pages?.projectName) {
|
|
532
|
+
return { vars: [], secrets: [] };
|
|
533
|
+
}
|
|
534
|
+
const branchConfigKey = target.scope === "prod" ? "production" : "preview";
|
|
535
|
+
const plainVars = Object.fromEntries(
|
|
536
|
+
Object.entries(vars).filter(([, value]) => typeof value === "string" && value.length > 0).map(([key, value]) => [key, { type: "plain_text", value }])
|
|
537
|
+
);
|
|
538
|
+
const secretVars = Object.fromEntries(
|
|
539
|
+
Object.entries(secrets).filter(([, value]) => typeof value === "string" && value.length > 0).map(([key, value]) => [key, { type: "secret_text", value }])
|
|
540
|
+
);
|
|
541
|
+
const envVars = {
|
|
542
|
+
...plainVars,
|
|
543
|
+
...secretVars
|
|
544
|
+
};
|
|
545
|
+
if (dryRun || Object.keys(envVars).length === 0) {
|
|
546
|
+
return {
|
|
547
|
+
vars: Object.keys(plainVars),
|
|
548
|
+
secrets: Object.keys(secretVars)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const projectPath = `/accounts/${encodeURIComponent(accountId)}/pages/projects/${encodeURIComponent(state.pages.projectName)}`;
|
|
552
|
+
const existing = cloudflareApiRequest(projectPath, { env, allowFailure: true });
|
|
553
|
+
const deploymentConfigs = existing?.result?.deployment_configs && typeof existing.result.deployment_configs === "object" ? existing.result.deployment_configs : {};
|
|
554
|
+
const currentBranchConfig = deploymentConfigs?.[branchConfigKey] && typeof deploymentConfigs[branchConfigKey] === "object" ? deploymentConfigs[branchConfigKey] : {};
|
|
555
|
+
const mergedDeploymentConfigs = {
|
|
556
|
+
...deploymentConfigs,
|
|
557
|
+
[branchConfigKey]: {
|
|
558
|
+
...currentBranchConfig,
|
|
559
|
+
env_vars: {
|
|
560
|
+
...currentBranchConfig?.env_vars ?? {},
|
|
561
|
+
...envVars
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
cloudflareApiRequest(projectPath, {
|
|
566
|
+
method: "PATCH",
|
|
567
|
+
env,
|
|
568
|
+
body: {
|
|
569
|
+
deployment_configs: mergedDeploymentConfigs
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
return {
|
|
573
|
+
vars: Object.keys(plainVars),
|
|
574
|
+
secrets: Object.keys(secretVars)
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
|
|
578
|
+
const target = toDeployTarget(input.context.target);
|
|
579
|
+
const deployConfig = input.context.deployConfig;
|
|
580
|
+
const state = loadDeployState(input.context.tenantRoot, deployConfig, { target });
|
|
581
|
+
const env = buildCloudflareEnv(input);
|
|
582
|
+
const kvNamespaces = dryRun ? [] : listKvNamespaces(input.context.tenantRoot, env);
|
|
583
|
+
const d1Databases = dryRun ? [] : listD1Databases(input.context.tenantRoot, env);
|
|
584
|
+
const queues = dryRun ? [] : listQueues(input.context.tenantRoot, env);
|
|
585
|
+
const buckets = dryRun ? [] : listR2Buckets(input.context.tenantRoot, env);
|
|
586
|
+
const pagesProjects = dryRun ? [] : listPagesProjects(input.context.tenantRoot, env);
|
|
587
|
+
const runStep = (label, fn) => {
|
|
588
|
+
try {
|
|
589
|
+
return fn();
|
|
590
|
+
} catch (error) {
|
|
591
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
592
|
+
throw new Error(`Cloudflare reconcile step ${label} failed: ${message}`);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
const ensureKv = (binding) => {
|
|
596
|
+
const current = state.kvNamespaces[binding];
|
|
597
|
+
if (hasLiveResourceId(current?.id)) {
|
|
598
|
+
const liveById = getCloudflareKvById(env, current.id);
|
|
599
|
+
if (liveById?.id) {
|
|
600
|
+
state.kvNamespaces[binding].previewId = current.previewId ?? current.id;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const existing = kvNamespaces.find((entry) => entry?.title === current.name);
|
|
605
|
+
if (existing?.id) {
|
|
606
|
+
state.kvNamespaces[binding].id = existing.id;
|
|
607
|
+
state.kvNamespaces[binding].previewId = existing.id;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (dryRun) {
|
|
611
|
+
state.kvNamespaces[binding].id = `dryrun-${current.name}`;
|
|
612
|
+
state.kvNamespaces[binding].previewId = `dryrun-${current.name}`;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
runWrangler(["kv", "namespace", "create", current.name], {
|
|
616
|
+
cwd: input.context.tenantRoot,
|
|
617
|
+
capture: true,
|
|
618
|
+
env
|
|
619
|
+
});
|
|
620
|
+
const created = listKvNamespaces(input.context.tenantRoot, env).find((entry) => entry?.title === current.name);
|
|
621
|
+
if (!created?.id) {
|
|
622
|
+
throw new Error(`Unable to resolve created KV namespace id for ${current.name}.`);
|
|
623
|
+
}
|
|
624
|
+
state.kvNamespaces[binding].id = created.id;
|
|
625
|
+
state.kvNamespaces[binding].previewId = created.id;
|
|
626
|
+
};
|
|
627
|
+
const ensureD1 = () => {
|
|
628
|
+
const current = state.d1Databases.SITE_DATA_DB;
|
|
629
|
+
if (hasLiveResourceId(current?.databaseId)) {
|
|
630
|
+
const liveById = getCloudflareD1ById(env, current.databaseId);
|
|
631
|
+
if (liveById?.uuid || liveById?.id) {
|
|
632
|
+
current.previewDatabaseId = current.previewDatabaseId ?? current.databaseId;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const existing = d1Databases.find((entry) => entry?.name === current.databaseName);
|
|
637
|
+
if (existing?.uuid) {
|
|
638
|
+
current.databaseId = existing.uuid;
|
|
639
|
+
current.previewDatabaseId = existing.previewDatabaseUuid ?? existing.uuid;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (dryRun) {
|
|
643
|
+
current.databaseId = `dryrun-${current.databaseName}`;
|
|
644
|
+
current.previewDatabaseId = `dryrun-${current.databaseName}`;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const created2 = cloudflareApiRequest(`/accounts/${encodeURIComponent(env.CLOUDFLARE_ACCOUNT_ID)}/d1/database`, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
env,
|
|
651
|
+
body: {
|
|
652
|
+
name: current.databaseName
|
|
653
|
+
}
|
|
654
|
+
})?.result;
|
|
655
|
+
if (created2?.uuid) {
|
|
656
|
+
current.databaseId = created2.uuid;
|
|
657
|
+
current.previewDatabaseId = created2.previewDatabaseUuid ?? created2.uuid;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
662
|
+
if (!/already exists/i.test(message)) {
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const created = findCloudflareD1ByName(input, env, current.databaseName, { attempts: 12, delayMs: 500 });
|
|
667
|
+
if (!created?.uuid) {
|
|
668
|
+
throw new Error(`Unable to resolve created D1 database id for ${current.databaseName}.`);
|
|
669
|
+
}
|
|
670
|
+
current.databaseId = created.uuid;
|
|
671
|
+
current.previewDatabaseId = created.previewDatabaseUuid ?? created.uuid;
|
|
672
|
+
};
|
|
673
|
+
const ensureQueue = () => {
|
|
674
|
+
const current = state.queues?.agentWork;
|
|
675
|
+
if (!current?.name) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const liveQueue = findCloudflareQueueByName(input, env, current.name, { attempts: 1, delayMs: 0 });
|
|
679
|
+
const liveDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName, { attempts: 1, delayMs: 0 }) : null;
|
|
680
|
+
if (liveQueue) {
|
|
681
|
+
current.queueId = queueId(liveQueue);
|
|
682
|
+
current.dlqId = current.dlqName ? queueId(liveDlq) : null;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
let refreshedQueues = queues;
|
|
686
|
+
const existing = refreshedQueues.find((entry) => queueName(entry) === current.name);
|
|
687
|
+
if (existing) {
|
|
688
|
+
current.queueId = queueId(existing);
|
|
689
|
+
const existingDlq = current.dlqName ? refreshedQueues.find((entry) => queueName(entry) === current.dlqName) : null;
|
|
690
|
+
current.dlqId = queueId(existingDlq);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (dryRun) {
|
|
694
|
+
current.queueId = `dryrun-${current.name}`;
|
|
695
|
+
current.dlqId = current.dlqName ? `dryrun-${current.dlqName}` : null;
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
runWrangler(["queues", "create", current.name], {
|
|
700
|
+
cwd: input.context.tenantRoot,
|
|
701
|
+
capture: true,
|
|
702
|
+
env
|
|
703
|
+
});
|
|
704
|
+
} catch (error) {
|
|
705
|
+
if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
refreshedQueues = listQueues(input.context.tenantRoot, env);
|
|
710
|
+
if (current.dlqName && !refreshedQueues.find((entry) => queueName(entry) === current.dlqName)) {
|
|
711
|
+
try {
|
|
712
|
+
runWrangler(["queues", "create", current.dlqName], {
|
|
713
|
+
cwd: input.context.tenantRoot,
|
|
714
|
+
capture: true,
|
|
715
|
+
env
|
|
716
|
+
});
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const created = findCloudflareQueueByName(input, env, current.name);
|
|
724
|
+
current.queueId = created ? queueId(created) : syntheticQueueLocator(current.name);
|
|
725
|
+
const createdDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName) : null;
|
|
726
|
+
current.dlqId = current.dlqName ? createdDlq ? queueId(createdDlq) : syntheticQueueLocator(current.dlqName) : null;
|
|
727
|
+
};
|
|
728
|
+
const ensureR2Bucket = () => {
|
|
729
|
+
const bucketName = state.content?.bucketName;
|
|
730
|
+
if (!bucketName) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
let refreshedBuckets = buckets;
|
|
734
|
+
const existing = refreshedBuckets.find((entry) => entry?.name === bucketName);
|
|
735
|
+
if (existing || dryRun) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
runWrangler(["r2", "bucket", "create", bucketName], {
|
|
740
|
+
cwd: input.context.tenantRoot,
|
|
741
|
+
capture: true,
|
|
742
|
+
env
|
|
743
|
+
});
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (!isWranglerAlreadyExistsError(error, [/bucket you tried to create already exists, and you own it/i, /\[code:\s*10004\]/i])) {
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
refreshedBuckets = listR2Buckets(input.context.tenantRoot, env);
|
|
750
|
+
};
|
|
751
|
+
const ensurePagesProject = () => {
|
|
752
|
+
const current = state.pages;
|
|
753
|
+
if (!current?.projectName) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const existing = pagesProjects.find((entry) => entry?.name === current.projectName);
|
|
757
|
+
if (existing) {
|
|
758
|
+
if (!dryRun && (existing.production_branch ?? "main") !== (current.productionBranch ?? "main")) {
|
|
759
|
+
cloudflareApiRequest(
|
|
760
|
+
`/accounts/${encodeURIComponent(env.CLOUDFLARE_ACCOUNT_ID)}/pages/projects/${encodeURIComponent(current.projectName)}`,
|
|
761
|
+
{
|
|
762
|
+
method: "PATCH",
|
|
763
|
+
env,
|
|
764
|
+
body: {
|
|
765
|
+
production_branch: current.productionBranch ?? "main"
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
current.url = existing.subdomain ? `https://${existing.subdomain}` : current.url ?? `https://${current.projectName}.pages.dev`;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (dryRun) {
|
|
774
|
+
current.url = `https://${current.projectName}.pages.dev`;
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
runWrangler([
|
|
779
|
+
"pages",
|
|
780
|
+
"project",
|
|
781
|
+
"create",
|
|
782
|
+
current.projectName,
|
|
783
|
+
"--production-branch",
|
|
784
|
+
current.productionBranch ?? "main"
|
|
785
|
+
], {
|
|
786
|
+
cwd: input.context.tenantRoot,
|
|
787
|
+
capture: true,
|
|
788
|
+
env
|
|
789
|
+
});
|
|
790
|
+
} catch (error) {
|
|
791
|
+
if (!isWranglerAlreadyExistsError(error, [/A project with this name already exists/i, /\[code:\s*8000002\]/i])) {
|
|
792
|
+
throw error;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
current.url = `https://${current.projectName}.pages.dev`;
|
|
796
|
+
};
|
|
797
|
+
runStep("kv-form-guard", () => ensureKv("FORM_GUARD_KV"));
|
|
798
|
+
runStep("kv-session", () => ensureKv("SESSION"));
|
|
799
|
+
runStep("d1", ensureD1);
|
|
800
|
+
runStep("queue", ensureQueue);
|
|
801
|
+
runStep("r2", ensureR2Bucket);
|
|
802
|
+
runStep("pages", ensurePagesProject);
|
|
803
|
+
runStep("web-cache", () => reconcileCloudflareWebCacheRules(input.context.tenantRoot, deployConfig, state, target, { dryRun, env }));
|
|
804
|
+
state.readiness.configured = true;
|
|
805
|
+
state.readiness.provisioned = hasProvisionedCloudflareResources(state);
|
|
806
|
+
state.readiness.deployable = state.readiness.provisioned === true;
|
|
807
|
+
state.readiness.phase = state.readiness.provisioned === true ? "provisioned" : "config_complete";
|
|
808
|
+
state.readiness.initialized = true;
|
|
809
|
+
state.readiness.initializedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
810
|
+
state.readiness.lastValidatedAt = state.readiness.initializedAt;
|
|
811
|
+
state.readiness.blockers = [];
|
|
812
|
+
state.readiness.warnings = [];
|
|
813
|
+
state.readiness.lastValidationSummary = {
|
|
814
|
+
cloudflare: state.readiness.provisioned === true ? "ready" : "incomplete",
|
|
815
|
+
railway: "configured"
|
|
816
|
+
};
|
|
817
|
+
writeDeployState(input.context.tenantRoot, state, { target });
|
|
818
|
+
return { state, summary: buildProvisioningSummary(deployConfig, state, target) };
|
|
819
|
+
}
|
|
820
|
+
function syncCloudflareSecretsForTarget(input, { dryRun = false } = {}) {
|
|
821
|
+
const target = toDeployTarget(input.context.target);
|
|
822
|
+
const deployConfig = input.context.deployConfig;
|
|
823
|
+
const state = loadDeployState(input.context.tenantRoot, deployConfig, { target });
|
|
824
|
+
const { secrets } = collectCloudflareEnvironmentSync(input);
|
|
825
|
+
const synced = [];
|
|
826
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
827
|
+
if (!value) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
synced.push(key);
|
|
831
|
+
}
|
|
832
|
+
state.generatedSecrets = {
|
|
833
|
+
...state.generatedSecrets ?? {},
|
|
834
|
+
TREESEED_FORM_TOKEN_SECRET: secrets.TREESEED_FORM_TOKEN_SECRET ?? state.generatedSecrets?.TREESEED_FORM_TOKEN_SECRET,
|
|
835
|
+
TREESEED_EDITORIAL_PREVIEW_SECRET: secrets.TREESEED_EDITORIAL_PREVIEW_SECRET ?? state.generatedSecrets?.TREESEED_EDITORIAL_PREVIEW_SECRET
|
|
836
|
+
};
|
|
837
|
+
writeDeployState(input.context.tenantRoot, state, { target });
|
|
838
|
+
return synced;
|
|
839
|
+
}
|
|
840
|
+
function observeCloudflareUnit(input) {
|
|
841
|
+
const snapshot = cloudflareObservationSnapshot(input);
|
|
842
|
+
const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects } = snapshot;
|
|
843
|
+
switch (input.unit.unitType) {
|
|
844
|
+
case "queue": {
|
|
845
|
+
const liveQueue = queues.find((entry) => queueName(entry) === state.queues?.agentWork?.name);
|
|
846
|
+
const liveDlq = queues.find((entry) => queueName(entry) === state.queues?.agentWork?.dlqName);
|
|
847
|
+
return {
|
|
848
|
+
exists: Boolean(liveQueue || state.queues?.agentWork?.queueId),
|
|
849
|
+
status: liveQueue ? "ready" : "pending",
|
|
850
|
+
live: { ...state.queues?.agentWork ?? {} },
|
|
851
|
+
locators: {
|
|
852
|
+
queueId: queueId(liveQueue) ?? state.queues?.agentWork?.queueId ?? null,
|
|
853
|
+
dlqId: queueId(liveDlq) ?? state.queues?.agentWork?.dlqId ?? null
|
|
854
|
+
},
|
|
855
|
+
warnings: [
|
|
856
|
+
...isSyntheticQueueLocator(state.queues?.agentWork?.queueId) ? ["Cloudflare queue id is pending propagation; using queue-name fallback."] : [],
|
|
857
|
+
...isSyntheticQueueLocator(state.queues?.agentWork?.dlqId) ? ["Cloudflare dead-letter queue id is pending propagation; using queue-name fallback."] : []
|
|
858
|
+
]
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
case "database": {
|
|
862
|
+
const liveDatabase = d1Databases.find((entry) => entry?.name === state.d1Databases?.SITE_DATA_DB?.databaseName);
|
|
863
|
+
return {
|
|
864
|
+
exists: Boolean(liveDatabase || hasLiveResourceId(state.d1Databases?.SITE_DATA_DB?.databaseId)),
|
|
865
|
+
status: liveDatabase ? "ready" : "pending",
|
|
866
|
+
live: { ...state.d1Databases?.SITE_DATA_DB ?? {} },
|
|
867
|
+
locators: {
|
|
868
|
+
databaseId: liveDatabase?.uuid ?? state.d1Databases?.SITE_DATA_DB?.databaseId ?? null
|
|
869
|
+
},
|
|
870
|
+
warnings: []
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
case "content-store": {
|
|
874
|
+
const liveBucket = buckets.find((entry) => entry?.name === state.content?.bucketName);
|
|
875
|
+
return {
|
|
876
|
+
exists: Boolean(liveBucket || state.content?.bucketName),
|
|
877
|
+
status: liveBucket ? "ready" : "pending",
|
|
878
|
+
live: { ...state.content ?? {} },
|
|
879
|
+
locators: {
|
|
880
|
+
bucketName: liveBucket?.name ?? state.content?.bucketName ?? null
|
|
881
|
+
},
|
|
882
|
+
warnings: []
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
case "kv-form-guard": {
|
|
886
|
+
const liveNamespace = kvNamespaces.find((entry) => entry?.title === state.kvNamespaces?.FORM_GUARD_KV?.name);
|
|
887
|
+
return {
|
|
888
|
+
exists: Boolean(liveNamespace || hasLiveResourceId(state.kvNamespaces?.FORM_GUARD_KV?.id)),
|
|
889
|
+
status: liveNamespace ? "ready" : "pending",
|
|
890
|
+
live: { ...state.kvNamespaces?.FORM_GUARD_KV ?? {} },
|
|
891
|
+
locators: { id: liveNamespace?.id ?? state.kvNamespaces?.FORM_GUARD_KV?.id ?? null },
|
|
892
|
+
warnings: []
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
case "kv-session": {
|
|
896
|
+
const liveNamespace = kvNamespaces.find((entry) => entry?.title === state.kvNamespaces?.SESSION?.name);
|
|
897
|
+
return {
|
|
898
|
+
exists: Boolean(liveNamespace || hasLiveResourceId(state.kvNamespaces?.SESSION?.id)),
|
|
899
|
+
status: liveNamespace ? "ready" : "pending",
|
|
900
|
+
live: { ...state.kvNamespaces?.SESSION ?? {} },
|
|
901
|
+
locators: { id: liveNamespace?.id ?? state.kvNamespaces?.SESSION?.id ?? null },
|
|
902
|
+
warnings: []
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
case "pages-project": {
|
|
906
|
+
const liveProject = pagesProjects.find((entry) => entry?.name === state.pages?.projectName);
|
|
907
|
+
return {
|
|
908
|
+
exists: Boolean(liveProject || state.pages?.projectName),
|
|
909
|
+
status: liveProject ? "ready" : "pending",
|
|
910
|
+
live: { ...state.pages ?? {} },
|
|
911
|
+
locators: {
|
|
912
|
+
projectName: liveProject?.name ?? state.pages?.projectName ?? null,
|
|
913
|
+
url: liveProject?.subdomain ? `https://${liveProject.subdomain}` : state.pages?.url ?? null
|
|
914
|
+
},
|
|
915
|
+
warnings: []
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
case "edge-worker":
|
|
919
|
+
return {
|
|
920
|
+
exists: Boolean(state.workerName),
|
|
921
|
+
status: state.workerName ? "ready" : "pending",
|
|
922
|
+
live: { workerName: state.workerName, lastDeployedUrl: state.lastDeployedUrl ?? null },
|
|
923
|
+
locators: { workerName: state.workerName ?? null, url: state.lastDeployedUrl ?? null },
|
|
924
|
+
warnings: []
|
|
925
|
+
};
|
|
926
|
+
default:
|
|
927
|
+
return noopObservedState(input);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function verifyCloudflareUnitOnce(input, postconditions) {
|
|
931
|
+
if (input.unit.unitType === "edge-worker") {
|
|
932
|
+
const target = toDeployTarget(input.context.target);
|
|
933
|
+
const state2 = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target });
|
|
934
|
+
return summarizeVerification(input.unit.unitId, [
|
|
935
|
+
verificationCheck("edge-worker.generated", "Generated Cloudflare worker config exists for the web runtime", "sdk", {
|
|
936
|
+
exists: Boolean(state2.workerName),
|
|
937
|
+
expected: state2.workerName ?? null,
|
|
938
|
+
observed: state2.workerName ?? null,
|
|
939
|
+
issues: state2.workerName ? [] : ["Generated Cloudflare worker runtime metadata is missing."]
|
|
940
|
+
})
|
|
941
|
+
]);
|
|
942
|
+
}
|
|
943
|
+
const snapshot = cloudflareObservationSnapshot(input, true);
|
|
944
|
+
const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, env } = snapshot;
|
|
945
|
+
switch (input.unit.unitType) {
|
|
946
|
+
case "queue": {
|
|
947
|
+
const queue = state.queues?.agentWork;
|
|
948
|
+
const liveQueue = findCloudflareQueueByName(input, env, queue?.name, { attempts: 12, delayMs: 500 });
|
|
949
|
+
const liveDlq = queue?.dlqName ? findCloudflareQueueByName(input, env, queue.dlqName, { attempts: 12, delayMs: 500 }) : null;
|
|
950
|
+
return summarizeVerification(input.unit.unitId, [
|
|
951
|
+
verificationCheck("queue.exists", "Queue exists by name and id", "cli", {
|
|
952
|
+
exists: Boolean(liveQueue && queueId(liveQueue)),
|
|
953
|
+
expected: queue?.name ?? null,
|
|
954
|
+
observed: liveQueue ? { name: queueName(liveQueue), id: queueId(liveQueue) } : null,
|
|
955
|
+
issues: liveQueue && queueId(liveQueue) ? [] : [`Cloudflare queue ${queue?.name ?? "(unset)"} was not found after reconcile.`]
|
|
956
|
+
}),
|
|
957
|
+
verificationCheck("queue.dlq", "Dead-letter queue exists by name and id", "cli", {
|
|
958
|
+
exists: !queue?.dlqName || Boolean(liveDlq && queueId(liveDlq)),
|
|
959
|
+
expected: queue?.dlqName ?? null,
|
|
960
|
+
observed: liveDlq ? { name: queueName(liveDlq), id: queueId(liveDlq) } : null,
|
|
961
|
+
issues: !queue?.dlqName || liveDlq && queueId(liveDlq) ? [] : [`Cloudflare dead-letter queue ${queue.dlqName} was not found after reconcile.`]
|
|
962
|
+
}),
|
|
963
|
+
verificationCheck("queue.binding", "Queue binding matches desired config", "sdk", {
|
|
964
|
+
exists: Boolean(queue?.binding),
|
|
965
|
+
configured: queue?.binding === input.unit.spec.binding,
|
|
966
|
+
expected: input.unit.spec.binding,
|
|
967
|
+
observed: queue?.binding ?? null,
|
|
968
|
+
issues: queue?.binding === input.unit.spec.binding ? [] : ["Configured queue binding does not match the desired value."]
|
|
969
|
+
})
|
|
970
|
+
], postconditions.length > 0 ? [] : []);
|
|
971
|
+
}
|
|
972
|
+
case "database": {
|
|
973
|
+
const db = state.d1Databases?.SITE_DATA_DB;
|
|
974
|
+
const live = getCloudflareD1ById(env, db?.databaseId) ?? findCloudflareD1ByName(input, env, db?.databaseName, { attempts: 12, delayMs: 500 });
|
|
975
|
+
const liveDatabaseId = live?.uuid ?? live?.id ?? null;
|
|
976
|
+
return summarizeVerification(input.unit.unitId, [
|
|
977
|
+
verificationCheck("database.exists", "D1 database exists by name and id", "cli", {
|
|
978
|
+
exists: Boolean(liveDatabaseId),
|
|
979
|
+
expected: db?.databaseName ?? null,
|
|
980
|
+
observed: live ? { name: live.name, id: liveDatabaseId } : null,
|
|
981
|
+
issues: liveDatabaseId ? [] : [`Cloudflare D1 database ${db?.databaseName ?? "(unset)"} was not found after reconcile.`]
|
|
982
|
+
}),
|
|
983
|
+
verificationCheck("database.binding", "Database binding matches desired config", "sdk", {
|
|
984
|
+
exists: Boolean(db?.binding),
|
|
985
|
+
configured: db?.binding === input.unit.spec.binding,
|
|
986
|
+
expected: input.unit.spec.binding,
|
|
987
|
+
observed: db?.binding ?? null,
|
|
988
|
+
issues: db?.binding === input.unit.spec.binding ? [] : ["Configured D1 binding does not match the desired value."]
|
|
989
|
+
})
|
|
990
|
+
]);
|
|
991
|
+
}
|
|
992
|
+
case "kv-form-guard":
|
|
993
|
+
case "kv-session": {
|
|
994
|
+
const binding = input.unit.unitType === "kv-form-guard" ? "FORM_GUARD_KV" : "SESSION";
|
|
995
|
+
const namespace = state.kvNamespaces?.[binding];
|
|
996
|
+
const live = getCloudflareKvById(env, namespace?.id) ?? kvNamespaces.find((entry) => entry?.title === namespace?.name);
|
|
997
|
+
return summarizeVerification(input.unit.unitId, [
|
|
998
|
+
verificationCheck("kv.exists", "KV namespace exists by title and id", "cli", {
|
|
999
|
+
exists: Boolean(live?.id),
|
|
1000
|
+
expected: namespace?.name ?? null,
|
|
1001
|
+
observed: live ? { title: live.title, id: live.id } : null,
|
|
1002
|
+
issues: live?.id ? [] : [`Cloudflare KV namespace ${namespace?.name ?? "(unset)"} was not found after reconcile.`]
|
|
1003
|
+
}),
|
|
1004
|
+
verificationCheck("kv.binding", "KV binding matches desired config", "sdk", {
|
|
1005
|
+
exists: Boolean(namespace?.binding),
|
|
1006
|
+
configured: namespace?.binding === input.unit.spec.binding,
|
|
1007
|
+
expected: input.unit.spec.binding,
|
|
1008
|
+
observed: namespace?.binding ?? null,
|
|
1009
|
+
issues: namespace?.binding === input.unit.spec.binding ? [] : ["Configured KV binding does not match the desired value."]
|
|
1010
|
+
})
|
|
1011
|
+
]);
|
|
1012
|
+
}
|
|
1013
|
+
case "content-store": {
|
|
1014
|
+
const bucketName = state.content?.bucketName;
|
|
1015
|
+
const live = buckets.find((entry) => entry?.name === bucketName);
|
|
1016
|
+
return summarizeVerification(input.unit.unitId, [
|
|
1017
|
+
verificationCheck("r2.exists", "R2 bucket exists by name", "cli", {
|
|
1018
|
+
exists: Boolean(live?.name),
|
|
1019
|
+
expected: bucketName ?? null,
|
|
1020
|
+
observed: live?.name ?? null,
|
|
1021
|
+
issues: live?.name ? [] : [`Cloudflare R2 bucket ${bucketName ?? "(unset)"} was not found after reconcile.`]
|
|
1022
|
+
}),
|
|
1023
|
+
verificationCheck("r2.binding", "R2 binding matches desired config", "sdk", {
|
|
1024
|
+
exists: Boolean(state.content?.r2Binding),
|
|
1025
|
+
configured: state.content?.r2Binding === input.unit.spec.binding,
|
|
1026
|
+
expected: input.unit.spec.binding,
|
|
1027
|
+
observed: state.content?.r2Binding ?? null,
|
|
1028
|
+
issues: state.content?.r2Binding === input.unit.spec.binding ? [] : ["Configured R2 binding does not match the desired value."]
|
|
1029
|
+
})
|
|
1030
|
+
]);
|
|
1031
|
+
}
|
|
1032
|
+
case "pages-project": {
|
|
1033
|
+
const current = state.pages;
|
|
1034
|
+
const liveProject = pagesProjects.find((entry) => entry?.name === current?.projectName);
|
|
1035
|
+
if (!env.CLOUDFLARE_ACCOUNT_ID || !current?.projectName) {
|
|
1036
|
+
return unsupportedVerification(input.unit.unitId, "Cloudflare Pages verification requires CLOUDFLARE_ACCOUNT_ID and a configured project name.");
|
|
1037
|
+
}
|
|
1038
|
+
const project = cloudflareApiRequest(
|
|
1039
|
+
`/accounts/${encodeURIComponent(env.CLOUDFLARE_ACCOUNT_ID)}/pages/projects/${encodeURIComponent(current.projectName)}`,
|
|
1040
|
+
{ env, allowFailure: true }
|
|
1041
|
+
)?.result;
|
|
1042
|
+
const branchKey = input.context.target.kind === "persistent" && input.context.target.scope === "prod" ? "production" : "preview";
|
|
1043
|
+
const branchConfig = project?.deployment_configs?.[branchKey] ?? {};
|
|
1044
|
+
const envVars = branchConfig?.env_vars && typeof branchConfig.env_vars === "object" ? branchConfig.env_vars : {};
|
|
1045
|
+
const sync = collectCloudflareEnvironmentSync(input);
|
|
1046
|
+
const expectedVars = Object.entries(sync.vars).filter(([, value]) => typeof value === "string" && value.length > 0);
|
|
1047
|
+
const checks = [
|
|
1048
|
+
verificationCheck("pages.exists", "Pages project exists", "cli", {
|
|
1049
|
+
exists: Boolean(liveProject?.name || project?.name),
|
|
1050
|
+
expected: current.projectName,
|
|
1051
|
+
observed: liveProject?.name ?? project?.name ?? null,
|
|
1052
|
+
issues: liveProject?.name || project?.name ? [] : [`Cloudflare Pages project ${current.projectName} was not found after reconcile.`]
|
|
1053
|
+
})
|
|
1054
|
+
];
|
|
1055
|
+
if (input.context.target.kind === "persistent" && input.context.target.scope === "prod") {
|
|
1056
|
+
checks.push(verificationCheck("pages.production-branch", "Pages production branch matches desired config", "api", {
|
|
1057
|
+
exists: typeof project?.production_branch === "string" && project.production_branch.length > 0,
|
|
1058
|
+
configured: (project?.production_branch ?? current.productionBranch ?? "main") === (current.productionBranch ?? "main"),
|
|
1059
|
+
expected: current.productionBranch ?? "main",
|
|
1060
|
+
observed: project?.production_branch ?? null,
|
|
1061
|
+
issues: (project?.production_branch ?? current.productionBranch ?? "main") === (current.productionBranch ?? "main") ? [] : ["Pages production branch does not match the desired value."]
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
for (const [name, expectedValue] of expectedVars) {
|
|
1065
|
+
checks.push(verificationCheck(`pages.var:${name}`, `Pages variable ${name} exists with the expected value`, "api", {
|
|
1066
|
+
exists: Boolean(envVars[name]),
|
|
1067
|
+
configured: envVars[name]?.value === expectedValue,
|
|
1068
|
+
expected: expectedValue,
|
|
1069
|
+
observed: envVars[name]?.value ?? null,
|
|
1070
|
+
issues: envVars[name]?.value === expectedValue ? [] : [`Pages variable ${name} does not match the expected value for ${branchKey}.`]
|
|
1071
|
+
}));
|
|
1072
|
+
}
|
|
1073
|
+
for (const name of sync.secretNames) {
|
|
1074
|
+
checks.push(verificationCheck(`pages.secret:${name}`, `Pages secret ${name} exists`, "api", {
|
|
1075
|
+
exists: Boolean(envVars[name]),
|
|
1076
|
+
expected: true,
|
|
1077
|
+
observed: Boolean(envVars[name]),
|
|
1078
|
+
issues: envVars[name] ? [] : [`Pages secret ${name} is missing from the ${branchKey} deployment config.`]
|
|
1079
|
+
}));
|
|
1080
|
+
}
|
|
1081
|
+
return summarizeVerification(input.unit.unitId, checks);
|
|
1082
|
+
}
|
|
1083
|
+
default:
|
|
1084
|
+
return unsupportedVerification(input.unit.unitId, `Cloudflare unit type ${input.unit.unitType} does not declare verification logic.`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function verifyCloudflareUnit(input, postconditions) {
|
|
1088
|
+
let attempt = 0;
|
|
1089
|
+
for (; ; ) {
|
|
1090
|
+
try {
|
|
1091
|
+
return verifyCloudflareUnitOnce(input, postconditions);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (attempt >= 2 || !isTransientCloudflareReconcileError(error)) {
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
attempt += 1;
|
|
1097
|
+
sleepMs(500 * attempt);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function buildCloudflareDiff(input, observed) {
|
|
1102
|
+
if (!observed.exists) {
|
|
1103
|
+
return {
|
|
1104
|
+
action: "create",
|
|
1105
|
+
reasons: ["resource missing"],
|
|
1106
|
+
before: observed.live,
|
|
1107
|
+
after: input.unit.spec
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
const locatorValues = Object.values(observed.locators).filter(Boolean);
|
|
1111
|
+
return {
|
|
1112
|
+
action: locatorValues.length > 0 ? "reuse" : "update",
|
|
1113
|
+
reasons: locatorValues.length > 0 ? ["resource already present"] : ["resource partially configured"],
|
|
1114
|
+
before: observed.live,
|
|
1115
|
+
after: input.unit.spec
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
function reconcileCloudflareUnit(input, diff) {
|
|
1119
|
+
const cacheKey = `cloudflare:reconcile:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
|
|
1120
|
+
const { state } = providerCache(input, cacheKey, () => {
|
|
1121
|
+
let attempt = 0;
|
|
1122
|
+
for (; ; ) {
|
|
1123
|
+
try {
|
|
1124
|
+
const reconciled = reconcileCloudflareTarget(input);
|
|
1125
|
+
syncCloudflareSecretsForTarget(input);
|
|
1126
|
+
syncPagesEnvironmentVariablesForTarget(input);
|
|
1127
|
+
return reconciled;
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
if (attempt >= 2 || !isTransientCloudflareReconcileError(error)) {
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
1132
|
+
attempt += 1;
|
|
1133
|
+
sleepMs(500 * attempt);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
const refreshed = observeCloudflareUnit(input);
|
|
1138
|
+
return {
|
|
1139
|
+
unit: input.unit,
|
|
1140
|
+
observed: refreshed,
|
|
1141
|
+
diff,
|
|
1142
|
+
action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
|
|
1143
|
+
warnings: refreshed.warnings,
|
|
1144
|
+
resourceLocators: refreshed.locators,
|
|
1145
|
+
state: input.unit.unitType === "edge-worker" ? { workerName: state.workerName, lastDeployedUrl: state.lastDeployedUrl ?? null } : refreshed.live
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
function buildCloudflareAdapter(unitType) {
|
|
1149
|
+
return {
|
|
1150
|
+
providerId: "cloudflare",
|
|
1151
|
+
unitTypes: [unitType],
|
|
1152
|
+
supports(candidateUnitType, providerId) {
|
|
1153
|
+
return providerId === "cloudflare" && candidateUnitType === unitType;
|
|
1154
|
+
},
|
|
1155
|
+
observe(input) {
|
|
1156
|
+
return observeCloudflareUnit(input);
|
|
1157
|
+
},
|
|
1158
|
+
requiredPostconditions(input) {
|
|
1159
|
+
switch (input.unit.unitType) {
|
|
1160
|
+
case "queue":
|
|
1161
|
+
return [
|
|
1162
|
+
{ key: "queue.exists", description: "Queue exists by name and id" },
|
|
1163
|
+
{ key: "queue.dlq", description: "Dead-letter queue exists by name and id when configured" },
|
|
1164
|
+
{ key: "queue.binding", description: "Queue binding matches desired config" }
|
|
1165
|
+
];
|
|
1166
|
+
case "database":
|
|
1167
|
+
return [
|
|
1168
|
+
{ key: "database.exists", description: "D1 database exists by name and id" },
|
|
1169
|
+
{ key: "database.binding", description: "D1 binding matches desired config" }
|
|
1170
|
+
];
|
|
1171
|
+
case "kv-form-guard":
|
|
1172
|
+
case "kv-session":
|
|
1173
|
+
return [
|
|
1174
|
+
{ key: "kv.exists", description: "KV namespace exists by title and id" },
|
|
1175
|
+
{ key: "kv.binding", description: "KV binding matches desired config" }
|
|
1176
|
+
];
|
|
1177
|
+
case "content-store":
|
|
1178
|
+
return [
|
|
1179
|
+
{ key: "r2.exists", description: "R2 bucket exists by name" },
|
|
1180
|
+
{ key: "r2.binding", description: "R2 binding matches desired config" }
|
|
1181
|
+
];
|
|
1182
|
+
case "pages-project":
|
|
1183
|
+
return [
|
|
1184
|
+
{ key: "pages.exists", description: "Pages project exists" },
|
|
1185
|
+
{ key: "pages.production-branch", description: "Pages production branch matches desired config" }
|
|
1186
|
+
];
|
|
1187
|
+
case "edge-worker":
|
|
1188
|
+
return [
|
|
1189
|
+
{ key: "edge-worker.generated", description: "Generated web runtime metadata exists" }
|
|
1190
|
+
];
|
|
1191
|
+
default:
|
|
1192
|
+
return [];
|
|
1193
|
+
}
|
|
1194
|
+
},
|
|
1195
|
+
plan(input) {
|
|
1196
|
+
return buildCloudflareDiff(input, input.observed);
|
|
1197
|
+
},
|
|
1198
|
+
reconcile(input) {
|
|
1199
|
+
return reconcileCloudflareUnit(input, input.diff);
|
|
1200
|
+
},
|
|
1201
|
+
verify(input) {
|
|
1202
|
+
return verifyCloudflareUnit(input, input.postconditions);
|
|
1203
|
+
},
|
|
1204
|
+
destroy(input) {
|
|
1205
|
+
const cacheKey = `cloudflare:destroy:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
|
|
1206
|
+
providerCache(input, cacheKey, () => destroyCloudflareResources(input.context.tenantRoot, { target: toDeployTarget(input.context.target) }));
|
|
1207
|
+
return {
|
|
1208
|
+
unit: input.unit,
|
|
1209
|
+
observed: input.observed,
|
|
1210
|
+
diff: {
|
|
1211
|
+
action: "destroy",
|
|
1212
|
+
reasons: ["target destroyed"],
|
|
1213
|
+
before: input.observed.live,
|
|
1214
|
+
after: {}
|
|
1215
|
+
},
|
|
1216
|
+
action: "destroy",
|
|
1217
|
+
warnings: [],
|
|
1218
|
+
resourceLocators: {},
|
|
1219
|
+
state: {},
|
|
1220
|
+
verification: null
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
function relativeRailwayRootDir(tenantRoot, serviceRoot) {
|
|
1226
|
+
const resolved = relative(tenantRoot, serviceRoot).replace(/\\/gu, "/");
|
|
1227
|
+
return !resolved || resolved === "" ? "." : resolved;
|
|
1228
|
+
}
|
|
1229
|
+
async function resolveRailwayTopologyForScope(input, scope, {
|
|
1230
|
+
ensure = false,
|
|
1231
|
+
refresh = false,
|
|
1232
|
+
serviceKeys,
|
|
1233
|
+
includeInstances = ensure,
|
|
1234
|
+
includeVariables = false
|
|
1235
|
+
} = {}) {
|
|
1236
|
+
const normalizedServiceKeys = Array.isArray(serviceKeys) && serviceKeys.length > 0 ? [...new Set(serviceKeys.map((value) => String(value).trim()).filter(Boolean))].sort() : ["__all__"];
|
|
1237
|
+
const cacheKey = `railway:topology:${scope}:${ensure ? "ensure" : "observe"}:${includeInstances ? "instances" : "no-instances"}:${includeVariables ? "variables" : "no-variables"}:${normalizedServiceKeys.join(",")}`;
|
|
1238
|
+
return await providerCache(input, cacheKey, async () => {
|
|
1239
|
+
const env = buildRailwayEnv(input, scope);
|
|
1240
|
+
const deployState = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
1241
|
+
const services = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => normalizedServiceKeys.includes("__all__") || normalizedServiceKeys.includes(service.key));
|
|
1242
|
+
let workspace = null;
|
|
1243
|
+
const knownProjects = [];
|
|
1244
|
+
const knownProjectIds = [...new Set(services.map((service) => service.projectId || deployState.services?.[service.key]?.projectId || "").filter((value) => typeof value === "string" && value.trim().length > 0).map((value) => value.trim()))];
|
|
1245
|
+
for (const projectId of knownProjectIds) {
|
|
1246
|
+
const project = await getRailwayProject({ projectId, env });
|
|
1247
|
+
if (project) {
|
|
1248
|
+
knownProjects.push(project);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (knownProjects.length === 0 || services.some((service) => !(service.projectId || deployState.services?.[service.key]?.projectId))) {
|
|
1252
|
+
workspace = await resolveRailwayWorkspaceContext({ env });
|
|
1253
|
+
const listedProjects = await listRailwayProjects({
|
|
1254
|
+
env,
|
|
1255
|
+
workspaceId: workspace.id
|
|
1256
|
+
});
|
|
1257
|
+
for (const project of listedProjects) {
|
|
1258
|
+
if (!knownProjects.find((entry) => entry?.id === project.id)) {
|
|
1259
|
+
knownProjects.push(project);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const projectsByKey = /* @__PURE__ */ new Map();
|
|
1264
|
+
for (const project of knownProjects) {
|
|
1265
|
+
if (!project) {
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
projectsByKey.set(project.id, project);
|
|
1269
|
+
projectsByKey.set(project.name, project);
|
|
1270
|
+
}
|
|
1271
|
+
const resolvedServices = /* @__PURE__ */ new Map();
|
|
1272
|
+
for (const service of services) {
|
|
1273
|
+
const persistedService = deployState.services?.[service.key] ?? {};
|
|
1274
|
+
const resolvedProjectId = service.projectId ?? persistedService.projectId ?? "";
|
|
1275
|
+
const resolvedProjectName = service.projectName ?? persistedService.projectName ?? "";
|
|
1276
|
+
const resolvedServiceId = service.serviceId ?? persistedService.serviceId ?? "";
|
|
1277
|
+
const resolvedServiceName = service.serviceName ?? persistedService.serviceName ?? "";
|
|
1278
|
+
let project = projectsByKey.get(resolvedProjectId) ?? projectsByKey.get(resolvedProjectName) ?? null;
|
|
1279
|
+
if (!project && ensure) {
|
|
1280
|
+
if (!workspace) {
|
|
1281
|
+
workspace = await resolveRailwayWorkspaceContext({ env });
|
|
1282
|
+
}
|
|
1283
|
+
const ensuredProject = await ensureRailwayProject({
|
|
1284
|
+
projectId: resolvedProjectId,
|
|
1285
|
+
projectName: resolvedProjectName,
|
|
1286
|
+
defaultEnvironmentName: service.railwayEnvironment || "staging",
|
|
1287
|
+
env,
|
|
1288
|
+
workspace: workspace.name
|
|
1289
|
+
});
|
|
1290
|
+
project = ensuredProject.project;
|
|
1291
|
+
projectsByKey.set(project.id, project);
|
|
1292
|
+
projectsByKey.set(project.name, project);
|
|
1293
|
+
}
|
|
1294
|
+
let environment = project?.environments.find((entry) => entry.name === service.railwayEnvironment || entry.id === service.railwayEnvironment) ?? null;
|
|
1295
|
+
if (project && !environment && ensure) {
|
|
1296
|
+
environment = (await ensureRailwayEnvironment({
|
|
1297
|
+
projectId: project.id,
|
|
1298
|
+
environmentName: service.railwayEnvironment,
|
|
1299
|
+
env
|
|
1300
|
+
})).environment;
|
|
1301
|
+
project = {
|
|
1302
|
+
...project,
|
|
1303
|
+
environments: [...project.environments.filter((entry) => entry.id !== environment?.id), environment]
|
|
1304
|
+
};
|
|
1305
|
+
projectsByKey.set(project.id, project);
|
|
1306
|
+
projectsByKey.set(project.name, project);
|
|
1307
|
+
}
|
|
1308
|
+
let resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
|
|
1309
|
+
if (project && !resolvedService && ensure) {
|
|
1310
|
+
resolvedService = (await ensureRailwayService({
|
|
1311
|
+
projectId: project.id,
|
|
1312
|
+
serviceId: resolvedServiceId,
|
|
1313
|
+
serviceName: resolvedServiceName,
|
|
1314
|
+
env
|
|
1315
|
+
})).service;
|
|
1316
|
+
project = {
|
|
1317
|
+
...project,
|
|
1318
|
+
services: [...project.services.filter((entry) => entry.id !== resolvedService?.id), resolvedService]
|
|
1319
|
+
};
|
|
1320
|
+
projectsByKey.set(project.id, project);
|
|
1321
|
+
projectsByKey.set(project.name, project);
|
|
1322
|
+
}
|
|
1323
|
+
let instance = null;
|
|
1324
|
+
if (includeInstances && resolvedService && environment) {
|
|
1325
|
+
if (ensure) {
|
|
1326
|
+
instance = (await ensureRailwayServiceInstanceConfiguration({
|
|
1327
|
+
serviceId: resolvedService.id,
|
|
1328
|
+
environmentId: environment.id,
|
|
1329
|
+
buildCommand: service.buildCommand,
|
|
1330
|
+
startCommand: service.startCommand,
|
|
1331
|
+
rootDirectory: relativeRailwayRootDir(input.context.tenantRoot, service.rootDir),
|
|
1332
|
+
healthcheckPath: service.healthcheckPath,
|
|
1333
|
+
healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
|
|
1334
|
+
healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
|
|
1335
|
+
restartPolicy: service.restartPolicy,
|
|
1336
|
+
runtimeMode: service.runtimeMode,
|
|
1337
|
+
env
|
|
1338
|
+
})).instance;
|
|
1339
|
+
} else {
|
|
1340
|
+
instance = await getRailwayServiceInstance({
|
|
1341
|
+
serviceId: resolvedService.id,
|
|
1342
|
+
environmentId: environment.id,
|
|
1343
|
+
env
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
const currentVariables = includeVariables && project && environment && resolvedService ? await listRailwayVariables({
|
|
1348
|
+
projectId: project.id,
|
|
1349
|
+
environmentId: environment.id,
|
|
1350
|
+
serviceId: resolvedService.id,
|
|
1351
|
+
env
|
|
1352
|
+
}) : {};
|
|
1353
|
+
resolvedServices.set(service.key, {
|
|
1354
|
+
configuredService: service,
|
|
1355
|
+
project,
|
|
1356
|
+
environment,
|
|
1357
|
+
service: resolvedService,
|
|
1358
|
+
instance,
|
|
1359
|
+
currentVariables
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
return {
|
|
1363
|
+
scope,
|
|
1364
|
+
env,
|
|
1365
|
+
workspace: workspace ?? {
|
|
1366
|
+
id: "",
|
|
1367
|
+
name: String(env.TREESEED_RAILWAY_WORKSPACE ?? "").trim()
|
|
1368
|
+
},
|
|
1369
|
+
services: resolvedServices
|
|
1370
|
+
};
|
|
1371
|
+
}, refresh);
|
|
1372
|
+
}
|
|
1373
|
+
async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
|
|
1374
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1375
|
+
const sync = collectRailwayEnvironmentSync(input);
|
|
1376
|
+
const topology = await resolveRailwayTopologyForScope(input, scope, {
|
|
1377
|
+
ensure: !dryRun,
|
|
1378
|
+
includeInstances: !dryRun,
|
|
1379
|
+
includeVariables: false
|
|
1380
|
+
});
|
|
1381
|
+
const workerEntry = topology.services.get("worker") ?? null;
|
|
1382
|
+
const railwayRuntimeVariables = Object.fromEntries(
|
|
1383
|
+
[
|
|
1384
|
+
["TREESEED_RAILWAY_PROJECT_ID", workerEntry?.project?.id],
|
|
1385
|
+
["TREESEED_RAILWAY_ENVIRONMENT_ID", workerEntry?.environment?.id],
|
|
1386
|
+
["TREESEED_RAILWAY_WORKER_SERVICE_ID", workerEntry?.service?.id]
|
|
1387
|
+
].filter((entry) => typeof entry[1] === "string" && entry[1].length > 0)
|
|
1388
|
+
);
|
|
1389
|
+
if (!dryRun) {
|
|
1390
|
+
const combinedVariables = {
|
|
1391
|
+
...sync.variables,
|
|
1392
|
+
...railwayRuntimeVariables,
|
|
1393
|
+
...sync.secrets
|
|
1394
|
+
};
|
|
1395
|
+
for (const entry of topology.services.values()) {
|
|
1396
|
+
if (!entry.project || !entry.environment || !entry.service) {
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
await upsertRailwayVariables({
|
|
1400
|
+
projectId: entry.project.id,
|
|
1401
|
+
environmentId: entry.environment.id,
|
|
1402
|
+
serviceId: entry.service.id,
|
|
1403
|
+
variables: combinedVariables,
|
|
1404
|
+
env: topology.env
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
scope,
|
|
1410
|
+
services: [...topology.services.values()].map((entry) => entry.configuredService),
|
|
1411
|
+
secrets: Object.keys(sync.secrets),
|
|
1412
|
+
variables: Object.keys(sync.variables),
|
|
1413
|
+
dryRun,
|
|
1414
|
+
workspace: topology.workspace.name
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
1418
|
+
let attempt = 0;
|
|
1419
|
+
for (; ; ) {
|
|
1420
|
+
try {
|
|
1421
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1422
|
+
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
1423
|
+
const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
1424
|
+
const persisted = state.services?.[serviceKey] ?? {};
|
|
1425
|
+
const topology = await resolveRailwayTopologyForScope(input, scope, {
|
|
1426
|
+
refresh,
|
|
1427
|
+
serviceKeys: [serviceKey],
|
|
1428
|
+
includeInstances: false,
|
|
1429
|
+
includeVariables: false
|
|
1430
|
+
});
|
|
1431
|
+
const entry = topology.services.get(serviceKey) ?? null;
|
|
1432
|
+
const configured = Boolean(
|
|
1433
|
+
entry?.configuredService && (entry.configuredService.serviceName || entry.configuredService.serviceId) && (entry.configuredService.projectName || entry.configuredService.projectId) && existsSync(resolve(entry.configuredService.rootDir))
|
|
1434
|
+
);
|
|
1435
|
+
return {
|
|
1436
|
+
exists: Boolean(entry?.configuredService),
|
|
1437
|
+
status: entry?.project && entry?.environment && entry?.service && configured ? "ready" : "pending",
|
|
1438
|
+
live: {
|
|
1439
|
+
...persisted ?? {},
|
|
1440
|
+
...entry?.configuredService ?? {},
|
|
1441
|
+
project: entry?.project ?? null,
|
|
1442
|
+
environment: entry?.environment ?? null,
|
|
1443
|
+
service: entry?.service ?? null,
|
|
1444
|
+
instance: entry?.instance ?? null
|
|
1445
|
+
},
|
|
1446
|
+
locators: {
|
|
1447
|
+
projectId: entry?.project?.id ?? entry?.configuredService.projectId ?? persisted.projectId ?? null,
|
|
1448
|
+
serviceId: entry?.service?.id ?? entry?.configuredService.serviceId ?? persisted.serviceId ?? null,
|
|
1449
|
+
serviceName: entry?.service?.name ?? entry?.configuredService.serviceName ?? persisted.serviceName ?? null,
|
|
1450
|
+
publicBaseUrl: entry?.configuredService.publicBaseUrl ?? persisted.publicBaseUrl ?? null,
|
|
1451
|
+
workspace: topology.workspace.name
|
|
1452
|
+
},
|
|
1453
|
+
warnings: []
|
|
1454
|
+
};
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
if (attempt >= 2 || !isTransientRailwayReconcileError(error)) {
|
|
1457
|
+
throw error;
|
|
1458
|
+
}
|
|
1459
|
+
attempt += 1;
|
|
1460
|
+
sleepMs(1e3 * attempt);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
function collectRailwayEnvironmentSync(input) {
|
|
1465
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1466
|
+
const values = resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope);
|
|
1467
|
+
const registry = collectTreeseedEnvironmentContext(input.context.tenantRoot);
|
|
1468
|
+
const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
1469
|
+
const secrets = Object.fromEntries(
|
|
1470
|
+
registry.entries.filter((entry) => entry.scopes.includes(scope) && entry.targets.includes("railway-secret")).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0)
|
|
1471
|
+
);
|
|
1472
|
+
const variables = Object.fromEntries(
|
|
1473
|
+
registry.entries.filter((entry) => entry.scopes.includes(scope) && entry.targets.includes("railway-var")).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0)
|
|
1474
|
+
);
|
|
1475
|
+
if (typeof values.CLOUDFLARE_API_TOKEN === "string" && values.CLOUDFLARE_API_TOKEN.length > 0) {
|
|
1476
|
+
secrets.CLOUDFLARE_API_TOKEN = values.CLOUDFLARE_API_TOKEN;
|
|
1477
|
+
}
|
|
1478
|
+
if (typeof values.CLOUDFLARE_ACCOUNT_ID === "string" && values.CLOUDFLARE_ACCOUNT_ID.length > 0) {
|
|
1479
|
+
variables.CLOUDFLARE_ACCOUNT_ID = values.CLOUDFLARE_ACCOUNT_ID;
|
|
1480
|
+
}
|
|
1481
|
+
const apiD1DatabaseId = state.d1Databases?.SITE_DATA_DB?.databaseId;
|
|
1482
|
+
if (typeof apiD1DatabaseId === "string" && apiD1DatabaseId.length > 0) {
|
|
1483
|
+
variables.TREESEED_API_D1_DATABASE_ID = apiD1DatabaseId;
|
|
1484
|
+
}
|
|
1485
|
+
return { scope, secrets, variables };
|
|
1486
|
+
}
|
|
1487
|
+
function buildAttachmentDiff(input, observed) {
|
|
1488
|
+
if (!observed.exists) {
|
|
1489
|
+
return {
|
|
1490
|
+
action: "create",
|
|
1491
|
+
reasons: ["attachment missing"],
|
|
1492
|
+
before: observed.live,
|
|
1493
|
+
after: input.unit.spec
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
return {
|
|
1497
|
+
action: observed.status === "ready" ? "reuse" : "update",
|
|
1498
|
+
reasons: observed.status === "ready" ? ["attachment already present"] : ["attachment requires update"],
|
|
1499
|
+
before: observed.live,
|
|
1500
|
+
after: input.unit.spec
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
function resolveDesiredDnsRecords(input) {
|
|
1504
|
+
if (typeof input.unit.spec.recordType === "string" && typeof input.unit.spec.recordContent === "string" && typeof input.unit.spec.recordName === "string") {
|
|
1505
|
+
return [{
|
|
1506
|
+
type: String(input.unit.spec.recordType).toUpperCase(),
|
|
1507
|
+
name: String(input.unit.spec.recordName),
|
|
1508
|
+
content: String(input.unit.spec.recordContent),
|
|
1509
|
+
status: "",
|
|
1510
|
+
proxied: typeof input.unit.spec.proxied === "boolean" ? input.unit.spec.proxied : void 0
|
|
1511
|
+
}];
|
|
1512
|
+
}
|
|
1513
|
+
const domain = typeof input.unit.spec.domain === "string" ? input.unit.spec.domain : "";
|
|
1514
|
+
if (!domain) {
|
|
1515
|
+
return [];
|
|
1516
|
+
}
|
|
1517
|
+
const railwayState = getCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState;
|
|
1518
|
+
const records = Array.isArray(railwayState?.dnsRecords) ? railwayState.dnsRecords.map((entry) => normalizeRailwayDomainDnsRecord(entry)).filter(Boolean) : [];
|
|
1519
|
+
const desiredRecords = records.map((record) => ({
|
|
1520
|
+
...record,
|
|
1521
|
+
proxied: false
|
|
1522
|
+
}));
|
|
1523
|
+
const verificationDnsHost = typeof railwayState?.verificationDnsHost === "string" ? railwayState.verificationDnsHost.trim() : "";
|
|
1524
|
+
const verificationToken = typeof railwayState?.verificationToken === "string" ? railwayState.verificationToken.trim() : "";
|
|
1525
|
+
if (verificationDnsHost && verificationToken) {
|
|
1526
|
+
const verificationName = verificationDnsHost.endsWith(`.${domain}`) ? verificationDnsHost : `${verificationDnsHost}.${domain.split(".").slice(-2).join(".")}`;
|
|
1527
|
+
desiredRecords.push({
|
|
1528
|
+
type: "TXT",
|
|
1529
|
+
name: verificationName,
|
|
1530
|
+
content: verificationToken,
|
|
1531
|
+
status: "",
|
|
1532
|
+
proxied: false
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
return desiredRecords;
|
|
1536
|
+
}
|
|
1537
|
+
function observeCustomDomainUnit(input) {
|
|
1538
|
+
switch (input.unit.unitType) {
|
|
1539
|
+
case "custom-domain:web": {
|
|
1540
|
+
const env = buildCloudflareEnv(input);
|
|
1541
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1542
|
+
const projectName = String(input.unit.spec.projectName ?? "").trim();
|
|
1543
|
+
const live = domain && projectName ? getCloudflarePagesDomain(env, projectName, domain) : null;
|
|
1544
|
+
if (live) {
|
|
1545
|
+
storeCustomDomainState(input, "cloudflare", domain, live);
|
|
1546
|
+
}
|
|
1547
|
+
return {
|
|
1548
|
+
exists: Boolean(live?.name || live?.domain),
|
|
1549
|
+
status: live?.name || live?.domain ? "ready" : "pending",
|
|
1550
|
+
live: live ?? {},
|
|
1551
|
+
locators: {
|
|
1552
|
+
domain: domain || null,
|
|
1553
|
+
projectName: projectName || null
|
|
1554
|
+
},
|
|
1555
|
+
warnings: []
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
case "custom-domain:api": {
|
|
1559
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1560
|
+
const live = getCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
|
|
1561
|
+
if (live?.domain) {
|
|
1562
|
+
storeCustomDomainState(input, "railway", domain, live);
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
exists: Boolean(live?.domain),
|
|
1566
|
+
status: live?.domain ? "ready" : "pending",
|
|
1567
|
+
live: live ?? {},
|
|
1568
|
+
locators: {
|
|
1569
|
+
domain: domain || null,
|
|
1570
|
+
serviceDomain: typeof live?.serviceDomain === "string" ? live.serviceDomain : null
|
|
1571
|
+
},
|
|
1572
|
+
warnings: []
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
default:
|
|
1576
|
+
return noopObservedState(input);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
function observeDnsRecordUnit(input) {
|
|
1580
|
+
const env = buildCloudflareEnv(input);
|
|
1581
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1582
|
+
const zoneId = domain ? resolveCloudflareZoneIdForHost(input.context.deployConfig, domain, env) : null;
|
|
1583
|
+
const desiredRecords = resolveDesiredDnsRecords(input);
|
|
1584
|
+
const liveRecords = zoneId ? desiredRecords.map(
|
|
1585
|
+
(record) => listCloudflareDnsRecords(env, zoneId, record.name).find((entry) => entry?.name === record.name && entry?.type === record.type) ?? null
|
|
1586
|
+
) : [];
|
|
1587
|
+
const matches = desiredRecords.length > 0 && liveRecords.every(
|
|
1588
|
+
(entry, index) => Boolean(entry) && entry?.content === desiredRecords[index]?.content && (desiredRecords[index]?.proxied === void 0 || Boolean(entry?.proxied) === Boolean(desiredRecords[index]?.proxied))
|
|
1589
|
+
);
|
|
1590
|
+
return {
|
|
1591
|
+
exists: desiredRecords.length > 0 && liveRecords.every(Boolean),
|
|
1592
|
+
status: matches ? "ready" : "pending",
|
|
1593
|
+
live: {
|
|
1594
|
+
zoneId,
|
|
1595
|
+
records: liveRecords.filter(Boolean)
|
|
1596
|
+
},
|
|
1597
|
+
locators: {
|
|
1598
|
+
zoneId
|
|
1599
|
+
},
|
|
1600
|
+
warnings: desiredRecords.length === 0 ? ["No desired DNS records were available for verification."] : []
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
function verifyCustomDomainUnit(input) {
|
|
1604
|
+
switch (input.unit.unitType) {
|
|
1605
|
+
case "custom-domain:web": {
|
|
1606
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1607
|
+
const projectName = String(input.unit.spec.projectName ?? "").trim();
|
|
1608
|
+
const env = buildCloudflareEnv(input);
|
|
1609
|
+
const live = domain && projectName ? getCloudflarePagesDomain(env, projectName, domain) : null;
|
|
1610
|
+
return summarizeVerification(input.unit.unitId, [
|
|
1611
|
+
verificationCheck("custom-domain.exists", "Pages custom domain attachment exists", "api", {
|
|
1612
|
+
exists: Boolean(live?.name || live?.domain),
|
|
1613
|
+
expected: domain || null,
|
|
1614
|
+
observed: live?.name ?? live?.domain ?? null,
|
|
1615
|
+
issues: live?.name || live?.domain ? [] : [`Cloudflare Pages custom domain ${domain || "(unset)"} is missing.`]
|
|
1616
|
+
})
|
|
1617
|
+
]);
|
|
1618
|
+
}
|
|
1619
|
+
case "custom-domain:api": {
|
|
1620
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1621
|
+
const live = getCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
|
|
1622
|
+
const dnsRecords = Array.isArray(live?.dnsRecords) ? live.dnsRecords : [];
|
|
1623
|
+
return summarizeVerification(input.unit.unitId, [
|
|
1624
|
+
verificationCheck("custom-domain.exists", "Railway custom domain attachment exists", "cli", {
|
|
1625
|
+
exists: Boolean(live?.domain),
|
|
1626
|
+
expected: domain || null,
|
|
1627
|
+
observed: typeof live?.domain === "string" ? live.domain : null,
|
|
1628
|
+
issues: live?.domain ? [] : [`Railway custom domain ${domain || "(unset)"} is missing.`]
|
|
1629
|
+
}),
|
|
1630
|
+
verificationCheck("custom-domain.dns-requirements", "Railway custom domain exposes DNS requirements", "api", {
|
|
1631
|
+
exists: dnsRecords.length > 0,
|
|
1632
|
+
expected: true,
|
|
1633
|
+
observed: dnsRecords.length,
|
|
1634
|
+
issues: dnsRecords.length > 0 ? [] : [`Railway custom domain ${domain || "(unset)"} did not expose DNS requirements.`]
|
|
1635
|
+
})
|
|
1636
|
+
]);
|
|
1637
|
+
}
|
|
1638
|
+
default:
|
|
1639
|
+
return unsupportedVerification(input.unit.unitId, `Unsupported custom-domain unit type ${input.unit.unitType}.`);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
function verifyDnsRecordUnit(input) {
|
|
1643
|
+
const env = buildCloudflareEnv(input);
|
|
1644
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1645
|
+
const zoneId = domain ? resolveCloudflareZoneIdForHost(input.context.deployConfig, domain, env) : null;
|
|
1646
|
+
const desiredRecords = resolveDesiredDnsRecords(input);
|
|
1647
|
+
if (!zoneId) {
|
|
1648
|
+
return unsupportedVerification(input.unit.unitId, `Cloudflare DNS zone could not be resolved for ${domain || "(unset)"}.`);
|
|
1649
|
+
}
|
|
1650
|
+
if (desiredRecords.length === 0 && input.unit.spec.targetKind === "railway-service") {
|
|
1651
|
+
return summarizeVerification(input.unit.unitId, [
|
|
1652
|
+
verificationCheck("dns-record.requirements", "Railway custom domain exposes DNS requirements", "api", {
|
|
1653
|
+
exists: false,
|
|
1654
|
+
expected: true,
|
|
1655
|
+
observed: 0,
|
|
1656
|
+
issues: [`Railway custom domain ${domain || "(unset)"} did not expose DNS requirements, so Cloudflare DNS records could not be created.`]
|
|
1657
|
+
})
|
|
1658
|
+
]);
|
|
1659
|
+
}
|
|
1660
|
+
const checks = desiredRecords.map((record, index) => {
|
|
1661
|
+
const live = listCloudflareDnsRecords(env, zoneId, record.name).find((entry) => entry?.name === record.name && entry?.type === record.type) ?? null;
|
|
1662
|
+
const proxiedMatches = record.proxied === void 0 ? true : Boolean(live?.proxied) === Boolean(record.proxied);
|
|
1663
|
+
return verificationCheck(`dns-record:${index + 1}`, `DNS record ${record.type} ${record.name} matches the desired value`, "api", {
|
|
1664
|
+
exists: Boolean(live?.id),
|
|
1665
|
+
configured: live?.content === record.content && proxiedMatches,
|
|
1666
|
+
expected: `${record.type} ${record.name} -> ${record.content}${record.proxied === void 0 ? "" : ` proxied=${record.proxied}`}`,
|
|
1667
|
+
observed: live ? `${live.type} ${live.name} -> ${live.content}${typeof live.proxied === "boolean" ? ` proxied=${live.proxied}` : ""}` : null,
|
|
1668
|
+
issues: live?.id ? live.content === record.content && proxiedMatches ? [] : [`DNS record ${record.name} does not match the expected value.`] : [`DNS record ${record.type} ${record.name} is missing.`]
|
|
1669
|
+
});
|
|
1670
|
+
});
|
|
1671
|
+
return summarizeVerification(input.unit.unitId, checks);
|
|
1672
|
+
}
|
|
1673
|
+
async function reconcileCustomDomainUnit(input, diff) {
|
|
1674
|
+
switch (input.unit.unitType) {
|
|
1675
|
+
case "custom-domain:web": {
|
|
1676
|
+
const env = buildCloudflareEnv(input);
|
|
1677
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1678
|
+
const projectName = String(input.unit.spec.projectName ?? "").trim();
|
|
1679
|
+
const state = ensureCloudflarePagesDomain(env, projectName, domain) ?? { domain };
|
|
1680
|
+
storeCustomDomainState(input, "cloudflare", domain, state);
|
|
1681
|
+
const observed = observeCustomDomainUnit(input);
|
|
1682
|
+
return {
|
|
1683
|
+
unit: input.unit,
|
|
1684
|
+
observed,
|
|
1685
|
+
diff,
|
|
1686
|
+
action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
|
|
1687
|
+
warnings: observed.warnings,
|
|
1688
|
+
resourceLocators: observed.locators,
|
|
1689
|
+
state,
|
|
1690
|
+
verification: null
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
case "custom-domain:api": {
|
|
1694
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1695
|
+
const serviceKey = String(input.unit.metadata.serviceKey ?? "api").trim();
|
|
1696
|
+
const topology = await resolveRailwayTopologyForScope(input, scope, {
|
|
1697
|
+
ensure: true,
|
|
1698
|
+
serviceKeys: [serviceKey],
|
|
1699
|
+
includeInstances: false,
|
|
1700
|
+
includeVariables: false
|
|
1701
|
+
});
|
|
1702
|
+
const entry = topology.services.get(serviceKey) ?? null;
|
|
1703
|
+
if (!entry?.configuredService) {
|
|
1704
|
+
throw new Error(`Railway service ${serviceKey} is not configured for custom domain reconciliation.`);
|
|
1705
|
+
}
|
|
1706
|
+
const state = await ensureRailwayCustomDomain(
|
|
1707
|
+
input,
|
|
1708
|
+
entry.configuredService,
|
|
1709
|
+
String(input.unit.spec.domain ?? "").trim(),
|
|
1710
|
+
topology.env,
|
|
1711
|
+
{
|
|
1712
|
+
projectId: entry.project?.id ?? null,
|
|
1713
|
+
environmentId: entry.environment?.id ?? null,
|
|
1714
|
+
serviceId: entry.service?.id ?? null
|
|
1715
|
+
}
|
|
1716
|
+
);
|
|
1717
|
+
storeCustomDomainState(input, "railway", String(input.unit.spec.domain ?? "").trim(), state);
|
|
1718
|
+
const observed = observeCustomDomainUnit(input);
|
|
1719
|
+
return {
|
|
1720
|
+
unit: input.unit,
|
|
1721
|
+
observed,
|
|
1722
|
+
diff,
|
|
1723
|
+
action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
|
|
1724
|
+
warnings: observed.warnings,
|
|
1725
|
+
resourceLocators: observed.locators,
|
|
1726
|
+
state,
|
|
1727
|
+
verification: null
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
default:
|
|
1731
|
+
throw new Error(`Unsupported custom-domain unit type ${input.unit.unitType}.`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
function reconcileDnsRecordUnit(input, diff) {
|
|
1735
|
+
let attempt = 0;
|
|
1736
|
+
for (; ; ) {
|
|
1737
|
+
try {
|
|
1738
|
+
const env = buildCloudflareEnv(input);
|
|
1739
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
1740
|
+
const zoneId = resolveCloudflareZoneIdForHost(input.context.deployConfig, domain, env);
|
|
1741
|
+
if (!zoneId) {
|
|
1742
|
+
throw new Error(`Cloudflare DNS zone could not be resolved for ${domain || "(unset)"}.`);
|
|
1743
|
+
}
|
|
1744
|
+
const desiredRecords = resolveDesiredDnsRecords(input);
|
|
1745
|
+
const created = desiredRecords.map((record) => ensureCloudflareDnsRecord(env, zoneId, record));
|
|
1746
|
+
const observed = observeDnsRecordUnit(input);
|
|
1747
|
+
return {
|
|
1748
|
+
unit: input.unit,
|
|
1749
|
+
observed,
|
|
1750
|
+
diff,
|
|
1751
|
+
action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
|
|
1752
|
+
warnings: observed.warnings,
|
|
1753
|
+
resourceLocators: observed.locators,
|
|
1754
|
+
state: {
|
|
1755
|
+
zoneId,
|
|
1756
|
+
records: created
|
|
1757
|
+
},
|
|
1758
|
+
verification: null
|
|
1759
|
+
};
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
if (attempt >= 2 || !isTransientCloudflareReconcileError(error)) {
|
|
1762
|
+
throw error;
|
|
1763
|
+
}
|
|
1764
|
+
attempt += 1;
|
|
1765
|
+
sleepMs(500 * attempt);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
async function verifyRailwayUnit(input) {
|
|
1770
|
+
let attempt = 0;
|
|
1771
|
+
for (; ; ) {
|
|
1772
|
+
try {
|
|
1773
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1774
|
+
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
1775
|
+
const topology = await resolveRailwayTopologyForScope(input, scope, {
|
|
1776
|
+
serviceKeys: [serviceKey],
|
|
1777
|
+
includeInstances: true,
|
|
1778
|
+
includeVariables: true
|
|
1779
|
+
});
|
|
1780
|
+
const entry = topology.services.get(serviceKey) ?? null;
|
|
1781
|
+
const service = entry?.configuredService ?? null;
|
|
1782
|
+
if (!service || !entry) {
|
|
1783
|
+
return summarizeVerification(input.unit.unitId, [
|
|
1784
|
+
verificationCheck("railway.service", "Railway service exists in the desired topology", "sdk", {
|
|
1785
|
+
exists: false,
|
|
1786
|
+
issues: [`Railway service ${serviceKey} is not configured for ${scope}.`]
|
|
1787
|
+
})
|
|
1788
|
+
]);
|
|
1789
|
+
}
|
|
1790
|
+
const sync = collectRailwayEnvironmentSync(input);
|
|
1791
|
+
const checks = [
|
|
1792
|
+
verificationCheck("railway.workspace", "Railway workspace is resolved", "api", {
|
|
1793
|
+
exists: Boolean(topology.workspace.id),
|
|
1794
|
+
expected: topology.workspace.name,
|
|
1795
|
+
observed: topology.workspace.name,
|
|
1796
|
+
issues: topology.workspace.id ? [] : ["Railway workspace could not be resolved."]
|
|
1797
|
+
}),
|
|
1798
|
+
verificationCheck("railway.project", "Railway project exists", "api", {
|
|
1799
|
+
exists: Boolean(entry.project),
|
|
1800
|
+
expected: service.projectName ?? service.projectId ?? null,
|
|
1801
|
+
observed: entry.project?.name ?? entry.project?.id ?? null,
|
|
1802
|
+
issues: entry.project ? [] : [`Railway project ${service.projectName ?? service.projectId ?? "(unset)"} was not found in workspace ${topology.workspace.name}.`]
|
|
1803
|
+
}),
|
|
1804
|
+
verificationCheck("railway.service", "Railway service exists", "api", {
|
|
1805
|
+
exists: Boolean(entry.service),
|
|
1806
|
+
expected: service.serviceName ?? service.serviceId ?? null,
|
|
1807
|
+
observed: entry.service?.name ?? entry.service?.id ?? null,
|
|
1808
|
+
issues: entry.service ? [] : [`Railway service ${service.serviceName ?? service.serviceId ?? "(unset)"} was not found.`]
|
|
1809
|
+
}),
|
|
1810
|
+
verificationCheck("railway.environment", "Railway environment exists", "api", {
|
|
1811
|
+
exists: Boolean(entry.environment),
|
|
1812
|
+
expected: service.railwayEnvironment,
|
|
1813
|
+
observed: entry.environment?.name ?? null,
|
|
1814
|
+
issues: entry.environment ? [] : [`Railway environment ${service.railwayEnvironment} was not found.`]
|
|
1815
|
+
}),
|
|
1816
|
+
verificationCheck("railway.instance", "Railway service instance exists", "api", {
|
|
1817
|
+
exists: Boolean(entry.instance?.id),
|
|
1818
|
+
expected: true,
|
|
1819
|
+
observed: entry.instance?.id ?? null,
|
|
1820
|
+
issues: entry.instance?.id ? [] : [`Railway service instance for ${service.serviceName ?? service.key} in ${service.railwayEnvironment} is missing.`]
|
|
1821
|
+
})
|
|
1822
|
+
];
|
|
1823
|
+
if (service.startCommand) {
|
|
1824
|
+
checks.push(verificationCheck("railway.instance.start-command", "Railway start command matches desired config", "api", {
|
|
1825
|
+
exists: Boolean(entry.instance?.id),
|
|
1826
|
+
configured: entry.instance?.startCommand === service.startCommand,
|
|
1827
|
+
expected: service.startCommand,
|
|
1828
|
+
observed: entry.instance?.startCommand ?? null,
|
|
1829
|
+
issues: entry.instance?.startCommand === service.startCommand ? [] : ["Railway start command does not match the desired value."]
|
|
1830
|
+
}));
|
|
1831
|
+
}
|
|
1832
|
+
const desiredRootDirectory = relativeRailwayRootDir(input.context.tenantRoot, service.rootDir);
|
|
1833
|
+
if (desiredRootDirectory) {
|
|
1834
|
+
checks.push(verificationCheck("railway.instance.root-directory", "Railway root directory matches desired config", "api", {
|
|
1835
|
+
exists: Boolean(entry.instance?.id),
|
|
1836
|
+
configured: entry.instance?.rootDirectory === desiredRootDirectory,
|
|
1837
|
+
expected: desiredRootDirectory,
|
|
1838
|
+
observed: entry.instance?.rootDirectory ?? null,
|
|
1839
|
+
issues: entry.instance?.rootDirectory === desiredRootDirectory ? [] : ["Railway root directory does not match the desired value."]
|
|
1840
|
+
}));
|
|
1841
|
+
}
|
|
1842
|
+
if (service.key === "api") {
|
|
1843
|
+
if (service.healthcheckPath || service.healthcheckIntervalSeconds || service.healthcheckTimeoutSeconds || service.restartPolicy || service.runtimeMode) {
|
|
1844
|
+
if (entry.instance?.runtimeConfigSupported !== true) {
|
|
1845
|
+
return unsupportedVerification(
|
|
1846
|
+
input.unit.unitId,
|
|
1847
|
+
"Railway API service runtime settings are unsupported by the current Railway API schema."
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (service.healthcheckPath) {
|
|
1852
|
+
checks.push(verificationCheck("railway.instance.healthcheck-path", "Railway API healthcheck path matches desired config", "api", {
|
|
1853
|
+
exists: Boolean(entry.instance?.id),
|
|
1854
|
+
configured: entry.instance?.healthcheckPath === service.healthcheckPath,
|
|
1855
|
+
expected: service.healthcheckPath,
|
|
1856
|
+
observed: entry.instance?.healthcheckPath ?? null,
|
|
1857
|
+
issues: entry.instance?.healthcheckPath === service.healthcheckPath ? [] : ["Railway API healthcheck path does not match the desired value."]
|
|
1858
|
+
}));
|
|
1859
|
+
}
|
|
1860
|
+
if (service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0) {
|
|
1861
|
+
checks.push(verificationCheck("railway.instance.healthcheck-timeout", "Railway API healthcheck timeout matches desired config", "api", {
|
|
1862
|
+
exists: Boolean(entry.instance?.id),
|
|
1863
|
+
configured: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds,
|
|
1864
|
+
expected: service.healthcheckTimeoutSeconds,
|
|
1865
|
+
observed: entry.instance?.healthcheckTimeoutSeconds ?? null,
|
|
1866
|
+
issues: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds ? [] : ["Railway API healthcheck timeout does not match the desired value."]
|
|
1867
|
+
}));
|
|
1868
|
+
}
|
|
1869
|
+
if (service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0) {
|
|
1870
|
+
checks.push(verificationCheck("railway.instance.healthcheck-interval", "Railway API healthcheck interval matches desired config", "api", {
|
|
1871
|
+
exists: Boolean(entry.instance?.id),
|
|
1872
|
+
configured: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds,
|
|
1873
|
+
expected: service.healthcheckIntervalSeconds,
|
|
1874
|
+
observed: entry.instance?.healthcheckIntervalSeconds ?? null,
|
|
1875
|
+
issues: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds ? [] : ["Railway API healthcheck interval does not match the desired value."]
|
|
1876
|
+
}));
|
|
1877
|
+
}
|
|
1878
|
+
if (service.restartPolicy) {
|
|
1879
|
+
checks.push(verificationCheck("railway.instance.restart-policy", "Railway API restart policy matches desired config", "api", {
|
|
1880
|
+
exists: Boolean(entry.instance?.id),
|
|
1881
|
+
configured: entry.instance?.restartPolicy === service.restartPolicy,
|
|
1882
|
+
expected: service.restartPolicy,
|
|
1883
|
+
observed: entry.instance?.restartPolicy ?? null,
|
|
1884
|
+
issues: entry.instance?.restartPolicy === service.restartPolicy ? [] : ["Railway API restart policy does not match the desired value."]
|
|
1885
|
+
}));
|
|
1886
|
+
}
|
|
1887
|
+
if (service.runtimeMode) {
|
|
1888
|
+
checks.push(verificationCheck("railway.instance.runtime-mode", "Railway API runtime mode matches desired config", "api", {
|
|
1889
|
+
exists: Boolean(entry.instance?.id),
|
|
1890
|
+
configured: entry.instance?.runtimeMode === service.runtimeMode,
|
|
1891
|
+
expected: service.runtimeMode,
|
|
1892
|
+
observed: entry.instance?.runtimeMode ?? null,
|
|
1893
|
+
issues: entry.instance?.runtimeMode === service.runtimeMode ? [] : ["Railway API runtime mode does not match the desired value."]
|
|
1894
|
+
}));
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
for (const [key, value] of Object.entries(sync.variables)) {
|
|
1898
|
+
checks.push(verificationCheck(`railway.var:${key}`, `Railway variable ${key} exists with the expected value`, "api", {
|
|
1899
|
+
exists: Object.hasOwn(entry.currentVariables, key),
|
|
1900
|
+
configured: entry.currentVariables[key] === value,
|
|
1901
|
+
expected: value,
|
|
1902
|
+
observed: entry.currentVariables[key] ?? null,
|
|
1903
|
+
issues: entry.currentVariables[key] === value ? [] : [`Railway variable ${key} does not match the expected value.`]
|
|
1904
|
+
}));
|
|
1905
|
+
}
|
|
1906
|
+
for (const key of Object.keys(sync.secrets)) {
|
|
1907
|
+
checks.push(verificationCheck(`railway.secret:${key}`, `Railway secret ${key} exists`, "api", {
|
|
1908
|
+
exists: Object.hasOwn(entry.currentVariables, key),
|
|
1909
|
+
expected: true,
|
|
1910
|
+
observed: Object.hasOwn(entry.currentVariables, key),
|
|
1911
|
+
issues: Object.hasOwn(entry.currentVariables, key) ? [] : [`Railway secret ${key} is missing.`]
|
|
1912
|
+
}));
|
|
1913
|
+
}
|
|
1914
|
+
return summarizeVerification(input.unit.unitId, checks);
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
if (attempt >= 2 || !isTransientRailwayReconcileError(error)) {
|
|
1917
|
+
throw error;
|
|
1918
|
+
}
|
|
1919
|
+
attempt += 1;
|
|
1920
|
+
sleepMs(1e3 * attempt);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
function buildRailwayDiff(input, observed) {
|
|
1925
|
+
if (!observed.exists) {
|
|
1926
|
+
return {
|
|
1927
|
+
action: "create",
|
|
1928
|
+
reasons: ["service missing from configured topology"],
|
|
1929
|
+
before: observed.live,
|
|
1930
|
+
after: input.unit.spec
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
return {
|
|
1934
|
+
action: observed.status === "ready" ? "reuse" : "update",
|
|
1935
|
+
reasons: observed.status === "ready" ? ["service already configured"] : ["service requires configuration sync"],
|
|
1936
|
+
before: observed.live,
|
|
1937
|
+
after: input.unit.spec
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
async function reconcileRailwayUnit(input, diff) {
|
|
1941
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1942
|
+
const cacheKey = `railway:sync:${scope}`;
|
|
1943
|
+
await providerCache(input, cacheKey, async () => {
|
|
1944
|
+
const synced = await syncRailwayEnvironmentForScope(input);
|
|
1945
|
+
return synced;
|
|
1946
|
+
});
|
|
1947
|
+
for (const key of input.context.session.keys()) {
|
|
1948
|
+
if (key.startsWith(`railway:topology:${scope}:`)) {
|
|
1949
|
+
input.context.session.delete(key);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
const refreshed = await observeRailwayUnit(input, { refresh: true });
|
|
1953
|
+
return {
|
|
1954
|
+
unit: input.unit,
|
|
1955
|
+
observed: refreshed,
|
|
1956
|
+
diff,
|
|
1957
|
+
action: diff.action === "update" || diff.action === "create" ? "drift_correct" : diff.action,
|
|
1958
|
+
warnings: refreshed.warnings,
|
|
1959
|
+
resourceLocators: refreshed.locators,
|
|
1960
|
+
state: refreshed.live,
|
|
1961
|
+
verification: null
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
function buildRailwayAdapter(unitType) {
|
|
1965
|
+
return {
|
|
1966
|
+
providerId: "railway",
|
|
1967
|
+
unitTypes: [unitType],
|
|
1968
|
+
supports(candidateUnitType, providerId) {
|
|
1969
|
+
return providerId === "railway" && candidateUnitType === unitType;
|
|
1970
|
+
},
|
|
1971
|
+
validate(input) {
|
|
1972
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1973
|
+
validateRailwayDeployPrerequisites(input.context.tenantRoot, scope, {
|
|
1974
|
+
env: buildRailwayEnv(input, scope)
|
|
1975
|
+
});
|
|
1976
|
+
},
|
|
1977
|
+
observe(input) {
|
|
1978
|
+
return observeRailwayUnit(input);
|
|
1979
|
+
},
|
|
1980
|
+
plan(input) {
|
|
1981
|
+
return buildRailwayDiff(input, input.observed);
|
|
1982
|
+
},
|
|
1983
|
+
reconcile(input) {
|
|
1984
|
+
return reconcileRailwayUnit(input, input.diff);
|
|
1985
|
+
},
|
|
1986
|
+
requiredPostconditions() {
|
|
1987
|
+
return [
|
|
1988
|
+
{ key: "railway.project", description: "Railway project exists" },
|
|
1989
|
+
{ key: "railway.service", description: "Railway service exists" },
|
|
1990
|
+
{ key: "railway.environment", description: "Railway environment exists" }
|
|
1991
|
+
];
|
|
1992
|
+
},
|
|
1993
|
+
verify(input) {
|
|
1994
|
+
return verifyRailwayUnit(input);
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
function buildCustomDomainAdapter(unitType, providerId) {
|
|
1999
|
+
return {
|
|
2000
|
+
providerId,
|
|
2001
|
+
unitTypes: [unitType],
|
|
2002
|
+
supports(candidateUnitType, candidateProviderId) {
|
|
2003
|
+
return candidateUnitType === unitType && candidateProviderId === providerId;
|
|
2004
|
+
},
|
|
2005
|
+
validate(input) {
|
|
2006
|
+
if (providerId === "railway") {
|
|
2007
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
2008
|
+
validateRailwayDeployPrerequisites(input.context.tenantRoot, scope, {
|
|
2009
|
+
env: buildRailwayEnv(input, scope)
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
},
|
|
2013
|
+
observe(input) {
|
|
2014
|
+
return observeCustomDomainUnit(input);
|
|
2015
|
+
},
|
|
2016
|
+
requiredPostconditions() {
|
|
2017
|
+
return [
|
|
2018
|
+
{ key: "custom-domain.exists", description: "Custom domain attachment exists" },
|
|
2019
|
+
...providerId === "railway" ? [{ key: "custom-domain.dns-requirements", description: "Custom domain exposes DNS requirements" }] : []
|
|
2020
|
+
];
|
|
2021
|
+
},
|
|
2022
|
+
plan(input) {
|
|
2023
|
+
return buildAttachmentDiff(input, input.observed);
|
|
2024
|
+
},
|
|
2025
|
+
reconcile(input) {
|
|
2026
|
+
return reconcileCustomDomainUnit(input, input.diff);
|
|
2027
|
+
},
|
|
2028
|
+
verify(input) {
|
|
2029
|
+
return verifyCustomDomainUnit(input);
|
|
2030
|
+
}
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
function buildDnsRecordAdapter() {
|
|
2034
|
+
return {
|
|
2035
|
+
providerId: "cloudflare-dns",
|
|
2036
|
+
unitTypes: ["dns-record"],
|
|
2037
|
+
supports(candidateUnitType, providerId) {
|
|
2038
|
+
return candidateUnitType === "dns-record" && providerId === "cloudflare-dns";
|
|
2039
|
+
},
|
|
2040
|
+
observe(input) {
|
|
2041
|
+
return observeDnsRecordUnit(input);
|
|
2042
|
+
},
|
|
2043
|
+
requiredPostconditions(input) {
|
|
2044
|
+
const desired = resolveDesiredDnsRecords(input);
|
|
2045
|
+
return desired.map((record, index) => ({
|
|
2046
|
+
key: `dns-record:${index + 1}`,
|
|
2047
|
+
description: `DNS record ${record.type} ${record.name} matches the desired value`
|
|
2048
|
+
}));
|
|
2049
|
+
},
|
|
2050
|
+
plan(input) {
|
|
2051
|
+
return buildAttachmentDiff(input, input.observed);
|
|
2052
|
+
},
|
|
2053
|
+
reconcile(input) {
|
|
2054
|
+
return reconcileDnsRecordUnit(input, input.diff);
|
|
2055
|
+
},
|
|
2056
|
+
verify(input) {
|
|
2057
|
+
return verifyDnsRecordUnit(input);
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
function createCloudflareReconcileAdapters() {
|
|
2062
|
+
return [
|
|
2063
|
+
buildCloudflareAdapter("queue"),
|
|
2064
|
+
buildCloudflareAdapter("database"),
|
|
2065
|
+
buildCloudflareAdapter("content-store"),
|
|
2066
|
+
buildCloudflareAdapter("kv-form-guard"),
|
|
2067
|
+
buildCloudflareAdapter("kv-session"),
|
|
2068
|
+
buildCloudflareAdapter("pages-project"),
|
|
2069
|
+
buildCloudflareAdapter("edge-worker"),
|
|
2070
|
+
buildCustomDomainAdapter("custom-domain:web", "cloudflare"),
|
|
2071
|
+
buildDnsRecordAdapter(),
|
|
2072
|
+
buildCompositeAdapter("web-ui")
|
|
2073
|
+
];
|
|
2074
|
+
}
|
|
2075
|
+
function createRailwayReconcileAdapters() {
|
|
2076
|
+
return [
|
|
2077
|
+
buildRailwayAdapter("railway-service:api"),
|
|
2078
|
+
buildRailwayAdapter("railway-service:manager"),
|
|
2079
|
+
buildRailwayAdapter("railway-service:worker"),
|
|
2080
|
+
buildRailwayAdapter("railway-service:workday-start"),
|
|
2081
|
+
buildRailwayAdapter("railway-service:workday-report"),
|
|
2082
|
+
buildCustomDomainAdapter("custom-domain:api", "railway"),
|
|
2083
|
+
buildCompositeAdapter("api-runtime"),
|
|
2084
|
+
buildCompositeAdapter("manager-runtime"),
|
|
2085
|
+
buildCompositeAdapter("worker-runtime"),
|
|
2086
|
+
buildCompositeAdapter("workday-start-runtime"),
|
|
2087
|
+
buildCompositeAdapter("workday-report-runtime")
|
|
2088
|
+
];
|
|
2089
|
+
}
|
|
2090
|
+
export {
|
|
2091
|
+
createCloudflareReconcileAdapters,
|
|
2092
|
+
createRailwayReconcileAdapters
|
|
2093
|
+
};
|