@starci/hfs 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/db/migrate.mjs +361 -0
- package/db/post-cutover.mjs +136 -0
- package/emit/db-types.mjs +10 -1
- package/lint/run.mjs +1 -1
- package/package.json +4 -1
- package/release/gate.mjs +298 -0
- package/release/index.mjs +103 -0
- package/release/push.mjs +110 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +3401 -152
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/be/api.yaml +32 -5
- package/runtime/knowledge/patterns/be/cli.yaml +14 -4
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
- package/runtime/knowledge/patterns/be/projections.yaml +4 -2
- package/runtime/knowledge/patterns/be/queues.yaml +11 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
- package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
- package/runtime/knowledge/patterns/be/saga.yaml +20 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +23 -4
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +3113 -43
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +14104 -0
- package/runtime/packages/cli/src/validate-args.mjs +213 -0
- package/runtime/scripts/api/git/add.mjs +5 -0
- package/runtime/scripts/api/git/init.mjs +7 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- package/runtime/scripts/api/git/tag.mjs +5 -0
- package/runtime/scripts/api/node/lib.mjs +20 -0
- package/runtime/scripts/api/node/run-node.mjs +6 -0
- package/runtime/scripts/api/sops/lib.mjs +41 -0
- package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
- package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
- package/runtime/scripts/api/supabase/database-query.mjs +5 -0
- package/runtime/scripts/api/supabase/lib.mjs +44 -0
- package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
- package/runtime/scripts/hfs/app-wiring.mjs +201 -0
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/check-results.mjs +28 -0
- package/runtime/scripts/hfs/check.mjs +46 -47
- package/runtime/scripts/hfs/coverage-from.mjs +70 -0
- package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
- package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/doc-commands.mjs +367 -0
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/example-secrets.mjs +158 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
- package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
- package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
- package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
- package/runtime/scripts/hfs/rules/assets.mjs +79 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
- package/runtime/scripts/hfs/rules/contract.mjs +66 -5
- package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
- package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
- package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
- package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
- package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +286 -7
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
- package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
- package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
- package/runtime/scripts/hfs/rules/database.mjs +9 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
- package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/edition.mjs +9 -8
- package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
- package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
- package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
- package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
- package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
- package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
- package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
- package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
- package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
- package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
- package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
- package/runtime/scripts/hfs/rules/saga.mjs +8 -16
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/services.mjs +4 -4
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
- package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
- package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +41 -1
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
- package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
- package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
- package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
- package/runtime/scripts/hfs/sealed-members.mjs +101 -0
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
- package/runtime/scripts/hfs/secret-edit.mjs +170 -0
- package/runtime/scripts/hfs/secret-init.mjs +127 -19
- package/runtime/scripts/hfs/secret-sops.mjs +38 -6
- package/runtime/scripts/hfs/secret-store.mjs +56 -54
- package/runtime/scripts/hfs/secret.mjs +34 -5
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
- package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
- package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
- package/runtime/scripts/lib/command-tokens.mjs +126 -0
- package/runtime/scripts/lib/compose-values.mjs +11 -0
- package/runtime/scripts/lib/deployment-findings.mjs +7 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/runtime-example.mjs +13 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/sql-ast.mjs +32 -0
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +5 -18
- package/runtime/scripts/lib/trim-end.mjs +8 -0
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/runtime/scripts/lib/workflow-events.mjs +8 -0
- package/scaffold/add-app.mjs +77 -59
- package/scaffold/add-cli-lite.mjs +14 -6
- package/scaffold/add-compose.mjs +79 -0
- package/scaffold/add-database-lite.mjs +14 -0
- package/scaffold/add-fe-overlay.mjs +122 -0
- package/scaffold/add-fe-page.mjs +269 -0
- package/scaffold/add-inbox-lite.mjs +74 -42
- package/scaffold/add-table.mjs +110 -87
- package/scaffold/add.mjs +159 -44
- package/scaffold/app.mjs +124 -39
- package/scaffold/edition-gate.mjs +3 -3
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +2 -2
- package/src/main.mjs +229 -116
- package/stack/age-install.mjs +37 -0
- package/stack/backup.mjs +100 -0
- package/stack/build-args.mjs +42 -0
- package/stack/build.mjs +173 -0
- package/stack/cli.mjs +130 -0
- package/stack/config.mjs +57 -0
- package/stack/context.mjs +57 -0
- package/stack/converge.mjs +107 -0
- package/stack/deploy.mjs +301 -0
- package/stack/digest.mjs +16 -0
- package/stack/docker-transport.mjs +53 -0
- package/stack/edge-alias.mjs +118 -0
- package/stack/errors.mjs +21 -0
- package/stack/image-scan.mjs +152 -0
- package/stack/ingress-create.mjs +114 -0
- package/stack/ingress-publish.mjs +273 -0
- package/stack/ingress.mjs +706 -0
- package/stack/jobs.mjs +51 -0
- package/stack/live-restore.mjs +177 -0
- package/stack/prepare.mjs +266 -0
- package/stack/private-file.mjs +22 -0
- package/stack/provision.mjs +90 -0
- package/stack/prune.mjs +46 -0
- package/stack/release-store.mjs +87 -0
- package/stack/remote.mjs +205 -0
- package/stack/render.mjs +149 -0
- package/stack/retire.mjs +47 -0
- package/stack/sealed.mjs +149 -0
- package/stack/task.mjs +42 -0
- package/stack/verify.mjs +99 -0
- package/sync/ci-commands.mjs +13 -0
- package/sync/index.mjs +108 -52
- package/sync/stack-images.mjs +127 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
- package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
- package/templates/app/codegen-graphql.mjs.partial +13 -2
- package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
- package/templates/app/codegen-prelude.mjs.partial +8 -0
- package/templates/app/gitignore +3 -0
- package/templates/app/gitignore-lite +3 -0
- package/templates/app/package-scripts/package.json +1 -0
- package/templates/app/package-scripts/supabase.json +7 -0
- package/templates/app/package-scripts-lite/package.json +1 -7
- package/templates/app/skeleton/.editorconfig +1 -0
- package/templates/app/skeleton/.gitattributes +7 -1
- package/templates/app/skeleton/.github/dependabot.yml +17 -0
- package/templates/app/skeleton/.nvmrc +1 -1
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
- package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
- package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
- package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
- package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
- package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
- package/templates/app/skeleton/README.md +6 -6
- package/templates/app/skeleton/scripts/codegen.mjs +1 -2
- package/templates/app/skeleton-lite/.editorconfig +16 -0
- package/templates/app/skeleton-lite/.gitattributes +8 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
- package/templates/app/skeleton-lite/.nvmrc +1 -0
- package/templates/app/skeleton-lite/.sops.yaml +3 -2
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +178 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
- package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
- package/templates/app/skeleton-lite/README.md +5 -3
- package/templates/app/skeleton-lite/supabase/config.toml +8 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
- package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +17 -18
- package/templates/be/inbox/database-client.ts.tpl +12 -11
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
- package/templates/be/inbox/event-index.ts.tpl +1 -0
- package/templates/be/inbox/events.json.tpl +10 -0
- package/templates/be/inbox/migration.sql.tpl +1 -0
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
- package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
- package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
- package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
- package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
- package/templates/be/inbox/platform/migration.sql.tpl +48 -0
- package/templates/be/inbox/service.spec.ts.tpl +42 -9
- package/templates/be/inbox/service.ts.tpl +23 -8
- package/templates/be/inbox/webhook-door.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
- package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
- package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
- package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
- package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
- package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
- package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
- package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
- package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
- package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
- package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
- package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
- package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
- package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
- package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
- package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
- package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
- package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
- package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
- package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
- package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
- package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
- package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
- package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
- package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
- package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
- package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
- package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
- package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
- package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
- package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
- package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +11 -9
- package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +2 -0
- package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
- package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +15 -1
- package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -14
- package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
- package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
- package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
- package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
- package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
- package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
- package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
- package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
- package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
- package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
- package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
- package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +14 -18
- package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +2 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +2 -3
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
- package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
- package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
- package/templates/be/table/api/feature.module.ts.tpl +2 -1
- package/templates/be/table/api/feature.options.ts.tpl +2 -0
- package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
- package/templates/be/table/api/graphql.module.ts.tpl +3 -2
- package/templates/be/table/api/graphql.options.ts.tpl +2 -0
- package/templates/be/table/migration.sql.tpl +1 -0
- package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
- package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
- package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
- package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
- package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
- package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
- package/templates/fe/auth/supabase-client.ts.tpl +34 -0
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
- package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
- package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
- package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
- package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
- package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
- package/upgrade/check.mjs +3 -1
- package/upgrade/index.mjs +53 -40
package/stack/build.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// build.mjs - `starci app stack build --env <env> [--push] [--execute]`: builds, from the CURRENT checkout, every image the deploy block declares
|
|
2
|
+
// (docker buildx, linux/amd64, no provenance so the digest is one manifest), passing the public build values of build-args.mjs, and prints
|
|
3
|
+
// `--image <service>=<registry/name@sha256:digest>` lines ready for `stack deploy`. It refuses a dirty tree and a HEAD that carries no release tag
|
|
4
|
+
// (the images must come from the released commit). It never reads registry credentials: pushing uses the operator's own `docker login`.
|
|
5
|
+
// The image name is `<registry host>/<owner>/<project>-<app>:<full sha>`, the owner being the GitHub owner of `origin` (ghcr.io only).
|
|
6
|
+
// With deploy.registry.visibility `public` (the default) every image is scanned for secret material BEFORE it is pushed (image-scan.mjs: the layers of
|
|
7
|
+
// a public package are public), and after the push the real visibility of the package is checked with an ANONYMOUS manifest fetch of the pushed
|
|
8
|
+
// digest; a package that is still private is reported with the one owner step that changes it (GitHub has no API for that) and the run fails.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { buildArgs } from './build-args.mjs';
|
|
13
|
+
import { firstLine, lines as splitLines, tool } from './context.mjs';
|
|
14
|
+
import { failure, refusal } from './errors.mjs';
|
|
15
|
+
import { requireAgeIdentity } from './sealed.mjs';
|
|
16
|
+
import { scanImage } from './image-scan.mjs';
|
|
17
|
+
|
|
18
|
+
const RELEASE_TAG = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
|
|
19
|
+
const GITHUB_REMOTE = /github\.com[:/]([A-Za-z0-9._-]+)\/[A-Za-z0-9._-]+?(?:\.git)?$/u;
|
|
20
|
+
const BUILD_ARG = /^([A-Z][A-Z0-9_]*)=(.*)$/u;
|
|
21
|
+
const SIDES = ['be', 'fe'];
|
|
22
|
+
const MAX_HITS_SHOWN = 20;
|
|
23
|
+
const MANIFEST_TYPES = ['application/vnd.oci.image.index.v1+json', 'application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json', 'application/vnd.docker.distribution.manifest.v2+json'];
|
|
24
|
+
|
|
25
|
+
const git = (ctx, config, args) => tool(ctx, 'git', args, { cwd: config.repoRoot });
|
|
26
|
+
|
|
27
|
+
/** The release facts of the checkout: { sha, tag } or a refusal (dirty tree, no release tag). */
|
|
28
|
+
function checkout(ctx, config) {
|
|
29
|
+
const status = git(ctx, config, ['status', '--porcelain']);
|
|
30
|
+
if (status.status !== 0) throw failure('STACK_BUILD_GIT', `git status failed: ${firstLine(status.stderr)}`);
|
|
31
|
+
const dirty = splitLines(status.stdout);
|
|
32
|
+
if (dirty.length > 0) throw refusal('STACK_BUILD_DIRTY', `the working tree is not clean (${dirty.length} path(s), first: ${dirty[0]}): images must come from the released commit`);
|
|
33
|
+
const sha = splitLines(git(ctx, config, ['rev-parse', 'HEAD']).stdout)[0] ?? '';
|
|
34
|
+
if (!/^[0-9a-f]{40}$/u.test(sha)) throw failure('STACK_BUILD_GIT', 'cannot read HEAD');
|
|
35
|
+
const tag = splitLines(git(ctx, config, ['tag', '--points-at', 'HEAD']).stdout).find((name) => RELEASE_TAG.test(name));
|
|
36
|
+
if (!tag) throw refusal('STACK_BUILD_UNTAGGED', 'HEAD carries no release tag (vMAJOR.MINOR.PATCH): build only the released commit');
|
|
37
|
+
return { sha, tag };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ownerOf(ctx, config) {
|
|
41
|
+
const remote = git(ctx, config, ['remote', 'get-url', 'origin']);
|
|
42
|
+
const match = GITHUB_REMOTE.exec(firstLine(remote.stdout));
|
|
43
|
+
if (remote.status !== 0 || !match) throw refusal('STACK_BUILD_OWNER', 'origin is not a github.com remote: the image owner cannot be derived');
|
|
44
|
+
return match[1].toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile; ambiguity and absence are refusals. */
|
|
48
|
+
function dockerfileOf(config, app) {
|
|
49
|
+
const found = SIDES.map((side) => `${side}/apps/${app}/Dockerfile`).filter((file) => fs.existsSync(path.join(config.repoRoot, file)));
|
|
50
|
+
if (found.length === 0) throw refusal('STACK_BUILD_NO_DOCKERFILE', `no Dockerfile for app ${app} (be/apps/${app}/Dockerfile or fe/apps/${app}/Dockerfile)`);
|
|
51
|
+
if (found.length > 1) throw refusal('STACK_BUILD_AMBIGUOUS', `app ${app} has a Dockerfile on both sides: ${found.join(', ')}`);
|
|
52
|
+
return found[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function argsOf(ctx, config, app, hasBuildArgs) {
|
|
56
|
+
if (!hasBuildArgs) return [];
|
|
57
|
+
const result = await buildArgs(ctx, config, { app });
|
|
58
|
+
return result.lines.map((line) => BUILD_ARG.exec(line)).filter(Boolean).map((match) => [match[1], match[2]]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function plan(config, { sha, owner }) {
|
|
62
|
+
return Object.entries(config.images).map(([service, entry]) => ({
|
|
63
|
+
service,
|
|
64
|
+
app: entry.app,
|
|
65
|
+
dockerfile: dockerfileOf(config, entry.app),
|
|
66
|
+
ref: `${config.registry.host}/${owner}/${config.project}-${entry.app}:${sha}`,
|
|
67
|
+
hasBuildArgs: Object.keys(entry.buildArgs.values).length > 0 || entry.buildArgs.sealed !== null,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildOne(ctx, config, item, args, push) {
|
|
72
|
+
const metadata = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'stack-build-')), 'metadata.json');
|
|
73
|
+
const argv = ['buildx', 'build', '--platform', 'linux/amd64', '--provenance=false', '--file', item.dockerfile, '--tag', item.ref, '--metadata-file', metadata,
|
|
74
|
+
...args.flatMap(([key, value]) => ['--build-arg', `${key}=${value}`]), push ? '--push' : '--load', '.'];
|
|
75
|
+
const run = tool(ctx, 'docker', argv, { cwd: config.repoRoot });
|
|
76
|
+
let digest = null;
|
|
77
|
+
try { digest = JSON.parse(fs.readFileSync(metadata, 'utf8'))['containerimage.digest'] ?? null; } catch { digest = null; }
|
|
78
|
+
fs.rmSync(path.dirname(metadata), { recursive: true, force: true });
|
|
79
|
+
if (run.status !== 0) return { ok: false, error: firstLine(run.stderr) || 'docker buildx failed' };
|
|
80
|
+
return { ok: true, digest };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `docker push` of a scanned local image; the digest is the one docker prints. */
|
|
84
|
+
function pushOne(ctx, config, item) {
|
|
85
|
+
const run = tool(ctx, 'docker', ['push', item.ref], { cwd: config.repoRoot });
|
|
86
|
+
if (run.status !== 0) return { ok: false, error: firstLine(run.stderr) || 'docker push failed' };
|
|
87
|
+
return { ok: true, digest: /digest:\s*(sha256:[0-9a-f]{64})/u.exec(String(run.stdout))?.[1] ?? null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scanRefusal(item, scan) {
|
|
91
|
+
const shown = scan.hits.slice(0, MAX_HITS_SHOWN).map((hit) => ` ${hit.kind}: ${hit.where} (${hit.why})`);
|
|
92
|
+
const more = scan.hits.length > MAX_HITS_SHOWN ? [` ... and ${scan.hits.length - MAX_HITS_SHOWN} more`] : [];
|
|
93
|
+
const head = `REFUSED to push ${item.service}: the image would be public and the scan found ${scan.hits.length} hit(s) and ${scan.errors.length} failed step(s) (places only, never the text)`;
|
|
94
|
+
return [head, ...shown, ...more, ...scan.errors.map((error) => ` scan error: ${error}`)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One image: built (and pushed in the same step when private); built, scanned and only then pushed when public. Returns { ok, digest, lines }. */
|
|
98
|
+
function processImage(ctx, config, item, args, { push }) {
|
|
99
|
+
const isPublic = config.registry.visibility === 'public';
|
|
100
|
+
const built = buildOne(ctx, config, item, args, push && !isPublic);
|
|
101
|
+
if (!built.ok) return { ok: false, lines: [`build of ${item.service} FAILED: ${built.error}`] };
|
|
102
|
+
if (!isPublic) return { ok: true, digest: built.digest, lines: [] };
|
|
103
|
+
const scan = scanImage(ctx, item.ref, { allowedArgs: args.map(([key]) => key) });
|
|
104
|
+
if (!scan.ok) return { ok: false, lines: scanRefusal(item, scan) };
|
|
105
|
+
const clean = `scanned ${item.service}: no secret material found`;
|
|
106
|
+
if (!push) return { ok: true, digest: null, lines: [clean] };
|
|
107
|
+
const pushed = pushOne(ctx, config, item);
|
|
108
|
+
if (!pushed.ok) return { ok: false, lines: [clean, `push of ${item.service} FAILED: ${pushed.error}`] };
|
|
109
|
+
return { ok: true, digest: pushed.digest, lines: [clean] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ownerStep = (name) => `owner step: GitHub -> Packages -> ${name} -> Package settings -> Change visibility -> Public (GitHub has no API for this)`;
|
|
113
|
+
|
|
114
|
+
/** 'public' when an ANONYMOUS client can fetch the manifest of the pushed digest, 'private' when the registry refuses it, 'unknown' on a network error. */
|
|
115
|
+
async function visibilityOf(ctx, host, repository, digest) {
|
|
116
|
+
try {
|
|
117
|
+
const token = await ctx.fetch(`https://${host}/token?scope=repository:${repository}:pull&service=${host}`, { signal: AbortSignal.timeout(15000) });
|
|
118
|
+
const bearer = token.ok ? (await token.json()).token : null;
|
|
119
|
+
const headers = { Accept: MANIFEST_TYPES.join(', '), ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}) };
|
|
120
|
+
const manifest = await ctx.fetch(`https://${host}/v2/${repository}/manifests/${digest}`, { headers, signal: AbortSignal.timeout(15000) });
|
|
121
|
+
return manifest.status === 200 ? 'public' : 'private';
|
|
122
|
+
} catch {
|
|
123
|
+
return 'unknown';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** After the push: the real visibility of every pushed package against the declared one. Returns { ok, lines, actual }. */
|
|
128
|
+
async function checkVisibility(ctx, config, images) {
|
|
129
|
+
const declared = config.registry.visibility;
|
|
130
|
+
const lines = [];
|
|
131
|
+
const actual = {};
|
|
132
|
+
let ok = true;
|
|
133
|
+
for (const [service, ref] of Object.entries(images)) {
|
|
134
|
+
const [name, digest] = ref.split('@');
|
|
135
|
+
const repository = name.slice(config.registry.host.length + 1);
|
|
136
|
+
const packageName = repository.slice(repository.indexOf('/') + 1);
|
|
137
|
+
actual[service] = await visibilityOf(ctx, config.registry.host, repository, digest);
|
|
138
|
+
if (actual[service] === 'unknown') lines.push(`visibility of ${packageName} could not be checked (network)`);
|
|
139
|
+
else if (actual[service] === declared) lines.push(`visibility of ${packageName}: ${actual[service]}, as declared`);
|
|
140
|
+
else if (declared === 'public') {
|
|
141
|
+
ok = false;
|
|
142
|
+
lines.push(`visibility of ${packageName} is PRIVATE but deploy.registry.visibility is public`, ownerStep(packageName));
|
|
143
|
+
} else lines.push(`note: ${packageName} is public although deploy.registry.visibility is private; change it in Package settings or declare public`);
|
|
144
|
+
}
|
|
145
|
+
return { ok, lines, actual };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `build`: dry run unless execute; pushing needs --push as well. */
|
|
149
|
+
export async function buildImages(ctx, config, { push = false, execute = false } = {}) {
|
|
150
|
+
if (Object.keys(config.images).length === 0) throw refusal('STACK_BUILD_NO_IMAGES', 'the deploy block declares no images');
|
|
151
|
+
if (config.registry.host !== 'ghcr.io') throw refusal('STACK_BUILD_REGISTRY', `build supports ghcr.io only (deploy.registry.host is ${config.registry.host})`);
|
|
152
|
+
const release = checkout(ctx, config);
|
|
153
|
+
const items = plan(config, { sha: release.sha, owner: ownerOf(ctx, config) });
|
|
154
|
+
const lines = [`release ${release.tag} at ${release.sha.slice(0, 12)}: ${items.length} image(s), registry ${config.registry.host} (${config.registry.visibility})`, ...items.map((item) => ` ${item.service}: ${item.dockerfile} -> ${item.ref}`)];
|
|
155
|
+
if (!execute) {
|
|
156
|
+
const scan = config.registry.visibility === 'public' ? ', scan for secret material' : '';
|
|
157
|
+
return { ok: true, lines: [...lines, `dry run: would build${scan}${push ? ' and push' : ' (local only, no digest without --push)'}; nothing was built`], data: { tag: release.tag, images: {} } };
|
|
158
|
+
}
|
|
159
|
+
if (items.some((item) => config.images[item.service].buildArgs.sealed !== null)) requireAgeIdentity(ctx, config, 'build');
|
|
160
|
+
const images = {};
|
|
161
|
+
for (const item of items) {
|
|
162
|
+
const outcome = processImage(ctx, config, item, await argsOf(ctx, config, item.app, item.hasBuildArgs), { push });
|
|
163
|
+
lines.push(...outcome.lines);
|
|
164
|
+
if (!outcome.ok) return { ok: false, lines, data: { tag: release.tag, images } };
|
|
165
|
+
if (push && !outcome.digest) return { ok: false, lines: [...lines, `${item.service} was pushed but docker reported no digest`], data: { tag: release.tag, images } };
|
|
166
|
+
if (push) images[item.service] = `${item.ref.slice(0, item.ref.lastIndexOf(':'))}@${outcome.digest}`;
|
|
167
|
+
lines.push(push ? `pushed ${item.service}: ${images[item.service]}` : `built ${item.service} (local image ${item.ref}; push it with --push to get a digest)`);
|
|
168
|
+
}
|
|
169
|
+
const visibility = push ? await checkVisibility(ctx, config, images) : { ok: true, lines: [], actual: {} };
|
|
170
|
+
lines.push(...visibility.lines);
|
|
171
|
+
if (push) lines.push('', 'for the deploy:', `${Object.entries(images).map(([service, ref]) => `--image ${service}=${ref}`).join(' ')} --source-sha ${release.sha}`);
|
|
172
|
+
return { ok: visibility.ok, lines, data: { tag: release.tag, sha: release.sha, images, visibility: visibility.actual } };
|
|
173
|
+
}
|
package/stack/cli.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// cli.mjs - `starci app stack <action>` for the Swarm deploy tooling: flags, dry run versus --execute, dispatch, text and --json output.
|
|
2
|
+
// starci app stack prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs|task --env <env> [flags]
|
|
3
|
+
// (up, down and status are the test-world verbs and are dispatched by packages/hfs/src/main.mjs.)
|
|
4
|
+
// Every action module exports one async function (ctx, config, options) -> { ok, lines, data }; a refusal throws StackError.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { stackContext } from './context.mjs';
|
|
7
|
+
import { loadDeployConfig } from './config.mjs';
|
|
8
|
+
import { withDockerTransport } from './docker-transport.mjs';
|
|
9
|
+
import { StackError, EXIT, refusal } from './errors.mjs';
|
|
10
|
+
|
|
11
|
+
/** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
|
|
12
|
+
const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune']);
|
|
13
|
+
|
|
14
|
+
export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
|
|
15
|
+
|
|
16
|
+
const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push']);
|
|
17
|
+
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project']);
|
|
18
|
+
const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
|
|
19
|
+
|
|
20
|
+
/** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
|
|
21
|
+
export function parseFlags(argv) {
|
|
22
|
+
const flags = {};
|
|
23
|
+
const positional = [];
|
|
24
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
25
|
+
const arg = argv[index];
|
|
26
|
+
if (BOOLEAN.has(arg)) flags[arg.slice(2)] = true;
|
|
27
|
+
else if (VALUE.has(arg)) {
|
|
28
|
+
const value = argv[index + 1];
|
|
29
|
+
if (value === undefined || value.startsWith('--')) throw refusal('STACK_USAGE', `${arg} needs a value`);
|
|
30
|
+
index += 1;
|
|
31
|
+
const key = arg.slice(2);
|
|
32
|
+
flags[key] = REPEAT.has(arg) ? [...(flags[key] ?? []), value] : value;
|
|
33
|
+
} else if (arg.startsWith('--')) throw refusal('STACK_USAGE', `unknown flag ${arg}`);
|
|
34
|
+
else positional.push(arg);
|
|
35
|
+
}
|
|
36
|
+
return { flags, positional };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `--image api=ghcr.io/o/x@sha256:...` (repeatable or comma separated) -> { api: 'ghcr.io/o/x@sha256:...' }. */
|
|
40
|
+
export function parseImages(values = []) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const item of values.flatMap((value) => value.split(','))) {
|
|
43
|
+
const at = item.indexOf('=');
|
|
44
|
+
const service = item.slice(0, at);
|
|
45
|
+
if (at < 1 || at === item.length - 1 || Object.hasOwn(out, service)) throw refusal('STACK_USAGE', `--image takes <service>=<reference>, once per service (got ${service || item})`);
|
|
46
|
+
out[service] = item.slice(at + 1);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const number = (flags, name) => {
|
|
52
|
+
if (flags[name] === undefined) return undefined;
|
|
53
|
+
const value = Number(flags[name]);
|
|
54
|
+
if (!Number.isFinite(value) || value < 0) throw refusal('STACK_USAGE', `--${name} must be a number of seconds`);
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The options object each action module receives, from the parsed flags. */
|
|
59
|
+
const OPTIONS = {
|
|
60
|
+
prepare: ({ flags }) => ({ execute: flags.execute === true, initSwarm: flags['init-swarm'] === true, ownerApproved: flags['owner-approved'] === true, fixLiveRestore: flags['fix-live-restore'] === true }),
|
|
61
|
+
doctor: () => ({}),
|
|
62
|
+
connect: ({ flags }) => ({ execute: flags.execute === true }),
|
|
63
|
+
disconnect: ({ flags }) => ({ execute: flags.execute === true }),
|
|
64
|
+
'build-args': ({ flags }) => ({ app: flags.app }),
|
|
65
|
+
render: ({ flags, cwd }) => ({
|
|
66
|
+
images: parseImages(flags.image), sourceSha: flags['source-sha'], previous: flags.previous ?? null,
|
|
67
|
+
out: flags.out ? path.resolve(cwd, flags.out) : null, allowUnpinned: flags['allow-unpinned'] === true,
|
|
68
|
+
extraCompose: (flags['extra-compose'] ?? []).map((file) => path.resolve(cwd, file)),
|
|
69
|
+
}),
|
|
70
|
+
deploy: ({ flags, cwd }) => ({
|
|
71
|
+
images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
|
|
72
|
+
out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
|
|
73
|
+
}),
|
|
74
|
+
converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
|
|
75
|
+
verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
|
|
76
|
+
rollback: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, timeoutSeconds: number(flags, 'timeout') }),
|
|
77
|
+
prune: ({ flags, cwd }) => ({ keepFiles: (flags.keep ?? []).map((file) => path.resolve(cwd, file)), execute: flags.execute === true }),
|
|
78
|
+
backup: ({ flags }) => ({ swarmState: flags['swarm-state'] === true, execute: flags.execute === true }),
|
|
79
|
+
ingress: ({ flags, positional }) => ({ action: positional[0], execute: flags.execute === true, args: positional.slice(1) }),
|
|
80
|
+
build: ({ flags }) => ({ push: flags.push === true, execute: flags.execute === true }),
|
|
81
|
+
accept: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, retrySeconds: number(flags, 'retry') }),
|
|
82
|
+
retire: ({ flags }) => ({ projects: flags.project ?? [], execute: flags.execute === true }),
|
|
83
|
+
jobs: ({ flags, positional }) => ({ action: positional[0], args: positional.slice(1), execute: flags.execute === true }),
|
|
84
|
+
task: ({ flags, positional }) => ({ name: positional[0], args: positional.slice(1), execute: flags.execute === true }),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const MODULES = {
|
|
88
|
+
prepare: ['./prepare.mjs', 'prepareHost'], doctor: ['./prepare.mjs', 'doctorHost'],
|
|
89
|
+
connect: ['./remote.mjs', 'connectCi'], disconnect: ['./remote.mjs', 'disconnectCi'],
|
|
90
|
+
'build-args': ['./build-args.mjs', 'buildArgs'], render: ['./render.mjs', 'renderRelease'],
|
|
91
|
+
deploy: ['./deploy.mjs', 'deployRelease'], converge: ['./converge.mjs', 'convergeStack'],
|
|
92
|
+
verify: ['./verify.mjs', 'verifyStack'], rollback: ['./deploy.mjs', 'rollbackRelease'],
|
|
93
|
+
prune: ['./prune.mjs', 'pruneStack'], backup: ['./backup.mjs', 'backupVolumes'], jobs: ['./jobs.mjs', 'runJobs'], task: ['./task.mjs', 'runTask'],
|
|
94
|
+
ingress: ['./ingress.mjs', 'runIngress'],
|
|
95
|
+
retire: ['./retire.mjs', 'retireCompose'],
|
|
96
|
+
build: ['./build.mjs', 'buildImages'],
|
|
97
|
+
accept: ['./deploy.mjs', 'acceptRelease'],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
async function handlerOf(action, importer) {
|
|
101
|
+
const [file, name] = MODULES[action];
|
|
102
|
+
return (await importer(file))[name];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function print(ctx, action, flags, result) {
|
|
106
|
+
if (flags.json) ctx.stdout(`${JSON.stringify({ verb: action, ok: result.ok, ...result.data }, null, 2)}\n`);
|
|
107
|
+
else for (const line of result.lines ?? []) ctx.stdout(`${line}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Runs one action; returns the exit code. `io.importer` lets a spec load fake action modules. */
|
|
111
|
+
export async function stackMain(argv, io = {}) {
|
|
112
|
+
const ctx = stackContext(io);
|
|
113
|
+
try {
|
|
114
|
+
const { flags, positional } = parseFlags(argv.slice(1));
|
|
115
|
+
const action = argv[0];
|
|
116
|
+
if (!ACTIONS.includes(action)) throw refusal('STACK_USAGE', `starci app stack takes ${[...ACTIONS, 'up', 'down', 'status'].join(', ')}`);
|
|
117
|
+
const config = loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' });
|
|
118
|
+
const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
|
|
119
|
+
const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
|
|
120
|
+
const sshCmd = flags['ssh-cmd'] ?? null;
|
|
121
|
+
const call = (context) => handler(context, config, { ...options, sshCmd });
|
|
122
|
+
const result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(ctx, config, { sshCmd }, call) : await call(ctx);
|
|
123
|
+
print(ctx, action, flags, result);
|
|
124
|
+
return result.ok ? EXIT.ok : EXIT.failed;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!(error instanceof StackError)) throw error;
|
|
127
|
+
ctx.stderr(`starci app stack: ${error.message}\n`);
|
|
128
|
+
return error.exit;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/stack/config.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// config.mjs - loads `environments.<env>.deploy` of .starcistacks/application-stacks.yaml for the stack verbs.
|
|
2
|
+
// The block is read and judged by the same module `starci app check` uses (runtime/scripts/hfs/stack-deploy-config.mjs).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
6
|
+
import { DEPLOY_CONFIG_CODE, NAME, normalizeDeploy } from '../runtime/scripts/hfs/stack-deploy-config.mjs';
|
|
7
|
+
import { refusal } from './errors.mjs';
|
|
8
|
+
|
|
9
|
+
export const STACKS_DIR = '.starcistacks';
|
|
10
|
+
export const STACKS_FILE = 'application-stacks.yaml';
|
|
11
|
+
|
|
12
|
+
const readYaml = (file, what) => {
|
|
13
|
+
let source;
|
|
14
|
+
try { source = fs.readFileSync(file, 'utf8'); } catch { throw refusal('STACKS_DEPLOY_FILE_MISSING', `${what} (${file}) is not readable`); }
|
|
15
|
+
try { return parseYaml(source); } catch { throw refusal('STACKS_DEPLOY_FILE_INVALID', `${what} is not valid YAML`); }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const projectOf = (repoRoot) => {
|
|
19
|
+
const doc = JSON.parse(fs.readFileSync(path.join(repoRoot, 'hfs.json'), 'utf8'));
|
|
20
|
+
return String(doc.project);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sealedMembers = (secretsDir) => {
|
|
24
|
+
try { return new Set(fs.readdirSync(secretsDir).filter((name) => name.endsWith('.enc')).map((name) => name.slice(0, -4))); } catch { return new Set(); }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const stackServices = (stackFile) => {
|
|
28
|
+
const model = readYaml(stackFile, 'the stack source');
|
|
29
|
+
return new Set(Object.keys(model?.services ?? {}));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The normalised deploy configuration of one environment, with absolute paths:
|
|
34
|
+
* { env, project, stack, repoRoot, envDir, stackFile, secretsDir, members, services, ...deploy block }.
|
|
35
|
+
* Throws StackError (exit 2) naming every problem when the block is missing or invalid.
|
|
36
|
+
*/
|
|
37
|
+
export function loadDeployConfig({ repoRoot, env }) {
|
|
38
|
+
if (!NAME.test(String(env))) throw refusal(DEPLOY_CONFIG_CODE, `--env ${env} must match ${NAME}`);
|
|
39
|
+
const declaration = readYaml(path.join(repoRoot, STACKS_DIR, STACKS_FILE), STACKS_FILE);
|
|
40
|
+
const environment = declaration?.environments?.[env];
|
|
41
|
+
if (!environment) throw refusal('STACKS_DEPLOY_ENV_UNKNOWN', `${STACKS_FILE} declares no environment ${env}`);
|
|
42
|
+
if (environment.runtime !== 'docker-swarm') throw refusal('STACKS_DEPLOY_NOT_SWARM', `environment ${env} runs ${environment.runtime}; stack deploy verbs need docker-swarm`);
|
|
43
|
+
if (environment.deploy === undefined) throw refusal('STACKS_DEPLOY_UNDECLARED', `environment ${env} has no deploy block in ${STACKS_FILE}`);
|
|
44
|
+
const envDir = path.join(repoRoot, STACKS_DIR, env);
|
|
45
|
+
const stackFile = path.join(envDir, String(environment.composeFiles?.[0] ?? ''));
|
|
46
|
+
const secretsDir = path.join(envDir, 'secrets');
|
|
47
|
+
const services = stackServices(stackFile);
|
|
48
|
+
const members = sealedMembers(secretsDir);
|
|
49
|
+
const probe = { exists: (rel) => fs.existsSync(path.join(envDir, rel)), services, members };
|
|
50
|
+
const project = projectOf(repoRoot);
|
|
51
|
+
const { config, problems } = normalizeDeploy(environment.deploy, { project, probe });
|
|
52
|
+
if (!config) {
|
|
53
|
+
const first = problems.slice(0, 6).map((item) => `${item.at}: ${item.message}`).join('; ');
|
|
54
|
+
throw refusal(DEPLOY_CONFIG_CODE, `${problems.length} problem${problems.length === 1 ? '' : 's'} in environments.${env}.deploy: ${first}`);
|
|
55
|
+
}
|
|
56
|
+
return { env, project, repoRoot, envDir, stackFile, secretsDir, members, services, ...config };
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// context.mjs - the seams every stack module runs through: spawning tools, sleeping, the clock and the output.
|
|
2
|
+
// Production uses the real implementations; the specs inject fakes (tests/helpers/stack-fakes.mjs).
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { defaultSops } from '../runtime/scripts/hfs/secret-sops.mjs';
|
|
5
|
+
|
|
6
|
+
const MAX_BUFFER = 1 << 24;
|
|
7
|
+
|
|
8
|
+
const realSpawn = (command, args, options = {}) => {
|
|
9
|
+
const run = spawnSync(command, args, { encoding: 'utf8', maxBuffer: MAX_BUFFER, ...options });
|
|
10
|
+
return { status: run.status, stdout: run.stdout ?? '', stderr: run.stderr ?? '', error: run.error };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const realSleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The context of one stack invocation.
|
|
17
|
+
* cwd, env the app root and the process environment
|
|
18
|
+
* spawn (command, args, { input, env, cwd }) -> { status, stdout, stderr, error }; never a shell
|
|
19
|
+
* sleep, now await sleep(ms); now() -> epoch ms
|
|
20
|
+
* stdout, stderr text writers
|
|
21
|
+
* fetch the HTTP seam of verify (url, init) -> Response
|
|
22
|
+
* sops the secret seam (decrypt(file, format) -> map; decryptBinary(file) -> text); `io.sops` or, on first use, the app's own sops/age tools
|
|
23
|
+
*/
|
|
24
|
+
export function stackContext(io = {}) {
|
|
25
|
+
const cwd = io.cwd ?? process.cwd();
|
|
26
|
+
const env = io.env ?? process.env;
|
|
27
|
+
let sops = io.sops;
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
cwd,
|
|
30
|
+
env,
|
|
31
|
+
spawn: io.spawn ?? realSpawn,
|
|
32
|
+
sleep: io.sleep ?? realSleep,
|
|
33
|
+
now: io.now ?? Date.now,
|
|
34
|
+
stdout: io.stdout ?? ((text) => process.stdout.write(text)),
|
|
35
|
+
stderr: io.stderr ?? ((text) => process.stderr.write(text)),
|
|
36
|
+
fetch: io.fetch ?? globalThis.fetch,
|
|
37
|
+
/** The secret seam: the app's own sops/age tools, created on first use so verbs that open no custody never need them. */
|
|
38
|
+
get sops() {
|
|
39
|
+
sops ??= defaultSops(env, cwd);
|
|
40
|
+
return sops;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Runs one tool; `env` replaces the child environment when given, `input` is written to its stdin. */
|
|
46
|
+
export const tool = (ctx, command, args, options = {}) => ctx.spawn(command, args, options);
|
|
47
|
+
|
|
48
|
+
/** Runs a tool and returns stdout, or throws the first stderr line. Used where a failure has no recovery. */
|
|
49
|
+
export function toolOutput(ctx, command, args, options = {}) {
|
|
50
|
+
const run = tool(ctx, command, args, options);
|
|
51
|
+
if (run.error) throw new Error(`cannot run ${command} (${run.error.message})`);
|
|
52
|
+
if (run.status !== 0) throw new Error(`${command} ${args.slice(0, 2).join(' ')}: ${firstLine(run.stderr)}`);
|
|
53
|
+
return run.stdout;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const firstLine = (text) => String(text ?? '').trim().split(/\r?\n/u)[0];
|
|
57
|
+
export const lines = (text) => String(text ?? '').split(/\r?\n/u).filter(Boolean);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// converge.mjs - observes whether a stack deploy has converged: desired versus running replicas, the update state of every service and
|
|
2
|
+
// failed or rejected tasks of the NEW service spec created since the deploy began (`docker stack deploy --detach` returns before any of this is
|
|
3
|
+
// true). The old tasks still restarting while a rolling update replaces them carry the previous spec: their failures are not the deploy's, so a
|
|
4
|
+
// task counts only when its spec equals the service's current task template. The update must also have reached `completed`.
|
|
5
|
+
// Two consecutive agreeing observations are required, so a task that starts and immediately exits is caught by the second look.
|
|
6
|
+
// Read-only: every call is a docker query. Time comes from ctx.now(), waits from ctx.sleep(), tools through ctx.spawn.
|
|
7
|
+
import { repeatInOrder } from '../runtime/scripts/lib/in-order.mjs';
|
|
8
|
+
import { lines as splitLines, toolOutput } from './context.mjs';
|
|
9
|
+
import { refusal } from './errors.mjs';
|
|
10
|
+
|
|
11
|
+
const POLL_MS = 5000;
|
|
12
|
+
const TAB = String.fromCodePoint(9);
|
|
13
|
+
// The spec is JSON (it never holds a raw tab), so it is split off at the last tab; the error text before it may hold anything.
|
|
14
|
+
const TASK_FORMAT = `{{.DesiredState}}|{{.Status.State}}|{{.Status.Timestamp}}|{{.Status.Err}}${TAB}{{json .Spec}}`;
|
|
15
|
+
|
|
16
|
+
const docker = (ctx, args) => toolOutput(ctx, 'docker', args);
|
|
17
|
+
|
|
18
|
+
/** The JSON text with object keys sorted at every depth, so two equal specs compare equal whatever the key order. */
|
|
19
|
+
function canonical(value) {
|
|
20
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
21
|
+
if (value !== null && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sameSpec(taskSpec, template) {
|
|
26
|
+
try {
|
|
27
|
+
return template === undefined || canonical(JSON.parse(taskSpec)) === template;
|
|
28
|
+
} catch {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Failed or rejected tasks of the current spec created since the deploy began, and how many tasks run as desired. */
|
|
34
|
+
function readTasks(ctx, name, sinceMs, template) {
|
|
35
|
+
const ids = splitLines(docker(ctx, ['service', 'ps', name, '--no-trunc', '-q']));
|
|
36
|
+
const rows = ids.length === 0 ? [] : splitLines(docker(ctx, ['inspect', '--type', 'task', '--format', TASK_FORMAT, ...ids]));
|
|
37
|
+
const out = { running: 0, failed: [] };
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
const cut = row.lastIndexOf(TAB);
|
|
40
|
+
const [wanted, state, timestamp, ...rest] = (cut < 0 ? row : row.slice(0, cut)).split('|');
|
|
41
|
+
if (wanted === 'running' && state === 'running') out.running += 1;
|
|
42
|
+
const current = cut < 0 || sameSpec(row.slice(cut + 1), template);
|
|
43
|
+
if ((state === 'failed' || state === 'rejected') && current && Date.parse(timestamp) >= sinceMs) out.failed.push(`${name}: task ${state}: ${rest.join('|')}`);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One service: { pending, failed } lines. A rolled back or paused update since the deploy began fails it at once. */
|
|
49
|
+
function observeService(ctx, name, sinceMs) {
|
|
50
|
+
const inspect = JSON.parse(docker(ctx, ['service', 'inspect', name, '--format', '{{json .}}']));
|
|
51
|
+
const desired = inspect.Spec?.Mode?.Replicated?.Replicas ?? 1;
|
|
52
|
+
const update = inspect.UpdateStatus;
|
|
53
|
+
const recentUpdate = Boolean(update) && (update.StartedAt ? Date.parse(update.StartedAt) : 0) >= sinceMs;
|
|
54
|
+
if (recentUpdate && /rollback|paused/u.test(update.State)) return { pending: [], failed: [`${name}: update ${update.State}: ${update.Message ?? ''}`] };
|
|
55
|
+
const pending = recentUpdate && update.State !== 'completed' ? [`${name}: update in progress (${update.State})`] : [];
|
|
56
|
+
const taskTemplate = inspect.Spec?.TaskTemplate;
|
|
57
|
+
const tasks = readTasks(ctx, name, sinceMs, taskTemplate === undefined ? undefined : canonical(taskTemplate));
|
|
58
|
+
if (tasks.running !== desired) pending.push(`${name}: ${tasks.running}/${desired} running`);
|
|
59
|
+
return { pending, failed: tasks.failed };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One observation over every service of the stack: { ok, pending, failed, services }. */
|
|
63
|
+
function observe(ctx, config, sinceMs) {
|
|
64
|
+
const services = splitLines(docker(ctx, ['service', 'ls', '--filter', `label=com.docker.stack.namespace=${config.stack}`, '--format', '{{.Name}}']));
|
|
65
|
+
const result = { pending: services.length === 0 ? [`no service of stack ${config.stack} yet`] : [], failed: [], services };
|
|
66
|
+
for (const name of services) {
|
|
67
|
+
const found = observeService(ctx, name, sinceMs);
|
|
68
|
+
result.pending.push(...found.pending);
|
|
69
|
+
result.failed.push(...found.failed);
|
|
70
|
+
}
|
|
71
|
+
return { ...result, ok: result.pending.length === 0 && result.failed.length === 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The observation, or a pending line when docker failed (a thrown docker error is never a crash). */
|
|
75
|
+
function tryObserve(ctx, config, sinceMs, previous) {
|
|
76
|
+
try {
|
|
77
|
+
return observe(ctx, config, sinceMs);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { ok: false, pending: [String(error.message)], failed: [], services: previous.services };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = (ok, lines, last) => ({ ok, lines, data: { services: last.services, failed: last.failed, pending: last.pending } });
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Waits until the stack converged. `since` is the ISO instant the deploy began (required). Returns { ok, lines, data: { services, failed, pending } };
|
|
87
|
+
* on failure the lines name the service and the Swarm task error text.
|
|
88
|
+
*/
|
|
89
|
+
export async function convergeStack(ctx, config, { since, timeoutSeconds = 600 } = {}) {
|
|
90
|
+
const sinceMs = Date.parse(since);
|
|
91
|
+
if (Number.isNaN(sinceMs)) throw refusal('STACK_CONVERGE_SINCE', '--since must be an ISO instant such as 2026-01-31T10:00:00Z');
|
|
92
|
+
const deadline = ctx.now() + timeoutSeconds * 1000;
|
|
93
|
+
let last = { pending: ['not observed'], failed: [], services: [] };
|
|
94
|
+
let stable = 0;
|
|
95
|
+
const outcome = await repeatInOrder(async () => {
|
|
96
|
+
if (ctx.now() >= deadline) return 'timeout';
|
|
97
|
+
last = tryObserve(ctx, config, sinceMs, last);
|
|
98
|
+
if (last.failed.length > 0) return 'failed';
|
|
99
|
+
stable = last.ok ? stable + 1 : 0;
|
|
100
|
+
if (stable >= 2) return 'converged';
|
|
101
|
+
await ctx.sleep(POLL_MS);
|
|
102
|
+
return undefined;
|
|
103
|
+
});
|
|
104
|
+
if (outcome === 'converged') return result(true, [`converge: ${last.services.join(', ')}: desired replicas running, no failed task`], last);
|
|
105
|
+
const reasons = [...last.failed, ...last.pending].map((line) => ` ${line}`);
|
|
106
|
+
return result(false, [`converge: ${last.failed.length > 0 ? 'FAILED' : 'TIMEOUT'}`, ...reasons], last);
|
|
107
|
+
}
|