@treeseed/sdk 0.12.60 → 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 -245
- 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 +5 -5
- 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 +51 -15
- 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 +2 -2
- package/dist/operations/services/railway-deploy.js +36 -91
- 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 +519 -684
- 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 +147 -0
- package/dist/reconcile/providers/railway-iac.js +289 -16
- 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 +4 -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 [
|
|
@@ -256,6 +503,7 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
256
503
|
const destructiveChanges = [];
|
|
257
504
|
const desired = /* @__PURE__ */ new Set([...desiredNames.services, ...desiredNames.volumes, ...desiredNames.database ? [desiredNames.database] : []]);
|
|
258
505
|
const allowedResourceDeletions = new Set(desiredNames.allowedResourceDeletions ?? []);
|
|
506
|
+
const protectedResourceNames = new Set(desiredNames.protectedResourceNames ?? []);
|
|
259
507
|
const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
|
|
260
508
|
for (const change of changeSet?.changes ?? []) {
|
|
261
509
|
const name = changeName(change);
|
|
@@ -268,6 +516,9 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
268
516
|
const desiredGitSource = sourceMode === "git" && typeof sourceRef === "string" && sourceRef.startsWith("github:");
|
|
269
517
|
const desiredImageSource = sourceMode === "image" && typeof sourceRef === "string" && sourceRef.startsWith("image:");
|
|
270
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
|
+
}
|
|
271
522
|
if (change.kind === "resource.delete") {
|
|
272
523
|
destructiveChanges.push(change.summary);
|
|
273
524
|
if (!allowedResourceDeletions.has(name) && !allowedResourceDeletions.has(serviceName)) {
|
|
@@ -304,7 +555,7 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
304
555
|
};
|
|
305
556
|
}
|
|
306
557
|
async function planRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
|
|
307
|
-
return runRailwayIac({
|
|
558
|
+
return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
|
|
308
559
|
command: "plan",
|
|
309
560
|
cwd: rendered.tempDir,
|
|
310
561
|
file: rendered.filePath,
|
|
@@ -317,10 +568,15 @@ async function planRailwayIacProject(input, rendered = renderRailwayIacProject(i
|
|
|
317
568
|
decryptVariables: false,
|
|
318
569
|
merge: true
|
|
319
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
|
+
`)
|
|
320
576
|
});
|
|
321
577
|
}
|
|
322
578
|
async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
|
|
323
|
-
return runRailwayIac({
|
|
579
|
+
return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
|
|
324
580
|
command: "apply",
|
|
325
581
|
cwd: rendered.tempDir,
|
|
326
582
|
file: rendered.filePath,
|
|
@@ -333,6 +589,11 @@ async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(
|
|
|
333
589
|
decryptVariables: false,
|
|
334
590
|
merge: true
|
|
335
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
|
+
`)
|
|
336
597
|
});
|
|
337
598
|
}
|
|
338
599
|
function cleanupRailwayIacRender(rendered) {
|
|
@@ -341,8 +602,20 @@ function cleanupRailwayIacRender(rendered) {
|
|
|
341
602
|
export {
|
|
342
603
|
applyRailwayIacProject,
|
|
343
604
|
cleanupRailwayIacRender,
|
|
605
|
+
cleanupStaleRailwayIacRenders,
|
|
606
|
+
detachRetainedRailwayCustomDomains,
|
|
607
|
+
detachRetainedRailwayVolumeBindings,
|
|
608
|
+
findRailwayPendingVolumeNameCollisions,
|
|
344
609
|
planRailwayIacProject,
|
|
610
|
+
railwayIacApplyFailure,
|
|
345
611
|
renderRailwayIacProject,
|
|
612
|
+
resolveRailwayIacVolumeBindings,
|
|
613
|
+
runRailwayIacWithRateLimitRetry,
|
|
346
614
|
selectRailwayIacRetainedResources,
|
|
347
|
-
validateRailwayIacChangeSet
|
|
615
|
+
validateRailwayIacChangeSet,
|
|
616
|
+
waitForRailwayServiceAbsence,
|
|
617
|
+
waitForRailwayServices,
|
|
618
|
+
waitForRailwayVolumeAdoptionResources,
|
|
619
|
+
waitForRailwayVolumeDetachment,
|
|
620
|
+
waitForRailwayVolumeName
|
|
348
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;
|