@treeseed/sdk 0.10.28 → 0.12.4
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 +213 -7
- package/dist/agent-capacity.d.ts +916 -0
- package/dist/agent-capacity.js +1600 -0
- package/dist/agent-tools.d.ts +37 -0
- package/dist/agent-tools.js +301 -0
- package/dist/api/app.d.ts +1 -1
- package/dist/api/auth/d1-database.d.ts +2 -2
- package/dist/api/auth/d1-provider.d.ts +4 -4
- package/dist/api/auth/d1-store.d.ts +2 -2
- package/dist/api/auth/memory-provider.d.ts +1 -1
- package/dist/api/auth/tokens.d.ts +1 -1
- package/dist/api/config.d.ts +3 -1
- package/dist/api/config.js +34 -10
- package/dist/api/http.d.ts +2 -2
- package/dist/api/index.d.ts +10 -10
- package/dist/api/index.js +4 -2
- package/dist/api/operations-routes.d.ts +3 -3
- package/dist/api/operations.d.ts +1 -1
- package/dist/api/providers.d.ts +1 -1
- package/dist/api/providers.js +21 -4
- package/dist/api/railway.d.ts +3 -2
- package/dist/api/sdk-dispatch.d.ts +4 -4
- package/dist/api/sdk-routes.d.ts +2 -2
- package/dist/api/templates.d.ts +2 -2
- package/dist/api/types.d.ts +3 -2
- package/dist/capacity-provider.d.ts +118 -59
- package/dist/capacity-provider.js +235 -43
- package/dist/capacity.d.ts +2 -2
- package/dist/cli-tools.d.ts +1 -1
- package/dist/content-operations.d.ts +97 -0
- package/dist/content-operations.js +250 -0
- package/dist/content-store.d.ts +4 -4
- package/dist/control-plane-client.d.ts +13 -18
- package/dist/control-plane-client.js +8 -86
- package/dist/control-plane.d.ts +3 -2
- package/dist/control-plane.js +38 -13
- package/dist/copilot.d.ts +3 -0
- package/dist/copilot.js +16 -1
- package/dist/d1-http.d.ts +1 -1
- package/dist/d1-store.d.ts +32 -57
- package/dist/d1-store.js +115 -318
- package/dist/db/d1.d.ts +1 -232
- package/dist/db/index.d.ts +3 -3
- package/dist/db/market-schema.d.ts +50900 -11018
- package/dist/db/market-schema.js +2313 -467
- package/dist/db/node-sqlite.d.ts +1 -232
- package/dist/db/node-sqlite.js +7 -2
- package/dist/db/schema.d.ts +0 -462
- package/dist/db/schema.js +2 -22
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.js +0 -11
- package/dist/fixture-support.js +52 -4
- package/dist/governance.d.ts +133 -0
- package/dist/governance.js +285 -0
- package/dist/graph/build.d.ts +2 -2
- package/dist/graph/context-query-contracts.d.ts +1 -1
- package/dist/graph/dsl.d.ts +1 -1
- package/dist/graph/query.d.ts +4 -4
- package/dist/graph/ranking.d.ts +1 -1
- package/dist/graph/schema.d.ts +1 -1
- package/dist/graph.d.ts +11 -11
- package/dist/guarantees/index.d.ts +425 -0
- package/dist/guarantees/index.js +1146 -0
- 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 +602 -0
- package/dist/hosting/contracts.d.ts +208 -0
- package/dist/hosting/contracts.js +0 -0
- package/dist/hosting/graph.d.ts +195 -0
- package/dist/hosting/graph.js +1092 -0
- package/dist/hosting/index.d.ts +4 -0
- package/dist/hosting/index.js +4 -0
- package/dist/index.d.ts +88 -67
- package/dist/index.js +142 -14
- package/dist/local-dev/managed-dev.d.ts +85 -0
- package/dist/local-dev/managed-dev.js +368 -0
- package/dist/managed-dependencies.d.ts +9 -0
- package/dist/managed-dependencies.js +10 -9
- package/dist/market-client.d.ts +327 -9
- package/dist/market-client.js +464 -20
- package/dist/model-registry.d.ts +1 -1
- package/dist/model-registry.js +19 -19
- package/dist/operations/agent-tools.d.ts +2 -11
- package/dist/operations/agent-tools.js +2 -14
- package/dist/operations/providers/default.d.ts +1 -1
- package/dist/operations/providers/default.js +20 -37
- package/dist/operations/repository-operations.d.ts +26 -2
- package/dist/operations/repository-operations.js +159 -1
- package/dist/operations/runtime.d.ts +3 -3
- package/dist/operations/services/bootstrap-runner.d.ts +4 -2
- package/dist/operations/services/bootstrap-runner.js +22 -2
- package/dist/operations/services/build-warning-policy.d.ts +34 -0
- package/dist/operations/services/build-warning-policy.js +81 -82
- package/dist/operations/services/commit-message-provider.js +4 -4
- package/dist/operations/services/config-runtime.d.ts +83 -24
- package/dist/operations/services/config-runtime.js +449 -130
- package/dist/operations/services/deploy.d.ts +106 -624
- package/dist/operations/services/deploy.js +746 -263
- package/dist/operations/services/deployment-readiness.d.ts +30 -0
- package/dist/operations/services/deployment-readiness.js +206 -0
- package/dist/operations/services/git-remote-policy.js +12 -4
- package/dist/operations/services/git-runner.d.ts +66 -0
- package/dist/operations/services/git-runner.js +479 -0
- package/dist/operations/services/git-workflow.d.ts +45 -6
- package/dist/operations/services/git-workflow.js +251 -23
- package/dist/operations/services/github-actions-verification.d.ts +4 -1
- package/dist/operations/services/github-actions-verification.js +12 -3
- package/dist/operations/services/github-api.js +22 -6
- package/dist/operations/services/github-automation.d.ts +4 -4
- package/dist/operations/services/github-automation.js +19 -21
- package/dist/operations/services/github-credentials.d.ts +13 -0
- package/dist/operations/services/github-credentials.js +59 -0
- package/dist/operations/services/hosted-service-checks.d.ts +81 -0
- package/dist/operations/services/hosted-service-checks.js +478 -0
- package/dist/operations/services/hosting-audit.d.ts +2 -2
- package/dist/operations/services/hosting-audit.js +7 -7
- package/dist/operations/services/hub-launch.d.ts +1 -1
- package/dist/operations/services/hub-provider-launch.d.ts +5 -5
- package/dist/operations/services/hub-provider-launch.js +22 -17
- package/dist/operations/services/live-hosted-service-checks.d.ts +26 -0
- package/dist/operations/services/live-hosted-service-checks.js +463 -0
- package/dist/operations/services/local-dev.js +0 -1
- package/dist/operations/services/managed-host-security.d.ts +1 -1
- package/dist/operations/services/managed-host-security.js +1 -1
- package/dist/operations/services/managed-repositories.d.ts +37 -0
- package/dist/operations/services/managed-repositories.js +177 -0
- package/dist/operations/services/operations-runner-smoke.d.ts +31 -0
- package/dist/operations/services/operations-runner-smoke.js +198 -0
- package/dist/operations/services/package-adapters.d.ts +170 -0
- package/dist/operations/services/package-adapters.js +859 -0
- package/dist/operations/services/package-reference-policy.d.ts +13 -88
- package/dist/operations/services/package-reference-policy.js +96 -215
- package/dist/operations/services/project-host-operations.d.ts +4 -4
- package/dist/operations/services/project-platform.d.ts +57 -173
- package/dist/operations/services/project-platform.js +80 -111
- package/dist/operations/services/project-web-monitor.d.ts +2 -2
- package/dist/operations/services/project-web-monitor.js +122 -5
- package/dist/operations/services/railway-api.d.ts +117 -5
- package/dist/operations/services/railway-api.js +915 -93
- package/dist/operations/services/railway-deploy.d.ts +30 -112
- package/dist/operations/services/railway-deploy.js +521 -961
- package/dist/operations/services/release-history.js +3 -4
- package/dist/operations/services/release-proof-ledger.d.ts +13 -0
- package/dist/operations/services/release-proof-ledger.js +81 -0
- package/dist/operations/services/release-proof-planner.d.ts +48 -0
- package/dist/operations/services/release-proof-planner.js +195 -0
- package/dist/operations/services/release-proof-runner.d.ts +16 -0
- package/dist/operations/services/release-proof-runner.js +169 -0
- package/dist/operations/services/release-proof.d.ts +81 -0
- package/dist/operations/services/release-proof.js +79 -0
- package/dist/operations/services/repository-save-orchestrator.d.ts +8 -7
- package/dist/operations/services/repository-save-orchestrator.js +604 -212
- package/dist/operations/services/runtime-paths.d.ts +1 -3
- package/dist/operations/services/runtime-paths.js +0 -4
- package/dist/operations/services/runtime-tools.d.ts +1 -191
- package/dist/operations/services/runtime-tools.js +4 -443
- package/dist/operations/services/save-deploy-preflight.js +9 -1
- package/dist/operations/services/template-host-bindings.d.ts +2 -2
- package/dist/operations/services/template-host-bindings.js +3 -2
- package/dist/operations/services/template-registry.d.ts +6 -6
- package/dist/operations/services/template-registry.js +33 -14
- package/dist/operations/services/template-secret-sync.d.ts +3 -3
- package/dist/operations/services/verification-cache.d.ts +25 -0
- package/dist/operations/services/verification-cache.js +71 -0
- package/dist/operations/services/workflow-timing.d.ts +25 -0
- package/dist/operations/services/workflow-timing.js +79 -0
- package/dist/operations/services/workspace-dependency-mode.js +50 -5
- package/dist/operations/services/workspace-preflight.js +8 -8
- package/dist/operations/services/workspace-save-script.d.ts +4 -4
- package/dist/operations/services/workspace-save.d.ts +1 -0
- package/dist/operations/services/workspace-save.js +42 -19
- package/dist/operations/services/workspace-tools.js +42 -7
- package/dist/operations-registry.d.ts +1 -1
- package/dist/operations-registry.js +5 -6
- package/dist/operations-types.d.ts +1 -1
- package/dist/operations.d.ts +17 -11
- package/dist/operations.js +57 -3
- package/dist/platform/book-export.js +2 -0
- package/dist/platform/books-data.d.ts +1 -1
- package/dist/platform/config.d.ts +27 -0
- package/dist/platform/config.js +100 -0
- package/dist/platform/content-runtime-source.d.ts +54 -0
- package/dist/platform/content-runtime-source.js +169 -0
- package/dist/platform/contracts.d.ts +38 -2
- package/dist/platform/deploy-config.d.ts +1 -1
- package/dist/platform/deploy-config.js +80 -9
- package/dist/platform/deploy-runtime.d.ts +1 -1
- package/dist/platform/desired-state.d.ts +63 -0
- package/dist/platform/desired-state.js +1021 -0
- package/dist/platform/env.yaml +568 -45
- package/dist/platform/environment.d.ts +3 -2
- package/dist/platform/environment.js +122 -21
- package/dist/platform/local-content-materialization.d.ts +9 -0
- package/dist/platform/local-content-materialization.js +212 -0
- package/dist/platform/plugin.d.ts +13 -4
- package/dist/platform/plugins/runtime.d.ts +3 -3
- package/dist/platform/plugins/runtime.js +62 -1
- package/dist/platform/plugins.d.ts +6 -6
- package/dist/platform/published-content-pipeline.d.ts +3 -3
- package/dist/platform/published-content-pipeline.js +5 -1
- package/dist/platform/published-content.d.ts +4 -3
- package/dist/platform/site-config-schema.d.ts +1 -0
- package/dist/platform/site-config-schema.js +4 -1
- package/dist/platform/tenant/runtime-config.d.ts +152 -2
- package/dist/platform/tenant-config.d.ts +1 -1
- package/dist/platform/utils/site-config-schema.d.ts +154 -0
- package/dist/platform/utils/site-config-schema.js +400 -461
- package/dist/platform-operation-store.d.ts +1 -1
- package/dist/platform-operation-store.js +2 -2
- package/dist/platform-operations.js +1 -1
- package/dist/plugin-default.d.ts +1 -1
- package/dist/plugin-default.js +27 -12
- package/dist/project-import.d.ts +91 -0
- package/dist/project-import.js +257 -0
- package/dist/project-workflow.d.ts +1 -1
- package/dist/reconcile/bootstrap-systems.d.ts +2 -2
- package/dist/reconcile/bootstrap-systems.js +5 -5
- package/dist/reconcile/builtin-adapters.d.ts +7 -1
- package/dist/reconcile/builtin-adapters.js +3190 -429
- package/dist/reconcile/contracts.d.ts +23 -6
- package/dist/reconcile/desired-state.d.ts +2 -192
- package/dist/reconcile/desired-state.js +29 -38
- package/dist/reconcile/engine.d.ts +61 -395
- package/dist/reconcile/engine.js +229 -44
- package/dist/reconcile/index.d.ts +10 -8
- package/dist/reconcile/index.js +2 -0
- package/dist/reconcile/live-acceptance.d.ts +100 -0
- package/dist/reconcile/live-acceptance.js +2027 -0
- package/dist/reconcile/platform.d.ts +104 -0
- package/dist/reconcile/platform.js +100 -0
- package/dist/reconcile/providers/docker-private.d.ts +54 -0
- package/dist/reconcile/providers/docker-private.js +101 -0
- package/dist/reconcile/providers/github-private.d.ts +65 -0
- package/dist/reconcile/providers/github-private.js +119 -0
- package/dist/reconcile/providers/local-private.d.ts +28 -0
- package/dist/reconcile/providers/local-private.js +75 -0
- package/dist/reconcile/providers/railway-iac.d.ts +66 -0
- package/dist/reconcile/providers/railway-iac.js +275 -0
- package/dist/reconcile/providers/release-private.d.ts +92 -0
- package/dist/reconcile/providers/release-private.js +152 -0
- package/dist/reconcile/registry.d.ts +2 -2
- package/dist/reconcile/registry.js +16 -1
- package/dist/reconcile/state.d.ts +1 -1
- package/dist/reconcile/state.js +7 -44
- package/dist/reconcile/units.d.ts +1 -1
- package/dist/reconcile/units.js +9 -3
- package/dist/remote.d.ts +6 -6
- package/dist/scenes/artifacts.d.ts +16 -0
- package/dist/scenes/artifacts.js +61 -0
- package/dist/scenes/auth.d.ts +2 -0
- package/dist/scenes/auth.js +28 -0
- package/dist/scenes/base-url.d.ts +12 -0
- package/dist/scenes/base-url.js +123 -0
- package/dist/scenes/builtin-plugins.d.ts +2 -0
- package/dist/scenes/builtin-plugins.js +447 -0
- package/dist/scenes/checkpoints.d.ts +14 -0
- package/dist/scenes/checkpoints.js +33 -0
- package/dist/scenes/device-matrix.d.ts +2 -0
- package/dist/scenes/device-matrix.js +97 -0
- package/dist/scenes/devices.d.ts +11 -0
- package/dist/scenes/devices.js +22 -0
- package/dist/scenes/diagnostics.d.ts +5 -0
- package/dist/scenes/diagnostics.js +22 -0
- package/dist/scenes/diagram-providers.d.ts +2 -0
- package/dist/scenes/diagram-providers.js +181 -0
- package/dist/scenes/diagram-validation.d.ts +22 -0
- package/dist/scenes/diagram-validation.js +74 -0
- package/dist/scenes/environment.d.ts +2 -0
- package/dist/scenes/environment.js +65 -0
- package/dist/scenes/evidence-bundle.d.ts +5 -0
- package/dist/scenes/evidence-bundle.js +42 -0
- package/dist/scenes/evidence-report.d.ts +10 -0
- package/dist/scenes/evidence-report.js +73 -0
- package/dist/scenes/evidence.d.ts +16 -0
- package/dist/scenes/evidence.js +277 -0
- package/dist/scenes/index.d.ts +48 -0
- package/dist/scenes/index.js +312 -0
- package/dist/scenes/inspect.d.ts +9 -0
- package/dist/scenes/inspect.js +64 -0
- package/dist/scenes/loader.d.ts +3 -0
- package/dist/scenes/loader.js +38 -0
- package/dist/scenes/logs.d.ts +2 -0
- package/dist/scenes/logs.js +47 -0
- package/dist/scenes/operations.d.ts +3 -0
- package/dist/scenes/operations.js +128 -0
- package/dist/scenes/phase0.d.ts +9 -0
- package/dist/scenes/phase0.js +637 -0
- package/dist/scenes/planner.d.ts +12 -0
- package/dist/scenes/planner.js +166 -0
- package/dist/scenes/playwright-adapter.d.ts +2 -0
- package/dist/scenes/playwright-adapter.js +63 -0
- package/dist/scenes/plugins.d.ts +5 -0
- package/dist/scenes/plugins.js +115 -0
- package/dist/scenes/progress.d.ts +17 -0
- package/dist/scenes/progress.js +24 -0
- package/dist/scenes/publish-destinations.d.ts +12 -0
- package/dist/scenes/publish-destinations.js +99 -0
- package/dist/scenes/publish-plan-report.d.ts +10 -0
- package/dist/scenes/publish-plan-report.js +70 -0
- package/dist/scenes/publish-plan.d.ts +15 -0
- package/dist/scenes/publish-plan.js +248 -0
- package/dist/scenes/publish-redaction.d.ts +16 -0
- package/dist/scenes/publish-redaction.js +148 -0
- package/dist/scenes/publish-release-record.d.ts +13 -0
- package/dist/scenes/publish-release-record.js +34 -0
- package/dist/scenes/publish-report.d.ts +10 -0
- package/dist/scenes/publish-report.js +67 -0
- package/dist/scenes/publish.d.ts +16 -0
- package/dist/scenes/publish.js +298 -0
- package/dist/scenes/registry.d.ts +12 -0
- package/dist/scenes/registry.js +65 -0
- package/dist/scenes/remotion-adapter.d.ts +3 -0
- package/dist/scenes/remotion-adapter.js +94 -0
- package/dist/scenes/remotion-composition-registry.d.ts +2 -0
- package/dist/scenes/remotion-composition-registry.js +12 -0
- package/dist/scenes/remotion-compositions.d.ts +15 -0
- package/dist/scenes/remotion-compositions.js +464 -0
- package/dist/scenes/remotion-diagrams.d.ts +9 -0
- package/dist/scenes/remotion-diagrams.js +188 -0
- package/dist/scenes/remotion-input.d.ts +13 -0
- package/dist/scenes/remotion-input.js +291 -0
- package/dist/scenes/remotion-visuals.d.ts +29 -0
- package/dist/scenes/remotion-visuals.js +198 -0
- package/dist/scenes/render-media-assets.d.ts +9 -0
- package/dist/scenes/render-media-assets.js +117 -0
- package/dist/scenes/render-report.d.ts +10 -0
- package/dist/scenes/render-report.js +27 -0
- package/dist/scenes/render.d.ts +2 -0
- package/dist/scenes/render.js +218 -0
- package/dist/scenes/reporter.d.ts +3 -0
- package/dist/scenes/reporter.js +101 -0
- package/dist/scenes/resume.d.ts +2 -0
- package/dist/scenes/resume.js +79 -0
- package/dist/scenes/runner.d.ts +2 -0
- package/dist/scenes/runner.js +674 -0
- package/dist/scenes/schema.d.ts +6 -0
- package/dist/scenes/schema.js +994 -0
- package/dist/scenes/seed.d.ts +2 -0
- package/dist/scenes/seed.js +83 -0
- package/dist/scenes/segments.d.ts +15 -0
- package/dist/scenes/segments.js +79 -0
- package/dist/scenes/selectors.d.ts +3 -0
- package/dist/scenes/selectors.js +21 -0
- package/dist/scenes/timeline.d.ts +11 -0
- package/dist/scenes/timeline.js +26 -0
- package/dist/scenes/timeouts.d.ts +6 -0
- package/dist/scenes/timeouts.js +17 -0
- package/dist/scenes/training-captions.d.ts +14 -0
- package/dist/scenes/training-captions.js +98 -0
- package/dist/scenes/training-report.d.ts +10 -0
- package/dist/scenes/training-report.js +78 -0
- package/dist/scenes/training-transcript.d.ts +24 -0
- package/dist/scenes/training-transcript.js +204 -0
- package/dist/scenes/training.d.ts +10 -0
- package/dist/scenes/training.js +134 -0
- package/dist/scenes/types.d.ts +1872 -0
- package/dist/scenes/types.js +8 -0
- package/dist/scenes/visual-audit-contact-sheets.d.ts +6 -0
- package/dist/scenes/visual-audit-contact-sheets.js +119 -0
- package/dist/scenes/visual-audit-fixtures.d.ts +25 -0
- package/dist/scenes/visual-audit-fixtures.js +210 -0
- package/dist/scenes/visual-audit-report.d.ts +11 -0
- package/dist/scenes/visual-audit-report.js +85 -0
- package/dist/scenes/visual-audit-review.d.ts +23 -0
- package/dist/scenes/visual-audit-review.js +829 -0
- package/dist/scenes/visual-audit-routes.d.ts +13 -0
- package/dist/scenes/visual-audit-routes.js +308 -0
- package/dist/scenes/visual-audit.d.ts +3 -0
- package/dist/scenes/visual-audit.js +596 -0
- package/dist/scripts/build-dist.js +154 -40
- package/dist/scripts/check-build-warnings.js +42 -53
- package/dist/scripts/cleanup-markdown.js +1 -1
- package/dist/scripts/cli-test-fixtures.js +24 -28
- package/dist/scripts/deployment-readiness.js +20 -0
- package/dist/scripts/generate-treedx-openapi-types.js +186 -0
- package/dist/scripts/migrate-journey-csv-to-guarantees.js +140 -0
- package/dist/scripts/operations-runner-smoke.js +16 -0
- package/dist/scripts/package-tools.js +12 -1
- package/dist/scripts/prepare.js +3 -7
- package/dist/scripts/release-verify.js +6 -6
- package/dist/scripts/run-fixture-astro-command.js +1 -1
- package/dist/scripts/template-catalog.test.js +68 -82
- package/dist/scripts/tenant-dev.js +0 -10
- package/dist/scripts/tenant-workflow-action.js +10 -1
- package/dist/scripts/test-cloudflare-local.js +1 -1
- package/dist/scripts/test-scaffold.js +0 -1
- package/dist/scripts/test-smoke.js +3 -3
- package/dist/scripts/treeseed-build-dist.js +4 -7
- package/dist/scripts/treeseed-release-verify.js +1 -1
- package/dist/scripts/verify-driver.js +30 -21
- package/dist/scripts/workflow-commands.test.js +18 -27
- package/dist/scripts/workspace-command-e2e.js +1 -1
- package/dist/scripts/workspace-lint.js +6 -0
- package/dist/scripts/workspace-release-test.js +90 -1
- package/dist/scripts/workspace-test.js +6 -0
- package/dist/sdk-dispatch.d.ts +1 -1
- package/dist/sdk-dispatch.js +0 -16
- package/dist/sdk-fields.d.ts +1 -1
- package/dist/sdk-filters.d.ts +1 -1
- package/dist/sdk-types.d.ts +1352 -116
- package/dist/sdk-types.js +413 -2
- package/dist/sdk.d.ts +106 -100
- package/dist/sdk.js +211 -51
- package/dist/secrets-capability.d.ts +421 -0
- package/dist/secrets-capability.js +530 -0
- package/dist/seeds/normalize.js +1 -0
- package/dist/seeds/planner.js +38 -0
- package/dist/seeds/schema.js +293 -4
- package/dist/seeds/types.d.ts +60 -0
- package/dist/seeds/types.js +9 -1
- package/dist/service-credentials.d.ts +54 -0
- package/dist/service-credentials.js +112 -0
- package/dist/stores/cursor-store.d.ts +2 -2
- package/dist/stores/envelopes.d.ts +1 -1
- package/dist/stores/helpers.d.ts +1 -1
- package/dist/stores/lease-store.d.ts +2 -2
- package/dist/stores/message-store.d.ts +2 -2
- package/dist/stores/operational-store.d.ts +3 -26
- package/dist/stores/operational-store.js +0 -225
- package/dist/stores/project-workflow-store.d.ts +2 -2
- package/dist/stores/run-store.d.ts +2 -3
- package/dist/stores/run-store.js +0 -98
- package/dist/stores/subscription-store.d.ts +2 -3
- package/dist/stores/subscription-store.js +0 -100
- package/dist/template-catalog.d.ts +1 -1
- package/dist/template-launch-requirements.d.ts +2 -2
- package/dist/template-launch-requirements.js +9 -0
- package/dist/template-launch-ui.d.ts +2 -2
- 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 +319 -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 +220 -0
- package/dist/treedx-backends.js +634 -0
- package/dist/treedx-client.d.ts +89 -0
- package/dist/treedx-client.js +184 -0
- package/dist/treeseed/services/compose.yml +2 -3
- package/dist/treeseed/template-catalog/catalog.fixture.json +27 -27
- package/dist/types/agents.d.ts +256 -0
- package/dist/types/agents.js +42 -1
- package/dist/verification.d.ts +1 -0
- package/dist/verification.js +143 -14
- package/dist/workflow/operations.d.ts +580 -523
- package/dist/workflow/operations.js +2084 -1279
- package/dist/workflow/policy.js +19 -7
- package/dist/workflow/runs.d.ts +9 -3
- package/dist/workflow/runs.js +66 -26
- package/dist/workflow/session.d.ts +4 -1
- package/dist/workflow/session.js +50 -10
- package/dist/workflow/worktrees.d.ts +6 -2
- package/dist/workflow/worktrees.js +98 -15
- package/dist/workflow-state.d.ts +17 -4
- package/dist/workflow-state.js +68 -41
- package/dist/workflow-support.d.ts +26 -17
- package/dist/workflow-support.js +84 -24
- package/dist/workflow.d.ts +51 -16
- package/dist/workflow.js +19 -7
- package/drizzle/d1/0000_treeseed_d1.sql +0 -18
- package/drizzle/market/0000_market_control_plane.sql +1793 -1499
- package/drizzle/market/0004_treedx_market_integration.sql +99 -0
- package/drizzle/market/0005_agent_capacity_coordination.sql +145 -0
- package/drizzle/market/0006_provider_assignment_lifecycle.sql +9 -0
- package/drizzle/market/0007_agent_architecture_gap_closure.sql +157 -0
- package/drizzle/market/0008_agent_capacity_operational_closure.sql +27 -0
- package/drizzle/market/0009_agent_capacity_runtime_hardening.sql +29 -0
- package/drizzle/market/0010_secrets_capability_persistence.sql +130 -0
- package/drizzle/market/0011_github_app_adapter.sql +47 -0
- package/drizzle/market/0012_workday_test_runs.sql +44 -0
- package/drizzle/market/0013_runtime_task_lifecycle.sql +39 -0
- package/drizzle/market/0014_governance_pipeline.sql +189 -0
- package/package.json +106 -18
- package/scripts/{verify-driver.mjs → verify-driver.ts} +19 -4
- package/templates/github/deploy-web.workflow.yml +49 -12
- package/dist/operations/services/mailpit-runtime.d.ts +0 -20
- package/dist/operations/services/mailpit-runtime.js +0 -67
- package/dist/operations/services/release-candidate.d.ts +0 -41
- package/dist/operations/services/release-candidate.js +0 -616
- package/dist/scripts/ensure-mailpit.js +0 -48
- package/dist/scripts/logs-mailpit.js +0 -2
- package/dist/scripts/run-ts.js +0 -45
- package/dist/scripts/stop-mailpit.js +0 -5
- package/dist/scripts/treeseed-run-ts.js +0 -45
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, relative, resolve } from "node:path";
|
|
7
|
+
import { resolveCapacityProviderLaunchPlan } from "../capacity-provider.js";
|
|
8
|
+
import { TreeDxClient } from "../treedx-client.js";
|
|
9
|
+
import { collectTreeseedEnvironmentContext, resolveTreeseedMachineEnvironmentValues, setTreeseedMachineEnvironmentValue } from "../operations/services/config-runtime.js";
|
|
4
10
|
import {
|
|
5
11
|
buildPublicVars,
|
|
6
12
|
buildCloudflarePagesFunctionBindings,
|
|
@@ -26,6 +32,7 @@ import {
|
|
|
26
32
|
queueName,
|
|
27
33
|
reconcileCloudflareWebCacheRules,
|
|
28
34
|
resolveConfiguredCloudflareAccountId,
|
|
35
|
+
resolveConfiguredSurfaceDomain,
|
|
29
36
|
resolveCloudflareZoneIdForHost,
|
|
30
37
|
runWrangler,
|
|
31
38
|
scopeFromTarget,
|
|
@@ -34,10 +41,7 @@ import {
|
|
|
34
41
|
} from "../operations/services/deploy.js";
|
|
35
42
|
import {
|
|
36
43
|
configuredRailwayServices,
|
|
37
|
-
|
|
38
|
-
ensureRailwayProjectContext,
|
|
39
|
-
ensureRailwayServiceVolumeWithCliFallback,
|
|
40
|
-
runRailway,
|
|
44
|
+
findStaleTreeseedOperationsRunnerResources,
|
|
41
45
|
validateRailwayDeployPrerequisites
|
|
42
46
|
} from "../operations/services/railway-deploy.js";
|
|
43
47
|
import { shouldExposeManagedHostRuntimeSecret } from "../operations/services/managed-host-security.js";
|
|
@@ -47,19 +51,56 @@ import {
|
|
|
47
51
|
ensureRailwayProject,
|
|
48
52
|
ensureRailwayService,
|
|
49
53
|
ensureRailwayServiceInstanceConfiguration,
|
|
54
|
+
ensureRailwayServiceVolume,
|
|
55
|
+
ensureRailwayPostgresService,
|
|
56
|
+
deleteRailwayService,
|
|
57
|
+
deleteRailwayVolume,
|
|
58
|
+
deployRailwayServiceInstance,
|
|
59
|
+
listRailwayEnvironmentServices,
|
|
50
60
|
getRailwayServiceInstance,
|
|
51
61
|
getRailwayProject,
|
|
52
62
|
listRailwayCustomDomains,
|
|
53
63
|
listRailwayEnvironments,
|
|
54
64
|
listRailwayProjects,
|
|
65
|
+
listRailwayServices,
|
|
55
66
|
listRailwayVolumes,
|
|
56
67
|
listRailwayVariables,
|
|
57
68
|
resolveRailwayWorkspaceContext,
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
updateRailwayServiceGitSource,
|
|
70
|
+
updateRailwayServiceImageSource
|
|
60
71
|
} from "../operations/services/railway-api.js";
|
|
61
72
|
import { loadTreeseedReconcileState } from "./state.js";
|
|
62
73
|
import { createTreeseedReconcileUnitId } from "./units.js";
|
|
74
|
+
import { discoverTreeseedApplications } from "../hosting/apps.js";
|
|
75
|
+
import { findTreeseedPackageAdapter, syncTreeseedPackageWorkflows } from "../operations/services/package-adapters.js";
|
|
76
|
+
import { resolveGitHubCredentialForRepository } from "../operations/services/github-credentials.js";
|
|
77
|
+
import {
|
|
78
|
+
dispatchReconcileGitHubWorkflow,
|
|
79
|
+
ensureReconcileGitHubEnvironment,
|
|
80
|
+
observeGitHubEnvironment,
|
|
81
|
+
observeGitHubWorkflowRun,
|
|
82
|
+
upsertReconcileGitHubSecret,
|
|
83
|
+
upsertReconcileGitHubVariable
|
|
84
|
+
} from "./providers/github-private.js";
|
|
85
|
+
import {
|
|
86
|
+
buildDockerImage,
|
|
87
|
+
inspectDockerAvailability,
|
|
88
|
+
inspectDockerImage,
|
|
89
|
+
inspectDockerManifest,
|
|
90
|
+
runDockerCompose
|
|
91
|
+
} from "./providers/docker-private.js";
|
|
92
|
+
import { checkHttpHealth, runManagedDevAction } from "./providers/local-private.js";
|
|
93
|
+
import {
|
|
94
|
+
ensureTemplateReleaseTag,
|
|
95
|
+
runHostedReconcileGate,
|
|
96
|
+
runHostedVerifyGate,
|
|
97
|
+
runReleaseVerifyCommand,
|
|
98
|
+
runTemplateReleaseVerifyCommand,
|
|
99
|
+
writeReleaseRecord
|
|
100
|
+
} from "./providers/release-private.js";
|
|
101
|
+
import { checkedOutTemplateRepositories } from "../operations/services/managed-repositories.js";
|
|
102
|
+
import { runTreeseedGit } from "../operations/services/git-runner.js";
|
|
103
|
+
import { withDockerhubServiceCredentialEnv } from "../service-credentials.js";
|
|
63
104
|
function toDeployTarget(target) {
|
|
64
105
|
return target.kind === "persistent" ? createPersistentDeployTarget(target.scope) : createBranchPreviewDeployTarget(target.branchName);
|
|
65
106
|
}
|
|
@@ -75,7 +116,7 @@ function isTransientCloudflareReconcileError(error) {
|
|
|
75
116
|
}
|
|
76
117
|
function isTransientRailwayReconcileError(error) {
|
|
77
118
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
78
|
-
return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|connectivity issue|rate limit|too many requests|429|5\d\d/iu.test(message);
|
|
119
|
+
return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|connectivity issue|rate limit|too many requests|429|5\d\d|operation is already in progress|Problem processing request/iu.test(message);
|
|
79
120
|
}
|
|
80
121
|
function syntheticQueueLocator(name) {
|
|
81
122
|
return `queue-name:${name}`;
|
|
@@ -103,81 +144,1793 @@ function noopDiff() {
|
|
|
103
144
|
after: {}
|
|
104
145
|
};
|
|
105
146
|
}
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
147
|
+
function buildCompositeAdapter(unitType) {
|
|
148
|
+
return {
|
|
149
|
+
providerId: "treeseed",
|
|
150
|
+
unitTypes: [unitType],
|
|
151
|
+
supports(candidateUnitType, providerId) {
|
|
152
|
+
return candidateUnitType === unitType && providerId === "treeseed";
|
|
153
|
+
},
|
|
154
|
+
async refresh(input) {
|
|
155
|
+
return noopObservedState(input);
|
|
156
|
+
},
|
|
157
|
+
diff() {
|
|
158
|
+
return noopDiff();
|
|
159
|
+
},
|
|
160
|
+
requiredPostconditions({ unit }) {
|
|
161
|
+
return unit.dependencies.map((dependency) => ({
|
|
162
|
+
key: dependency,
|
|
163
|
+
description: `Dependency ${dependency} is verified`
|
|
164
|
+
}));
|
|
165
|
+
},
|
|
166
|
+
apply({ unit, observed, diff }) {
|
|
167
|
+
return {
|
|
168
|
+
unit,
|
|
169
|
+
observed,
|
|
170
|
+
diff,
|
|
171
|
+
action: diff.action,
|
|
172
|
+
warnings: [],
|
|
173
|
+
resourceLocators: {},
|
|
174
|
+
state: {
|
|
175
|
+
unitId: unit.unitId,
|
|
176
|
+
reconciledAt: nowIso()
|
|
177
|
+
},
|
|
178
|
+
verification: null
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
verify({ context, unit, postconditions }) {
|
|
182
|
+
const dependencyResults = context.session.get("treeseed:verification-results");
|
|
183
|
+
const checks = postconditions.map((condition) => {
|
|
184
|
+
const dependency = dependencyResults?.get(condition.key);
|
|
185
|
+
const verified = dependency?.verified === true;
|
|
186
|
+
return {
|
|
187
|
+
key: condition.key,
|
|
188
|
+
description: condition.description,
|
|
189
|
+
source: "derived",
|
|
190
|
+
exists: verified,
|
|
191
|
+
configured: verified,
|
|
192
|
+
ready: verified,
|
|
193
|
+
verified,
|
|
194
|
+
expected: true,
|
|
195
|
+
observed: dependency?.verified ?? false,
|
|
196
|
+
issues: verified ? [] : [`Dependency ${condition.key} is not verified.`]
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
return summarizeVerification(unit.unitId, checks);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function genericObservedState(input, exists = true, warnings = []) {
|
|
204
|
+
return {
|
|
205
|
+
exists,
|
|
206
|
+
status: exists ? "ready" : "pending",
|
|
207
|
+
live: {
|
|
208
|
+
unitId: input.unit.unitId,
|
|
209
|
+
unitType: input.unit.unitType,
|
|
210
|
+
provider: input.unit.provider,
|
|
211
|
+
spec: input.unit.spec
|
|
212
|
+
},
|
|
213
|
+
locators: { unitId: input.unit.unitId },
|
|
214
|
+
warnings
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function genericResult(input, state = input.observed.live) {
|
|
218
|
+
return {
|
|
219
|
+
unit: input.unit,
|
|
220
|
+
observed: input.observed,
|
|
221
|
+
diff: input.diff,
|
|
222
|
+
action: input.diff.action,
|
|
223
|
+
warnings: input.observed.warnings,
|
|
224
|
+
resourceLocators: input.observed.locators,
|
|
225
|
+
state: {
|
|
226
|
+
...state,
|
|
227
|
+
reconciledAt: nowIso()
|
|
228
|
+
},
|
|
229
|
+
verification: null
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function genericVerification(input, observed, description = "Desired resource is represented in the canonical graph") {
|
|
233
|
+
return summarizeVerification(input.unit.unitId, [
|
|
234
|
+
verificationCheck("desired-resource", description, "derived", {
|
|
235
|
+
exists: observed.exists,
|
|
236
|
+
configured: true,
|
|
237
|
+
ready: observed.status !== "error",
|
|
238
|
+
verified: observed.exists && observed.status !== "error",
|
|
239
|
+
observed: observed.live
|
|
240
|
+
})
|
|
241
|
+
], observed.warnings);
|
|
242
|
+
}
|
|
243
|
+
function buildManifestAdapter() {
|
|
244
|
+
return {
|
|
245
|
+
providerId: "treeseed",
|
|
246
|
+
unitTypes: ["package-manifest", "template-manifest"],
|
|
247
|
+
supports(unitType, providerId) {
|
|
248
|
+
return (unitType === "package-manifest" || unitType === "template-manifest") && providerId === "treeseed";
|
|
249
|
+
},
|
|
250
|
+
async refresh(input) {
|
|
251
|
+
const manifestPath = typeof input.unit.spec.manifestPath === "string" ? input.unit.spec.manifestPath : null;
|
|
252
|
+
const warningLabel = input.unit.unitType === "template-manifest" ? "template manifest path is not available" : "package manifest path is not available";
|
|
253
|
+
return genericObservedState(input, manifestPath ? existsSync(manifestPath) : true, manifestPath ? [] : [warningLabel]);
|
|
254
|
+
},
|
|
255
|
+
diff(input) {
|
|
256
|
+
const label = input.unit.unitType === "template-manifest" ? "template manifest" : "package manifest";
|
|
257
|
+
return input.observed.exists ? noopDiff() : { action: "blocked", reasons: [`${label} is missing`], before: input.observed.live, after: input.unit.spec };
|
|
258
|
+
},
|
|
259
|
+
apply(input) {
|
|
260
|
+
return genericResult(input);
|
|
261
|
+
},
|
|
262
|
+
verify(input) {
|
|
263
|
+
return genericVerification(input, input.observed, input.unit.unitType === "template-manifest" ? "Template manifest exists and is readable" : "Package manifest exists and is readable");
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function buildPackageWorkflowAdapter() {
|
|
268
|
+
return {
|
|
269
|
+
providerId: "github",
|
|
270
|
+
unitTypes: ["package-workflow"],
|
|
271
|
+
supports(unitType, providerId) {
|
|
272
|
+
return unitType === "package-workflow" && providerId === "github";
|
|
273
|
+
},
|
|
274
|
+
refresh(input) {
|
|
275
|
+
const packageId = typeof input.unit.spec.packageId === "string" ? input.unit.spec.packageId : typeof input.unit.metadata.packageId === "string" ? input.unit.metadata.packageId : null;
|
|
276
|
+
const adapter = packageId ? findTreeseedPackageAdapter(input.context.tenantRoot, packageId) : null;
|
|
277
|
+
const sync = packageId ? syncTreeseedPackageWorkflows({ root: input.context.tenantRoot, packageId, execute: false }) : [];
|
|
278
|
+
return {
|
|
279
|
+
...genericObservedState(input, Boolean(adapter), adapter ? [] : [`Package ${packageId ?? "(unknown)"} was not discovered.`]),
|
|
280
|
+
status: sync.some((entry) => entry.changed || !entry.exists) ? "drifted" : adapter ? "ready" : "error",
|
|
281
|
+
live: {
|
|
282
|
+
...input.unit.spec,
|
|
283
|
+
packageId,
|
|
284
|
+
workflows: sync
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
diff(input) {
|
|
289
|
+
if (!input.observed.exists) {
|
|
290
|
+
return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
291
|
+
}
|
|
292
|
+
const workflows = Array.isArray(input.observed.live.workflows) ? input.observed.live.workflows : [];
|
|
293
|
+
if (workflows.some((entry) => entry.changed || !entry.exists)) {
|
|
294
|
+
return { action: "update", reasons: ["package workflow files differ from rendered templates"], before: input.observed.live, after: input.unit.spec };
|
|
295
|
+
}
|
|
296
|
+
return noopDiff();
|
|
297
|
+
},
|
|
298
|
+
apply(input) {
|
|
299
|
+
const packageId = typeof input.unit.spec.packageId === "string" ? input.unit.spec.packageId : typeof input.unit.metadata.packageId === "string" ? input.unit.metadata.packageId : null;
|
|
300
|
+
if (packageId && input.context.dryRun !== true && input.diff.action !== "noop") {
|
|
301
|
+
const sync = syncTreeseedPackageWorkflows({
|
|
302
|
+
root: input.context.tenantRoot,
|
|
303
|
+
packageId,
|
|
304
|
+
execute: true
|
|
305
|
+
});
|
|
306
|
+
return genericResult(input, { ...input.observed.live, workflowSync: sync });
|
|
307
|
+
}
|
|
308
|
+
return genericResult(input);
|
|
309
|
+
},
|
|
310
|
+
verify(input) {
|
|
311
|
+
const workflows = Array.isArray(input.observed.live.workflows) ? input.observed.live.workflows : [];
|
|
312
|
+
const checks = workflows.map((workflow) => verificationCheck(
|
|
313
|
+
`workflow:${workflow.workflow ?? "unknown"}`,
|
|
314
|
+
`Workflow ${workflow.workflow ?? "unknown"} matches the rendered template`,
|
|
315
|
+
"sdk",
|
|
316
|
+
{
|
|
317
|
+
exists: workflow.exists === true,
|
|
318
|
+
configured: workflow.changed !== true,
|
|
319
|
+
ready: workflow.exists === true && workflow.changed !== true,
|
|
320
|
+
verified: workflow.exists === true && workflow.changed !== true,
|
|
321
|
+
expected: "rendered-template",
|
|
322
|
+
observed: workflow,
|
|
323
|
+
issues: [
|
|
324
|
+
...workflow.exists ? [] : ["workflow file is missing"],
|
|
325
|
+
...workflow.changed ? ["workflow file has template drift"] : []
|
|
326
|
+
]
|
|
327
|
+
}
|
|
328
|
+
));
|
|
329
|
+
return summarizeVerification(input.unit.unitId, checks.length > 0 ? checks : [
|
|
330
|
+
verificationCheck("package-workflow", "Package workflow resource is observable", "sdk", {
|
|
331
|
+
exists: input.observed.exists,
|
|
332
|
+
configured: input.observed.exists,
|
|
333
|
+
ready: input.observed.exists,
|
|
334
|
+
verified: input.observed.exists
|
|
335
|
+
})
|
|
336
|
+
], input.observed.warnings);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function buildGraphOnlyAdapter(providerId, unitTypes, description) {
|
|
341
|
+
return {
|
|
342
|
+
providerId,
|
|
343
|
+
unitTypes,
|
|
344
|
+
supports(unitType, candidateProviderId) {
|
|
345
|
+
return candidateProviderId === providerId && unitTypes.includes(unitType);
|
|
346
|
+
},
|
|
347
|
+
refresh(input) {
|
|
348
|
+
return genericObservedState(input);
|
|
349
|
+
},
|
|
350
|
+
diff() {
|
|
351
|
+
return noopDiff();
|
|
352
|
+
},
|
|
353
|
+
apply(input) {
|
|
354
|
+
return genericResult(input);
|
|
355
|
+
},
|
|
356
|
+
verify(input) {
|
|
357
|
+
return genericVerification(input, input.observed, description);
|
|
358
|
+
},
|
|
359
|
+
destroy(input) {
|
|
360
|
+
return genericResult({
|
|
361
|
+
...input,
|
|
362
|
+
diff: { action: "delete", reasons: ["selected for destroy"], before: input.observed.live, after: {} }
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function buildGitHubEnv(input) {
|
|
368
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
369
|
+
const resolvedScope = scope === "local" ? "staging" : scope;
|
|
370
|
+
const configuredValues = resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, resolvedScope);
|
|
371
|
+
const values = {
|
|
372
|
+
...normalizeEnvironmentValues(configuredValues),
|
|
373
|
+
...resolveReconcileEnvironmentValues(input, resolvedScope)
|
|
374
|
+
};
|
|
375
|
+
const providerValues = withDockerhubServiceCredentialEnv(values);
|
|
376
|
+
const repository = typeof input.unit.spec.repository === "string" ? input.unit.spec.repository : null;
|
|
377
|
+
if (!repository) return providerValues;
|
|
378
|
+
const credential = resolveGitHubCredentialForRepository(repository, { values: providerValues, env: providerValues });
|
|
379
|
+
return credential.token ? { ...providerValues, TREESEED_GITHUB_TOKEN: credential.token, GH_TOKEN: credential.token, GITHUB_TOKEN: credential.token } : providerValues;
|
|
380
|
+
}
|
|
381
|
+
function repositoryFromUnit(input) {
|
|
382
|
+
const repository = input.unit.spec.repository;
|
|
383
|
+
if (typeof repository !== "string" || !repository.trim()) {
|
|
384
|
+
throw new Error(`${input.unit.unitId} requires a GitHub repository.`);
|
|
385
|
+
}
|
|
386
|
+
return repository.trim();
|
|
387
|
+
}
|
|
388
|
+
function workflowName(value, fallback) {
|
|
389
|
+
return (typeof value === "string" && value.trim() ? value.trim() : fallback).replace(/^\.github\/workflows\//u, "");
|
|
390
|
+
}
|
|
391
|
+
function environmentFromUnit(input) {
|
|
392
|
+
const environment = input.unit.spec.environment;
|
|
393
|
+
if (typeof environment !== "string" || !environment.trim()) {
|
|
394
|
+
throw new Error(`${input.unit.unitId} requires a GitHub environment.`);
|
|
395
|
+
}
|
|
396
|
+
return environment.trim();
|
|
397
|
+
}
|
|
398
|
+
function buildGitHubEnvironmentAdapter() {
|
|
399
|
+
return {
|
|
400
|
+
providerId: "github",
|
|
401
|
+
unitTypes: ["github-environment"],
|
|
402
|
+
supports(unitType, providerId) {
|
|
403
|
+
return unitType === "github-environment" && providerId === "github";
|
|
404
|
+
},
|
|
405
|
+
async refresh(input) {
|
|
406
|
+
const repository = repositoryFromUnit(input);
|
|
407
|
+
const environment = environmentFromUnit(input);
|
|
408
|
+
const observed = await observeGitHubEnvironment(repository, environment, buildGitHubEnv(input));
|
|
409
|
+
const warnings = observed.exists ? [] : [String(observed.error ?? "GitHub environment is missing")];
|
|
410
|
+
return {
|
|
411
|
+
...genericObservedState(input, observed.exists, warnings),
|
|
412
|
+
status: observed.exists ? "ready" : "pending",
|
|
413
|
+
live: observed
|
|
414
|
+
};
|
|
415
|
+
},
|
|
416
|
+
diff(input) {
|
|
417
|
+
if (input.observed.live?.authAvailable === false) {
|
|
418
|
+
return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
419
|
+
}
|
|
420
|
+
return input.observed.exists ? noopDiff() : { action: "create", reasons: ["GitHub environment is missing"], before: input.observed.live, after: input.unit.spec };
|
|
421
|
+
},
|
|
422
|
+
async apply(input) {
|
|
423
|
+
if (input.diff.action !== "noop") {
|
|
424
|
+
const result = await ensureReconcileGitHubEnvironment(repositoryFromUnit(input), environmentFromUnit(input), typeof input.unit.spec.branch === "string" ? input.unit.spec.branch : null, buildGitHubEnv(input));
|
|
425
|
+
return genericResult(input, { ...input.observed.live, result });
|
|
426
|
+
}
|
|
427
|
+
return genericResult(input);
|
|
428
|
+
},
|
|
429
|
+
verify(input) {
|
|
430
|
+
return genericVerification(input, input.observed, "GitHub environment exists");
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function buildGitHubBindingAdapter(unitType) {
|
|
435
|
+
return {
|
|
436
|
+
providerId: "github",
|
|
437
|
+
unitTypes: [unitType],
|
|
438
|
+
supports(candidateUnitType, providerId) {
|
|
439
|
+
return candidateUnitType === unitType && providerId === "github";
|
|
440
|
+
},
|
|
441
|
+
async refresh(input) {
|
|
442
|
+
const repository = repositoryFromUnit(input);
|
|
443
|
+
const environment = environmentFromUnit(input);
|
|
444
|
+
const nameKey = unitType === "github-secret-binding" ? "secretName" : "variableName";
|
|
445
|
+
const name = String(input.unit.spec[nameKey] ?? input.unit.spec.envName ?? "");
|
|
446
|
+
const observed = await observeGitHubEnvironment(repository, environment, buildGitHubEnv(input));
|
|
447
|
+
const names = unitType === "github-secret-binding" ? observed.secretNames : observed.variableNames;
|
|
448
|
+
const exists = observed.exists && names.includes(name);
|
|
449
|
+
const warnings = observed.authAvailable === false ? [String(observed.error ?? "GitHub authentication is unavailable")] : observed.exists ? [] : [`GitHub environment ${environment} is missing`];
|
|
450
|
+
return {
|
|
451
|
+
...genericObservedState(input, exists, warnings),
|
|
452
|
+
status: exists ? "ready" : "pending",
|
|
453
|
+
live: { repository, environment, name, exists, observed }
|
|
454
|
+
};
|
|
455
|
+
},
|
|
456
|
+
diff(input) {
|
|
457
|
+
const name = String(input.observed.live.name ?? "");
|
|
458
|
+
if (input.observed.live?.observed?.authAvailable === false) {
|
|
459
|
+
return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
460
|
+
}
|
|
461
|
+
if (input.observed.exists) {
|
|
462
|
+
return noopDiff();
|
|
463
|
+
}
|
|
464
|
+
const value = buildGitHubEnv(input)[name];
|
|
465
|
+
if (!value) {
|
|
466
|
+
return { action: "blocked", reasons: [`Missing local value for ${name}`], before: input.observed.live, after: input.unit.spec };
|
|
467
|
+
}
|
|
468
|
+
return { action: "update", reasons: [`GitHub ${unitType === "github-secret-binding" ? "secret" : "variable"} ${name} is missing`], before: input.observed.live, after: input.unit.spec };
|
|
469
|
+
},
|
|
470
|
+
async apply(input) {
|
|
471
|
+
if (input.diff.action === "noop" || input.diff.action === "blocked") return genericResult(input);
|
|
472
|
+
const name = String(input.observed.live.name ?? input.unit.spec.envName ?? "");
|
|
473
|
+
const value = buildGitHubEnv(input)[name];
|
|
474
|
+
if (!value) throw new Error(`Missing local value for ${name}`);
|
|
475
|
+
const result = unitType === "github-secret-binding" ? await upsertReconcileGitHubSecret(repositoryFromUnit(input), environmentFromUnit(input), name, value, buildGitHubEnv(input)) : await upsertReconcileGitHubVariable(repositoryFromUnit(input), environmentFromUnit(input), name, value, buildGitHubEnv(input));
|
|
476
|
+
return genericResult(input, { ...input.observed.live, result });
|
|
477
|
+
},
|
|
478
|
+
verify(input) {
|
|
479
|
+
return genericVerification(input, input.observed, `GitHub ${unitType === "github-secret-binding" ? "secret" : "variable"} binding exists`);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function buildGitHubWorkflowDispatchAdapter() {
|
|
484
|
+
return {
|
|
485
|
+
providerId: "github",
|
|
486
|
+
unitTypes: ["github-workflow-dispatch"],
|
|
487
|
+
supports(unitType, providerId) {
|
|
488
|
+
return unitType === "github-workflow-dispatch" && providerId === "github";
|
|
489
|
+
},
|
|
490
|
+
async refresh(input) {
|
|
491
|
+
const repository = repositoryFromUnit(input);
|
|
492
|
+
const workflow = workflowName(input.unit.spec.workflow, "publish.yml");
|
|
493
|
+
const branch = typeof input.unit.spec.branch === "string" ? input.unit.spec.branch : null;
|
|
494
|
+
const latest = await observeGitHubWorkflowRun({ repository, workflow, branch, env: buildGitHubEnv(input) });
|
|
495
|
+
const authBlocked = Boolean(latest && typeof latest === "object" && "authAvailable" in latest && latest.authAvailable === false);
|
|
496
|
+
return {
|
|
497
|
+
...genericObservedState(input, Boolean(latest) && !authBlocked, authBlocked ? [String(latest.error ?? "GitHub authentication is unavailable")] : []),
|
|
498
|
+
status: latest && !authBlocked ? "ready" : "pending",
|
|
499
|
+
live: { repository, workflow, branch, latest }
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
diff(input) {
|
|
503
|
+
if (input.observed.live?.latest?.authAvailable === false) {
|
|
504
|
+
return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
505
|
+
}
|
|
506
|
+
const latest = input.observed.live?.latest;
|
|
507
|
+
const conclusion = typeof latest?.conclusion === "string" ? latest.conclusion : null;
|
|
508
|
+
const status = typeof latest?.status === "string" ? latest.status : null;
|
|
509
|
+
const expectedHeadSha = typeof input.unit.spec.expectedHeadSha === "string" ? input.unit.spec.expectedHeadSha : null;
|
|
510
|
+
const observedHeadSha = typeof latest?.headSha === "string" ? latest.headSha : typeof latest?.head_sha === "string" ? latest.head_sha : null;
|
|
511
|
+
if (!input.observed.exists) {
|
|
512
|
+
return { action: "create", reasons: ["workflow dispatch has no observed run"], before: input.observed.live, after: input.unit.spec };
|
|
513
|
+
}
|
|
514
|
+
if (expectedHeadSha && observedHeadSha && observedHeadSha !== expectedHeadSha) {
|
|
515
|
+
return { action: "create", reasons: [`latest workflow run is for ${observedHeadSha}, expected ${expectedHeadSha}`], before: input.observed.live, after: input.unit.spec };
|
|
516
|
+
}
|
|
517
|
+
if (conclusion && conclusion !== "success") {
|
|
518
|
+
return { action: "create", reasons: [`latest workflow run concluded ${conclusion}`], before: input.observed.live, after: input.unit.spec };
|
|
519
|
+
}
|
|
520
|
+
if (status && status !== "completed" && input.unit.spec.wait === true) {
|
|
521
|
+
return { action: "create", reasons: [`latest workflow run is still ${status}`], before: input.observed.live, after: input.unit.spec };
|
|
522
|
+
}
|
|
523
|
+
return noopDiff();
|
|
524
|
+
},
|
|
525
|
+
async apply(input) {
|
|
526
|
+
if (input.diff.action === "noop") return genericResult(input);
|
|
527
|
+
const result = await dispatchReconcileGitHubWorkflow({
|
|
528
|
+
repository: repositoryFromUnit(input),
|
|
529
|
+
workflow: workflowName(input.unit.spec.workflow, "publish.yml"),
|
|
530
|
+
branch: String(input.unit.spec.branch ?? "staging"),
|
|
531
|
+
inputs: typeof input.unit.spec.inputs === "object" && input.unit.spec.inputs ? input.unit.spec.inputs : {},
|
|
532
|
+
wait: input.unit.spec.wait === true,
|
|
533
|
+
timeoutMs: typeof input.unit.spec.timeoutMs === "number" ? input.unit.spec.timeoutMs : void 0,
|
|
534
|
+
env: buildGitHubEnv(input)
|
|
535
|
+
});
|
|
536
|
+
return genericResult(input, { ...input.observed.live, result });
|
|
537
|
+
},
|
|
538
|
+
verify(input) {
|
|
539
|
+
const latest = input.observed.live?.latest;
|
|
540
|
+
const result = input.result?.state?.result && typeof input.result.state.result === "object" ? input.result.state.result : null;
|
|
541
|
+
const observedRun = result?.runId ? result : latest;
|
|
542
|
+
const expectedHeadSha = typeof input.unit.spec.expectedHeadSha === "string" ? input.unit.spec.expectedHeadSha : null;
|
|
543
|
+
const observedHeadSha = typeof observedRun?.headSha === "string" ? observedRun.headSha : typeof observedRun?.head_sha === "string" ? observedRun.head_sha : null;
|
|
544
|
+
const status = typeof observedRun?.status === "string" ? observedRun.status : null;
|
|
545
|
+
const conclusion = typeof observedRun?.conclusion === "string" ? observedRun.conclusion : null;
|
|
546
|
+
const wait = input.unit.spec.wait === true;
|
|
547
|
+
const issues = [
|
|
548
|
+
...expectedHeadSha && observedHeadSha && observedHeadSha !== expectedHeadSha ? [`latest workflow run is for ${observedHeadSha}, expected ${expectedHeadSha}`] : [],
|
|
549
|
+
...wait && status && status !== "completed" ? [`workflow run is still ${status}`] : [],
|
|
550
|
+
...wait && status === "completed" && conclusion !== "success" ? [`workflow run concluded ${conclusion ?? "unknown"}`] : []
|
|
551
|
+
];
|
|
552
|
+
return summarizeVerification(input.unit.unitId, [
|
|
553
|
+
verificationCheck("github.workflow-run", "GitHub workflow dispatch produced a successful observed run", "api", {
|
|
554
|
+
exists: Boolean(observedRun),
|
|
555
|
+
configured: !expectedHeadSha || !observedHeadSha || observedHeadSha === expectedHeadSha,
|
|
556
|
+
ready: !wait || status === "completed",
|
|
557
|
+
verified: Boolean(observedRun) && issues.length === 0,
|
|
558
|
+
expected: {
|
|
559
|
+
workflow: input.unit.spec.workflow,
|
|
560
|
+
branch: input.unit.spec.branch,
|
|
561
|
+
headSha: expectedHeadSha,
|
|
562
|
+
wait,
|
|
563
|
+
conclusion: wait ? "success" : void 0
|
|
564
|
+
},
|
|
565
|
+
observed: observedRun,
|
|
566
|
+
issues
|
|
567
|
+
})
|
|
568
|
+
], input.observed.warnings);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function buildPackageImageAdapter() {
|
|
573
|
+
return {
|
|
574
|
+
providerId: "dockerhub",
|
|
575
|
+
unitTypes: ["package-image"],
|
|
576
|
+
supports(unitType, providerId) {
|
|
577
|
+
return unitType === "package-image" && providerId === "dockerhub";
|
|
578
|
+
},
|
|
579
|
+
async refresh(input) {
|
|
580
|
+
const env = buildGitHubEnv(input);
|
|
581
|
+
const repository = typeof input.unit.spec.repository === "string" ? input.unit.spec.repository : null;
|
|
582
|
+
const environment = typeof input.unit.spec.environment === "string" ? input.unit.spec.environment : "staging";
|
|
583
|
+
const requiredSecrets = Array.isArray(input.unit.spec.requiredSecrets) ? input.unit.spec.requiredSecrets.map((entry) => String(entry)).filter(Boolean) : ["TREESEED_DOCKERHUB_TOKEN"];
|
|
584
|
+
const requiredVariables = Array.isArray(input.unit.spec.requiredVariables) ? input.unit.spec.requiredVariables.map((entry) => String(entry)).filter(Boolean) : ["TREESEED_DOCKERHUB_USERNAME"];
|
|
585
|
+
const localMissingSecrets = requiredSecrets.filter((name) => !env[name]);
|
|
586
|
+
const localMissingVariables = requiredVariables.filter((name) => !env[name]);
|
|
587
|
+
let remote = null;
|
|
588
|
+
if ((localMissingSecrets.length > 0 || localMissingVariables.length > 0) && repository) {
|
|
589
|
+
remote = await observeGitHubEnvironment(repository, environment, env);
|
|
590
|
+
}
|
|
591
|
+
const remoteSecretNames = new Set(remote?.secretNames ?? []);
|
|
592
|
+
const remoteVariableNames = new Set(remote?.variableNames ?? []);
|
|
593
|
+
const missingSecrets = localMissingSecrets.filter((name) => !remoteSecretNames.has(name));
|
|
594
|
+
const missingVariables = localMissingVariables.filter((name) => !remoteVariableNames.has(name));
|
|
595
|
+
const usernameConfigured = missingVariables.length === 0;
|
|
596
|
+
const tokenConfigured = missingSecrets.length === 0;
|
|
597
|
+
return {
|
|
598
|
+
...genericObservedState(input, usernameConfigured && tokenConfigured, [
|
|
599
|
+
...missingVariables.map((name) => `${name} is not configured`),
|
|
600
|
+
...missingSecrets.map((name) => `${name} is not configured`)
|
|
601
|
+
]),
|
|
602
|
+
status: usernameConfigured && tokenConfigured ? "ready" : "pending",
|
|
603
|
+
live: {
|
|
604
|
+
...input.unit.spec,
|
|
605
|
+
dockerHub: {
|
|
606
|
+
usernameConfigured,
|
|
607
|
+
tokenConfigured,
|
|
608
|
+
source: localMissingSecrets.length === 0 && localMissingVariables.length === 0 ? "local-env" : "github-environment",
|
|
609
|
+
environment,
|
|
610
|
+
repository
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
},
|
|
615
|
+
diff(input) {
|
|
616
|
+
return input.observed.exists ? noopDiff() : { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
617
|
+
},
|
|
618
|
+
apply(input) {
|
|
619
|
+
return genericResult(input);
|
|
620
|
+
},
|
|
621
|
+
verify(input) {
|
|
622
|
+
return genericVerification(input, input.observed, "Docker Hub credentials for package image publication are configured");
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
function buildDockerImageBuildAdapter() {
|
|
627
|
+
return {
|
|
628
|
+
providerId: "docker",
|
|
629
|
+
unitTypes: ["docker-image-build"],
|
|
630
|
+
supports(unitType, providerId) {
|
|
631
|
+
return unitType === "docker-image-build" && providerId === "docker";
|
|
632
|
+
},
|
|
633
|
+
refresh(input) {
|
|
634
|
+
const availability = inspectDockerAvailability();
|
|
635
|
+
const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
|
|
636
|
+
const inspectedImages = Object.fromEntries(tags.map((tag) => [tag, inspectDockerImage(tag)]));
|
|
637
|
+
const inspectedManifests = Object.fromEntries(tags.map((tag) => [tag, inspectDockerManifest(tag)]));
|
|
638
|
+
const exists = availability.available && availability.buildxAvailable;
|
|
639
|
+
return {
|
|
640
|
+
...genericObservedState(input, exists, availability.warnings),
|
|
641
|
+
status: !exists ? "error" : tags.some((tag) => inspectedImages[tag] || inspectedManifests[tag]) ? "ready" : "pending",
|
|
642
|
+
live: {
|
|
643
|
+
...input.unit.spec,
|
|
644
|
+
docker: availability,
|
|
645
|
+
tags,
|
|
646
|
+
inspectedImages,
|
|
647
|
+
inspectedManifests
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
},
|
|
651
|
+
diff(input) {
|
|
652
|
+
if (!input.observed.exists) {
|
|
653
|
+
return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
654
|
+
}
|
|
655
|
+
const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
|
|
656
|
+
const inspectedImages = input.observed.live.inspectedImages;
|
|
657
|
+
const inspectedManifests = input.observed.live.inspectedManifests;
|
|
658
|
+
const missing = tags.filter((tag) => !inspectedImages?.[tag] && !inspectedManifests?.[tag]);
|
|
659
|
+
return missing.length > 0 ? { action: "create", reasons: [`missing docker image tags: ${missing.join(", ")}`], before: input.observed.live, after: input.unit.spec } : noopDiff();
|
|
660
|
+
},
|
|
661
|
+
apply(input) {
|
|
662
|
+
if (input.diff.action === "blocked" || input.diff.action === "noop") return genericResult(input);
|
|
663
|
+
const platforms = Array.isArray(input.unit.spec.platforms) ? input.unit.spec.platforms.filter((entry) => typeof entry === "string") : ["linux/amd64"];
|
|
664
|
+
const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
|
|
665
|
+
runDockerImagePrepareCommand(input);
|
|
666
|
+
const result = buildDockerImage({
|
|
667
|
+
tenantRoot: input.context.tenantRoot,
|
|
668
|
+
packageRoot: String(input.unit.spec.packageRoot ?? input.context.tenantRoot),
|
|
669
|
+
context: String(input.unit.spec.context ?? "."),
|
|
670
|
+
dockerfile: String(input.unit.spec.dockerfile ?? "Dockerfile"),
|
|
671
|
+
target: typeof input.unit.spec.target === "string" ? input.unit.spec.target : null,
|
|
672
|
+
platforms,
|
|
673
|
+
tags,
|
|
674
|
+
labels: typeof input.unit.spec.labels === "object" && input.unit.spec.labels ? input.unit.spec.labels : {},
|
|
675
|
+
buildArgs: typeof input.unit.spec.buildArgs === "object" && input.unit.spec.buildArgs ? input.unit.spec.buildArgs : {},
|
|
676
|
+
push: input.unit.spec.push === true,
|
|
677
|
+
load: input.unit.spec.load !== false,
|
|
678
|
+
provenance: input.unit.spec.provenance === false ? false : void 0,
|
|
679
|
+
env: input.context.launchEnv
|
|
680
|
+
});
|
|
681
|
+
return genericResult(input, { ...input.observed.live, build: result });
|
|
682
|
+
},
|
|
683
|
+
verify(input) {
|
|
684
|
+
const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
|
|
685
|
+
const checks = tags.map((tag) => {
|
|
686
|
+
const image = inspectDockerImage(tag);
|
|
687
|
+
const manifest = inspectDockerManifest(tag);
|
|
688
|
+
const ok = Boolean(image || manifest);
|
|
689
|
+
return verificationCheck(`docker-image:${tag}`, `Docker image ${tag} exists locally or as an inspectable manifest`, "cli", {
|
|
690
|
+
exists: ok,
|
|
691
|
+
configured: input.observed.exists,
|
|
692
|
+
ready: ok,
|
|
693
|
+
verified: ok,
|
|
694
|
+
observed: image ?? manifest,
|
|
695
|
+
issues: ok ? [] : [`Docker image ${tag} was not found after build.`]
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
return summarizeVerification(input.unit.unitId, checks.length > 0 ? checks : [
|
|
699
|
+
verificationCheck("docker", "Docker daemon and Buildx are available", "cli", {
|
|
700
|
+
exists: input.observed.exists,
|
|
701
|
+
configured: input.observed.exists,
|
|
702
|
+
ready: input.observed.exists,
|
|
703
|
+
verified: input.observed.exists
|
|
704
|
+
})
|
|
705
|
+
], input.observed.warnings);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function runDockerImagePrepareCommand(input) {
|
|
710
|
+
const prepareCommand = input.unit.spec.prepareCommand;
|
|
711
|
+
if (!prepareCommand || typeof prepareCommand !== "object") return;
|
|
712
|
+
const command = typeof prepareCommand.command === "string" ? String(prepareCommand.command) : null;
|
|
713
|
+
const rawArgs = prepareCommand.args;
|
|
714
|
+
const args = Array.isArray(rawArgs) ? rawArgs.filter((entry) => typeof entry === "string") : [];
|
|
715
|
+
if (!command) return;
|
|
716
|
+
const packageRoot = resolve(input.context.tenantRoot, String(input.unit.spec.packageRoot ?? "."));
|
|
717
|
+
const result = spawnSync(command, args, {
|
|
718
|
+
cwd: packageRoot,
|
|
719
|
+
env: { ...process.env, ...input.context.launchEnv ?? {} },
|
|
720
|
+
encoding: "utf8",
|
|
721
|
+
stdio: "pipe"
|
|
722
|
+
});
|
|
723
|
+
if (result.status !== 0) {
|
|
724
|
+
throw new Error(result.stderr?.trim() || result.stdout?.trim() || `${command} ${args.join(" ")} failed`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function buildLocalDockerComposeAdapter() {
|
|
728
|
+
return {
|
|
729
|
+
providerId: "local",
|
|
730
|
+
unitTypes: ["local-docker-compose"],
|
|
731
|
+
supports(unitType, providerId) {
|
|
732
|
+
return unitType === "local-docker-compose" && providerId === "local";
|
|
733
|
+
},
|
|
734
|
+
refresh(input) {
|
|
735
|
+
const composeFiles = resolveLocalComposeFiles(input);
|
|
736
|
+
const composeFilesExist = composeFiles.length > 0 && composeFiles.every((composeFile) => existsSync(composeFile));
|
|
737
|
+
const cwd = resolve(input.context.tenantRoot, String(input.unit.spec.cwd ?? "."));
|
|
738
|
+
const docker = inspectDockerAvailability();
|
|
739
|
+
const projectName = String(input.unit.spec.projectName ?? "treeseed-capacity-provider");
|
|
740
|
+
const env = buildLocalComposeLaunchEnv(input);
|
|
741
|
+
const profiles = localComposeProfiles(input);
|
|
742
|
+
const buildPolicy = localComposeBuildPolicy(input);
|
|
743
|
+
const ps = composeFilesExist && docker.available ? runDockerCompose({ composeFiles, projectName, cwd, env, profiles, buildPolicy, action: "ps" }) : null;
|
|
744
|
+
const hasContainers = Boolean(ps?.ok && ps.stdout.trim());
|
|
745
|
+
const config = composeFilesExist && docker.available ? runDockerCompose({ composeFiles, projectName, cwd, env, profiles, buildPolicy, action: "config" }) : null;
|
|
746
|
+
const missingComposeFiles = composeFiles.filter((composeFile) => !existsSync(composeFile));
|
|
747
|
+
return {
|
|
748
|
+
...genericObservedState(input, Boolean(composeFilesExist && docker.available), [
|
|
749
|
+
...composeFiles.length > 0 ? [] : ["Compose file is missing: (unset)"],
|
|
750
|
+
...missingComposeFiles.map((composeFile) => `Compose file is missing: ${composeFile}`),
|
|
751
|
+
...docker.warnings
|
|
752
|
+
]),
|
|
753
|
+
status: hasContainers ? "ready" : composeFilesExist && docker.available ? "pending" : "error",
|
|
754
|
+
live: {
|
|
755
|
+
...input.unit.spec,
|
|
756
|
+
composeFile: composeFiles[0] ?? null,
|
|
757
|
+
composeFiles,
|
|
758
|
+
projectName,
|
|
759
|
+
cwd,
|
|
760
|
+
profiles,
|
|
761
|
+
buildPolicy,
|
|
762
|
+
docker,
|
|
763
|
+
ps,
|
|
764
|
+
hasContainers,
|
|
765
|
+
configHash: config?.stdout?.trim() || null
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
diff(input) {
|
|
770
|
+
if (!input.observed.exists) return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
771
|
+
return input.observed.status === "ready" ? noopDiff() : { action: "create", reasons: ["compose services are not running"], before: input.observed.live, after: input.unit.spec };
|
|
772
|
+
},
|
|
773
|
+
apply(input) {
|
|
774
|
+
if (input.diff.action === "blocked" || input.diff.action === "noop") return genericResult(input);
|
|
775
|
+
runLocalComposePrepareCommand(input);
|
|
776
|
+
ensureLocalComposeDataDir(input);
|
|
777
|
+
const composeFiles = observedOrResolvedComposeFiles(input);
|
|
778
|
+
const cwd = String(input.observed.live.cwd ?? input.context.tenantRoot);
|
|
779
|
+
const projectName = String(input.unit.spec.projectName ?? "treeseed-capacity-provider");
|
|
780
|
+
const env = buildLocalComposeLaunchEnv(input);
|
|
781
|
+
const result = runDockerCompose({
|
|
782
|
+
composeFiles,
|
|
783
|
+
projectName,
|
|
784
|
+
cwd,
|
|
785
|
+
env,
|
|
786
|
+
profiles: localComposeProfiles(input),
|
|
787
|
+
buildPolicy: localComposeBuildPolicy(input),
|
|
788
|
+
action: "up"
|
|
789
|
+
});
|
|
790
|
+
if (!result.ok) {
|
|
791
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || "docker compose up failed");
|
|
792
|
+
}
|
|
793
|
+
return genericResult(input, { ...input.observed.live, compose: result });
|
|
794
|
+
},
|
|
795
|
+
async verify(input) {
|
|
796
|
+
const healthChecks = Array.isArray(input.unit.spec.healthChecks) ? input.unit.spec.healthChecks : [];
|
|
797
|
+
const checks = [];
|
|
798
|
+
for (const healthCheck of healthChecks) {
|
|
799
|
+
if (healthCheck.kind === "http" && typeof healthCheck.url === "string") {
|
|
800
|
+
const health = await checkHttpHealthWithRetry(healthCheck.url);
|
|
801
|
+
checks.push(verificationCheck(String(healthCheck.id ?? healthCheck.url), `HTTP health ${healthCheck.url}`, "api", {
|
|
802
|
+
exists: health.ok,
|
|
803
|
+
configured: true,
|
|
804
|
+
ready: health.ok,
|
|
805
|
+
verified: health.ok,
|
|
806
|
+
observed: health,
|
|
807
|
+
issues: health.ok ? [] : [`Health endpoint ${healthCheck.url} did not respond successfully.`]
|
|
808
|
+
}));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (checks.length === 0) {
|
|
812
|
+
checks.push(verificationCheck("compose", "Docker Compose project is observable", "cli", {
|
|
813
|
+
exists: input.observed.exists,
|
|
814
|
+
configured: input.observed.exists,
|
|
815
|
+
ready: input.observed.status === "ready",
|
|
816
|
+
verified: input.observed.exists && input.observed.status !== "error",
|
|
817
|
+
observed: input.observed.live
|
|
818
|
+
}));
|
|
819
|
+
}
|
|
820
|
+
return summarizeVerification(input.unit.unitId, checks, input.observed.warnings);
|
|
821
|
+
},
|
|
822
|
+
destroy(input) {
|
|
823
|
+
const composeFiles = observedOrResolvedComposeFiles(input);
|
|
824
|
+
const cwd = String(input.observed.live.cwd ?? input.context.tenantRoot);
|
|
825
|
+
const projectName = String(input.unit.spec.projectName ?? "treeseed-capacity-provider");
|
|
826
|
+
if (input.context.dryRun !== true && composeFiles.length > 0 && composeFiles.every((composeFile) => existsSync(composeFile))) {
|
|
827
|
+
const result = runDockerCompose({
|
|
828
|
+
composeFiles,
|
|
829
|
+
projectName,
|
|
830
|
+
cwd,
|
|
831
|
+
env: buildLocalComposeLaunchEnv(input),
|
|
832
|
+
profiles: localComposeProfiles(input),
|
|
833
|
+
buildPolicy: localComposeBuildPolicy(input),
|
|
834
|
+
action: "down"
|
|
835
|
+
});
|
|
836
|
+
if (!result.ok) {
|
|
837
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || "docker compose down failed");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return genericResult({
|
|
841
|
+
...input,
|
|
842
|
+
diff: { action: "delete", reasons: ["selected local compose project for destroy"], before: input.observed.live, after: {} }
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
function ensureLocalComposeDataDir(input) {
|
|
848
|
+
const dataDir = typeof input.unit.spec.dataDir === "string" ? input.unit.spec.dataDir.trim() : "";
|
|
849
|
+
if (!dataDir) return;
|
|
850
|
+
mkdirSync(resolve(input.context.tenantRoot, dataDir), { recursive: true });
|
|
851
|
+
}
|
|
852
|
+
function resolveLocalComposeFiles(input) {
|
|
853
|
+
const rawComposeFiles = Array.isArray(input.unit.spec.composeFiles) ? input.unit.spec.composeFiles : typeof input.unit.spec.composeFile === "string" ? [input.unit.spec.composeFile] : [];
|
|
854
|
+
return rawComposeFiles.filter((composeFile) => typeof composeFile === "string" && composeFile.trim().length > 0).map((composeFile) => resolve(input.context.tenantRoot, composeFile));
|
|
855
|
+
}
|
|
856
|
+
function observedOrResolvedComposeFiles(input) {
|
|
857
|
+
const observed = input.observed.live.composeFiles;
|
|
858
|
+
if (Array.isArray(observed)) {
|
|
859
|
+
return observed.filter((composeFile) => typeof composeFile === "string" && composeFile.trim().length > 0);
|
|
860
|
+
}
|
|
861
|
+
return resolveLocalComposeFiles(input);
|
|
862
|
+
}
|
|
863
|
+
function localComposeProfiles(input) {
|
|
864
|
+
return Array.isArray(input.unit.spec.profiles) ? input.unit.spec.profiles.filter((profile) => typeof profile === "string" && profile.trim().length > 0) : [];
|
|
865
|
+
}
|
|
866
|
+
function localComposeBuildPolicy(input) {
|
|
867
|
+
return input.unit.spec.buildPolicy === "never" || input.unit.spec.buildPolicy === "always" ? input.unit.spec.buildPolicy : "missing";
|
|
868
|
+
}
|
|
869
|
+
function runLocalComposePrepareCommand(input) {
|
|
870
|
+
const prepareCommand = input.unit.spec.prepareCommand;
|
|
871
|
+
if (!prepareCommand || typeof prepareCommand !== "object") return;
|
|
872
|
+
const command = typeof prepareCommand.command === "string" ? String(prepareCommand.command) : null;
|
|
873
|
+
const rawArgs = prepareCommand.args;
|
|
874
|
+
const args = Array.isArray(rawArgs) ? rawArgs.filter((entry) => typeof entry === "string") : [];
|
|
875
|
+
if (!command) return;
|
|
876
|
+
const result = spawnSync(command, args, {
|
|
877
|
+
cwd: input.context.tenantRoot,
|
|
878
|
+
env: buildLocalComposeLaunchEnv(input),
|
|
879
|
+
encoding: "utf8",
|
|
880
|
+
stdio: "pipe"
|
|
881
|
+
});
|
|
882
|
+
if (result.status !== 0) {
|
|
883
|
+
throw new Error(result.stderr?.trim() || result.stdout?.trim() || `${command} ${args.join(" ")} failed`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
async function checkHttpHealthWithRetry(url, attempts = 90, intervalMs = 2e3) {
|
|
887
|
+
let last = await checkHttpHealth(url);
|
|
888
|
+
for (let attempt = 1; attempt < attempts && !last.ok; attempt += 1) {
|
|
889
|
+
await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
|
|
890
|
+
last = await checkHttpHealth(url);
|
|
891
|
+
}
|
|
892
|
+
return last;
|
|
893
|
+
}
|
|
894
|
+
function buildLocalComposeLaunchEnv(input) {
|
|
895
|
+
const fallback = resolveCapacityProviderLaunchPlan({
|
|
896
|
+
schemaVersion: 1,
|
|
897
|
+
provider: {
|
|
898
|
+
dataDir: ".treeseed/local-capacity-provider/data",
|
|
899
|
+
environment: "local"
|
|
900
|
+
},
|
|
901
|
+
runtime: {
|
|
902
|
+
images: {
|
|
903
|
+
roles: {
|
|
904
|
+
manager: { image: "treeseed/agent-manager", tag: "latest" },
|
|
905
|
+
runner: { image: "treeseed/agent-runner", tag: "latest" }
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
api: {
|
|
909
|
+
hostPort: 4783
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}).composeEnv;
|
|
913
|
+
return {
|
|
914
|
+
...input.context.launchEnv,
|
|
915
|
+
...fallback,
|
|
916
|
+
...typeof input.unit.spec.env === "object" && input.unit.spec.env ? input.unit.spec.env : {}
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
function localContentSpecString(input, key) {
|
|
920
|
+
const value = input.unit.spec[key];
|
|
921
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
922
|
+
}
|
|
923
|
+
function localContentSpecRecord(input, key) {
|
|
924
|
+
const value = input.unit.spec[key];
|
|
925
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
926
|
+
}
|
|
927
|
+
function localContentGitEnvironment(input) {
|
|
928
|
+
const repo = localContentSpecRecord(input, "contentRepository");
|
|
929
|
+
const owner = typeof repo.owner === "string" ? repo.owner : "";
|
|
930
|
+
const name = typeof repo.name === "string" ? repo.name : "";
|
|
931
|
+
if (!owner || !name) return { env: input.context.launchEnv, credential: null };
|
|
932
|
+
const credential = resolveGitHubCredentialForRepository(`${owner}/${name}`, { env: input.context.launchEnv });
|
|
933
|
+
const env = credential.token ? {
|
|
934
|
+
...input.context.launchEnv,
|
|
935
|
+
TREESEED_GITHUB_TOKEN: credential.token,
|
|
936
|
+
GH_TOKEN: credential.token,
|
|
937
|
+
GITHUB_TOKEN: credential.token
|
|
938
|
+
} : input.context.launchEnv;
|
|
939
|
+
return { env, credential };
|
|
940
|
+
}
|
|
941
|
+
function localContentPathInsideTenant(input, targetPath) {
|
|
942
|
+
if (!targetPath) return true;
|
|
943
|
+
const resolvedTarget = resolve(targetPath);
|
|
944
|
+
const resolvedTenant = resolve(input.context.tenantRoot);
|
|
945
|
+
return resolvedTarget === resolvedTenant || resolvedTarget.startsWith(`${resolvedTenant}/`);
|
|
946
|
+
}
|
|
947
|
+
function inspectLocalContentGit(targetPath) {
|
|
948
|
+
const origin = runTreeseedGit(["remote", "get-url", "origin"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim();
|
|
949
|
+
const branch = runTreeseedGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim();
|
|
950
|
+
const status = runTreeseedGit(["status", "--porcelain"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim();
|
|
951
|
+
return {
|
|
952
|
+
isGit: Boolean(runTreeseedGit(["rev-parse", "--is-inside-work-tree"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim()),
|
|
953
|
+
origin: origin || null,
|
|
954
|
+
branch: branch || null,
|
|
955
|
+
dirty: status.length > 0
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function localContentObservedState(input) {
|
|
959
|
+
const targetPath = localContentSpecString(input, "effectiveLocalPath");
|
|
960
|
+
const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
|
|
961
|
+
const executeRequested = input.unit.spec.executeRequested === true;
|
|
962
|
+
const warnings = [];
|
|
963
|
+
if (!localContentPathInsideTenant(input, targetPath)) {
|
|
964
|
+
warnings.push("local content target path is outside the Treeseed workspace");
|
|
965
|
+
return {
|
|
966
|
+
...genericObservedState(input, false, warnings),
|
|
967
|
+
status: "error",
|
|
968
|
+
live: {
|
|
969
|
+
...input.unit.spec,
|
|
970
|
+
targetPath,
|
|
971
|
+
materializationStatus: "blocked"
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
if (materialization === "none" || !targetPath) {
|
|
976
|
+
return {
|
|
977
|
+
...genericObservedState(input, true, warnings),
|
|
978
|
+
live: {
|
|
979
|
+
...input.unit.spec,
|
|
980
|
+
materializationStatus: "not_requested"
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const exists = existsSync(targetPath);
|
|
985
|
+
const stat = exists ? statSync(targetPath) : null;
|
|
986
|
+
if (exists && !stat?.isDirectory()) {
|
|
987
|
+
warnings.push("local content target path exists but is not a directory");
|
|
988
|
+
return {
|
|
989
|
+
...genericObservedState(input, false, warnings),
|
|
990
|
+
status: "error",
|
|
991
|
+
live: {
|
|
992
|
+
...input.unit.spec,
|
|
993
|
+
targetPath,
|
|
994
|
+
materializationStatus: "blocked"
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const git = exists ? inspectLocalContentGit(targetPath) : null;
|
|
999
|
+
if (exists && materialization === "managed_clone" && !git?.isGit) {
|
|
1000
|
+
warnings.push("managed local content target exists but is not a Git worktree");
|
|
1001
|
+
return {
|
|
1002
|
+
...genericObservedState(input, false, warnings),
|
|
1003
|
+
status: "error",
|
|
1004
|
+
live: {
|
|
1005
|
+
...input.unit.spec,
|
|
1006
|
+
targetPath,
|
|
1007
|
+
materializationStatus: "blocked",
|
|
1008
|
+
git
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const status = exists ? materialization === "managed_clone" ? "managed_clone_ready" : materialization === "submodule" ? "submodule_ready" : "existing_path_ready" : materialization === "managed_clone" ? "managed_clone_missing" : materialization === "submodule" ? "submodule_missing" : "existing_path_missing";
|
|
1013
|
+
if (git?.dirty) {
|
|
1014
|
+
warnings.push("local content Git worktree has uncommitted changes; Treeseed will not reset or overwrite it");
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
...genericObservedState(input, exists || !executeRequested, warnings),
|
|
1018
|
+
status: exists || !executeRequested ? "ready" : "pending",
|
|
1019
|
+
live: {
|
|
1020
|
+
...input.unit.spec,
|
|
1021
|
+
targetPath,
|
|
1022
|
+
materializationStatus: status,
|
|
1023
|
+
git
|
|
1024
|
+
},
|
|
1025
|
+
locators: {
|
|
1026
|
+
unitId: input.unit.unitId,
|
|
1027
|
+
targetPath,
|
|
1028
|
+
origin: git?.origin ?? null
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function expectedLocalContentOrigin(input) {
|
|
1033
|
+
const repo = localContentSpecRecord(input, "contentRepository");
|
|
1034
|
+
return typeof repo.gitUrl === "string" ? repo.gitUrl : null;
|
|
1035
|
+
}
|
|
1036
|
+
function originMatches(observed, expected) {
|
|
1037
|
+
const liveGit = observed.live.git && typeof observed.live.git === "object" ? observed.live.git : null;
|
|
1038
|
+
const origin = typeof liveGit?.origin === "string" ? liveGit.origin : null;
|
|
1039
|
+
return !origin || !expected || origin === expected || origin.replace(/\.git$/u, "") === expected.replace(/\.git$/u, "");
|
|
1040
|
+
}
|
|
1041
|
+
function githubRepoSlugFromUrl(url) {
|
|
1042
|
+
if (!url) return null;
|
|
1043
|
+
if (!/^(https?:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)/iu.test(url)) return null;
|
|
1044
|
+
const raw = url.replace(/^https?:\/\/github\.com\//iu, "").replace(/^git@github\.com:/iu, "").replace(/^ssh:\/\/git@github\.com\//iu, "").replace(/\.git$/iu, "").replace(/^\/+|\/+$/gu, "");
|
|
1045
|
+
const [owner, name, ...extra] = raw.split("/").filter(Boolean);
|
|
1046
|
+
return owner && name && extra.length === 0 ? `${owner}/${name}` : null;
|
|
1047
|
+
}
|
|
1048
|
+
function runLocalContentClone(input, targetPath, gitUrl, branch) {
|
|
1049
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1050
|
+
const { env, credential } = localContentGitEnvironment(input);
|
|
1051
|
+
const repoSlug = githubRepoSlugFromUrl(gitUrl);
|
|
1052
|
+
if (repoSlug && credential?.configured) {
|
|
1053
|
+
const args2 = ["repo", "clone", repoSlug, targetPath, "--"];
|
|
1054
|
+
if (branch) args2.push("--branch", branch);
|
|
1055
|
+
const result2 = spawnSync("gh", args2, {
|
|
1056
|
+
cwd: input.context.tenantRoot,
|
|
1057
|
+
env,
|
|
1058
|
+
encoding: "utf8",
|
|
1059
|
+
stdio: "pipe",
|
|
1060
|
+
timeout: 12e4,
|
|
1061
|
+
maxBuffer: 1024 * 1024 * 16
|
|
1062
|
+
});
|
|
1063
|
+
if (result2.status !== 0) {
|
|
1064
|
+
throw new Error((result2.stderr || result2.stdout || "GitHub CLI clone failed. Run `npx trsd install --json` and configure TREESEED_GITHUB_TOKEN if this repository is private.").trim());
|
|
1065
|
+
}
|
|
1066
|
+
return {
|
|
1067
|
+
status: result2.status,
|
|
1068
|
+
stdout: result2.stdout ?? "",
|
|
1069
|
+
stderr: result2.stderr ?? "",
|
|
1070
|
+
tool: "gh",
|
|
1071
|
+
credentialEnvName: credential.envName,
|
|
1072
|
+
fallbackUsed: credential.fallbackUsed
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
const args = ["clone"];
|
|
1076
|
+
if (branch) args.push("--branch", branch);
|
|
1077
|
+
args.push(gitUrl, targetPath);
|
|
1078
|
+
const result = runTreeseedGit(args, {
|
|
1079
|
+
cwd: input.context.tenantRoot,
|
|
1080
|
+
mode: "mutate",
|
|
1081
|
+
env,
|
|
1082
|
+
timeoutMs: 12e4,
|
|
1083
|
+
maxBuffer: 1024 * 1024 * 16
|
|
1084
|
+
});
|
|
1085
|
+
return {
|
|
1086
|
+
...result,
|
|
1087
|
+
tool: "git",
|
|
1088
|
+
credentialEnvName: credential?.envName ?? null,
|
|
1089
|
+
fallbackUsed: credential?.fallbackUsed ?? false
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
function buildLocalContentMaterializationAdapter() {
|
|
1093
|
+
return {
|
|
1094
|
+
providerId: "local",
|
|
1095
|
+
unitTypes: ["local-content-materialization"],
|
|
1096
|
+
supports(unitType, providerId) {
|
|
1097
|
+
return unitType === "local-content-materialization" && providerId === "local";
|
|
1098
|
+
},
|
|
1099
|
+
refresh(input) {
|
|
1100
|
+
return localContentObservedState(input);
|
|
1101
|
+
},
|
|
1102
|
+
diff(input) {
|
|
1103
|
+
const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
|
|
1104
|
+
const executeRequested = input.unit.spec.executeRequested === true;
|
|
1105
|
+
const expectedOrigin = expectedLocalContentOrigin(input);
|
|
1106
|
+
if (input.observed.status === "error") {
|
|
1107
|
+
return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
|
|
1108
|
+
}
|
|
1109
|
+
if (materialization === "existing_path" && !input.observed.exists) {
|
|
1110
|
+
return { action: "blocked", reasons: ["existing local content path is missing; choose preview/edit with managed_clone or configure the path"], before: input.observed.live, after: input.unit.spec };
|
|
1111
|
+
}
|
|
1112
|
+
if (input.observed.exists && !originMatches(input.observed, expectedOrigin)) {
|
|
1113
|
+
return { action: "blocked", reasons: ["managed local content origin does not match the configured content repository"], before: input.observed.live, after: input.unit.spec };
|
|
1114
|
+
}
|
|
1115
|
+
if (!executeRequested) {
|
|
1116
|
+
return noopDiff();
|
|
1117
|
+
}
|
|
1118
|
+
if (!input.observed.exists && (materialization === "managed_clone" || materialization === "submodule")) {
|
|
1119
|
+
return { action: "create", reasons: [`${materialization} local content is requested but missing`], before: input.observed.live, after: input.unit.spec };
|
|
1120
|
+
}
|
|
1121
|
+
if (input.observed.exists && materialization === "managed_clone") {
|
|
1122
|
+
return { action: "update", reasons: ["managed local content exists; fetch without resetting local work"], before: input.observed.live, after: input.unit.spec };
|
|
1123
|
+
}
|
|
1124
|
+
return noopDiff();
|
|
1125
|
+
},
|
|
1126
|
+
apply(input) {
|
|
1127
|
+
if (input.diff.action === "noop" || input.diff.action === "blocked") return genericResult(input);
|
|
1128
|
+
const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
|
|
1129
|
+
const targetPath = localContentSpecString(input, "effectiveLocalPath");
|
|
1130
|
+
const expectedOrigin = expectedLocalContentOrigin(input);
|
|
1131
|
+
const repo = localContentSpecRecord(input, "contentRepository");
|
|
1132
|
+
const branch = typeof repo.defaultBranch === "string" ? repo.defaultBranch : null;
|
|
1133
|
+
if (!targetPath) return genericResult(input);
|
|
1134
|
+
if (materialization === "submodule") {
|
|
1135
|
+
const submodulePath = typeof repo.submodulePath === "string" ? repo.submodulePath : localContentSpecString(input, "contentPath");
|
|
1136
|
+
if (!submodulePath) return genericResult(input);
|
|
1137
|
+
const { env } = localContentGitEnvironment(input);
|
|
1138
|
+
const submodule = runTreeseedGit(["submodule", "update", "--init", "--", submodulePath], {
|
|
1139
|
+
cwd: input.context.tenantRoot,
|
|
1140
|
+
mode: "mutate",
|
|
1141
|
+
env,
|
|
1142
|
+
timeoutMs: 12e4,
|
|
1143
|
+
maxBuffer: 1024 * 1024 * 16
|
|
1144
|
+
});
|
|
1145
|
+
return genericResult(input, { ...input.observed.live, submodule: { status: submodule.status } });
|
|
1146
|
+
}
|
|
1147
|
+
if (materialization === "managed_clone" && expectedOrigin) {
|
|
1148
|
+
const result = input.observed.exists ? runTreeseedGit(["fetch", "origin"], {
|
|
1149
|
+
cwd: targetPath,
|
|
1150
|
+
mode: "mutate",
|
|
1151
|
+
env: localContentGitEnvironment(input).env,
|
|
1152
|
+
timeoutMs: 12e4,
|
|
1153
|
+
maxBuffer: 1024 * 1024 * 16
|
|
1154
|
+
}) : runLocalContentClone(input, targetPath, expectedOrigin, branch);
|
|
1155
|
+
return genericResult(input, {
|
|
1156
|
+
...input.observed.live,
|
|
1157
|
+
materializedAt: nowIso(),
|
|
1158
|
+
gitOperation: {
|
|
1159
|
+
status: result.status,
|
|
1160
|
+
tool: "tool" in result ? result.tool : "git",
|
|
1161
|
+
credentialEnvName: "credentialEnvName" in result ? result.credentialEnvName : null,
|
|
1162
|
+
fallbackUsed: "fallbackUsed" in result ? result.fallbackUsed : false
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
return genericResult(input);
|
|
1167
|
+
},
|
|
1168
|
+
verify(input) {
|
|
1169
|
+
const targetPath = localContentSpecString(input, "effectiveLocalPath");
|
|
1170
|
+
const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
|
|
1171
|
+
const exists = !targetPath || existsSync(targetPath);
|
|
1172
|
+
const expectedOrigin = expectedLocalContentOrigin(input);
|
|
1173
|
+
const originOk = originMatches(input.observed, expectedOrigin);
|
|
1174
|
+
const checks = [
|
|
1175
|
+
verificationCheck("local-content.path", "Local content path policy is satisfied", "sdk", {
|
|
1176
|
+
exists: materialization === "none" ? true : exists,
|
|
1177
|
+
configured: true,
|
|
1178
|
+
ready: materialization === "none" || exists,
|
|
1179
|
+
verified: materialization === "none" || exists,
|
|
1180
|
+
observed: targetPath,
|
|
1181
|
+
issues: materialization !== "none" && !exists ? ["Local content path is missing."] : []
|
|
1182
|
+
}),
|
|
1183
|
+
verificationCheck("local-content.origin", "Local content origin matches the configured repository when observable", "sdk", {
|
|
1184
|
+
exists: true,
|
|
1185
|
+
configured: originOk,
|
|
1186
|
+
ready: originOk,
|
|
1187
|
+
verified: originOk,
|
|
1188
|
+
expected: expectedOrigin,
|
|
1189
|
+
observed: input.observed.live.git,
|
|
1190
|
+
issues: originOk ? [] : ["Origin does not match configured content repository."]
|
|
1191
|
+
})
|
|
1192
|
+
];
|
|
1193
|
+
return summarizeVerification(input.unit.unitId, checks, input.observed.warnings);
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
function buildLocalProcessAdapter() {
|
|
1198
|
+
return {
|
|
1199
|
+
providerId: "local",
|
|
1200
|
+
unitTypes: ["local-process"],
|
|
1201
|
+
supports(unitType, providerId) {
|
|
1202
|
+
return unitType === "local-process" && providerId === "local";
|
|
1203
|
+
},
|
|
1204
|
+
async refresh(input) {
|
|
1205
|
+
const surfaces = Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [String(input.unit.spec.processId ?? input.unit.logicalName)];
|
|
1206
|
+
const status = await runManagedDevAction({
|
|
1207
|
+
tenantRoot: input.context.tenantRoot,
|
|
1208
|
+
action: "status",
|
|
1209
|
+
surfaces,
|
|
1210
|
+
options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
|
|
1211
|
+
env: input.context.launchEnv
|
|
1212
|
+
});
|
|
1213
|
+
const safeStatus = sanitizeManagedDevObservation(status);
|
|
1214
|
+
return {
|
|
1215
|
+
...genericObservedState(input, true, safeStatus.ok ? [] : [safeStatus.output || "managed dev status failed"]),
|
|
1216
|
+
status: safeStatus.ok ? "ready" : "pending",
|
|
1217
|
+
live: {
|
|
1218
|
+
...input.unit.spec,
|
|
1219
|
+
status: safeStatus
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
},
|
|
1223
|
+
diff(input) {
|
|
1224
|
+
return input.observed.status === "ready" ? noopDiff() : { action: "create", reasons: ["local process is not reported ready"], before: input.observed.live, after: input.unit.spec };
|
|
1225
|
+
},
|
|
1226
|
+
async apply(input) {
|
|
1227
|
+
if (input.diff.action === "noop") return genericResult(input);
|
|
1228
|
+
const surfaces = Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [String(input.unit.spec.processId ?? input.unit.logicalName)];
|
|
1229
|
+
const result = await runManagedDevAction({
|
|
1230
|
+
tenantRoot: input.context.tenantRoot,
|
|
1231
|
+
action: input.unit.spec.action === "restart" ? "restart" : "start",
|
|
1232
|
+
surfaces,
|
|
1233
|
+
options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
|
|
1234
|
+
env: input.context.launchEnv
|
|
1235
|
+
});
|
|
1236
|
+
return genericResult(input, { ...input.observed.live, managedDev: result });
|
|
1237
|
+
},
|
|
1238
|
+
async verify(input) {
|
|
1239
|
+
const status = await runManagedDevAction({
|
|
1240
|
+
tenantRoot: input.context.tenantRoot,
|
|
1241
|
+
action: "status",
|
|
1242
|
+
surfaces: Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [],
|
|
1243
|
+
options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
|
|
1244
|
+
env: input.context.launchEnv
|
|
1245
|
+
});
|
|
1246
|
+
const safeStatus = sanitizeManagedDevObservation(status);
|
|
1247
|
+
return summarizeVerification(input.unit.unitId, [
|
|
1248
|
+
verificationCheck("managed-dev-status", "Managed dev status is observable", "sdk", {
|
|
1249
|
+
exists: safeStatus.ok,
|
|
1250
|
+
configured: true,
|
|
1251
|
+
ready: safeStatus.ok,
|
|
1252
|
+
verified: safeStatus.ok,
|
|
1253
|
+
observed: safeStatus.parsed ?? safeStatus.output,
|
|
1254
|
+
issues: safeStatus.ok ? [] : [safeStatus.output || "managed dev status failed"]
|
|
1255
|
+
})
|
|
1256
|
+
], input.observed.warnings);
|
|
1257
|
+
},
|
|
1258
|
+
async destroy(input) {
|
|
1259
|
+
const surfaces = Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [String(input.unit.spec.processId ?? input.unit.logicalName)];
|
|
1260
|
+
if (input.context.dryRun !== true) {
|
|
1261
|
+
await runManagedDevAction({
|
|
1262
|
+
tenantRoot: input.context.tenantRoot,
|
|
1263
|
+
action: "stop",
|
|
1264
|
+
surfaces,
|
|
1265
|
+
options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
|
|
1266
|
+
env: input.context.launchEnv
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
return genericResult({
|
|
1270
|
+
...input,
|
|
1271
|
+
diff: { action: "delete", reasons: ["selected local process for stop"], before: input.observed.live, after: {} }
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
function sanitizeManagedDevObservation(value) {
|
|
1277
|
+
if (Array.isArray(value)) {
|
|
1278
|
+
return value.map((entry) => sanitizeManagedDevObservation(entry));
|
|
1279
|
+
}
|
|
1280
|
+
if (!value || typeof value !== "object") {
|
|
1281
|
+
return value;
|
|
1282
|
+
}
|
|
1283
|
+
const result = {};
|
|
1284
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1285
|
+
if (key === "env") {
|
|
1286
|
+
result.redactedEnv = redactEnvironmentRecord(entry);
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
result[key] = sanitizeManagedDevObservation(entry);
|
|
1290
|
+
}
|
|
1291
|
+
return result;
|
|
1292
|
+
}
|
|
1293
|
+
function redactEnvironmentRecord(value) {
|
|
1294
|
+
if (!value || typeof value !== "object") {
|
|
1295
|
+
return {};
|
|
1296
|
+
}
|
|
1297
|
+
return Object.fromEntries(
|
|
1298
|
+
Object.entries(value).map(([key, entry]) => [
|
|
1299
|
+
key,
|
|
1300
|
+
key === "PATH" || key === "NODE_ENV" ? String(entry ?? "") : "<redacted>"
|
|
1301
|
+
])
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
function buildCapacityProviderAdapter(providerId) {
|
|
1305
|
+
return {
|
|
1306
|
+
providerId,
|
|
1307
|
+
unitTypes: ["capacity-provider"],
|
|
1308
|
+
supports(unitType, candidateProviderId) {
|
|
1309
|
+
return unitType === "capacity-provider" && candidateProviderId === providerId;
|
|
1310
|
+
},
|
|
1311
|
+
refresh(input) {
|
|
1312
|
+
const dependencies = input.unit.dependencies;
|
|
1313
|
+
return {
|
|
1314
|
+
...genericObservedState(input),
|
|
1315
|
+
live: {
|
|
1316
|
+
...input.unit.spec,
|
|
1317
|
+
dependencies
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
},
|
|
1321
|
+
diff() {
|
|
1322
|
+
return noopDiff();
|
|
1323
|
+
},
|
|
1324
|
+
apply(input) {
|
|
1325
|
+
return genericResult(input);
|
|
1326
|
+
},
|
|
1327
|
+
async verify(input) {
|
|
1328
|
+
const dependencyResults = input.context.session.get("treeseed:verification-results");
|
|
1329
|
+
const checks = input.unit.dependencies.map((dependency) => {
|
|
1330
|
+
const verification = dependencyResults?.get(dependency);
|
|
1331
|
+
const ok = verification ? verification.verified === true : true;
|
|
1332
|
+
return verificationCheck(`dependency:${dependency}`, `Capacity provider dependency ${dependency} is verified`, "derived", {
|
|
1333
|
+
exists: ok,
|
|
1334
|
+
configured: ok,
|
|
1335
|
+
ready: ok,
|
|
1336
|
+
verified: ok,
|
|
1337
|
+
observed: verification ?? null,
|
|
1338
|
+
issues: ok ? [] : [`Dependency ${dependency} is not verified.`]
|
|
1339
|
+
});
|
|
1340
|
+
});
|
|
1341
|
+
const healthEndpoint = typeof input.unit.spec.healthEndpoint === "string" ? input.unit.spec.healthEndpoint : null;
|
|
1342
|
+
if (healthEndpoint) {
|
|
1343
|
+
const health = await checkHttpHealth(healthEndpoint);
|
|
1344
|
+
checks.push(verificationCheck("capacity-provider-health", `Capacity provider health endpoint ${healthEndpoint} responds`, "api", {
|
|
1345
|
+
exists: health.ok,
|
|
1346
|
+
configured: true,
|
|
1347
|
+
ready: health.ok,
|
|
1348
|
+
verified: health.ok,
|
|
1349
|
+
observed: health,
|
|
1350
|
+
issues: health.ok ? [] : [`Capacity provider health endpoint ${healthEndpoint} did not respond successfully.`]
|
|
1351
|
+
}));
|
|
1352
|
+
}
|
|
1353
|
+
if (checks.length === 0) {
|
|
1354
|
+
checks.push(verificationCheck("capacity-provider", "Capacity provider desired topology is observable", "derived", {
|
|
1355
|
+
exists: input.observed.exists,
|
|
1356
|
+
configured: input.observed.exists,
|
|
1357
|
+
ready: input.observed.status !== "error",
|
|
1358
|
+
verified: input.observed.exists && input.observed.status !== "error",
|
|
1359
|
+
observed: input.observed.live
|
|
1360
|
+
}));
|
|
1361
|
+
}
|
|
1362
|
+
return summarizeVerification(input.unit.unitId, checks, input.observed.warnings);
|
|
1363
|
+
},
|
|
1364
|
+
destroy(input) {
|
|
1365
|
+
return genericResult({
|
|
1366
|
+
...input,
|
|
1367
|
+
diff: { action: "delete", reasons: ["selected capacity provider for destroy"], before: input.observed.live, after: {} }
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
function recordValue(value) {
|
|
1373
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1374
|
+
}
|
|
1375
|
+
function nonEmptyString(value) {
|
|
1376
|
+
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
1377
|
+
}
|
|
1378
|
+
function localTreeDxProjects(value) {
|
|
1379
|
+
return Array.isArray(value) ? value.flatMap((entry) => {
|
|
1380
|
+
const record = recordValue(entry);
|
|
1381
|
+
const slug = nonEmptyString(record.slug);
|
|
1382
|
+
const repositoryName = nonEmptyString(record.repositoryName);
|
|
1383
|
+
const repositoryId = nonEmptyString(record.repositoryId) || repositoryName;
|
|
1384
|
+
const localRoot = nonEmptyString(record.localRoot);
|
|
1385
|
+
const contentPath = nonEmptyString(record.contentPath);
|
|
1386
|
+
if (!slug || !repositoryName || !repositoryId || !localRoot || !contentPath) return [];
|
|
1387
|
+
return [{
|
|
1388
|
+
projectKey: nonEmptyString(record.projectKey) || void 0,
|
|
1389
|
+
slug,
|
|
1390
|
+
repositoryName,
|
|
1391
|
+
repositoryId,
|
|
1392
|
+
localRoot,
|
|
1393
|
+
contentPath,
|
|
1394
|
+
defaultRef: nonEmptyString(record.defaultRef) || "refs/heads/main",
|
|
1395
|
+
seedPaths: Array.isArray(record.seedPaths) ? record.seedPaths.filter((item) => typeof item === "string" && item.trim().length > 0) : []
|
|
1396
|
+
}];
|
|
1397
|
+
}) : [];
|
|
1398
|
+
}
|
|
1399
|
+
function base64urlJson(value) {
|
|
1400
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
1401
|
+
}
|
|
1402
|
+
function mintLocalTreeDxJwt(auth) {
|
|
1403
|
+
const secret = nonEmptyString(auth.TREESEED_TREEDX_JWT_HS256_SECRET);
|
|
1404
|
+
const issuer = nonEmptyString(auth.TREESEED_TREEDX_JWT_ISSUER);
|
|
1405
|
+
const audience = nonEmptyString(auth.TREESEED_TREEDX_JWT_AUDIENCE);
|
|
1406
|
+
if (!secret || !issuer || !audience) return "";
|
|
1407
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1408
|
+
const actorId = nonEmptyString(auth.TREESEED_TREEDX_PROXY_ACTOR_ID) || "treeseed-sdk-reconciler";
|
|
1409
|
+
const tenantId = nonEmptyString(auth.TREESEED_TREEDX_PROXY_TENANT_ID) || "treeseed-control-plane";
|
|
1410
|
+
const payload = {
|
|
1411
|
+
iss: issuer,
|
|
1412
|
+
aud: audience,
|
|
1413
|
+
sub: actorId,
|
|
1414
|
+
jti: randomUUID(),
|
|
1415
|
+
iat: now,
|
|
1416
|
+
nbf: now - 5,
|
|
1417
|
+
exp: now + 3600,
|
|
1418
|
+
treedx_actor_id: actorId,
|
|
1419
|
+
treedx_tenant_id: tenantId,
|
|
1420
|
+
treedx_repo_ids: ["*"],
|
|
1421
|
+
treedx_capabilities: ["*"],
|
|
1422
|
+
treedx_refs: ["*"],
|
|
1423
|
+
treedx_paths: ["**"]
|
|
1424
|
+
};
|
|
1425
|
+
const signingInput = `${base64urlJson({ alg: "HS256", typ: "JWT" })}.${base64urlJson(payload)}`;
|
|
1426
|
+
const signature = createHmac("sha256", secret).update(signingInput).digest("base64url");
|
|
1427
|
+
return `${signingInput}.${signature}`;
|
|
1428
|
+
}
|
|
1429
|
+
async function listLocalTreeDxSeedFiles(project) {
|
|
1430
|
+
const paths = project.seedPaths?.length ? project.seedPaths : [project.contentPath];
|
|
1431
|
+
const files = [];
|
|
1432
|
+
const visit = async (absoluteDir, root) => {
|
|
1433
|
+
if (!existsSync(absoluteDir)) return;
|
|
1434
|
+
for (const entry of await readdir(absoluteDir, { withFileTypes: true })) {
|
|
1435
|
+
const absolutePath = resolve(absoluteDir, entry.name);
|
|
1436
|
+
if (entry.isDirectory()) {
|
|
1437
|
+
await visit(absolutePath, root);
|
|
1438
|
+
} else if (entry.isFile() && /\.(md|mdx)$/iu.test(entry.name)) {
|
|
1439
|
+
files.push({
|
|
1440
|
+
path: relative(root, absolutePath).replace(/\\/gu, "/"),
|
|
1441
|
+
content: await readFile(absolutePath, "utf8")
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
for (const seedPath of paths) {
|
|
1447
|
+
await visit(resolve(project.localRoot, seedPath), project.localRoot);
|
|
1448
|
+
}
|
|
1449
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
1450
|
+
}
|
|
1451
|
+
async function ensureLocalTreeDxProjectRepository(client, project) {
|
|
1452
|
+
const repositories = recordValue(await client.repositories.list()).repos;
|
|
1453
|
+
const existing = Array.isArray(repositories) ? repositories.find((entry) => recordValue(entry).repositoryName === project.repositoryName || recordValue(entry).name === project.repositoryName) : null;
|
|
1454
|
+
const existingRepoId = nonEmptyString(recordValue(existing).repoId);
|
|
1455
|
+
if (existingRepoId) return { repoId: existingRepoId, created: false };
|
|
1456
|
+
const registered = recordValue(await client.repositories.register({
|
|
1457
|
+
name: project.repositoryName,
|
|
1458
|
+
repositoryName: project.repositoryName,
|
|
1459
|
+
createIfMissing: true,
|
|
1460
|
+
defaultRef: project.defaultRef ?? "refs/heads/main"
|
|
1461
|
+
}));
|
|
1462
|
+
const repo = recordValue(registered.repo);
|
|
1463
|
+
const repoId = nonEmptyString(repo.repoId);
|
|
1464
|
+
if (!repoId) throw new Error(`TreeDX did not return a repository id for ${project.repositoryName}.`);
|
|
1465
|
+
return { repoId, created: true };
|
|
1466
|
+
}
|
|
1467
|
+
async function syncLocalTreeDxProjectContent(client, project) {
|
|
1468
|
+
const files = await listLocalTreeDxSeedFiles(project);
|
|
1469
|
+
const repository = await ensureLocalTreeDxProjectRepository(client, project);
|
|
1470
|
+
if (files.length === 0) {
|
|
1471
|
+
return { project: project.slug, repositoryId: repository.repoId, repositoryName: project.repositoryName, files: 0, committed: false };
|
|
1472
|
+
}
|
|
1473
|
+
const workspace = recordValue(await client.request({
|
|
1474
|
+
method: "POST",
|
|
1475
|
+
path: `/api/v1/repos/${encodeURIComponent(repository.repoId)}/workspaces`,
|
|
1476
|
+
body: {
|
|
1477
|
+
baseRef: project.defaultRef ?? "refs/heads/main",
|
|
1478
|
+
branchName: `refs/heads/treeseed-local-sync/${project.slug}/${Date.now().toString(36)}`,
|
|
1479
|
+
mode: "writable",
|
|
1480
|
+
allowedPaths: [`${project.contentPath.replace(/\/+$/u, "")}/**`],
|
|
1481
|
+
ttlSeconds: 900
|
|
1482
|
+
}
|
|
1483
|
+
}));
|
|
1484
|
+
const workspaceId = nonEmptyString(workspace.workspaceId);
|
|
1485
|
+
if (!workspaceId) throw new Error(`TreeDX did not return a workspace id for ${project.slug}.`);
|
|
1486
|
+
try {
|
|
1487
|
+
for (const file of files) {
|
|
1488
|
+
await client.request({
|
|
1489
|
+
method: "PUT",
|
|
1490
|
+
path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/files`,
|
|
1491
|
+
query: { path: file.path },
|
|
1492
|
+
body: { content: file.content, encoding: "utf8" }
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
const commit = recordValue(await client.request({
|
|
1496
|
+
method: "POST",
|
|
1497
|
+
path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/commit`,
|
|
1498
|
+
body: {
|
|
1499
|
+
message: `Sync ${project.slug} knowledge hub seed content`,
|
|
1500
|
+
author: { name: "TreeSeed Reconciler", email: "reconciler@treeseed.local" }
|
|
1501
|
+
}
|
|
1502
|
+
}));
|
|
1503
|
+
const graphRefresh = recordValue(await client.request({
|
|
1504
|
+
method: "POST",
|
|
1505
|
+
path: `/api/v1/repos/${encodeURIComponent(repository.repoId)}/graph/refresh`,
|
|
1506
|
+
body: {
|
|
1507
|
+
paths: project.seedPaths?.length ? project.seedPaths.map((seedPath) => `${seedPath.replace(/\/+$/u, "")}/**`) : [`${project.contentPath.replace(/\/+$/u, "")}/**`]
|
|
1508
|
+
}
|
|
1509
|
+
}).catch((error) => ({
|
|
1510
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1511
|
+
})));
|
|
1512
|
+
return {
|
|
1513
|
+
project: project.slug,
|
|
1514
|
+
repositoryId: repository.repoId,
|
|
1515
|
+
repositoryName: project.repositoryName,
|
|
1516
|
+
files: files.length,
|
|
1517
|
+
committed: true,
|
|
1518
|
+
commitSha: nonEmptyString(commit.commitSha) || null,
|
|
1519
|
+
graphRefresh
|
|
1520
|
+
};
|
|
1521
|
+
} finally {
|
|
1522
|
+
await client.request({ method: "POST", path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/close`, body: {} }).catch(() => null);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
async function ensureLocalTreeDxProjectRepositoryRef(client, project) {
|
|
1526
|
+
const repository = await ensureLocalTreeDxProjectRepository(client, project);
|
|
1527
|
+
return {
|
|
1528
|
+
project: project.slug,
|
|
1529
|
+
repositoryId: repository.repoId,
|
|
1530
|
+
repositoryName: project.repositoryName,
|
|
1531
|
+
files: 0,
|
|
1532
|
+
committed: false,
|
|
1533
|
+
skippedContentSync: true
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
function buildLocalTreeDxAdapter() {
|
|
1537
|
+
return {
|
|
1538
|
+
providerId: "local",
|
|
1539
|
+
unitTypes: ["local-treedx"],
|
|
1540
|
+
supports(unitType, providerId) {
|
|
1541
|
+
return unitType === "local-treedx" && providerId === "local";
|
|
1542
|
+
},
|
|
1543
|
+
refresh(input) {
|
|
1544
|
+
return {
|
|
1545
|
+
...genericObservedState(input),
|
|
1546
|
+
live: {
|
|
1547
|
+
...input.unit.spec,
|
|
1548
|
+
dependencies: input.unit.dependencies
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
},
|
|
1552
|
+
diff(input) {
|
|
1553
|
+
const projects = localTreeDxProjects(input.unit.spec.projects);
|
|
1554
|
+
if (projects.length === 0) return noopDiff();
|
|
1555
|
+
return {
|
|
1556
|
+
action: "update",
|
|
1557
|
+
reasons: ["local TreeDX project content repositories must be registered and seeded through TreeDX"],
|
|
1558
|
+
before: input.observed.live,
|
|
1559
|
+
after: input.unit.spec
|
|
1560
|
+
};
|
|
1561
|
+
},
|
|
1562
|
+
async apply(input) {
|
|
1563
|
+
if (input.diff.action === "noop" || input.diff.action === "blocked") return genericResult(input);
|
|
1564
|
+
const baseUrl = nonEmptyString(input.unit.spec.baseUrl);
|
|
1565
|
+
const token = nonEmptyString(input.unit.spec.token) || mintLocalTreeDxJwt(recordValue(input.unit.spec.auth));
|
|
1566
|
+
const projects = localTreeDxProjects(input.unit.spec.projects);
|
|
1567
|
+
if (!baseUrl || !token || projects.length === 0) return genericResult(input);
|
|
1568
|
+
const client = new TreeDxClient({ baseUrl, token });
|
|
1569
|
+
const syncedProjects = [];
|
|
1570
|
+
const syncSeedContent = input.unit.spec.syncSeedContent !== false;
|
|
1571
|
+
for (const project of projects) {
|
|
1572
|
+
syncedProjects.push(syncSeedContent ? await syncLocalTreeDxProjectContent(client, project) : await ensureLocalTreeDxProjectRepositoryRef(client, project));
|
|
1573
|
+
}
|
|
1574
|
+
return genericResult(input, { ...input.observed.live, syncedProjects });
|
|
1575
|
+
},
|
|
1576
|
+
async verify(input) {
|
|
1577
|
+
const dependencyResults = input.context.session.get("treeseed:verification-results");
|
|
1578
|
+
const dependencyChecks = input.unit.dependencies.map((dependency) => {
|
|
1579
|
+
const verification = dependencyResults?.get(dependency);
|
|
1580
|
+
const ok = verification ? verification.verified === true : true;
|
|
1581
|
+
return verificationCheck(`dependency:${dependency}`, `TreeDX dependency ${dependency} is verified`, "derived", {
|
|
1582
|
+
exists: ok,
|
|
1583
|
+
configured: ok,
|
|
1584
|
+
ready: ok,
|
|
1585
|
+
verified: ok,
|
|
1586
|
+
observed: verification ?? null,
|
|
1587
|
+
issues: ok ? [] : [`Dependency ${dependency} is not verified.`]
|
|
1588
|
+
});
|
|
1589
|
+
});
|
|
1590
|
+
const healthEndpoint = typeof input.unit.spec.healthEndpoint === "string" ? input.unit.spec.healthEndpoint : null;
|
|
1591
|
+
if (healthEndpoint) {
|
|
1592
|
+
const health = await checkHttpHealth(healthEndpoint);
|
|
1593
|
+
dependencyChecks.push(verificationCheck("treedx-health", `TreeDX health endpoint ${healthEndpoint} responds`, "api", {
|
|
1594
|
+
exists: health.ok,
|
|
1595
|
+
configured: true,
|
|
1596
|
+
ready: health.ok,
|
|
1597
|
+
verified: health.ok,
|
|
1598
|
+
observed: health,
|
|
1599
|
+
issues: health.ok ? [] : [`TreeDX health endpoint ${healthEndpoint} did not respond successfully.`]
|
|
1600
|
+
}));
|
|
1601
|
+
}
|
|
1602
|
+
const contentIsTreeDx = input.unit.spec.contentRepositoryAccessMode === "treedx";
|
|
1603
|
+
const siteIsFilesystem = input.unit.spec.siteRepositoryAccessMode === "filesystem";
|
|
1604
|
+
const projectIsFilesystem = input.unit.spec.projectRepositoryAccessMode === "filesystem";
|
|
1605
|
+
const topologyChecks = [
|
|
1606
|
+
verificationCheck("content-repository-plane", "Content repositories use TreeDX by default", "derived", {
|
|
1607
|
+
exists: true,
|
|
1608
|
+
configured: contentIsTreeDx,
|
|
1609
|
+
ready: contentIsTreeDx,
|
|
1610
|
+
verified: contentIsTreeDx,
|
|
1611
|
+
observed: input.unit.spec.contentRepositoryAccessMode,
|
|
1612
|
+
issues: contentIsTreeDx ? [] : ["Content repository access mode is not treedx."]
|
|
1613
|
+
}),
|
|
1614
|
+
verificationCheck("site-repository-plane", "Project site repositories use local filesystem/worktrees by default", "derived", {
|
|
1615
|
+
exists: true,
|
|
1616
|
+
configured: siteIsFilesystem,
|
|
1617
|
+
ready: siteIsFilesystem,
|
|
1618
|
+
verified: siteIsFilesystem,
|
|
1619
|
+
observed: input.unit.spec.siteRepositoryAccessMode,
|
|
1620
|
+
issues: siteIsFilesystem ? [] : ["Site repository access mode is not filesystem."]
|
|
1621
|
+
}),
|
|
1622
|
+
verificationCheck("project-repository-plane", "Super-project repositories use local filesystem/worktrees by default", "derived", {
|
|
1623
|
+
exists: true,
|
|
1624
|
+
configured: projectIsFilesystem,
|
|
1625
|
+
ready: projectIsFilesystem,
|
|
1626
|
+
verified: projectIsFilesystem,
|
|
1627
|
+
observed: input.unit.spec.projectRepositoryAccessMode,
|
|
1628
|
+
issues: projectIsFilesystem ? [] : ["Project repository access mode is not filesystem."]
|
|
1629
|
+
})
|
|
1630
|
+
];
|
|
1631
|
+
const repositoryChecks = [];
|
|
1632
|
+
const baseUrl = nonEmptyString(input.unit.spec.baseUrl);
|
|
1633
|
+
const token = nonEmptyString(input.unit.spec.token) || mintLocalTreeDxJwt(recordValue(input.unit.spec.auth));
|
|
1634
|
+
if (baseUrl && token) {
|
|
1635
|
+
const client = new TreeDxClient({ baseUrl, token });
|
|
1636
|
+
let repositories = [];
|
|
1637
|
+
try {
|
|
1638
|
+
const list = recordValue(await client.repositories.list()).repos;
|
|
1639
|
+
repositories = Array.isArray(list) ? list : [];
|
|
1640
|
+
} catch {
|
|
1641
|
+
repositories = [];
|
|
1642
|
+
}
|
|
1643
|
+
for (const project of localTreeDxProjects(input.unit.spec.projects)) {
|
|
1644
|
+
const repo = repositories.find((entry) => {
|
|
1645
|
+
const record = recordValue(entry);
|
|
1646
|
+
return record.repositoryName === project.repositoryName || record.name === project.repositoryName;
|
|
1647
|
+
});
|
|
1648
|
+
if (repo) {
|
|
1649
|
+
repositoryChecks.push(verificationCheck(`treedx-repo:${project.slug}`, `TreeDX repository ${project.repositoryId} is registered`, "api", {
|
|
1650
|
+
exists: true,
|
|
1651
|
+
configured: true,
|
|
1652
|
+
ready: true,
|
|
1653
|
+
verified: true,
|
|
1654
|
+
observed: repo
|
|
1655
|
+
}));
|
|
1656
|
+
} else {
|
|
1657
|
+
repositoryChecks.push(verificationCheck(`treedx-repo:${project.slug}`, `TreeDX repository ${project.repositoryId} is registered`, "api", {
|
|
1658
|
+
exists: false,
|
|
1659
|
+
configured: true,
|
|
1660
|
+
ready: false,
|
|
1661
|
+
verified: false,
|
|
1662
|
+
observed: { repositoryName: project.repositoryName },
|
|
1663
|
+
issues: [`TreeDX repository ${project.repositoryId} is not registered.`]
|
|
1664
|
+
}));
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
return summarizeVerification(input.unit.unitId, [...dependencyChecks, ...topologyChecks, ...repositoryChecks], input.observed.warnings);
|
|
1669
|
+
},
|
|
1670
|
+
destroy(input) {
|
|
1671
|
+
return genericResult({
|
|
1672
|
+
...input,
|
|
1673
|
+
diff: { action: "delete", reasons: ["selected local TreeDX topology for destroy"], before: input.observed.live, after: {} }
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
function buildReleaseGateAdapter() {
|
|
1679
|
+
const unitTypes = [
|
|
1680
|
+
"release-gate:verify",
|
|
1681
|
+
"release-gate:template-verify",
|
|
1682
|
+
"release-gate:template-release-record",
|
|
1683
|
+
"release-gate:npm-publish",
|
|
1684
|
+
"release-gate:image-publish",
|
|
1685
|
+
"release-gate:hosted-reconcile",
|
|
1686
|
+
"release-gate:live-verify",
|
|
1687
|
+
"release-gate:candidate-record",
|
|
1688
|
+
"release-gate:production-record"
|
|
1689
|
+
];
|
|
1690
|
+
return {
|
|
1691
|
+
providerId: "treeseed",
|
|
1692
|
+
unitTypes,
|
|
1693
|
+
supports(unitType, providerId) {
|
|
1694
|
+
return providerId === "treeseed" && unitTypes.includes(unitType);
|
|
1695
|
+
},
|
|
1696
|
+
refresh(input) {
|
|
1697
|
+
const fingerprint = typeof input.unit.spec.fingerprint === "string" ? input.unit.spec.fingerprint : null;
|
|
1698
|
+
const previousFingerprint = typeof input.persistedState?.lastReconciledState?.fingerprint === "string" ? input.persistedState.lastReconciledState.fingerprint : null;
|
|
1699
|
+
return {
|
|
1700
|
+
...genericObservedState(input),
|
|
1701
|
+
status: fingerprint && previousFingerprint === fingerprint ? "ready" : "pending",
|
|
1702
|
+
live: {
|
|
1703
|
+
...input.unit.spec,
|
|
1704
|
+
previousFingerprint,
|
|
1705
|
+
fingerprint
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
},
|
|
1709
|
+
diff(input) {
|
|
1710
|
+
return input.observed.status === "ready" ? noopDiff() : { action: "update", reasons: ["release gate fingerprint has not been reconciled"], before: input.observed.live, after: input.unit.spec };
|
|
1711
|
+
},
|
|
1712
|
+
async apply(input) {
|
|
1713
|
+
if (input.diff.action === "noop") return genericResult(input);
|
|
1714
|
+
const gateKind = String(input.unit.spec.gateKind ?? input.unit.unitType);
|
|
1715
|
+
const packageId = typeof input.unit.spec.packageId === "string" ? input.unit.spec.packageId : null;
|
|
1716
|
+
const templateId = typeof input.unit.spec.templateId === "string" ? input.unit.spec.templateId : null;
|
|
1717
|
+
if (process.env.TREESEED_WORKFLOW_RELEASE_GATES_MODE === "skip") {
|
|
1718
|
+
return genericResult(input, {
|
|
1719
|
+
...input.observed.live,
|
|
1720
|
+
gateKind,
|
|
1721
|
+
packageId,
|
|
1722
|
+
templateId,
|
|
1723
|
+
skipped: true,
|
|
1724
|
+
reason: "disabled",
|
|
1725
|
+
fingerprint: input.unit.spec.fingerprint
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
if (gateKind === "release-gate:verify" && packageId) {
|
|
1729
|
+
const verify = await runReleaseVerifyCommand({ tenantRoot: input.context.tenantRoot, packageId, env: input.context.launchEnv });
|
|
1730
|
+
if (verify.ok !== true) {
|
|
1731
|
+
throw new Error([verify.stderr, verify.stdout].filter(Boolean).join("\n").trim() || `${packageId} release verification failed`);
|
|
1732
|
+
}
|
|
1733
|
+
return genericResult(input, { ...input.observed.live, verify, fingerprint: input.unit.spec.fingerprint });
|
|
1734
|
+
}
|
|
1735
|
+
if (gateKind === "release-gate:template-verify" && templateId) {
|
|
1736
|
+
const verify = runTemplateReleaseVerifyCommand({ tenantRoot: input.context.tenantRoot, templateId, env: input.context.launchEnv });
|
|
1737
|
+
if (verify.ok !== true) {
|
|
1738
|
+
throw new Error([verify.stderr, verify.stdout].filter(Boolean).join("\n").trim() || `${templateId} template release verification failed`);
|
|
1739
|
+
}
|
|
1740
|
+
return genericResult(input, { ...input.observed.live, verify, fingerprint: input.unit.spec.fingerprint });
|
|
1741
|
+
}
|
|
1742
|
+
if (gateKind === "release-gate:template-release-record" && templateId) {
|
|
1743
|
+
const releaseTag = typeof input.unit.spec.releaseTag === "string" ? input.unit.spec.releaseTag : null;
|
|
1744
|
+
const tag = releaseTag && input.context.target.kind === "persistent" && input.context.target.scope === "prod" ? ensureTemplateReleaseTag({ tenantRoot: input.context.tenantRoot, templateId, tagName: releaseTag }) : null;
|
|
1745
|
+
const recordPath = typeof input.unit.spec.recordPath === "string" ? input.unit.spec.recordPath : `.treeseed/templates/${templateId}/latest-release.json`;
|
|
1746
|
+
const template = checkedOutTemplateRepositories(input.context.tenantRoot).find((candidate) => candidate.templateManifest?.id === templateId);
|
|
1747
|
+
const record = writeReleaseRecord({
|
|
1748
|
+
tenantRoot: input.context.tenantRoot,
|
|
1749
|
+
recordPath,
|
|
1750
|
+
record: {
|
|
1751
|
+
schemaVersion: 1,
|
|
1752
|
+
kind: gateKind,
|
|
1753
|
+
templateId,
|
|
1754
|
+
releaseTag,
|
|
1755
|
+
tag,
|
|
1756
|
+
environment: input.unit.spec.environment ?? null,
|
|
1757
|
+
version: template?.templateManifest?.version ?? null,
|
|
1758
|
+
recordedAt: nowIso()
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
return genericResult(input, { ...input.observed.live, record, tag, fingerprint: input.unit.spec.fingerprint });
|
|
1762
|
+
}
|
|
1763
|
+
if ((gateKind === "release-gate:npm-publish" || gateKind === "release-gate:image-publish") && packageId) {
|
|
1764
|
+
const adapter = findTreeseedPackageAdapter(input.context.tenantRoot, packageId);
|
|
1765
|
+
const repository = typeof adapter?.metadata.repository === "string" ? adapter.metadata.repository : null;
|
|
1766
|
+
if (!repository) {
|
|
1767
|
+
throw new Error(`${packageId} does not declare a GitHub repository.`);
|
|
1768
|
+
}
|
|
1769
|
+
const workflow = gateKind === "release-gate:image-publish" ? workflowName(adapter?.metadata.developmentImageWorkflow, "publish.yml") : workflowName(adapter?.metadata.hostedVerifyWorkflow, "publish.yml");
|
|
1770
|
+
const dispatch = await dispatchReconcileGitHubWorkflow({
|
|
1771
|
+
repository,
|
|
1772
|
+
workflow,
|
|
1773
|
+
branch: input.context.target.kind === "persistent" && input.context.target.scope === "prod" ? "main" : "staging",
|
|
1774
|
+
inputs: {},
|
|
1775
|
+
wait: input.context.target.kind === "persistent" && input.context.target.scope === "prod",
|
|
1776
|
+
env: buildGitHubEnv(input)
|
|
1777
|
+
});
|
|
1778
|
+
return genericResult(input, { ...input.observed.live, dispatch, fingerprint: input.unit.spec.fingerprint });
|
|
1779
|
+
}
|
|
1780
|
+
if (gateKind === "release-gate:hosted-reconcile") {
|
|
1781
|
+
const environment = input.unit.spec.environment === "prod" ? "prod" : "staging";
|
|
1782
|
+
const selector = typeof input.unit.spec.hostedSelector === "object" && input.unit.spec.hostedSelector ? input.unit.spec.hostedSelector : { environment, provider: ["cloudflare", "railway", "github", "dockerhub"] };
|
|
1783
|
+
const nested = await runHostedReconcileGate({
|
|
1784
|
+
parentContext: input.context,
|
|
1785
|
+
selector,
|
|
1786
|
+
target: { kind: "persistent", scope: environment },
|
|
1787
|
+
dryRun: input.context.dryRun === true
|
|
1788
|
+
});
|
|
1789
|
+
return genericResult(input, { ...input.observed.live, nested, fingerprint: input.unit.spec.fingerprint });
|
|
1790
|
+
}
|
|
1791
|
+
if (gateKind === "release-gate:live-verify") {
|
|
1792
|
+
const environment = input.unit.spec.environment === "prod" ? "prod" : "staging";
|
|
1793
|
+
const selector = typeof input.unit.spec.hostedSelector === "object" && input.unit.spec.hostedSelector ? input.unit.spec.hostedSelector : { environment, provider: ["cloudflare", "railway", "github", "dockerhub"] };
|
|
1794
|
+
const status = await runHostedVerifyGate({
|
|
1795
|
+
parentContext: input.context,
|
|
1796
|
+
selector,
|
|
1797
|
+
target: { kind: "persistent", scope: environment }
|
|
1798
|
+
});
|
|
1799
|
+
if (!status.ready) {
|
|
1800
|
+
throw new Error(`Hosted live verification failed for ${environment}: ${status.blockers.join("\n")}`);
|
|
1801
|
+
}
|
|
1802
|
+
return genericResult(input, { ...input.observed.live, status, fingerprint: input.unit.spec.fingerprint });
|
|
1803
|
+
}
|
|
1804
|
+
if (gateKind === "release-gate:candidate-record" || gateKind === "release-gate:production-record") {
|
|
1805
|
+
const recordPath = typeof input.unit.spec.recordPath === "string" ? input.unit.spec.recordPath : gateKind === "release-gate:production-record" ? ".treeseed/workflow/releases/latest-production.json" : ".treeseed/workflow/release-candidates/latest-staging.json";
|
|
1806
|
+
const record = writeReleaseRecord({
|
|
1807
|
+
tenantRoot: input.context.tenantRoot,
|
|
1808
|
+
recordPath,
|
|
1809
|
+
record: {
|
|
1810
|
+
schemaVersion: 1,
|
|
1811
|
+
kind: gateKind,
|
|
1812
|
+
unitId: input.unit.unitId,
|
|
1813
|
+
fingerprint: input.unit.spec.fingerprint ?? null,
|
|
1814
|
+
environment: input.unit.spec.environment ?? null,
|
|
1815
|
+
recordedAt: nowIso()
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
return genericResult(input, { ...input.observed.live, record, fingerprint: input.unit.spec.fingerprint });
|
|
1819
|
+
}
|
|
1820
|
+
return genericResult(input, { ...input.observed.live, fingerprint: input.unit.spec.fingerprint });
|
|
1821
|
+
},
|
|
1822
|
+
verify(input) {
|
|
1823
|
+
const dependencyResults = input.context.session.get("treeseed:verification-results");
|
|
1824
|
+
const dependencyChecks = input.unit.dependencies.map((dependency) => {
|
|
1825
|
+
const verification = dependencyResults?.get(dependency);
|
|
1826
|
+
const ok = verification?.verified === true;
|
|
1827
|
+
return verificationCheck(`dependency:${dependency}`, `Release gate dependency ${dependency} passed`, "derived", {
|
|
1828
|
+
exists: ok,
|
|
1829
|
+
configured: ok,
|
|
1830
|
+
ready: ok,
|
|
1831
|
+
verified: ok,
|
|
1832
|
+
observed: verification ?? null,
|
|
1833
|
+
issues: ok ? [] : [`Dependency ${dependency} is not verified.`]
|
|
1834
|
+
});
|
|
1835
|
+
});
|
|
1836
|
+
const ownCheck = verificationCheck("release-gate", "Release gate reconciled its fingerprint", "sdk", {
|
|
1837
|
+
exists: true,
|
|
1838
|
+
configured: true,
|
|
1839
|
+
ready: input.result?.state?.fingerprint === input.unit.spec.fingerprint || input.diff.action === "noop",
|
|
1840
|
+
verified: input.result?.state?.fingerprint === input.unit.spec.fingerprint || input.diff.action === "noop",
|
|
1841
|
+
expected: input.unit.spec.fingerprint ?? null,
|
|
1842
|
+
observed: input.result?.state?.fingerprint ?? input.observed.live.previousFingerprint ?? null
|
|
1843
|
+
});
|
|
1844
|
+
return summarizeVerification(input.unit.unitId, [...dependencyChecks, ownCheck], input.observed.warnings);
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
function workflowFingerprint(input) {
|
|
1849
|
+
return JSON.stringify({
|
|
1850
|
+
unitId: input.unit.unitId,
|
|
1851
|
+
unitType: input.unit.unitType,
|
|
1852
|
+
spec: input.unit.spec,
|
|
1853
|
+
dependencies: input.unit.dependencies
|
|
1854
|
+
});
|
|
127
1855
|
}
|
|
128
|
-
function
|
|
1856
|
+
function buildWorkflowMetaAdapter() {
|
|
1857
|
+
const unitTypes = [
|
|
1858
|
+
"branch-preview",
|
|
1859
|
+
"branch-preview-cleanup",
|
|
1860
|
+
"workflow-gate",
|
|
1861
|
+
"save-gate:local-verify",
|
|
1862
|
+
"save-gate:promotion-readiness",
|
|
1863
|
+
"save-gate:hosted-verify"
|
|
1864
|
+
];
|
|
129
1865
|
return {
|
|
130
1866
|
providerId: "treeseed",
|
|
131
|
-
unitTypes
|
|
132
|
-
supports(
|
|
133
|
-
return
|
|
1867
|
+
unitTypes,
|
|
1868
|
+
supports(unitType, providerId) {
|
|
1869
|
+
return providerId === "treeseed" && unitTypes.includes(unitType);
|
|
134
1870
|
},
|
|
135
|
-
|
|
136
|
-
|
|
1871
|
+
refresh(input) {
|
|
1872
|
+
const fingerprint = workflowFingerprint(input);
|
|
1873
|
+
const previousFingerprint = typeof input.persistedState?.lastReconciledState?.fingerprint === "string" ? input.persistedState.lastReconciledState.fingerprint : null;
|
|
1874
|
+
return {
|
|
1875
|
+
...genericObservedState(input),
|
|
1876
|
+
status: previousFingerprint === fingerprint ? "ready" : "pending",
|
|
1877
|
+
live: {
|
|
1878
|
+
...input.unit.spec,
|
|
1879
|
+
fingerprint,
|
|
1880
|
+
previousFingerprint
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
137
1883
|
},
|
|
138
|
-
|
|
139
|
-
return noopDiff();
|
|
1884
|
+
diff(input) {
|
|
1885
|
+
return input.observed.status === "ready" ? noopDiff() : { action: input.unit.unitType === "branch-preview-cleanup" ? "delete" : "update", reasons: [`${input.unit.unitType} fingerprint has not been reconciled`], before: input.observed.live, after: input.unit.spec };
|
|
140
1886
|
},
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
1887
|
+
async apply(input) {
|
|
1888
|
+
if (input.diff.action === "noop") return genericResult(input);
|
|
1889
|
+
const fingerprint = workflowFingerprint(input);
|
|
1890
|
+
if (input.unit.unitType === "branch-preview") {
|
|
1891
|
+
const selector = typeof input.unit.spec.resources === "object" && input.unit.spec.resources ? input.unit.spec.resources : { environment: "staging", appId: [String(input.unit.spec.appId ?? "web")] };
|
|
1892
|
+
const nested = await runHostedReconcileGate({
|
|
1893
|
+
parentContext: input.context,
|
|
1894
|
+
selector,
|
|
1895
|
+
target: input.context.target,
|
|
1896
|
+
dryRun: input.context.dryRun === true
|
|
1897
|
+
});
|
|
1898
|
+
return genericResult(input, { ...input.observed.live, nested, fingerprint });
|
|
1899
|
+
}
|
|
1900
|
+
if (input.unit.unitType === "save-gate:hosted-verify") {
|
|
1901
|
+
const selector = typeof input.unit.spec.selector === "object" && input.unit.spec.selector ? input.unit.spec.selector : { environment: input.context.target.kind === "persistent" ? input.context.target.scope : "staging" };
|
|
1902
|
+
const status = await runHostedVerifyGate({
|
|
1903
|
+
parentContext: input.context,
|
|
1904
|
+
selector,
|
|
1905
|
+
target: input.context.target
|
|
1906
|
+
});
|
|
1907
|
+
if (!status.ready) {
|
|
1908
|
+
throw new Error(`Save hosted verification failed: ${status.blockers.join("\n")}`);
|
|
1909
|
+
}
|
|
1910
|
+
return genericResult(input, { ...input.observed.live, status, fingerprint });
|
|
1911
|
+
}
|
|
1912
|
+
return genericResult(input, { ...input.observed.live, fingerprint });
|
|
146
1913
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
1914
|
+
verify(input) {
|
|
1915
|
+
const fingerprint = workflowFingerprint(input);
|
|
1916
|
+
const observedFingerprint = typeof input.observed.live.fingerprint === "string" ? input.observed.live.fingerprint : null;
|
|
1917
|
+
return summarizeVerification(input.unit.unitId, [
|
|
1918
|
+
verificationCheck("workflow-meta-fingerprint", `${input.unit.unitType} fingerprint is reconciled`, "derived", {
|
|
1919
|
+
exists: true,
|
|
1920
|
+
configured: observedFingerprint === fingerprint,
|
|
1921
|
+
ready: observedFingerprint === fingerprint,
|
|
1922
|
+
verified: observedFingerprint === fingerprint,
|
|
1923
|
+
expected: fingerprint,
|
|
1924
|
+
observed: observedFingerprint,
|
|
1925
|
+
issues: observedFingerprint === fingerprint ? [] : [`Expected fingerprint ${fingerprint}, observed ${observedFingerprint ?? "none"}.`]
|
|
1926
|
+
})
|
|
1927
|
+
], input.observed.warnings);
|
|
161
1928
|
},
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
key: condition.key,
|
|
169
|
-
description: condition.description,
|
|
170
|
-
source: "derived",
|
|
171
|
-
exists: verified,
|
|
172
|
-
configured: verified,
|
|
173
|
-
ready: verified,
|
|
174
|
-
verified,
|
|
175
|
-
expected: true,
|
|
176
|
-
observed: dependency?.verified ?? false,
|
|
177
|
-
issues: verified ? [] : [`Dependency ${condition.key} is not verified.`]
|
|
178
|
-
};
|
|
179
|
-
});
|
|
180
|
-
return summarizeVerification(unit.unitId, checks);
|
|
1929
|
+
destroy(input) {
|
|
1930
|
+
return genericResult({
|
|
1931
|
+
...input,
|
|
1932
|
+
diff: { action: "delete", reasons: [`selected ${input.unit.unitType} for destroy`], before: input.observed.live, after: {} }
|
|
1933
|
+
}, { ...input.observed.live, destroyedAt: nowIso(), fingerprint: workflowFingerprint(input) });
|
|
181
1934
|
}
|
|
182
1935
|
};
|
|
183
1936
|
}
|
|
@@ -199,27 +1952,38 @@ function normalizeEnvironmentValues(env) {
|
|
|
199
1952
|
}
|
|
200
1953
|
function resolveReconcileEnvironmentValues(input, scope) {
|
|
201
1954
|
if (scope === "local") {
|
|
202
|
-
return resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope);
|
|
1955
|
+
return normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope));
|
|
203
1956
|
}
|
|
204
|
-
|
|
1957
|
+
return {
|
|
205
1958
|
...normalizeEnvironmentValues(process.env),
|
|
206
1959
|
...normalizeEnvironmentValues(input.context.launchEnv)
|
|
207
1960
|
};
|
|
208
|
-
if (hostedRuntimeValues.CLOUDFLARE_API_TOKEN || hostedRuntimeValues.RAILWAY_API_TOKEN || hostedRuntimeValues.GH_TOKEN) {
|
|
209
|
-
return hostedRuntimeValues;
|
|
210
|
-
}
|
|
211
|
-
const values = {
|
|
212
|
-
...resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope),
|
|
213
|
-
...hostedRuntimeValues
|
|
214
|
-
};
|
|
215
|
-
return values;
|
|
216
1961
|
}
|
|
217
1962
|
function buildCloudflareEnv(input) {
|
|
218
1963
|
const scope = scopeFromTarget(toDeployTarget(input.context.target));
|
|
219
1964
|
const values = resolveReconcileEnvironmentValues(input, scope);
|
|
1965
|
+
let accountId = [
|
|
1966
|
+
values.TREESEED_CLOUDFLARE_ACCOUNT_ID,
|
|
1967
|
+
input.context.launchEnv.TREESEED_CLOUDFLARE_ACCOUNT_ID,
|
|
1968
|
+
process.env.TREESEED_CLOUDFLARE_ACCOUNT_ID,
|
|
1969
|
+
resolveConfiguredCloudflareAccountId(input.context.deployConfig)
|
|
1970
|
+
].find((value) => typeof value === "string" && value.trim().length > 0 && value.trim() !== "replace-with-cloudflare-account-id")?.trim() ?? "";
|
|
1971
|
+
let token = [
|
|
1972
|
+
values.TREESEED_CLOUDFLARE_API_TOKEN,
|
|
1973
|
+
input.context.launchEnv.TREESEED_CLOUDFLARE_API_TOKEN,
|
|
1974
|
+
process.env.TREESEED_CLOUDFLARE_API_TOKEN
|
|
1975
|
+
].find((value) => typeof value === "string" && value.trim().length > 0)?.trim() ?? "";
|
|
1976
|
+
if (scope !== "local" && (!accountId || !token)) {
|
|
1977
|
+
const configuredValues = normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope));
|
|
1978
|
+
accountId = accountId || ([
|
|
1979
|
+
configuredValues.TREESEED_CLOUDFLARE_ACCOUNT_ID,
|
|
1980
|
+
resolveConfiguredCloudflareAccountId(input.context.deployConfig)
|
|
1981
|
+
].find((value) => typeof value === "string" && value.trim().length > 0 && value.trim() !== "replace-with-cloudflare-account-id")?.trim() ?? "");
|
|
1982
|
+
token = token || (configuredValues.TREESEED_CLOUDFLARE_API_TOKEN ?? "");
|
|
1983
|
+
}
|
|
220
1984
|
return {
|
|
221
|
-
CLOUDFLARE_ACCOUNT_ID:
|
|
222
|
-
CLOUDFLARE_API_TOKEN:
|
|
1985
|
+
CLOUDFLARE_ACCOUNT_ID: accountId,
|
|
1986
|
+
CLOUDFLARE_API_TOKEN: token
|
|
223
1987
|
};
|
|
224
1988
|
}
|
|
225
1989
|
function hasLiveResourceId(value) {
|
|
@@ -227,15 +1991,24 @@ function hasLiveResourceId(value) {
|
|
|
227
1991
|
}
|
|
228
1992
|
function buildRailwayEnv(input, scope) {
|
|
229
1993
|
const values = resolveReconcileEnvironmentValues(input, scope);
|
|
230
|
-
|
|
231
|
-
values.
|
|
232
|
-
input.context.launchEnv.
|
|
233
|
-
process.env.
|
|
1994
|
+
let token = [
|
|
1995
|
+
values.TREESEED_RAILWAY_API_TOKEN,
|
|
1996
|
+
input.context.launchEnv.TREESEED_RAILWAY_API_TOKEN,
|
|
1997
|
+
process.env.TREESEED_RAILWAY_API_TOKEN
|
|
234
1998
|
].find((value) => typeof value === "string" && value.trim().length > 0)?.trim() ?? "";
|
|
1999
|
+
let railwayApiUrl = values.TREESEED_RAILWAY_API_URL ?? input.context.launchEnv.TREESEED_RAILWAY_API_URL ?? process.env.TREESEED_RAILWAY_API_URL ?? "";
|
|
2000
|
+
let railwayWorkspace = values.TREESEED_RAILWAY_WORKSPACE ?? input.context.launchEnv.TREESEED_RAILWAY_WORKSPACE ?? process.env.TREESEED_RAILWAY_WORKSPACE ?? "";
|
|
2001
|
+
if (scope !== "local" && !token) {
|
|
2002
|
+
const configuredValues = normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope));
|
|
2003
|
+
token = configuredValues.TREESEED_RAILWAY_API_TOKEN ?? "";
|
|
2004
|
+
railwayApiUrl = railwayApiUrl || (configuredValues.TREESEED_RAILWAY_API_URL ?? "");
|
|
2005
|
+
railwayWorkspace = railwayWorkspace || (configuredValues.TREESEED_RAILWAY_WORKSPACE ?? "");
|
|
2006
|
+
}
|
|
235
2007
|
return {
|
|
2008
|
+
TREESEED_RAILWAY_API_TOKEN: token,
|
|
236
2009
|
RAILWAY_API_TOKEN: token,
|
|
237
|
-
TREESEED_RAILWAY_API_URL:
|
|
238
|
-
TREESEED_RAILWAY_WORKSPACE:
|
|
2010
|
+
TREESEED_RAILWAY_API_URL: railwayApiUrl,
|
|
2011
|
+
TREESEED_RAILWAY_WORKSPACE: railwayWorkspace
|
|
239
2012
|
};
|
|
240
2013
|
}
|
|
241
2014
|
function findCloudflareQueueByName(input, env, expectedName, { attempts = 6, delayMs = 350 } = {}) {
|
|
@@ -329,7 +2102,7 @@ function listCloudflareQueuesViaApi(env) {
|
|
|
329
2102
|
return Array.isArray(payload?.result) ? payload.result : [];
|
|
330
2103
|
}
|
|
331
2104
|
function cloudflareObservationSnapshot(input, forceRefresh = false) {
|
|
332
|
-
const cacheKey = `cloudflare:
|
|
2105
|
+
const cacheKey = `cloudflare:refresh:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
|
|
333
2106
|
return providerCache(input, cacheKey, () => {
|
|
334
2107
|
const target = toDeployTarget(input.context.target);
|
|
335
2108
|
const env = buildCloudflareEnv(input);
|
|
@@ -512,6 +2285,8 @@ function normalizeRailwayDomainPayload(value) {
|
|
|
512
2285
|
}
|
|
513
2286
|
const record = value;
|
|
514
2287
|
const status = record.status && typeof record.status === "object" ? record.status : {};
|
|
2288
|
+
const certificate = record.certificate && typeof record.certificate === "object" ? record.certificate : status.certificate && typeof status.certificate === "object" ? status.certificate : {};
|
|
2289
|
+
const verification = record.verification && typeof record.verification === "object" ? record.verification : status.verification && typeof status.verification === "object" ? status.verification : {};
|
|
515
2290
|
const domain = typeof record.domain === "string" ? record.domain.trim() : typeof record.name === "string" ? record.name.trim() : "";
|
|
516
2291
|
const serviceDomain = firstRailwayDomainString(
|
|
517
2292
|
record.serviceDomain,
|
|
@@ -550,65 +2325,42 @@ function normalizeRailwayDomainPayload(value) {
|
|
|
550
2325
|
id: typeof record.id === "string" ? record.id.trim() : null,
|
|
551
2326
|
domain,
|
|
552
2327
|
serviceDomain,
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
2328
|
+
verified: record.verified === true || status.verified === true || verification.verified === true,
|
|
2329
|
+
certificateStatus: firstRailwayDomainString(
|
|
2330
|
+
record.certificateStatus,
|
|
2331
|
+
status.certificateStatus,
|
|
2332
|
+
certificate.status
|
|
2333
|
+
)?.toUpperCase() ?? null,
|
|
2334
|
+
verificationDnsHost: typeof record.verificationDnsHost === "string" ? record.verificationDnsHost.trim() : typeof status.verificationDnsHost === "string" ? String(status.verificationDnsHost).trim() : typeof verification.dnsHost === "string" ? String(verification.dnsHost).trim() : null,
|
|
2335
|
+
verificationToken: typeof record.verificationToken === "string" ? record.verificationToken.trim() : typeof status.verificationToken === "string" ? String(status.verificationToken).trim() : typeof verification.token === "string" ? String(verification.token).trim() : null,
|
|
556
2336
|
dnsRecords: effectiveDnsRecords
|
|
557
2337
|
};
|
|
558
2338
|
}
|
|
559
2339
|
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
|
-
}
|
|
2340
|
+
if (!identifiers?.projectId || !identifiers.environmentId || !identifiers.serviceId) {
|
|
2341
|
+
throw new Error(`Railway custom domain ${domain} cannot be reconciled without project, environment, and service ids.`);
|
|
571
2342
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
allowFailure: true,
|
|
2343
|
+
const existing = await listRailwayCustomDomains({
|
|
2344
|
+
projectId: identifiers.projectId,
|
|
2345
|
+
environmentId: identifiers.environmentId,
|
|
2346
|
+
serviceId: identifiers.serviceId,
|
|
577
2347
|
env
|
|
578
2348
|
});
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const refreshed = await listRailwayCustomDomains({
|
|
583
|
-
projectId: identifiers.projectId,
|
|
584
|
-
environmentId: identifiers.environmentId,
|
|
585
|
-
serviceId: identifiers.serviceId,
|
|
586
|
-
env
|
|
587
|
-
});
|
|
588
|
-
const matched = refreshed.find((entry) => entry.domain === domain) ?? null;
|
|
589
|
-
if (matched) {
|
|
590
|
-
return matched;
|
|
591
|
-
}
|
|
2349
|
+
const matched = existing.find((entry) => entry.domain === domain) ?? null;
|
|
2350
|
+
if (matched) {
|
|
2351
|
+
return matched;
|
|
592
2352
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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,
|
|
2353
|
+
const ensured = await ensureRailwayCustomDomainViaApi({
|
|
2354
|
+
projectId: identifiers.projectId,
|
|
2355
|
+
environmentId: identifiers.environmentId,
|
|
2356
|
+
serviceId: identifiers.serviceId,
|
|
607
2357
|
domain,
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
2358
|
+
env
|
|
2359
|
+
});
|
|
2360
|
+
if (!ensured.domain) {
|
|
2361
|
+
throw new Error(`Railway API custom domain reconciliation did not return ${domain}.`);
|
|
2362
|
+
}
|
|
2363
|
+
return ensured.domain;
|
|
612
2364
|
}
|
|
613
2365
|
function collectCloudflareEnvironmentSync(input) {
|
|
614
2366
|
const target = toDeployTarget(input.context.target);
|
|
@@ -637,7 +2389,7 @@ function collectCloudflareEnvironmentSync(input) {
|
|
|
637
2389
|
secretNames.add(entry.id);
|
|
638
2390
|
}
|
|
639
2391
|
}
|
|
640
|
-
if (entry.targets.includes("cloudflare-var") && value) {
|
|
2392
|
+
if (entry.targets.includes("cloudflare-var") && value && entry.id !== "TREESEED_PROJECT_DOMAINS") {
|
|
641
2393
|
vars[entry.id] = value;
|
|
642
2394
|
varNames.add(entry.id);
|
|
643
2395
|
}
|
|
@@ -858,61 +2610,6 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
|
|
|
858
2610
|
current.databaseId = created.uuid;
|
|
859
2611
|
current.previewDatabaseId = created.previewDatabaseUuid ?? created.uuid;
|
|
860
2612
|
};
|
|
861
|
-
const ensureQueue = () => {
|
|
862
|
-
const current = state.queues?.agentWork;
|
|
863
|
-
if (!current?.name) {
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
const liveQueue = findCloudflareQueueByName(input, env, current.name, { attempts: 1, delayMs: 0 });
|
|
867
|
-
const liveDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName, { attempts: 1, delayMs: 0 }) : null;
|
|
868
|
-
if (liveQueue) {
|
|
869
|
-
current.queueId = queueId(liveQueue);
|
|
870
|
-
current.dlqId = current.dlqName ? queueId(liveDlq) : null;
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
let refreshedQueues = queues;
|
|
874
|
-
const existing = refreshedQueues.find((entry) => queueName(entry) === current.name);
|
|
875
|
-
if (existing) {
|
|
876
|
-
current.queueId = queueId(existing);
|
|
877
|
-
const existingDlq = current.dlqName ? refreshedQueues.find((entry) => queueName(entry) === current.dlqName) : null;
|
|
878
|
-
current.dlqId = queueId(existingDlq);
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
if (dryRun) {
|
|
882
|
-
current.queueId = `dryrun-${current.name}`;
|
|
883
|
-
current.dlqId = current.dlqName ? `dryrun-${current.dlqName}` : null;
|
|
884
|
-
return;
|
|
885
|
-
}
|
|
886
|
-
try {
|
|
887
|
-
runWrangler(["queues", "create", current.name], {
|
|
888
|
-
cwd: input.context.tenantRoot,
|
|
889
|
-
capture: true,
|
|
890
|
-
env
|
|
891
|
-
});
|
|
892
|
-
} catch (error) {
|
|
893
|
-
if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
|
|
894
|
-
throw error;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
refreshedQueues = listQueues(input.context.tenantRoot, env);
|
|
898
|
-
if (current.dlqName && !refreshedQueues.find((entry) => queueName(entry) === current.dlqName)) {
|
|
899
|
-
try {
|
|
900
|
-
runWrangler(["queues", "create", current.dlqName], {
|
|
901
|
-
cwd: input.context.tenantRoot,
|
|
902
|
-
capture: true,
|
|
903
|
-
env
|
|
904
|
-
});
|
|
905
|
-
} catch (error) {
|
|
906
|
-
if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
|
|
907
|
-
throw error;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
const created = findCloudflareQueueByName(input, env, current.name);
|
|
912
|
-
current.queueId = created ? queueId(created) : syntheticQueueLocator(current.name);
|
|
913
|
-
const createdDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName) : null;
|
|
914
|
-
current.dlqId = current.dlqName ? createdDlq ? queueId(createdDlq) : syntheticQueueLocator(current.dlqName) : null;
|
|
915
|
-
};
|
|
916
2613
|
const ensureR2Bucket = () => {
|
|
917
2614
|
const bucketName = state.content?.bucketName;
|
|
918
2615
|
if (!bucketName) {
|
|
@@ -1031,7 +2728,6 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
|
|
|
1031
2728
|
};
|
|
1032
2729
|
runStep("kv-form-guard", () => ensureKv("FORM_GUARD_KV"));
|
|
1033
2730
|
runStep("d1", ensureD1);
|
|
1034
|
-
runStep("queue", ensureQueue);
|
|
1035
2731
|
runStep("r2", ensureR2Bucket);
|
|
1036
2732
|
runStep("pages", ensurePagesProject);
|
|
1037
2733
|
runStep("turnstile-widget", ensureTurnstileWidget);
|
|
@@ -1077,20 +2773,19 @@ function observeCloudflareUnit(input) {
|
|
|
1077
2773
|
const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets } = snapshot;
|
|
1078
2774
|
switch (input.unit.unitType) {
|
|
1079
2775
|
case "queue": {
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
2776
|
+
const name = input.unit.spec.name;
|
|
2777
|
+
const dlqName = input.unit.spec.dlqName;
|
|
2778
|
+
const liveQueue = queues.find((entry) => queueName(entry) === name);
|
|
2779
|
+
const liveDlq = dlqName ? queues.find((entry) => queueName(entry) === dlqName) : null;
|
|
1082
2780
|
return {
|
|
1083
|
-
exists: Boolean(liveQueue
|
|
2781
|
+
exists: Boolean(liveQueue),
|
|
1084
2782
|
status: liveQueue ? "ready" : "pending",
|
|
1085
|
-
live: {
|
|
2783
|
+
live: { name, dlqName },
|
|
1086
2784
|
locators: {
|
|
1087
|
-
queueId: queueId(liveQueue) ??
|
|
1088
|
-
dlqId: queueId(liveDlq) ??
|
|
2785
|
+
queueId: queueId(liveQueue) ?? null,
|
|
2786
|
+
dlqId: queueId(liveDlq) ?? null
|
|
1089
2787
|
},
|
|
1090
|
-
warnings: [
|
|
1091
|
-
...isSyntheticQueueLocator(state.queues?.agentWork?.queueId) ? ["Cloudflare queue id is pending propagation; using queue-name fallback."] : [],
|
|
1092
|
-
...isSyntheticQueueLocator(state.queues?.agentWork?.dlqId) ? ["Cloudflare dead-letter queue id is pending propagation; using queue-name fallback."] : []
|
|
1093
|
-
]
|
|
2788
|
+
warnings: []
|
|
1094
2789
|
};
|
|
1095
2790
|
}
|
|
1096
2791
|
case "database": {
|
|
@@ -1180,7 +2875,7 @@ function verifyCloudflareUnitOnce(input, postconditions) {
|
|
|
1180
2875
|
const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets, env } = snapshot;
|
|
1181
2876
|
switch (input.unit.unitType) {
|
|
1182
2877
|
case "queue": {
|
|
1183
|
-
const queue =
|
|
2878
|
+
const queue = input.unit.spec;
|
|
1184
2879
|
const liveQueue = findCloudflareQueueByName(input, env, queue?.name, { attempts: 12, delayMs: 500 });
|
|
1185
2880
|
const liveDlq = queue?.dlqName ? findCloudflareQueueByName(input, env, queue.dlqName, { attempts: 12, delayMs: 500 }) : null;
|
|
1186
2881
|
return summarizeVerification(input.unit.unitId, [
|
|
@@ -1417,16 +3112,27 @@ function buildCloudflareDiff(input, observed) {
|
|
|
1417
3112
|
after: input.unit.spec
|
|
1418
3113
|
};
|
|
1419
3114
|
}
|
|
3115
|
+
if (input.unit.unitType === "pages-project") {
|
|
3116
|
+
const verification = verifyCloudflareUnit(input, []);
|
|
3117
|
+
if (verification.supported && !verification.verified) {
|
|
3118
|
+
return {
|
|
3119
|
+
action: "update",
|
|
3120
|
+
reasons: [...verification.missing, ...verification.drifted],
|
|
3121
|
+
before: observed.live,
|
|
3122
|
+
after: input.unit.spec
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
1420
3126
|
const locatorValues = Object.values(observed.locators).filter(Boolean);
|
|
1421
3127
|
return {
|
|
1422
|
-
action: locatorValues.length > 0 ? "
|
|
3128
|
+
action: locatorValues.length > 0 ? "noop" : "update",
|
|
1423
3129
|
reasons: locatorValues.length > 0 ? ["resource already present"] : ["resource partially configured"],
|
|
1424
3130
|
before: observed.live,
|
|
1425
3131
|
after: input.unit.spec
|
|
1426
3132
|
};
|
|
1427
3133
|
}
|
|
1428
3134
|
function reconcileCloudflareUnit(input, diff) {
|
|
1429
|
-
const cacheKey = `cloudflare:
|
|
3135
|
+
const cacheKey = `cloudflare:apply:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
|
|
1430
3136
|
const { state } = providerCache(input, cacheKey, () => {
|
|
1431
3137
|
let attempt = 0;
|
|
1432
3138
|
for (; ; ) {
|
|
@@ -1449,7 +3155,7 @@ function reconcileCloudflareUnit(input, diff) {
|
|
|
1449
3155
|
unit: input.unit,
|
|
1450
3156
|
observed: refreshed,
|
|
1451
3157
|
diff,
|
|
1452
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
3158
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
1453
3159
|
warnings: refreshed.warnings,
|
|
1454
3160
|
resourceLocators: refreshed.locators,
|
|
1455
3161
|
state: input.unit.unitType === "edge-worker" ? { workerName: state.workerName, lastDeployedUrl: state.lastDeployedUrl ?? null } : refreshed.live
|
|
@@ -1462,7 +3168,7 @@ function buildCloudflareAdapter(unitType) {
|
|
|
1462
3168
|
supports(candidateUnitType, providerId) {
|
|
1463
3169
|
return providerId === "cloudflare" && candidateUnitType === unitType;
|
|
1464
3170
|
},
|
|
1465
|
-
|
|
3171
|
+
refresh(input) {
|
|
1466
3172
|
return observeCloudflareUnit(input);
|
|
1467
3173
|
},
|
|
1468
3174
|
requiredPostconditions(input) {
|
|
@@ -1507,10 +3213,10 @@ function buildCloudflareAdapter(unitType) {
|
|
|
1507
3213
|
return [];
|
|
1508
3214
|
}
|
|
1509
3215
|
},
|
|
1510
|
-
|
|
3216
|
+
diff(input) {
|
|
1511
3217
|
return buildCloudflareDiff(input, input.observed);
|
|
1512
3218
|
},
|
|
1513
|
-
|
|
3219
|
+
apply(input) {
|
|
1514
3220
|
return reconcileCloudflareUnit(input, input.diff);
|
|
1515
3221
|
},
|
|
1516
3222
|
verify(input) {
|
|
@@ -1523,12 +3229,12 @@ function buildCloudflareAdapter(unitType) {
|
|
|
1523
3229
|
unit: input.unit,
|
|
1524
3230
|
observed: input.observed,
|
|
1525
3231
|
diff: {
|
|
1526
|
-
action: "
|
|
3232
|
+
action: "delete",
|
|
1527
3233
|
reasons: ["target destroyed"],
|
|
1528
3234
|
before: input.observed.live,
|
|
1529
3235
|
after: {}
|
|
1530
3236
|
},
|
|
1531
|
-
action: "
|
|
3237
|
+
action: "delete",
|
|
1532
3238
|
warnings: [],
|
|
1533
3239
|
resourceLocators: {},
|
|
1534
3240
|
state: {},
|
|
@@ -1541,6 +3247,31 @@ function relativeRailwayRootDir(tenantRoot, serviceRoot) {
|
|
|
1541
3247
|
const resolved = relative(tenantRoot, serviceRoot).replace(/\\/gu, "/");
|
|
1542
3248
|
return !resolved || resolved === "" ? "." : resolved;
|
|
1543
3249
|
}
|
|
3250
|
+
function railwayServiceRootDirectory(tenantRoot, service) {
|
|
3251
|
+
const sourceRootDirectory = String(service.sourceRootDirectory ?? "").trim();
|
|
3252
|
+
if (sourceRootDirectory) {
|
|
3253
|
+
return sourceRootDirectory;
|
|
3254
|
+
}
|
|
3255
|
+
return relativeRailwayRootDir(service.application?.root ?? tenantRoot, service.rootDir);
|
|
3256
|
+
}
|
|
3257
|
+
function configuredMarketDatabaseService(tenantRoot, deployConfig) {
|
|
3258
|
+
if (deployConfig.services?.treeseedDatabase) {
|
|
3259
|
+
return treeseedDatabaseDescriptor(deployConfig.services.treeseedDatabase, deployConfig.slug);
|
|
3260
|
+
}
|
|
3261
|
+
for (const application of discoverTreeseedApplications(tenantRoot)) {
|
|
3262
|
+
const service = application.config.services?.treeseedDatabase;
|
|
3263
|
+
if (service) {
|
|
3264
|
+
return treeseedDatabaseDescriptor(service, application.config.slug);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
return null;
|
|
3268
|
+
}
|
|
3269
|
+
function treeseedDatabaseDescriptor(service, slug) {
|
|
3270
|
+
return {
|
|
3271
|
+
service,
|
|
3272
|
+
serviceName: typeof service.railway?.serviceName === "string" && service.railway.serviceName.trim() ? service.railway.serviceName.trim() : `${slug ?? "treeseed-api"}-postgres`
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
1544
3275
|
async function ensureRailwayEnvironmentForService({
|
|
1545
3276
|
service,
|
|
1546
3277
|
project,
|
|
@@ -1559,16 +3290,6 @@ async function ensureRailwayEnvironmentForService({
|
|
|
1559
3290
|
throw error;
|
|
1560
3291
|
}
|
|
1561
3292
|
}
|
|
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
3293
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
1573
3294
|
const environments = await listRailwayEnvironments({ projectId: project.id, env });
|
|
1574
3295
|
const existing = environments.find((environment) => environment.name === environmentName || environment.id === environmentName) ?? null;
|
|
@@ -1577,10 +3298,11 @@ async function ensureRailwayEnvironmentForService({
|
|
|
1577
3298
|
}
|
|
1578
3299
|
await new Promise((resolve2) => setTimeout(resolve2, 2500));
|
|
1579
3300
|
}
|
|
1580
|
-
throw new Error(`Railway environment
|
|
3301
|
+
throw new Error(`Railway API environment provisioning failed for ${project.name ?? service.projectName ?? project.id}/${environmentName}.`);
|
|
1581
3302
|
}
|
|
1582
3303
|
async function resolveRailwayTopologyForScope(input, scope, {
|
|
1583
3304
|
ensure = false,
|
|
3305
|
+
ensureServices = ensure,
|
|
1584
3306
|
refresh = false,
|
|
1585
3307
|
serviceKeys,
|
|
1586
3308
|
includeInstances = ensure,
|
|
@@ -1592,21 +3314,28 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1592
3314
|
const env = buildRailwayEnv(input, scope);
|
|
1593
3315
|
const deployState = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
1594
3316
|
const services = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => normalizedServiceKeys.includes("__all__") || normalizedServiceKeys.includes(service.key));
|
|
3317
|
+
traceRailwayReconcile(env, "topology:start", `scope=${scope} ensure=${ensure ? "yes" : "no"} services=${services.map((service) => service.key).join(",")}`);
|
|
1595
3318
|
let workspace = null;
|
|
1596
3319
|
const knownProjects = [];
|
|
1597
3320
|
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()))];
|
|
1598
3321
|
for (const projectId of knownProjectIds) {
|
|
3322
|
+
traceRailwayReconcile(env, "topology:known-project:get", projectId);
|
|
1599
3323
|
const project = await getRailwayProject({ projectId, env });
|
|
3324
|
+
traceRailwayReconcile(env, "topology:known-project:done", `${projectId}:${project?.name ?? "(missing)"}`);
|
|
1600
3325
|
if (project) {
|
|
1601
3326
|
knownProjects.push(project);
|
|
1602
3327
|
}
|
|
1603
3328
|
}
|
|
1604
3329
|
if (knownProjects.length === 0 || services.some((service) => !(service.projectId || deployState.services?.[service.key]?.projectId))) {
|
|
3330
|
+
traceRailwayReconcile(env, "topology:workspace:get", "start");
|
|
1605
3331
|
workspace = await resolveRailwayWorkspaceContext({ env });
|
|
3332
|
+
traceRailwayReconcile(env, "topology:workspace:done", workspace.name);
|
|
3333
|
+
traceRailwayReconcile(env, "topology:projects:list", workspace.name);
|
|
1606
3334
|
const listedProjects = await listRailwayProjects({
|
|
1607
3335
|
env,
|
|
1608
3336
|
workspaceId: workspace.id
|
|
1609
3337
|
});
|
|
3338
|
+
traceRailwayReconcile(env, "topology:projects:list:done", String(listedProjects.length));
|
|
1610
3339
|
for (const project of listedProjects) {
|
|
1611
3340
|
if (!knownProjects.find((entry) => entry?.id === project.id)) {
|
|
1612
3341
|
knownProjects.push(project);
|
|
@@ -1626,16 +3355,30 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1626
3355
|
}
|
|
1627
3356
|
const resolvedServices = /* @__PURE__ */ new Map();
|
|
1628
3357
|
for (const service of services) {
|
|
3358
|
+
traceRailwayReconcile(env, "topology:service:start", service.key);
|
|
1629
3359
|
const persistedService = deployState.services?.[service.key] ?? {};
|
|
1630
3360
|
const resolvedProjectId = service.projectId ?? persistedService.projectId ?? "";
|
|
1631
3361
|
const resolvedProjectName = service.projectName ?? persistedService.projectName ?? "";
|
|
1632
3362
|
const resolvedServiceId = service.serviceId ?? persistedService.serviceId ?? "";
|
|
1633
3363
|
const resolvedServiceName = service.serviceName ?? persistedService.serviceName ?? "";
|
|
1634
3364
|
let project = projectsByKey.get(resolvedProjectId) ?? projectsByKey.get(resolvedProjectName) ?? null;
|
|
3365
|
+
if (project && (!Array.isArray(project.services) || project.services.length === 0 || !Array.isArray(project.environments) || project.environments.length === 0)) {
|
|
3366
|
+
const hydratedProject = await getRailwayProject({ projectId: project.id, env });
|
|
3367
|
+
if (hydratedProject) {
|
|
3368
|
+
project = hydratedProject;
|
|
3369
|
+
projectsByKey.set(project.id, project);
|
|
3370
|
+
projectsByKey.set(project.name, project);
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
if (project) {
|
|
3374
|
+
traceRailwayReconcile(env, "topology:project:resolved", `${service.key}:${project.name}:services=${project.services.map((entry) => entry.name).join(",") || "(none)"}:envs=${project.environments.map((entry) => entry.name).join(",") || "(none)"}`);
|
|
3375
|
+
}
|
|
1635
3376
|
if (!project && ensure) {
|
|
1636
3377
|
if (!workspace) {
|
|
3378
|
+
traceRailwayReconcile(env, "topology:workspace", "resolving workspace");
|
|
1637
3379
|
workspace = await resolveRailwayWorkspaceContext({ env });
|
|
1638
3380
|
}
|
|
3381
|
+
traceRailwayReconcile(env, "topology:project:ensure", `${service.key}:${resolvedProjectName || resolvedProjectId}`);
|
|
1639
3382
|
const ensuredProject = await ensureRailwayProject({
|
|
1640
3383
|
projectId: resolvedProjectId,
|
|
1641
3384
|
projectName: resolvedProjectName,
|
|
@@ -1648,7 +3391,11 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1648
3391
|
projectsByKey.set(project.name, project);
|
|
1649
3392
|
}
|
|
1650
3393
|
let environment = project?.environments.find((entry) => entry.name === service.railwayEnvironment || entry.id === service.railwayEnvironment) ?? null;
|
|
3394
|
+
if (project) {
|
|
3395
|
+
traceRailwayReconcile(env, "topology:environment:resolved", `${service.key}:${environment?.name ?? "(none)"}:${service.railwayEnvironment}`);
|
|
3396
|
+
}
|
|
1651
3397
|
if (project && !environment && ensure) {
|
|
3398
|
+
traceRailwayReconcile(env, "topology:environment:ensure", `${service.key}:${service.railwayEnvironment}`);
|
|
1652
3399
|
environment = await ensureRailwayEnvironmentForService({
|
|
1653
3400
|
service,
|
|
1654
3401
|
project,
|
|
@@ -1662,12 +3409,30 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1662
3409
|
projectsByKey.set(project.id, project);
|
|
1663
3410
|
projectsByKey.set(project.name, project);
|
|
1664
3411
|
}
|
|
3412
|
+
if (project && environment && (!Array.isArray(project.services) || project.services.length === 0 || !project.services.some((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName))) {
|
|
3413
|
+
const environmentServices = await listRailwayEnvironmentServices({ environmentId: environment.id, env }).catch(() => []);
|
|
3414
|
+
traceRailwayReconcile(env, "topology:environment-services:lookup", `${service.key}:${environment.name}:${environmentServices.map((entry) => entry.name).join(",") || "(none)"}`);
|
|
3415
|
+
if (environmentServices.length > 0) {
|
|
3416
|
+
project = {
|
|
3417
|
+
...project,
|
|
3418
|
+
services: [...new Map([...project.services, ...environmentServices].map((entry) => [entry.id, entry])).values()]
|
|
3419
|
+
};
|
|
3420
|
+
projectsByKey.set(project.id, project);
|
|
3421
|
+
projectsByKey.set(project.name, project);
|
|
3422
|
+
traceRailwayReconcile(env, "topology:environment-services:resolved", `${service.key}:${environmentServices.map((entry) => entry.name).join(",")}`);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
1665
3425
|
let resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
|
|
1666
|
-
if (project && !resolvedService &&
|
|
3426
|
+
if (project && !resolvedService && ensureServices) {
|
|
3427
|
+
traceRailwayReconcile(env, "topology:railway-service:ensure", `${service.key}:${resolvedServiceName || resolvedServiceId}`);
|
|
1667
3428
|
resolvedService = (await ensureRailwayService({
|
|
1668
3429
|
projectId: project.id,
|
|
1669
3430
|
serviceId: resolvedServiceId,
|
|
1670
3431
|
serviceName: resolvedServiceName,
|
|
3432
|
+
environmentId: environment?.id,
|
|
3433
|
+
imageRef: service.imageRef,
|
|
3434
|
+
sourceRepo: service.sourceRepo,
|
|
3435
|
+
sourceBranch: service.sourceBranch,
|
|
1671
3436
|
env
|
|
1672
3437
|
})).service;
|
|
1673
3438
|
project = {
|
|
@@ -1676,16 +3441,37 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1676
3441
|
};
|
|
1677
3442
|
projectsByKey.set(project.id, project);
|
|
1678
3443
|
projectsByKey.set(project.name, project);
|
|
3444
|
+
} else if (project && resolvedService && ensureServices && (service.imageRef || service.sourceRepo)) {
|
|
3445
|
+
traceRailwayReconcile(env, "topology:railway-service:repair-source", `${service.key}:${resolvedService.name}`);
|
|
3446
|
+
try {
|
|
3447
|
+
resolvedService = (await ensureRailwayService({
|
|
3448
|
+
projectId: project.id,
|
|
3449
|
+
serviceId: resolvedService.id,
|
|
3450
|
+
serviceName: resolvedServiceName || resolvedService.name,
|
|
3451
|
+
environmentId: environment?.id,
|
|
3452
|
+
imageRef: service.imageRef,
|
|
3453
|
+
sourceRepo: service.sourceRepo,
|
|
3454
|
+
sourceBranch: service.sourceBranch,
|
|
3455
|
+
env
|
|
3456
|
+
})).service;
|
|
3457
|
+
} catch (error) {
|
|
3458
|
+
if (!looksLikeRailwayExistingSourceRepairUnsupported(error)) {
|
|
3459
|
+
throw error;
|
|
3460
|
+
}
|
|
3461
|
+
traceRailwayReconcile(env, "topology:railway-service:repair-source-skipped", `${service.key}:${resolvedService.name}`);
|
|
3462
|
+
}
|
|
1679
3463
|
}
|
|
1680
3464
|
let instance = null;
|
|
1681
3465
|
if (includeInstances && resolvedService && environment) {
|
|
1682
|
-
if (
|
|
3466
|
+
if (ensureServices) {
|
|
3467
|
+
traceRailwayReconcile(env, "topology:instance:ensure", `${service.key}:${resolvedService.name}`);
|
|
1683
3468
|
instance = (await ensureRailwayServiceInstanceConfiguration({
|
|
1684
3469
|
serviceId: resolvedService.id,
|
|
1685
3470
|
environmentId: environment.id,
|
|
1686
3471
|
buildCommand: service.buildCommand,
|
|
3472
|
+
dockerfilePath: !service.imageRef ? service.dockerfilePath ?? (existsSync(resolve(service.rootDir, "Dockerfile")) ? "/Dockerfile" : null) : null,
|
|
1687
3473
|
startCommand: service.startCommand,
|
|
1688
|
-
rootDirectory:
|
|
3474
|
+
rootDirectory: service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service),
|
|
1689
3475
|
healthcheckPath: service.healthcheckPath,
|
|
1690
3476
|
healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
|
|
1691
3477
|
healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
|
|
@@ -1701,154 +3487,714 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1701
3487
|
});
|
|
1702
3488
|
}
|
|
1703
3489
|
}
|
|
1704
|
-
const currentVariables = includeVariables && project && environment && resolvedService ? await listRailwayVariables({
|
|
1705
|
-
projectId: project.id,
|
|
1706
|
-
environmentId: environment.id,
|
|
1707
|
-
serviceId: resolvedService.id,
|
|
1708
|
-
env
|
|
1709
|
-
}) : {};
|
|
1710
|
-
resolvedServices.set(service.key, {
|
|
1711
|
-
configuredService: service,
|
|
1712
|
-
project,
|
|
1713
|
-
environment,
|
|
1714
|
-
service: resolvedService,
|
|
1715
|
-
instance,
|
|
1716
|
-
currentVariables
|
|
1717
|
-
});
|
|
3490
|
+
const currentVariables = includeVariables && project && environment && resolvedService ? await listRailwayVariables({
|
|
3491
|
+
projectId: project.id,
|
|
3492
|
+
environmentId: environment.id,
|
|
3493
|
+
serviceId: resolvedService.id,
|
|
3494
|
+
env
|
|
3495
|
+
}) : {};
|
|
3496
|
+
resolvedServices.set(service.key, {
|
|
3497
|
+
configuredService: service,
|
|
3498
|
+
project,
|
|
3499
|
+
environment,
|
|
3500
|
+
service: resolvedService,
|
|
3501
|
+
instance,
|
|
3502
|
+
currentVariables
|
|
3503
|
+
});
|
|
3504
|
+
traceRailwayReconcile(env, "topology:service:done", service.key);
|
|
3505
|
+
}
|
|
3506
|
+
traceRailwayReconcile(env, "topology:done", `scope=${scope}`);
|
|
3507
|
+
return {
|
|
3508
|
+
scope,
|
|
3509
|
+
env,
|
|
3510
|
+
workspace: workspace ?? {
|
|
3511
|
+
id: "",
|
|
3512
|
+
name: String(env.TREESEED_RAILWAY_WORKSPACE ?? "").trim()
|
|
3513
|
+
},
|
|
3514
|
+
services: resolvedServices
|
|
3515
|
+
};
|
|
3516
|
+
}, refresh);
|
|
3517
|
+
}
|
|
3518
|
+
function traceRailwayReconcile(env, stage, message) {
|
|
3519
|
+
if (env?.TREESEED_RECONCILE_TRACE === "1" || process.env.TREESEED_RECONCILE_TRACE === "1") {
|
|
3520
|
+
console.error(`[trsd][railway][${stage}] ${message}`);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
function railwayDriftSessionKey(scope) {
|
|
3524
|
+
return `railway:provider-drift:${scope}`;
|
|
3525
|
+
}
|
|
3526
|
+
function recordRailwayProviderDrift(input, scope, drift) {
|
|
3527
|
+
const key = railwayDriftSessionKey(scope);
|
|
3528
|
+
const current = input.context.session.get(key);
|
|
3529
|
+
const entries = Array.isArray(current) ? current : [];
|
|
3530
|
+
entries.push(drift);
|
|
3531
|
+
input.context.session.set(key, entries);
|
|
3532
|
+
}
|
|
3533
|
+
function railwayProviderDrift(input, scope) {
|
|
3534
|
+
const current = input.context.session.get(railwayDriftSessionKey(scope));
|
|
3535
|
+
return Array.isArray(current) ? current : [];
|
|
3536
|
+
}
|
|
3537
|
+
function activeRailwayVolumeInstances(volume) {
|
|
3538
|
+
const instances = Array.isArray(volume.instances) ? volume.instances : [];
|
|
3539
|
+
return instances.filter((instance) => {
|
|
3540
|
+
const state = String(instance.state ?? "READY").toUpperCase();
|
|
3541
|
+
const pendingDeletion = "isPendingDeletion" in instance && instance.isPendingDeletion === true;
|
|
3542
|
+
const deletedAt = "deletedAt" in instance && typeof instance.deletedAt === "string" && instance.deletedAt.trim();
|
|
3543
|
+
return !pendingDeletion && !deletedAt && state !== "DELETING" && state !== "DELETED";
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
function isRailwayCapacityProviderService(service) {
|
|
3547
|
+
return String(service.key ?? "").startsWith("capacityProvider");
|
|
3548
|
+
}
|
|
3549
|
+
function railwayServiceMatchesKey(service, key) {
|
|
3550
|
+
return service.key === key || service.instanceKey === key || service.serviceName === key || service.serviceId === key;
|
|
3551
|
+
}
|
|
3552
|
+
function configuredRailwayProjectSyncGroups(input, scope, serviceKeys) {
|
|
3553
|
+
const allServices = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service));
|
|
3554
|
+
const selected = Array.isArray(serviceKeys) && serviceKeys.length > 0 ? allServices.filter((service) => serviceKeys.some((key) => railwayServiceMatchesKey(service, key))) : allServices;
|
|
3555
|
+
const selectedProjectKeys = new Set(selected.map((service) => `${service.projectName}:::${service.railwayEnvironment}`));
|
|
3556
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3557
|
+
for (const service of allServices) {
|
|
3558
|
+
const key = `${service.projectName}:::${service.railwayEnvironment}`;
|
|
3559
|
+
if (!selectedProjectKeys.has(key)) {
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
const services = grouped.get(key) ?? [];
|
|
3563
|
+
services.push(service);
|
|
3564
|
+
grouped.set(key, services);
|
|
3565
|
+
}
|
|
3566
|
+
return [...grouped.values()];
|
|
3567
|
+
}
|
|
3568
|
+
function railwayIacServiceInput(input, sync, service, scope) {
|
|
3569
|
+
if (scope === "staging" && service.imageRef) {
|
|
3570
|
+
throw new Error(`Railway staging service ${service.serviceName} must build from Git source, not Docker image ${service.imageRef}.`);
|
|
3571
|
+
}
|
|
3572
|
+
if (scope === "prod" && service.sourceMode === "git") {
|
|
3573
|
+
throw new Error(`Railway production service ${service.serviceName} must deploy an immutable image, not Git source.`);
|
|
3574
|
+
}
|
|
3575
|
+
const serviceSync = sync.forService(service.key, service);
|
|
3576
|
+
const deployVariablePrefix = service.serviceName.includes("treedx") || service.key.includes("treedx") ? "TREEDX" : "TREESEED";
|
|
3577
|
+
const sourceVariables = service.sourceMode === "git" ? {
|
|
3578
|
+
...service.sourceRepo ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_REPOSITORY`]: service.sourceRepo } : {},
|
|
3579
|
+
...service.sourceBranch ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_BRANCH`]: service.sourceBranch } : {},
|
|
3580
|
+
...service.sourceCommit ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_COMMIT`]: service.sourceCommit } : {}
|
|
3581
|
+
} : {};
|
|
3582
|
+
return {
|
|
3583
|
+
key: service.instanceKey ?? service.key,
|
|
3584
|
+
serviceName: service.serviceName,
|
|
3585
|
+
sourceMode: service.sourceMode,
|
|
3586
|
+
sourceRepo: service.sourceRepo,
|
|
3587
|
+
sourceBranch: service.sourceBranch,
|
|
3588
|
+
sourceCommit: service.sourceCommit,
|
|
3589
|
+
sourceRootDirectory: service.sourceRootDirectory ?? railwayServiceRootDirectory(input.context.tenantRoot, service),
|
|
3590
|
+
imageRef: service.imageRef,
|
|
3591
|
+
dockerfilePath: service.dockerfilePath,
|
|
3592
|
+
buildCommand: service.buildCommand,
|
|
3593
|
+
startCommand: service.startCommand,
|
|
3594
|
+
healthcheckPath: service.healthcheckPath,
|
|
3595
|
+
healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
|
|
3596
|
+
runtimeMode: service.runtimeMode,
|
|
3597
|
+
volumeMountPath: service.volumeMountPath,
|
|
3598
|
+
variables: {
|
|
3599
|
+
...serviceSync.variables,
|
|
3600
|
+
...sourceVariables
|
|
3601
|
+
},
|
|
3602
|
+
secrets: serviceSync.secrets
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3605
|
+
function configuredRailwayIacDatabase(input, projectServices, detachVolumeIds = []) {
|
|
3606
|
+
if (!projectServices.some((service) => service.key === "api" || service.key === "operationsRunner")) {
|
|
3607
|
+
return null;
|
|
3608
|
+
}
|
|
3609
|
+
const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
|
|
3610
|
+
const treeseedDatabaseService = treeseedDatabase?.service;
|
|
3611
|
+
if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
|
|
3612
|
+
return null;
|
|
3613
|
+
}
|
|
3614
|
+
return {
|
|
3615
|
+
serviceName: treeseedDatabase.serviceName,
|
|
3616
|
+
environmentVariable: "TREESEED_DATABASE_URL",
|
|
3617
|
+
mountPath: "/var/lib/postgresql/data",
|
|
3618
|
+
detachVolumeIds
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3621
|
+
function activeAttachedRailwayVolumeIds(volumes, serviceId, environmentId, desiredVolumeName) {
|
|
3622
|
+
if (!serviceId) {
|
|
3623
|
+
return [];
|
|
3624
|
+
}
|
|
3625
|
+
return volumes.filter((volume) => volume.instances.some(
|
|
3626
|
+
(instance) => instance.serviceId === serviceId && instance.environmentId === environmentId && (volume.name !== desiredVolumeName || activeRailwayVolumeInstances({ instances: [instance] }).length === 0)
|
|
3627
|
+
)).map((volume) => volume.id);
|
|
3628
|
+
}
|
|
3629
|
+
function blockedPendingRailwayVolumeAttachments(volumes, serviceIdByName, environmentId) {
|
|
3630
|
+
const serviceNameById = new Map([...serviceIdByName.entries()].filter(([, serviceId]) => serviceId).map(([serviceName, serviceId]) => [serviceId, serviceName]));
|
|
3631
|
+
const blocked = [];
|
|
3632
|
+
for (const volume of volumes) {
|
|
3633
|
+
for (const instance of volume.instances) {
|
|
3634
|
+
if (!instance.serviceId || instance.environmentId !== environmentId) {
|
|
3635
|
+
continue;
|
|
3636
|
+
}
|
|
3637
|
+
const serviceName = serviceNameById.get(instance.serviceId);
|
|
3638
|
+
if (!serviceName) {
|
|
3639
|
+
continue;
|
|
3640
|
+
}
|
|
3641
|
+
if (activeRailwayVolumeInstances({ instances: [instance] }).length > 0) {
|
|
3642
|
+
continue;
|
|
3643
|
+
}
|
|
3644
|
+
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)"}`);
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
return blocked;
|
|
3648
|
+
}
|
|
3649
|
+
async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
|
|
3650
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
3651
|
+
const desiredRunners = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false);
|
|
3652
|
+
const desiredServiceNames = new Set(desiredRunners.map((service) => service.serviceName).filter(Boolean));
|
|
3653
|
+
const desiredVolumeNames = new Set([...desiredServiceNames].map((serviceName) => `${serviceName}-volume`));
|
|
3654
|
+
if (desiredServiceNames.size === 0) {
|
|
3655
|
+
traceRailwayReconcile(topology.env, "sync:runner:cleanup-skip", "no desired operations runner service names");
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
const projectEntries = [...topology.services.values()].filter((entry) => entry.project?.id && entry.environment?.id).map((entry) => ({ project: entry.project, environment: entry.environment }));
|
|
3659
|
+
traceRailwayReconcile(
|
|
3660
|
+
topology.env,
|
|
3661
|
+
"sync:runner:cleanup-projects",
|
|
3662
|
+
projectEntries.map((entry) => `${entry.project.name}:${entry.environment.name}`).join(",") || "(none)"
|
|
3663
|
+
);
|
|
3664
|
+
const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
|
|
3665
|
+
for (const { project, environment } of projects.values()) {
|
|
3666
|
+
await reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
3667
|
+
scope,
|
|
3668
|
+
env: topology.env,
|
|
3669
|
+
project,
|
|
3670
|
+
environment,
|
|
3671
|
+
desiredServiceNames,
|
|
3672
|
+
desiredVolumeNames
|
|
3673
|
+
});
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
async function reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
3677
|
+
scope,
|
|
3678
|
+
env,
|
|
3679
|
+
project,
|
|
3680
|
+
environment,
|
|
3681
|
+
desiredServiceNames,
|
|
3682
|
+
desiredVolumeNames
|
|
3683
|
+
}) {
|
|
3684
|
+
if (desiredServiceNames.size === 0 || desiredVolumeNames.size === 0) {
|
|
3685
|
+
traceRailwayReconcile(env, "sync:runner:delete-skip", `${project.name}: no desired runner names; refusing to prune operations runner resources`);
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3688
|
+
const services = await listRailwayServices({ projectId: project.id, env });
|
|
3689
|
+
const desiredServiceIds = new Set(services.filter((service) => desiredServiceNames.has(service.name)).map((service) => service.id));
|
|
3690
|
+
const staleServices = findStaleTreeseedOperationsRunnerResources(services, desiredServiceNames);
|
|
3691
|
+
traceRailwayReconcile(
|
|
3692
|
+
env,
|
|
3693
|
+
"sync:runner:observed-services",
|
|
3694
|
+
`${project.name}: desired=${[...desiredServiceNames].join(",") || "(none)"} stale=${staleServices.map((service) => service.name).join(",") || "(none)"}`
|
|
3695
|
+
);
|
|
3696
|
+
for (const service of staleServices) {
|
|
3697
|
+
traceRailwayReconcile(env, "sync:runner:block-stale-service", `${service.name}:${service.id}`);
|
|
3698
|
+
recordRailwayProviderDrift(input, scope, {
|
|
3699
|
+
kind: "railway.stale-operations-runner-service",
|
|
3700
|
+
action: "manual-repair-required",
|
|
3701
|
+
status: "blocked",
|
|
3702
|
+
projectId: project.id,
|
|
3703
|
+
environmentId: environment.id,
|
|
3704
|
+
serviceId: service.id,
|
|
3705
|
+
serviceName: service.name,
|
|
3706
|
+
reason: "Railway reports an undeclared operations runner service. Reconciliation refuses to delete existing services; adopt, rename, retain, or remove it through an explicit destroy workflow."
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
const volumes = await listRailwayVolumes({ projectId: project.id, env }).catch(() => []);
|
|
3710
|
+
const staleVolumes = findStaleTreeseedOperationsRunnerResources(volumes, desiredVolumeNames).filter((volume) => {
|
|
3711
|
+
const activeInstances = activeRailwayVolumeInstances(volume);
|
|
3712
|
+
return volume.instances.length === 0 || activeInstances.length > 0;
|
|
3713
|
+
}).filter((volume) => activeRailwayVolumeInstances(volume).every((instance) => !desiredServiceIds.has(instance.serviceId ?? "")));
|
|
3714
|
+
traceRailwayReconcile(
|
|
3715
|
+
env,
|
|
3716
|
+
"sync:runner:observed-volumes",
|
|
3717
|
+
`${project.name}: desired=${[...desiredVolumeNames].join(",") || "(none)"} stale=${staleVolumes.map((volume) => volume.name ?? volume.id).join(",") || "(none)"}`
|
|
3718
|
+
);
|
|
3719
|
+
for (const volume of staleVolumes) {
|
|
3720
|
+
traceRailwayReconcile(env, "sync:runner:block-stale-volume", `${volume.name ?? volume.id}:${volume.id}`);
|
|
3721
|
+
recordRailwayProviderDrift(input, scope, {
|
|
3722
|
+
kind: "railway.stale-operations-runner-volume",
|
|
3723
|
+
action: "manual-repair-required",
|
|
3724
|
+
status: "blocked",
|
|
3725
|
+
projectId: project.id,
|
|
3726
|
+
environmentId: environment.id,
|
|
3727
|
+
volumeId: volume.id,
|
|
3728
|
+
volumeName: volume.name ?? null,
|
|
3729
|
+
reason: "Railway reports an undeclared operations runner volume. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow."
|
|
3730
|
+
});
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKeys } = {}) {
|
|
3734
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
3735
|
+
const sync = collectRailwayEnvironmentSync(input);
|
|
3736
|
+
const selectedServiceKeys = Array.isArray(serviceKeys) && serviceKeys.length > 0 ? [...new Set(serviceKeys.map((value) => String(value).trim()).filter(Boolean))] : void 0;
|
|
3737
|
+
traceRailwayReconcile(input.context.env, "sync:start", `scope=${scope} dryRun=${dryRun ? "yes" : "no"}`);
|
|
3738
|
+
const projectGroups = configuredRailwayProjectSyncGroups(input, scope, selectedServiceKeys);
|
|
3739
|
+
if (projectGroups.length === 0) {
|
|
3740
|
+
return {
|
|
3741
|
+
scope,
|
|
3742
|
+
services: [],
|
|
3743
|
+
secrets: Object.keys(sync.secrets),
|
|
3744
|
+
variables: Object.keys(sync.variables),
|
|
3745
|
+
dryRun,
|
|
3746
|
+
workspace: ""
|
|
3747
|
+
};
|
|
3748
|
+
}
|
|
3749
|
+
let workspace = "";
|
|
3750
|
+
const syncedServices = [];
|
|
3751
|
+
for (const projectServices of projectGroups) {
|
|
3752
|
+
const serviceKeySet = [...new Set(projectServices.map((service) => service.key))];
|
|
3753
|
+
const topology = await resolveRailwayTopologyForScope(input, scope, {
|
|
3754
|
+
ensure: !dryRun,
|
|
3755
|
+
ensureServices: false,
|
|
3756
|
+
serviceKeys: serviceKeySet,
|
|
3757
|
+
includeInstances: false,
|
|
3758
|
+
includeVariables: false,
|
|
3759
|
+
refresh: true
|
|
3760
|
+
});
|
|
3761
|
+
workspace = topology.workspace.name;
|
|
3762
|
+
const resolvedEntry = [...topology.services.values()].find((entry) => entry.project && entry.environment) ?? null;
|
|
3763
|
+
const project = resolvedEntry?.project;
|
|
3764
|
+
const environment = resolvedEntry?.environment;
|
|
3765
|
+
traceRailwayReconcile(topology.env, "sync:topology", `project-services=${projectServices.map((service) => service.serviceName).join(",")}`);
|
|
3766
|
+
if (dryRun) {
|
|
3767
|
+
syncedServices.push(...projectServices);
|
|
3768
|
+
continue;
|
|
3769
|
+
}
|
|
3770
|
+
if (!project || !environment) {
|
|
3771
|
+
throw new Error(`Railway IaC reconciliation could not resolve project/environment for ${projectServices[0]?.projectName ?? "(unknown project)"}/${projectServices[0]?.railwayEnvironment ?? "(unknown environment)"}.`);
|
|
3772
|
+
}
|
|
3773
|
+
const liveServices = await listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }).catch(() => project.services);
|
|
3774
|
+
const liveServiceByName = new Map(liveServices.map((service) => [service.name, service]));
|
|
3775
|
+
const liveVolumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
|
|
3776
|
+
const databaseDescriptor = configuredRailwayIacDatabase(input, projectServices);
|
|
3777
|
+
const serviceIdByName = new Map(projectServices.map((service) => {
|
|
3778
|
+
const liveService = liveServiceByName.get(service.serviceName) ?? project.services.find((candidate) => candidate.name === service.serviceName || candidate.id === service.serviceId) ?? null;
|
|
3779
|
+
return [service.serviceName, liveService?.id ?? null];
|
|
3780
|
+
}));
|
|
3781
|
+
if (databaseDescriptor) {
|
|
3782
|
+
const databaseLiveService2 = liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null;
|
|
3783
|
+
serviceIdByName.set(databaseDescriptor.serviceName, databaseLiveService2?.id ?? null);
|
|
3784
|
+
}
|
|
3785
|
+
const pendingVolumeBlocks = blockedPendingRailwayVolumeAttachments(liveVolumes, serviceIdByName, environment.id);
|
|
3786
|
+
if (pendingVolumeBlocks.length > 0) {
|
|
3787
|
+
for (const reason of pendingVolumeBlocks) {
|
|
3788
|
+
recordRailwayProviderDrift(input, scope, {
|
|
3789
|
+
kind: "railway.pending-volume-attachment",
|
|
3790
|
+
action: "blocked",
|
|
3791
|
+
status: "blocked",
|
|
3792
|
+
projectId: project.id,
|
|
3793
|
+
environmentId: environment.id,
|
|
3794
|
+
reason
|
|
3795
|
+
});
|
|
3796
|
+
}
|
|
3797
|
+
throw new Error(`Railway has pending-deletion volume attachments that block official IaC reconciliation. ${pendingVolumeBlocks.join("; ")}`);
|
|
1718
3798
|
}
|
|
1719
|
-
|
|
3799
|
+
const detachVolumeIdsByServiceName = new Map(projectServices.map((service) => {
|
|
3800
|
+
const liveService = liveServiceByName.get(service.serviceName) ?? project.services.find((candidate) => candidate.name === service.serviceName || candidate.id === service.serviceId) ?? null;
|
|
3801
|
+
return [
|
|
3802
|
+
service.serviceName,
|
|
3803
|
+
activeAttachedRailwayVolumeIds(liveVolumes, liveService?.id, environment.id, `${service.serviceName}-volume`)
|
|
3804
|
+
];
|
|
3805
|
+
}));
|
|
3806
|
+
const databaseLiveService = databaseDescriptor ? liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null : null;
|
|
3807
|
+
const databaseDetachVolumeIds = databaseDescriptor ? activeAttachedRailwayVolumeIds(liveVolumes, databaseLiveService?.id, environment.id, `${databaseDescriptor.serviceName}-volume`) : [];
|
|
3808
|
+
const databaseForIac = databaseDescriptor ? { ...databaseDescriptor, detachVolumeIds: databaseDetachVolumeIds, useNativePostgres: Boolean(databaseLiveService) } : null;
|
|
3809
|
+
const token = String(topology.env.TREESEED_RAILWAY_API_TOKEN ?? topology.env.RAILWAY_API_TOKEN ?? "").trim();
|
|
3810
|
+
if (!token) {
|
|
3811
|
+
throw new Error(`Railway IaC reconciliation requires TREESEED_RAILWAY_API_TOKEN for ${project.name}/${environment.name}.`);
|
|
3812
|
+
}
|
|
3813
|
+
const iacInput = {
|
|
3814
|
+
tenantRoot: input.context.tenantRoot,
|
|
1720
3815
|
scope,
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
3816
|
+
projectName: project.name,
|
|
3817
|
+
projectId: project.id,
|
|
3818
|
+
environmentName: environment.name,
|
|
3819
|
+
environmentId: environment.id,
|
|
3820
|
+
railwayApiToken: token,
|
|
3821
|
+
railwayApiUrl: String(topology.env.TREESEED_RAILWAY_API_URL ?? topology.env.RAILWAY_API_URL ?? "").trim() || null,
|
|
3822
|
+
services: projectServices.map((service) => ({
|
|
3823
|
+
...railwayIacServiceInput(input, sync, service, scope),
|
|
3824
|
+
detachVolumeIds: detachVolumeIdsByServiceName.get(service.serviceName) ?? []
|
|
3825
|
+
})),
|
|
3826
|
+
database: databaseForIac
|
|
1727
3827
|
};
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
continue;
|
|
3828
|
+
const {
|
|
3829
|
+
applyRailwayIacProject,
|
|
3830
|
+
cleanupRailwayIacRender,
|
|
3831
|
+
planRailwayIacProject,
|
|
3832
|
+
renderRailwayIacProject,
|
|
3833
|
+
validateRailwayIacChangeSet
|
|
3834
|
+
} = await import("./providers/railway-iac.js");
|
|
3835
|
+
const rendered = renderRailwayIacProject(iacInput);
|
|
3836
|
+
try {
|
|
3837
|
+
traceRailwayReconcile(topology.env, "sync:iac-plan", `${project.name}/${environment.name}:${rendered.serviceNames.join(",")}:volumes=${rendered.volumeNames.join(",")}`);
|
|
3838
|
+
const plan = await planRailwayIacProject(iacInput, rendered);
|
|
3839
|
+
if (!plan.ok) {
|
|
3840
|
+
const diagnostics = plan.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
|
|
3841
|
+
throw new Error(`Railway IaC plan failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
|
|
1743
3842
|
}
|
|
1744
|
-
const
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
variables: {
|
|
1750
|
-
...serviceSync.variables,
|
|
1751
|
-
...serviceSync.secrets
|
|
1752
|
-
},
|
|
1753
|
-
env: topology.env
|
|
3843
|
+
const validation = validateRailwayIacChangeSet(plan.changeSet, {
|
|
3844
|
+
services: rendered.serviceNames,
|
|
3845
|
+
volumes: rendered.volumeNames,
|
|
3846
|
+
database: rendered.databaseName,
|
|
3847
|
+
scope
|
|
1754
3848
|
});
|
|
1755
|
-
if (
|
|
1756
|
-
|
|
1757
|
-
const volume = await ensureRailwayServiceVolumeWithCliFallback({
|
|
1758
|
-
tenantRoot: input.context.tenantRoot,
|
|
1759
|
-
projectId: entry.project.id,
|
|
1760
|
-
environmentId: entry.environment.id,
|
|
1761
|
-
environmentName: entry.environment.name,
|
|
1762
|
-
serviceId: entry.service.id,
|
|
1763
|
-
serviceName: entry.service.name,
|
|
1764
|
-
name: volumeName,
|
|
1765
|
-
mountPath: entry.configuredService.volumeMountPath,
|
|
1766
|
-
preferCli: entry.configuredService.key === "marketOperationsRunner",
|
|
1767
|
-
env: topology.env
|
|
1768
|
-
});
|
|
1769
|
-
if (volume.instance?.serviceId !== entry.service.id || volume.instance?.environmentId !== entry.environment.id || volume.instance?.mountPath !== entry.configuredService.volumeMountPath) {
|
|
1770
|
-
ensureRailwayProjectContext(entry.configuredService, { env: topology.env, capture: true });
|
|
1771
|
-
let attachMessage = "";
|
|
1772
|
-
let attached = false;
|
|
1773
|
-
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
1774
|
-
const attachResult = runRailway([
|
|
1775
|
-
"volume",
|
|
1776
|
-
"--service",
|
|
1777
|
-
entry.service.id,
|
|
1778
|
-
"attach",
|
|
1779
|
-
"--volume",
|
|
1780
|
-
volume.volume.id,
|
|
1781
|
-
"--yes",
|
|
1782
|
-
"--json"
|
|
1783
|
-
], {
|
|
1784
|
-
cwd: entry.configuredService.rootDir,
|
|
1785
|
-
capture: true,
|
|
1786
|
-
allowFailure: true,
|
|
1787
|
-
env: topology.env
|
|
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
|
-
}
|
|
1806
|
-
}
|
|
3849
|
+
if (!validation.ok) {
|
|
3850
|
+
throw new Error(`Railway IaC plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
|
|
1807
3851
|
}
|
|
3852
|
+
const apply = await applyRailwayIacProject(iacInput, rendered);
|
|
3853
|
+
if (!apply.ok) {
|
|
3854
|
+
const diagnostics = apply.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
|
|
3855
|
+
throw new Error(`Railway IaC apply failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
|
|
3856
|
+
}
|
|
3857
|
+
syncedServices.push(...projectServices);
|
|
3858
|
+
} finally {
|
|
3859
|
+
cleanupRailwayIacRender(rendered);
|
|
1808
3860
|
}
|
|
1809
3861
|
}
|
|
3862
|
+
traceRailwayReconcile(input.context.env, "sync:done", `scope=${scope}`);
|
|
1810
3863
|
return {
|
|
1811
3864
|
scope,
|
|
1812
|
-
services:
|
|
3865
|
+
services: syncedServices,
|
|
1813
3866
|
secrets: Object.keys(sync.secrets),
|
|
1814
3867
|
variables: Object.keys(sync.variables),
|
|
1815
3868
|
dryRun,
|
|
1816
|
-
workspace
|
|
3869
|
+
workspace
|
|
3870
|
+
};
|
|
3871
|
+
}
|
|
3872
|
+
function shouldDeployRailwayServiceBySourceUpload(scope, service) {
|
|
3873
|
+
return scope === "staging" && service.sourceMode === "git" && Boolean(service.sourceRepo) && !service.dockerfilePath && service.key !== "capacityProviderManager" && service.key !== "capacityProviderRunner" && !service.imageRef;
|
|
3874
|
+
}
|
|
3875
|
+
function deployRailwayServiceBySourceUpload(input, entry, env, scope) {
|
|
3876
|
+
if (!entry.project?.id || !entry.environment?.name || !entry.service?.name) {
|
|
3877
|
+
throw new Error(`Railway source upload deploy requires resolved project, environment, and service for ${entry.configuredService.key}.`);
|
|
3878
|
+
}
|
|
3879
|
+
const rootDir = String(entry.configuredService.rootDir ?? "").trim();
|
|
3880
|
+
if (!rootDir || !existsSync(rootDir)) {
|
|
3881
|
+
throw new Error(`Railway source upload deploy root does not exist for ${entry.configuredService.key}: ${rootDir || "(unset)"}.`);
|
|
3882
|
+
}
|
|
3883
|
+
const uploadContext = prepareRailwaySourceUploadContext(input, entry.configuredService, rootDir);
|
|
3884
|
+
const uploadRoot = uploadContext.rootDir;
|
|
3885
|
+
const args = [
|
|
3886
|
+
"trsd",
|
|
3887
|
+
"railway",
|
|
3888
|
+
"--environment",
|
|
3889
|
+
scope,
|
|
3890
|
+
"--",
|
|
3891
|
+
"up",
|
|
3892
|
+
"--service",
|
|
3893
|
+
entry.service.name,
|
|
3894
|
+
"--project",
|
|
3895
|
+
entry.project.id,
|
|
3896
|
+
"--environment",
|
|
3897
|
+
entry.environment.name,
|
|
3898
|
+
"--ci",
|
|
3899
|
+
"--detach",
|
|
3900
|
+
"--path-as-root",
|
|
3901
|
+
...uploadContext.noGitignore ? ["--no-gitignore"] : [],
|
|
3902
|
+
"--verbose",
|
|
3903
|
+
"--message",
|
|
3904
|
+
`trsd staging source upload ${entry.configuredService.key}`,
|
|
3905
|
+
uploadRoot
|
|
3906
|
+
];
|
|
3907
|
+
const commandEnv = {
|
|
3908
|
+
...process.env,
|
|
3909
|
+
...env,
|
|
3910
|
+
TREESEED_RECONCILE_TRACE: env.TREESEED_RECONCILE_TRACE ?? process.env.TREESEED_RECONCILE_TRACE
|
|
1817
3911
|
};
|
|
3912
|
+
try {
|
|
3913
|
+
const result = spawnSync("npx", args, {
|
|
3914
|
+
cwd: input.context.tenantRoot,
|
|
3915
|
+
env: commandEnv,
|
|
3916
|
+
encoding: "utf8",
|
|
3917
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3918
|
+
maxBuffer: 1024 * 1024 * 20
|
|
3919
|
+
});
|
|
3920
|
+
if ((result.status ?? 1) !== 0) {
|
|
3921
|
+
const stderr = String(result.stderr ?? "").split("\n").slice(-120).join("\n");
|
|
3922
|
+
const stdout = String(result.stdout ?? "").split("\n").slice(-120).join("\n");
|
|
3923
|
+
throw new Error(`Railway source upload deploy failed for ${entry.configuredService.key} with status ${result.status ?? 1}.
|
|
3924
|
+
${stderr || stdout}`);
|
|
3925
|
+
}
|
|
3926
|
+
} finally {
|
|
3927
|
+
if (uploadContext.cleanupDir) {
|
|
3928
|
+
rmSync(uploadContext.cleanupDir, { recursive: true, force: true });
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
function prepareRailwaySourceUploadContext(input, service, rootDir) {
|
|
3933
|
+
const role = service.key === "capacityProviderManager" ? "manager" : service.key === "capacityProviderRunner" ? "runner" : null;
|
|
3934
|
+
if (!role) {
|
|
3935
|
+
return { rootDir, cleanupDir: null, noGitignore: false };
|
|
3936
|
+
}
|
|
3937
|
+
const cleanupDir = mkdtempSync(resolve(tmpdir(), `trsd-railway-agent-${role}-`));
|
|
3938
|
+
const contextRoot = resolve(cleanupDir, "context");
|
|
3939
|
+
mkdirSync(contextRoot, { recursive: true });
|
|
3940
|
+
for (const entry of ["src", "scripts", "templates", "docs"]) {
|
|
3941
|
+
const source = resolve(rootDir, entry);
|
|
3942
|
+
if (existsSync(source)) {
|
|
3943
|
+
cpSync(source, resolve(contextRoot, entry), { recursive: true });
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
for (const entry of ["docker-entrypoint.sh", "package.json", "package-lock.json", "tsconfig.json", "tsconfig.dist.json"]) {
|
|
3947
|
+
const source = resolve(rootDir, entry);
|
|
3948
|
+
if (existsSync(source)) {
|
|
3949
|
+
cpSync(source, resolve(contextRoot, entry));
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
const sdkRoot = resolve(rootDir, "..", "sdk");
|
|
3953
|
+
let sdkTarballPrepared = false;
|
|
3954
|
+
if (existsSync(resolve(sdkRoot, "package.json"))) {
|
|
3955
|
+
const pack = spawnSync("npm", ["pack", "--ignore-scripts", "--pack-destination", contextRoot], {
|
|
3956
|
+
cwd: sdkRoot,
|
|
3957
|
+
env: process.env,
|
|
3958
|
+
encoding: "utf8",
|
|
3959
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3960
|
+
maxBuffer: 1024 * 1024 * 20
|
|
3961
|
+
});
|
|
3962
|
+
if ((pack.status ?? 1) !== 0) {
|
|
3963
|
+
const stderr = String(pack.stderr ?? "").split("\n").slice(-80).join("\n");
|
|
3964
|
+
const stdout = String(pack.stdout ?? "").split("\n").slice(-80).join("\n");
|
|
3965
|
+
throw new Error(`Failed to pack local SDK for Agent ${role} Railway source upload context.
|
|
3966
|
+
${stderr || stdout}`);
|
|
3967
|
+
}
|
|
3968
|
+
const filename = String(pack.stdout ?? "").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).at(-1);
|
|
3969
|
+
if (!filename || !existsSync(resolve(contextRoot, filename))) {
|
|
3970
|
+
throw new Error(`Local SDK pack did not produce a tarball for Agent ${role} Railway source upload context.`);
|
|
3971
|
+
}
|
|
3972
|
+
cpSync(resolve(contextRoot, filename), resolve(contextRoot, "treeseed-sdk.tgz"));
|
|
3973
|
+
if (filename !== "treeseed-sdk.tgz") {
|
|
3974
|
+
rmSync(resolve(contextRoot, filename), { force: true });
|
|
3975
|
+
}
|
|
3976
|
+
sdkTarballPrepared = true;
|
|
3977
|
+
}
|
|
3978
|
+
const dockerfile = [
|
|
3979
|
+
"FROM node:22-alpine AS builder",
|
|
3980
|
+
"WORKDIR /app",
|
|
3981
|
+
"RUN apk add --no-cache ca-certificates git openssh-client",
|
|
3982
|
+
sdkTarballPrepared ? "COPY package.json package-lock.json treeseed-sdk.tgz ./" : "COPY package.json package-lock.json ./",
|
|
3983
|
+
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",
|
|
3984
|
+
"COPY . .",
|
|
3985
|
+
"RUN npm run build:dist \\",
|
|
3986
|
+
" && npm prune --omit=dev --ignore-scripts \\",
|
|
3987
|
+
" && rm -rf node_modules/@github node_modules/@openai node_modules/@cloudflare node_modules/@railway \\",
|
|
3988
|
+
" node_modules/wrangler node_modules/miniflare node_modules/workerd \\",
|
|
3989
|
+
" node_modules/playwright node_modules/playwright-core node_modules/gpt-tokenizer \\",
|
|
3990
|
+
" node_modules/@remotion node_modules/remotion node_modules/@rspack \\",
|
|
3991
|
+
" node_modules/@repomix node_modules/repomix node_modules/@esbuild node_modules/esbuild \\",
|
|
3992
|
+
" node_modules/webpack node_modules/typescript node_modules/@mediabunny node_modules/mediabunny \\",
|
|
3993
|
+
" node_modules/web-tree-sitter node_modules/caniuse-lite node_modules/@img node_modules/@secretlint \\",
|
|
3994
|
+
" node_modules/lightningcss* node_modules/vite \\",
|
|
3995
|
+
" && npm cache clean --force \\",
|
|
3996
|
+
" && rm -rf src scripts test .git .github",
|
|
3997
|
+
"FROM node:22-alpine AS runtime",
|
|
3998
|
+
"WORKDIR /app",
|
|
3999
|
+
"ENV NODE_ENV=production TREESEED_PROVIDER_DATA_DIR=/data",
|
|
4000
|
+
"RUN apk add --no-cache ca-certificates tini util-linux git openssh-client \\",
|
|
4001
|
+
" && mkdir -p /data",
|
|
4002
|
+
"COPY --from=builder --chown=65532:65532 /app/package.json /app/package-lock.json ./",
|
|
4003
|
+
"COPY --from=builder --chown=65532:65532 /app/node_modules ./node_modules",
|
|
4004
|
+
"COPY --from=builder --chown=65532:65532 /app/dist ./dist",
|
|
4005
|
+
"COPY --from=builder --chown=65532:65532 /app/templates ./templates",
|
|
4006
|
+
"COPY --from=builder --chown=65532:65532 /app/docs ./docs",
|
|
4007
|
+
"COPY --from=builder --chown=65532:65532 /app/docker-entrypoint.sh ./docker-entrypoint.sh",
|
|
4008
|
+
"RUN chmod 0755 /app/docker-entrypoint.sh \\",
|
|
4009
|
+
" && chown -R 65532:65532 /data",
|
|
4010
|
+
"EXPOSE 3100",
|
|
4011
|
+
'ENTRYPOINT ["tini", "--", "/app/docker-entrypoint.sh"]',
|
|
4012
|
+
`ENV TREESEED_PROVIDER_ROLE=${role}`,
|
|
4013
|
+
`CMD ["${role}"]`,
|
|
4014
|
+
""
|
|
4015
|
+
].join("\n");
|
|
4016
|
+
writeFileSync(
|
|
4017
|
+
resolve(contextRoot, "Dockerfile"),
|
|
4018
|
+
dockerfile
|
|
4019
|
+
);
|
|
4020
|
+
return { rootDir: contextRoot, cleanupDir, noGitignore: true };
|
|
4021
|
+
}
|
|
4022
|
+
async function deployRailwayServiceInstanceWithSourceRepair(entry, env) {
|
|
4023
|
+
if (!entry.environment || !entry.service) {
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
4026
|
+
const deploy = () => deployRailwayServiceInstance({
|
|
4027
|
+
serviceId: entry.service.id,
|
|
4028
|
+
environmentId: entry.environment.id,
|
|
4029
|
+
env
|
|
4030
|
+
});
|
|
4031
|
+
if (entry.configuredService.sourceMode === "git" && entry.configuredService.sourceRepo && !entry.configuredService.imageRef) {
|
|
4032
|
+
await repairRailwayServiceSource(entry, env);
|
|
4033
|
+
}
|
|
4034
|
+
try {
|
|
4035
|
+
await deploy();
|
|
4036
|
+
return;
|
|
4037
|
+
} catch (error) {
|
|
4038
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
4039
|
+
if (/Problem processing request/iu.test(message)) {
|
|
4040
|
+
traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
|
|
4041
|
+
return;
|
|
4042
|
+
}
|
|
4043
|
+
if (!/Deployment not found/iu.test(message)) {
|
|
4044
|
+
throw error;
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
const repaired = await repairRailwayServiceSource(entry, env);
|
|
4048
|
+
if (repaired) {
|
|
4049
|
+
try {
|
|
4050
|
+
await deploy();
|
|
4051
|
+
return;
|
|
4052
|
+
} catch (error) {
|
|
4053
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
4054
|
+
if (/Problem processing request/iu.test(message)) {
|
|
4055
|
+
traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
|
|
4056
|
+
return;
|
|
4057
|
+
}
|
|
4058
|
+
if (!/Deployment not found/iu.test(message)) {
|
|
4059
|
+
throw error;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
throw new Error(
|
|
4064
|
+
`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.`
|
|
4065
|
+
);
|
|
4066
|
+
}
|
|
4067
|
+
async function repairRailwayServiceSource(entry, env) {
|
|
4068
|
+
if (!entry.service) {
|
|
4069
|
+
return false;
|
|
4070
|
+
}
|
|
4071
|
+
const sourceRepo = String(entry.configuredService.sourceRepo ?? "").trim();
|
|
4072
|
+
if (sourceRepo) {
|
|
4073
|
+
await updateRailwayServiceGitSource({
|
|
4074
|
+
serviceId: entry.service.id,
|
|
4075
|
+
sourceRepo,
|
|
4076
|
+
sourceBranch: entry.configuredService.sourceBranch,
|
|
4077
|
+
env
|
|
4078
|
+
});
|
|
4079
|
+
return true;
|
|
4080
|
+
}
|
|
4081
|
+
const imageRef = String(entry.configuredService.imageRef ?? "").trim();
|
|
4082
|
+
if (imageRef) {
|
|
4083
|
+
await updateRailwayServiceImageSource({
|
|
4084
|
+
serviceId: entry.service.id,
|
|
4085
|
+
imageRef,
|
|
4086
|
+
env
|
|
4087
|
+
});
|
|
4088
|
+
return true;
|
|
4089
|
+
}
|
|
4090
|
+
return false;
|
|
4091
|
+
}
|
|
4092
|
+
function looksLikeRailwayExistingSourceRepairUnsupported(error) {
|
|
4093
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
4094
|
+
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);
|
|
1818
4095
|
}
|
|
1819
4096
|
async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
1820
|
-
const
|
|
1821
|
-
|
|
4097
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
4098
|
+
const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
|
|
4099
|
+
const treeseedDatabaseService = treeseedDatabase?.service;
|
|
4100
|
+
if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
|
|
1822
4101
|
return;
|
|
1823
4102
|
}
|
|
1824
|
-
const
|
|
4103
|
+
const serviceName = treeseedDatabase.serviceName;
|
|
4104
|
+
const firstService = [...topology.services.values()].find(
|
|
4105
|
+
(entry) => entry.project && entry.environment && ["api", "operationsRunner"].includes(entry.configuredService.key)
|
|
4106
|
+
);
|
|
1825
4107
|
if (!firstService?.project || !firstService.environment) {
|
|
4108
|
+
await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, null);
|
|
1826
4109
|
return;
|
|
1827
4110
|
}
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
4111
|
+
await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, firstService.project.id);
|
|
4112
|
+
let postgresService;
|
|
4113
|
+
try {
|
|
4114
|
+
postgresService = (await ensureRailwayPostgresService({
|
|
4115
|
+
projectId: firstService.project.id,
|
|
4116
|
+
environmentId: firstService.environment.id,
|
|
4117
|
+
serviceName,
|
|
4118
|
+
env: topology.env
|
|
4119
|
+
})).service;
|
|
4120
|
+
} catch (error) {
|
|
4121
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
4122
|
+
throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. ${detail}`);
|
|
4123
|
+
}
|
|
1831
4124
|
if (!postgresService) {
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
4125
|
+
throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. No service was returned.`);
|
|
4126
|
+
}
|
|
4127
|
+
try {
|
|
4128
|
+
const postgresVolume = await ensureRailwayServiceVolume({
|
|
4129
|
+
projectId: firstService.project.id,
|
|
4130
|
+
environmentId: firstService.environment.id,
|
|
4131
|
+
serviceId: postgresService.id,
|
|
4132
|
+
name: `${serviceName}-volume`,
|
|
4133
|
+
mountPath: "/var/lib/postgresql/data",
|
|
1837
4134
|
env: topology.env
|
|
1838
4135
|
});
|
|
1839
|
-
if (
|
|
1840
|
-
throw new Error(
|
|
4136
|
+
if (postgresVolume.instance?.serviceId !== postgresService.id || postgresVolume.instance?.environmentId !== firstService.environment.id || postgresVolume.instance?.mountPath !== "/var/lib/postgresql/data") {
|
|
4137
|
+
throw new Error(`observed volume ${postgresVolume.volume.name ?? postgresVolume.volume.id ?? "(unknown)"} is not attached to ${serviceName} at /var/lib/postgresql/data`);
|
|
1841
4138
|
}
|
|
1842
|
-
|
|
1843
|
-
const
|
|
1844
|
-
|
|
1845
|
-
|
|
4139
|
+
} catch (error) {
|
|
4140
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
4141
|
+
throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name for ${serviceName}: ${detail}`);
|
|
4142
|
+
}
|
|
4143
|
+
const services = await listRailwayServices({ projectId: firstService.project.id, env: topology.env });
|
|
4144
|
+
for (const service of services) {
|
|
4145
|
+
if (service.id === postgresService.id || service.name === serviceName) {
|
|
4146
|
+
continue;
|
|
1846
4147
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
4148
|
+
const variables = await listRailwayVariables({
|
|
4149
|
+
projectId: firstService.project.id,
|
|
4150
|
+
environmentId: firstService.environment.id,
|
|
4151
|
+
serviceId: service.id,
|
|
1850
4152
|
env: topology.env
|
|
1851
|
-
});
|
|
4153
|
+
}).catch(() => ({}));
|
|
4154
|
+
const looksLikePostgres = typeof variables.DATABASE_URL === "string" && typeof variables.PGHOST === "string" && typeof variables.PGUSER === "string" && typeof variables.PGPASSWORD === "string";
|
|
4155
|
+
if (looksLikePostgres) {
|
|
4156
|
+
traceRailwayReconcile(topology.env, "sync:postgres:block-duplicate", `${service.name}:${service.id}`);
|
|
4157
|
+
recordRailwayProviderDrift(input, scope, {
|
|
4158
|
+
kind: "railway.duplicate-postgres-service",
|
|
4159
|
+
action: "manual-repair-required",
|
|
4160
|
+
status: "blocked",
|
|
4161
|
+
projectId: firstService.project.id,
|
|
4162
|
+
environmentId: firstService.environment.id,
|
|
4163
|
+
serviceId: service.id,
|
|
4164
|
+
serviceName: service.name,
|
|
4165
|
+
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.`
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
const detachedPostgresVolumes = await listRailwayVolumes({
|
|
4170
|
+
projectId: firstService.project.id,
|
|
4171
|
+
env: topology.env
|
|
4172
|
+
});
|
|
4173
|
+
for (const volume of detachedPostgresVolumes) {
|
|
4174
|
+
if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
|
|
4175
|
+
traceRailwayReconcile(topology.env, "sync:postgres:retain-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
|
|
4176
|
+
continue;
|
|
4177
|
+
}
|
|
4178
|
+
const postgresInstances = volume.instances.filter(
|
|
4179
|
+
(instance) => instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
|
|
4180
|
+
);
|
|
4181
|
+
const activePostgresInstances = postgresInstances.filter((instance) => Boolean(instance.serviceId));
|
|
4182
|
+
const attachedToCanonical = postgresInstances.some((instance) => instance.serviceId === postgresService.id);
|
|
4183
|
+
const detached = postgresInstances.length > 0 && postgresInstances.every((instance) => !instance.serviceId);
|
|
4184
|
+
const detachedCanonicalVolume = volume.name === `${serviceName}-volume` && activePostgresInstances.length === 0;
|
|
4185
|
+
if ((detached || detachedCanonicalVolume) && !attachedToCanonical) {
|
|
4186
|
+
traceRailwayReconcile(topology.env, "sync:postgres:block-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
|
|
4187
|
+
recordRailwayProviderDrift(input, scope, {
|
|
4188
|
+
kind: "railway.detached-postgres-volume",
|
|
4189
|
+
action: "manual-repair-required",
|
|
4190
|
+
status: "blocked",
|
|
4191
|
+
projectId: firstService.project.id,
|
|
4192
|
+
environmentId: firstService.environment.id,
|
|
4193
|
+
volumeId: volume.id,
|
|
4194
|
+
volumeName: volume.name ?? null,
|
|
4195
|
+
reason: "Railway reports a detached PostgreSQL volume. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow."
|
|
4196
|
+
});
|
|
4197
|
+
}
|
|
1852
4198
|
}
|
|
1853
4199
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
1854
4200
|
const existingVolumes = await listRailwayVolumes({
|
|
@@ -1864,6 +4210,61 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
|
1864
4210
|
await new Promise((resolve2) => setTimeout(resolve2, 3e3));
|
|
1865
4211
|
}
|
|
1866
4212
|
}
|
|
4213
|
+
async function reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, ownerProjectId) {
|
|
4214
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
4215
|
+
const projectEntries = [...topology.services.values()].filter((entry) => entry.project?.id && entry.environment?.id).map((entry) => ({ project: entry.project, environment: entry.environment }));
|
|
4216
|
+
const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
|
|
4217
|
+
for (const { project, environment } of projects.values()) {
|
|
4218
|
+
if (ownerProjectId && project.id === ownerProjectId) {
|
|
4219
|
+
continue;
|
|
4220
|
+
}
|
|
4221
|
+
const services = await listRailwayServices({ projectId: project.id, env: topology.env }).catch(() => []);
|
|
4222
|
+
for (const service of services.filter((entry) => entry.name === serviceName)) {
|
|
4223
|
+
traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-service", `${project.name}:${service.name}:${service.id}`);
|
|
4224
|
+
recordRailwayProviderDrift(input, scope, {
|
|
4225
|
+
kind: "railway.accidental-market-database-service",
|
|
4226
|
+
action: "manual-repair-required",
|
|
4227
|
+
status: "blocked",
|
|
4228
|
+
projectId: project.id,
|
|
4229
|
+
environmentId: environment.id,
|
|
4230
|
+
serviceId: service.id,
|
|
4231
|
+
serviceName: service.name,
|
|
4232
|
+
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.`
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
const volumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
|
|
4236
|
+
for (const volume of volumes) {
|
|
4237
|
+
if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
|
|
4238
|
+
continue;
|
|
4239
|
+
}
|
|
4240
|
+
const postgresInstances = volume.instances.filter(
|
|
4241
|
+
(instance) => instance.environmentId === environment.id && instance.mountPath === "/var/lib/postgresql/data"
|
|
4242
|
+
);
|
|
4243
|
+
if (postgresInstances.length === 0) {
|
|
4244
|
+
continue;
|
|
4245
|
+
}
|
|
4246
|
+
const activeServiceIds = new Set(services.map((service) => service.id));
|
|
4247
|
+
const stillAttached = postgresInstances.some((instance) => instance.serviceId && activeServiceIds.has(instance.serviceId));
|
|
4248
|
+
if (stillAttached) {
|
|
4249
|
+
continue;
|
|
4250
|
+
}
|
|
4251
|
+
traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-volume", `${project.name}:${volume.name ?? volume.id}:${volume.id}`);
|
|
4252
|
+
recordRailwayProviderDrift(input, scope, {
|
|
4253
|
+
kind: "railway.accidental-market-database-volume",
|
|
4254
|
+
action: "manual-repair-required",
|
|
4255
|
+
status: "blocked",
|
|
4256
|
+
projectId: project.id,
|
|
4257
|
+
environmentId: environment.id,
|
|
4258
|
+
volumeId: volume.id,
|
|
4259
|
+
volumeName: volume.name ?? null,
|
|
4260
|
+
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.`
|
|
4261
|
+
});
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
function isRetainedRailwayDetachedPostgresVolume(value) {
|
|
4266
|
+
return String(value ?? "").trim().startsWith("retained-");
|
|
4267
|
+
}
|
|
1867
4268
|
async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
1868
4269
|
let attempt = 0;
|
|
1869
4270
|
for (; ; ) {
|
|
@@ -1879,15 +4280,19 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
|
1879
4280
|
includeVariables: false
|
|
1880
4281
|
});
|
|
1881
4282
|
const entry = topology.services.get(serviceKey) ?? null;
|
|
4283
|
+
const configuredService = entry?.configuredService ?? null;
|
|
4284
|
+
const usesPortableSource = Boolean(
|
|
4285
|
+
configuredService?.imageRef || configuredService?.sourceMode === "git" && configuredService?.sourceRepo
|
|
4286
|
+
);
|
|
1882
4287
|
const configured = Boolean(
|
|
1883
|
-
|
|
4288
|
+
configuredService && (configuredService.serviceName || configuredService.serviceId) && (configuredService.projectName || configuredService.projectId) && (usesPortableSource || existsSync(resolve(configuredService.rootDir)))
|
|
1884
4289
|
);
|
|
1885
4290
|
return {
|
|
1886
|
-
exists: Boolean(entry?.
|
|
4291
|
+
exists: Boolean(entry?.project && entry?.environment && entry?.service),
|
|
1887
4292
|
status: entry?.project && entry?.environment && entry?.service && configured ? "ready" : "pending",
|
|
1888
4293
|
live: {
|
|
1889
4294
|
...persisted ?? {},
|
|
1890
|
-
...
|
|
4295
|
+
...configuredService ?? {},
|
|
1891
4296
|
project: entry?.project ?? null,
|
|
1892
4297
|
environment: entry?.environment ?? null,
|
|
1893
4298
|
service: entry?.service ?? null,
|
|
@@ -1911,32 +4316,48 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
|
1911
4316
|
}
|
|
1912
4317
|
}
|
|
1913
4318
|
}
|
|
1914
|
-
function collectRailwayEnvironmentSync(input) {
|
|
4319
|
+
function collectRailwayEnvironmentSync(input, valuesOverlay = {}) {
|
|
1915
4320
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1916
|
-
const values = resolveReconcileEnvironmentValues(input, scope);
|
|
1917
4321
|
const registry = collectTreeseedEnvironmentContext(input.context.tenantRoot);
|
|
4322
|
+
const configuredServices = configuredRailwayServices(input.context.tenantRoot, scope);
|
|
4323
|
+
const hasPublicTreeDxService = configuredServices.some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
|
|
4324
|
+
const machineValues = hasPublicTreeDxService ? normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope)) : {};
|
|
4325
|
+
const baseValues = {
|
|
4326
|
+
...resolveReconcileEnvironmentValues(input, scope),
|
|
4327
|
+
...machineValues,
|
|
4328
|
+
...valuesOverlay
|
|
4329
|
+
};
|
|
4330
|
+
const values = {
|
|
4331
|
+
...baseValues,
|
|
4332
|
+
...ensurePublicTreeDxSecretsForRailwaySync(input, scope, baseValues, registry)
|
|
4333
|
+
};
|
|
4334
|
+
if (scope !== "local") {
|
|
4335
|
+
sanitizeRemoteRailwayServiceUrls(input, scope, values);
|
|
4336
|
+
}
|
|
1918
4337
|
const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
1919
4338
|
const serviceTargetsEntry = (entry, serviceKey) => {
|
|
1920
4339
|
const targets = Array.isArray(entry.serviceTargets) ? entry.serviceTargets.map((value) => String(value).trim()).filter(Boolean) : [];
|
|
1921
4340
|
return targets.length === 0 || targets.includes(serviceKey);
|
|
1922
4341
|
};
|
|
1923
|
-
const entriesForService = (target, serviceKey) => registry.entries.filter(
|
|
1924
|
-
|
|
4342
|
+
const entriesForService = (target, serviceKey) => registry.entries.filter(
|
|
4343
|
+
(entry) => entry.scopes.includes(scope) && entry.targets.includes(target) && serviceTargetsEntry(entry, serviceKey)
|
|
4344
|
+
).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0);
|
|
4345
|
+
const aggregateEntries = (target) => registry.entries.filter((entry) => entry.scopes.includes(scope) && entry.targets.includes(target)).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0);
|
|
1925
4346
|
const secrets = Object.fromEntries(aggregateEntries("railway-secret"));
|
|
1926
4347
|
const variables = Object.fromEntries(aggregateEntries("railway-var"));
|
|
1927
4348
|
const apiOnlySecrets = {};
|
|
1928
4349
|
const apiOnlyVariables = {};
|
|
1929
|
-
const
|
|
1930
|
-
const
|
|
1931
|
-
const
|
|
1932
|
-
const
|
|
1933
|
-
if (typeof values.
|
|
1934
|
-
secrets.
|
|
1935
|
-
apiOnlySecrets.
|
|
1936
|
-
}
|
|
1937
|
-
if (typeof values.
|
|
1938
|
-
variables.
|
|
1939
|
-
apiOnlyVariables.
|
|
4350
|
+
const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
|
|
4351
|
+
const treeseedDatabaseService = treeseedDatabase?.service;
|
|
4352
|
+
const treeseedDatabaseServiceName = treeseedDatabase?.serviceName ?? "";
|
|
4353
|
+
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}}` : "";
|
|
4354
|
+
if (typeof values.TREESEED_CLOUDFLARE_API_TOKEN === "string" && values.TREESEED_CLOUDFLARE_API_TOKEN.length > 0 && shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, "TREESEED_CLOUDFLARE_API_TOKEN")) {
|
|
4355
|
+
secrets.TREESEED_CLOUDFLARE_API_TOKEN = values.TREESEED_CLOUDFLARE_API_TOKEN;
|
|
4356
|
+
apiOnlySecrets.TREESEED_CLOUDFLARE_API_TOKEN = values.TREESEED_CLOUDFLARE_API_TOKEN;
|
|
4357
|
+
}
|
|
4358
|
+
if (typeof values.TREESEED_CLOUDFLARE_ACCOUNT_ID === "string" && values.TREESEED_CLOUDFLARE_ACCOUNT_ID.length > 0) {
|
|
4359
|
+
variables.TREESEED_CLOUDFLARE_ACCOUNT_ID = values.TREESEED_CLOUDFLARE_ACCOUNT_ID;
|
|
4360
|
+
apiOnlyVariables.TREESEED_CLOUDFLARE_ACCOUNT_ID = values.TREESEED_CLOUDFLARE_ACCOUNT_ID;
|
|
1940
4361
|
}
|
|
1941
4362
|
const siteDataDb = state.d1Databases?.SITE_DATA_DB;
|
|
1942
4363
|
let apiD1DatabaseId = siteDataDb?.databaseId;
|
|
@@ -1948,28 +4369,183 @@ function collectRailwayEnvironmentSync(input) {
|
|
|
1948
4369
|
variables.TREESEED_API_D1_DATABASE_ID = apiD1DatabaseId;
|
|
1949
4370
|
apiOnlyVariables.TREESEED_API_D1_DATABASE_ID = apiD1DatabaseId;
|
|
1950
4371
|
}
|
|
1951
|
-
if (
|
|
1952
|
-
secrets.
|
|
4372
|
+
if (treeseedDatabaseUrl) {
|
|
4373
|
+
secrets.TREESEED_DATABASE_URL = treeseedDatabaseUrl;
|
|
1953
4374
|
}
|
|
1954
4375
|
return {
|
|
1955
4376
|
scope,
|
|
1956
4377
|
secrets,
|
|
1957
4378
|
variables,
|
|
1958
|
-
forService(serviceKey) {
|
|
4379
|
+
forService(serviceKey, configuredService) {
|
|
4380
|
+
const capacityVariables = capacityProviderVariablesForService(input, scope, values, serviceKey, configuredService);
|
|
4381
|
+
const capacitySecrets = capacityProviderSecretsForService(values, serviceKey);
|
|
4382
|
+
const serviceVariables = configuredService?.environmentVariables && typeof configuredService.environmentVariables === "object" ? Object.fromEntries(Object.entries(configuredService.environmentVariables).filter(([, value]) => typeof value === "string" && value.length > 0)) : {};
|
|
4383
|
+
const serviceSecretRefs = Array.isArray(configuredService?.secretRefs) ? configuredService.secretRefs.map((value) => String(value).trim()).filter(Boolean) : [];
|
|
4384
|
+
const serviceVariableRefs = Array.isArray(configuredService?.variableRefs) ? configuredService.variableRefs.map((value) => String(value).trim()).filter(Boolean) : [];
|
|
4385
|
+
const serviceValue = (key) => {
|
|
4386
|
+
const direct = values[key];
|
|
4387
|
+
if (typeof direct === "string" && direct.length > 0) return direct;
|
|
4388
|
+
if (key.startsWith("TREEDX_")) {
|
|
4389
|
+
const legacy = values[`TREESEED_${key}`];
|
|
4390
|
+
if (typeof legacy === "string" && legacy.length > 0) return legacy;
|
|
4391
|
+
}
|
|
4392
|
+
return void 0;
|
|
4393
|
+
};
|
|
4394
|
+
const serviceRefSecrets = Object.fromEntries(serviceSecretRefs.map((key) => [key, serviceValue(key)]).filter(([, value]) => typeof value === "string" && value.length > 0));
|
|
4395
|
+
const serviceRefVariables = Object.fromEntries(serviceVariableRefs.map((key) => [key, serviceValue(key)]).filter(([, value]) => typeof value === "string" && value.length > 0));
|
|
1959
4396
|
return {
|
|
1960
4397
|
secrets: {
|
|
1961
4398
|
...Object.fromEntries(entriesForService("railway-secret", serviceKey)),
|
|
1962
|
-
...
|
|
1963
|
-
...
|
|
4399
|
+
...serviceRefSecrets,
|
|
4400
|
+
...treeseedDatabaseUrl && ["api", "operationsRunner"].includes(serviceKey) ? { TREESEED_DATABASE_URL: treeseedDatabaseUrl } : {},
|
|
4401
|
+
...serviceKey === "api" ? apiOnlySecrets : {},
|
|
4402
|
+
...capacitySecrets
|
|
1964
4403
|
},
|
|
1965
4404
|
variables: {
|
|
4405
|
+
...serviceVariables,
|
|
4406
|
+
...serviceRefVariables,
|
|
1966
4407
|
...Object.fromEntries(entriesForService("railway-var", serviceKey)),
|
|
1967
|
-
...serviceKey === "
|
|
4408
|
+
...serviceKey === "operationsRunner" ? { TREESEED_MANAGER_ID: scope } : {},
|
|
4409
|
+
...serviceKey === "api" ? apiOnlyVariables : {},
|
|
4410
|
+
...capacityVariables
|
|
1968
4411
|
}
|
|
1969
4412
|
};
|
|
1970
4413
|
}
|
|
1971
4414
|
};
|
|
1972
4415
|
}
|
|
4416
|
+
function isLoopbackServiceUrl(value) {
|
|
4417
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
4418
|
+
try {
|
|
4419
|
+
const url = new URL(value);
|
|
4420
|
+
return url.hostname === "127.0.0.1" || url.hostname === "localhost";
|
|
4421
|
+
} catch {
|
|
4422
|
+
return false;
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
function sanitizeRemoteRailwayServiceUrls(input, scope, values) {
|
|
4426
|
+
const configuredApiDomain = resolveConfiguredSurfaceDomain(input.context.deployConfig, { kind: "persistent", scope }, "api");
|
|
4427
|
+
const configuredApiBaseUrl = configuredApiDomain ? `https://${configuredApiDomain}` : "";
|
|
4428
|
+
const apiBaseUrl = [
|
|
4429
|
+
values.TREESEED_MARKET_API_BASE_URL,
|
|
4430
|
+
values.TREESEED_STAGING_MARKET_API_BASE_URL,
|
|
4431
|
+
configuredApiBaseUrl
|
|
4432
|
+
].find((value) => typeof value === "string" && value.trim() && !isLoopbackServiceUrl(value))?.trim();
|
|
4433
|
+
if (!apiBaseUrl) return;
|
|
4434
|
+
if (isLoopbackServiceUrl(values.TREESEED_API_BASE_URL)) {
|
|
4435
|
+
values.TREESEED_API_BASE_URL = apiBaseUrl;
|
|
4436
|
+
}
|
|
4437
|
+
for (const key of ["TREESEED_CENTRAL_MARKET_API_BASE_URL", "TREESEED_CATALOG_MARKET_API_BASE_URLS"]) {
|
|
4438
|
+
if (isLoopbackServiceUrl(values[key])) {
|
|
4439
|
+
values[key] = apiBaseUrl;
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
function capacityProviderRoleForService(serviceKey) {
|
|
4444
|
+
if (serviceKey === "capacityProviderManager") return "manager";
|
|
4445
|
+
if (serviceKey === "capacityProviderRunner") return "runner";
|
|
4446
|
+
return null;
|
|
4447
|
+
}
|
|
4448
|
+
function capacityProviderSecretsForService(values, serviceKey) {
|
|
4449
|
+
if (!capacityProviderRoleForService(serviceKey)) return {};
|
|
4450
|
+
const apiKey = String(values.TREESEED_CAPACITY_PROVIDER_API_KEY ?? "").trim();
|
|
4451
|
+
return apiKey ? { TREESEED_CAPACITY_PROVIDER_API_KEY: apiKey } : {};
|
|
4452
|
+
}
|
|
4453
|
+
function ensureCapacityProviderApiKeyForRailwaySync(input, scope) {
|
|
4454
|
+
const hasCapacityProviderService = configuredRailwayServices(input.context.tenantRoot, scope).some((service) => String(service.key).startsWith("capacityProvider") && service.enabled !== false);
|
|
4455
|
+
if (!hasCapacityProviderService) return null;
|
|
4456
|
+
const values = resolveReconcileEnvironmentValues(input, scope);
|
|
4457
|
+
const existing = String(values.TREESEED_CAPACITY_PROVIDER_API_KEY ?? "").trim();
|
|
4458
|
+
if (existing) return existing;
|
|
4459
|
+
const registry = collectTreeseedEnvironmentContext(input.context.tenantRoot);
|
|
4460
|
+
const entry = registry.entries.find((candidate) => candidate.id === "TREESEED_CAPACITY_PROVIDER_API_KEY") ?? {
|
|
4461
|
+
id: "TREESEED_CAPACITY_PROVIDER_API_KEY",
|
|
4462
|
+
label: "Capacity provider API key",
|
|
4463
|
+
group: "capacity-provider",
|
|
4464
|
+
description: "Provider-scoped bearer key used by hosted capacity provider services.",
|
|
4465
|
+
howToGet: "Generated by Railway reconciliation when capacity provider services are enabled.",
|
|
4466
|
+
sensitivity: "secret",
|
|
4467
|
+
targets: ["railway-secret"],
|
|
4468
|
+
scopes: ["staging", "prod"],
|
|
4469
|
+
storage: "scoped",
|
|
4470
|
+
requirement: "optional",
|
|
4471
|
+
purposes: ["deploy", "config"]
|
|
4472
|
+
};
|
|
4473
|
+
const generated = `tscp_${randomBytes(32).toString("base64url")}`;
|
|
4474
|
+
setTreeseedMachineEnvironmentValue(input.context.tenantRoot, scope, entry, generated);
|
|
4475
|
+
return generated;
|
|
4476
|
+
}
|
|
4477
|
+
function ensurePublicTreeDxSecretsForRailwaySync(input, scope, values, registry) {
|
|
4478
|
+
const hasPublicTreeDxService = configuredRailwayServices(input.context.tenantRoot, scope).some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
|
|
4479
|
+
if (!hasPublicTreeDxService) return {};
|
|
4480
|
+
const generated = {};
|
|
4481
|
+
const specs = [
|
|
4482
|
+
["TREESEED_TREEDX_SECRET_KEY_BASE", () => randomBytes(64).toString("hex")],
|
|
4483
|
+
["TREESEED_TREEDX_ADMIN_TOKEN", () => `tdx_admin_${randomBytes(32).toString("base64url")}`],
|
|
4484
|
+
["TREESEED_TREEDX_JWT_HS256_SECRET", () => randomBytes(48).toString("base64url")]
|
|
4485
|
+
];
|
|
4486
|
+
for (const [key, create] of specs) {
|
|
4487
|
+
const existing = String(values[key] ?? "").trim();
|
|
4488
|
+
if (existing) continue;
|
|
4489
|
+
const entry = registry.entries.find((candidate) => candidate.id === key);
|
|
4490
|
+
if (!entry) continue;
|
|
4491
|
+
const value = create();
|
|
4492
|
+
setTreeseedMachineEnvironmentValue(input.context.tenantRoot, scope, entry, value);
|
|
4493
|
+
generated[key] = value;
|
|
4494
|
+
}
|
|
4495
|
+
return generated;
|
|
4496
|
+
}
|
|
4497
|
+
function capacityProviderVariablesForService(input, scope, values, serviceKey, configuredService) {
|
|
4498
|
+
const role = capacityProviderRoleForService(serviceKey);
|
|
4499
|
+
if (!role) return {};
|
|
4500
|
+
const variables = {
|
|
4501
|
+
TREESEED_PROVIDER_ENVIRONMENT: scope === "prod" ? "production" : scope,
|
|
4502
|
+
TREESEED_PROVIDER_ROLE: role
|
|
4503
|
+
};
|
|
4504
|
+
const marketUrl = resolveCapacityProviderMarketUrl(input, scope, values);
|
|
4505
|
+
if (marketUrl) {
|
|
4506
|
+
variables.TREESEED_MARKET_URL = marketUrl;
|
|
4507
|
+
variables.TREESEED_API_BASE_URL = marketUrl;
|
|
4508
|
+
}
|
|
4509
|
+
if (role === "runner") {
|
|
4510
|
+
variables.TREESEED_PROVIDER_RUNNER_ID = String(configuredService?.runnerId ?? configuredService?.serviceName ?? "treeseed-agent-runner-01");
|
|
4511
|
+
variables.TREESEED_PROVIDER_DATA_DIR = String(configuredService?.volumeMountPath ?? "/data");
|
|
4512
|
+
}
|
|
4513
|
+
return variables;
|
|
4514
|
+
}
|
|
4515
|
+
function resolveCapacityProviderMarketUrl(input, scope, values) {
|
|
4516
|
+
const hostedApiBaseUrl = resolveHostedApiBaseUrl(input, scope);
|
|
4517
|
+
if (hostedApiBaseUrl) return hostedApiBaseUrl;
|
|
4518
|
+
for (const key of ["TREESEED_MARKET_URL", "TREESEED_MARKET_API_BASE_URL", "TREESEED_STAGING_MARKET_API_BASE_URL", "TREESEED_API_BASE_URL", "TREESEED_CENTRAL_MARKET_API_BASE_URL", "TREESEED_PUBLIC_MARKET_URL", "TREESEED_SITE_URL"]) {
|
|
4519
|
+
const value = String(values[key] ?? "").trim();
|
|
4520
|
+
if (/^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/iu.test(value)) continue;
|
|
4521
|
+
if (value) return value.replace(/\/+$/u, "");
|
|
4522
|
+
}
|
|
4523
|
+
const applications = discoverTreeseedApplications(input.context.tenantRoot);
|
|
4524
|
+
const webApplication = applications.find((application) => application.roles.includes("web")) ?? applications.find((application) => application.root === resolve(input.context.tenantRoot));
|
|
4525
|
+
const web = webApplication?.config?.surfaces?.web;
|
|
4526
|
+
const environment = scope === "prod" ? "prod" : scope;
|
|
4527
|
+
const domain = String(web?.environments?.[environment]?.domain ?? web?.publicBaseUrl ?? "").trim();
|
|
4528
|
+
if (!domain) return "";
|
|
4529
|
+
if (/^https?:\/\//u.test(domain)) return domain.replace(/\/+$/u, "");
|
|
4530
|
+
return `https://${domain.replace(/\/+$/u, "")}`;
|
|
4531
|
+
}
|
|
4532
|
+
function resolveHostedApiBaseUrl(input, scope) {
|
|
4533
|
+
const environment = scope === "prod" ? "prod" : scope;
|
|
4534
|
+
const rootConnections = input.context.deployConfig.connections;
|
|
4535
|
+
const configuredConnectionUrl = String(rootConnections?.api?.environments?.[environment]?.baseUrl ?? "").trim();
|
|
4536
|
+
if (configuredConnectionUrl) return configuredConnectionUrl.replace(/\/+$/u, "");
|
|
4537
|
+
const applications = discoverTreeseedApplications(input.context.tenantRoot);
|
|
4538
|
+
for (const application of applications) {
|
|
4539
|
+
const apiSurface = application.config?.surfaces?.api;
|
|
4540
|
+
const configuredBaseUrl = String(apiSurface?.environments?.[environment]?.baseUrl ?? "").trim();
|
|
4541
|
+
if (configuredBaseUrl) return configuredBaseUrl.replace(/\/+$/u, "");
|
|
4542
|
+
const configuredDomain = String(apiSurface?.environments?.[environment]?.domain ?? "").trim();
|
|
4543
|
+
if (configuredDomain) {
|
|
4544
|
+
return /^https?:\/\//u.test(configuredDomain) ? configuredDomain.replace(/\/+$/u, "") : `https://${configuredDomain.replace(/\/+$/u, "")}`;
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
return "";
|
|
4548
|
+
}
|
|
1973
4549
|
function buildAttachmentDiff(input, observed) {
|
|
1974
4550
|
if (!observed.exists) {
|
|
1975
4551
|
return {
|
|
@@ -1980,7 +4556,7 @@ function buildAttachmentDiff(input, observed) {
|
|
|
1980
4556
|
};
|
|
1981
4557
|
}
|
|
1982
4558
|
return {
|
|
1983
|
-
action: observed.status === "ready" ? "
|
|
4559
|
+
action: observed.status === "ready" ? "noop" : "update",
|
|
1984
4560
|
reasons: observed.status === "ready" ? ["attachment already present"] : ["attachment requires update"],
|
|
1985
4561
|
before: observed.live,
|
|
1986
4562
|
after: input.unit.spec
|
|
@@ -2114,7 +4690,13 @@ function verifyCustomDomainUnit(input) {
|
|
|
2114
4690
|
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
2115
4691
|
const live = getCustomDomainState(input, "railway", domain) ?? getPersistedCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
|
|
2116
4692
|
const dnsRecords = Array.isArray(live?.dnsRecords) ? live.dnsRecords : [];
|
|
4693
|
+
const certificateStatus = typeof live?.certificateStatus === "string" ? live.certificateStatus.trim().toUpperCase() : null;
|
|
4694
|
+
const verified = live?.verified === true;
|
|
2117
4695
|
const hasDnsRequirements = dnsRecords.length > 0 || typeof live?.serviceDomain === "string" && live.serviceDomain.trim().length > 0 || typeof live?.verificationDnsHost === "string" && live.verificationDnsHost.trim().length > 0 && typeof live?.verificationToken === "string" && live.verificationToken.trim().length > 0;
|
|
4696
|
+
const certificateFailed = certificateStatus ? /FAILED|ERROR|MISSING/u.test(certificateStatus) : false;
|
|
4697
|
+
const certificatePending = certificateStatus ? /VALIDATING|ISSUING|GENERATING|PENDING/u.test(certificateStatus) : false;
|
|
4698
|
+
const certificateReady = verified && !certificateFailed && !certificatePending;
|
|
4699
|
+
const certificateAcceptable = certificateReady || hasDnsRequirements && certificatePending;
|
|
2118
4700
|
return summarizeVerification(input.unit.unitId, [
|
|
2119
4701
|
verificationCheck("custom-domain.exists", "Railway custom domain attachment exists", "cli", {
|
|
2120
4702
|
exists: Boolean(live?.domain),
|
|
@@ -2130,6 +4712,21 @@ function verifyCustomDomainUnit(input) {
|
|
|
2130
4712
|
verificationDnsHost: typeof live?.verificationDnsHost === "string" ? live.verificationDnsHost : null
|
|
2131
4713
|
},
|
|
2132
4714
|
issues: hasDnsRequirements ? [] : [`Railway custom domain ${domain || "(unset)"} did not expose DNS requirements.`]
|
|
4715
|
+
}),
|
|
4716
|
+
verificationCheck("custom-domain.certificate", "Railway custom domain certificate is issued or pending provider validation", "api", {
|
|
4717
|
+
exists: Boolean(live?.domain),
|
|
4718
|
+
ready: certificateAcceptable,
|
|
4719
|
+
verified: certificateAcceptable,
|
|
4720
|
+
expected: {
|
|
4721
|
+
verified: true,
|
|
4722
|
+
certificateStatus: "issued or provider validation pending with DNS requirements"
|
|
4723
|
+
},
|
|
4724
|
+
observed: {
|
|
4725
|
+
verified,
|
|
4726
|
+
certificateStatus,
|
|
4727
|
+
hasDnsRequirements
|
|
4728
|
+
},
|
|
4729
|
+
issues: certificateAcceptable ? [] : [`Railway custom domain ${domain || "(unset)"} certificate is not ready (verified=${verified}, status=${certificateStatus ?? "(unknown)"}).`]
|
|
2133
4730
|
})
|
|
2134
4731
|
]);
|
|
2135
4732
|
}
|
|
@@ -2186,7 +4783,7 @@ async function reconcileCustomDomainUnit(input, diff) {
|
|
|
2186
4783
|
unit: input.unit,
|
|
2187
4784
|
observed,
|
|
2188
4785
|
diff,
|
|
2189
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
4786
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
2190
4787
|
warnings: observed.warnings,
|
|
2191
4788
|
resourceLocators: observed.locators,
|
|
2192
4789
|
state,
|
|
@@ -2223,7 +4820,7 @@ async function reconcileCustomDomainUnit(input, diff) {
|
|
|
2223
4820
|
unit: input.unit,
|
|
2224
4821
|
observed,
|
|
2225
4822
|
diff,
|
|
2226
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
4823
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
2227
4824
|
warnings: observed.warnings,
|
|
2228
4825
|
resourceLocators: observed.locators,
|
|
2229
4826
|
state,
|
|
@@ -2251,7 +4848,7 @@ function reconcileDnsRecordUnit(input, diff) {
|
|
|
2251
4848
|
unit: input.unit,
|
|
2252
4849
|
observed,
|
|
2253
4850
|
diff,
|
|
2254
|
-
action: diff.action === "create" || diff.action === "update" ? "
|
|
4851
|
+
action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
|
|
2255
4852
|
warnings: observed.warnings,
|
|
2256
4853
|
resourceLocators: observed.locators,
|
|
2257
4854
|
state: {
|
|
@@ -2291,7 +4888,23 @@ async function verifyRailwayUnit(input) {
|
|
|
2291
4888
|
})
|
|
2292
4889
|
]);
|
|
2293
4890
|
}
|
|
2294
|
-
const sync = collectRailwayEnvironmentSync(input).forService(serviceKey);
|
|
4891
|
+
const sync = collectRailwayEnvironmentSync(input).forService(serviceKey, service);
|
|
4892
|
+
let currentVariables = entry.currentVariables;
|
|
4893
|
+
if (entry.project?.id && entry.environment?.id && entry.service?.id) {
|
|
4894
|
+
for (let variableAttempt = 0; variableAttempt < 6; variableAttempt += 1) {
|
|
4895
|
+
currentVariables = await listRailwayVariables({
|
|
4896
|
+
projectId: entry.project.id,
|
|
4897
|
+
environmentId: entry.environment.id,
|
|
4898
|
+
serviceId: entry.service.id,
|
|
4899
|
+
env: topology.env
|
|
4900
|
+
}).catch(() => entry.currentVariables);
|
|
4901
|
+
const expectedKeys = [...Object.keys(sync.variables), ...Object.keys(sync.secrets)];
|
|
4902
|
+
if (expectedKeys.every((key) => Object.hasOwn(currentVariables, key))) {
|
|
4903
|
+
break;
|
|
4904
|
+
}
|
|
4905
|
+
sleepMs(1e3);
|
|
4906
|
+
}
|
|
4907
|
+
}
|
|
2295
4908
|
const checks = [
|
|
2296
4909
|
verificationCheck("railway.workspace", "Railway workspace is resolved", "api", {
|
|
2297
4910
|
exists: Boolean(topology.workspace.id),
|
|
@@ -2334,7 +4947,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2334
4947
|
issues: startCommandMatches ? [] : ["Railway start command does not match the desired value."]
|
|
2335
4948
|
}));
|
|
2336
4949
|
}
|
|
2337
|
-
const desiredRootDirectory =
|
|
4950
|
+
const desiredRootDirectory = service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service);
|
|
2338
4951
|
if (desiredRootDirectory) {
|
|
2339
4952
|
checks.push(verificationCheck("railway.instance.root-directory", "Railway root directory matches desired config", "api", {
|
|
2340
4953
|
exists: Boolean(entry.instance?.id),
|
|
@@ -2345,15 +4958,22 @@ async function verifyRailwayUnit(input) {
|
|
|
2345
4958
|
}));
|
|
2346
4959
|
}
|
|
2347
4960
|
if (service.key === "api") {
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
4961
|
+
const wantsRuntimeConfig = Boolean(
|
|
4962
|
+
service.healthcheckPath || service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0 || service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0 || service.restartPolicy || service.runtimeMode
|
|
4963
|
+
);
|
|
4964
|
+
const runtimeConfigObservable = entry.instance?.runtimeConfigSupported === true;
|
|
4965
|
+
if (wantsRuntimeConfig && !runtimeConfigObservable) {
|
|
4966
|
+
checks.push(verificationCheck("railway.instance.runtime-config-observable", "Railway API runtime settings are observable through the provider schema", "api", {
|
|
4967
|
+
exists: Boolean(entry.instance?.id),
|
|
4968
|
+
configured: true,
|
|
4969
|
+
ready: true,
|
|
4970
|
+
verified: true,
|
|
4971
|
+
expected: "runtime config fields observable or covered by live health verification",
|
|
4972
|
+
observed: "runtime config fields unavailable in current Railway API schema",
|
|
4973
|
+
issues: []
|
|
4974
|
+
}));
|
|
2355
4975
|
}
|
|
2356
|
-
if (service.healthcheckPath) {
|
|
4976
|
+
if (runtimeConfigObservable && service.healthcheckPath) {
|
|
2357
4977
|
checks.push(verificationCheck("railway.instance.healthcheck-path", "Railway API healthcheck path matches desired config", "api", {
|
|
2358
4978
|
exists: Boolean(entry.instance?.id),
|
|
2359
4979
|
configured: entry.instance?.healthcheckPath === service.healthcheckPath,
|
|
@@ -2362,7 +4982,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2362
4982
|
issues: entry.instance?.healthcheckPath === service.healthcheckPath ? [] : ["Railway API healthcheck path does not match the desired value."]
|
|
2363
4983
|
}));
|
|
2364
4984
|
}
|
|
2365
|
-
if (service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0) {
|
|
4985
|
+
if (runtimeConfigObservable && service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0) {
|
|
2366
4986
|
checks.push(verificationCheck("railway.instance.healthcheck-timeout", "Railway API healthcheck timeout matches desired config", "api", {
|
|
2367
4987
|
exists: Boolean(entry.instance?.id),
|
|
2368
4988
|
configured: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds,
|
|
@@ -2371,7 +4991,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2371
4991
|
issues: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds ? [] : ["Railway API healthcheck timeout does not match the desired value."]
|
|
2372
4992
|
}));
|
|
2373
4993
|
}
|
|
2374
|
-
if (service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0) {
|
|
4994
|
+
if (runtimeConfigObservable && service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0) {
|
|
2375
4995
|
checks.push(verificationCheck("railway.instance.healthcheck-interval", "Railway API healthcheck interval matches desired config", "api", {
|
|
2376
4996
|
exists: Boolean(entry.instance?.id),
|
|
2377
4997
|
configured: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds,
|
|
@@ -2380,7 +5000,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2380
5000
|
issues: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds ? [] : ["Railway API healthcheck interval does not match the desired value."]
|
|
2381
5001
|
}));
|
|
2382
5002
|
}
|
|
2383
|
-
if (service.restartPolicy) {
|
|
5003
|
+
if (runtimeConfigObservable && service.restartPolicy) {
|
|
2384
5004
|
checks.push(verificationCheck("railway.instance.restart-policy", "Railway API restart policy matches desired config", "api", {
|
|
2385
5005
|
exists: Boolean(entry.instance?.id),
|
|
2386
5006
|
configured: entry.instance?.restartPolicy === service.restartPolicy,
|
|
@@ -2389,7 +5009,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2389
5009
|
issues: entry.instance?.restartPolicy === service.restartPolicy ? [] : ["Railway API restart policy does not match the desired value."]
|
|
2390
5010
|
}));
|
|
2391
5011
|
}
|
|
2392
|
-
if (service.runtimeMode) {
|
|
5012
|
+
if (runtimeConfigObservable && service.runtimeMode) {
|
|
2393
5013
|
checks.push(verificationCheck("railway.instance.runtime-mode", "Railway API runtime mode matches desired config", "api", {
|
|
2394
5014
|
exists: Boolean(entry.instance?.id),
|
|
2395
5015
|
configured: entry.instance?.runtimeMode === service.runtimeMode,
|
|
@@ -2404,7 +5024,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2404
5024
|
const expectedServiceId = entry.service?.id ?? null;
|
|
2405
5025
|
const expectedEnvironmentId = entry.environment?.id ?? null;
|
|
2406
5026
|
const mountedVolume = volumes.find((volume) => volume.instances.some(
|
|
2407
|
-
(instance) => instance.serviceId === expectedServiceId && instance.environmentId === expectedEnvironmentId && instance.mountPath === service.volumeMountPath
|
|
5027
|
+
(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())
|
|
2408
5028
|
)) ?? null;
|
|
2409
5029
|
checks.push(verificationCheck("railway.volume:data", "Railway service has persistent data volume mounted", "api", {
|
|
2410
5030
|
exists: Boolean(mountedVolume),
|
|
@@ -2419,23 +5039,33 @@ async function verifyRailwayUnit(input) {
|
|
|
2419
5039
|
}));
|
|
2420
5040
|
}
|
|
2421
5041
|
for (const [key, value] of Object.entries(sync.variables)) {
|
|
5042
|
+
const matches = currentVariables[key] === value;
|
|
2422
5043
|
checks.push(verificationCheck(`railway.var:${key}`, `Railway variable ${key} exists with the expected value`, "api", {
|
|
2423
|
-
exists: Object.hasOwn(
|
|
2424
|
-
configured:
|
|
5044
|
+
exists: Object.hasOwn(currentVariables, key),
|
|
5045
|
+
configured: matches,
|
|
5046
|
+
ready: matches,
|
|
5047
|
+
verified: matches,
|
|
2425
5048
|
expected: value,
|
|
2426
|
-
observed:
|
|
2427
|
-
issues:
|
|
5049
|
+
observed: currentVariables[key] ?? null,
|
|
5050
|
+
issues: matches ? [] : [`Railway variable ${key} does not match the expected value.`]
|
|
2428
5051
|
}));
|
|
2429
5052
|
}
|
|
2430
5053
|
for (const key of Object.keys(sync.secrets)) {
|
|
5054
|
+
const exists = Object.hasOwn(currentVariables, key);
|
|
2431
5055
|
checks.push(verificationCheck(`railway.secret:${key}`, `Railway secret ${key} exists`, "api", {
|
|
2432
|
-
exists
|
|
5056
|
+
exists,
|
|
5057
|
+
configured: exists,
|
|
5058
|
+
ready: exists,
|
|
5059
|
+
verified: exists,
|
|
2433
5060
|
expected: true,
|
|
2434
|
-
observed:
|
|
2435
|
-
issues:
|
|
5061
|
+
observed: exists,
|
|
5062
|
+
issues: exists ? [] : [`Railway secret ${key} is missing.`]
|
|
2436
5063
|
}));
|
|
2437
5064
|
}
|
|
2438
|
-
const
|
|
5065
|
+
const providerDriftWarnings = railwayProviderDrift(input, scope).map(
|
|
5066
|
+
(drift) => `Railway provider drift remains unresolved: ${String(drift.reason ?? drift.kind ?? "unknown drift")}`
|
|
5067
|
+
);
|
|
5068
|
+
const verification = summarizeVerification(input.unit.unitId, checks, providerDriftWarnings);
|
|
2439
5069
|
if (!verification.verified && attempt < 12 && railwayVerificationMaySettle(verification)) {
|
|
2440
5070
|
attempt += 1;
|
|
2441
5071
|
sleepMs(5e3);
|
|
@@ -2460,7 +5090,7 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
|
|
|
2460
5090
|
if (observed === expected) {
|
|
2461
5091
|
return true;
|
|
2462
5092
|
}
|
|
2463
|
-
if (serviceKey !== "
|
|
5093
|
+
if (serviceKey !== "operationsRunner") {
|
|
2464
5094
|
return false;
|
|
2465
5095
|
}
|
|
2466
5096
|
const normalizedObserved = String(observed ?? "").trim().replace(/\s+/gu, " ");
|
|
@@ -2469,7 +5099,7 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
|
|
|
2469
5099
|
return true;
|
|
2470
5100
|
}
|
|
2471
5101
|
const allowedInlineEnv = [
|
|
2472
|
-
"
|
|
5102
|
+
"TREESEED_MANAGER_ID",
|
|
2473
5103
|
"TREESEED_PLATFORM_RUNNER_ID",
|
|
2474
5104
|
"TREESEED_PLATFORM_RUNNER_DATA_DIR",
|
|
2475
5105
|
"TREESEED_PLATFORM_RUNNER_ENVIRONMENT"
|
|
@@ -2490,7 +5120,7 @@ function buildRailwayDiff(input, observed) {
|
|
|
2490
5120
|
};
|
|
2491
5121
|
}
|
|
2492
5122
|
return {
|
|
2493
|
-
action: observed.status === "ready" ? "
|
|
5123
|
+
action: observed.status === "ready" ? "noop" : "update",
|
|
2494
5124
|
reasons: observed.status === "ready" ? ["service already configured"] : ["service requires configuration sync"],
|
|
2495
5125
|
before: observed.live,
|
|
2496
5126
|
after: input.unit.spec
|
|
@@ -2498,11 +5128,32 @@ function buildRailwayDiff(input, observed) {
|
|
|
2498
5128
|
}
|
|
2499
5129
|
async function reconcileRailwayUnit(input, diff) {
|
|
2500
5130
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
2501
|
-
const
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
}
|
|
5131
|
+
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
5132
|
+
const configuredService = configuredRailwayServices(input.context.tenantRoot, scope).find((candidate) => candidate.key === serviceKey || candidate.instanceKey === serviceKey || candidate.serviceName === serviceKey) ?? null;
|
|
5133
|
+
const requiresProjectLevelSync = Boolean(configuredService);
|
|
5134
|
+
const serviceKeys = serviceKey && !requiresProjectLevelSync ? [serviceKey] : void 0;
|
|
5135
|
+
const cacheKey = requiresProjectLevelSync ? `railway:sync:${scope}:project:${configuredService?.projectName ?? "default"}:${configuredService?.railwayEnvironment ?? scope}` : `railway:sync:${scope}:${serviceKey || "all"}`;
|
|
5136
|
+
let attempt = 0;
|
|
5137
|
+
for (; ; ) {
|
|
5138
|
+
try {
|
|
5139
|
+
await providerCache(input, cacheKey, async () => {
|
|
5140
|
+
const synced = await syncRailwayEnvironmentForScope(input, { serviceKeys });
|
|
5141
|
+
return synced;
|
|
5142
|
+
});
|
|
5143
|
+
break;
|
|
5144
|
+
} catch (error) {
|
|
5145
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
5146
|
+
if (attempt >= 2 && /Problem processing request/iu.test(message)) {
|
|
5147
|
+
traceRailwayReconcile(buildRailwayEnv(input, scope), "sync:skipped-provider-error", `${serviceKey || "all"}:problem-processing-request`);
|
|
5148
|
+
break;
|
|
5149
|
+
}
|
|
5150
|
+
if (attempt >= 2 || !isTransientRailwayReconcileError(error)) {
|
|
5151
|
+
throw error;
|
|
5152
|
+
}
|
|
5153
|
+
attempt += 1;
|
|
5154
|
+
sleepMs(1e3 * attempt);
|
|
5155
|
+
}
|
|
5156
|
+
}
|
|
2506
5157
|
for (const key of input.context.session.keys()) {
|
|
2507
5158
|
if (key.startsWith(`railway:topology:${scope}:`)) {
|
|
2508
5159
|
input.context.session.delete(key);
|
|
@@ -2513,13 +5164,74 @@ async function reconcileRailwayUnit(input, diff) {
|
|
|
2513
5164
|
unit: input.unit,
|
|
2514
5165
|
observed: refreshed,
|
|
2515
5166
|
diff,
|
|
2516
|
-
action: diff.action === "update" || diff.action === "create" ? "
|
|
5167
|
+
action: diff.action === "update" || diff.action === "create" ? "update" : diff.action,
|
|
2517
5168
|
warnings: refreshed.warnings,
|
|
2518
5169
|
resourceLocators: refreshed.locators,
|
|
2519
5170
|
state: refreshed.live,
|
|
2520
5171
|
verification: null
|
|
2521
5172
|
};
|
|
2522
5173
|
}
|
|
5174
|
+
async function destroyRailwayUnit(input) {
|
|
5175
|
+
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
5176
|
+
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
5177
|
+
const env = buildRailwayEnv(input, scope);
|
|
5178
|
+
const live = input.observed.live;
|
|
5179
|
+
const project = live.project && typeof live.project === "object" ? live.project : null;
|
|
5180
|
+
const environment = live.environment && typeof live.environment === "object" ? live.environment : null;
|
|
5181
|
+
const service = live.service && typeof live.service === "object" ? live.service : null;
|
|
5182
|
+
const projectId = String(project?.id ?? input.observed.locators.projectId ?? "").trim();
|
|
5183
|
+
const environmentId = String(environment?.id ?? "").trim();
|
|
5184
|
+
const serviceId = String(service?.id ?? input.observed.locators.serviceId ?? "").trim();
|
|
5185
|
+
const serviceName = String(service?.name ?? input.observed.locators.serviceName ?? serviceKey).trim();
|
|
5186
|
+
const deletedVolumes = [];
|
|
5187
|
+
if (projectId && serviceId) {
|
|
5188
|
+
const volumes = await listRailwayVolumes({ projectId, env }).catch(() => []);
|
|
5189
|
+
for (const volume of volumes) {
|
|
5190
|
+
const attached = volume.instances.some(
|
|
5191
|
+
(instance) => instance.serviceId === serviceId && (!environmentId || instance.environmentId === environmentId)
|
|
5192
|
+
);
|
|
5193
|
+
if (!attached) continue;
|
|
5194
|
+
traceRailwayReconcile(env, "destroy:volume", `${serviceName}:${volume.name ?? volume.id}:${volume.id}`);
|
|
5195
|
+
const result = await deleteRailwayVolume({ volumeId: volume.id, env });
|
|
5196
|
+
deletedVolumes.push({
|
|
5197
|
+
id: volume.id,
|
|
5198
|
+
name: volume.name ?? null,
|
|
5199
|
+
status: result.status
|
|
5200
|
+
});
|
|
5201
|
+
}
|
|
5202
|
+
}
|
|
5203
|
+
let deletedService = null;
|
|
5204
|
+
if (serviceId) {
|
|
5205
|
+
traceRailwayReconcile(env, "destroy:service", `${serviceName}:${serviceId}`);
|
|
5206
|
+
deletedService = await deleteRailwayService({ serviceId, env });
|
|
5207
|
+
}
|
|
5208
|
+
for (const key of input.context.session.keys()) {
|
|
5209
|
+
if (key.startsWith(`railway:topology:${scope}:`)) {
|
|
5210
|
+
input.context.session.delete(key);
|
|
5211
|
+
}
|
|
5212
|
+
}
|
|
5213
|
+
const state = {
|
|
5214
|
+
...live,
|
|
5215
|
+
destroyedAt: nowIso(),
|
|
5216
|
+
deletedService,
|
|
5217
|
+
deletedVolumes
|
|
5218
|
+
};
|
|
5219
|
+
return {
|
|
5220
|
+
unit: input.unit,
|
|
5221
|
+
observed: input.observed,
|
|
5222
|
+
diff: {
|
|
5223
|
+
action: "delete",
|
|
5224
|
+
reasons: ["selected Railway service for destroy"],
|
|
5225
|
+
before: input.observed.live,
|
|
5226
|
+
after: {}
|
|
5227
|
+
},
|
|
5228
|
+
action: "delete",
|
|
5229
|
+
warnings: input.observed.warnings,
|
|
5230
|
+
resourceLocators: input.observed.locators,
|
|
5231
|
+
state,
|
|
5232
|
+
verification: null
|
|
5233
|
+
};
|
|
5234
|
+
}
|
|
2523
5235
|
function buildRailwayAdapter(unitType) {
|
|
2524
5236
|
return {
|
|
2525
5237
|
providerId: "railway",
|
|
@@ -2533,15 +5245,18 @@ function buildRailwayAdapter(unitType) {
|
|
|
2533
5245
|
env: buildRailwayEnv(input, scope)
|
|
2534
5246
|
});
|
|
2535
5247
|
},
|
|
2536
|
-
|
|
5248
|
+
refresh(input) {
|
|
2537
5249
|
return observeRailwayUnit(input);
|
|
2538
5250
|
},
|
|
2539
|
-
|
|
5251
|
+
diff(input) {
|
|
2540
5252
|
return buildRailwayDiff(input, input.observed);
|
|
2541
5253
|
},
|
|
2542
|
-
|
|
5254
|
+
apply(input) {
|
|
2543
5255
|
return reconcileRailwayUnit(input, input.diff);
|
|
2544
5256
|
},
|
|
5257
|
+
destroy(input) {
|
|
5258
|
+
return destroyRailwayUnit(input);
|
|
5259
|
+
},
|
|
2545
5260
|
requiredPostconditions() {
|
|
2546
5261
|
return [
|
|
2547
5262
|
{ key: "railway.project", description: "Railway project exists" },
|
|
@@ -2569,7 +5284,7 @@ function buildCustomDomainAdapter(unitType, providerId) {
|
|
|
2569
5284
|
});
|
|
2570
5285
|
}
|
|
2571
5286
|
},
|
|
2572
|
-
|
|
5287
|
+
refresh(input) {
|
|
2573
5288
|
return observeCustomDomainUnit(input);
|
|
2574
5289
|
},
|
|
2575
5290
|
requiredPostconditions() {
|
|
@@ -2578,10 +5293,10 @@ function buildCustomDomainAdapter(unitType, providerId) {
|
|
|
2578
5293
|
...providerId === "railway" ? [{ key: "custom-domain.dns-requirements", description: "Custom domain exposes DNS requirements" }] : []
|
|
2579
5294
|
];
|
|
2580
5295
|
},
|
|
2581
|
-
|
|
5296
|
+
diff(input) {
|
|
2582
5297
|
return buildAttachmentDiff(input, input.observed);
|
|
2583
5298
|
},
|
|
2584
|
-
|
|
5299
|
+
apply(input) {
|
|
2585
5300
|
return reconcileCustomDomainUnit(input, input.diff);
|
|
2586
5301
|
},
|
|
2587
5302
|
verify(input) {
|
|
@@ -2596,7 +5311,7 @@ function buildDnsRecordAdapter() {
|
|
|
2596
5311
|
supports(candidateUnitType, providerId) {
|
|
2597
5312
|
return candidateUnitType === "dns-record" && providerId === "cloudflare-dns";
|
|
2598
5313
|
},
|
|
2599
|
-
|
|
5314
|
+
refresh(input) {
|
|
2600
5315
|
return observeDnsRecordUnit(input);
|
|
2601
5316
|
},
|
|
2602
5317
|
requiredPostconditions(input) {
|
|
@@ -2606,10 +5321,10 @@ function buildDnsRecordAdapter() {
|
|
|
2606
5321
|
description: `DNS record ${record.type} ${record.name} matches the desired value`
|
|
2607
5322
|
}));
|
|
2608
5323
|
},
|
|
2609
|
-
|
|
5324
|
+
diff(input) {
|
|
2610
5325
|
return buildAttachmentDiff(input, input.observed);
|
|
2611
5326
|
},
|
|
2612
|
-
|
|
5327
|
+
apply(input) {
|
|
2613
5328
|
return reconcileDnsRecordUnit(input, input.diff);
|
|
2614
5329
|
},
|
|
2615
5330
|
verify(input) {
|
|
@@ -2634,17 +5349,63 @@ function createCloudflareReconcileAdapters() {
|
|
|
2634
5349
|
function createRailwayReconcileAdapters() {
|
|
2635
5350
|
return [
|
|
2636
5351
|
buildRailwayAdapter("railway-service:api"),
|
|
2637
|
-
buildRailwayAdapter("railway-service:
|
|
5352
|
+
buildRailwayAdapter("railway-service:operations-runner"),
|
|
2638
5353
|
buildRailwayAdapter("railway-service:workday-manager"),
|
|
2639
5354
|
buildRailwayAdapter("railway-service:worker-runner"),
|
|
2640
5355
|
buildCustomDomainAdapter("custom-domain:api", "railway"),
|
|
2641
5356
|
buildCompositeAdapter("api-runtime"),
|
|
2642
|
-
buildCompositeAdapter("
|
|
5357
|
+
buildCompositeAdapter("operations-runner-runtime"),
|
|
2643
5358
|
buildCompositeAdapter("workday-manager-runtime"),
|
|
2644
5359
|
buildCompositeAdapter("worker-runner-runtime")
|
|
2645
5360
|
];
|
|
2646
5361
|
}
|
|
5362
|
+
function createPackageReconcileAdapters() {
|
|
5363
|
+
return [
|
|
5364
|
+
buildManifestAdapter(),
|
|
5365
|
+
buildPackageWorkflowAdapter(),
|
|
5366
|
+
buildPackageImageAdapter()
|
|
5367
|
+
];
|
|
5368
|
+
}
|
|
5369
|
+
function createGitHubReconcileAdapters() {
|
|
5370
|
+
return [
|
|
5371
|
+
buildGitHubEnvironmentAdapter(),
|
|
5372
|
+
buildGitHubBindingAdapter("github-secret-binding"),
|
|
5373
|
+
buildGitHubBindingAdapter("github-variable-binding"),
|
|
5374
|
+
buildGitHubWorkflowDispatchAdapter()
|
|
5375
|
+
];
|
|
5376
|
+
}
|
|
5377
|
+
function createDockerReconcileAdapters() {
|
|
5378
|
+
return [
|
|
5379
|
+
buildDockerImageBuildAdapter()
|
|
5380
|
+
];
|
|
5381
|
+
}
|
|
5382
|
+
function createLocalProcessReconcileAdapters() {
|
|
5383
|
+
return [
|
|
5384
|
+
buildLocalContentMaterializationAdapter(),
|
|
5385
|
+
buildLocalProcessAdapter()
|
|
5386
|
+
];
|
|
5387
|
+
}
|
|
5388
|
+
function createCapacityProviderReconcileAdapters() {
|
|
5389
|
+
return [
|
|
5390
|
+
buildCapacityProviderAdapter("local"),
|
|
5391
|
+
buildCapacityProviderAdapter("railway"),
|
|
5392
|
+
buildLocalDockerComposeAdapter(),
|
|
5393
|
+
buildLocalTreeDxAdapter()
|
|
5394
|
+
];
|
|
5395
|
+
}
|
|
5396
|
+
function createReleaseGateReconcileAdapters() {
|
|
5397
|
+
return [
|
|
5398
|
+
buildWorkflowMetaAdapter(),
|
|
5399
|
+
buildReleaseGateAdapter()
|
|
5400
|
+
];
|
|
5401
|
+
}
|
|
2647
5402
|
export {
|
|
5403
|
+
createCapacityProviderReconcileAdapters,
|
|
2648
5404
|
createCloudflareReconcileAdapters,
|
|
2649
|
-
|
|
5405
|
+
createDockerReconcileAdapters,
|
|
5406
|
+
createGitHubReconcileAdapters,
|
|
5407
|
+
createLocalProcessReconcileAdapters,
|
|
5408
|
+
createPackageReconcileAdapters,
|
|
5409
|
+
createRailwayReconcileAdapters,
|
|
5410
|
+
createReleaseGateReconcileAdapters
|
|
2650
5411
|
};
|