@treeseed/sdk 0.12.59 → 0.12.61
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/guarantees/index.js +59 -56
- package/dist/hosting/contracts.d.ts +0 -19
- package/dist/hosting/graph.d.ts +1 -86
- package/dist/hosting/graph.js +96 -247
- package/dist/local-dev/managed-dev.js +30 -8
- package/dist/managed-dependencies.d.ts +3 -0
- package/dist/managed-dependencies.js +294 -20
- package/dist/operations/services/deploy.js +6 -6
- package/dist/operations/services/deployment-readiness.js +5 -4
- package/dist/operations/services/git-runner.d.ts +2 -0
- package/dist/operations/services/git-runner.js +23 -2
- package/dist/operations/services/hosted-service-checks.js +28 -0
- package/dist/operations/services/live-hosted-service-checks.js +56 -14
- package/dist/operations/services/local-cleanup.d.ts +1 -0
- package/dist/operations/services/local-cleanup.js +28 -9
- package/dist/operations/services/package-adapters.js +3 -3
- package/dist/operations/services/railway-api.d.ts +72 -28
- package/dist/operations/services/railway-api.js +321 -876
- package/dist/operations/services/railway-cli.d.ts +47 -0
- package/dist/operations/services/railway-cli.js +142 -0
- package/dist/operations/services/railway-deploy.d.ts +5 -0
- package/dist/operations/services/railway-deploy.js +47 -75
- package/dist/operations/services/railway-source-policy.d.ts +6 -0
- package/dist/operations/services/railway-source-policy.js +52 -9
- package/dist/operations/services/repository-save-orchestrator.d.ts +2 -0
- package/dist/operations/services/repository-save-orchestrator.js +45 -14
- package/dist/operations-types.d.ts +3 -1
- package/dist/platform/contracts.d.ts +1 -0
- package/dist/platform/deploy-config.js +2 -1
- package/dist/reconcile/builtin-adapters.js +524 -680
- package/dist/reconcile/desired-state.js +5 -3
- package/dist/reconcile/engine.js +34 -28
- package/dist/reconcile/live-acceptance.js +2 -11
- package/dist/reconcile/providers/railway-iac.d.ts +148 -0
- package/dist/reconcile/providers/railway-iac.js +294 -18
- package/dist/scenes/runner.js +11 -11
- package/dist/scripts/build-dist.js +22 -0
- package/dist/workflow/operations.d.ts +12 -0
- package/dist/workflow/operations.js +265 -90
- package/dist/workflow/runs.d.ts +4 -0
- package/dist/workflow/runs.js +25 -0
- package/dist/workflow-support.d.ts +1 -1
- package/dist/workflow-support.js +3 -1
- package/package.json +1 -2
|
@@ -1,9 +1,160 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
runRailwayIac
|
|
5
5
|
} from "railway/iac";
|
|
6
6
|
import { assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "../../operations/services/railway-source-policy.js";
|
|
7
|
+
async function waitForRailwayVolumeAdoptionResources({
|
|
8
|
+
load,
|
|
9
|
+
serviceName,
|
|
10
|
+
volumeId,
|
|
11
|
+
attempts = 12,
|
|
12
|
+
intervalMs = 2500,
|
|
13
|
+
sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
|
|
14
|
+
}) {
|
|
15
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
16
|
+
const current = await load();
|
|
17
|
+
const service = current.services.find((candidate) => candidate.name === serviceName) ?? null;
|
|
18
|
+
const volume = current.volumes.find((candidate) => candidate.id === volumeId) ?? null;
|
|
19
|
+
if (service && volume) return { service, volume, services: current.services, volumes: current.volumes, attempt };
|
|
20
|
+
if (attempt < attempts) await sleep(intervalMs);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
async function waitForRailwayServices({
|
|
25
|
+
load,
|
|
26
|
+
serviceNames,
|
|
27
|
+
attempts = 12,
|
|
28
|
+
intervalMs = 2500,
|
|
29
|
+
sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
|
|
30
|
+
}) {
|
|
31
|
+
const expected = [...new Set(serviceNames)];
|
|
32
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
33
|
+
const services = await load();
|
|
34
|
+
const observed = new Set(services.map((service) => service.name).filter(Boolean));
|
|
35
|
+
if (expected.every((name) => observed.has(name))) return { services, attempt };
|
|
36
|
+
if (attempt < attempts) await sleep(intervalMs);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
async function waitForRailwayVolumeName({
|
|
41
|
+
load,
|
|
42
|
+
volumeId,
|
|
43
|
+
expectedName,
|
|
44
|
+
attempts = 12,
|
|
45
|
+
intervalMs = 2500,
|
|
46
|
+
sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
|
|
47
|
+
}) {
|
|
48
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
49
|
+
const volume = (await load()).find((candidate) => candidate.id === volumeId) ?? null;
|
|
50
|
+
if (volume?.name === expectedName) return { volume, attempt };
|
|
51
|
+
if (attempt < attempts) await sleep(intervalMs);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
async function waitForRailwayVolumeDetachment({
|
|
56
|
+
load,
|
|
57
|
+
volumeId,
|
|
58
|
+
environmentId,
|
|
59
|
+
serviceId,
|
|
60
|
+
attempts = 12,
|
|
61
|
+
intervalMs = 2500,
|
|
62
|
+
sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
|
|
63
|
+
}) {
|
|
64
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
65
|
+
const volume = (await load()).find((candidate) => candidate.id === volumeId) ?? null;
|
|
66
|
+
const attached = volume?.instances?.some(
|
|
67
|
+
(instance) => instance.environmentId === environmentId && instance.serviceId === serviceId
|
|
68
|
+
) ?? false;
|
|
69
|
+
if (!attached) return { volume, attempt };
|
|
70
|
+
if (attempt < attempts) await sleep(intervalMs);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
async function waitForRailwayServiceAbsence({
|
|
75
|
+
load,
|
|
76
|
+
serviceId,
|
|
77
|
+
attempts = 12,
|
|
78
|
+
intervalMs = 2500,
|
|
79
|
+
sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
|
|
80
|
+
}) {
|
|
81
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
82
|
+
if (!(await load()).some((service) => service.id === serviceId)) return { attempt };
|
|
83
|
+
if (attempt < attempts) await sleep(intervalMs);
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const STALE_RAILWAY_IAC_RENDER_AGE_MS = 15 * 60 * 1e3;
|
|
88
|
+
function cleanupStaleRailwayIacRenders(tenantRoot, now = Date.now()) {
|
|
89
|
+
const tempParent = resolve(tenantRoot, ".treeseed", "tmp");
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = readdirSync(tempParent);
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const removed = [];
|
|
97
|
+
for (const entry of entries.filter((name) => name.startsWith("railway-iac-"))) {
|
|
98
|
+
const path = resolve(tempParent, entry);
|
|
99
|
+
try {
|
|
100
|
+
if (now - statSync(path).mtimeMs < STALE_RAILWAY_IAC_RENDER_AGE_MS) continue;
|
|
101
|
+
rmSync(path, { recursive: true, force: true });
|
|
102
|
+
removed.push(path);
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return removed;
|
|
107
|
+
}
|
|
108
|
+
function railwayIacApplyFailure(response) {
|
|
109
|
+
const diagnostics = (response.diagnostics ?? []).map((entry) => String(entry.message ?? "").trim()).filter(Boolean);
|
|
110
|
+
if (!response.ok) return diagnostics.join("; ") || "Railway IaC planning failed before apply.";
|
|
111
|
+
const changes = response.changeSet?.changes ?? [];
|
|
112
|
+
if (changes.length === 0) return null;
|
|
113
|
+
if (!response.applyResult) return "Railway IaC returned no apply result for a non-empty change set.";
|
|
114
|
+
const successfulStatuses = /* @__PURE__ */ new Set(["APPLIED", "COMPLETED", "SUCCESS", "SUCCEEDED"]);
|
|
115
|
+
const applyStatus = String(response.applyResult.status ?? "").trim().toUpperCase();
|
|
116
|
+
const failedChanges = (response.applyResult.changes ?? []).filter((change) => !successfulStatuses.has(String(change.status ?? "").trim().toUpperCase())).map((change) => `${change.path ?? change.kind}: ${change.status || "unknown"}`);
|
|
117
|
+
const applyDiagnostics = (response.applyResult.diagnostics ?? []).map((entry) => typeof entry === "string" ? entry : JSON.stringify(entry)).filter(Boolean);
|
|
118
|
+
if (!successfulStatuses.has(applyStatus) || failedChanges.length > 0 || applyDiagnostics.length > 0) {
|
|
119
|
+
return [
|
|
120
|
+
`apply status ${applyStatus || "unknown"}`,
|
|
121
|
+
...failedChanges,
|
|
122
|
+
...diagnostics,
|
|
123
|
+
...applyDiagnostics
|
|
124
|
+
].join("; ");
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
async function runRailwayIacWithRateLimitRetry(run, {
|
|
129
|
+
delaysMs = [15e3, 45e3, 9e4],
|
|
130
|
+
sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
|
|
131
|
+
onRetry,
|
|
132
|
+
onWait
|
|
133
|
+
} = {}) {
|
|
134
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
135
|
+
try {
|
|
136
|
+
const result = await run();
|
|
137
|
+
if (result && typeof result === "object" && "ok" in result && result.ok === false) {
|
|
138
|
+
const diagnostics = Array.isArray(result.diagnostics) ? result.diagnostics.map((entry) => typeof entry === "string" ? entry : String(entry?.message ?? "")).filter(Boolean).join("; ") : "";
|
|
139
|
+
if (/fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|HTTP\s+429|rate[ -]?limit/iu.test(diagnostics)) {
|
|
140
|
+
throw new Error(diagnostics || "Railway IaC transport failed.");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const delayMs = delaysMs[attempt - 1];
|
|
146
|
+
if (delayMs === void 0 || !/fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|HTTP\s+429|rate[ -]?limit/iu.test(String(error))) throw error;
|
|
147
|
+
onRetry?.(attempt + 1, delayMs, error);
|
|
148
|
+
let remainingMs = delayMs;
|
|
149
|
+
while (remainingMs > 0) {
|
|
150
|
+
const sliceMs = Math.min(15e3, remainingMs);
|
|
151
|
+
await sleep(sliceMs);
|
|
152
|
+
remainingMs -= sliceMs;
|
|
153
|
+
if (remainingMs > 0) onWait?.(attempt + 1, remainingMs);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
7
158
|
function js(value) {
|
|
8
159
|
return JSON.stringify(value);
|
|
9
160
|
}
|
|
@@ -59,16 +210,14 @@ function buildConfig(service) {
|
|
|
59
210
|
}
|
|
60
211
|
return null;
|
|
61
212
|
}
|
|
62
|
-
function deployConfig(service
|
|
213
|
+
function deployConfig(service) {
|
|
63
214
|
const runtimeMode = String(service.runtimeMode ?? "").trim();
|
|
64
215
|
const deploy = {
|
|
65
216
|
...service.startCommand ? { startCommand: service.startCommand } : {},
|
|
66
217
|
...service.healthcheckPath ? { healthcheckPath: service.healthcheckPath } : {},
|
|
67
218
|
...service.healthcheckTimeoutSeconds ? { healthcheckTimeout: service.healthcheckTimeoutSeconds } : {},
|
|
68
|
-
...
|
|
69
|
-
...runtimeMode === "
|
|
70
|
-
...service.volumeMountPath ? { requiredMountPath: service.volumeMountPath } : {},
|
|
71
|
-
region
|
|
219
|
+
...typeof service.numReplicas === "number" ? { numReplicas: service.numReplicas } : {},
|
|
220
|
+
...runtimeMode === "serverless" ? { sleepApplication: true } : {}
|
|
72
221
|
};
|
|
73
222
|
return Object.keys(deploy).length > 0 ? deploy : null;
|
|
74
223
|
}
|
|
@@ -105,10 +254,96 @@ function normalizeIacScope(input) {
|
|
|
105
254
|
const environmentName = String(input.environmentName ?? "").trim().toLowerCase();
|
|
106
255
|
return environmentName === "production" || environmentName === "prod" ? "prod" : environmentName === "staging" ? "staging" : "local";
|
|
107
256
|
}
|
|
257
|
+
function activeObservedVolumeInstances(volume) {
|
|
258
|
+
return volume.instances.filter((instance) => {
|
|
259
|
+
const state = String(instance.state ?? "READY").toUpperCase();
|
|
260
|
+
return instance.isPendingDeletion !== true && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && state !== "DELETING" && state !== "DELETED";
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function findRailwayPendingVolumeNameCollisions(input) {
|
|
264
|
+
const serviceIdByName = new Map((input.liveServices ?? []).map((service) => [service.name, service.id]));
|
|
265
|
+
return input.services.flatMap((service) => {
|
|
266
|
+
if (!service.volumeMountPath) return [];
|
|
267
|
+
const canonicalVolumeName = `${service.serviceName}-volume`;
|
|
268
|
+
const desiredServiceId = serviceIdByName.get(service.serviceName) ?? null;
|
|
269
|
+
return input.volumes.filter((volume) => volume.name === canonicalVolumeName || String(volume.name ?? "").startsWith("pending-delete-") && Boolean(desiredServiceId) && volume.instances.some((instance) => instance.serviceId === desiredServiceId)).filter((volume) => volume.instances.length > 0 && activeObservedVolumeInstances(volume).length === 0).map((volume) => ({
|
|
270
|
+
serviceName: service.serviceName,
|
|
271
|
+
volumeId: volume.id,
|
|
272
|
+
canonicalVolumeName,
|
|
273
|
+
mountPath: service.volumeMountPath,
|
|
274
|
+
serviceId: volume.instances.find((instance) => instance.serviceId)?.serviceId ?? null
|
|
275
|
+
}));
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function resolveRailwayIacVolumeBindings(input) {
|
|
279
|
+
const bindings = [];
|
|
280
|
+
const blockedReasons = [];
|
|
281
|
+
const serviceIdByName = new Map(input.liveServices.map((service) => [service.name, service.id]));
|
|
282
|
+
for (const service of input.services.filter((candidate) => Boolean(candidate.volumeMountPath))) {
|
|
283
|
+
const canonicalVolumeName = `${service.serviceName}-volume`;
|
|
284
|
+
const desiredServiceId = serviceIdByName.get(service.serviceName) ?? null;
|
|
285
|
+
const canonical = input.volumes.flatMap(
|
|
286
|
+
(volume) => activeObservedVolumeInstances(volume).filter((instance) => instance.environmentId === input.environmentId && volume.name === canonicalVolumeName).map((instance) => ({ volume, instance }))
|
|
287
|
+
);
|
|
288
|
+
const candidatesByVolume = new Map(canonical.map((candidate) => [candidate.volume.id, candidate]));
|
|
289
|
+
if (candidatesByVolume.size > 1) {
|
|
290
|
+
blockedReasons.push(`${service.serviceName}: ${candidatesByVolume.size} active volumes are viable in environment ${input.environmentId}; refusing ambiguous stateful volume ownership.`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const selected = candidatesByVolume.values().next().value;
|
|
294
|
+
if (!selected?.volume.id || !selected.volume.name) {
|
|
295
|
+
const pendingCanonicalCollision = input.volumes.some(
|
|
296
|
+
(volume) => (volume.name === canonicalVolumeName || String(volume.name ?? "").startsWith("pending-delete-") && Boolean(desiredServiceId) && volume.instances.some((instance) => instance.serviceId === desiredServiceId)) && volume.instances.length > 0 && activeObservedVolumeInstances(volume).length === 0
|
|
297
|
+
);
|
|
298
|
+
if (pendingCanonicalCollision) continue;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
bindings.push({
|
|
302
|
+
serviceName: service.serviceName,
|
|
303
|
+
volumeId: selected.volume.id,
|
|
304
|
+
volumeName: selected.volume.name,
|
|
305
|
+
canonicalVolumeName,
|
|
306
|
+
mode: "canonical",
|
|
307
|
+
reason: selected.instance.serviceId === desiredServiceId ? "existing desired-service attachment" : "active canonical volume"
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return { bindings, blockedReasons };
|
|
311
|
+
}
|
|
312
|
+
function detachRetainedRailwayVolumeBindings(resources, bindings) {
|
|
313
|
+
const movedVolumeNames = new Set(bindings.map((binding) => binding.volumeName));
|
|
314
|
+
return resources.map((resource) => {
|
|
315
|
+
if (resource.type !== "service" && resource.type !== "database") return resource;
|
|
316
|
+
const attachments = Object.fromEntries(Object.entries(resource.volumeAttachments ?? {}).filter(([, attachment]) => !movedVolumeNames.has(String(attachment.volume).replace(/^volume\./u, ""))));
|
|
317
|
+
const volumeMounts = Object.fromEntries(Object.entries(resource.volumeMounts ?? {}).filter(([volumeId]) => !bindings.some((binding) => binding.volumeId === volumeId)));
|
|
318
|
+
let deploy = resource.deploy;
|
|
319
|
+
if (deploy && Object.keys(attachments).length === 0 && Object.keys(volumeMounts).length === 0) {
|
|
320
|
+
const { requiredMountPath: _requiredMountPath, ...deployWithoutMountRequirement } = deploy;
|
|
321
|
+
deploy = Object.keys(deployWithoutMountRequirement).length > 0 ? deployWithoutMountRequirement : void 0;
|
|
322
|
+
}
|
|
323
|
+
const { volumeAttachments: _attachments, volumeMounts: _volumeMounts, ...retained } = resource;
|
|
324
|
+
return {
|
|
325
|
+
...retained,
|
|
326
|
+
...Object.keys(attachments).length > 0 ? { volumeAttachments: attachments } : {},
|
|
327
|
+
...Object.keys(volumeMounts).length > 0 ? { volumeMounts } : {},
|
|
328
|
+
...deploy ? { deploy } : {}
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function detachRetainedRailwayCustomDomains(resources, domains) {
|
|
333
|
+
const selected = new Set(domains);
|
|
334
|
+
return resources.map((resource) => {
|
|
335
|
+
if (resource.type !== "service" || selected.size === 0) return resource;
|
|
336
|
+
const customDomains = Object.fromEntries(Object.entries(resource.networking?.customDomains ?? {}).filter(([domain]) => !selected.has(domain)));
|
|
337
|
+
if (Object.keys(customDomains).length === Object.keys(resource.networking?.customDomains ?? {}).length) return resource;
|
|
338
|
+
const networking = { ...resource.networking ?? {}, customDomains };
|
|
339
|
+
return { ...resource, networking };
|
|
340
|
+
});
|
|
341
|
+
}
|
|
108
342
|
function renderRailwayIacProject(input) {
|
|
109
343
|
const scope = normalizeIacScope(input);
|
|
110
344
|
const region = input.region?.trim() || "us-east4-eqdc4a";
|
|
111
345
|
const tempParent = resolve(input.tenantRoot, ".treeseed", "tmp");
|
|
346
|
+
cleanupStaleRailwayIacRenders(input.tenantRoot);
|
|
112
347
|
mkdirSync(tempParent, { recursive: true });
|
|
113
348
|
const tempDir = mkdtempSync(resolve(tempParent, "railway-iac-"));
|
|
114
349
|
const filePath = resolve(tempDir, "railway.mjs");
|
|
@@ -175,23 +410,30 @@ function renderRailwayIacProject(input) {
|
|
|
175
410
|
`env: ${renderServiceEnv(service, databaseVariableName, databaseEnvName)}`
|
|
176
411
|
];
|
|
177
412
|
const build = buildConfig(service);
|
|
178
|
-
const deploy = deployConfig(service
|
|
413
|
+
const deploy = deployConfig(service);
|
|
179
414
|
if (build) entries.push(`build: ${js(build)}`);
|
|
180
415
|
if (deploy) entries.push(`deploy: ${js(deploy)}`);
|
|
416
|
+
entries.push(`regions: ${js({ [region]: 1 })}`);
|
|
417
|
+
if ((service.customDomains?.length ?? 0) > 0) {
|
|
418
|
+
entries.push(`networking: ${js({
|
|
419
|
+
customDomains: Object.fromEntries(service.customDomains.map((domain) => [domain, {}]))
|
|
420
|
+
})}`);
|
|
421
|
+
}
|
|
181
422
|
if (service.volumeMountPath) {
|
|
182
|
-
const volumeName = `${service.serviceName}-volume`;
|
|
423
|
+
const volumeName = service.volumeName?.trim() || `${service.serviceName}-volume`;
|
|
424
|
+
const volumeAddress = service.volumeAddress?.trim() || null;
|
|
183
425
|
const volumeVar = id("vol", index);
|
|
184
426
|
const volumeMounts = [
|
|
185
427
|
...(service.detachVolumeIds ?? []).map((volumeId) => `${js(volumeId)}: null`),
|
|
186
428
|
`${js(service.volumeMountPath)}: ${volumeVar}`
|
|
187
429
|
];
|
|
188
430
|
volumeNames.push(volumeName);
|
|
189
|
-
declarations.push(` const ${volumeVar} = volume(${js(volumeName)}, ${js({
|
|
431
|
+
declarations.push(` const ${volumeVar} = ${volumeAddress ? "Object.assign(" : ""}volume(${js(volumeName)}, ${js({
|
|
190
432
|
region,
|
|
191
433
|
sizeMB: 5e4,
|
|
192
434
|
allowOnlineResize: true,
|
|
193
435
|
alerts: { usage: { 80: {}, 95: {}, 100: {} } }
|
|
194
|
-
})});`);
|
|
436
|
+
})})${volumeAddress ? `, { address: ${js(volumeAddress)} })` : ""};`);
|
|
195
437
|
entries.push(`volumeMounts: { ${volumeMounts.join(", ")} }`);
|
|
196
438
|
resources.push(volumeVar);
|
|
197
439
|
}
|
|
@@ -223,11 +465,16 @@ ${declarations.join("\n")}
|
|
|
223
465
|
}
|
|
224
466
|
function selectRailwayIacRetainedResources(plan, allowedNames) {
|
|
225
467
|
const allowed = new Set(allowedNames);
|
|
226
|
-
|
|
227
|
-
return (plan.currentGraph?.resources ?? []).filter((resource) => deletedAllowedNames.has(resource.name));
|
|
468
|
+
return (plan.currentGraph?.resources ?? []).filter((resource) => allowed.has(resource.name));
|
|
228
469
|
}
|
|
229
470
|
function changeName(change) {
|
|
230
|
-
|
|
471
|
+
const directName = String(change?.resource?.name ?? change?.previous?.name ?? "").trim();
|
|
472
|
+
if (directName) return directName;
|
|
473
|
+
const location = String(change?.address ?? change?.path ?? "").trim();
|
|
474
|
+
const pathMatch = /(?:^|\.)?(?:resources\.)?(?:service|database|volume)\.([^\.\s]+)/u.exec(location);
|
|
475
|
+
if (pathMatch?.[1]) return pathMatch[1];
|
|
476
|
+
const summaryMatch = /\b(?:service|database|volume)\s+([^\s]+)/iu.exec(String(change?.summary ?? ""));
|
|
477
|
+
return summaryMatch?.[1] ?? location;
|
|
231
478
|
}
|
|
232
479
|
function changeFieldText(change) {
|
|
233
480
|
return [
|
|
@@ -255,10 +502,12 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
255
502
|
const blockedReasons = [];
|
|
256
503
|
const destructiveChanges = [];
|
|
257
504
|
const desired = /* @__PURE__ */ new Set([...desiredNames.services, ...desiredNames.volumes, ...desiredNames.database ? [desiredNames.database] : []]);
|
|
505
|
+
const allowedResourceDeletions = new Set(desiredNames.allowedResourceDeletions ?? []);
|
|
506
|
+
const protectedResourceNames = new Set(desiredNames.protectedResourceNames ?? []);
|
|
258
507
|
const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
|
|
259
508
|
for (const change of changeSet?.changes ?? []) {
|
|
260
509
|
const name = changeName(change);
|
|
261
|
-
const serviceName = name.replace(/^(service|database)\./u, "");
|
|
510
|
+
const serviceName = name.replace(/^(service|database|volume)\./u, "");
|
|
262
511
|
const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[serviceName] ?? null;
|
|
263
512
|
const sourceRef = desiredNames.serviceSourceRefs?.[name] ?? desiredNames.serviceSourceRefs?.[serviceName] ?? null;
|
|
264
513
|
const sourceChanged = change.kind === "resource.update" && isRailwaySourceChange(change);
|
|
@@ -267,9 +516,14 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
267
516
|
const desiredGitSource = sourceMode === "git" && typeof sourceRef === "string" && sourceRef.startsWith("github:");
|
|
268
517
|
const desiredImageSource = sourceMode === "image" && typeof sourceRef === "string" && sourceRef.startsWith("image:");
|
|
269
518
|
const apiPolicyService = isApiRailwaySourcePolicyService({ serviceName });
|
|
519
|
+
if (protectedResourceNames.has(name) && (change.kind === "resource.update" || change.kind === "resource.delete")) {
|
|
520
|
+
blockedReasons.push(`Railway IaC plan would ${change.kind === "resource.delete" ? "delete" : "update"} sibling-environment resource ${name}.`);
|
|
521
|
+
}
|
|
270
522
|
if (change.kind === "resource.delete") {
|
|
271
523
|
destructiveChanges.push(change.summary);
|
|
272
|
-
|
|
524
|
+
if (!allowedResourceDeletions.has(name) && !allowedResourceDeletions.has(serviceName)) {
|
|
525
|
+
blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only deletes explicitly recognized obsolete aliases. Use the explicit destroy workflow for other deletions.`);
|
|
526
|
+
}
|
|
273
527
|
if (desired.has(name) && !created.has(name)) {
|
|
274
528
|
blockedReasons.push(`Railway IaC plan would delete desired resource ${name}.`);
|
|
275
529
|
}
|
|
@@ -301,7 +555,7 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
301
555
|
};
|
|
302
556
|
}
|
|
303
557
|
async function planRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
|
|
304
|
-
return runRailwayIac({
|
|
558
|
+
return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
|
|
305
559
|
command: "plan",
|
|
306
560
|
cwd: rendered.tempDir,
|
|
307
561
|
file: rendered.filePath,
|
|
@@ -314,10 +568,15 @@ async function planRailwayIacProject(input, rendered = renderRailwayIacProject(i
|
|
|
314
568
|
decryptVariables: false,
|
|
315
569
|
merge: true
|
|
316
570
|
}
|
|
571
|
+
}), {
|
|
572
|
+
onRetry: (attempt, delayMs, error) => process.stderr.write(`[trsd][railway][iac:retry] command=plan attempt=${attempt} waitMs=${delayMs} reason=${error instanceof Error ? error.message : String(error)}
|
|
573
|
+
`),
|
|
574
|
+
onWait: (attempt, remainingMs) => process.stderr.write(`[trsd][railway][iac:retry] command=plan attempt=${attempt} cooldownRemainingMs=${remainingMs}
|
|
575
|
+
`)
|
|
317
576
|
});
|
|
318
577
|
}
|
|
319
578
|
async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
|
|
320
|
-
return runRailwayIac({
|
|
579
|
+
return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
|
|
321
580
|
command: "apply",
|
|
322
581
|
cwd: rendered.tempDir,
|
|
323
582
|
file: rendered.filePath,
|
|
@@ -330,6 +589,11 @@ async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(
|
|
|
330
589
|
decryptVariables: false,
|
|
331
590
|
merge: true
|
|
332
591
|
}
|
|
592
|
+
}), {
|
|
593
|
+
onRetry: (attempt, delayMs, error) => process.stderr.write(`[trsd][railway][iac:retry] command=apply attempt=${attempt} waitMs=${delayMs} reason=${error instanceof Error ? error.message : String(error)}
|
|
594
|
+
`),
|
|
595
|
+
onWait: (attempt, remainingMs) => process.stderr.write(`[trsd][railway][iac:retry] command=apply attempt=${attempt} cooldownRemainingMs=${remainingMs}
|
|
596
|
+
`)
|
|
333
597
|
});
|
|
334
598
|
}
|
|
335
599
|
function cleanupRailwayIacRender(rendered) {
|
|
@@ -338,8 +602,20 @@ function cleanupRailwayIacRender(rendered) {
|
|
|
338
602
|
export {
|
|
339
603
|
applyRailwayIacProject,
|
|
340
604
|
cleanupRailwayIacRender,
|
|
605
|
+
cleanupStaleRailwayIacRenders,
|
|
606
|
+
detachRetainedRailwayCustomDomains,
|
|
607
|
+
detachRetainedRailwayVolumeBindings,
|
|
608
|
+
findRailwayPendingVolumeNameCollisions,
|
|
341
609
|
planRailwayIacProject,
|
|
610
|
+
railwayIacApplyFailure,
|
|
342
611
|
renderRailwayIacProject,
|
|
612
|
+
resolveRailwayIacVolumeBindings,
|
|
613
|
+
runRailwayIacWithRateLimitRetry,
|
|
343
614
|
selectRailwayIacRetainedResources,
|
|
344
|
-
validateRailwayIacChangeSet
|
|
615
|
+
validateRailwayIacChangeSet,
|
|
616
|
+
waitForRailwayServiceAbsence,
|
|
617
|
+
waitForRailwayServices,
|
|
618
|
+
waitForRailwayVolumeAdoptionResources,
|
|
619
|
+
waitForRailwayVolumeDetachment,
|
|
620
|
+
waitForRailwayVolumeName
|
|
345
621
|
};
|
package/dist/scenes/runner.js
CHANGED
|
@@ -243,10 +243,10 @@ async function runTreeseedScene(input) {
|
|
|
243
243
|
const recordingVideo = Boolean(videoDir);
|
|
244
244
|
const capture = resolveCapture({ scene, device, runtimeMode: runtime.mode, recording: Boolean(videoDir) });
|
|
245
245
|
const artifacts = createTreeseedSceneRunArtifacts({ paths, playwrightTracePath: tracePath });
|
|
246
|
-
writeFileSync(artifacts.consoleLogPath
|
|
247
|
-
writeFileSync(artifacts.networkLogPath
|
|
248
|
-
writeFileSync(artifacts.errorsLogPath
|
|
249
|
-
|
|
246
|
+
writeFileSync(artifacts.consoleLogPath, "", "utf8");
|
|
247
|
+
writeFileSync(artifacts.networkLogPath, "", "utf8");
|
|
248
|
+
writeFileSync(artifacts.errorsLogPath, "", "utf8");
|
|
249
|
+
writeFileSync(artifacts.progressPath, "", "utf8");
|
|
250
250
|
const timeline = createTreeseedSceneTimeline({ sceneId: scene.id, runId: paths.runId, startedAtMs: startedAt.getTime() });
|
|
251
251
|
const progress = createTreeseedSceneProgress({ sceneId: scene.id, runId: paths.runId, startedAtMs: startedAt.getTime(), progressPath: artifacts.progressPath, onProgress: input.onProgress });
|
|
252
252
|
progress.push("scene.run.started", { title: scene.title, environment: plan.environment });
|
|
@@ -368,15 +368,15 @@ async function runTreeseedScene(input) {
|
|
|
368
368
|
if (message.type() !== "error") return;
|
|
369
369
|
const entry = { message: message.text(), timestamp: now().toISOString(), ...currentStepId ? { stepId: currentStepId } : {} };
|
|
370
370
|
consoleErrors.push(entry);
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
appendTreeseedSceneJsonl(artifacts.consoleLogPath, entry);
|
|
372
|
+
appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "console", ...entry });
|
|
373
373
|
timeline.push("console", entry, currentStepId);
|
|
374
374
|
});
|
|
375
375
|
session.page.on("requestfailed", (request) => {
|
|
376
376
|
const entry = { message: request.failure()?.errorText ?? "request failed", timestamp: now().toISOString(), url: request.url(), method: request.method(), ...currentStepId ? { stepId: currentStepId } : {} };
|
|
377
377
|
networkErrors.push(entry);
|
|
378
|
-
|
|
379
|
-
|
|
378
|
+
appendTreeseedSceneJsonl(artifacts.networkLogPath, entry);
|
|
379
|
+
appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "network", ...entry });
|
|
380
380
|
timeline.push("network", entry, currentStepId);
|
|
381
381
|
});
|
|
382
382
|
session.page.on("response", (response) => {
|
|
@@ -391,8 +391,8 @@ async function runTreeseedScene(input) {
|
|
|
391
391
|
if (status < 400) return;
|
|
392
392
|
const entry = { message: `HTTP ${status}`, timestamp: now().toISOString(), url: response.url(), method: response.request().method(), status, ...currentStepId ? { stepId: currentStepId } : {} };
|
|
393
393
|
networkErrors.push(entry);
|
|
394
|
-
|
|
395
|
-
|
|
394
|
+
appendTreeseedSceneJsonl(artifacts.networkLogPath, entry);
|
|
395
|
+
appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "network", ...entry });
|
|
396
396
|
timeline.push("network", entry, currentStepId);
|
|
397
397
|
});
|
|
398
398
|
if (tracePath) await session.startTracing?.();
|
|
@@ -673,7 +673,7 @@ async function runTreeseedScene(input) {
|
|
|
673
673
|
segments,
|
|
674
674
|
checkpoints,
|
|
675
675
|
resumedFrom: null,
|
|
676
|
-
progressPath: artifacts.progressPath
|
|
676
|
+
progressPath: artifacts.progressPath,
|
|
677
677
|
warnings: splitDiagnostics(diagnostics, "warning"),
|
|
678
678
|
blockers: splitDiagnostics(diagnostics, "error"),
|
|
679
679
|
diagnostics
|
|
@@ -36,6 +36,27 @@ function lockOwnerIsRunning() {
|
|
|
36
36
|
return code === 'EPERM';
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
function processIsRunning(pid) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const code = typeof error === 'object' && error && 'code' in error ? error.code : null;
|
|
46
|
+
return code === 'EPERM';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function removeStaleBuildRoots() {
|
|
50
|
+
for (const entry of readdirSync(packageRoot, { withFileTypes: true })) {
|
|
51
|
+
const match = entry.isDirectory() ? /^\.treeseed-dist-build-(\d+)$/u.exec(entry.name) : null;
|
|
52
|
+
if (!match?.[1])
|
|
53
|
+
continue;
|
|
54
|
+
const pid = Number.parseInt(match[1], 10);
|
|
55
|
+
if (pid === process.pid || processIsRunning(pid))
|
|
56
|
+
continue;
|
|
57
|
+
rmSync(resolve(packageRoot, entry.name), { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
39
60
|
async function acquireBuildLock() {
|
|
40
61
|
const startedAt = Date.now();
|
|
41
62
|
while (true) {
|
|
@@ -45,6 +66,7 @@ async function acquireBuildLock() {
|
|
|
45
66
|
pid: process.pid,
|
|
46
67
|
startedAt: new Date().toISOString(),
|
|
47
68
|
}, null, 2));
|
|
69
|
+
removeStaleBuildRoots();
|
|
48
70
|
return () => rmSync(buildLockRoot, { recursive: true, force: true });
|
|
49
71
|
}
|
|
50
72
|
catch (error) {
|
|
@@ -1085,6 +1085,10 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
|
|
|
1085
1085
|
status: string;
|
|
1086
1086
|
reason: string;
|
|
1087
1087
|
attempts?: undefined;
|
|
1088
|
+
} | {
|
|
1089
|
+
status: string;
|
|
1090
|
+
reason: string;
|
|
1091
|
+
attempts: number;
|
|
1088
1092
|
} | {
|
|
1089
1093
|
status: string;
|
|
1090
1094
|
reason: null;
|
|
@@ -1144,6 +1148,14 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
|
|
|
1144
1148
|
cached: boolean;
|
|
1145
1149
|
}[];
|
|
1146
1150
|
};
|
|
1151
|
+
apiEnvironmentIsolation: {
|
|
1152
|
+
status: string;
|
|
1153
|
+
order: string[];
|
|
1154
|
+
reports: Record<string, import("../workflow-support.js").TreeseedLiveHostedServiceCheckReport>;
|
|
1155
|
+
} | {
|
|
1156
|
+
status: string;
|
|
1157
|
+
reason: string;
|
|
1158
|
+
};
|
|
1147
1159
|
productionImageRefs: {
|
|
1148
1160
|
persisted: Record<string, string>;
|
|
1149
1161
|
readiness: import("../workflow-support.js").TreeseedDeploymentReadinessReport;
|