@treeseed/sdk 0.12.60 → 0.12.62
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 +441 -105
- package/dist/workflow/runs.d.ts +5 -0
- package/dist/workflow/runs.js +9 -3
- package/dist/workflow-support.d.ts +1 -1
- package/dist/workflow-support.js +3 -1
- package/package.json +1 -2
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
4
4
|
import { readdir, readFile } from "node:fs/promises";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
5
|
import { dirname, relative, resolve } from "node:path";
|
|
7
6
|
import { resolveCapacityProviderLaunchPlan } from "../capacity-provider.js";
|
|
8
7
|
import { TreeDxClient } from "../treedx-client.js";
|
|
@@ -43,22 +42,23 @@ import {
|
|
|
43
42
|
configuredRailwayServices,
|
|
44
43
|
findStaleTreeseedOperationsRunnerResources,
|
|
45
44
|
isTreeseedOperationsRunnerResourceName,
|
|
46
|
-
|
|
45
|
+
railwayObsoleteAliasCleanupPolicy,
|
|
47
46
|
validateRailwayDeployPrerequisites
|
|
48
47
|
} from "../operations/services/railway-deploy.js";
|
|
49
48
|
import { shouldExposeManagedHostRuntimeSecret } from "../operations/services/managed-host-security.js";
|
|
49
|
+
import { assertNoRailwaySourceIdentityCollisions } from "../operations/services/railway-source-policy.js";
|
|
50
|
+
import {
|
|
51
|
+
attachRailwayVolumeWithCli,
|
|
52
|
+
detachRailwayVolumeWithCli,
|
|
53
|
+
updateRailwayVolumeWithCli
|
|
54
|
+
} from "../operations/services/railway-cli.js";
|
|
50
55
|
import {
|
|
51
56
|
ensureRailwayEnvironment,
|
|
52
|
-
ensureRailwayCustomDomain as ensureRailwayCustomDomainViaApi,
|
|
53
57
|
ensureRailwayProject,
|
|
54
|
-
|
|
55
|
-
ensureRailwayServiceInstanceConfiguration,
|
|
56
|
-
ensureRailwayServiceVolume,
|
|
57
|
-
ensureRailwayPostgresService,
|
|
58
|
+
ensureRailwayCustomDomain,
|
|
58
59
|
deleteRailwayService,
|
|
59
60
|
deleteRailwayCustomDomain,
|
|
60
61
|
deleteRailwayVolume,
|
|
61
|
-
deployRailwayServiceInstance,
|
|
62
62
|
inspectRailwayServiceDeploymentHealth,
|
|
63
63
|
listRailwayEnvironmentServices,
|
|
64
64
|
getRailwayServiceInstance,
|
|
@@ -70,12 +70,28 @@ import {
|
|
|
70
70
|
listRailwayServices,
|
|
71
71
|
listRailwayVolumes,
|
|
72
72
|
listRailwayVariables,
|
|
73
|
-
resolveRailwayWorkspaceContext
|
|
74
|
-
updateRailwayServiceGitSource,
|
|
75
|
-
updateRailwayServiceImageSource
|
|
73
|
+
resolveRailwayWorkspaceContext
|
|
76
74
|
} from "../operations/services/railway-api.js";
|
|
77
75
|
import { loadTreeseedReconcileState } from "./state.js";
|
|
78
76
|
import { createTreeseedReconcileUnitId } from "./units.js";
|
|
77
|
+
import {
|
|
78
|
+
applyRailwayIacProject,
|
|
79
|
+
cleanupRailwayIacRender,
|
|
80
|
+
detachRetainedRailwayCustomDomains,
|
|
81
|
+
detachRetainedRailwayVolumeBindings,
|
|
82
|
+
findRailwayPendingVolumeNameCollisions,
|
|
83
|
+
planRailwayIacProject,
|
|
84
|
+
railwayIacApplyFailure,
|
|
85
|
+
renderRailwayIacProject,
|
|
86
|
+
resolveRailwayIacVolumeBindings,
|
|
87
|
+
waitForRailwayVolumeAdoptionResources,
|
|
88
|
+
waitForRailwayVolumeDetachment,
|
|
89
|
+
waitForRailwayVolumeName,
|
|
90
|
+
waitForRailwayServiceAbsence,
|
|
91
|
+
waitForRailwayServices,
|
|
92
|
+
selectRailwayIacRetainedResources,
|
|
93
|
+
validateRailwayIacChangeSet
|
|
94
|
+
} from "./providers/railway-iac.js";
|
|
79
95
|
import { discoverTreeseedApplications } from "../hosting/apps.js";
|
|
80
96
|
import { findTreeseedPackageAdapter, syncTreeseedPackageWorkflows } from "../operations/services/package-adapters.js";
|
|
81
97
|
import { resolveGitHubCredentialForRepository } from "../operations/services/github-credentials.js";
|
|
@@ -2349,6 +2365,8 @@ function normalizeRailwayDomainPayload(value) {
|
|
|
2349
2365
|
return {
|
|
2350
2366
|
id: typeof record.id === "string" ? record.id.trim() : null,
|
|
2351
2367
|
domain,
|
|
2368
|
+
serviceId: typeof record.serviceId === "string" ? record.serviceId.trim() : null,
|
|
2369
|
+
serviceName: typeof record.serviceName === "string" ? record.serviceName.trim() : null,
|
|
2352
2370
|
serviceDomain,
|
|
2353
2371
|
verified: record.verified === true || status.verified === true || verification.verified === true,
|
|
2354
2372
|
certificateStatus: firstRailwayDomainString(
|
|
@@ -2361,94 +2379,29 @@ function normalizeRailwayDomainPayload(value) {
|
|
|
2361
2379
|
dnsRecords: effectiveDnsRecords
|
|
2362
2380
|
};
|
|
2363
2381
|
}
|
|
2364
|
-
async function ensureRailwayCustomDomain(input, service, domain, env, identifiers) {
|
|
2365
|
-
if (!identifiers?.projectId || !identifiers.environmentId || !identifiers.serviceId) {
|
|
2366
|
-
throw new Error(`Railway custom domain ${domain} cannot be reconciled without project, environment, and service ids.`);
|
|
2367
|
-
}
|
|
2368
|
-
const existing = await listRailwayCustomDomains({
|
|
2369
|
-
projectId: identifiers.projectId,
|
|
2370
|
-
environmentId: identifiers.environmentId,
|
|
2371
|
-
serviceId: identifiers.serviceId,
|
|
2372
|
-
env
|
|
2373
|
-
});
|
|
2374
|
-
const matched = existing.find((entry) => entry.domain === domain) ?? null;
|
|
2375
|
-
if (matched) {
|
|
2376
|
-
return matched;
|
|
2377
|
-
}
|
|
2378
|
-
const environmentServices = await listRailwayEnvironmentServices({
|
|
2379
|
-
environmentId: identifiers.environmentId,
|
|
2380
|
-
env
|
|
2381
|
-
});
|
|
2382
|
-
for (const candidate of environmentServices) {
|
|
2383
|
-
const candidateServiceId = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
2384
|
-
if (!candidateServiceId || candidateServiceId === identifiers.serviceId) {
|
|
2385
|
-
continue;
|
|
2386
|
-
}
|
|
2387
|
-
const candidateDomains = await listRailwayCustomDomains({
|
|
2388
|
-
projectId: identifiers.projectId,
|
|
2389
|
-
environmentId: identifiers.environmentId,
|
|
2390
|
-
serviceId: candidateServiceId,
|
|
2391
|
-
env
|
|
2392
|
-
});
|
|
2393
|
-
const staleAttachment = candidateDomains.find((entry) => entry.domain === domain) ?? null;
|
|
2394
|
-
if (!staleAttachment) {
|
|
2395
|
-
continue;
|
|
2396
|
-
}
|
|
2397
|
-
if (!staleAttachment.id) {
|
|
2398
|
-
throw new Error(`Railway custom domain ${domain} is attached to service ${candidateServiceId}, but Railway did not return its domain id for exact-state reattachment.`);
|
|
2399
|
-
}
|
|
2400
|
-
await deleteRailwayCustomDomain({ domainId: staleAttachment.id, env });
|
|
2401
|
-
let detached = false;
|
|
2402
|
-
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
2403
|
-
const remaining = await listRailwayCustomDomains({
|
|
2404
|
-
projectId: identifiers.projectId,
|
|
2405
|
-
environmentId: identifiers.environmentId,
|
|
2406
|
-
serviceId: candidateServiceId,
|
|
2407
|
-
env
|
|
2408
|
-
});
|
|
2409
|
-
if (!remaining.some((entry) => entry.domain === domain)) {
|
|
2410
|
-
detached = true;
|
|
2411
|
-
break;
|
|
2412
|
-
}
|
|
2413
|
-
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
2414
|
-
}
|
|
2415
|
-
if (!detached) {
|
|
2416
|
-
throw new Error(`Railway custom domain ${domain} remained attached to service ${candidateServiceId} after deletion.`);
|
|
2417
|
-
}
|
|
2418
|
-
break;
|
|
2419
|
-
}
|
|
2420
|
-
const ensured = await ensureRailwayCustomDomainViaApi({
|
|
2421
|
-
projectId: identifiers.projectId,
|
|
2422
|
-
environmentId: identifiers.environmentId,
|
|
2423
|
-
serviceId: identifiers.serviceId,
|
|
2424
|
-
domain,
|
|
2425
|
-
env
|
|
2426
|
-
});
|
|
2427
|
-
if (!ensured.domain) {
|
|
2428
|
-
throw new Error(`Railway API custom domain reconciliation did not return ${domain}.`);
|
|
2429
|
-
}
|
|
2430
|
-
return ensured.domain;
|
|
2431
|
-
}
|
|
2432
2382
|
async function observeRailwayCustomDomainLive(input, domain) {
|
|
2433
2383
|
if (!domain) {
|
|
2434
2384
|
return null;
|
|
2435
2385
|
}
|
|
2436
2386
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
2437
2387
|
const serviceKey = String(input.unit.metadata.serviceKey ?? "api").trim();
|
|
2438
|
-
const topology = await
|
|
2439
|
-
refresh:
|
|
2440
|
-
serviceKeys: [serviceKey],
|
|
2388
|
+
const topology = await resolveRailwayUnitTopology(input, scope, {
|
|
2389
|
+
refresh: false,
|
|
2441
2390
|
includeInstances: false,
|
|
2442
2391
|
includeVariables: false
|
|
2443
2392
|
});
|
|
2444
|
-
const entry = topology
|
|
2393
|
+
const entry = findRailwayTopologyEntry(topology, serviceKey);
|
|
2445
2394
|
const identifiers = {
|
|
2446
2395
|
projectId: entry?.project?.id ?? null,
|
|
2447
2396
|
environmentId: entry?.environment?.id ?? null,
|
|
2448
2397
|
serviceId: entry?.service?.id ?? null
|
|
2449
2398
|
};
|
|
2450
2399
|
if (!identifiers.projectId || !identifiers.environmentId || !identifiers.serviceId) {
|
|
2451
|
-
return
|
|
2400
|
+
return {
|
|
2401
|
+
domain: null,
|
|
2402
|
+
serviceId: null,
|
|
2403
|
+
serviceName: entry?.configuredService?.serviceName ?? null
|
|
2404
|
+
};
|
|
2452
2405
|
}
|
|
2453
2406
|
const [customDomains, serviceDomains] = await Promise.all([
|
|
2454
2407
|
listRailwayCustomDomains({
|
|
@@ -2471,9 +2424,15 @@ async function observeRailwayCustomDomainLive(input, domain) {
|
|
|
2471
2424
|
const serviceDomain = serviceDomains.find((entry2) => entry2.kind === "service") ?? null;
|
|
2472
2425
|
const normalized = normalizeRailwayDomainPayload({
|
|
2473
2426
|
...customDomain,
|
|
2427
|
+
serviceId: identifiers.serviceId,
|
|
2428
|
+
serviceName: entry?.configuredService?.serviceName ?? null,
|
|
2474
2429
|
serviceDomain: serviceDomain?.domain ?? null
|
|
2475
2430
|
});
|
|
2476
|
-
return normalized?.domain ? normalized :
|
|
2431
|
+
return normalized?.domain ? normalized : {
|
|
2432
|
+
...customDomain,
|
|
2433
|
+
serviceId: identifiers.serviceId,
|
|
2434
|
+
serviceName: entry?.configuredService?.serviceName ?? null
|
|
2435
|
+
};
|
|
2477
2436
|
}
|
|
2478
2437
|
function railwayCustomDomainHasDnsRequirements(live) {
|
|
2479
2438
|
if (!live) {
|
|
@@ -3421,7 +3380,6 @@ async function ensureRailwayEnvironmentForService({
|
|
|
3421
3380
|
}
|
|
3422
3381
|
async function resolveRailwayTopologyForScope(input, scope, {
|
|
3423
3382
|
ensure = false,
|
|
3424
|
-
ensureServices = ensure,
|
|
3425
3383
|
refresh = false,
|
|
3426
3384
|
serviceKeys,
|
|
3427
3385
|
includeInstances = ensure,
|
|
@@ -3549,71 +3507,14 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
3549
3507
|
traceRailwayReconcile(env, "topology:environment-services:resolved", `${service.key}:${environmentServices.map((entry) => entry.name).join(",")}`);
|
|
3550
3508
|
}
|
|
3551
3509
|
}
|
|
3552
|
-
|
|
3553
|
-
if (project && !resolvedService && ensureServices) {
|
|
3554
|
-
traceRailwayReconcile(env, "topology:railway-service:ensure", `${service.key}:${resolvedServiceName || resolvedServiceId}`);
|
|
3555
|
-
resolvedService = (await ensureRailwayService({
|
|
3556
|
-
projectId: project.id,
|
|
3557
|
-
serviceId: resolvedServiceId,
|
|
3558
|
-
serviceName: resolvedServiceName,
|
|
3559
|
-
environmentId: environment?.id,
|
|
3560
|
-
imageRef: service.imageRef,
|
|
3561
|
-
sourceRepo: service.sourceRepo,
|
|
3562
|
-
sourceBranch: service.sourceBranch,
|
|
3563
|
-
env
|
|
3564
|
-
})).service;
|
|
3565
|
-
project = {
|
|
3566
|
-
...project,
|
|
3567
|
-
services: [...project.services.filter((entry) => entry.id !== resolvedService?.id), resolvedService]
|
|
3568
|
-
};
|
|
3569
|
-
projectsByKey.set(project.id, project);
|
|
3570
|
-
projectsByKey.set(project.name, project);
|
|
3571
|
-
} else if (project && resolvedService && ensureServices && (service.imageRef || service.sourceRepo)) {
|
|
3572
|
-
traceRailwayReconcile(env, "topology:railway-service:repair-source", `${service.key}:${resolvedService.name}`);
|
|
3573
|
-
try {
|
|
3574
|
-
resolvedService = (await ensureRailwayService({
|
|
3575
|
-
projectId: project.id,
|
|
3576
|
-
serviceId: resolvedService.id,
|
|
3577
|
-
serviceName: resolvedServiceName || resolvedService.name,
|
|
3578
|
-
environmentId: environment?.id,
|
|
3579
|
-
imageRef: service.imageRef,
|
|
3580
|
-
sourceRepo: service.sourceRepo,
|
|
3581
|
-
sourceBranch: service.sourceBranch,
|
|
3582
|
-
env
|
|
3583
|
-
})).service;
|
|
3584
|
-
} catch (error) {
|
|
3585
|
-
if (!looksLikeRailwayExistingSourceRepairUnsupported(error)) {
|
|
3586
|
-
throw error;
|
|
3587
|
-
}
|
|
3588
|
-
traceRailwayReconcile(env, "topology:railway-service:repair-source-skipped", `${service.key}:${resolvedService.name}`);
|
|
3589
|
-
}
|
|
3590
|
-
}
|
|
3510
|
+
const resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
|
|
3591
3511
|
let instance = null;
|
|
3592
3512
|
if (includeInstances && resolvedService && environment) {
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
buildCommand: service.buildCommand,
|
|
3599
|
-
dockerfilePath: !service.imageRef ? service.dockerfilePath ?? (existsSync(resolve(service.rootDir, "Dockerfile")) ? "/Dockerfile" : null) : null,
|
|
3600
|
-
startCommand: service.startCommand,
|
|
3601
|
-
rootDirectory: service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service),
|
|
3602
|
-
healthcheckPath: service.healthcheckPath,
|
|
3603
|
-
healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
|
|
3604
|
-
healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
|
|
3605
|
-
restartPolicy: service.restartPolicy,
|
|
3606
|
-
runtimeMode: service.runtimeMode,
|
|
3607
|
-
clearSourceConfiguration: service.sourceMode === "image" || Boolean(service.imageRef),
|
|
3608
|
-
env
|
|
3609
|
-
})).instance;
|
|
3610
|
-
} else {
|
|
3611
|
-
instance = await getRailwayServiceInstance({
|
|
3612
|
-
serviceId: resolvedService.id,
|
|
3613
|
-
environmentId: environment.id,
|
|
3614
|
-
env
|
|
3615
|
-
});
|
|
3616
|
-
}
|
|
3513
|
+
instance = await getRailwayServiceInstance({
|
|
3514
|
+
serviceId: resolvedService.id,
|
|
3515
|
+
environmentId: environment.id,
|
|
3516
|
+
env
|
|
3517
|
+
});
|
|
3617
3518
|
}
|
|
3618
3519
|
const currentVariables = includeVariables && project && environment && resolvedService ? await listRailwayVariables({
|
|
3619
3520
|
projectId: project.id,
|
|
@@ -3621,7 +3522,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
3621
3522
|
serviceId: resolvedService.id,
|
|
3622
3523
|
env
|
|
3623
3524
|
}) : {};
|
|
3624
|
-
resolvedServices.set(service.key, {
|
|
3525
|
+
resolvedServices.set(service.instanceKey ?? service.serviceName ?? service.key, {
|
|
3625
3526
|
configuredService: service,
|
|
3626
3527
|
project,
|
|
3627
3528
|
environment,
|
|
@@ -3711,6 +3612,16 @@ function configuredRailwaySiblingResourceNames(input, scope, projectName) {
|
|
|
3711
3612
|
resolveReconcileEnvironmentValues(input, siblingScope),
|
|
3712
3613
|
{ identityOnly: true }
|
|
3713
3614
|
).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service)).filter((service) => service.projectName === projectName);
|
|
3615
|
+
const currentServices = configuredRailwayServices(
|
|
3616
|
+
input.context.tenantRoot,
|
|
3617
|
+
scope,
|
|
3618
|
+
resolveReconcileEnvironmentValues(input, scope),
|
|
3619
|
+
{ identityOnly: true }
|
|
3620
|
+
).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service)).filter((service) => service.projectName === projectName);
|
|
3621
|
+
assertNoRailwaySourceIdentityCollisions([
|
|
3622
|
+
...currentServices.map((service) => ({ ...service, environment: scope })),
|
|
3623
|
+
...siblingServices.map((service) => ({ ...service, environment: siblingScope }))
|
|
3624
|
+
]);
|
|
3714
3625
|
const database = configuredRailwayIacDatabase(input, siblingServices);
|
|
3715
3626
|
return [.../* @__PURE__ */ new Set([
|
|
3716
3627
|
...siblingServices.flatMap((service) => [
|
|
@@ -3737,6 +3648,14 @@ function railwayIacServiceInput(input, sync, service, scope) {
|
|
|
3737
3648
|
...service.sourceBranch ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_BRANCH`]: service.sourceBranch } : {},
|
|
3738
3649
|
...service.sourceCommit ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_COMMIT`]: service.sourceCommit } : {}
|
|
3739
3650
|
} : {};
|
|
3651
|
+
let customDomain = null;
|
|
3652
|
+
if (service.key === "api" && service.publicBaseUrl) {
|
|
3653
|
+
try {
|
|
3654
|
+
customDomain = new URL(service.publicBaseUrl).hostname || null;
|
|
3655
|
+
} catch {
|
|
3656
|
+
customDomain = null;
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3740
3659
|
return {
|
|
3741
3660
|
key: service.instanceKey ?? service.key,
|
|
3742
3661
|
serviceName: service.serviceName,
|
|
@@ -3753,6 +3672,7 @@ function railwayIacServiceInput(input, sync, service, scope) {
|
|
|
3753
3672
|
healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
|
|
3754
3673
|
runtimeMode: service.runtimeMode,
|
|
3755
3674
|
volumeMountPath: service.volumeMountPath,
|
|
3675
|
+
customDomains: customDomain ? [customDomain] : [],
|
|
3756
3676
|
variables: {
|
|
3757
3677
|
...serviceSync.variables,
|
|
3758
3678
|
...sourceVariables
|
|
@@ -3792,26 +3712,6 @@ function activeAttachedRailwayVolumeIds(volumes, serviceId, environmentId, desir
|
|
|
3792
3712
|
(instance) => instance.serviceId === serviceId && instance.environmentId === environmentId && (volume.name !== desiredVolumeName || activeRailwayVolumeInstances({ instances: [instance] }).length === 0)
|
|
3793
3713
|
)).map((volume) => volume.id);
|
|
3794
3714
|
}
|
|
3795
|
-
function blockedPendingRailwayVolumeAttachments(volumes, serviceIdByName, environmentId) {
|
|
3796
|
-
const serviceNameById = new Map([...serviceIdByName.entries()].filter(([, serviceId]) => serviceId).map(([serviceName, serviceId]) => [serviceId, serviceName]));
|
|
3797
|
-
const blocked = [];
|
|
3798
|
-
for (const volume of volumes) {
|
|
3799
|
-
for (const instance of volume.instances) {
|
|
3800
|
-
if (!instance.serviceId || instance.environmentId !== environmentId) {
|
|
3801
|
-
continue;
|
|
3802
|
-
}
|
|
3803
|
-
const serviceName = serviceNameById.get(instance.serviceId);
|
|
3804
|
-
if (!serviceName) {
|
|
3805
|
-
continue;
|
|
3806
|
-
}
|
|
3807
|
-
if (activeRailwayVolumeInstances({ instances: [instance] }).length > 0) {
|
|
3808
|
-
continue;
|
|
3809
|
-
}
|
|
3810
|
-
blocked.push(`${serviceName}: volume ${volume.name ?? volume.id} (${volume.id}) is pending/deleted while still attached to service ${instance.serviceId} at ${instance.mountPath ?? "(unknown mount)"}; deletedAt=${instance.deletedAt ?? "(none)"} state=${instance.state ?? "(unknown)"}`);
|
|
3811
|
-
}
|
|
3812
|
-
}
|
|
3813
|
-
return blocked;
|
|
3814
|
-
}
|
|
3815
3715
|
async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
|
|
3816
3716
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
3817
3717
|
const desiredRunners = configuredRailwayServicesForInput(input, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false);
|
|
@@ -3917,11 +3817,11 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
|
|
|
3917
3817
|
}
|
|
3918
3818
|
let workspace = "";
|
|
3919
3819
|
const syncedServices = [];
|
|
3820
|
+
const projectPlans = [];
|
|
3920
3821
|
for (const projectServices of projectGroups) {
|
|
3921
3822
|
const serviceKeySet = [...new Set(projectServices.map((service) => service.key))];
|
|
3922
3823
|
const topology = await resolveRailwayTopologyForScope(input, scope, {
|
|
3923
3824
|
ensure: !planOnly,
|
|
3924
|
-
ensureServices: false,
|
|
3925
3825
|
serviceKeys: serviceKeySet,
|
|
3926
3826
|
includeInstances: false,
|
|
3927
3827
|
includeVariables: false,
|
|
@@ -3935,46 +3835,29 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
|
|
|
3935
3835
|
for (const service of projectServices) {
|
|
3936
3836
|
railwayIacServiceInput(input, sync, service, scope);
|
|
3937
3837
|
}
|
|
3938
|
-
if (planOnly) {
|
|
3939
|
-
syncedServices.push(...projectServices);
|
|
3940
|
-
continue;
|
|
3941
|
-
}
|
|
3942
3838
|
if (!project || !environment) {
|
|
3943
|
-
throw new Error(`Railway IaC reconciliation could not resolve project/environment for ${projectServices[0]?.projectName ?? "(unknown project)"}/${projectServices[0]?.railwayEnvironment ?? "(unknown environment)"}.`);
|
|
3839
|
+
throw new Error(`Railway IaC ${planOnly ? "plan" : "reconciliation"} could not resolve project/environment for ${projectServices[0]?.projectName ?? "(unknown project)"}/${projectServices[0]?.railwayEnvironment ?? "(unknown environment)"}.`);
|
|
3944
3840
|
}
|
|
3945
3841
|
const liveServices = await listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }).catch(() => project.services);
|
|
3946
3842
|
const liveServiceByName = new Map(liveServices.map((service) => [service.name, service]));
|
|
3947
3843
|
const liveVolumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
|
|
3948
3844
|
const databaseDescriptor = configuredRailwayIacDatabase(input, projectServices);
|
|
3949
|
-
const
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
status: "blocked",
|
|
3964
|
-
projectId: project.id,
|
|
3965
|
-
environmentId: environment.id,
|
|
3966
|
-
reason
|
|
3967
|
-
});
|
|
3968
|
-
}
|
|
3969
|
-
throw new Error(`Railway has pending-deletion volume attachments that block official IaC reconciliation. ${pendingVolumeBlocks.join("; ")}`);
|
|
3845
|
+
const baseIacServices = projectServices.map((service) => railwayIacServiceInput(input, sync, service, scope));
|
|
3846
|
+
const pendingVolumeNameCollisions = findRailwayPendingVolumeNameCollisions({
|
|
3847
|
+
services: baseIacServices,
|
|
3848
|
+
liveServices,
|
|
3849
|
+
volumes: liveVolumes
|
|
3850
|
+
});
|
|
3851
|
+
const volumeBindingResult = resolveRailwayIacVolumeBindings({
|
|
3852
|
+
environmentId: environment.id,
|
|
3853
|
+
services: baseIacServices,
|
|
3854
|
+
liveServices,
|
|
3855
|
+
volumes: liveVolumes
|
|
3856
|
+
});
|
|
3857
|
+
if (volumeBindingResult.blockedReasons.length > 0) {
|
|
3858
|
+
throw new Error(`Railway volume lineage blocks reconciliation: ${volumeBindingResult.blockedReasons.join("; ")}`);
|
|
3970
3859
|
}
|
|
3971
|
-
const
|
|
3972
|
-
const liveService = liveServiceByName.get(service.serviceName) ?? project.services.find((candidate) => candidate.name === service.serviceName || candidate.id === service.serviceId) ?? null;
|
|
3973
|
-
return [
|
|
3974
|
-
service.serviceName,
|
|
3975
|
-
activeAttachedRailwayVolumeIds(liveVolumes, liveService?.id, environment.id, `${service.serviceName}-volume`)
|
|
3976
|
-
];
|
|
3977
|
-
}));
|
|
3860
|
+
const volumeBindingByServiceName = new Map(volumeBindingResult.bindings.map((binding) => [binding.serviceName, binding]));
|
|
3978
3861
|
const databaseLiveService = databaseDescriptor ? liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null : null;
|
|
3979
3862
|
const databaseDetachVolumeIds = databaseDescriptor ? activeAttachedRailwayVolumeIds(liveVolumes, databaseLiveService?.id, environment.id, `${databaseDescriptor.serviceName}-volume`) : [];
|
|
3980
3863
|
const databaseForIac = databaseDescriptor ? { ...databaseDescriptor, detachVolumeIds: databaseDetachVolumeIds, useNativePostgres: false } : null;
|
|
@@ -3991,26 +3874,46 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
|
|
|
3991
3874
|
environmentId: environment.id,
|
|
3992
3875
|
railwayApiToken: token,
|
|
3993
3876
|
railwayApiUrl: String(topology.env.TREESEED_RAILWAY_API_URL ?? topology.env.RAILWAY_API_URL ?? "").trim() || null,
|
|
3994
|
-
services:
|
|
3995
|
-
...
|
|
3996
|
-
|
|
3877
|
+
services: baseIacServices.map((service) => ({
|
|
3878
|
+
...service,
|
|
3879
|
+
volumeName: volumeBindingByServiceName.get(service.serviceName)?.canonicalVolumeName ?? null,
|
|
3880
|
+
volumeAddress: (() => {
|
|
3881
|
+
const binding = volumeBindingByServiceName.get(service.serviceName);
|
|
3882
|
+
return binding && binding.volumeName !== binding.canonicalVolumeName ? `volume.${binding.volumeName}` : null;
|
|
3883
|
+
})()
|
|
3997
3884
|
})),
|
|
3998
3885
|
database: databaseForIac
|
|
3999
3886
|
};
|
|
4000
|
-
const
|
|
4001
|
-
|
|
3887
|
+
const projectEnvironmentServices = await Promise.all(project.environments.map(async (candidate) => ({
|
|
3888
|
+
environment: candidate,
|
|
3889
|
+
services: await listRailwayEnvironmentServices({ environmentId: candidate.id, env: topology.env }).catch(() => [])
|
|
3890
|
+
})));
|
|
3891
|
+
const activeProjectEnvironmentServices = projectEnvironmentServices.flatMap((entry) => entry.services);
|
|
3892
|
+
const siblingEnvironmentName = scope === "prod" ? "staging" : scope === "staging" ? "production" : null;
|
|
3893
|
+
const siblingEnvironmentIds = new Set(project.environments.filter((candidate) => siblingEnvironmentName && candidate.name === siblingEnvironmentName).map((candidate) => candidate.id));
|
|
3894
|
+
const activeSiblingServices = projectEnvironmentServices.filter((entry) => siblingEnvironmentIds.has(entry.environment.id)).flatMap((entry) => entry.services);
|
|
3895
|
+
const activeSiblingServiceIds = new Set(activeSiblingServices.map((service) => service.id));
|
|
3896
|
+
const activeSiblingResourceNames = [
|
|
3897
|
+
...activeSiblingServices.map((service) => service.name),
|
|
3898
|
+
...liveVolumes.filter((volume) => activeRailwayVolumeInstances(volume).some((instance) => siblingEnvironmentIds.has(instance.environmentId ?? "") || activeSiblingServiceIds.has(instance.serviceId ?? ""))).map((volume) => volume.name).filter((name) => Boolean(name))
|
|
3899
|
+
];
|
|
3900
|
+
const aliasCleanup = railwayObsoleteAliasCleanupPolicy(
|
|
3901
|
+
scope,
|
|
3902
|
+
projectServices,
|
|
3903
|
+
project.services.map((service) => service.name),
|
|
3904
|
+
activeProjectEnvironmentServices.map((service) => service.name)
|
|
3905
|
+
);
|
|
3906
|
+
const protectedSiblingResourceNames = [.../* @__PURE__ */ new Set([
|
|
4002
3907
|
...configuredRailwaySiblingResourceNames(input, scope, project.name),
|
|
4003
|
-
...
|
|
3908
|
+
...activeSiblingResourceNames
|
|
3909
|
+
])];
|
|
3910
|
+
const siblingResourceNames = [
|
|
3911
|
+
...protectedSiblingResourceNames,
|
|
3912
|
+
...aliasCleanup.retainedResourceNames
|
|
3913
|
+
];
|
|
3914
|
+
const allowedResourceDeletions = [
|
|
3915
|
+
...aliasCleanup.allowedResourceDeletions.filter((name) => !protectedSiblingResourceNames.includes(name))
|
|
4004
3916
|
];
|
|
4005
|
-
const { allowedResourceDeletions } = aliasMigration;
|
|
4006
|
-
const {
|
|
4007
|
-
applyRailwayIacProject,
|
|
4008
|
-
cleanupRailwayIacRender,
|
|
4009
|
-
planRailwayIacProject,
|
|
4010
|
-
renderRailwayIacProject,
|
|
4011
|
-
selectRailwayIacRetainedResources,
|
|
4012
|
-
validateRailwayIacChangeSet
|
|
4013
|
-
} = await import("./providers/railway-iac.js");
|
|
4014
3917
|
let effectiveIacInput = iacInput;
|
|
4015
3918
|
let rendered = renderRailwayIacProject(effectiveIacInput);
|
|
4016
3919
|
try {
|
|
@@ -4030,7 +3933,8 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
|
|
|
4030
3933
|
service.serviceName,
|
|
4031
3934
|
service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
|
|
4032
3935
|
])),
|
|
4033
|
-
allowedResourceDeletions
|
|
3936
|
+
allowedResourceDeletions,
|
|
3937
|
+
protectedResourceNames: protectedSiblingResourceNames
|
|
4034
3938
|
});
|
|
4035
3939
|
if (!validation.ok && effectiveIacInput.database && !effectiveIacInput.database.useNativePostgres && railwayIacPlanDeletesResource(plan.changeSet, effectiveIacInput.database.serviceName)) {
|
|
4036
3940
|
traceRailwayReconcile(topology.env, "sync:iac-native-postgres-adopt", `${project.name}/${environment.name}:${effectiveIacInput.database.serviceName}`);
|
|
@@ -4058,10 +3962,17 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
|
|
|
4058
3962
|
service.serviceName,
|
|
4059
3963
|
service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
|
|
4060
3964
|
])),
|
|
4061
|
-
allowedResourceDeletions
|
|
3965
|
+
allowedResourceDeletions,
|
|
3966
|
+
protectedResourceNames: protectedSiblingResourceNames
|
|
4062
3967
|
});
|
|
4063
3968
|
}
|
|
4064
|
-
const retainedResources =
|
|
3969
|
+
const retainedResources = detachRetainedRailwayCustomDomains(
|
|
3970
|
+
detachRetainedRailwayVolumeBindings(
|
|
3971
|
+
selectRailwayIacRetainedResources(plan, siblingResourceNames),
|
|
3972
|
+
volumeBindingResult.bindings
|
|
3973
|
+
),
|
|
3974
|
+
effectiveIacInput.services.flatMap((service) => service.customDomains ?? [])
|
|
3975
|
+
);
|
|
4065
3976
|
if (retainedResources.length > 0) {
|
|
4066
3977
|
traceRailwayReconcile(
|
|
4067
3978
|
topology.env,
|
|
@@ -4086,16 +3997,219 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
|
|
|
4086
3997
|
service.serviceName,
|
|
4087
3998
|
service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
|
|
4088
3999
|
])),
|
|
4089
|
-
allowedResourceDeletions
|
|
4000
|
+
allowedResourceDeletions,
|
|
4001
|
+
protectedResourceNames: protectedSiblingResourceNames
|
|
4090
4002
|
});
|
|
4091
4003
|
}
|
|
4092
4004
|
if (!validation.ok) {
|
|
4093
4005
|
throw new Error(`Railway IaC plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
|
|
4094
4006
|
}
|
|
4007
|
+
projectPlans.push({
|
|
4008
|
+
projectName: project.name,
|
|
4009
|
+
environmentName: environment.name,
|
|
4010
|
+
changes: (plan.changeSet?.changes ?? []).map((change) => ({
|
|
4011
|
+
kind: String(change.kind ?? ""),
|
|
4012
|
+
path: String(change.path ?? change.address ?? ""),
|
|
4013
|
+
summary: String(change.summary ?? "")
|
|
4014
|
+
})),
|
|
4015
|
+
volumeBindings: volumeBindingResult.bindings
|
|
4016
|
+
});
|
|
4017
|
+
if (planOnly) {
|
|
4018
|
+
syncedServices.push(...projectServices);
|
|
4019
|
+
continue;
|
|
4020
|
+
}
|
|
4021
|
+
for (const collision of pendingVolumeNameCollisions) {
|
|
4022
|
+
const serviceId = liveServiceByName.get(collision.serviceName)?.id ?? collision.serviceId ?? liveServices[0]?.id;
|
|
4023
|
+
if (!serviceId) {
|
|
4024
|
+
throw new Error(`Railway pending volume ${collision.canonicalVolumeName} cannot be released because no environment service is available for CLI scope.`);
|
|
4025
|
+
}
|
|
4026
|
+
const tombstoneName = `pending-delete-${collision.volumeId.slice(0, 8)}`;
|
|
4027
|
+
traceRailwayReconcile(topology.env, "sync:volume:release-pending-name", `${collision.canonicalVolumeName}:${tombstoneName}`);
|
|
4028
|
+
await updateRailwayVolumeWithCli({
|
|
4029
|
+
projectId: project.id,
|
|
4030
|
+
environmentId: environment.id,
|
|
4031
|
+
serviceId,
|
|
4032
|
+
volumeId: collision.volumeId,
|
|
4033
|
+
name: tombstoneName,
|
|
4034
|
+
mountPath: collision.mountPath,
|
|
4035
|
+
env: topology.env
|
|
4036
|
+
});
|
|
4037
|
+
const released = await waitForRailwayVolumeName({
|
|
4038
|
+
volumeId: collision.volumeId,
|
|
4039
|
+
expectedName: tombstoneName,
|
|
4040
|
+
load: () => listRailwayVolumes({ projectId: project.id, env: topology.env }),
|
|
4041
|
+
sleep: async (milliseconds) => {
|
|
4042
|
+
traceRailwayReconcile(topology.env, "sync:volume:release-pending-settle", `${collision.canonicalVolumeName}:${milliseconds}ms`);
|
|
4043
|
+
await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
4044
|
+
}
|
|
4045
|
+
});
|
|
4046
|
+
if (!released) {
|
|
4047
|
+
throw new Error(`Railway pending volume name ${collision.canonicalVolumeName} remained locked after 12 observations.`);
|
|
4048
|
+
}
|
|
4049
|
+
const desiredServiceId = liveServiceByName.get(collision.serviceName)?.id ?? null;
|
|
4050
|
+
if (desiredServiceId === serviceId && collision.serviceId === desiredServiceId) {
|
|
4051
|
+
traceRailwayReconcile(topology.env, "sync:volume:replace-pending-service", `${collision.serviceName}:${serviceId}`);
|
|
4052
|
+
await deleteRailwayService({
|
|
4053
|
+
projectId: project.id,
|
|
4054
|
+
environmentId: environment.id,
|
|
4055
|
+
serviceId,
|
|
4056
|
+
env: topology.env
|
|
4057
|
+
});
|
|
4058
|
+
const absent = await waitForRailwayServiceAbsence({
|
|
4059
|
+
serviceId,
|
|
4060
|
+
load: () => listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }),
|
|
4061
|
+
sleep: async (milliseconds) => {
|
|
4062
|
+
traceRailwayReconcile(topology.env, "sync:volume:replace-pending-service-settle", `${collision.serviceName}:${milliseconds}ms`);
|
|
4063
|
+
await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
4064
|
+
}
|
|
4065
|
+
});
|
|
4066
|
+
if (!absent) throw new Error(`Railway service ${collision.serviceName} remained after pending-volume replacement was requested.`);
|
|
4067
|
+
} else {
|
|
4068
|
+
traceRailwayReconcile(topology.env, "sync:volume:release-pending-detach", `${tombstoneName}:${serviceId}`);
|
|
4069
|
+
await detachRailwayVolumeWithCli({
|
|
4070
|
+
projectId: project.id,
|
|
4071
|
+
environmentId: environment.id,
|
|
4072
|
+
serviceId,
|
|
4073
|
+
volumeId: collision.volumeId,
|
|
4074
|
+
env: topology.env
|
|
4075
|
+
});
|
|
4076
|
+
const detached = await waitForRailwayVolumeDetachment({
|
|
4077
|
+
volumeId: collision.volumeId,
|
|
4078
|
+
environmentId: environment.id,
|
|
4079
|
+
serviceId,
|
|
4080
|
+
load: () => listRailwayVolumes({ projectId: project.id, env: topology.env }),
|
|
4081
|
+
sleep: async (milliseconds) => {
|
|
4082
|
+
traceRailwayReconcile(topology.env, "sync:volume:release-pending-detach-settle", `${tombstoneName}:${milliseconds}ms`);
|
|
4083
|
+
await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
4084
|
+
}
|
|
4085
|
+
});
|
|
4086
|
+
if (!detached) throw new Error(`Railway pending volume ${tombstoneName} remained attached after 12 observations.`);
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
if (pendingVolumeNameCollisions.length > 0) {
|
|
4090
|
+
cleanupRailwayIacRender(rendered);
|
|
4091
|
+
rendered = renderRailwayIacProject(effectiveIacInput);
|
|
4092
|
+
plan = await planRailwayIacProject(effectiveIacInput, rendered);
|
|
4093
|
+
if (!plan.ok) {
|
|
4094
|
+
const diagnostics = plan.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
|
|
4095
|
+
throw new Error(`Railway IaC recovery replan failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
|
|
4096
|
+
}
|
|
4097
|
+
validation = validateRailwayIacChangeSet(plan.changeSet, {
|
|
4098
|
+
services: rendered.serviceNames,
|
|
4099
|
+
volumes: rendered.volumeNames,
|
|
4100
|
+
database: rendered.databaseName,
|
|
4101
|
+
scope,
|
|
4102
|
+
serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null])),
|
|
4103
|
+
serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode === "image" ? `image:${service.imageRef ?? ""}` : service.sourceMode === "git" ? `github:${service.sourceRepo ?? ""}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : null])),
|
|
4104
|
+
allowedResourceDeletions,
|
|
4105
|
+
protectedResourceNames: protectedSiblingResourceNames
|
|
4106
|
+
});
|
|
4107
|
+
if (!validation.ok) throw new Error(`Railway IaC recovery plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
|
|
4108
|
+
}
|
|
4095
4109
|
const apply = await applyRailwayIacProject(effectiveIacInput, rendered);
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4110
|
+
const applyFailure = railwayIacApplyFailure(apply);
|
|
4111
|
+
if (applyFailure) throw new Error(`Railway IaC apply failed for ${project.name}/${environment.name}: ${applyFailure}`);
|
|
4112
|
+
for (const binding of volumeBindingResult.bindings) {
|
|
4113
|
+
const desiredConfig = effectiveIacInput.services.find((service) => service.serviceName === binding.serviceName);
|
|
4114
|
+
if (!desiredConfig?.volumeMountPath) {
|
|
4115
|
+
throw new Error(`Railway volume adoption could not resolve ${binding.canonicalVolumeName} and its desired service.`);
|
|
4116
|
+
}
|
|
4117
|
+
const settled = await waitForRailwayVolumeAdoptionResources({
|
|
4118
|
+
serviceName: binding.serviceName,
|
|
4119
|
+
volumeId: binding.volumeId,
|
|
4120
|
+
load: async () => ({
|
|
4121
|
+
services: await listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }),
|
|
4122
|
+
volumes: await listRailwayVolumes({ projectId: project.id, env: topology.env })
|
|
4123
|
+
}),
|
|
4124
|
+
sleep: async (milliseconds) => {
|
|
4125
|
+
traceRailwayReconcile(topology.env, "sync:volume:settle", `${binding.canonicalVolumeName}:${milliseconds}ms`);
|
|
4126
|
+
await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
4127
|
+
}
|
|
4128
|
+
});
|
|
4129
|
+
if (!settled) {
|
|
4130
|
+
throw new Error(`Railway volume adoption could not resolve ${binding.canonicalVolumeName} and its desired service after 12 observations.`);
|
|
4131
|
+
}
|
|
4132
|
+
const desiredService = settled.service;
|
|
4133
|
+
const liveVolume = settled.volume;
|
|
4134
|
+
const volumesAfterApply = settled.volumes;
|
|
4135
|
+
const activeEnvironmentAttachments = liveVolume.instances.filter(
|
|
4136
|
+
(instance) => instance.environmentId === environment.id && !instance.isPendingDeletion && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && !["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
|
|
4137
|
+
);
|
|
4138
|
+
const nameCollisions = volumesAfterApply.filter(
|
|
4139
|
+
(volume) => volume.id !== liveVolume.id && volume.name === binding.canonicalVolumeName
|
|
4140
|
+
);
|
|
4141
|
+
for (const collision of nameCollisions) {
|
|
4142
|
+
const pendingDeletion = collision.instances.length > 0 && collision.instances.every(
|
|
4143
|
+
(instance) => instance.isPendingDeletion === true || Boolean(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) || ["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
|
|
4144
|
+
);
|
|
4145
|
+
if (!pendingDeletion) {
|
|
4146
|
+
throw new Error(`Railway volume name ${binding.canonicalVolumeName} is owned by active volume ${collision.id}; refusing ambiguous adoption.`);
|
|
4147
|
+
}
|
|
4148
|
+
const tombstoneName = `pending-delete-${collision.id.slice(0, 8)}`;
|
|
4149
|
+
traceRailwayReconcile(topology.env, "sync:volume:rename-tombstone", `${binding.canonicalVolumeName}:${tombstoneName}`);
|
|
4150
|
+
await updateRailwayVolumeWithCli({
|
|
4151
|
+
projectId: project.id,
|
|
4152
|
+
environmentId: environment.id,
|
|
4153
|
+
serviceId: desiredService.id,
|
|
4154
|
+
volumeId: collision.id,
|
|
4155
|
+
name: tombstoneName,
|
|
4156
|
+
mountPath: collision.instances[0]?.mountPath || desiredConfig.volumeMountPath,
|
|
4157
|
+
env: topology.env
|
|
4158
|
+
});
|
|
4159
|
+
}
|
|
4160
|
+
for (const attachment of activeEnvironmentAttachments.filter((instance) => instance.serviceId && instance.serviceId !== desiredService.id)) {
|
|
4161
|
+
traceRailwayReconcile(topology.env, "sync:volume:detach-stale", `${liveVolume.name ?? liveVolume.id}:${attachment.serviceId}`);
|
|
4162
|
+
await detachRailwayVolumeWithCli({
|
|
4163
|
+
projectId: project.id,
|
|
4164
|
+
environmentId: environment.id,
|
|
4165
|
+
serviceId: attachment.serviceId,
|
|
4166
|
+
volumeId: liveVolume.id,
|
|
4167
|
+
env: topology.env
|
|
4168
|
+
});
|
|
4169
|
+
}
|
|
4170
|
+
const desiredAttachment = activeEnvironmentAttachments.find((instance) => instance.serviceId === desiredService.id);
|
|
4171
|
+
if (liveVolume.name !== binding.canonicalVolumeName || desiredAttachment?.mountPath !== desiredConfig.volumeMountPath) {
|
|
4172
|
+
traceRailwayReconcile(topology.env, "sync:volume:update", `${liveVolume.name ?? liveVolume.id}:${binding.canonicalVolumeName}:${desiredConfig.volumeMountPath}`);
|
|
4173
|
+
await updateRailwayVolumeWithCli({
|
|
4174
|
+
projectId: project.id,
|
|
4175
|
+
environmentId: environment.id,
|
|
4176
|
+
serviceId: desiredService.id,
|
|
4177
|
+
volumeId: liveVolume.id,
|
|
4178
|
+
name: binding.canonicalVolumeName,
|
|
4179
|
+
mountPath: desiredConfig.volumeMountPath,
|
|
4180
|
+
env: topology.env
|
|
4181
|
+
});
|
|
4182
|
+
}
|
|
4183
|
+
const refreshedVolume = (await listRailwayVolumes({ projectId: project.id, env: topology.env })).find((volume) => volume.id === liveVolume.id);
|
|
4184
|
+
const attachedAfterUpdate = refreshedVolume?.instances.some(
|
|
4185
|
+
(instance) => instance.environmentId === environment.id && instance.serviceId === desiredService.id && !instance.isPendingDeletion && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && !["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
|
|
4186
|
+
);
|
|
4187
|
+
if (!attachedAfterUpdate) {
|
|
4188
|
+
traceRailwayReconcile(topology.env, "sync:volume:attach", `${binding.canonicalVolumeName}:${desiredService.name}`);
|
|
4189
|
+
await attachRailwayVolumeWithCli({
|
|
4190
|
+
projectId: project.id,
|
|
4191
|
+
environmentId: environment.id,
|
|
4192
|
+
serviceId: desiredService.id,
|
|
4193
|
+
volumeId: liveVolume.id,
|
|
4194
|
+
env: topology.env
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
const appliedServiceObservation = await waitForRailwayServices({
|
|
4199
|
+
serviceNames: effectiveIacInput.services.map((service) => service.serviceName),
|
|
4200
|
+
load: () => listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }),
|
|
4201
|
+
sleep: async (milliseconds) => {
|
|
4202
|
+
traceRailwayReconcile(topology.env, "sync:service:settle", `${project.name}/${environment.name}:${milliseconds}ms`);
|
|
4203
|
+
await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
4204
|
+
}
|
|
4205
|
+
});
|
|
4206
|
+
const appliedServices = appliedServiceObservation?.services ?? [];
|
|
4207
|
+
const appliedServiceByName = new Map(appliedServices.map((service) => [service.name, service]));
|
|
4208
|
+
for (const service of effectiveIacInput.services) {
|
|
4209
|
+
const appliedService = appliedServiceByName.get(service.serviceName);
|
|
4210
|
+
if (!appliedService) {
|
|
4211
|
+
throw new Error(`Railway IaC did not create or retain expected service ${service.serviceName} in ${environment.name}.`);
|
|
4212
|
+
}
|
|
4099
4213
|
}
|
|
4100
4214
|
assertNoBlockedRailwayProviderDrift(input, scope);
|
|
4101
4215
|
syncedServices.push(...projectServices);
|
|
@@ -4110,406 +4224,14 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
|
|
|
4110
4224
|
secrets: Object.keys(sync.secrets),
|
|
4111
4225
|
variables: Object.keys(sync.variables),
|
|
4112
4226
|
planOnly,
|
|
4113
|
-
workspace
|
|
4227
|
+
workspace,
|
|
4228
|
+
projectPlans
|
|
4114
4229
|
};
|
|
4115
4230
|
}
|
|
4116
|
-
function
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
if (!entry.project?.id || !entry.environment?.name || !entry.service?.name) {
|
|
4121
|
-
throw new Error(`Railway source upload deploy requires resolved project, environment, and service for ${entry.configuredService.key}.`);
|
|
4122
|
-
}
|
|
4123
|
-
const rootDir = String(entry.configuredService.rootDir ?? "").trim();
|
|
4124
|
-
if (!rootDir || !existsSync(rootDir)) {
|
|
4125
|
-
throw new Error(`Railway source upload deploy root does not exist for ${entry.configuredService.key}: ${rootDir || "(unset)"}.`);
|
|
4126
|
-
}
|
|
4127
|
-
const uploadContext = prepareRailwaySourceUploadContext(input, entry.configuredService, rootDir);
|
|
4128
|
-
const uploadRoot = uploadContext.rootDir;
|
|
4129
|
-
const args = [
|
|
4130
|
-
"trsd",
|
|
4131
|
-
"railway",
|
|
4132
|
-
"--environment",
|
|
4133
|
-
scope,
|
|
4134
|
-
"--",
|
|
4135
|
-
"up",
|
|
4136
|
-
"--service",
|
|
4137
|
-
entry.service.name,
|
|
4138
|
-
"--project",
|
|
4139
|
-
entry.project.id,
|
|
4140
|
-
"--environment",
|
|
4141
|
-
entry.environment.name,
|
|
4142
|
-
"--ci",
|
|
4143
|
-
"--detach",
|
|
4144
|
-
"--path-as-root",
|
|
4145
|
-
...uploadContext.noGitignore ? ["--no-gitignore"] : [],
|
|
4146
|
-
"--verbose",
|
|
4147
|
-
"--message",
|
|
4148
|
-
`trsd staging source upload ${entry.configuredService.key}`,
|
|
4149
|
-
uploadRoot
|
|
4150
|
-
];
|
|
4151
|
-
const commandEnv = {
|
|
4152
|
-
...process.env,
|
|
4153
|
-
...env,
|
|
4154
|
-
TREESEED_RECONCILE_TRACE: env.TREESEED_RECONCILE_TRACE ?? process.env.TREESEED_RECONCILE_TRACE
|
|
4155
|
-
};
|
|
4156
|
-
try {
|
|
4157
|
-
const result = spawnSync("npx", args, {
|
|
4158
|
-
cwd: input.context.tenantRoot,
|
|
4159
|
-
env: commandEnv,
|
|
4160
|
-
encoding: "utf8",
|
|
4161
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
4162
|
-
maxBuffer: 1024 * 1024 * 20
|
|
4163
|
-
});
|
|
4164
|
-
if ((result.status ?? 1) !== 0) {
|
|
4165
|
-
const stderr = String(result.stderr ?? "").split("\n").slice(-120).join("\n");
|
|
4166
|
-
const stdout = String(result.stdout ?? "").split("\n").slice(-120).join("\n");
|
|
4167
|
-
throw new Error(`Railway source upload deploy failed for ${entry.configuredService.key} with status ${result.status ?? 1}.
|
|
4168
|
-
${stderr || stdout}`);
|
|
4169
|
-
}
|
|
4170
|
-
} finally {
|
|
4171
|
-
if (uploadContext.cleanupDir) {
|
|
4172
|
-
rmSync(uploadContext.cleanupDir, { recursive: true, force: true });
|
|
4173
|
-
}
|
|
4174
|
-
}
|
|
4175
|
-
}
|
|
4176
|
-
function prepareRailwaySourceUploadContext(input, service, rootDir) {
|
|
4177
|
-
const role = service.key === "capacityProviderManager" ? "manager" : service.key === "capacityProviderRunner" ? "runner" : null;
|
|
4178
|
-
if (!role) {
|
|
4179
|
-
return { rootDir, cleanupDir: null, noGitignore: false };
|
|
4180
|
-
}
|
|
4181
|
-
const cleanupDir = mkdtempSync(resolve(tmpdir(), `trsd-railway-agent-${role}-`));
|
|
4182
|
-
const contextRoot = resolve(cleanupDir, "context");
|
|
4183
|
-
mkdirSync(contextRoot, { recursive: true });
|
|
4184
|
-
for (const entry of ["src", "scripts", "templates", "docs"]) {
|
|
4185
|
-
const source = resolve(rootDir, entry);
|
|
4186
|
-
if (existsSync(source)) {
|
|
4187
|
-
cpSync(source, resolve(contextRoot, entry), { recursive: true });
|
|
4188
|
-
}
|
|
4189
|
-
}
|
|
4190
|
-
for (const entry of ["docker-entrypoint.sh", "package.json", "package-lock.json", "tsconfig.json", "tsconfig.dist.json"]) {
|
|
4191
|
-
const source = resolve(rootDir, entry);
|
|
4192
|
-
if (existsSync(source)) {
|
|
4193
|
-
cpSync(source, resolve(contextRoot, entry));
|
|
4194
|
-
}
|
|
4195
|
-
}
|
|
4196
|
-
const sdkRoot = resolve(rootDir, "..", "sdk");
|
|
4197
|
-
let sdkTarballPrepared = false;
|
|
4198
|
-
if (existsSync(resolve(sdkRoot, "package.json"))) {
|
|
4199
|
-
const pack = spawnSync("npm", ["pack", "--ignore-scripts", "--pack-destination", contextRoot], {
|
|
4200
|
-
cwd: sdkRoot,
|
|
4201
|
-
env: process.env,
|
|
4202
|
-
encoding: "utf8",
|
|
4203
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
4204
|
-
maxBuffer: 1024 * 1024 * 20
|
|
4205
|
-
});
|
|
4206
|
-
if ((pack.status ?? 1) !== 0) {
|
|
4207
|
-
const stderr = String(pack.stderr ?? "").split("\n").slice(-80).join("\n");
|
|
4208
|
-
const stdout = String(pack.stdout ?? "").split("\n").slice(-80).join("\n");
|
|
4209
|
-
throw new Error(`Failed to pack local SDK for Agent ${role} Railway source upload context.
|
|
4210
|
-
${stderr || stdout}`);
|
|
4211
|
-
}
|
|
4212
|
-
const filename = String(pack.stdout ?? "").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).at(-1);
|
|
4213
|
-
if (!filename || !existsSync(resolve(contextRoot, filename))) {
|
|
4214
|
-
throw new Error(`Local SDK pack did not produce a tarball for Agent ${role} Railway source upload context.`);
|
|
4215
|
-
}
|
|
4216
|
-
cpSync(resolve(contextRoot, filename), resolve(contextRoot, "treeseed-sdk.tgz"));
|
|
4217
|
-
if (filename !== "treeseed-sdk.tgz") {
|
|
4218
|
-
rmSync(resolve(contextRoot, filename), { force: true });
|
|
4219
|
-
}
|
|
4220
|
-
sdkTarballPrepared = true;
|
|
4221
|
-
}
|
|
4222
|
-
const dockerfile = [
|
|
4223
|
-
"FROM node:22-alpine AS builder",
|
|
4224
|
-
"WORKDIR /app",
|
|
4225
|
-
"RUN apk add --no-cache ca-certificates git openssh-client",
|
|
4226
|
-
sdkTarballPrepared ? "COPY package.json package-lock.json treeseed-sdk.tgz ./" : "COPY package.json package-lock.json ./",
|
|
4227
|
-
sdkTarballPrepared ? "RUN npm ci --include=dev --ignore-scripts \\\n && npm install --include=dev --ignore-scripts ./treeseed-sdk.tgz" : "RUN npm ci --include=dev --ignore-scripts",
|
|
4228
|
-
"COPY . .",
|
|
4229
|
-
"RUN npm run build:dist \\",
|
|
4230
|
-
" && npm prune --omit=dev --ignore-scripts \\",
|
|
4231
|
-
" && rm -rf node_modules/@github node_modules/@openai node_modules/@cloudflare node_modules/@railway \\",
|
|
4232
|
-
" node_modules/wrangler node_modules/miniflare node_modules/workerd \\",
|
|
4233
|
-
" node_modules/playwright node_modules/playwright-core node_modules/gpt-tokenizer \\",
|
|
4234
|
-
" node_modules/@remotion node_modules/remotion node_modules/@rspack \\",
|
|
4235
|
-
" node_modules/@repomix node_modules/repomix node_modules/@esbuild node_modules/esbuild \\",
|
|
4236
|
-
" node_modules/webpack node_modules/typescript node_modules/@mediabunny node_modules/mediabunny \\",
|
|
4237
|
-
" node_modules/web-tree-sitter node_modules/caniuse-lite node_modules/@img node_modules/@secretlint \\",
|
|
4238
|
-
" node_modules/lightningcss* node_modules/vite \\",
|
|
4239
|
-
" && npm cache clean --force \\",
|
|
4240
|
-
" && rm -rf src scripts test .git .github",
|
|
4241
|
-
"FROM node:22-alpine AS runtime",
|
|
4242
|
-
"WORKDIR /app",
|
|
4243
|
-
"ENV NODE_ENV=production TREESEED_PROVIDER_DATA_DIR=/data",
|
|
4244
|
-
"RUN apk add --no-cache ca-certificates tini util-linux git openssh-client \\",
|
|
4245
|
-
" && mkdir -p /data",
|
|
4246
|
-
"COPY --from=builder --chown=65532:65532 /app/package.json /app/package-lock.json ./",
|
|
4247
|
-
"COPY --from=builder --chown=65532:65532 /app/node_modules ./node_modules",
|
|
4248
|
-
"COPY --from=builder --chown=65532:65532 /app/dist ./dist",
|
|
4249
|
-
"COPY --from=builder --chown=65532:65532 /app/templates ./templates",
|
|
4250
|
-
"COPY --from=builder --chown=65532:65532 /app/docs ./docs",
|
|
4251
|
-
"COPY --from=builder --chown=65532:65532 /app/docker-entrypoint.sh ./docker-entrypoint.sh",
|
|
4252
|
-
"RUN chmod 0755 /app/docker-entrypoint.sh \\",
|
|
4253
|
-
" && chown -R 65532:65532 /data",
|
|
4254
|
-
"EXPOSE 3100",
|
|
4255
|
-
'ENTRYPOINT ["tini", "--", "/app/docker-entrypoint.sh"]',
|
|
4256
|
-
`ENV TREESEED_PROVIDER_ROLE=${role}`,
|
|
4257
|
-
`CMD ["${role}"]`,
|
|
4258
|
-
""
|
|
4259
|
-
].join("\n");
|
|
4260
|
-
writeFileSync(
|
|
4261
|
-
resolve(contextRoot, "Dockerfile"),
|
|
4262
|
-
dockerfile
|
|
4263
|
-
);
|
|
4264
|
-
return { rootDir: contextRoot, cleanupDir, noGitignore: true };
|
|
4265
|
-
}
|
|
4266
|
-
async function deployRailwayServiceInstanceWithSourceRepair(entry, env) {
|
|
4267
|
-
if (!entry.environment || !entry.service) {
|
|
4268
|
-
return;
|
|
4269
|
-
}
|
|
4270
|
-
const deploy = () => deployRailwayServiceInstance({
|
|
4271
|
-
serviceId: entry.service.id,
|
|
4272
|
-
environmentId: entry.environment.id,
|
|
4273
|
-
env
|
|
4274
|
-
});
|
|
4275
|
-
if (entry.configuredService.sourceMode === "git" && entry.configuredService.sourceRepo && !entry.configuredService.imageRef) {
|
|
4276
|
-
await repairRailwayServiceSource(entry, env);
|
|
4277
|
-
}
|
|
4278
|
-
try {
|
|
4279
|
-
await deploy();
|
|
4280
|
-
return;
|
|
4281
|
-
} catch (error) {
|
|
4282
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
4283
|
-
if (/Problem processing request/iu.test(message)) {
|
|
4284
|
-
traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
|
|
4285
|
-
return;
|
|
4286
|
-
}
|
|
4287
|
-
if (!/Deployment not found/iu.test(message)) {
|
|
4288
|
-
throw error;
|
|
4289
|
-
}
|
|
4290
|
-
}
|
|
4291
|
-
const repaired = await repairRailwayServiceSource(entry, env);
|
|
4292
|
-
if (repaired) {
|
|
4293
|
-
try {
|
|
4294
|
-
await deploy();
|
|
4295
|
-
return;
|
|
4296
|
-
} catch (error) {
|
|
4297
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
4298
|
-
if (/Problem processing request/iu.test(message)) {
|
|
4299
|
-
traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
|
|
4300
|
-
return;
|
|
4301
|
-
}
|
|
4302
|
-
if (!/Deployment not found/iu.test(message)) {
|
|
4303
|
-
throw error;
|
|
4304
|
-
}
|
|
4305
|
-
}
|
|
4306
|
-
}
|
|
4307
|
-
throw new Error(
|
|
4308
|
-
`Railway deployment for existing service ${entry.service.name} (${entry.service.id}) was not found; refusing to delete and recreate an existing service. Repair the service in place or restore a deployable service source before rerunning reconciliation.`
|
|
4309
|
-
);
|
|
4310
|
-
}
|
|
4311
|
-
async function repairRailwayServiceSource(entry, env) {
|
|
4312
|
-
if (!entry.service) {
|
|
4313
|
-
return false;
|
|
4314
|
-
}
|
|
4315
|
-
const sourceRepo = String(entry.configuredService.sourceRepo ?? "").trim();
|
|
4316
|
-
if (sourceRepo) {
|
|
4317
|
-
await updateRailwayServiceGitSource({
|
|
4318
|
-
serviceId: entry.service.id,
|
|
4319
|
-
sourceRepo,
|
|
4320
|
-
sourceBranch: entry.configuredService.sourceBranch,
|
|
4321
|
-
env
|
|
4322
|
-
});
|
|
4323
|
-
return true;
|
|
4324
|
-
}
|
|
4325
|
-
const imageRef = String(entry.configuredService.imageRef ?? "").trim();
|
|
4326
|
-
if (imageRef) {
|
|
4327
|
-
await updateRailwayServiceImageSource({
|
|
4328
|
-
serviceId: entry.service.id,
|
|
4329
|
-
imageRef,
|
|
4330
|
-
env
|
|
4331
|
-
});
|
|
4332
|
-
return true;
|
|
4333
|
-
}
|
|
4334
|
-
return false;
|
|
4335
|
-
}
|
|
4336
|
-
function looksLikeRailwayExistingSourceRepairUnsupported(error) {
|
|
4337
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
4338
|
-
return /source update .*existing service|existing service .*source update|provider-supported .*source update|ServiceUpdateInput|Problem processing request|User does not have access to the repo/iu.test(message);
|
|
4339
|
-
}
|
|
4340
|
-
async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
4341
|
-
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
4342
|
-
const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
|
|
4343
|
-
const treeseedDatabaseService = treeseedDatabase?.service;
|
|
4344
|
-
if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
|
|
4345
|
-
return;
|
|
4346
|
-
}
|
|
4347
|
-
const serviceName = treeseedDatabase.serviceName;
|
|
4348
|
-
const firstService = [...topology.services.values()].find(
|
|
4349
|
-
(entry) => entry.project && entry.environment && ["api", "operationsRunner"].includes(entry.configuredService.key)
|
|
4350
|
-
);
|
|
4351
|
-
if (!firstService?.project || !firstService.environment) {
|
|
4352
|
-
await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, null);
|
|
4353
|
-
return;
|
|
4354
|
-
}
|
|
4355
|
-
await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, firstService.project.id);
|
|
4356
|
-
let postgresService;
|
|
4357
|
-
try {
|
|
4358
|
-
postgresService = (await ensureRailwayPostgresService({
|
|
4359
|
-
projectId: firstService.project.id,
|
|
4360
|
-
environmentId: firstService.environment.id,
|
|
4361
|
-
serviceName,
|
|
4362
|
-
env: topology.env
|
|
4363
|
-
})).service;
|
|
4364
|
-
} catch (error) {
|
|
4365
|
-
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
4366
|
-
throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. ${detail}`);
|
|
4367
|
-
}
|
|
4368
|
-
if (!postgresService) {
|
|
4369
|
-
throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. No service was returned.`);
|
|
4370
|
-
}
|
|
4371
|
-
try {
|
|
4372
|
-
const postgresVolume = await ensureRailwayServiceVolume({
|
|
4373
|
-
projectId: firstService.project.id,
|
|
4374
|
-
environmentId: firstService.environment.id,
|
|
4375
|
-
serviceId: postgresService.id,
|
|
4376
|
-
name: `${serviceName}-volume`,
|
|
4377
|
-
mountPath: "/var/lib/postgresql/data",
|
|
4378
|
-
env: topology.env
|
|
4379
|
-
});
|
|
4380
|
-
if (postgresVolume.instance?.serviceId !== postgresService.id || postgresVolume.instance?.environmentId !== firstService.environment.id || postgresVolume.instance?.mountPath !== "/var/lib/postgresql/data") {
|
|
4381
|
-
throw new Error(`observed volume ${postgresVolume.volume.name ?? postgresVolume.volume.id ?? "(unknown)"} is not attached to ${serviceName} at /var/lib/postgresql/data`);
|
|
4382
|
-
}
|
|
4383
|
-
} catch (error) {
|
|
4384
|
-
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
4385
|
-
throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name for ${serviceName}: ${detail}`);
|
|
4386
|
-
}
|
|
4387
|
-
const services = await listRailwayServices({ projectId: firstService.project.id, env: topology.env });
|
|
4388
|
-
for (const service of services) {
|
|
4389
|
-
if (service.id === postgresService.id || service.name === serviceName) {
|
|
4390
|
-
continue;
|
|
4391
|
-
}
|
|
4392
|
-
const variables = await listRailwayVariables({
|
|
4393
|
-
projectId: firstService.project.id,
|
|
4394
|
-
environmentId: firstService.environment.id,
|
|
4395
|
-
serviceId: service.id,
|
|
4396
|
-
env: topology.env
|
|
4397
|
-
}).catch(() => ({}));
|
|
4398
|
-
const looksLikePostgres = typeof variables.DATABASE_URL === "string" && typeof variables.PGHOST === "string" && typeof variables.PGUSER === "string" && typeof variables.PGPASSWORD === "string";
|
|
4399
|
-
if (looksLikePostgres) {
|
|
4400
|
-
traceRailwayReconcile(topology.env, "sync:postgres:block-duplicate", `${service.name}:${service.id}`);
|
|
4401
|
-
recordRailwayProviderDrift(input, scope, {
|
|
4402
|
-
kind: "railway.duplicate-postgres-service",
|
|
4403
|
-
action: "manual-repair-required",
|
|
4404
|
-
status: "blocked",
|
|
4405
|
-
projectId: firstService.project.id,
|
|
4406
|
-
environmentId: firstService.environment.id,
|
|
4407
|
-
serviceId: service.id,
|
|
4408
|
-
serviceName: service.name,
|
|
4409
|
-
reason: `Railway reports an extra PostgreSQL service in ${firstService.project.name}. Reconciliation refuses to delete existing services; adopt, retain, or remove it through an explicit destroy workflow.`
|
|
4410
|
-
});
|
|
4411
|
-
}
|
|
4412
|
-
}
|
|
4413
|
-
const detachedPostgresVolumes = await listRailwayVolumes({
|
|
4414
|
-
projectId: firstService.project.id,
|
|
4415
|
-
env: topology.env
|
|
4416
|
-
});
|
|
4417
|
-
for (const volume of detachedPostgresVolumes) {
|
|
4418
|
-
if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
|
|
4419
|
-
traceRailwayReconcile(topology.env, "sync:postgres:retain-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
|
|
4420
|
-
continue;
|
|
4421
|
-
}
|
|
4422
|
-
const postgresInstances = volume.instances.filter(
|
|
4423
|
-
(instance) => instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
|
|
4424
|
-
);
|
|
4425
|
-
const activePostgresInstances = postgresInstances.filter((instance) => Boolean(instance.serviceId));
|
|
4426
|
-
const attachedToCanonical = postgresInstances.some((instance) => instance.serviceId === postgresService.id);
|
|
4427
|
-
const detached = postgresInstances.length > 0 && postgresInstances.every((instance) => !instance.serviceId);
|
|
4428
|
-
const detachedCanonicalVolume = volume.name === `${serviceName}-volume` && activePostgresInstances.length === 0;
|
|
4429
|
-
if ((detached || detachedCanonicalVolume) && !attachedToCanonical) {
|
|
4430
|
-
traceRailwayReconcile(topology.env, "sync:postgres:block-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
|
|
4431
|
-
recordRailwayProviderDrift(input, scope, {
|
|
4432
|
-
kind: "railway.detached-postgres-volume",
|
|
4433
|
-
action: "manual-repair-required",
|
|
4434
|
-
status: "blocked",
|
|
4435
|
-
projectId: firstService.project.id,
|
|
4436
|
-
environmentId: firstService.environment.id,
|
|
4437
|
-
volumeId: volume.id,
|
|
4438
|
-
volumeName: volume.name ?? null,
|
|
4439
|
-
reason: "Railway reports a detached PostgreSQL volume. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow."
|
|
4440
|
-
});
|
|
4441
|
-
}
|
|
4442
|
-
}
|
|
4443
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
4444
|
-
const existingVolumes = await listRailwayVolumes({
|
|
4445
|
-
projectId: firstService.project.id,
|
|
4446
|
-
env: topology.env
|
|
4447
|
-
});
|
|
4448
|
-
const attached = existingVolumes.some((volume) => volume.instances.some(
|
|
4449
|
-
(instance) => instance.serviceId === postgresService.id && instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
|
|
4450
|
-
));
|
|
4451
|
-
if (attached) {
|
|
4452
|
-
return;
|
|
4453
|
-
}
|
|
4454
|
-
await new Promise((resolve2) => setTimeout(resolve2, 3e3));
|
|
4455
|
-
}
|
|
4456
|
-
}
|
|
4457
|
-
async function reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, ownerProjectId) {
|
|
4458
|
-
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
4459
|
-
const projectEntries = [...topology.services.values()].filter((entry) => entry.project?.id && entry.environment?.id).map((entry) => ({ project: entry.project, environment: entry.environment }));
|
|
4460
|
-
const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
|
|
4461
|
-
for (const { project, environment } of projects.values()) {
|
|
4462
|
-
if (ownerProjectId && project.id === ownerProjectId) {
|
|
4463
|
-
continue;
|
|
4464
|
-
}
|
|
4465
|
-
const services = await listRailwayServices({ projectId: project.id, env: topology.env }).catch(() => []);
|
|
4466
|
-
for (const service of services.filter((entry) => entry.name === serviceName)) {
|
|
4467
|
-
traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-service", `${project.name}:${service.name}:${service.id}`);
|
|
4468
|
-
recordRailwayProviderDrift(input, scope, {
|
|
4469
|
-
kind: "railway.accidental-market-database-service",
|
|
4470
|
-
action: "manual-repair-required",
|
|
4471
|
-
status: "blocked",
|
|
4472
|
-
projectId: project.id,
|
|
4473
|
-
environmentId: environment.id,
|
|
4474
|
-
serviceId: service.id,
|
|
4475
|
-
serviceName: service.name,
|
|
4476
|
-
reason: `Railway reports undeclared Market database service ${service.name} in non-owner project ${project.name}. Reconciliation refuses to delete existing services; adopt, retain, or remove it through an explicit destroy workflow.`
|
|
4477
|
-
});
|
|
4478
|
-
}
|
|
4479
|
-
const volumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
|
|
4480
|
-
for (const volume of volumes) {
|
|
4481
|
-
if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
|
|
4482
|
-
continue;
|
|
4483
|
-
}
|
|
4484
|
-
const postgresInstances = volume.instances.filter(
|
|
4485
|
-
(instance) => instance.environmentId === environment.id && instance.mountPath === "/var/lib/postgresql/data"
|
|
4486
|
-
);
|
|
4487
|
-
if (postgresInstances.length === 0) {
|
|
4488
|
-
continue;
|
|
4489
|
-
}
|
|
4490
|
-
const activeServiceIds = new Set(services.map((service) => service.id));
|
|
4491
|
-
const stillAttached = postgresInstances.some((instance) => instance.serviceId && activeServiceIds.has(instance.serviceId));
|
|
4492
|
-
if (stillAttached) {
|
|
4493
|
-
continue;
|
|
4494
|
-
}
|
|
4495
|
-
traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-volume", `${project.name}:${volume.name ?? volume.id}:${volume.id}`);
|
|
4496
|
-
recordRailwayProviderDrift(input, scope, {
|
|
4497
|
-
kind: "railway.accidental-market-database-volume",
|
|
4498
|
-
action: "manual-repair-required",
|
|
4499
|
-
status: "blocked",
|
|
4500
|
-
projectId: project.id,
|
|
4501
|
-
environmentId: environment.id,
|
|
4502
|
-
volumeId: volume.id,
|
|
4503
|
-
volumeName: volume.name ?? null,
|
|
4504
|
-
reason: `Railway reports undeclared Market database volume ${volume.name ?? volume.id} in non-owner project ${project.name}. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow.`
|
|
4505
|
-
});
|
|
4506
|
-
}
|
|
4507
|
-
}
|
|
4508
|
-
}
|
|
4509
|
-
function isRetainedRailwayDetachedPostgresVolume(value) {
|
|
4510
|
-
return String(value ?? "").trim().startsWith("retained-");
|
|
4511
|
-
}
|
|
4512
|
-
async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
4231
|
+
async function observeRailwayUnit(input, {
|
|
4232
|
+
refresh = false,
|
|
4233
|
+
topology: providedTopology
|
|
4234
|
+
} = {}) {
|
|
4513
4235
|
let attempt = 0;
|
|
4514
4236
|
for (; ; ) {
|
|
4515
4237
|
try {
|
|
@@ -4517,13 +4239,13 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
|
4517
4239
|
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
4518
4240
|
const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
4519
4241
|
const persisted = state.services?.[serviceKey] ?? {};
|
|
4520
|
-
const topology = await
|
|
4242
|
+
const topology = providedTopology ?? await resolveRailwayUnitTopology(input, scope, {
|
|
4521
4243
|
refresh,
|
|
4522
|
-
serviceKeys: [serviceKey],
|
|
4523
4244
|
includeInstances: false,
|
|
4524
|
-
includeVariables: false
|
|
4245
|
+
includeVariables: false,
|
|
4246
|
+
cacheSuffix: refresh ? "refresh" : "observe"
|
|
4525
4247
|
});
|
|
4526
|
-
const entry = topology
|
|
4248
|
+
const entry = findRailwayTopologyEntry(topology, railwayUnitServiceIdentity(input));
|
|
4527
4249
|
const configuredService = entry?.configuredService ?? null;
|
|
4528
4250
|
const usesPortableSource = Boolean(
|
|
4529
4251
|
configuredService?.imageRef || configuredService?.sourceMode === "git" && configuredService?.sourceRepo
|
|
@@ -4876,8 +4598,7 @@ async function observeCustomDomainUnit(input) {
|
|
|
4876
4598
|
}
|
|
4877
4599
|
case "custom-domain:api": {
|
|
4878
4600
|
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
4879
|
-
const
|
|
4880
|
-
const live = (cached && railwayCustomDomainHasDnsRequirements(cached) ? cached : null) ?? await observeRailwayCustomDomainLive(input, domain) ?? cached ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
|
|
4601
|
+
const live = await observeRailwayCustomDomainLive(input, domain);
|
|
4881
4602
|
if (live?.domain) {
|
|
4882
4603
|
storeCustomDomainState(input, "railway", domain, live);
|
|
4883
4604
|
}
|
|
@@ -4887,6 +4608,8 @@ async function observeCustomDomainUnit(input) {
|
|
|
4887
4608
|
live: live ?? {},
|
|
4888
4609
|
locators: {
|
|
4889
4610
|
domain: domain || null,
|
|
4611
|
+
serviceId: typeof live?.serviceId === "string" ? live.serviceId : null,
|
|
4612
|
+
serviceName: typeof live?.serviceName === "string" ? live.serviceName : null,
|
|
4890
4613
|
serviceDomain: typeof live?.serviceDomain === "string" ? live.serviceDomain : null
|
|
4891
4614
|
},
|
|
4892
4615
|
warnings: []
|
|
@@ -4938,8 +4661,7 @@ async function verifyCustomDomainUnit(input) {
|
|
|
4938
4661
|
}
|
|
4939
4662
|
case "custom-domain:api": {
|
|
4940
4663
|
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
4941
|
-
const
|
|
4942
|
-
const live = (cached && railwayCustomDomainHasDnsRequirements(cached) ? cached : null) ?? await observeRailwayCustomDomainLive(input, domain) ?? cached ?? getPersistedCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
|
|
4664
|
+
const live = await observeRailwayCustomDomainLive(input, domain);
|
|
4943
4665
|
const dnsRecords = Array.isArray(live?.dnsRecords) ? live.dnsRecords : [];
|
|
4944
4666
|
const certificateStatus = typeof live?.certificateStatus === "string" ? live.certificateStatus.trim().toUpperCase() : null;
|
|
4945
4667
|
const verified = live?.verified === true;
|
|
@@ -4955,6 +4677,13 @@ async function verifyCustomDomainUnit(input) {
|
|
|
4955
4677
|
observed: typeof live?.domain === "string" ? live.domain : null,
|
|
4956
4678
|
issues: live?.domain ? [] : [`Railway custom domain ${domain || "(unset)"} is missing.`]
|
|
4957
4679
|
}),
|
|
4680
|
+
verificationCheck("custom-domain.service", "Railway custom domain is attached to the desired service", "api", {
|
|
4681
|
+
exists: Boolean(live?.serviceId),
|
|
4682
|
+
configured: String(live?.serviceName ?? "") === String(input.unit.spec.serviceName ?? ""),
|
|
4683
|
+
expected: input.unit.spec.serviceName ?? null,
|
|
4684
|
+
observed: live?.serviceName ?? null,
|
|
4685
|
+
issues: String(live?.serviceName ?? "") === String(input.unit.spec.serviceName ?? "") ? [] : [`Railway custom domain ${domain || "(unset)"} is not attached to ${String(input.unit.spec.serviceName ?? "(unset service)")}.`]
|
|
4686
|
+
}),
|
|
4958
4687
|
verificationCheck("custom-domain.dns-requirements", "Railway custom domain exposes DNS requirements", "api", {
|
|
4959
4688
|
exists: hasDnsRequirements,
|
|
4960
4689
|
expected: true,
|
|
@@ -5043,30 +4772,72 @@ async function reconcileCustomDomainUnit(input, diff) {
|
|
|
5043
4772
|
}
|
|
5044
4773
|
case "custom-domain:api": {
|
|
5045
4774
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
4775
|
+
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
5046
4776
|
const serviceKey = String(input.unit.metadata.serviceKey ?? "api").trim();
|
|
5047
|
-
const topology = await
|
|
5048
|
-
|
|
5049
|
-
serviceKeys: [serviceKey],
|
|
4777
|
+
const topology = await resolveRailwayUnitTopology(input, scope, {
|
|
4778
|
+
refresh: false,
|
|
5050
4779
|
includeInstances: false,
|
|
5051
|
-
includeVariables: false
|
|
4780
|
+
includeVariables: false,
|
|
4781
|
+
cacheSuffix: "post-apply"
|
|
5052
4782
|
});
|
|
5053
|
-
const entry = topology
|
|
5054
|
-
if (!entry?.
|
|
5055
|
-
throw new Error(`Railway
|
|
4783
|
+
const entry = findRailwayTopologyEntry(topology, serviceKey);
|
|
4784
|
+
if (!entry?.project?.id || !entry.environment?.id || !entry.service?.id || !domain) {
|
|
4785
|
+
throw new Error(`Railway custom domain reconciliation could not resolve ${domain || "(unset domain)"} and its desired service.`);
|
|
5056
4786
|
}
|
|
5057
|
-
const
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
4787
|
+
const projectServices = await listRailwayServices({ projectId: entry.project.id, env: topology.env });
|
|
4788
|
+
const detachedServiceIds = [];
|
|
4789
|
+
for (const candidate of projectServices) {
|
|
4790
|
+
if (candidate.id === entry.service.id) continue;
|
|
4791
|
+
const domains = await listRailwayCustomDomains({
|
|
4792
|
+
projectId: entry.project.id,
|
|
4793
|
+
environmentId: entry.environment.id,
|
|
4794
|
+
serviceId: candidate.id,
|
|
4795
|
+
env: topology.env
|
|
4796
|
+
}).catch(() => []);
|
|
4797
|
+
for (const stale of domains.filter((candidateDomain) => candidateDomain.domain === domain)) {
|
|
4798
|
+
if (!stale.id) throw new Error(`Railway custom domain ${domain} is attached to ${candidate.name} but has no provider id for safe transfer.`);
|
|
4799
|
+
traceRailwayReconcile(topology.env, "sync:custom-domain:detach-stale", `${domain}:${candidate.name}`);
|
|
4800
|
+
await deleteRailwayCustomDomain({
|
|
4801
|
+
projectId: entry.project.id,
|
|
4802
|
+
environmentId: entry.environment.id,
|
|
4803
|
+
serviceId: candidate.id,
|
|
4804
|
+
domainId: stale.id,
|
|
4805
|
+
env: topology.env
|
|
4806
|
+
});
|
|
4807
|
+
detachedServiceIds.push(candidate.id);
|
|
5066
4808
|
}
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
4809
|
+
}
|
|
4810
|
+
for (const detachedServiceId of detachedServiceIds) {
|
|
4811
|
+
let stillAttached = true;
|
|
4812
|
+
for (let detachAttempt = 0; detachAttempt < 12 && stillAttached; detachAttempt += 1) {
|
|
4813
|
+
const domains = await listRailwayCustomDomains({
|
|
4814
|
+
projectId: entry.project.id,
|
|
4815
|
+
environmentId: entry.environment.id,
|
|
4816
|
+
serviceId: detachedServiceId,
|
|
4817
|
+
env: topology.env
|
|
4818
|
+
});
|
|
4819
|
+
stillAttached = domains.some((candidateDomain) => candidateDomain.domain === domain);
|
|
4820
|
+
if (stillAttached) sleepMs(2e3);
|
|
4821
|
+
}
|
|
4822
|
+
if (stillAttached) {
|
|
4823
|
+
throw new Error(`Railway custom domain ${domain} remained attached to stale service ${detachedServiceId} after deletion.`);
|
|
4824
|
+
}
|
|
4825
|
+
}
|
|
4826
|
+
await ensureRailwayCustomDomain({
|
|
4827
|
+
projectId: entry.project.id,
|
|
4828
|
+
environmentId: entry.environment.id,
|
|
4829
|
+
serviceId: entry.service.id,
|
|
4830
|
+
domain,
|
|
4831
|
+
env: topology.env
|
|
4832
|
+
});
|
|
4833
|
+
let observed = await observeCustomDomainUnit(input);
|
|
4834
|
+
for (let domainAttempt = 0; domainAttempt < 12 && (!observed.exists || observed.locators.serviceId !== entry.service.id); domainAttempt += 1) {
|
|
4835
|
+
sleepMs(2e3);
|
|
4836
|
+
observed = await observeCustomDomainUnit(input);
|
|
4837
|
+
}
|
|
4838
|
+
if (!observed.exists || observed.locators.serviceId !== entry.service.id) {
|
|
4839
|
+
throw new Error(`Railway did not attach custom domain ${domain} to ${entry.service.name}.`);
|
|
4840
|
+
}
|
|
5070
4841
|
return {
|
|
5071
4842
|
unit: input.unit,
|
|
5072
4843
|
observed,
|
|
@@ -5074,7 +4845,7 @@ async function reconcileCustomDomainUnit(input, diff) {
|
|
|
5074
4845
|
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
5075
4846
|
warnings: observed.warnings,
|
|
5076
4847
|
resourceLocators: observed.locators,
|
|
5077
|
-
state,
|
|
4848
|
+
state: observed.live,
|
|
5078
4849
|
verification: null
|
|
5079
4850
|
};
|
|
5080
4851
|
}
|
|
@@ -5123,13 +4894,13 @@ async function verifyRailwayUnit(input) {
|
|
|
5123
4894
|
try {
|
|
5124
4895
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
5125
4896
|
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
5126
|
-
const topology = await
|
|
5127
|
-
serviceKeys: [serviceKey],
|
|
4897
|
+
const topology = await resolveRailwayUnitTopology(input, scope, {
|
|
5128
4898
|
includeInstances: true,
|
|
5129
4899
|
includeVariables: true,
|
|
5130
|
-
refresh: true
|
|
4900
|
+
refresh: true,
|
|
4901
|
+
cacheSuffix: "verify"
|
|
5131
4902
|
});
|
|
5132
|
-
const entry = topology
|
|
4903
|
+
const entry = findRailwayTopologyEntry(topology, railwayUnitServiceIdentity(input));
|
|
5133
4904
|
const service = entry?.configuredService ?? null;
|
|
5134
4905
|
if (!service || !entry) {
|
|
5135
4906
|
return summarizeVerification(input.unit.unitId, [
|
|
@@ -5322,19 +5093,28 @@ async function verifyRailwayUnit(input) {
|
|
|
5322
5093
|
const volumes = entry.project?.id ? await listRailwayVolumes({ projectId: entry.project.id, env: topology.env }) : [];
|
|
5323
5094
|
const expectedServiceId = entry.service?.id ?? null;
|
|
5324
5095
|
const expectedEnvironmentId = entry.environment?.id ?? null;
|
|
5325
|
-
const
|
|
5096
|
+
const mountedVolumes = volumes.filter((volume) => volume.instances.some(
|
|
5326
5097
|
(instance) => instance.serviceId === expectedServiceId && instance.environmentId === expectedEnvironmentId && instance.mountPath === service.volumeMountPath && !instance.isPendingDeletion && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && !["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
|
|
5327
|
-
))
|
|
5098
|
+
));
|
|
5099
|
+
const expectedVolumeName = `${service.serviceName}-volume`;
|
|
5100
|
+
const mountedVolume = mountedVolumes.find((volume) => volume.name === expectedVolumeName) ?? null;
|
|
5101
|
+
const volumeIssues = [
|
|
5102
|
+
mountedVolume ? null : `Railway service ${service.serviceName ?? service.key} is not mounted to canonical volume ${expectedVolumeName} at ${service.volumeMountPath}.`,
|
|
5103
|
+
mountedVolumes.length === 1 ? null : `Railway service ${service.serviceName ?? service.key} has ${mountedVolumes.length} active volumes mounted at ${service.volumeMountPath}; expected exactly one.`
|
|
5104
|
+
].filter((issue) => Boolean(issue));
|
|
5328
5105
|
checks.push(verificationCheck("railway.volume:data", "Railway service has persistent data volume mounted", "api", {
|
|
5329
|
-
exists:
|
|
5330
|
-
configured:
|
|
5331
|
-
|
|
5106
|
+
exists: mountedVolumes.length > 0,
|
|
5107
|
+
configured: volumeIssues.length === 0,
|
|
5108
|
+
ready: volumeIssues.length === 0,
|
|
5109
|
+
verified: volumeIssues.length === 0,
|
|
5110
|
+
expected: { name: expectedVolumeName, mountPath: service.volumeMountPath, count: 1 },
|
|
5332
5111
|
observed: mountedVolume ? {
|
|
5333
5112
|
name: mountedVolume.name,
|
|
5334
5113
|
mountPath: service.volumeMountPath,
|
|
5114
|
+
count: mountedVolumes.length,
|
|
5335
5115
|
source: "api"
|
|
5336
|
-
} :
|
|
5337
|
-
issues:
|
|
5116
|
+
} : mountedVolumes.map((volume) => ({ name: volume.name, mountPath: service.volumeMountPath })),
|
|
5117
|
+
issues: volumeIssues
|
|
5338
5118
|
}));
|
|
5339
5119
|
}
|
|
5340
5120
|
for (const [key, value] of Object.entries(sync.variables)) {
|
|
@@ -5382,7 +5162,7 @@ async function verifyRailwayUnit(input) {
|
|
|
5382
5162
|
}
|
|
5383
5163
|
function railwayVerificationMaySettle(verification) {
|
|
5384
5164
|
return verification.checks.some(
|
|
5385
|
-
(check) => !check.verified && (check.key === "railway.instance" || check.key.startsWith("railway.instance.") || check.key === "railway.volume:data")
|
|
5165
|
+
(check) => !check.verified && (check.key === "railway.instance" || check.key.startsWith("railway.instance.") || check.key === "railway.service.source-mode" || check.key === "railway.volume:data")
|
|
5386
5166
|
);
|
|
5387
5167
|
}
|
|
5388
5168
|
function railwayStartCommandMatches(serviceKey, observed, expected) {
|
|
@@ -5409,13 +5189,54 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
|
|
|
5409
5189
|
}
|
|
5410
5190
|
return remainder === normalizedExpected;
|
|
5411
5191
|
}
|
|
5412
|
-
function
|
|
5192
|
+
function railwayProjectServiceKeys(input, scope, configuredService) {
|
|
5193
|
+
return configuredRailwayServicesForInput(input, scope).filter((candidate) => candidate.projectName === configuredService.projectName && candidate.railwayEnvironment === configuredService.railwayEnvironment).map((candidate) => candidate.key);
|
|
5194
|
+
}
|
|
5195
|
+
function railwayUnitServiceIdentity(input) {
|
|
5196
|
+
return String(input.unit.metadata.serviceInstanceKey ?? input.unit.spec.serviceName ?? input.unit.metadata.serviceKey ?? "").trim();
|
|
5197
|
+
}
|
|
5198
|
+
function configuredRailwayServiceForUnit(input, scope) {
|
|
5199
|
+
const identity = railwayUnitServiceIdentity(input);
|
|
5200
|
+
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
5201
|
+
return configuredRailwayServicesForInput(input, scope).find((candidate) => candidate.instanceKey === identity || candidate.serviceName === identity || candidate.key === identity || candidate.key === serviceKey) ?? null;
|
|
5202
|
+
}
|
|
5203
|
+
function findRailwayTopologyEntry(topology, identity) {
|
|
5204
|
+
return topology.services.get(identity) ?? [...topology.services.values()].find((entry) => entry.configuredService.instanceKey === identity || entry.configuredService.serviceName === identity || entry.configuredService.key === identity) ?? null;
|
|
5205
|
+
}
|
|
5206
|
+
async function resolveRailwayUnitTopology(input, scope, options) {
|
|
5207
|
+
const configuredService = configuredRailwayServiceForUnit(input, scope);
|
|
5208
|
+
const serviceKeys = configuredService ? railwayProjectServiceKeys(input, scope, configuredService) : [String(input.unit.metadata.serviceKey ?? "").trim()];
|
|
5209
|
+
const projectKey = configuredService ? `${configuredService.projectName}:${configuredService.railwayEnvironment}` : serviceKeys.join(",");
|
|
5210
|
+
const load = () => resolveRailwayTopologyForScope(input, scope, {
|
|
5211
|
+
refresh: options.refresh,
|
|
5212
|
+
serviceKeys,
|
|
5213
|
+
includeInstances: options.includeInstances,
|
|
5214
|
+
includeVariables: options.includeVariables
|
|
5215
|
+
});
|
|
5216
|
+
return options.cacheSuffix ? await providerCache(input, `railway:unit-topology:${scope}:${projectKey}:${options.cacheSuffix}`, load) : await load();
|
|
5217
|
+
}
|
|
5218
|
+
async function buildRailwayDiff(input, observed) {
|
|
5219
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
5220
|
+
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
5221
|
+
const configuredService = configuredRailwayServiceForUnit(input, scope);
|
|
5222
|
+
const projectServiceKeys = configuredService ? railwayProjectServiceKeys(input, scope, configuredService) : [];
|
|
5223
|
+
const projectPlan = configuredService ? await providerCache(input, `railway:plan:${scope}:${configuredService.projectName}:${configuredService.railwayEnvironment}`, () => syncRailwayEnvironmentForScope(input, { planOnly: true, serviceKeys: projectServiceKeys })) : null;
|
|
5224
|
+
const plannedChanges = projectPlan?.projectPlans?.flatMap((plan) => plan.changes) ?? [];
|
|
5225
|
+
const planReasons = plannedChanges.map((change) => change.summary || `${change.kind} ${change.path}`);
|
|
5413
5226
|
if (!observed.exists) {
|
|
5414
5227
|
return {
|
|
5415
5228
|
action: "create",
|
|
5416
|
-
reasons: ["service missing from configured topology"],
|
|
5229
|
+
reasons: ["service missing from configured topology", ...planReasons],
|
|
5417
5230
|
before: observed.live,
|
|
5418
|
-
after: input.unit.spec
|
|
5231
|
+
after: { ...input.unit.spec, railwayProjectChanges: plannedChanges }
|
|
5232
|
+
};
|
|
5233
|
+
}
|
|
5234
|
+
if (plannedChanges.length > 0) {
|
|
5235
|
+
return {
|
|
5236
|
+
action: "update",
|
|
5237
|
+
reasons: planReasons,
|
|
5238
|
+
before: observed.live,
|
|
5239
|
+
after: { ...input.unit.spec, railwayProjectChanges: plannedChanges }
|
|
5419
5240
|
};
|
|
5420
5241
|
}
|
|
5421
5242
|
return {
|
|
@@ -5428,9 +5249,9 @@ function buildRailwayDiff(input, observed) {
|
|
|
5428
5249
|
async function reconcileRailwayUnit(input, diff) {
|
|
5429
5250
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
5430
5251
|
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
5431
|
-
const configuredService =
|
|
5252
|
+
const configuredService = configuredRailwayServiceForUnit(input, scope);
|
|
5432
5253
|
const requiresProjectLevelSync = Boolean(configuredService);
|
|
5433
|
-
const serviceKeys =
|
|
5254
|
+
const serviceKeys = configuredService ? railwayProjectServiceKeys(input, scope, configuredService) : serviceKey ? [serviceKey] : void 0;
|
|
5434
5255
|
const cacheKey = requiresProjectLevelSync ? `railway:sync:${scope}:project:${configuredService?.projectName ?? "default"}:${configuredService?.railwayEnvironment ?? scope}` : `railway:sync:${scope}:${serviceKey || "all"}`;
|
|
5435
5256
|
let attempt = 0;
|
|
5436
5257
|
for (; ; ) {
|
|
@@ -5441,11 +5262,6 @@ async function reconcileRailwayUnit(input, diff) {
|
|
|
5441
5262
|
});
|
|
5442
5263
|
break;
|
|
5443
5264
|
} catch (error) {
|
|
5444
|
-
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
5445
|
-
if (attempt >= 2 && /Problem processing request/iu.test(message)) {
|
|
5446
|
-
traceRailwayReconcile(buildRailwayEnv(input, scope), "sync:skipped-provider-error", `${serviceKey || "all"}:problem-processing-request`);
|
|
5447
|
-
break;
|
|
5448
|
-
}
|
|
5449
5265
|
if (attempt >= 2 || !isTransientRailwayReconcileError(error)) {
|
|
5450
5266
|
throw error;
|
|
5451
5267
|
}
|
|
@@ -5453,12 +5269,20 @@ async function reconcileRailwayUnit(input, diff) {
|
|
|
5453
5269
|
sleepMs(1e3 * attempt);
|
|
5454
5270
|
}
|
|
5455
5271
|
}
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5272
|
+
const postApplyTopology = await providerCache(input, `${cacheKey}:post-apply-topology`, async () => {
|
|
5273
|
+
for (const key of input.context.session.keys()) {
|
|
5274
|
+
if (key.startsWith(`railway:topology:${scope}:`) || key.startsWith(`railway:unit-topology:${scope}:`)) {
|
|
5275
|
+
input.context.session.delete(key);
|
|
5276
|
+
}
|
|
5459
5277
|
}
|
|
5460
|
-
|
|
5461
|
-
|
|
5278
|
+
return resolveRailwayUnitTopology(input, scope, {
|
|
5279
|
+
refresh: true,
|
|
5280
|
+
includeInstances: false,
|
|
5281
|
+
includeVariables: false,
|
|
5282
|
+
cacheSuffix: "post-apply"
|
|
5283
|
+
});
|
|
5284
|
+
});
|
|
5285
|
+
const refreshed = await observeRailwayUnit(input, { topology: await postApplyTopology });
|
|
5462
5286
|
return {
|
|
5463
5287
|
unit: input.unit,
|
|
5464
5288
|
observed: refreshed,
|
|
@@ -5491,7 +5315,7 @@ async function destroyRailwayUnit(input) {
|
|
|
5491
5315
|
);
|
|
5492
5316
|
if (!attached) continue;
|
|
5493
5317
|
traceRailwayReconcile(env, "destroy:volume", `${serviceName}:${volume.name ?? volume.id}:${volume.id}`);
|
|
5494
|
-
const result = await deleteRailwayVolume({ volumeId: volume.id, env });
|
|
5318
|
+
const result = await deleteRailwayVolume({ projectId, environmentId, volumeId: volume.id, env });
|
|
5495
5319
|
deletedVolumes.push({
|
|
5496
5320
|
id: volume.id,
|
|
5497
5321
|
name: volume.name ?? null,
|
|
@@ -5502,7 +5326,7 @@ async function destroyRailwayUnit(input) {
|
|
|
5502
5326
|
let deletedService = null;
|
|
5503
5327
|
if (serviceId) {
|
|
5504
5328
|
traceRailwayReconcile(env, "destroy:service", `${serviceName}:${serviceId}`);
|
|
5505
|
-
deletedService = await deleteRailwayService({ serviceId, env });
|
|
5329
|
+
deletedService = await deleteRailwayService({ projectId, environmentId, serviceId, env });
|
|
5506
5330
|
}
|
|
5507
5331
|
for (const key of input.context.session.keys()) {
|
|
5508
5332
|
if (key.startsWith(`railway:topology:${scope}:`)) {
|
|
@@ -5595,8 +5419,19 @@ function buildCustomDomainAdapter(unitType, providerId) {
|
|
|
5595
5419
|
diff(input) {
|
|
5596
5420
|
return buildAttachmentDiff(input, input.observed);
|
|
5597
5421
|
},
|
|
5598
|
-
apply(input) {
|
|
5599
|
-
|
|
5422
|
+
async apply(input) {
|
|
5423
|
+
let attempt = 0;
|
|
5424
|
+
for (; ; ) {
|
|
5425
|
+
try {
|
|
5426
|
+
return await reconcileCustomDomainUnit(input, input.diff);
|
|
5427
|
+
} catch (error) {
|
|
5428
|
+
if (providerId !== "railway" || attempt >= 2 || !isTransientRailwayReconcileError(error)) throw error;
|
|
5429
|
+
attempt += 1;
|
|
5430
|
+
process.stderr.write(`[trsd][railway][custom-domain] domain=${String(input.unit.spec.domain ?? "")} retry=${attempt}/2
|
|
5431
|
+
`);
|
|
5432
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3 * attempt));
|
|
5433
|
+
}
|
|
5434
|
+
}
|
|
5600
5435
|
},
|
|
5601
5436
|
verify(input) {
|
|
5602
5437
|
return verifyCustomDomainUnit(input);
|