@starci/hfs 4.9.9 → 4.9.11
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 +20 -0
- package/README.md +14 -0
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +1 -1
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +1 -1
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +81 -18
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
- package/runtime/knowledge/hfs/rules.yaml +74 -3
- package/runtime/knowledge/hfs/slots.yaml +36 -3
- package/runtime/modules/kernel/failure-codes.yaml +21 -0
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/check.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/src/main.mjs +7 -4
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
|
@@ -1820,7 +1820,7 @@ rules:
|
|
|
1820
1820
|
- {path: packages/eslint/be/be-r89-source-form-coverage.spec.mjs, edition: full, codes: [BE_SOURCE_FORM, BE_PUBLIC_CONTRACT_FORM]}
|
|
1821
1821
|
- id: R90
|
|
1822
1822
|
code: "BE_INFRA_OWNER"
|
|
1823
|
-
law: "Each raw infrastructure library (HTTP, cache, queue, scheduler, logger, date, config, events) is imported or referenced only by its one owning platform or integration capability, and the `HttpClient` port of `platform/http` is called only by an `integrations` capability."
|
|
1823
|
+
law: "Each raw infrastructure library (HTTP, cache, queue, scheduler, logger, date, config, events, model SDKs) is imported or referenced only by its one owning platform or integration capability, and the `HttpClient` port of `platform/http` is called only by an `integrations` capability."
|
|
1824
1824
|
kinds: [lint, codemod]
|
|
1825
1825
|
failureCodes: ["BE_INFRA_OWNER"]
|
|
1826
1826
|
enforcers:
|
|
@@ -4716,10 +4716,10 @@ rules:
|
|
|
4716
4716
|
- {path: packages/hfs/src/hfs-release-provenance.spec.mjs, edition: full}
|
|
4717
4717
|
- id: R272
|
|
4718
4718
|
code: "BE_APP_BOOT_PROOF_MISSING"
|
|
4719
|
-
law: "Every lite back-end app proves its real Nest AppModule composition by compiling the dependency graph with explicit fixture options and infrastructure doubles; unresolved providers, circular dependencies and a missing or failed proof are refused. The app check wiring verb uses the same proof. Full retains its existing e2e boot proof."
|
|
4719
|
+
law: "Every lite back-end app proves its real Nest AppModule composition by compiling the dependency graph with explicit fixture options and infrastructure doubles; unresolved providers, circular dependencies and a missing or failed proof are refused. The proof worker runs within its own budget from its spawn and is retried once when it runs out; a second timeout reaches no verdict and is reported as BOOT_PROOF_TIMEOUT, a warning, never as a failed proof. The app check wiring verb uses the same proof. Full retains its existing e2e boot proof."
|
|
4720
4720
|
kinds: [check]
|
|
4721
4721
|
gates: ["settle","land","ci"]
|
|
4722
|
-
failureCodes: ["BE_APP_BOOT_PROOF_MISSING"]
|
|
4722
|
+
failureCodes: ["BE_APP_BOOT_PROOF_MISSING", "BOOT_PROOF_TIMEOUT"]
|
|
4723
4723
|
enforcers:
|
|
4724
4724
|
- {kind: hfs, id: boot-proof, at: scripts/hfs/rules/boot-proof.mjs}
|
|
4725
4725
|
editions: ["lite"]
|
|
@@ -5756,3 +5756,74 @@ rules:
|
|
|
5756
5756
|
source: owner
|
|
5757
5757
|
quote: "ok trò"
|
|
5758
5758
|
ruling: "A route page is one Grammar Page (owner ruling page-frame): the single owner of the page inset and rhythm; the lint refuses a sibling beside the Page and a bare PageHeader or PageContainer in a page owner."
|
|
5759
|
+
- id: R326
|
|
5760
|
+
code: "BE_AI_KEY_CUSTODY"
|
|
5761
|
+
law: "An upstream model provider key variable (OPENROUTER_API_KEY, OPENAI_API_KEY and the rest of the canon's one list) is named only inside the ai-router gateway's custody owner integrations/ai-gateway, in a literal, a member or a destructured property; every other file reaches a model through the gateway with a virtual key."
|
|
5762
|
+
kinds: [lint]
|
|
5763
|
+
gates: [pre-commit, settle, land, ci]
|
|
5764
|
+
failureCodes: ["BE_AI_KEY_CUSTODY"]
|
|
5765
|
+
enforcers:
|
|
5766
|
+
- {kind: eslint-be, id: provider-key-custody}
|
|
5767
|
+
editions: [lite, full]
|
|
5768
|
+
docs: [contexts/code/be/ai.md]
|
|
5769
|
+
templateNa: "The skeleton scaffolds no model call; an app adds the ai and ai-gateway integrations when it calls a model."
|
|
5770
|
+
proves:
|
|
5771
|
+
- packages/eslint-canon-be/src/ai-gateway.spec.ts
|
|
5772
|
+
exemplifiedBy:
|
|
5773
|
+
- {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_KEY_CUSTODY]}
|
|
5774
|
+
witness:
|
|
5775
|
+
- {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_KEY_CUSTODY]}
|
|
5776
|
+
cases:
|
|
5777
|
+
- id: case-1
|
|
5778
|
+
date: "2026-10-29"
|
|
5779
|
+
source: owner
|
|
5780
|
+
quote: "nhớ làm langchain nhé update .claude lun là call thẳng từ code phải dùng langchain với embeded context"
|
|
5781
|
+
ruling: "A model call made straight from code uses LangChain with retrieved context, through the ai-router gateway (owner ruling be-ai-langchain-gateway): only the gateway's custody owner reads a provider key."
|
|
5782
|
+
- id: R327
|
|
5783
|
+
code: "BE_AI_GATEWAY_CLIENT"
|
|
5784
|
+
law: "Code builds only the ChatOpenAI and OpenAIEmbeddings clients of @langchain/openai, each from an inline options object with no spread, its configuration.baseURL read from a <c>.options.ts type and its apiKey the reveal() of a platform/config Secret, so every model call goes through the ai-router gateway with a virtual key."
|
|
5785
|
+
kinds: [lint]
|
|
5786
|
+
gates: [pre-commit, settle, land, ci]
|
|
5787
|
+
failureCodes: ["BE_AI_GATEWAY_CLIENT"]
|
|
5788
|
+
enforcers:
|
|
5789
|
+
- {kind: eslint-be, id: ai-client-through-gateway}
|
|
5790
|
+
editions: [lite, full]
|
|
5791
|
+
docs: [contexts/code/be/ai.md]
|
|
5792
|
+
templateNa: "The skeleton scaffolds no model call; an app adds the ai and ai-gateway integrations when it calls a model."
|
|
5793
|
+
proves:
|
|
5794
|
+
- packages/eslint-canon-be/src/ai-gateway.spec.ts
|
|
5795
|
+
exemplifiedBy:
|
|
5796
|
+
- {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_GATEWAY_CLIENT]}
|
|
5797
|
+
witness:
|
|
5798
|
+
- {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_GATEWAY_CLIENT]}
|
|
5799
|
+
cases:
|
|
5800
|
+
- id: case-1
|
|
5801
|
+
date: "2026-10-29"
|
|
5802
|
+
source: owner
|
|
5803
|
+
quote: "nhớ làm langchain nhé update .claude lun là call thẳng từ code phải dùng langchain với embeded context"
|
|
5804
|
+
ruling: "A model call made straight from code uses LangChain with retrieved context, through the ai-router gateway (owner ruling be-ai-langchain-gateway): the LangChain clients point at the gateway's base URL with its virtual key."
|
|
5805
|
+
- id: R333
|
|
5806
|
+
code: "BE_ENTITY_SCHEMA_DRIFT"
|
|
5807
|
+
law: "Every TypeORM entity column of an app's entity list (be/src/platform/database/entities.ts) matches the schema its SQL migrations build: `starci app db drift` applies db/bootstrap.sql and every active db/migrations/*.sql to a scratch database (a container of the app's db image, or the empty database --url-env names), reads information_schema.columns and pg_catalog, and refuses a missing table, a missing column, a type that differs once both are normalised to PostgreSQL's format_type names (an enum by its type name), and a nullability that differs. A database column no entity maps is fine. A host that cannot provide the scratch database gets no verdict (exit 2, needs a database), never a green."
|
|
5808
|
+
kinds: [check]
|
|
5809
|
+
gates: [ci]
|
|
5810
|
+
failureCodes: ["BE_ENTITY_SCHEMA_DRIFT"]
|
|
5811
|
+
enforcers:
|
|
5812
|
+
- {kind: hfs, id: db-drift, at: packages/hfs/db/drift.mjs}
|
|
5813
|
+
editions: [lite]
|
|
5814
|
+
editionWhy: "The owner ruling of 2026-10-29 maps lite tables with TypeORM entities while db/migrations/*.sql stays the only DDL and synchronize is false, so only lite entities can drift from a schema they do not create; full entities ship with their own TypeORM migrations."
|
|
5815
|
+
docs: [contexts/code/be/persistence.md]
|
|
5816
|
+
templateNa: "The entities and the migrations are the product's tables; the check judges whichever an app writes, and a template scaffolds no table."
|
|
5817
|
+
proves:
|
|
5818
|
+
- {path: packages/hfs/src/db-drift.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
|
|
5819
|
+
- {path: packages/hfs/src/db-drift-docker.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
|
|
5820
|
+
exemplifiedBy:
|
|
5821
|
+
- {path: packages/hfs/src/db-drift.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
|
|
5822
|
+
witness:
|
|
5823
|
+
- {path: packages/hfs/src/db-drift.spec.mjs, edition: lite, codes: [BE_ENTITY_SCHEMA_DRIFT]}
|
|
5824
|
+
cases:
|
|
5825
|
+
- id: case-1
|
|
5826
|
+
date: "2026-10-29"
|
|
5827
|
+
source: owner
|
|
5828
|
+
quote: "lite persistence moves to TypeORM entities while db/migrations/*.sql stays the only DDL and synchronize is false"
|
|
5829
|
+
ruling: "Lite entities only map tables the migrations create, so each entity column is checked against the migrated schema by starci app db drift, which CI runs in the build job and the release gate runs as its db-drift row."
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
|
|
8
8
|
# Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
|
|
9
9
|
schema: starci/hfs-slots@3
|
|
10
|
-
version: 3.
|
|
10
|
+
version: 3.8.0
|
|
11
11
|
|
|
12
12
|
versioning:
|
|
13
13
|
# MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
|
|
@@ -45,6 +45,8 @@ versioning:
|
|
|
45
45
|
# 2026-10-24: lite may serve SSE, and SSE is the default door for one-way push), and lets fe.modules.api hold the SSE stream operations (sse/).
|
|
46
46
|
# 3.7.0 is a minor (alpha.31): it ADDS the optional app.scripts.integration slot (scripts/integration/*.mjs, the product test-support scripts that only the integration harness runs) and the optional fe.transport.stream slot, the fetch reader of server-sent-events streams (the stream half of the
|
|
47
47
|
# transport client layer), and lets fe.hooks.swr hold the SWR subscription hooks (api/sse/subscriptions/useSubscribe<X>Swr.ts, one useSWRSubscription per stream operation).
|
|
48
|
+
# 3.8.0 is a minor (urgent release, 2026-10-29): it ADDS the optional fe.dev-server-generated slot (apps/*/AGENTS.md, which `next dev` rewrites) and the
|
|
49
|
+
# slot field rewrittenBy, so the release gate's clean-tree check passes over an untracked file a declared dev server writes, and over nothing else.
|
|
48
50
|
|
|
49
51
|
# Field meaning, per slot.
|
|
50
52
|
# id stable name, never changes meaning inside a major.
|
|
@@ -56,6 +58,8 @@ versioning:
|
|
|
56
58
|
# A slot is optional in the sense of the requirements list when its presence is not required.
|
|
57
59
|
# tracked tracked (committed) | ignored (may exist, must be gitignored) | external (must not exist in the tree;
|
|
58
60
|
# goesTo says where it lives).
|
|
61
|
+
# rewrittenBy on an ignored file slot: the dev server that writes the file into the working tree while it runs (`next dev`). The
|
|
62
|
+
# release gate's clean-tree check (release/gate.mjs dirtyPaths) passes over an untracked file such a slot owns, and only it.
|
|
59
63
|
# tier node of the import direction matrix (tiers below); none = takes no part in import checks;
|
|
60
64
|
# inherit = the tier of the enclosing owner.
|
|
61
65
|
# owner true when every directory matching the slot is an owner: the unit cross-owner imports and cycles use.
|
|
@@ -254,6 +258,25 @@ ruleParams:
|
|
|
254
258
|
"@socket.io/postgres-adapter": [platform/realtime] # the stateless socket.io adapter of a scaled lite back end without Redis
|
|
255
259
|
"@socket.io/redis-adapter": [platform/realtime] # the sharded socket.io adapter of a scaled back end on the declared Redis
|
|
256
260
|
pg: [platform/realtime] # the LISTEN connection of the Postgres hub; every other query goes through TypeORM
|
|
261
|
+
# BE-AI-1, BE-AI-3: every model call goes through the ai-router gateway. Its custody owner registers @starci/ai-router with the
|
|
262
|
+
# provider keys; the one LangChain integration owns @langchain/*; no provider SDK, openai client or agent framework is imported.
|
|
263
|
+
"@starci/ai-router": [integrations/ai-gateway]
|
|
264
|
+
"@starci/ai-router/testing": [integrations/ai-gateway, integrations/ai] # the fake provider a spec of either side boots the gateway with
|
|
265
|
+
"@langchain": [integrations/ai] # a scope key: every @langchain/* package
|
|
266
|
+
"@langchain/langgraph": [] # an agent loop runs in the agent runtime, never in code
|
|
267
|
+
"@langchain/community": [] # retrieval reads the app's own persistence through its owner
|
|
268
|
+
langchain: [] # createAgent is an agent loop
|
|
269
|
+
openai: [] # code calls models through @langchain/openai
|
|
270
|
+
"@anthropic-ai/sdk": []
|
|
271
|
+
"@google/genai": []
|
|
272
|
+
"@google/generative-ai": []
|
|
273
|
+
"@mistralai/mistralai": []
|
|
274
|
+
"@openrouter/ai-sdk-provider": []
|
|
275
|
+
"@ai-sdk": []
|
|
276
|
+
ai: []
|
|
277
|
+
cohere-ai: []
|
|
278
|
+
groq-sdk: []
|
|
279
|
+
ollama: []
|
|
257
280
|
# R87 BE_CQRS_SHAPE no-event-bus: the in-process event vocabulary the rule refuses. `imports` maps a module
|
|
258
281
|
# specifier to the member names that are an in-process event; `[]` flags the package itself (its emitter packages
|
|
259
282
|
# are the `[]` owners of infraOwners above). `classes` maps a module specifier to the class names that are an
|
|
@@ -1136,6 +1159,16 @@ slots:
|
|
|
1136
1159
|
tier: none
|
|
1137
1160
|
tests: none
|
|
1138
1161
|
why: produced by `npm run codegen` (prebuild and pretypecheck); never committed
|
|
1162
|
+
- id: fe.dev-server-generated
|
|
1163
|
+
since: 3.8.0
|
|
1164
|
+
profiles: [fe]
|
|
1165
|
+
path: "apps/*/AGENTS.md"
|
|
1166
|
+
presence: optional
|
|
1167
|
+
tracked: ignored
|
|
1168
|
+
rewrittenBy: next dev
|
|
1169
|
+
tier: none
|
|
1170
|
+
tests: none
|
|
1171
|
+
why: the agent notes `next dev` writes at a Next app's root each time it starts; never committed, and never a dirty tree for the release gate
|
|
1139
1172
|
|
|
1140
1173
|
# ----- never in the working tree: caches, scratch, agent output ----------------------------------------
|
|
1141
1174
|
- id: repo.tool-cache
|
|
@@ -1892,7 +1925,7 @@ slots:
|
|
|
1892
1925
|
tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
|
|
1893
1926
|
coverage: required
|
|
1894
1927
|
budget: {indexExports: 80} # a provider's port surface includes clients, config, options, tokens and errors
|
|
1895
|
-
rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING]
|
|
1928
|
+
rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT]
|
|
1896
1929
|
- id: be.integrations.model
|
|
1897
1930
|
since: 3.1.0
|
|
1898
1931
|
profiles: [be]
|
|
@@ -1902,7 +1935,7 @@ slots:
|
|
|
1902
1935
|
tier: integrations
|
|
1903
1936
|
tests: none
|
|
1904
1937
|
coverage: required
|
|
1905
|
-
why:
|
|
1938
|
+
why: a self-hosted ML model client is an integration; weights and datasets are never in git, and a language model is called through the ai-router gateway by the integrations/ai LangChain client (BE-AI-1, BE-AI-3)
|
|
1906
1939
|
rules: [BE_TIER_DIRECTION, BE_INFRA_OWNER, BE_INTEGRATION_SPEC_MISSING]
|
|
1907
1940
|
|
|
1908
1941
|
- id: be.integrations.supabase
|
|
@@ -1739,6 +1739,16 @@ BE_WEBHOOK_UNVERIFIED:
|
|
|
1739
1739
|
owner: op-retry
|
|
1740
1740
|
kind: check-finding
|
|
1741
1741
|
|
|
1742
|
+
BOOT_PROOF_TIMEOUT:
|
|
1743
|
+
title: "The lite boot proof's worker ran out of its budget twice and reached no verdict"
|
|
1744
|
+
title_vi: "Bằng chứng khởi động lite hết thời gian hai lần, chưa có kết luận"
|
|
1745
|
+
meaning_vi: "Tiến trình chứng minh ghép nối Nest chạy quá ngân sách thời gian riêng của nó (STARCI_BOOT_PROOF_TIMEOUT_MS, lấy từ heavy.bootProofTimeoutSeconds của ops/liveness.yaml) cả lần đầu lẫn lần thử lại. Đây không phải lỗi ghép nối: máy đang quá tải nên chưa phán được."
|
|
1746
|
+
causes_vi:
|
|
1747
|
+
- "Máy chủ đang chạy nhiều lệnh nặng cùng lúc nên trình biên dịch TypeScript của bằng chứng chạy quá chậm"
|
|
1748
|
+
nextStep_vi: "Chạy lại starci app check --wiring khi máy rảnh hơn; nếu vẫn hết thời gian khi máy rảnh, nâng heavy.bootProofTimeoutSeconds trong ops/liveness.yaml."
|
|
1749
|
+
owner: op-retry
|
|
1750
|
+
kind: check-finding
|
|
1751
|
+
|
|
1742
1752
|
CI_DECLARATION_DRIFT:
|
|
1743
1753
|
title: "Workflow references drift from CI declarations"
|
|
1744
1754
|
title_vi: "Workflow lệch khai báo CI"
|
|
@@ -5629,3 +5639,14 @@ STACK_TOOLING_REIMPLEMENTED:
|
|
|
5629
5639
|
nextStep_vi: "Xóa tệp hoặc lệnh đó và gọi động từ được nêu trong thông báo (starci app stack render, deploy, converge, verify, prune, rollback, prepare, doctor), đặt hằng số của ứng dụng vào khối deploy của application-stacks.yaml."
|
|
5630
5640
|
owner: op-retry
|
|
5631
5641
|
kind: check-finding
|
|
5642
|
+
|
|
5643
|
+
BE_ENTITY_SCHEMA_DRIFT:
|
|
5644
|
+
title: "A TypeORM entity column differs from the schema the migrations build"
|
|
5645
|
+
title_vi: "Cột entity TypeORM lệch với schema mà migration tạo ra"
|
|
5646
|
+
meaning_vi: "starci app db drift áp db/bootstrap.sql và mọi migration vào một cơ sở dữ liệu tạm, rồi so từng cột entity với schema đó: bảng hoặc cột không tồn tại, kiểu khác nhau sau khi chuẩn hóa theo tên của PostgreSQL, hoặc khả năng nhận NULL khác nhau."
|
|
5647
|
+
causes_vi:
|
|
5648
|
+
- "Vi phạm R333: entity được sửa mà không có migration tương ứng, hoặc migration đổi bảng mà entity chưa theo kịp."
|
|
5649
|
+
- "Entity khai báo sai kiểu (ví dụ timestamp thay vì timestamptz) hoặc quên nullable cho một cột cho phép NULL."
|
|
5650
|
+
nextStep_vi: "Viết migration mới trong db/migrations để schema có đúng cột đó, hoặc sửa entity cho khớp schema hiện tại, rồi chạy lại starci app db drift cho đến khi xanh."
|
|
5651
|
+
owner: op-retry
|
|
5652
|
+
kind: check-finding
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Dynamic roots need public fixture options: src/tests/fixtures/builders/<app>.builder.ts may export
|
|
3
3
|
// buildWiringProof(AppModule) => { options, overrides: [{ token, value }], request? }. The runner always chooses
|
|
4
4
|
// the real root; a fixture cannot substitute an empty module. No environment parser or main.ts runs.
|
|
5
|
+
// The proof worker has its own budget, counted from its spawn: STARCI_BOOT_PROOF_TIMEOUT_MS, which the starci CLI sets from ops/liveness.yaml
|
|
6
|
+
// (heavy.bootProofTimeoutSeconds). A worker that runs out of it reached no verdict: it is retried once, and a second timeout is reported as
|
|
7
|
+
// BOOT_PROOF_TIMEOUT (a warning: the host was too loaded to judge), never as the proof failing (BE_APP_BOOT_PROOF_MISSING).
|
|
5
8
|
import fs from 'node:fs';
|
|
6
9
|
import path from 'node:path';
|
|
7
10
|
import { createRequire } from 'node:module';
|
|
@@ -12,6 +15,8 @@ import { isMain } from '../lib/is-main.mjs';
|
|
|
12
15
|
import { readJson } from './rules/read.mjs';
|
|
13
16
|
|
|
14
17
|
export const BOOT_PROOF_CODE = 'BE_APP_BOOT_PROOF_MISSING';
|
|
18
|
+
export const BOOT_PROOF_TIMEOUT = 'BOOT_PROOF_TIMEOUT';
|
|
19
|
+
export const BOOT_PROOF_TIMEOUT_ENV = 'STARCI_BOOT_PROOF_TIMEOUT_MS';
|
|
15
20
|
const WORKER = fileURLToPath(import.meta.url);
|
|
16
21
|
const DEFAULT_TIMEOUT = 15000;
|
|
17
22
|
const DI_FAILURES = new Map([
|
|
@@ -29,6 +34,18 @@ export const wiringFixturePath = app => `src/tests/fixtures/builders/${app}.buil
|
|
|
29
34
|
const refusal = (file, reason) => ({ code: BOOT_PROOF_CODE, level: 'error', path: file,
|
|
30
35
|
message: `${file}: lite boot proof failed (${reason}); compile the real AppModule with explicit fixture options and infrastructure doubles using starci app check --wiring`, reason });
|
|
31
36
|
|
|
37
|
+
const timedOut = (file, budget) => ({ code: BOOT_PROOF_TIMEOUT, level: 'warning', path: file, reason: 'proof worker timed out',
|
|
38
|
+
message: `${file}: the lite boot proof reached no verdict: its worker ran out of its ${Math.round(budget / 1000)} s budget twice (the host is loaded); this is no failure of the proof, rerun starci app check --wiring when the host is quieter` });
|
|
39
|
+
|
|
40
|
+
/** The worker's budget in ms, from its spawn: the CLI's STARCI_BOOT_PROOF_TIMEOUT_MS, else four compile deadlines (at least a minute). */
|
|
41
|
+
export function workerBudget(timeout, env = process.env) {
|
|
42
|
+
const configured = Number(env[BOOT_PROOF_TIMEOUT_ENV]);
|
|
43
|
+
return Number.isInteger(configured) && configured > 0 ? configured : Math.max(60000, timeout * 4) + 2000;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when the worker was stopped by its budget (spawnSync's ETIMEDOUT, or the kill signal it sends), not when it ended by itself. */
|
|
47
|
+
const isTimeout = (result) => result?.error?.code === 'ETIMEDOUT' || (result?.status === null && result?.signal != null);
|
|
48
|
+
|
|
32
49
|
/** Stable diagnostics never forward exception text, source values or child output. */
|
|
33
50
|
function failureReason(error) {
|
|
34
51
|
// Nest exceptions inherit Error.name; their constructor identifies the refusal.
|
|
@@ -153,8 +170,12 @@ function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, time
|
|
|
153
170
|
const fixture = fixtures[app] ?? wiringFixturePath(app);
|
|
154
171
|
const fixtureExists = fs.existsSync(path.join(beRoot, fixture)) && (!files || files.includes(`${prefix}${fixture}`));
|
|
155
172
|
const args = [WORKER, '--worker', JSON.stringify({ repoRoot: beRoot, app, fixture: fixtureExists ? fixture : null, timeout })];
|
|
156
|
-
const
|
|
157
|
-
const
|
|
173
|
+
const budget = workerBudget(timeout);
|
|
174
|
+
const options = { cwd: beRoot, encoding: 'utf8', env: {}, windowsHide: true, timeout: budget, maxBuffer: 1024 * 1024 };
|
|
175
|
+
const spawnWorker = () => (run ? run(process.execPath, args, options) : runNode(args, options));
|
|
176
|
+
let result = spawnWorker();
|
|
177
|
+
if (isTimeout(result)) result = spawnWorker();
|
|
178
|
+
if (isTimeout(result)) return timedOut(file, budget);
|
|
158
179
|
let proof;
|
|
159
180
|
try { proof = JSON.parse(result.stdout ?? ''); } catch { /* Output from app source cannot serve as a proof. */ }
|
|
160
181
|
if (result.status !== 0 || proof?.ok !== true) return refusal(file, SAFE_REASONS.has(proof?.reason) ? proof.reason : WORKER_FAILURE);
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
// findings fail the check.
|
|
62
62
|
import fs from 'node:fs';
|
|
63
63
|
import path from 'node:path';
|
|
64
|
-
import { appWiring, BOOT_PROOF_CODE } from './app-wiring.mjs';
|
|
64
|
+
import { appWiring, BOOT_PROOF_CODE, BOOT_PROOF_TIMEOUT } from './app-wiring.mjs';
|
|
65
65
|
import { coverageFrom as coverageFromReport } from './coverage-from.mjs';
|
|
66
66
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
67
67
|
import { ARCHITECTURE_RULE_IDS, checkArchitecture } from './architecture/index.mjs';
|
|
@@ -410,7 +410,7 @@ export function withCheckProofs(result, { repoRoot, root = skillRoot, manifest =
|
|
|
410
410
|
const proofFindings = Object.values(reports).flatMap(report => report.findings);
|
|
411
411
|
const why = readWhy(root, [...new Set(proofFindings.map(finding => finding.code))]);
|
|
412
412
|
// The explicit whole-tree wiring proof supersedes the ordinary finder's scoped result.
|
|
413
|
-
const previous = result.findings.filter(finding => !wiring || finding.code !== BOOT_PROOF_CODE);
|
|
413
|
+
const previous = result.findings.filter(finding => !wiring || (finding.code !== BOOT_PROOF_CODE && finding.code !== BOOT_PROOF_TIMEOUT));
|
|
414
414
|
const findings = [...previous, ...withWhy(judgedFindings(proofFindings, repo.edition ?? 'full', root), why)];
|
|
415
415
|
const counts = summarize(findings);
|
|
416
416
|
return { ...result, ...reports, findings, counts, ok: counts.error === 0 };
|
|
@@ -87,7 +87,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
87
87
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
88
88
|
const strList = (v) => stringList(v);
|
|
89
89
|
|
|
90
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
|
|
90
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy', 'rewrittenBy'];
|
|
91
91
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
92
92
|
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
93
93
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
@@ -114,6 +114,7 @@ function appIdentityProblems(slot, at, appScope, scopes) {
|
|
|
114
114
|
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
115
115
|
if (profilesInvalid(slot.profiles, appScope, scopes)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
|
|
116
116
|
else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
|
|
117
|
+
if (slot.rewrittenBy !== undefined && (typeof slot.rewrittenBy !== 'string' || !slot.rewrittenBy.trim() || slot.tracked !== 'ignored' || String(slot.path).endsWith('/'))) bad.push(`${at}: rewrittenBy names the dev server that writes the files of an ignored file slot`);
|
|
117
118
|
return bad;
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -48,7 +48,7 @@ export const A15_CODES = [
|
|
|
48
48
|
'HFS_WORKFLOW_CONTEXT',
|
|
49
49
|
'CI_STANDARD',
|
|
50
50
|
'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
|
|
51
|
-
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_REDIS_PERSISTENCE', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
|
|
51
|
+
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BOOT_PROOF_TIMEOUT', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_REDIS_PERSISTENCE', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
|
|
52
52
|
// codes-end
|
|
53
53
|
];
|
|
54
54
|
|
package/src/main.mjs
CHANGED
|
@@ -96,10 +96,11 @@ import { dbMigrateMain } from '../db/migrate.mjs';
|
|
|
96
96
|
import { dbPostCutoverMain } from '../db/post-cutover.mjs';
|
|
97
97
|
import { dbDumpMain } from '../db/dump.mjs';
|
|
98
98
|
import { dbResetMain } from '../db/reset.mjs';
|
|
99
|
+
import { dbDriftMain } from '../db/drift.mjs';
|
|
99
100
|
import { codemodMain } from '../codemod/button-intent.mjs';
|
|
100
101
|
|
|
101
102
|
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage', 'codemod']);
|
|
102
|
-
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
|
|
103
|
+
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain], ['drift', dbDriftMain]]);
|
|
103
104
|
|
|
104
105
|
const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
|
|
105
106
|
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|block|composite|branch|leaf|page|overlay|layout> <name> [options] [--cwd <dir>]
|
|
@@ -114,11 +115,13 @@ starci app db dump (--linked | --local | --url-env <NAME>) --out <dir> [--schema
|
|
|
114
115
|
starci app db reset --linked --auth keep|drop --storage keep|drop --backup <receipt.json> [--execute] [--json] [--cwd <dir>]
|
|
115
116
|
starci app db migrate [--execute] [--local | --linked | --url-env <NAME>] [--json] [--cwd <dir>]
|
|
116
117
|
starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
|
|
117
|
-
starci app
|
|
118
|
-
starci app release
|
|
118
|
+
starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
119
|
+
starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json] [--cwd <dir>]
|
|
120
|
+
starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json] [--cwd <dir>]
|
|
119
121
|
starci app upgrade [--plan] [--cwd <dir>]
|
|
120
122
|
starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
|
|
121
123
|
starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
|
|
124
|
+
starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--execute] [--json] [--cwd <dir>]
|
|
122
125
|
starci app explain <path> [--cwd <dir>] [--json]
|
|
123
126
|
starci app emit [--cwd <dir>]
|
|
124
127
|
starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
|
|
@@ -342,7 +345,7 @@ async function installedStackMain(cwd) {
|
|
|
342
345
|
function databaseMain(argv, io) {
|
|
343
346
|
const [verb, ...rest] = argv;
|
|
344
347
|
const dispatch = DATABASE_VERBS.get(verb);
|
|
345
|
-
if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate, post-cutover, dump or
|
|
348
|
+
if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate, post-cutover, dump, reset or drift');
|
|
346
349
|
return dispatch(rest, io);
|
|
347
350
|
}
|
|
348
351
|
|
package/stack/cli.mjs
CHANGED
|
@@ -14,8 +14,8 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
|
|
|
14
14
|
|
|
15
15
|
export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
|
|
16
16
|
|
|
17
|
-
const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push']);
|
|
18
|
-
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding']);
|
|
17
|
+
const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent']);
|
|
18
|
+
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out']);
|
|
19
19
|
const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
|
|
20
20
|
|
|
21
21
|
/** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
|
|
@@ -71,6 +71,7 @@ const OPTIONS = {
|
|
|
71
71
|
deploy: ({ flags, cwd }) => ({
|
|
72
72
|
images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
|
|
73
73
|
out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
|
|
74
|
+
urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
|
|
74
75
|
}),
|
|
75
76
|
converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
|
|
76
77
|
verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
|
|
@@ -125,6 +126,8 @@ export async function stackMain(argv, io = {}) {
|
|
|
125
126
|
if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
|
|
126
127
|
throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
|
|
127
128
|
const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
|
|
129
|
+
if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env and --backup-out belong to deploy');
|
|
130
|
+
if (action === 'deploy' && !flags.urgent && (flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
|
|
128
131
|
const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
|
|
129
132
|
const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
|
|
130
133
|
const sshCmd = flags['ssh-cmd'] ?? null;
|
|
@@ -132,6 +135,11 @@ export async function stackMain(argv, io = {}) {
|
|
|
132
135
|
const toolContext = nativeToolContext(ctx, binding);
|
|
133
136
|
const result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(toolContext, config, { sshCmd }, call) : await call(toolContext);
|
|
134
137
|
print(ctx, action, flags, result);
|
|
138
|
+
// A floor step of an urgent deploy that failed is a refusal with its own code (urgent-floor.mjs), printed after the lines that led to it.
|
|
139
|
+
if (result.refused) {
|
|
140
|
+
ctx.stderr(`starci app stack: ${result.refused.code}: ${result.refused.message}\n`);
|
|
141
|
+
return EXIT.refused;
|
|
142
|
+
}
|
|
135
143
|
return result.ok ? EXIT.ok : EXIT.failed;
|
|
136
144
|
} catch (error) {
|
|
137
145
|
if (!(error instanceof StackError)) throw error;
|
package/stack/deploy.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// deploy: render, provision, store the release record, pre-pull, `docker stack deploy`, converge, post-deploy tasks (deploy.postDeploy, when declared), verify, accept, prune. A failure at or
|
|
3
3
|
// after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
|
|
4
4
|
// Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
|
|
5
|
+
// An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
|
|
6
|
+
// backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails.
|
|
5
7
|
//
|
|
6
8
|
// `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
|
|
7
9
|
// module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
|
|
@@ -21,6 +23,7 @@ import { postDeployStack } from './post-deploy.mjs';
|
|
|
21
23
|
import { pruneStack } from './prune.mjs';
|
|
22
24
|
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
23
25
|
import { createReleaseStore } from './release-store.mjs';
|
|
26
|
+
import { urgentDeploy } from './urgent-floor.mjs';
|
|
24
27
|
import { verifyStack } from './verify.mjs';
|
|
25
28
|
|
|
26
29
|
/** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
|
|
@@ -39,8 +42,11 @@ export const DEFAULT_DEPS = Object.freeze({
|
|
|
39
42
|
pruneStack,
|
|
40
43
|
releaseStore: createReleaseStore,
|
|
41
44
|
ssh: lazy('./remote.mjs', 'ownerSshRunner'),
|
|
45
|
+
floor: {},
|
|
42
46
|
});
|
|
43
47
|
|
|
48
|
+
const fileSha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
49
|
+
|
|
44
50
|
const IMAGE_LINE = /^[ \t]+image:[ \t]*(\S+)[ \t]*$/gmu;
|
|
45
51
|
const QUOTE = /^["']|["']$/gu;
|
|
46
52
|
const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9./_:@-]*$/u;
|
|
@@ -212,7 +218,7 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
|
|
|
212
218
|
}
|
|
213
219
|
|
|
214
220
|
/**
|
|
215
|
-
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd }.
|
|
221
|
+
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut }.
|
|
216
222
|
* skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
|
|
217
223
|
* inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
|
|
218
224
|
* Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack } }.
|
|
@@ -224,15 +230,26 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
224
230
|
const out = options.out ?? made;
|
|
225
231
|
const cleanups = [];
|
|
226
232
|
try {
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
233
|
+
// The local render and its inspection: a release that cannot render is refused before anything remote happens.
|
|
234
|
+
const render = async () => {
|
|
235
|
+
const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, null));
|
|
236
|
+
return { rendered, releaseId: inspectRendered(out, config).releaseId };
|
|
237
|
+
};
|
|
238
|
+
const dryRun = async () => {
|
|
239
|
+
const { rendered, releaseId } = await render();
|
|
230
240
|
const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
|
|
231
241
|
const plan = options.skipPublic
|
|
232
242
|
? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept`
|
|
233
243
|
: `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted`;
|
|
234
244
|
return { ok: true, lines: [...(rendered.lines ?? []), plan], data: { releaseId, steps: [{ step: 'render', ok: true }], executed: false } };
|
|
245
|
+
};
|
|
246
|
+
if (options.urgent === true) {
|
|
247
|
+
return await urgentDeploy(ctx, config, options, {
|
|
248
|
+
render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: () => deployExecute(ctx, config, options, d, out, cleanups), sha256: fileSha256,
|
|
249
|
+
}, d.floor);
|
|
235
250
|
}
|
|
251
|
+
if (options.execute !== true) return await dryRun();
|
|
252
|
+
await render();
|
|
236
253
|
return await deployExecute(ctx, config, options, d, out, cleanups);
|
|
237
254
|
} finally {
|
|
238
255
|
cleanups.forEach((close) => close());
|