@treeseed/sdk 0.10.27 → 0.11.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/README.md +207 -6
- package/dist/capacity-provider.d.ts +3 -1
- package/dist/capacity-provider.js +25 -5
- package/dist/control-plane.d.ts +1 -0
- package/dist/control-plane.js +38 -13
- package/dist/db/market-schema.d.ts +8860 -6172
- package/dist/db/market-schema.js +108 -0
- package/dist/db/node-sqlite.js +7 -2
- package/dist/hosting/apps.d.ts +12 -0
- package/dist/hosting/apps.js +107 -0
- package/dist/hosting/builtins.d.ts +25 -0
- package/dist/hosting/builtins.js +791 -0
- package/dist/hosting/contracts.d.ts +207 -0
- package/dist/hosting/contracts.js +0 -0
- package/dist/hosting/graph.d.ts +192 -0
- package/dist/hosting/graph.js +1106 -0
- package/dist/hosting/index.d.ts +4 -0
- package/dist/hosting/index.js +4 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +71 -7
- package/dist/managed-dependencies.js +1 -2
- package/dist/market-client.d.ts +63 -3
- package/dist/market-client.js +83 -11
- package/dist/operations/services/bootstrap-runner.d.ts +3 -1
- package/dist/operations/services/bootstrap-runner.js +22 -2
- package/dist/operations/services/config-runtime.d.ts +10 -5
- package/dist/operations/services/config-runtime.js +209 -66
- package/dist/operations/services/deploy.d.ts +70 -7
- package/dist/operations/services/deploy.js +579 -64
- package/dist/operations/services/deployment-readiness.d.ts +30 -0
- package/dist/operations/services/deployment-readiness.js +175 -0
- package/dist/operations/services/git-workflow.d.ts +2 -1
- package/dist/operations/services/git-workflow.js +9 -3
- package/dist/operations/services/github-actions-verification.d.ts +1 -0
- package/dist/operations/services/github-actions-verification.js +1 -0
- package/dist/operations/services/github-api.js +1 -1
- package/dist/operations/services/github-automation.d.ts +1 -1
- package/dist/operations/services/github-automation.js +4 -3
- package/dist/operations/services/github-credentials.d.ts +13 -0
- package/dist/operations/services/github-credentials.js +58 -0
- package/dist/operations/services/hosted-service-checks.d.ts +63 -0
- package/dist/operations/services/hosted-service-checks.js +327 -0
- package/dist/operations/services/hub-provider-launch.js +3 -3
- package/dist/operations/services/live-hosted-service-checks.d.ts +25 -0
- package/dist/operations/services/live-hosted-service-checks.js +350 -0
- package/dist/operations/services/managed-host-security.js +1 -1
- package/dist/operations/services/operations-runner-smoke.d.ts +30 -0
- package/dist/operations/services/operations-runner-smoke.js +180 -0
- package/dist/operations/services/package-adapters.d.ts +95 -0
- package/dist/operations/services/package-adapters.js +288 -0
- package/dist/operations/services/package-reference-policy.d.ts +1 -0
- package/dist/operations/services/package-reference-policy.js +15 -2
- package/dist/operations/services/project-platform.d.ts +80 -22
- package/dist/operations/services/project-platform.js +49 -8
- package/dist/operations/services/project-web-monitor.js +26 -4
- package/dist/operations/services/railway-api.d.ts +88 -5
- package/dist/operations/services/railway-api.js +626 -35
- package/dist/operations/services/railway-deploy.d.ts +46 -40
- package/dist/operations/services/railway-deploy.js +261 -293
- package/dist/operations/services/release-candidate.d.ts +19 -0
- package/dist/operations/services/release-candidate.js +375 -38
- package/dist/operations/services/repository-save-orchestrator.d.ts +3 -1
- package/dist/operations/services/repository-save-orchestrator.js +279 -66
- package/dist/operations/services/runtime-tools.d.ts +1 -0
- package/dist/operations/services/runtime-tools.js +10 -9
- package/dist/operations/services/template-registry.js +14 -7
- package/dist/operations/services/verification-cache.d.ts +25 -0
- package/dist/operations/services/verification-cache.js +71 -0
- package/dist/operations/services/workspace-dependency-mode.js +9 -1
- package/dist/operations/services/workspace-save.js +1 -1
- package/dist/operations/services/workspace-tools.js +2 -1
- package/dist/platform/contracts.d.ts +32 -1
- package/dist/platform/deploy-config.js +73 -8
- package/dist/platform/env.yaml +163 -35
- package/dist/platform/environment.d.ts +1 -0
- package/dist/platform/environment.js +74 -5
- package/dist/platform/plugin.d.ts +9 -0
- package/dist/platform-operation-store.js +2 -2
- package/dist/platform-operations.js +1 -1
- package/dist/reconcile/bootstrap-systems.js +2 -2
- package/dist/reconcile/builtin-adapters.js +372 -189
- package/dist/reconcile/contracts.d.ts +9 -5
- package/dist/reconcile/desired-state.d.ts +1 -0
- package/dist/reconcile/desired-state.js +5 -5
- package/dist/reconcile/engine.d.ts +5 -2
- package/dist/reconcile/engine.js +53 -32
- package/dist/reconcile/index.d.ts +2 -0
- package/dist/reconcile/index.js +2 -0
- package/dist/reconcile/live-acceptance.d.ts +79 -0
- package/dist/reconcile/live-acceptance.js +1615 -0
- package/dist/reconcile/platform.d.ts +104 -0
- package/dist/reconcile/platform.js +100 -0
- package/dist/reconcile/state.js +4 -4
- package/dist/reconcile/units.js +2 -2
- package/dist/scripts/deployment-readiness.js +20 -0
- package/dist/scripts/generate-treedx-openapi-types.js +186 -0
- package/dist/scripts/operations-runner-smoke.js +16 -0
- package/dist/scripts/release-verify.js +4 -1
- package/dist/scripts/template-catalog.test.js +7 -7
- package/dist/scripts/tenant-workflow-action.js +10 -1
- package/dist/sdk-types.d.ts +172 -5
- package/dist/sdk-types.js +28 -3
- package/dist/sdk.d.ts +35 -24
- package/dist/sdk.js +186 -17
- package/dist/template-launch-requirements.js +9 -0
- package/dist/treedx/adapters.d.ts +6 -0
- package/dist/treedx/adapters.js +36 -0
- package/dist/treedx/client.d.ts +222 -0
- package/dist/treedx/client.js +871 -0
- package/dist/treedx/errors.d.ts +13 -0
- package/dist/treedx/errors.js +17 -0
- package/dist/treedx/federated-client.d.ts +27 -0
- package/dist/treedx/federated-client.js +158 -0
- package/dist/treedx/generated/openapi-types.d.ts +3558 -0
- package/dist/treedx/generated/openapi-types.js +0 -0
- package/dist/treedx/graph-adapter.d.ts +33 -0
- package/dist/treedx/graph-adapter.js +156 -0
- package/dist/treedx/index.d.ts +14 -0
- package/dist/treedx/index.js +48 -0
- package/dist/treedx/market-integration.d.ts +27 -0
- package/dist/treedx/market-integration.js +131 -0
- package/dist/treedx/ports.d.ts +166 -0
- package/dist/treedx/ports.js +231 -0
- package/dist/treedx/query-adapter.d.ts +19 -0
- package/dist/treedx/query-adapter.js +62 -0
- package/dist/treedx/registry-client.d.ts +11 -0
- package/dist/treedx/registry-client.js +19 -0
- package/dist/treedx/repository-adapter.d.ts +45 -0
- package/dist/treedx/repository-adapter.js +308 -0
- package/dist/treedx/sdk-integration.d.ts +27 -0
- package/dist/treedx/sdk-integration.js +63 -0
- package/dist/treedx/types.d.ts +1084 -0
- package/dist/treedx/types.js +8 -0
- package/dist/treedx/workspace-adapter.d.ts +27 -0
- package/dist/treedx/workspace-adapter.js +65 -0
- package/dist/treedx-backends.d.ts +218 -0
- package/dist/treedx-backends.js +632 -0
- package/dist/treedx-client.d.ts +86 -0
- package/dist/treedx-client.js +175 -0
- package/dist/treeseed/template-catalog/catalog.fixture.json +497 -138
- package/dist/workflow/operations.d.ts +119 -13
- package/dist/workflow/operations.js +309 -53
- package/dist/workflow-state.d.ts +13 -0
- package/dist/workflow-state.js +43 -26
- package/dist/workflow-support.d.ts +11 -3
- package/dist/workflow-support.js +67 -3
- package/dist/workflow.d.ts +5 -0
- package/drizzle/market/0004_treedx_market_integration.sql +99 -0
- package/package.json +34 -3
- package/templates/github/deploy-web.workflow.yml +39 -6
- package/dist/treeseed/template-catalog/templates/starter-basic/template/astro.config.d.ts +0 -3
- package/dist/treeseed/template-catalog/templates/starter-basic/template/astro.config.ts +0 -6
- package/dist/treeseed/template-catalog/templates/starter-basic/template/package.json +0 -35
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/api/server.js +0 -4
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/config.yaml +0 -65
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/decisions/adopt-initial-proposal-loop.mdx +0 -22
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/empty/.gitkeep +0 -1
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/knowledge/handbook/index.mdx +0 -11
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/pages/welcome.mdx +0 -11
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/people/starter-steward.mdx +0 -11
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/proposals/establish-initial-proposal-loop.mdx +0 -17
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content.config.d.ts +0 -1
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content.config.ts +0 -3
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/env.yaml +0 -1
- package/dist/treeseed/template-catalog/templates/starter-basic/template/src/manifest.yaml +0 -26
- package/dist/treeseed/template-catalog/templates/starter-basic/template/treeseed.site.yaml +0 -74
- package/dist/treeseed/template-catalog/templates/starter-basic/template/tsconfig.json +0 -9
- package/dist/treeseed/template-catalog/templates/starter-basic/template.config.json +0 -103
|
@@ -34,10 +34,7 @@ import {
|
|
|
34
34
|
} from "../operations/services/deploy.js";
|
|
35
35
|
import {
|
|
36
36
|
configuredRailwayServices,
|
|
37
|
-
|
|
38
|
-
ensureRailwayProjectContext,
|
|
39
|
-
ensureRailwayServiceVolumeWithCliFallback,
|
|
40
|
-
runRailway,
|
|
37
|
+
findStaleTreeseedOperationsRunnerResources,
|
|
41
38
|
validateRailwayDeployPrerequisites
|
|
42
39
|
} from "../operations/services/railway-deploy.js";
|
|
43
40
|
import { shouldExposeManagedHostRuntimeSecret } from "../operations/services/managed-host-security.js";
|
|
@@ -47,19 +44,24 @@ import {
|
|
|
47
44
|
ensureRailwayProject,
|
|
48
45
|
ensureRailwayService,
|
|
49
46
|
ensureRailwayServiceInstanceConfiguration,
|
|
47
|
+
ensureRailwayServiceVolume,
|
|
48
|
+
ensureRailwayPostgresService,
|
|
49
|
+
deleteRailwayService,
|
|
50
|
+
deleteRailwayVolume,
|
|
50
51
|
getRailwayServiceInstance,
|
|
51
52
|
getRailwayProject,
|
|
52
53
|
listRailwayCustomDomains,
|
|
53
54
|
listRailwayEnvironments,
|
|
54
55
|
listRailwayProjects,
|
|
56
|
+
listRailwayServices,
|
|
55
57
|
listRailwayVolumes,
|
|
56
58
|
listRailwayVariables,
|
|
57
59
|
resolveRailwayWorkspaceContext,
|
|
58
|
-
updateRailwayServiceName,
|
|
59
60
|
upsertRailwayVariables
|
|
60
61
|
} from "../operations/services/railway-api.js";
|
|
61
62
|
import { loadTreeseedReconcileState } from "./state.js";
|
|
62
63
|
import { createTreeseedReconcileUnitId } from "./units.js";
|
|
64
|
+
import { discoverTreeseedApplications } from "../hosting/apps.js";
|
|
63
65
|
function toDeployTarget(target) {
|
|
64
66
|
return target.kind === "persistent" ? createPersistentDeployTarget(target.scope) : createBranchPreviewDeployTarget(target.branchName);
|
|
65
67
|
}
|
|
@@ -103,28 +105,6 @@ function noopDiff() {
|
|
|
103
105
|
after: {}
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
|
-
function parseRailwayJsonPayload(output) {
|
|
107
|
-
const text = typeof output === "string" ? output.trim() : "";
|
|
108
|
-
if (!text) {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
return JSON.parse(text);
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
const lines = text.split(/\r?\n/u);
|
|
116
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
117
|
-
const candidate = lines.slice(index).join("\n").trim();
|
|
118
|
-
if (!candidate.startsWith("{") && !candidate.startsWith("[")) {
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
return JSON.parse(candidate);
|
|
123
|
-
} catch {
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
108
|
function buildCompositeAdapter(unitType) {
|
|
129
109
|
return {
|
|
130
110
|
providerId: "treeseed",
|
|
@@ -132,10 +112,10 @@ function buildCompositeAdapter(unitType) {
|
|
|
132
112
|
supports(candidateUnitType, providerId) {
|
|
133
113
|
return candidateUnitType === unitType && providerId === "treeseed";
|
|
134
114
|
},
|
|
135
|
-
|
|
115
|
+
refresh(input) {
|
|
136
116
|
return noopObservedState(input);
|
|
137
117
|
},
|
|
138
|
-
|
|
118
|
+
diff() {
|
|
139
119
|
return noopDiff();
|
|
140
120
|
},
|
|
141
121
|
requiredPostconditions({ unit }) {
|
|
@@ -144,7 +124,7 @@ function buildCompositeAdapter(unitType) {
|
|
|
144
124
|
description: `Dependency ${dependency} is verified`
|
|
145
125
|
}));
|
|
146
126
|
},
|
|
147
|
-
|
|
127
|
+
apply({ unit, observed, diff }) {
|
|
148
128
|
return {
|
|
149
129
|
unit,
|
|
150
130
|
observed,
|
|
@@ -329,7 +309,7 @@ function listCloudflareQueuesViaApi(env) {
|
|
|
329
309
|
return Array.isArray(payload?.result) ? payload.result : [];
|
|
330
310
|
}
|
|
331
311
|
function cloudflareObservationSnapshot(input, forceRefresh = false) {
|
|
332
|
-
const cacheKey = `cloudflare:
|
|
312
|
+
const cacheKey = `cloudflare:refresh:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
|
|
333
313
|
return providerCache(input, cacheKey, () => {
|
|
334
314
|
const target = toDeployTarget(input.context.target);
|
|
335
315
|
const env = buildCloudflareEnv(input);
|
|
@@ -557,58 +537,30 @@ function normalizeRailwayDomainPayload(value) {
|
|
|
557
537
|
};
|
|
558
538
|
}
|
|
559
539
|
async function ensureRailwayCustomDomain(input, service, domain, env, identifiers) {
|
|
560
|
-
if (identifiers?.projectId
|
|
561
|
-
|
|
562
|
-
projectId: identifiers.projectId,
|
|
563
|
-
environmentId: identifiers.environmentId,
|
|
564
|
-
serviceId: identifiers.serviceId,
|
|
565
|
-
domain,
|
|
566
|
-
env
|
|
567
|
-
});
|
|
568
|
-
if (ensured.domain) {
|
|
569
|
-
return ensured.domain;
|
|
570
|
-
}
|
|
540
|
+
if (!identifiers?.projectId || !identifiers.environmentId || !identifiers.serviceId) {
|
|
541
|
+
throw new Error(`Railway custom domain ${domain} cannot be reconciled without project, environment, and service ids.`);
|
|
571
542
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
allowFailure: true,
|
|
543
|
+
const existing = await listRailwayCustomDomains({
|
|
544
|
+
projectId: identifiers.projectId,
|
|
545
|
+
environmentId: identifiers.environmentId,
|
|
546
|
+
serviceId: identifiers.serviceId,
|
|
577
547
|
env
|
|
578
548
|
});
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
});
|
|
588
|
-
const matched = refreshed.find((entry) => entry.domain === domain) ?? null;
|
|
589
|
-
if (matched) {
|
|
590
|
-
return matched;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
if (result.status !== 0 && !/already exists|already assigned|taken|has already been taken|not available/iu.test(output)) {
|
|
594
|
-
throw new Error(result.stderr?.trim() || result.stdout?.trim() || `railway domain ${domain} failed`);
|
|
595
|
-
}
|
|
596
|
-
let parsedJson = {};
|
|
597
|
-
if (result.stdout?.trim()) {
|
|
598
|
-
try {
|
|
599
|
-
parsedJson = JSON.parse(result.stdout);
|
|
600
|
-
} catch {
|
|
601
|
-
parsedJson = {};
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
const parsed = normalizeRailwayDomainPayload(parsedJson);
|
|
605
|
-
return parsed ?? {
|
|
606
|
-
id: null,
|
|
549
|
+
const matched = existing.find((entry) => entry.domain === domain) ?? null;
|
|
550
|
+
if (matched) {
|
|
551
|
+
return matched;
|
|
552
|
+
}
|
|
553
|
+
const ensured = await ensureRailwayCustomDomainViaApi({
|
|
554
|
+
projectId: identifiers.projectId,
|
|
555
|
+
environmentId: identifiers.environmentId,
|
|
556
|
+
serviceId: identifiers.serviceId,
|
|
607
557
|
domain,
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
558
|
+
env
|
|
559
|
+
});
|
|
560
|
+
if (!ensured.domain) {
|
|
561
|
+
throw new Error(`Railway API custom domain reconciliation did not return ${domain}.`);
|
|
562
|
+
}
|
|
563
|
+
return ensured.domain;
|
|
612
564
|
}
|
|
613
565
|
function collectCloudflareEnvironmentSync(input) {
|
|
614
566
|
const target = toDeployTarget(input.context.target);
|
|
@@ -1417,16 +1369,27 @@ function buildCloudflareDiff(input, observed) {
|
|
|
1417
1369
|
after: input.unit.spec
|
|
1418
1370
|
};
|
|
1419
1371
|
}
|
|
1372
|
+
if (input.unit.unitType === "pages-project") {
|
|
1373
|
+
const verification = verifyCloudflareUnit(input, []);
|
|
1374
|
+
if (verification.supported && !verification.verified) {
|
|
1375
|
+
return {
|
|
1376
|
+
action: "update",
|
|
1377
|
+
reasons: [...verification.missing, ...verification.drifted],
|
|
1378
|
+
before: observed.live,
|
|
1379
|
+
after: input.unit.spec
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1420
1383
|
const locatorValues = Object.values(observed.locators).filter(Boolean);
|
|
1421
1384
|
return {
|
|
1422
|
-
action: locatorValues.length > 0 ? "
|
|
1385
|
+
action: locatorValues.length > 0 ? "noop" : "update",
|
|
1423
1386
|
reasons: locatorValues.length > 0 ? ["resource already present"] : ["resource partially configured"],
|
|
1424
1387
|
before: observed.live,
|
|
1425
1388
|
after: input.unit.spec
|
|
1426
1389
|
};
|
|
1427
1390
|
}
|
|
1428
1391
|
function reconcileCloudflareUnit(input, diff) {
|
|
1429
|
-
const cacheKey = `cloudflare:
|
|
1392
|
+
const cacheKey = `cloudflare:apply:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
|
|
1430
1393
|
const { state } = providerCache(input, cacheKey, () => {
|
|
1431
1394
|
let attempt = 0;
|
|
1432
1395
|
for (; ; ) {
|
|
@@ -1449,7 +1412,7 @@ function reconcileCloudflareUnit(input, diff) {
|
|
|
1449
1412
|
unit: input.unit,
|
|
1450
1413
|
observed: refreshed,
|
|
1451
1414
|
diff,
|
|
1452
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
1415
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
1453
1416
|
warnings: refreshed.warnings,
|
|
1454
1417
|
resourceLocators: refreshed.locators,
|
|
1455
1418
|
state: input.unit.unitType === "edge-worker" ? { workerName: state.workerName, lastDeployedUrl: state.lastDeployedUrl ?? null } : refreshed.live
|
|
@@ -1462,7 +1425,7 @@ function buildCloudflareAdapter(unitType) {
|
|
|
1462
1425
|
supports(candidateUnitType, providerId) {
|
|
1463
1426
|
return providerId === "cloudflare" && candidateUnitType === unitType;
|
|
1464
1427
|
},
|
|
1465
|
-
|
|
1428
|
+
refresh(input) {
|
|
1466
1429
|
return observeCloudflareUnit(input);
|
|
1467
1430
|
},
|
|
1468
1431
|
requiredPostconditions(input) {
|
|
@@ -1507,10 +1470,10 @@ function buildCloudflareAdapter(unitType) {
|
|
|
1507
1470
|
return [];
|
|
1508
1471
|
}
|
|
1509
1472
|
},
|
|
1510
|
-
|
|
1473
|
+
diff(input) {
|
|
1511
1474
|
return buildCloudflareDiff(input, input.observed);
|
|
1512
1475
|
},
|
|
1513
|
-
|
|
1476
|
+
apply(input) {
|
|
1514
1477
|
return reconcileCloudflareUnit(input, input.diff);
|
|
1515
1478
|
},
|
|
1516
1479
|
verify(input) {
|
|
@@ -1523,12 +1486,12 @@ function buildCloudflareAdapter(unitType) {
|
|
|
1523
1486
|
unit: input.unit,
|
|
1524
1487
|
observed: input.observed,
|
|
1525
1488
|
diff: {
|
|
1526
|
-
action: "
|
|
1489
|
+
action: "delete",
|
|
1527
1490
|
reasons: ["target destroyed"],
|
|
1528
1491
|
before: input.observed.live,
|
|
1529
1492
|
after: {}
|
|
1530
1493
|
},
|
|
1531
|
-
action: "
|
|
1494
|
+
action: "delete",
|
|
1532
1495
|
warnings: [],
|
|
1533
1496
|
resourceLocators: {},
|
|
1534
1497
|
state: {},
|
|
@@ -1541,6 +1504,27 @@ function relativeRailwayRootDir(tenantRoot, serviceRoot) {
|
|
|
1541
1504
|
const resolved = relative(tenantRoot, serviceRoot).replace(/\\/gu, "/");
|
|
1542
1505
|
return !resolved || resolved === "" ? "." : resolved;
|
|
1543
1506
|
}
|
|
1507
|
+
function railwayServiceRootDirectory(tenantRoot, service) {
|
|
1508
|
+
return relativeRailwayRootDir(service.application?.root ?? tenantRoot, service.rootDir);
|
|
1509
|
+
}
|
|
1510
|
+
function configuredMarketDatabaseService(tenantRoot, deployConfig) {
|
|
1511
|
+
if (deployConfig.services?.treeseedDatabase) {
|
|
1512
|
+
return treeseedDatabaseDescriptor(deployConfig.services.treeseedDatabase, deployConfig.slug);
|
|
1513
|
+
}
|
|
1514
|
+
for (const application of discoverTreeseedApplications(tenantRoot)) {
|
|
1515
|
+
const service = application.config.services?.treeseedDatabase;
|
|
1516
|
+
if (service) {
|
|
1517
|
+
return treeseedDatabaseDescriptor(service, application.config.slug);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
function treeseedDatabaseDescriptor(service, slug) {
|
|
1523
|
+
return {
|
|
1524
|
+
service,
|
|
1525
|
+
serviceName: typeof service.railway?.serviceName === "string" && service.railway.serviceName.trim() ? service.railway.serviceName.trim() : `${slug ?? "treeseed-api"}-postgres`
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1544
1528
|
async function ensureRailwayEnvironmentForService({
|
|
1545
1529
|
service,
|
|
1546
1530
|
project,
|
|
@@ -1559,16 +1543,6 @@ async function ensureRailwayEnvironmentForService({
|
|
|
1559
1543
|
throw error;
|
|
1560
1544
|
}
|
|
1561
1545
|
}
|
|
1562
|
-
ensureRailwayProjectContext({
|
|
1563
|
-
...service,
|
|
1564
|
-
projectId: project.id,
|
|
1565
|
-
projectName: project.name ?? service.projectName,
|
|
1566
|
-
railwayEnvironment: environmentName
|
|
1567
|
-
}, {
|
|
1568
|
-
env,
|
|
1569
|
-
allowFailure: true,
|
|
1570
|
-
capture: true
|
|
1571
|
-
});
|
|
1572
1546
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
1573
1547
|
const environments = await listRailwayEnvironments({ projectId: project.id, env });
|
|
1574
1548
|
const existing = environments.find((environment) => environment.name === environmentName || environment.id === environmentName) ?? null;
|
|
@@ -1577,7 +1551,7 @@ async function ensureRailwayEnvironmentForService({
|
|
|
1577
1551
|
}
|
|
1578
1552
|
await new Promise((resolve2) => setTimeout(resolve2, 2500));
|
|
1579
1553
|
}
|
|
1580
|
-
throw new Error(`Railway environment
|
|
1554
|
+
throw new Error(`Railway API environment provisioning failed for ${project.name ?? service.projectName ?? project.id}/${environmentName}.`);
|
|
1581
1555
|
}
|
|
1582
1556
|
async function resolveRailwayTopologyForScope(input, scope, {
|
|
1583
1557
|
ensure = false,
|
|
@@ -1592,6 +1566,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1592
1566
|
const env = buildRailwayEnv(input, scope);
|
|
1593
1567
|
const deployState = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
1594
1568
|
const services = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => normalizedServiceKeys.includes("__all__") || normalizedServiceKeys.includes(service.key));
|
|
1569
|
+
traceRailwayReconcile(env, "topology:start", `scope=${scope} ensure=${ensure ? "yes" : "no"} services=${services.map((service) => service.key).join(",")}`);
|
|
1595
1570
|
let workspace = null;
|
|
1596
1571
|
const knownProjects = [];
|
|
1597
1572
|
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()))];
|
|
@@ -1626,6 +1601,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1626
1601
|
}
|
|
1627
1602
|
const resolvedServices = /* @__PURE__ */ new Map();
|
|
1628
1603
|
for (const service of services) {
|
|
1604
|
+
traceRailwayReconcile(env, "topology:service:start", service.key);
|
|
1629
1605
|
const persistedService = deployState.services?.[service.key] ?? {};
|
|
1630
1606
|
const resolvedProjectId = service.projectId ?? persistedService.projectId ?? "";
|
|
1631
1607
|
const resolvedProjectName = service.projectName ?? persistedService.projectName ?? "";
|
|
@@ -1634,8 +1610,10 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1634
1610
|
let project = projectsByKey.get(resolvedProjectId) ?? projectsByKey.get(resolvedProjectName) ?? null;
|
|
1635
1611
|
if (!project && ensure) {
|
|
1636
1612
|
if (!workspace) {
|
|
1613
|
+
traceRailwayReconcile(env, "topology:workspace", "resolving workspace");
|
|
1637
1614
|
workspace = await resolveRailwayWorkspaceContext({ env });
|
|
1638
1615
|
}
|
|
1616
|
+
traceRailwayReconcile(env, "topology:project:ensure", `${service.key}:${resolvedProjectName || resolvedProjectId}`);
|
|
1639
1617
|
const ensuredProject = await ensureRailwayProject({
|
|
1640
1618
|
projectId: resolvedProjectId,
|
|
1641
1619
|
projectName: resolvedProjectName,
|
|
@@ -1649,6 +1627,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1649
1627
|
}
|
|
1650
1628
|
let environment = project?.environments.find((entry) => entry.name === service.railwayEnvironment || entry.id === service.railwayEnvironment) ?? null;
|
|
1651
1629
|
if (project && !environment && ensure) {
|
|
1630
|
+
traceRailwayReconcile(env, "topology:environment:ensure", `${service.key}:${service.railwayEnvironment}`);
|
|
1652
1631
|
environment = await ensureRailwayEnvironmentForService({
|
|
1653
1632
|
service,
|
|
1654
1633
|
project,
|
|
@@ -1664,6 +1643,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1664
1643
|
}
|
|
1665
1644
|
let resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
|
|
1666
1645
|
if (project && !resolvedService && ensure) {
|
|
1646
|
+
traceRailwayReconcile(env, "topology:railway-service:ensure", `${service.key}:${resolvedServiceName || resolvedServiceId}`);
|
|
1667
1647
|
resolvedService = (await ensureRailwayService({
|
|
1668
1648
|
projectId: project.id,
|
|
1669
1649
|
serviceId: resolvedServiceId,
|
|
@@ -1680,12 +1660,13 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1680
1660
|
let instance = null;
|
|
1681
1661
|
if (includeInstances && resolvedService && environment) {
|
|
1682
1662
|
if (ensure) {
|
|
1663
|
+
traceRailwayReconcile(env, "topology:instance:ensure", `${service.key}:${resolvedService.name}`);
|
|
1683
1664
|
instance = (await ensureRailwayServiceInstanceConfiguration({
|
|
1684
1665
|
serviceId: resolvedService.id,
|
|
1685
1666
|
environmentId: environment.id,
|
|
1686
1667
|
buildCommand: service.buildCommand,
|
|
1687
1668
|
startCommand: service.startCommand,
|
|
1688
|
-
rootDirectory:
|
|
1669
|
+
rootDirectory: railwayServiceRootDirectory(input.context.tenantRoot, service),
|
|
1689
1670
|
healthcheckPath: service.healthcheckPath,
|
|
1690
1671
|
healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
|
|
1691
1672
|
healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
|
|
@@ -1715,7 +1696,9 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1715
1696
|
instance,
|
|
1716
1697
|
currentVariables
|
|
1717
1698
|
});
|
|
1699
|
+
traceRailwayReconcile(env, "topology:service:done", service.key);
|
|
1718
1700
|
}
|
|
1701
|
+
traceRailwayReconcile(env, "topology:done", `scope=${scope}`);
|
|
1719
1702
|
return {
|
|
1720
1703
|
scope,
|
|
1721
1704
|
env,
|
|
@@ -1727,20 +1710,171 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1727
1710
|
};
|
|
1728
1711
|
}, refresh);
|
|
1729
1712
|
}
|
|
1713
|
+
function traceRailwayReconcile(env, stage, message) {
|
|
1714
|
+
if (env?.TREESEED_RECONCILE_TRACE === "1" || process.env.TREESEED_RECONCILE_TRACE === "1") {
|
|
1715
|
+
console.error(`[trsd][railway][${stage}] ${message}`);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
function railwayDriftSessionKey(scope) {
|
|
1719
|
+
return `railway:provider-drift:${scope}`;
|
|
1720
|
+
}
|
|
1721
|
+
function recordRailwayProviderDrift(input, scope, drift) {
|
|
1722
|
+
const key = railwayDriftSessionKey(scope);
|
|
1723
|
+
const current = input.context.session.get(key);
|
|
1724
|
+
const entries = Array.isArray(current) ? current : [];
|
|
1725
|
+
entries.push(drift);
|
|
1726
|
+
input.context.session.set(key, entries);
|
|
1727
|
+
}
|
|
1728
|
+
function railwayProviderDrift(input, scope) {
|
|
1729
|
+
const current = input.context.session.get(railwayDriftSessionKey(scope));
|
|
1730
|
+
return Array.isArray(current) ? current : [];
|
|
1731
|
+
}
|
|
1732
|
+
function activeRailwayVolumeInstances(volume) {
|
|
1733
|
+
const instances = Array.isArray(volume.instances) ? volume.instances : [];
|
|
1734
|
+
return instances.filter((instance) => {
|
|
1735
|
+
const state = String(instance.state ?? "READY").toUpperCase();
|
|
1736
|
+
return state !== "DELETING" && state !== "DELETED";
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
async function waitForRailwayServiceDeleted({
|
|
1740
|
+
projectId,
|
|
1741
|
+
serviceId,
|
|
1742
|
+
env
|
|
1743
|
+
}) {
|
|
1744
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
1745
|
+
const services = await listRailwayServices({ projectId, env }).catch(() => []);
|
|
1746
|
+
if (!services.some((service) => service.id === serviceId)) {
|
|
1747
|
+
return true;
|
|
1748
|
+
}
|
|
1749
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1500));
|
|
1750
|
+
}
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
async function waitForRailwayVolumeDeleted({
|
|
1754
|
+
projectId,
|
|
1755
|
+
volumeId,
|
|
1756
|
+
env
|
|
1757
|
+
}) {
|
|
1758
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
1759
|
+
const volumes = await listRailwayVolumes({ projectId, env }).catch(() => []);
|
|
1760
|
+
if (!volumes.some((volume) => volume.id === volumeId)) {
|
|
1761
|
+
return true;
|
|
1762
|
+
}
|
|
1763
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1500));
|
|
1764
|
+
}
|
|
1765
|
+
return false;
|
|
1766
|
+
}
|
|
1767
|
+
async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
|
|
1768
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1769
|
+
const desiredRunners = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false);
|
|
1770
|
+
const desiredServiceNames = new Set(desiredRunners.map((service) => service.serviceName).filter(Boolean));
|
|
1771
|
+
const desiredVolumeNames = new Set([...desiredServiceNames].map((serviceName) => `${serviceName}-volume`));
|
|
1772
|
+
if (desiredServiceNames.size === 0) {
|
|
1773
|
+
traceRailwayReconcile(topology.env, "sync:runner:cleanup-skip", "no desired operations runner service names");
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
const projectEntries = [...topology.services.values()].filter((entry) => entry.project?.id && entry.environment?.id).map((entry) => ({ project: entry.project, environment: entry.environment }));
|
|
1777
|
+
traceRailwayReconcile(
|
|
1778
|
+
topology.env,
|
|
1779
|
+
"sync:runner:cleanup-projects",
|
|
1780
|
+
projectEntries.map((entry) => `${entry.project.name}:${entry.environment.name}`).join(",") || "(none)"
|
|
1781
|
+
);
|
|
1782
|
+
const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
|
|
1783
|
+
for (const { project, environment } of projects.values()) {
|
|
1784
|
+
await reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
1785
|
+
scope,
|
|
1786
|
+
env: topology.env,
|
|
1787
|
+
project,
|
|
1788
|
+
environment,
|
|
1789
|
+
desiredServiceNames,
|
|
1790
|
+
desiredVolumeNames
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
async function reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
1795
|
+
scope,
|
|
1796
|
+
env,
|
|
1797
|
+
project,
|
|
1798
|
+
environment,
|
|
1799
|
+
desiredServiceNames,
|
|
1800
|
+
desiredVolumeNames
|
|
1801
|
+
}) {
|
|
1802
|
+
if (desiredServiceNames.size === 0 || desiredVolumeNames.size === 0) {
|
|
1803
|
+
traceRailwayReconcile(env, "sync:runner:delete-skip", `${project.name}: no desired runner names; refusing to prune operations runner resources`);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
const services = await listRailwayServices({ projectId: project.id, env });
|
|
1807
|
+
const desiredServiceIds = new Set(services.filter((service) => desiredServiceNames.has(service.name)).map((service) => service.id));
|
|
1808
|
+
const staleServices = findStaleTreeseedOperationsRunnerResources(services, desiredServiceNames);
|
|
1809
|
+
traceRailwayReconcile(
|
|
1810
|
+
env,
|
|
1811
|
+
"sync:runner:observed-services",
|
|
1812
|
+
`${project.name}: desired=${[...desiredServiceNames].join(",") || "(none)"} stale=${staleServices.map((service) => service.name).join(",") || "(none)"}`
|
|
1813
|
+
);
|
|
1814
|
+
for (const service of staleServices) {
|
|
1815
|
+
traceRailwayReconcile(env, "sync:runner:delete-stale-service", `${service.name}:${service.id}`);
|
|
1816
|
+
await deleteRailwayService({ serviceId: service.id, env });
|
|
1817
|
+
const deleted = await waitForRailwayServiceDeleted({ projectId: project.id, serviceId: service.id, env });
|
|
1818
|
+
if (!deleted) {
|
|
1819
|
+
recordRailwayProviderDrift(input, scope, {
|
|
1820
|
+
kind: "railway.stale-operations-runner-service",
|
|
1821
|
+
action: "delete",
|
|
1822
|
+
status: "blocked",
|
|
1823
|
+
projectId: project.id,
|
|
1824
|
+
environmentId: environment.id,
|
|
1825
|
+
serviceId: service.id,
|
|
1826
|
+
serviceName: service.name,
|
|
1827
|
+
reason: "Railway still reports the stale operations runner service after delete reconciliation."
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
const volumes = await listRailwayVolumes({ projectId: project.id, env }).catch(() => []);
|
|
1832
|
+
const staleVolumes = findStaleTreeseedOperationsRunnerResources(volumes, desiredVolumeNames).filter((volume) => {
|
|
1833
|
+
const activeInstances = activeRailwayVolumeInstances(volume);
|
|
1834
|
+
return volume.instances.length === 0 || activeInstances.length > 0;
|
|
1835
|
+
}).filter((volume) => activeRailwayVolumeInstances(volume).every((instance) => !desiredServiceIds.has(instance.serviceId ?? "")));
|
|
1836
|
+
traceRailwayReconcile(
|
|
1837
|
+
env,
|
|
1838
|
+
"sync:runner:observed-volumes",
|
|
1839
|
+
`${project.name}: desired=${[...desiredVolumeNames].join(",") || "(none)"} stale=${staleVolumes.map((volume) => volume.name ?? volume.id).join(",") || "(none)"}`
|
|
1840
|
+
);
|
|
1841
|
+
for (const volume of staleVolumes) {
|
|
1842
|
+
traceRailwayReconcile(env, "sync:runner:delete-stale-volume", `${volume.name ?? volume.id}:${volume.id}`);
|
|
1843
|
+
await deleteRailwayVolume({ volumeId: volume.id, env });
|
|
1844
|
+
const deleted = await waitForRailwayVolumeDeleted({ projectId: project.id, volumeId: volume.id, env });
|
|
1845
|
+
if (!deleted) {
|
|
1846
|
+
recordRailwayProviderDrift(input, scope, {
|
|
1847
|
+
kind: "railway.stale-operations-runner-volume",
|
|
1848
|
+
action: "delete",
|
|
1849
|
+
status: "blocked",
|
|
1850
|
+
projectId: project.id,
|
|
1851
|
+
environmentId: environment.id,
|
|
1852
|
+
volumeId: volume.id,
|
|
1853
|
+
volumeName: volume.name ?? null,
|
|
1854
|
+
reason: "Railway still reports the stale operations runner volume after delete reconciliation."
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1730
1859
|
async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
|
|
1731
1860
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1732
1861
|
const sync = collectRailwayEnvironmentSync(input);
|
|
1862
|
+
traceRailwayReconcile(input.context.env, "sync:start", `scope=${scope} dryRun=${dryRun ? "yes" : "no"}`);
|
|
1733
1863
|
const topology = await resolveRailwayTopologyForScope(input, scope, {
|
|
1734
1864
|
ensure: !dryRun,
|
|
1735
1865
|
includeInstances: !dryRun,
|
|
1736
1866
|
includeVariables: false
|
|
1737
1867
|
});
|
|
1868
|
+
traceRailwayReconcile(topology.env, "sync:topology", `services=${[...topology.services.keys()].join(",")}`);
|
|
1738
1869
|
if (!dryRun) {
|
|
1870
|
+
traceRailwayReconcile(topology.env, "sync:postgres:start", "ensuring Treeseed database");
|
|
1739
1871
|
await ensureRailwayMarketDatabaseForScope(input, topology);
|
|
1872
|
+
traceRailwayReconcile(topology.env, "sync:postgres:done", "Treeseed database ensured");
|
|
1740
1873
|
for (const entry of topology.services.values()) {
|
|
1741
1874
|
if (!entry.project || !entry.environment || !entry.service) {
|
|
1742
1875
|
continue;
|
|
1743
1876
|
}
|
|
1877
|
+
traceRailwayReconcile(topology.env, "sync:variables", `${entry.configuredService.key}:${entry.service.name}`);
|
|
1744
1878
|
const serviceSync = sync.forService(entry.configuredService.key);
|
|
1745
1879
|
await upsertRailwayVariables({
|
|
1746
1880
|
projectId: entry.project.id,
|
|
@@ -1753,60 +1887,42 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
|
|
|
1753
1887
|
env: topology.env
|
|
1754
1888
|
});
|
|
1755
1889
|
if (entry.configuredService.volumeMountPath) {
|
|
1756
|
-
const volumeName =
|
|
1757
|
-
|
|
1758
|
-
|
|
1890
|
+
const volumeName = `${entry.service.name}-volume`;
|
|
1891
|
+
traceRailwayReconcile(topology.env, "sync:volume", `${entry.configuredService.key}:${volumeName}:${entry.configuredService.volumeMountPath}`);
|
|
1892
|
+
const volume = await ensureRailwayServiceVolume({
|
|
1759
1893
|
projectId: entry.project.id,
|
|
1760
1894
|
environmentId: entry.environment.id,
|
|
1761
|
-
environmentName: entry.environment.name,
|
|
1762
1895
|
serviceId: entry.service.id,
|
|
1763
|
-
serviceName: entry.service.name,
|
|
1764
1896
|
name: volumeName,
|
|
1765
1897
|
mountPath: entry.configuredService.volumeMountPath,
|
|
1766
|
-
preferCli: entry.configuredService.key === "marketOperationsRunner",
|
|
1767
1898
|
env: topology.env
|
|
1768
1899
|
});
|
|
1769
1900
|
if (volume.instance?.serviceId !== entry.service.id || volume.instance?.environmentId !== entry.environment.id || volume.instance?.mountPath !== entry.configuredService.volumeMountPath) {
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
});
|
|
1789
|
-
if ((attachResult.status ?? 1) === 0) {
|
|
1790
|
-
attached = true;
|
|
1791
|
-
break;
|
|
1792
|
-
}
|
|
1793
|
-
attachMessage = attachResult.stderr?.trim() || attachResult.stdout?.trim() || "";
|
|
1794
|
-
if (/already mounted/iu.test(attachMessage)) {
|
|
1795
|
-
attached = true;
|
|
1796
|
-
break;
|
|
1797
|
-
}
|
|
1798
|
-
if (!/volume .*not found|not found|does not exist/iu.test(attachMessage)) {
|
|
1799
|
-
break;
|
|
1800
|
-
}
|
|
1801
|
-
sleepMs(2e3 * (attempt + 1));
|
|
1802
|
-
}
|
|
1803
|
-
if (!attached) {
|
|
1804
|
-
throw new Error(attachMessage || `Railway volume attach failed for ${entry.service.name}.`);
|
|
1805
|
-
}
|
|
1901
|
+
throw new Error(`Railway API volume reconciliation did not mount ${volumeName} on ${entry.service.name} at ${entry.configuredService.volumeMountPath}.`);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
if (entry.configuredService.key === "operationsRunner") {
|
|
1905
|
+
const desiredServiceNames = new Set(configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false).map((service) => service.serviceName).filter(Boolean));
|
|
1906
|
+
if (entry.configuredService.serviceName) {
|
|
1907
|
+
desiredServiceNames.add(entry.configuredService.serviceName);
|
|
1908
|
+
}
|
|
1909
|
+
const desiredVolumeNames = new Set([...desiredServiceNames].map((serviceName) => `${serviceName}-volume`));
|
|
1910
|
+
if (desiredServiceNames.size > 0) {
|
|
1911
|
+
await reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
1912
|
+
scope,
|
|
1913
|
+
env: topology.env,
|
|
1914
|
+
project: entry.project,
|
|
1915
|
+
environment: entry.environment,
|
|
1916
|
+
desiredServiceNames,
|
|
1917
|
+
desiredVolumeNames
|
|
1918
|
+
});
|
|
1806
1919
|
}
|
|
1807
1920
|
}
|
|
1808
1921
|
}
|
|
1922
|
+
traceRailwayReconcile(topology.env, "sync:runner:cleanup-stale", "reconciling old operations runner resources");
|
|
1923
|
+
await reconcileStaleOperationsRunnerResourcesForScope(input, topology);
|
|
1809
1924
|
}
|
|
1925
|
+
traceRailwayReconcile(topology.env, "sync:done", `scope=${scope}`);
|
|
1810
1926
|
return {
|
|
1811
1927
|
scope,
|
|
1812
1928
|
services: [...topology.services.values()].map((entry) => entry.configuredService),
|
|
@@ -1817,38 +1933,101 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
|
|
|
1817
1933
|
};
|
|
1818
1934
|
}
|
|
1819
1935
|
async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1936
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1937
|
+
const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
|
|
1938
|
+
const treeseedDatabaseService = treeseedDatabase?.service;
|
|
1939
|
+
if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
|
|
1822
1940
|
return;
|
|
1823
1941
|
}
|
|
1824
1942
|
const firstService = [...topology.services.values()].find((entry) => entry.project && entry.environment);
|
|
1825
1943
|
if (!firstService?.project || !firstService.environment) {
|
|
1826
1944
|
return;
|
|
1827
1945
|
}
|
|
1828
|
-
const
|
|
1829
|
-
|
|
1830
|
-
|
|
1946
|
+
const serviceName = treeseedDatabase.serviceName;
|
|
1947
|
+
let postgresService;
|
|
1948
|
+
try {
|
|
1949
|
+
postgresService = (await ensureRailwayPostgresService({
|
|
1950
|
+
projectId: firstService.project.id,
|
|
1951
|
+
environmentId: firstService.environment.id,
|
|
1952
|
+
serviceName,
|
|
1953
|
+
env: topology.env
|
|
1954
|
+
})).service;
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
1957
|
+
throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. ${detail}`);
|
|
1958
|
+
}
|
|
1831
1959
|
if (!postgresService) {
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1960
|
+
throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. No service was returned.`);
|
|
1961
|
+
}
|
|
1962
|
+
try {
|
|
1963
|
+
const postgresVolume = await ensureRailwayServiceVolume({
|
|
1964
|
+
projectId: firstService.project.id,
|
|
1965
|
+
environmentId: firstService.environment.id,
|
|
1966
|
+
serviceId: postgresService.id,
|
|
1967
|
+
name: `${serviceName}-volume`,
|
|
1968
|
+
mountPath: "/var/lib/postgresql/data",
|
|
1837
1969
|
env: topology.env
|
|
1838
1970
|
});
|
|
1839
|
-
if (
|
|
1840
|
-
throw new Error(
|
|
1971
|
+
if (postgresVolume.volume.name !== `${serviceName}-volume`) {
|
|
1972
|
+
throw new Error(`observed volume name ${postgresVolume.volume.name ?? "(unnamed)"}`);
|
|
1841
1973
|
}
|
|
1842
|
-
|
|
1843
|
-
const
|
|
1844
|
-
|
|
1845
|
-
|
|
1974
|
+
} catch (error) {
|
|
1975
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
1976
|
+
throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name ${serviceName}-volume: ${detail}`);
|
|
1977
|
+
}
|
|
1978
|
+
const services = await listRailwayServices({ projectId: firstService.project.id, env: topology.env });
|
|
1979
|
+
for (const service of services) {
|
|
1980
|
+
if (service.id === postgresService.id || service.name === serviceName) {
|
|
1981
|
+
continue;
|
|
1846
1982
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1983
|
+
const variables = await listRailwayVariables({
|
|
1984
|
+
projectId: firstService.project.id,
|
|
1985
|
+
environmentId: firstService.environment.id,
|
|
1986
|
+
serviceId: service.id,
|
|
1850
1987
|
env: topology.env
|
|
1851
|
-
});
|
|
1988
|
+
}).catch(() => ({}));
|
|
1989
|
+
const looksLikePostgres = typeof variables.DATABASE_URL === "string" && typeof variables.PGHOST === "string" && typeof variables.PGUSER === "string" && typeof variables.PGPASSWORD === "string";
|
|
1990
|
+
if (looksLikePostgres) {
|
|
1991
|
+
traceRailwayReconcile(topology.env, "sync:postgres:delete-duplicate", `${service.name}:${service.id}`);
|
|
1992
|
+
await deleteRailwayService({ serviceId: service.id, env: topology.env });
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
const detachedPostgresVolumes = await listRailwayVolumes({
|
|
1996
|
+
projectId: firstService.project.id,
|
|
1997
|
+
env: topology.env
|
|
1998
|
+
});
|
|
1999
|
+
for (const volume of detachedPostgresVolumes) {
|
|
2000
|
+
const postgresInstances = volume.instances.filter(
|
|
2001
|
+
(instance) => instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
|
|
2002
|
+
);
|
|
2003
|
+
const attachedToCanonical = postgresInstances.some((instance) => instance.serviceId === postgresService.id);
|
|
2004
|
+
const detached = postgresInstances.length > 0 && postgresInstances.every((instance) => !instance.serviceId);
|
|
2005
|
+
if (detached && !attachedToCanonical) {
|
|
2006
|
+
traceRailwayReconcile(topology.env, "sync:postgres:delete-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
|
|
2007
|
+
await deleteRailwayVolume({ volumeId: volume.id, env: topology.env });
|
|
2008
|
+
let deleted = false;
|
|
2009
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
2010
|
+
await new Promise((resolve2) => setTimeout(resolve2, 3e3));
|
|
2011
|
+
const refreshed = await listRailwayVolumes({
|
|
2012
|
+
projectId: firstService.project.id,
|
|
2013
|
+
env: topology.env
|
|
2014
|
+
});
|
|
2015
|
+
deleted = !refreshed.some((candidate) => candidate.id === volume.id);
|
|
2016
|
+
if (deleted) break;
|
|
2017
|
+
}
|
|
2018
|
+
if (!deleted) {
|
|
2019
|
+
recordRailwayProviderDrift(input, scope, {
|
|
2020
|
+
kind: "railway.detached-postgres-volume",
|
|
2021
|
+
action: "delete",
|
|
2022
|
+
status: "blocked",
|
|
2023
|
+
projectId: firstService.project.id,
|
|
2024
|
+
environmentId: firstService.environment.id,
|
|
2025
|
+
volumeId: volume.id,
|
|
2026
|
+
volumeName: volume.name ?? null,
|
|
2027
|
+
reason: "Railway still reports the detached PostgreSQL volume after delete reconciliation."
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
1852
2031
|
}
|
|
1853
2032
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
1854
2033
|
const existingVolumes = await listRailwayVolumes({
|
|
@@ -1926,10 +2105,10 @@ function collectRailwayEnvironmentSync(input) {
|
|
|
1926
2105
|
const variables = Object.fromEntries(aggregateEntries("railway-var"));
|
|
1927
2106
|
const apiOnlySecrets = {};
|
|
1928
2107
|
const apiOnlyVariables = {};
|
|
1929
|
-
const
|
|
1930
|
-
const
|
|
1931
|
-
const
|
|
1932
|
-
const
|
|
2108
|
+
const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
|
|
2109
|
+
const treeseedDatabaseService = treeseedDatabase?.service;
|
|
2110
|
+
const treeseedDatabaseServiceName = treeseedDatabase?.serviceName ?? "";
|
|
2111
|
+
const treeseedDatabaseUrl = typeof values.TREESEED_DATABASE_URL === "string" && values.TREESEED_DATABASE_URL.length > 0 ? values.TREESEED_DATABASE_URL : treeseedDatabaseService?.enabled !== false && treeseedDatabaseService?.provider === "railway" ? `\${{${treeseedDatabaseServiceName}.DATABASE_URL}}` : "";
|
|
1933
2112
|
if (typeof values.CLOUDFLARE_API_TOKEN === "string" && values.CLOUDFLARE_API_TOKEN.length > 0 && shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, "CLOUDFLARE_API_TOKEN")) {
|
|
1934
2113
|
secrets.CLOUDFLARE_API_TOKEN = values.CLOUDFLARE_API_TOKEN;
|
|
1935
2114
|
apiOnlySecrets.CLOUDFLARE_API_TOKEN = values.CLOUDFLARE_API_TOKEN;
|
|
@@ -1948,8 +2127,8 @@ function collectRailwayEnvironmentSync(input) {
|
|
|
1948
2127
|
variables.TREESEED_API_D1_DATABASE_ID = apiD1DatabaseId;
|
|
1949
2128
|
apiOnlyVariables.TREESEED_API_D1_DATABASE_ID = apiD1DatabaseId;
|
|
1950
2129
|
}
|
|
1951
|
-
if (
|
|
1952
|
-
secrets.
|
|
2130
|
+
if (treeseedDatabaseUrl) {
|
|
2131
|
+
secrets.TREESEED_DATABASE_URL = treeseedDatabaseUrl;
|
|
1953
2132
|
}
|
|
1954
2133
|
return {
|
|
1955
2134
|
scope,
|
|
@@ -1959,11 +2138,12 @@ function collectRailwayEnvironmentSync(input) {
|
|
|
1959
2138
|
return {
|
|
1960
2139
|
secrets: {
|
|
1961
2140
|
...Object.fromEntries(entriesForService("railway-secret", serviceKey)),
|
|
1962
|
-
...
|
|
2141
|
+
...treeseedDatabaseUrl && ["api", "operationsRunner"].includes(serviceKey) ? { TREESEED_DATABASE_URL: treeseedDatabaseUrl } : {},
|
|
1963
2142
|
...serviceKey === "api" ? apiOnlySecrets : {}
|
|
1964
2143
|
},
|
|
1965
2144
|
variables: {
|
|
1966
2145
|
...Object.fromEntries(entriesForService("railway-var", serviceKey)),
|
|
2146
|
+
...serviceKey === "operationsRunner" ? { TREESEED_MANAGER_ID: scope } : {},
|
|
1967
2147
|
...serviceKey === "api" ? apiOnlyVariables : {}
|
|
1968
2148
|
}
|
|
1969
2149
|
};
|
|
@@ -1980,7 +2160,7 @@ function buildAttachmentDiff(input, observed) {
|
|
|
1980
2160
|
};
|
|
1981
2161
|
}
|
|
1982
2162
|
return {
|
|
1983
|
-
action: observed.status === "ready" ? "
|
|
2163
|
+
action: observed.status === "ready" ? "noop" : "update",
|
|
1984
2164
|
reasons: observed.status === "ready" ? ["attachment already present"] : ["attachment requires update"],
|
|
1985
2165
|
before: observed.live,
|
|
1986
2166
|
after: input.unit.spec
|
|
@@ -2186,7 +2366,7 @@ async function reconcileCustomDomainUnit(input, diff) {
|
|
|
2186
2366
|
unit: input.unit,
|
|
2187
2367
|
observed,
|
|
2188
2368
|
diff,
|
|
2189
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
2369
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
2190
2370
|
warnings: observed.warnings,
|
|
2191
2371
|
resourceLocators: observed.locators,
|
|
2192
2372
|
state,
|
|
@@ -2223,7 +2403,7 @@ async function reconcileCustomDomainUnit(input, diff) {
|
|
|
2223
2403
|
unit: input.unit,
|
|
2224
2404
|
observed,
|
|
2225
2405
|
diff,
|
|
2226
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
2406
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
2227
2407
|
warnings: observed.warnings,
|
|
2228
2408
|
resourceLocators: observed.locators,
|
|
2229
2409
|
state,
|
|
@@ -2251,7 +2431,7 @@ function reconcileDnsRecordUnit(input, diff) {
|
|
|
2251
2431
|
unit: input.unit,
|
|
2252
2432
|
observed,
|
|
2253
2433
|
diff,
|
|
2254
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
2434
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
2255
2435
|
warnings: observed.warnings,
|
|
2256
2436
|
resourceLocators: observed.locators,
|
|
2257
2437
|
state: {
|
|
@@ -2334,7 +2514,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2334
2514
|
issues: startCommandMatches ? [] : ["Railway start command does not match the desired value."]
|
|
2335
2515
|
}));
|
|
2336
2516
|
}
|
|
2337
|
-
const desiredRootDirectory =
|
|
2517
|
+
const desiredRootDirectory = railwayServiceRootDirectory(input.context.tenantRoot, service);
|
|
2338
2518
|
if (desiredRootDirectory) {
|
|
2339
2519
|
checks.push(verificationCheck("railway.instance.root-directory", "Railway root directory matches desired config", "api", {
|
|
2340
2520
|
exists: Boolean(entry.instance?.id),
|
|
@@ -2435,7 +2615,10 @@ async function verifyRailwayUnit(input) {
|
|
|
2435
2615
|
issues: Object.hasOwn(entry.currentVariables, key) ? [] : [`Railway secret ${key} is missing.`]
|
|
2436
2616
|
}));
|
|
2437
2617
|
}
|
|
2438
|
-
const
|
|
2618
|
+
const providerDriftWarnings = railwayProviderDrift(input, scope).map(
|
|
2619
|
+
(drift) => `Railway provider drift remains unresolved: ${String(drift.reason ?? drift.kind ?? "unknown drift")}`
|
|
2620
|
+
);
|
|
2621
|
+
const verification = summarizeVerification(input.unit.unitId, checks, providerDriftWarnings);
|
|
2439
2622
|
if (!verification.verified && attempt < 12 && railwayVerificationMaySettle(verification)) {
|
|
2440
2623
|
attempt += 1;
|
|
2441
2624
|
sleepMs(5e3);
|
|
@@ -2460,7 +2643,7 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
|
|
|
2460
2643
|
if (observed === expected) {
|
|
2461
2644
|
return true;
|
|
2462
2645
|
}
|
|
2463
|
-
if (serviceKey !== "
|
|
2646
|
+
if (serviceKey !== "operationsRunner") {
|
|
2464
2647
|
return false;
|
|
2465
2648
|
}
|
|
2466
2649
|
const normalizedObserved = String(observed ?? "").trim().replace(/\s+/gu, " ");
|
|
@@ -2469,7 +2652,7 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
|
|
|
2469
2652
|
return true;
|
|
2470
2653
|
}
|
|
2471
2654
|
const allowedInlineEnv = [
|
|
2472
|
-
"
|
|
2655
|
+
"TREESEED_MANAGER_ID",
|
|
2473
2656
|
"TREESEED_PLATFORM_RUNNER_ID",
|
|
2474
2657
|
"TREESEED_PLATFORM_RUNNER_DATA_DIR",
|
|
2475
2658
|
"TREESEED_PLATFORM_RUNNER_ENVIRONMENT"
|
|
@@ -2490,7 +2673,7 @@ function buildRailwayDiff(input, observed) {
|
|
|
2490
2673
|
};
|
|
2491
2674
|
}
|
|
2492
2675
|
return {
|
|
2493
|
-
action: observed.status === "ready" ? "
|
|
2676
|
+
action: observed.status === "ready" ? "noop" : "update",
|
|
2494
2677
|
reasons: observed.status === "ready" ? ["service already configured"] : ["service requires configuration sync"],
|
|
2495
2678
|
before: observed.live,
|
|
2496
2679
|
after: input.unit.spec
|
|
@@ -2513,7 +2696,7 @@ async function reconcileRailwayUnit(input, diff) {
|
|
|
2513
2696
|
unit: input.unit,
|
|
2514
2697
|
observed: refreshed,
|
|
2515
2698
|
diff,
|
|
2516
|
-
action: diff.action === "update" || diff.action === "create" ? "
|
|
2699
|
+
action: diff.action === "update" || diff.action === "create" ? "update" : diff.action,
|
|
2517
2700
|
warnings: refreshed.warnings,
|
|
2518
2701
|
resourceLocators: refreshed.locators,
|
|
2519
2702
|
state: refreshed.live,
|
|
@@ -2533,13 +2716,13 @@ function buildRailwayAdapter(unitType) {
|
|
|
2533
2716
|
env: buildRailwayEnv(input, scope)
|
|
2534
2717
|
});
|
|
2535
2718
|
},
|
|
2536
|
-
|
|
2719
|
+
refresh(input) {
|
|
2537
2720
|
return observeRailwayUnit(input);
|
|
2538
2721
|
},
|
|
2539
|
-
|
|
2722
|
+
diff(input) {
|
|
2540
2723
|
return buildRailwayDiff(input, input.observed);
|
|
2541
2724
|
},
|
|
2542
|
-
|
|
2725
|
+
apply(input) {
|
|
2543
2726
|
return reconcileRailwayUnit(input, input.diff);
|
|
2544
2727
|
},
|
|
2545
2728
|
requiredPostconditions() {
|
|
@@ -2569,7 +2752,7 @@ function buildCustomDomainAdapter(unitType, providerId) {
|
|
|
2569
2752
|
});
|
|
2570
2753
|
}
|
|
2571
2754
|
},
|
|
2572
|
-
|
|
2755
|
+
refresh(input) {
|
|
2573
2756
|
return observeCustomDomainUnit(input);
|
|
2574
2757
|
},
|
|
2575
2758
|
requiredPostconditions() {
|
|
@@ -2578,10 +2761,10 @@ function buildCustomDomainAdapter(unitType, providerId) {
|
|
|
2578
2761
|
...providerId === "railway" ? [{ key: "custom-domain.dns-requirements", description: "Custom domain exposes DNS requirements" }] : []
|
|
2579
2762
|
];
|
|
2580
2763
|
},
|
|
2581
|
-
|
|
2764
|
+
diff(input) {
|
|
2582
2765
|
return buildAttachmentDiff(input, input.observed);
|
|
2583
2766
|
},
|
|
2584
|
-
|
|
2767
|
+
apply(input) {
|
|
2585
2768
|
return reconcileCustomDomainUnit(input, input.diff);
|
|
2586
2769
|
},
|
|
2587
2770
|
verify(input) {
|
|
@@ -2596,7 +2779,7 @@ function buildDnsRecordAdapter() {
|
|
|
2596
2779
|
supports(candidateUnitType, providerId) {
|
|
2597
2780
|
return candidateUnitType === "dns-record" && providerId === "cloudflare-dns";
|
|
2598
2781
|
},
|
|
2599
|
-
|
|
2782
|
+
refresh(input) {
|
|
2600
2783
|
return observeDnsRecordUnit(input);
|
|
2601
2784
|
},
|
|
2602
2785
|
requiredPostconditions(input) {
|
|
@@ -2606,10 +2789,10 @@ function buildDnsRecordAdapter() {
|
|
|
2606
2789
|
description: `DNS record ${record.type} ${record.name} matches the desired value`
|
|
2607
2790
|
}));
|
|
2608
2791
|
},
|
|
2609
|
-
|
|
2792
|
+
diff(input) {
|
|
2610
2793
|
return buildAttachmentDiff(input, input.observed);
|
|
2611
2794
|
},
|
|
2612
|
-
|
|
2795
|
+
apply(input) {
|
|
2613
2796
|
return reconcileDnsRecordUnit(input, input.diff);
|
|
2614
2797
|
},
|
|
2615
2798
|
verify(input) {
|
|
@@ -2634,12 +2817,12 @@ function createCloudflareReconcileAdapters() {
|
|
|
2634
2817
|
function createRailwayReconcileAdapters() {
|
|
2635
2818
|
return [
|
|
2636
2819
|
buildRailwayAdapter("railway-service:api"),
|
|
2637
|
-
buildRailwayAdapter("railway-service:
|
|
2820
|
+
buildRailwayAdapter("railway-service:operations-runner"),
|
|
2638
2821
|
buildRailwayAdapter("railway-service:workday-manager"),
|
|
2639
2822
|
buildRailwayAdapter("railway-service:worker-runner"),
|
|
2640
2823
|
buildCustomDomainAdapter("custom-domain:api", "railway"),
|
|
2641
2824
|
buildCompositeAdapter("api-runtime"),
|
|
2642
|
-
buildCompositeAdapter("
|
|
2825
|
+
buildCompositeAdapter("operations-runner-runtime"),
|
|
2643
2826
|
buildCompositeAdapter("workday-manager-runtime"),
|
|
2644
2827
|
buildCompositeAdapter("worker-runner-runtime")
|
|
2645
2828
|
];
|