@treeseed/sdk 0.11.0 → 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 +9 -4
- 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 +117 -60
- package/dist/capacity-provider.js +215 -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 +2 -2
- 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 +49037 -11843
- package/dist/db/market-schema.js +1788 -50
- package/dist/db/node-sqlite.d.ts +1 -232
- 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 +1 -1
- package/dist/hosting/builtins.d.ts +1 -1
- package/dist/hosting/builtins.js +9 -198
- package/dist/hosting/contracts.d.ts +3 -2
- package/dist/hosting/graph.d.ts +24 -21
- package/dist/hosting/graph.js +280 -294
- package/dist/hosting/index.d.ts +4 -4
- package/dist/index.d.ts +88 -74
- package/dist/index.js +81 -10
- 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 -8
- package/dist/market-client.d.ts +265 -7
- package/dist/market-client.js +381 -9
- 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 +1 -1
- 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 +73 -19
- package/dist/operations/services/config-runtime.js +276 -100
- package/dist/operations/services/deploy.d.ts +41 -622
- package/dist/operations/services/deploy.js +181 -213
- package/dist/operations/services/deployment-readiness.d.ts +1 -1
- package/dist/operations/services/deployment-readiness.js +38 -7
- 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 +43 -5
- package/dist/operations/services/git-workflow.js +242 -20
- package/dist/operations/services/github-actions-verification.d.ts +3 -1
- package/dist/operations/services/github-actions-verification.js +11 -3
- package/dist/operations/services/github-api.js +21 -5
- package/dist/operations/services/github-automation.d.ts +3 -3
- package/dist/operations/services/github-automation.js +15 -18
- package/dist/operations/services/github-credentials.js +2 -1
- package/dist/operations/services/hosted-service-checks.d.ts +19 -1
- package/dist/operations/services/hosted-service-checks.js +162 -11
- 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 +19 -14
- package/dist/operations/services/live-hosted-service-checks.d.ts +2 -1
- package/dist/operations/services/live-hosted-service-checks.js +193 -80
- 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-repositories.d.ts +37 -0
- package/dist/operations/services/managed-repositories.js +177 -0
- package/dist/operations/services/operations-runner-smoke.d.ts +3 -2
- package/dist/operations/services/operations-runner-smoke.js +22 -4
- package/dist/operations/services/package-adapters.d.ts +77 -2
- package/dist/operations/services/package-adapters.js +627 -56
- package/dist/operations/services/package-reference-policy.d.ts +12 -88
- package/dist/operations/services/package-reference-policy.js +81 -213
- package/dist/operations/services/project-host-operations.d.ts +4 -4
- package/dist/operations/services/project-platform.d.ts +19 -193
- package/dist/operations/services/project-platform.js +31 -103
- package/dist/operations/services/project-web-monitor.d.ts +2 -2
- package/dist/operations/services/project-web-monitor.js +97 -2
- package/dist/operations/services/railway-api.d.ts +36 -7
- package/dist/operations/services/railway-api.js +331 -100
- package/dist/operations/services/railway-deploy.d.ts +7 -95
- package/dist/operations/services/railway-deploy.js +334 -742
- 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 +6 -7
- package/dist/operations/services/repository-save-orchestrator.js +353 -174
- 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 -192
- package/dist/operations/services/runtime-tools.js +4 -444
- 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/workflow-timing.d.ts +25 -0
- package/dist/operations/services/workflow-timing.js +79 -0
- package/dist/operations/services/workspace-dependency-mode.js +41 -4
- 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 +41 -18
- package/dist/operations/services/workspace-tools.js +40 -6
- 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 +6 -1
- package/dist/platform/deploy-config.d.ts +1 -1
- package/dist/platform/deploy-config.js +7 -1
- 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 +414 -19
- package/dist/platform/environment.d.ts +2 -2
- package/dist/platform/environment.js +54 -22
- 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 +5 -5
- 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/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 +3 -3
- package/dist/reconcile/builtin-adapters.d.ts +7 -1
- package/dist/reconcile/builtin-adapters.js +2924 -346
- package/dist/reconcile/contracts.d.ts +15 -2
- package/dist/reconcile/desired-state.d.ts +2 -193
- package/dist/reconcile/desired-state.js +24 -33
- package/dist/reconcile/engine.d.ts +60 -397
- package/dist/reconcile/engine.js +182 -18
- package/dist/reconcile/index.d.ts +10 -10
- package/dist/reconcile/live-acceptance.d.ts +22 -1
- package/dist/reconcile/live-acceptance.js +456 -44
- 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 +3 -40
- package/dist/reconcile/units.d.ts +1 -1
- package/dist/reconcile/units.js +7 -1
- 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/migrate-journey-csv-to-guarantees.js +140 -0
- package/dist/scripts/package-tools.js +12 -1
- package/dist/scripts/prepare.js +3 -7
- package/dist/scripts/release-verify.js +2 -5
- 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/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 +1184 -113
- package/dist/sdk-types.js +393 -0
- package/dist/sdk.d.ts +74 -79
- package/dist/sdk.js +27 -36
- 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-ui.d.ts +2 -2
- package/dist/treedx/adapters.d.ts +6 -6
- package/dist/treedx/client.d.ts +1 -1
- package/dist/treedx/errors.d.ts +1 -1
- package/dist/treedx/federated-client.d.ts +3 -3
- package/dist/treedx/graph-adapter.d.ts +2 -2
- package/dist/treedx/index.d.ts +14 -14
- package/dist/treedx/market-integration.d.ts +1 -1
- package/dist/treedx/ports.d.ts +30 -30
- package/dist/treedx/query-adapter.d.ts +3 -3
- package/dist/treedx/registry-client.d.ts +2 -2
- package/dist/treedx/repository-adapter.d.ts +4 -4
- package/dist/treedx/repository-adapter.js +14 -3
- package/dist/treedx/sdk-integration.d.ts +3 -3
- package/dist/treedx/types.d.ts +2 -2
- package/dist/treedx/workspace-adapter.d.ts +2 -2
- package/dist/treedx-backends.d.ts +13 -11
- package/dist/treedx-backends.js +52 -50
- package/dist/treedx-client.d.ts +3 -0
- package/dist/treedx-client.js +10 -1
- package/dist/treeseed/services/compose.yml +2 -3
- package/dist/treeseed/template-catalog/catalog.fixture.json +4 -4
- 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 +510 -559
- package/dist/workflow/operations.js +1891 -1342
- 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 +5 -5
- package/dist/workflow-state.js +27 -17
- package/dist/workflow-support.d.ts +26 -25
- package/dist/workflow-support.js +27 -31
- package/dist/workflow.d.ts +46 -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/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 +75 -18
- package/scripts/{verify-driver.mjs → verify-driver.ts} +19 -4
- package/templates/github/deploy-web.workflow.yml +12 -8
- 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 -60
- package/dist/operations/services/release-candidate.js +0 -953
- 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,
|
|
@@ -48,6 +55,8 @@ import {
|
|
|
48
55
|
ensureRailwayPostgresService,
|
|
49
56
|
deleteRailwayService,
|
|
50
57
|
deleteRailwayVolume,
|
|
58
|
+
deployRailwayServiceInstance,
|
|
59
|
+
listRailwayEnvironmentServices,
|
|
51
60
|
getRailwayServiceInstance,
|
|
52
61
|
getRailwayProject,
|
|
53
62
|
listRailwayCustomDomains,
|
|
@@ -57,11 +66,41 @@ import {
|
|
|
57
66
|
listRailwayVolumes,
|
|
58
67
|
listRailwayVariables,
|
|
59
68
|
resolveRailwayWorkspaceContext,
|
|
60
|
-
|
|
69
|
+
updateRailwayServiceGitSource,
|
|
70
|
+
updateRailwayServiceImageSource
|
|
61
71
|
} from "../operations/services/railway-api.js";
|
|
62
72
|
import { loadTreeseedReconcileState } from "./state.js";
|
|
63
73
|
import { createTreeseedReconcileUnitId } from "./units.js";
|
|
64
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";
|
|
65
104
|
function toDeployTarget(target) {
|
|
66
105
|
return target.kind === "persistent" ? createPersistentDeployTarget(target.scope) : createBranchPreviewDeployTarget(target.branchName);
|
|
67
106
|
}
|
|
@@ -77,7 +116,7 @@ function isTransientCloudflareReconcileError(error) {
|
|
|
77
116
|
}
|
|
78
117
|
function isTransientRailwayReconcileError(error) {
|
|
79
118
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
80
|
-
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);
|
|
81
120
|
}
|
|
82
121
|
function syntheticQueueLocator(name) {
|
|
83
122
|
return `queue-name:${name}`;
|
|
@@ -85,79 +124,1813 @@ function syntheticQueueLocator(name) {
|
|
|
85
124
|
function isSyntheticQueueLocator(value) {
|
|
86
125
|
return typeof value === "string" && value.startsWith("queue-name:");
|
|
87
126
|
}
|
|
88
|
-
function noopObservedState(input) {
|
|
127
|
+
function noopObservedState(input) {
|
|
128
|
+
return {
|
|
129
|
+
exists: true,
|
|
130
|
+
status: "ready",
|
|
131
|
+
live: {
|
|
132
|
+
unitId: input.unit.unitId,
|
|
133
|
+
dependencies: input.unit.dependencies
|
|
134
|
+
},
|
|
135
|
+
locators: {},
|
|
136
|
+
warnings: []
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function noopDiff() {
|
|
140
|
+
return {
|
|
141
|
+
action: "noop",
|
|
142
|
+
reasons: ["composite unit"],
|
|
143
|
+
before: {},
|
|
144
|
+
after: {}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
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);
|
|
89
1527
|
return {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
locators: {},
|
|
97
|
-
warnings: []
|
|
1528
|
+
project: project.slug,
|
|
1529
|
+
repositoryId: repository.repoId,
|
|
1530
|
+
repositoryName: project.repositoryName,
|
|
1531
|
+
files: 0,
|
|
1532
|
+
committed: false,
|
|
1533
|
+
skippedContentSync: true
|
|
98
1534
|
};
|
|
99
1535
|
}
|
|
100
|
-
function
|
|
1536
|
+
function buildLocalTreeDxAdapter() {
|
|
101
1537
|
return {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|
|
106
1676
|
};
|
|
107
1677
|
}
|
|
108
|
-
function
|
|
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
|
+
];
|
|
109
1690
|
return {
|
|
110
1691
|
providerId: "treeseed",
|
|
111
|
-
unitTypes
|
|
112
|
-
supports(
|
|
113
|
-
return
|
|
1692
|
+
unitTypes,
|
|
1693
|
+
supports(unitType, providerId) {
|
|
1694
|
+
return providerId === "treeseed" && unitTypes.includes(unitType);
|
|
114
1695
|
},
|
|
115
1696
|
refresh(input) {
|
|
116
|
-
|
|
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
|
+
};
|
|
117
1708
|
},
|
|
118
|
-
diff() {
|
|
119
|
-
return noopDiff();
|
|
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 };
|
|
120
1711
|
},
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 });
|
|
126
1821
|
},
|
|
127
|
-
|
|
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
|
+
});
|
|
1855
|
+
}
|
|
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
|
+
];
|
|
1865
|
+
return {
|
|
1866
|
+
providerId: "treeseed",
|
|
1867
|
+
unitTypes,
|
|
1868
|
+
supports(unitType, providerId) {
|
|
1869
|
+
return providerId === "treeseed" && unitTypes.includes(unitType);
|
|
1870
|
+
},
|
|
1871
|
+
refresh(input) {
|
|
1872
|
+
const fingerprint = workflowFingerprint(input);
|
|
1873
|
+
const previousFingerprint = typeof input.persistedState?.lastReconciledState?.fingerprint === "string" ? input.persistedState.lastReconciledState.fingerprint : null;
|
|
128
1874
|
return {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
unitId: unit.unitId,
|
|
137
|
-
reconciledAt: nowIso()
|
|
138
|
-
},
|
|
139
|
-
verification: null
|
|
1875
|
+
...genericObservedState(input),
|
|
1876
|
+
status: previousFingerprint === fingerprint ? "ready" : "pending",
|
|
1877
|
+
live: {
|
|
1878
|
+
...input.unit.spec,
|
|
1879
|
+
fingerprint,
|
|
1880
|
+
previousFingerprint
|
|
1881
|
+
}
|
|
140
1882
|
};
|
|
141
1883
|
},
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 };
|
|
1886
|
+
},
|
|
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 });
|
|
1913
|
+
},
|
|
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);
|
|
1928
|
+
},
|
|
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) });
|
|
161
1934
|
}
|
|
162
1935
|
};
|
|
163
1936
|
}
|
|
@@ -179,27 +1952,38 @@ function normalizeEnvironmentValues(env) {
|
|
|
179
1952
|
}
|
|
180
1953
|
function resolveReconcileEnvironmentValues(input, scope) {
|
|
181
1954
|
if (scope === "local") {
|
|
182
|
-
return resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope);
|
|
1955
|
+
return normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope));
|
|
183
1956
|
}
|
|
184
|
-
|
|
1957
|
+
return {
|
|
185
1958
|
...normalizeEnvironmentValues(process.env),
|
|
186
1959
|
...normalizeEnvironmentValues(input.context.launchEnv)
|
|
187
1960
|
};
|
|
188
|
-
if (hostedRuntimeValues.CLOUDFLARE_API_TOKEN || hostedRuntimeValues.RAILWAY_API_TOKEN || hostedRuntimeValues.GH_TOKEN) {
|
|
189
|
-
return hostedRuntimeValues;
|
|
190
|
-
}
|
|
191
|
-
const values = {
|
|
192
|
-
...resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope),
|
|
193
|
-
...hostedRuntimeValues
|
|
194
|
-
};
|
|
195
|
-
return values;
|
|
196
1961
|
}
|
|
197
1962
|
function buildCloudflareEnv(input) {
|
|
198
1963
|
const scope = scopeFromTarget(toDeployTarget(input.context.target));
|
|
199
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
|
+
}
|
|
200
1984
|
return {
|
|
201
|
-
CLOUDFLARE_ACCOUNT_ID:
|
|
202
|
-
CLOUDFLARE_API_TOKEN:
|
|
1985
|
+
CLOUDFLARE_ACCOUNT_ID: accountId,
|
|
1986
|
+
CLOUDFLARE_API_TOKEN: token
|
|
203
1987
|
};
|
|
204
1988
|
}
|
|
205
1989
|
function hasLiveResourceId(value) {
|
|
@@ -207,15 +1991,24 @@ function hasLiveResourceId(value) {
|
|
|
207
1991
|
}
|
|
208
1992
|
function buildRailwayEnv(input, scope) {
|
|
209
1993
|
const values = resolveReconcileEnvironmentValues(input, scope);
|
|
210
|
-
|
|
211
|
-
values.
|
|
212
|
-
input.context.launchEnv.
|
|
213
|
-
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
|
|
214
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
|
+
}
|
|
215
2007
|
return {
|
|
2008
|
+
TREESEED_RAILWAY_API_TOKEN: token,
|
|
216
2009
|
RAILWAY_API_TOKEN: token,
|
|
217
|
-
TREESEED_RAILWAY_API_URL:
|
|
218
|
-
TREESEED_RAILWAY_WORKSPACE:
|
|
2010
|
+
TREESEED_RAILWAY_API_URL: railwayApiUrl,
|
|
2011
|
+
TREESEED_RAILWAY_WORKSPACE: railwayWorkspace
|
|
219
2012
|
};
|
|
220
2013
|
}
|
|
221
2014
|
function findCloudflareQueueByName(input, env, expectedName, { attempts = 6, delayMs = 350 } = {}) {
|
|
@@ -492,6 +2285,8 @@ function normalizeRailwayDomainPayload(value) {
|
|
|
492
2285
|
}
|
|
493
2286
|
const record = value;
|
|
494
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 : {};
|
|
495
2290
|
const domain = typeof record.domain === "string" ? record.domain.trim() : typeof record.name === "string" ? record.name.trim() : "";
|
|
496
2291
|
const serviceDomain = firstRailwayDomainString(
|
|
497
2292
|
record.serviceDomain,
|
|
@@ -530,9 +2325,14 @@ function normalizeRailwayDomainPayload(value) {
|
|
|
530
2325
|
id: typeof record.id === "string" ? record.id.trim() : null,
|
|
531
2326
|
domain,
|
|
532
2327
|
serviceDomain,
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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,
|
|
536
2336
|
dnsRecords: effectiveDnsRecords
|
|
537
2337
|
};
|
|
538
2338
|
}
|
|
@@ -589,7 +2389,7 @@ function collectCloudflareEnvironmentSync(input) {
|
|
|
589
2389
|
secretNames.add(entry.id);
|
|
590
2390
|
}
|
|
591
2391
|
}
|
|
592
|
-
if (entry.targets.includes("cloudflare-var") && value) {
|
|
2392
|
+
if (entry.targets.includes("cloudflare-var") && value && entry.id !== "TREESEED_PROJECT_DOMAINS") {
|
|
593
2393
|
vars[entry.id] = value;
|
|
594
2394
|
varNames.add(entry.id);
|
|
595
2395
|
}
|
|
@@ -810,61 +2610,6 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
|
|
|
810
2610
|
current.databaseId = created.uuid;
|
|
811
2611
|
current.previewDatabaseId = created.previewDatabaseUuid ?? created.uuid;
|
|
812
2612
|
};
|
|
813
|
-
const ensureQueue = () => {
|
|
814
|
-
const current = state.queues?.agentWork;
|
|
815
|
-
if (!current?.name) {
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
const liveQueue = findCloudflareQueueByName(input, env, current.name, { attempts: 1, delayMs: 0 });
|
|
819
|
-
const liveDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName, { attempts: 1, delayMs: 0 }) : null;
|
|
820
|
-
if (liveQueue) {
|
|
821
|
-
current.queueId = queueId(liveQueue);
|
|
822
|
-
current.dlqId = current.dlqName ? queueId(liveDlq) : null;
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
let refreshedQueues = queues;
|
|
826
|
-
const existing = refreshedQueues.find((entry) => queueName(entry) === current.name);
|
|
827
|
-
if (existing) {
|
|
828
|
-
current.queueId = queueId(existing);
|
|
829
|
-
const existingDlq = current.dlqName ? refreshedQueues.find((entry) => queueName(entry) === current.dlqName) : null;
|
|
830
|
-
current.dlqId = queueId(existingDlq);
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
if (dryRun) {
|
|
834
|
-
current.queueId = `dryrun-${current.name}`;
|
|
835
|
-
current.dlqId = current.dlqName ? `dryrun-${current.dlqName}` : null;
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
try {
|
|
839
|
-
runWrangler(["queues", "create", current.name], {
|
|
840
|
-
cwd: input.context.tenantRoot,
|
|
841
|
-
capture: true,
|
|
842
|
-
env
|
|
843
|
-
});
|
|
844
|
-
} catch (error) {
|
|
845
|
-
if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
|
|
846
|
-
throw error;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
refreshedQueues = listQueues(input.context.tenantRoot, env);
|
|
850
|
-
if (current.dlqName && !refreshedQueues.find((entry) => queueName(entry) === current.dlqName)) {
|
|
851
|
-
try {
|
|
852
|
-
runWrangler(["queues", "create", current.dlqName], {
|
|
853
|
-
cwd: input.context.tenantRoot,
|
|
854
|
-
capture: true,
|
|
855
|
-
env
|
|
856
|
-
});
|
|
857
|
-
} catch (error) {
|
|
858
|
-
if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
|
|
859
|
-
throw error;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
const created = findCloudflareQueueByName(input, env, current.name);
|
|
864
|
-
current.queueId = created ? queueId(created) : syntheticQueueLocator(current.name);
|
|
865
|
-
const createdDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName) : null;
|
|
866
|
-
current.dlqId = current.dlqName ? createdDlq ? queueId(createdDlq) : syntheticQueueLocator(current.dlqName) : null;
|
|
867
|
-
};
|
|
868
2613
|
const ensureR2Bucket = () => {
|
|
869
2614
|
const bucketName = state.content?.bucketName;
|
|
870
2615
|
if (!bucketName) {
|
|
@@ -983,7 +2728,6 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
|
|
|
983
2728
|
};
|
|
984
2729
|
runStep("kv-form-guard", () => ensureKv("FORM_GUARD_KV"));
|
|
985
2730
|
runStep("d1", ensureD1);
|
|
986
|
-
runStep("queue", ensureQueue);
|
|
987
2731
|
runStep("r2", ensureR2Bucket);
|
|
988
2732
|
runStep("pages", ensurePagesProject);
|
|
989
2733
|
runStep("turnstile-widget", ensureTurnstileWidget);
|
|
@@ -1029,20 +2773,19 @@ function observeCloudflareUnit(input) {
|
|
|
1029
2773
|
const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets } = snapshot;
|
|
1030
2774
|
switch (input.unit.unitType) {
|
|
1031
2775
|
case "queue": {
|
|
1032
|
-
const
|
|
1033
|
-
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;
|
|
1034
2780
|
return {
|
|
1035
|
-
exists: Boolean(liveQueue
|
|
2781
|
+
exists: Boolean(liveQueue),
|
|
1036
2782
|
status: liveQueue ? "ready" : "pending",
|
|
1037
|
-
live: {
|
|
2783
|
+
live: { name, dlqName },
|
|
1038
2784
|
locators: {
|
|
1039
|
-
queueId: queueId(liveQueue) ??
|
|
1040
|
-
dlqId: queueId(liveDlq) ??
|
|
2785
|
+
queueId: queueId(liveQueue) ?? null,
|
|
2786
|
+
dlqId: queueId(liveDlq) ?? null
|
|
1041
2787
|
},
|
|
1042
|
-
warnings: [
|
|
1043
|
-
...isSyntheticQueueLocator(state.queues?.agentWork?.queueId) ? ["Cloudflare queue id is pending propagation; using queue-name fallback."] : [],
|
|
1044
|
-
...isSyntheticQueueLocator(state.queues?.agentWork?.dlqId) ? ["Cloudflare dead-letter queue id is pending propagation; using queue-name fallback."] : []
|
|
1045
|
-
]
|
|
2788
|
+
warnings: []
|
|
1046
2789
|
};
|
|
1047
2790
|
}
|
|
1048
2791
|
case "database": {
|
|
@@ -1132,7 +2875,7 @@ function verifyCloudflareUnitOnce(input, postconditions) {
|
|
|
1132
2875
|
const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets, env } = snapshot;
|
|
1133
2876
|
switch (input.unit.unitType) {
|
|
1134
2877
|
case "queue": {
|
|
1135
|
-
const queue =
|
|
2878
|
+
const queue = input.unit.spec;
|
|
1136
2879
|
const liveQueue = findCloudflareQueueByName(input, env, queue?.name, { attempts: 12, delayMs: 500 });
|
|
1137
2880
|
const liveDlq = queue?.dlqName ? findCloudflareQueueByName(input, env, queue.dlqName, { attempts: 12, delayMs: 500 }) : null;
|
|
1138
2881
|
return summarizeVerification(input.unit.unitId, [
|
|
@@ -1505,6 +3248,10 @@ function relativeRailwayRootDir(tenantRoot, serviceRoot) {
|
|
|
1505
3248
|
return !resolved || resolved === "" ? "." : resolved;
|
|
1506
3249
|
}
|
|
1507
3250
|
function railwayServiceRootDirectory(tenantRoot, service) {
|
|
3251
|
+
const sourceRootDirectory = String(service.sourceRootDirectory ?? "").trim();
|
|
3252
|
+
if (sourceRootDirectory) {
|
|
3253
|
+
return sourceRootDirectory;
|
|
3254
|
+
}
|
|
1508
3255
|
return relativeRailwayRootDir(service.application?.root ?? tenantRoot, service.rootDir);
|
|
1509
3256
|
}
|
|
1510
3257
|
function configuredMarketDatabaseService(tenantRoot, deployConfig) {
|
|
@@ -1555,6 +3302,7 @@ async function ensureRailwayEnvironmentForService({
|
|
|
1555
3302
|
}
|
|
1556
3303
|
async function resolveRailwayTopologyForScope(input, scope, {
|
|
1557
3304
|
ensure = false,
|
|
3305
|
+
ensureServices = ensure,
|
|
1558
3306
|
refresh = false,
|
|
1559
3307
|
serviceKeys,
|
|
1560
3308
|
includeInstances = ensure,
|
|
@@ -1571,17 +3319,23 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1571
3319
|
const knownProjects = [];
|
|
1572
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()))];
|
|
1573
3321
|
for (const projectId of knownProjectIds) {
|
|
3322
|
+
traceRailwayReconcile(env, "topology:known-project:get", projectId);
|
|
1574
3323
|
const project = await getRailwayProject({ projectId, env });
|
|
3324
|
+
traceRailwayReconcile(env, "topology:known-project:done", `${projectId}:${project?.name ?? "(missing)"}`);
|
|
1575
3325
|
if (project) {
|
|
1576
3326
|
knownProjects.push(project);
|
|
1577
3327
|
}
|
|
1578
3328
|
}
|
|
1579
3329
|
if (knownProjects.length === 0 || services.some((service) => !(service.projectId || deployState.services?.[service.key]?.projectId))) {
|
|
3330
|
+
traceRailwayReconcile(env, "topology:workspace:get", "start");
|
|
1580
3331
|
workspace = await resolveRailwayWorkspaceContext({ env });
|
|
3332
|
+
traceRailwayReconcile(env, "topology:workspace:done", workspace.name);
|
|
3333
|
+
traceRailwayReconcile(env, "topology:projects:list", workspace.name);
|
|
1581
3334
|
const listedProjects = await listRailwayProjects({
|
|
1582
3335
|
env,
|
|
1583
3336
|
workspaceId: workspace.id
|
|
1584
3337
|
});
|
|
3338
|
+
traceRailwayReconcile(env, "topology:projects:list:done", String(listedProjects.length));
|
|
1585
3339
|
for (const project of listedProjects) {
|
|
1586
3340
|
if (!knownProjects.find((entry) => entry?.id === project.id)) {
|
|
1587
3341
|
knownProjects.push(project);
|
|
@@ -1608,6 +3362,17 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1608
3362
|
const resolvedServiceId = service.serviceId ?? persistedService.serviceId ?? "";
|
|
1609
3363
|
const resolvedServiceName = service.serviceName ?? persistedService.serviceName ?? "";
|
|
1610
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
|
+
}
|
|
1611
3376
|
if (!project && ensure) {
|
|
1612
3377
|
if (!workspace) {
|
|
1613
3378
|
traceRailwayReconcile(env, "topology:workspace", "resolving workspace");
|
|
@@ -1626,6 +3391,9 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1626
3391
|
projectsByKey.set(project.name, project);
|
|
1627
3392
|
}
|
|
1628
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
|
+
}
|
|
1629
3397
|
if (project && !environment && ensure) {
|
|
1630
3398
|
traceRailwayReconcile(env, "topology:environment:ensure", `${service.key}:${service.railwayEnvironment}`);
|
|
1631
3399
|
environment = await ensureRailwayEnvironmentForService({
|
|
@@ -1641,13 +3409,30 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1641
3409
|
projectsByKey.set(project.id, project);
|
|
1642
3410
|
projectsByKey.set(project.name, project);
|
|
1643
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
|
+
}
|
|
1644
3425
|
let resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
|
|
1645
|
-
if (project && !resolvedService &&
|
|
3426
|
+
if (project && !resolvedService && ensureServices) {
|
|
1646
3427
|
traceRailwayReconcile(env, "topology:railway-service:ensure", `${service.key}:${resolvedServiceName || resolvedServiceId}`);
|
|
1647
3428
|
resolvedService = (await ensureRailwayService({
|
|
1648
3429
|
projectId: project.id,
|
|
1649
3430
|
serviceId: resolvedServiceId,
|
|
1650
3431
|
serviceName: resolvedServiceName,
|
|
3432
|
+
environmentId: environment?.id,
|
|
3433
|
+
imageRef: service.imageRef,
|
|
3434
|
+
sourceRepo: service.sourceRepo,
|
|
3435
|
+
sourceBranch: service.sourceBranch,
|
|
1651
3436
|
env
|
|
1652
3437
|
})).service;
|
|
1653
3438
|
project = {
|
|
@@ -1656,17 +3441,37 @@ async function resolveRailwayTopologyForScope(input, scope, {
|
|
|
1656
3441
|
};
|
|
1657
3442
|
projectsByKey.set(project.id, project);
|
|
1658
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
|
+
}
|
|
1659
3463
|
}
|
|
1660
3464
|
let instance = null;
|
|
1661
3465
|
if (includeInstances && resolvedService && environment) {
|
|
1662
|
-
if (
|
|
3466
|
+
if (ensureServices) {
|
|
1663
3467
|
traceRailwayReconcile(env, "topology:instance:ensure", `${service.key}:${resolvedService.name}`);
|
|
1664
3468
|
instance = (await ensureRailwayServiceInstanceConfiguration({
|
|
1665
3469
|
serviceId: resolvedService.id,
|
|
1666
3470
|
environmentId: environment.id,
|
|
1667
3471
|
buildCommand: service.buildCommand,
|
|
3472
|
+
dockerfilePath: !service.imageRef ? service.dockerfilePath ?? (existsSync(resolve(service.rootDir, "Dockerfile")) ? "/Dockerfile" : null) : null,
|
|
1668
3473
|
startCommand: service.startCommand,
|
|
1669
|
-
rootDirectory: railwayServiceRootDirectory(input.context.tenantRoot, service),
|
|
3474
|
+
rootDirectory: service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service),
|
|
1670
3475
|
healthcheckPath: service.healthcheckPath,
|
|
1671
3476
|
healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
|
|
1672
3477
|
healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
|
|
@@ -1733,36 +3538,113 @@ function activeRailwayVolumeInstances(volume) {
|
|
|
1733
3538
|
const instances = Array.isArray(volume.instances) ? volume.instances : [];
|
|
1734
3539
|
return instances.filter((instance) => {
|
|
1735
3540
|
const state = String(instance.state ?? "READY").toUpperCase();
|
|
1736
|
-
|
|
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";
|
|
1737
3544
|
});
|
|
1738
3545
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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;
|
|
1748
3561
|
}
|
|
1749
|
-
|
|
3562
|
+
const services = grouped.get(key) ?? [];
|
|
3563
|
+
services.push(service);
|
|
3564
|
+
grouped.set(key, services);
|
|
1750
3565
|
}
|
|
1751
|
-
return
|
|
3566
|
+
return [...grouped.values()];
|
|
1752
3567
|
}
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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)"}`);
|
|
1762
3645
|
}
|
|
1763
|
-
await new Promise((resolve2) => setTimeout(resolve2, 1500));
|
|
1764
3646
|
}
|
|
1765
|
-
return
|
|
3647
|
+
return blocked;
|
|
1766
3648
|
}
|
|
1767
3649
|
async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
|
|
1768
3650
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
@@ -1812,21 +3694,17 @@ async function reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
|
1812
3694
|
`${project.name}: desired=${[...desiredServiceNames].join(",") || "(none)"} stale=${staleServices.map((service) => service.name).join(",") || "(none)"}`
|
|
1813
3695
|
);
|
|
1814
3696
|
for (const service of staleServices) {
|
|
1815
|
-
traceRailwayReconcile(env, "sync:runner:
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
serviceName: service.name,
|
|
1827
|
-
reason: "Railway still reports the stale operations runner service after delete reconciliation."
|
|
1828
|
-
});
|
|
1829
|
-
}
|
|
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
|
+
});
|
|
1830
3708
|
}
|
|
1831
3709
|
const volumes = await listRailwayVolumes({ projectId: project.id, env }).catch(() => []);
|
|
1832
3710
|
const staleVolumes = findStaleTreeseedOperationsRunnerResources(volumes, desiredVolumeNames).filter((volume) => {
|
|
@@ -1836,101 +3714,384 @@ async function reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
|
1836
3714
|
traceRailwayReconcile(
|
|
1837
3715
|
env,
|
|
1838
3716
|
"sync:runner:observed-volumes",
|
|
1839
|
-
`${project.name}: desired=${[...desiredVolumeNames].join(",") || "(none)"} stale=${staleVolumes.map((volume) => volume.name ?? volume.id).join(",") || "(none)"}`
|
|
1840
|
-
);
|
|
1841
|
-
for (const volume of staleVolumes) {
|
|
1842
|
-
traceRailwayReconcile(env, "sync:runner:
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
volumeName: volume.name ?? null,
|
|
1854
|
-
reason: "Railway still reports the stale operations runner volume after delete reconciliation."
|
|
1855
|
-
});
|
|
1856
|
-
}
|
|
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
|
+
});
|
|
1857
3731
|
}
|
|
1858
3732
|
}
|
|
1859
|
-
async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
|
|
3733
|
+
async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKeys } = {}) {
|
|
1860
3734
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
1861
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;
|
|
1862
3737
|
traceRailwayReconcile(input.context.env, "sync:start", `scope=${scope} dryRun=${dryRun ? "yes" : "no"}`);
|
|
1863
|
-
const
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
+
});
|
|
1876
3796
|
}
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
3797
|
+
throw new Error(`Railway has pending-deletion volume attachments that block official IaC reconciliation. ${pendingVolumeBlocks.join("; ")}`);
|
|
3798
|
+
}
|
|
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,
|
|
3815
|
+
scope,
|
|
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
|
|
3827
|
+
};
|
|
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}` : "."}`);
|
|
3842
|
+
}
|
|
3843
|
+
const validation = validateRailwayIacChangeSet(plan.changeSet, {
|
|
3844
|
+
services: rendered.serviceNames,
|
|
3845
|
+
volumes: rendered.volumeNames,
|
|
3846
|
+
database: rendered.databaseName,
|
|
3847
|
+
scope
|
|
1888
3848
|
});
|
|
1889
|
-
if (
|
|
1890
|
-
|
|
1891
|
-
traceRailwayReconcile(topology.env, "sync:volume", `${entry.configuredService.key}:${volumeName}:${entry.configuredService.volumeMountPath}`);
|
|
1892
|
-
const volume = await ensureRailwayServiceVolume({
|
|
1893
|
-
projectId: entry.project.id,
|
|
1894
|
-
environmentId: entry.environment.id,
|
|
1895
|
-
serviceId: entry.service.id,
|
|
1896
|
-
name: volumeName,
|
|
1897
|
-
mountPath: entry.configuredService.volumeMountPath,
|
|
1898
|
-
env: topology.env
|
|
1899
|
-
});
|
|
1900
|
-
if (volume.instance?.serviceId !== entry.service.id || volume.instance?.environmentId !== entry.environment.id || volume.instance?.mountPath !== entry.configuredService.volumeMountPath) {
|
|
1901
|
-
throw new Error(`Railway API volume reconciliation did not mount ${volumeName} on ${entry.service.name} at ${entry.configuredService.volumeMountPath}.`);
|
|
1902
|
-
}
|
|
3849
|
+
if (!validation.ok) {
|
|
3850
|
+
throw new Error(`Railway IaC plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
|
|
1903
3851
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
}
|
|
1909
|
-
const desiredVolumeNames = new Set([...desiredServiceNames].map((serviceName) => `${serviceName}-volume`));
|
|
1910
|
-
if (desiredServiceNames.size > 0) {
|
|
1911
|
-
await reconcileStaleOperationsRunnerResourcesForProject(input, {
|
|
1912
|
-
scope,
|
|
1913
|
-
env: topology.env,
|
|
1914
|
-
project: entry.project,
|
|
1915
|
-
environment: entry.environment,
|
|
1916
|
-
desiredServiceNames,
|
|
1917
|
-
desiredVolumeNames
|
|
1918
|
-
});
|
|
1919
|
-
}
|
|
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}` : "."}`);
|
|
1920
3856
|
}
|
|
3857
|
+
syncedServices.push(...projectServices);
|
|
3858
|
+
} finally {
|
|
3859
|
+
cleanupRailwayIacRender(rendered);
|
|
1921
3860
|
}
|
|
1922
|
-
traceRailwayReconcile(topology.env, "sync:runner:cleanup-stale", "reconciling old operations runner resources");
|
|
1923
|
-
await reconcileStaleOperationsRunnerResourcesForScope(input, topology);
|
|
1924
3861
|
}
|
|
1925
|
-
traceRailwayReconcile(
|
|
3862
|
+
traceRailwayReconcile(input.context.env, "sync:done", `scope=${scope}`);
|
|
1926
3863
|
return {
|
|
1927
3864
|
scope,
|
|
1928
|
-
services:
|
|
3865
|
+
services: syncedServices,
|
|
1929
3866
|
secrets: Object.keys(sync.secrets),
|
|
1930
3867
|
variables: Object.keys(sync.variables),
|
|
1931
3868
|
dryRun,
|
|
1932
|
-
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
|
|
1933
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);
|
|
1934
4095
|
}
|
|
1935
4096
|
async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
1936
4097
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
@@ -1939,11 +4100,15 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
|
1939
4100
|
if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
|
|
1940
4101
|
return;
|
|
1941
4102
|
}
|
|
1942
|
-
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
|
+
);
|
|
1943
4107
|
if (!firstService?.project || !firstService.environment) {
|
|
4108
|
+
await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, null);
|
|
1944
4109
|
return;
|
|
1945
4110
|
}
|
|
1946
|
-
|
|
4111
|
+
await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, firstService.project.id);
|
|
1947
4112
|
let postgresService;
|
|
1948
4113
|
try {
|
|
1949
4114
|
postgresService = (await ensureRailwayPostgresService({
|
|
@@ -1968,12 +4133,12 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
|
1968
4133
|
mountPath: "/var/lib/postgresql/data",
|
|
1969
4134
|
env: topology.env
|
|
1970
4135
|
});
|
|
1971
|
-
if (postgresVolume.
|
|
1972
|
-
throw new Error(`observed volume
|
|
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`);
|
|
1973
4138
|
}
|
|
1974
4139
|
} catch (error) {
|
|
1975
4140
|
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
1976
|
-
throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name ${serviceName}
|
|
4141
|
+
throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name for ${serviceName}: ${detail}`);
|
|
1977
4142
|
}
|
|
1978
4143
|
const services = await listRailwayServices({ projectId: firstService.project.id, env: topology.env });
|
|
1979
4144
|
for (const service of services) {
|
|
@@ -1988,8 +4153,17 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
|
1988
4153
|
}).catch(() => ({}));
|
|
1989
4154
|
const looksLikePostgres = typeof variables.DATABASE_URL === "string" && typeof variables.PGHOST === "string" && typeof variables.PGUSER === "string" && typeof variables.PGPASSWORD === "string";
|
|
1990
4155
|
if (looksLikePostgres) {
|
|
1991
|
-
traceRailwayReconcile(topology.env, "sync:postgres:
|
|
1992
|
-
|
|
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
|
+
});
|
|
1993
4167
|
}
|
|
1994
4168
|
}
|
|
1995
4169
|
const detachedPostgresVolumes = await listRailwayVolumes({
|
|
@@ -1997,36 +4171,29 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
|
1997
4171
|
env: topology.env
|
|
1998
4172
|
});
|
|
1999
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
|
+
}
|
|
2000
4178
|
const postgresInstances = volume.instances.filter(
|
|
2001
4179
|
(instance) => instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
|
|
2002
4180
|
);
|
|
4181
|
+
const activePostgresInstances = postgresInstances.filter((instance) => Boolean(instance.serviceId));
|
|
2003
4182
|
const attachedToCanonical = postgresInstances.some((instance) => instance.serviceId === postgresService.id);
|
|
2004
4183
|
const detached = postgresInstances.length > 0 && postgresInstances.every((instance) => !instance.serviceId);
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
}
|
|
2018
|
-
if (!deleted) {
|
|
2019
|
-
recordRailwayProviderDrift(input, scope, {
|
|
2020
|
-
kind: "railway.detached-postgres-volume",
|
|
2021
|
-
action: "delete",
|
|
2022
|
-
status: "blocked",
|
|
2023
|
-
projectId: firstService.project.id,
|
|
2024
|
-
environmentId: firstService.environment.id,
|
|
2025
|
-
volumeId: volume.id,
|
|
2026
|
-
volumeName: volume.name ?? null,
|
|
2027
|
-
reason: "Railway still reports the detached PostgreSQL volume after delete reconciliation."
|
|
2028
|
-
});
|
|
2029
|
-
}
|
|
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
|
+
});
|
|
2030
4197
|
}
|
|
2031
4198
|
}
|
|
2032
4199
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
@@ -2043,6 +4210,61 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
|
|
|
2043
4210
|
await new Promise((resolve2) => setTimeout(resolve2, 3e3));
|
|
2044
4211
|
}
|
|
2045
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
|
+
}
|
|
2046
4268
|
async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
2047
4269
|
let attempt = 0;
|
|
2048
4270
|
for (; ; ) {
|
|
@@ -2058,15 +4280,19 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
|
2058
4280
|
includeVariables: false
|
|
2059
4281
|
});
|
|
2060
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
|
+
);
|
|
2061
4287
|
const configured = Boolean(
|
|
2062
|
-
|
|
4288
|
+
configuredService && (configuredService.serviceName || configuredService.serviceId) && (configuredService.projectName || configuredService.projectId) && (usesPortableSource || existsSync(resolve(configuredService.rootDir)))
|
|
2063
4289
|
);
|
|
2064
4290
|
return {
|
|
2065
|
-
exists: Boolean(entry?.
|
|
4291
|
+
exists: Boolean(entry?.project && entry?.environment && entry?.service),
|
|
2066
4292
|
status: entry?.project && entry?.environment && entry?.service && configured ? "ready" : "pending",
|
|
2067
4293
|
live: {
|
|
2068
4294
|
...persisted ?? {},
|
|
2069
|
-
...
|
|
4295
|
+
...configuredService ?? {},
|
|
2070
4296
|
project: entry?.project ?? null,
|
|
2071
4297
|
environment: entry?.environment ?? null,
|
|
2072
4298
|
service: entry?.service ?? null,
|
|
@@ -2090,17 +4316,33 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
|
|
|
2090
4316
|
}
|
|
2091
4317
|
}
|
|
2092
4318
|
}
|
|
2093
|
-
function collectRailwayEnvironmentSync(input) {
|
|
4319
|
+
function collectRailwayEnvironmentSync(input, valuesOverlay = {}) {
|
|
2094
4320
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
2095
|
-
const values = resolveReconcileEnvironmentValues(input, scope);
|
|
2096
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
|
+
}
|
|
2097
4337
|
const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
|
|
2098
4338
|
const serviceTargetsEntry = (entry, serviceKey) => {
|
|
2099
4339
|
const targets = Array.isArray(entry.serviceTargets) ? entry.serviceTargets.map((value) => String(value).trim()).filter(Boolean) : [];
|
|
2100
4340
|
return targets.length === 0 || targets.includes(serviceKey);
|
|
2101
4341
|
};
|
|
2102
|
-
const entriesForService = (target, serviceKey) => registry.entries.filter(
|
|
2103
|
-
|
|
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);
|
|
2104
4346
|
const secrets = Object.fromEntries(aggregateEntries("railway-secret"));
|
|
2105
4347
|
const variables = Object.fromEntries(aggregateEntries("railway-var"));
|
|
2106
4348
|
const apiOnlySecrets = {};
|
|
@@ -2109,13 +4351,13 @@ function collectRailwayEnvironmentSync(input) {
|
|
|
2109
4351
|
const treeseedDatabaseService = treeseedDatabase?.service;
|
|
2110
4352
|
const treeseedDatabaseServiceName = treeseedDatabase?.serviceName ?? "";
|
|
2111
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}}` : "";
|
|
2112
|
-
if (typeof values.
|
|
2113
|
-
secrets.
|
|
2114
|
-
apiOnlySecrets.
|
|
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;
|
|
2115
4357
|
}
|
|
2116
|
-
if (typeof values.
|
|
2117
|
-
variables.
|
|
2118
|
-
apiOnlyVariables.
|
|
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;
|
|
2119
4361
|
}
|
|
2120
4362
|
const siteDataDb = state.d1Databases?.SITE_DATA_DB;
|
|
2121
4363
|
let apiD1DatabaseId = siteDataDb?.databaseId;
|
|
@@ -2134,22 +4376,176 @@ function collectRailwayEnvironmentSync(input) {
|
|
|
2134
4376
|
scope,
|
|
2135
4377
|
secrets,
|
|
2136
4378
|
variables,
|
|
2137
|
-
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));
|
|
2138
4396
|
return {
|
|
2139
4397
|
secrets: {
|
|
2140
4398
|
...Object.fromEntries(entriesForService("railway-secret", serviceKey)),
|
|
4399
|
+
...serviceRefSecrets,
|
|
2141
4400
|
...treeseedDatabaseUrl && ["api", "operationsRunner"].includes(serviceKey) ? { TREESEED_DATABASE_URL: treeseedDatabaseUrl } : {},
|
|
2142
|
-
...serviceKey === "api" ? apiOnlySecrets : {}
|
|
4401
|
+
...serviceKey === "api" ? apiOnlySecrets : {},
|
|
4402
|
+
...capacitySecrets
|
|
2143
4403
|
},
|
|
2144
4404
|
variables: {
|
|
4405
|
+
...serviceVariables,
|
|
4406
|
+
...serviceRefVariables,
|
|
2145
4407
|
...Object.fromEntries(entriesForService("railway-var", serviceKey)),
|
|
2146
4408
|
...serviceKey === "operationsRunner" ? { TREESEED_MANAGER_ID: scope } : {},
|
|
2147
|
-
...serviceKey === "api" ? apiOnlyVariables : {}
|
|
4409
|
+
...serviceKey === "api" ? apiOnlyVariables : {},
|
|
4410
|
+
...capacityVariables
|
|
2148
4411
|
}
|
|
2149
4412
|
};
|
|
2150
4413
|
}
|
|
2151
4414
|
};
|
|
2152
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
|
+
}
|
|
2153
4549
|
function buildAttachmentDiff(input, observed) {
|
|
2154
4550
|
if (!observed.exists) {
|
|
2155
4551
|
return {
|
|
@@ -2294,7 +4690,13 @@ function verifyCustomDomainUnit(input) {
|
|
|
2294
4690
|
const domain = String(input.unit.spec.domain ?? "").trim();
|
|
2295
4691
|
const live = getCustomDomainState(input, "railway", domain) ?? getPersistedCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
|
|
2296
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;
|
|
2297
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;
|
|
2298
4700
|
return summarizeVerification(input.unit.unitId, [
|
|
2299
4701
|
verificationCheck("custom-domain.exists", "Railway custom domain attachment exists", "cli", {
|
|
2300
4702
|
exists: Boolean(live?.domain),
|
|
@@ -2310,6 +4712,21 @@ function verifyCustomDomainUnit(input) {
|
|
|
2310
4712
|
verificationDnsHost: typeof live?.verificationDnsHost === "string" ? live.verificationDnsHost : null
|
|
2311
4713
|
},
|
|
2312
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)"}).`]
|
|
2313
4730
|
})
|
|
2314
4731
|
]);
|
|
2315
4732
|
}
|
|
@@ -2471,7 +4888,23 @@ async function verifyRailwayUnit(input) {
|
|
|
2471
4888
|
})
|
|
2472
4889
|
]);
|
|
2473
4890
|
}
|
|
2474
|
-
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
|
+
}
|
|
2475
4908
|
const checks = [
|
|
2476
4909
|
verificationCheck("railway.workspace", "Railway workspace is resolved", "api", {
|
|
2477
4910
|
exists: Boolean(topology.workspace.id),
|
|
@@ -2514,7 +4947,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2514
4947
|
issues: startCommandMatches ? [] : ["Railway start command does not match the desired value."]
|
|
2515
4948
|
}));
|
|
2516
4949
|
}
|
|
2517
|
-
const desiredRootDirectory = railwayServiceRootDirectory(input.context.tenantRoot, service);
|
|
4950
|
+
const desiredRootDirectory = service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service);
|
|
2518
4951
|
if (desiredRootDirectory) {
|
|
2519
4952
|
checks.push(verificationCheck("railway.instance.root-directory", "Railway root directory matches desired config", "api", {
|
|
2520
4953
|
exists: Boolean(entry.instance?.id),
|
|
@@ -2525,15 +4958,22 @@ async function verifyRailwayUnit(input) {
|
|
|
2525
4958
|
}));
|
|
2526
4959
|
}
|
|
2527
4960
|
if (service.key === "api") {
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
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
|
+
}));
|
|
2535
4975
|
}
|
|
2536
|
-
if (service.healthcheckPath) {
|
|
4976
|
+
if (runtimeConfigObservable && service.healthcheckPath) {
|
|
2537
4977
|
checks.push(verificationCheck("railway.instance.healthcheck-path", "Railway API healthcheck path matches desired config", "api", {
|
|
2538
4978
|
exists: Boolean(entry.instance?.id),
|
|
2539
4979
|
configured: entry.instance?.healthcheckPath === service.healthcheckPath,
|
|
@@ -2542,7 +4982,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2542
4982
|
issues: entry.instance?.healthcheckPath === service.healthcheckPath ? [] : ["Railway API healthcheck path does not match the desired value."]
|
|
2543
4983
|
}));
|
|
2544
4984
|
}
|
|
2545
|
-
if (service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0) {
|
|
4985
|
+
if (runtimeConfigObservable && service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0) {
|
|
2546
4986
|
checks.push(verificationCheck("railway.instance.healthcheck-timeout", "Railway API healthcheck timeout matches desired config", "api", {
|
|
2547
4987
|
exists: Boolean(entry.instance?.id),
|
|
2548
4988
|
configured: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds,
|
|
@@ -2551,7 +4991,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2551
4991
|
issues: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds ? [] : ["Railway API healthcheck timeout does not match the desired value."]
|
|
2552
4992
|
}));
|
|
2553
4993
|
}
|
|
2554
|
-
if (service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0) {
|
|
4994
|
+
if (runtimeConfigObservable && service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0) {
|
|
2555
4995
|
checks.push(verificationCheck("railway.instance.healthcheck-interval", "Railway API healthcheck interval matches desired config", "api", {
|
|
2556
4996
|
exists: Boolean(entry.instance?.id),
|
|
2557
4997
|
configured: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds,
|
|
@@ -2560,7 +5000,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2560
5000
|
issues: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds ? [] : ["Railway API healthcheck interval does not match the desired value."]
|
|
2561
5001
|
}));
|
|
2562
5002
|
}
|
|
2563
|
-
if (service.restartPolicy) {
|
|
5003
|
+
if (runtimeConfigObservable && service.restartPolicy) {
|
|
2564
5004
|
checks.push(verificationCheck("railway.instance.restart-policy", "Railway API restart policy matches desired config", "api", {
|
|
2565
5005
|
exists: Boolean(entry.instance?.id),
|
|
2566
5006
|
configured: entry.instance?.restartPolicy === service.restartPolicy,
|
|
@@ -2569,7 +5009,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2569
5009
|
issues: entry.instance?.restartPolicy === service.restartPolicy ? [] : ["Railway API restart policy does not match the desired value."]
|
|
2570
5010
|
}));
|
|
2571
5011
|
}
|
|
2572
|
-
if (service.runtimeMode) {
|
|
5012
|
+
if (runtimeConfigObservable && service.runtimeMode) {
|
|
2573
5013
|
checks.push(verificationCheck("railway.instance.runtime-mode", "Railway API runtime mode matches desired config", "api", {
|
|
2574
5014
|
exists: Boolean(entry.instance?.id),
|
|
2575
5015
|
configured: entry.instance?.runtimeMode === service.runtimeMode,
|
|
@@ -2584,7 +5024,7 @@ async function verifyRailwayUnit(input) {
|
|
|
2584
5024
|
const expectedServiceId = entry.service?.id ?? null;
|
|
2585
5025
|
const expectedEnvironmentId = entry.environment?.id ?? null;
|
|
2586
5026
|
const mountedVolume = volumes.find((volume) => volume.instances.some(
|
|
2587
|
-
(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())
|
|
2588
5028
|
)) ?? null;
|
|
2589
5029
|
checks.push(verificationCheck("railway.volume:data", "Railway service has persistent data volume mounted", "api", {
|
|
2590
5030
|
exists: Boolean(mountedVolume),
|
|
@@ -2599,20 +5039,27 @@ async function verifyRailwayUnit(input) {
|
|
|
2599
5039
|
}));
|
|
2600
5040
|
}
|
|
2601
5041
|
for (const [key, value] of Object.entries(sync.variables)) {
|
|
5042
|
+
const matches = currentVariables[key] === value;
|
|
2602
5043
|
checks.push(verificationCheck(`railway.var:${key}`, `Railway variable ${key} exists with the expected value`, "api", {
|
|
2603
|
-
exists: Object.hasOwn(
|
|
2604
|
-
configured:
|
|
5044
|
+
exists: Object.hasOwn(currentVariables, key),
|
|
5045
|
+
configured: matches,
|
|
5046
|
+
ready: matches,
|
|
5047
|
+
verified: matches,
|
|
2605
5048
|
expected: value,
|
|
2606
|
-
observed:
|
|
2607
|
-
issues:
|
|
5049
|
+
observed: currentVariables[key] ?? null,
|
|
5050
|
+
issues: matches ? [] : [`Railway variable ${key} does not match the expected value.`]
|
|
2608
5051
|
}));
|
|
2609
5052
|
}
|
|
2610
5053
|
for (const key of Object.keys(sync.secrets)) {
|
|
5054
|
+
const exists = Object.hasOwn(currentVariables, key);
|
|
2611
5055
|
checks.push(verificationCheck(`railway.secret:${key}`, `Railway secret ${key} exists`, "api", {
|
|
2612
|
-
exists
|
|
5056
|
+
exists,
|
|
5057
|
+
configured: exists,
|
|
5058
|
+
ready: exists,
|
|
5059
|
+
verified: exists,
|
|
2613
5060
|
expected: true,
|
|
2614
|
-
observed:
|
|
2615
|
-
issues:
|
|
5061
|
+
observed: exists,
|
|
5062
|
+
issues: exists ? [] : [`Railway secret ${key} is missing.`]
|
|
2616
5063
|
}));
|
|
2617
5064
|
}
|
|
2618
5065
|
const providerDriftWarnings = railwayProviderDrift(input, scope).map(
|
|
@@ -2681,11 +5128,32 @@ function buildRailwayDiff(input, observed) {
|
|
|
2681
5128
|
}
|
|
2682
5129
|
async function reconcileRailwayUnit(input, diff) {
|
|
2683
5130
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
2684
|
-
const
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
}
|
|
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
|
+
}
|
|
2689
5157
|
for (const key of input.context.session.keys()) {
|
|
2690
5158
|
if (key.startsWith(`railway:topology:${scope}:`)) {
|
|
2691
5159
|
input.context.session.delete(key);
|
|
@@ -2703,6 +5171,67 @@ async function reconcileRailwayUnit(input, diff) {
|
|
|
2703
5171
|
verification: null
|
|
2704
5172
|
};
|
|
2705
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
|
+
}
|
|
2706
5235
|
function buildRailwayAdapter(unitType) {
|
|
2707
5236
|
return {
|
|
2708
5237
|
providerId: "railway",
|
|
@@ -2725,6 +5254,9 @@ function buildRailwayAdapter(unitType) {
|
|
|
2725
5254
|
apply(input) {
|
|
2726
5255
|
return reconcileRailwayUnit(input, input.diff);
|
|
2727
5256
|
},
|
|
5257
|
+
destroy(input) {
|
|
5258
|
+
return destroyRailwayUnit(input);
|
|
5259
|
+
},
|
|
2728
5260
|
requiredPostconditions() {
|
|
2729
5261
|
return [
|
|
2730
5262
|
{ key: "railway.project", description: "Railway project exists" },
|
|
@@ -2827,7 +5359,53 @@ function createRailwayReconcileAdapters() {
|
|
|
2827
5359
|
buildCompositeAdapter("worker-runner-runtime")
|
|
2828
5360
|
];
|
|
2829
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
|
+
}
|
|
2830
5402
|
export {
|
|
5403
|
+
createCapacityProviderReconcileAdapters,
|
|
2831
5404
|
createCloudflareReconcileAdapters,
|
|
2832
|
-
|
|
5405
|
+
createDockerReconcileAdapters,
|
|
5406
|
+
createGitHubReconcileAdapters,
|
|
5407
|
+
createLocalProcessReconcileAdapters,
|
|
5408
|
+
createPackageReconcileAdapters,
|
|
5409
|
+
createRailwayReconcileAdapters,
|
|
5410
|
+
createReleaseGateReconcileAdapters
|
|
2833
5411
|
};
|