@starci/hfs 1.0.1 → 2.0.1
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 +51 -0
- package/README.md +110 -12
- package/bin/hfs.mjs +119 -15
- package/emit/compiler.mjs +35 -0
- package/emit/contracts.mjs +97 -0
- package/emit/operations-worker.mjs +24 -0
- package/emit/operations.mjs +126 -0
- package/emit/schema-worker.mjs +117 -0
- package/emit/static-graph.mjs +670 -0
- package/emit/type-schema.mjs +145 -0
- package/package.json +4 -1
- package/report/sonar.mjs +180 -0
- package/runtime/engine/admission.mjs +284 -0
- package/runtime/engine/digest.mjs +10 -0
- package/runtime/engine/ledger-db.mjs +1245 -0
- package/runtime/engine/machine-db.mjs +1484 -0
- package/runtime/engine/migrations/machine/0001-init.sql +887 -0
- package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
- package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
- package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
- package/runtime/knowledge/hfs/slots.yaml +405 -137
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/knowledge/sonar-gate.yaml +85 -0
- package/runtime/modules/kernel/failure-codes.yaml +1480 -16
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +316 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +89 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/architecture.mjs +41 -0
- package/runtime/scripts/checks/common.mjs +37 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/artifact-hold.mjs +89 -0
- package/runtime/scripts/lib/artifact-store.mjs +103 -0
- package/runtime/scripts/lib/fs-kind.mjs +10 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-check.mjs +254 -28
- package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
- package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
- package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
- package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
- package/runtime/scripts/lib/hfs-slots.mjs +88 -40
- package/runtime/scripts/lib/hfs-tree.mjs +80 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/redact.mjs +148 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/safe-remove.mjs +179 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/sleep-sync.mjs +17 -0
- package/runtime/scripts/lib/stack-declaration.mjs +52 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/scaffold/service.mjs +333 -0
- package/sync/format.mjs +46 -0
- package/sync/hygiene.mjs +56 -24
- package/sync/index.mjs +133 -41
- package/sync/managed.mjs +170 -0
- package/sync/skeleton.mjs +32 -10
- package/sync/sonar-key.mjs +13 -0
- package/sync/ts-strict.mjs +48 -0
- package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
- package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
- package/templates/be/hooks/husky/pre-commit +13 -0
- package/templates/be/hooks/husky/pre-push +7 -0
- package/templates/be/package-scripts/package.json +22 -0
- package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
- package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
- package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
- package/templates/be/tool-config/eslint.config.mjs +3 -0
- package/templates/be/tool-config/jest.config.js +1 -0
- package/templates/be/tool-config/prettierignore +8 -0
- package/templates/be/tool-config/prettierrc +1 -0
- package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
- package/templates/be/tool-config/tsconfig.build.json +5 -0
- package/templates/be/tool-config/tsconfig.json +11 -0
- package/templates/common/gitignore.base +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
- package/templates/fe/hooks/husky/pre-commit +16 -0
- package/templates/fe/hooks/husky/pre-push +6 -0
- package/templates/fe/package-scripts/package.json +17 -0
- package/templates/fe/parts/api-client.ts +44 -0
- package/templates/fe/parts/api-outcome.ts +7 -0
- package/templates/fe/quality-config/sonar-project.properties +8 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
- package/templates/fe/tool-config/eslint.config.mjs +3 -0
- package/templates/fe/tool-config/prettierignore +10 -0
- package/templates/fe/tool-config/prettierrc +1 -0
- package/templates/fe/tool-config/stylelint.config.mjs +3 -0
- package/templates/fe/tool-config/tsconfig.json +4 -0
- package/templates/be/pre-commit +0 -8
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
- package/templates/common/codecov.yml +0 -13
- package/templates/common/pre-push +0 -5
- package/templates/fe/e2e.yml +0 -22
- package/templates/fe/pre-commit +0 -7
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
- package/templates/fe/sonar-project.properties +0 -11
- /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
- /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The services of a repository's own stack definition, read from `.starcistacks/application-stacks.yaml` and the compose
|
|
7
|
+
* files of one environment (`environments.<env>.composeFiles`, `include:` followed). The R47 `test-world-files` machine check
|
|
8
|
+
* reads the stack through it: it refuses a fake of a service the stack declares, and a `fakedBy` of a stateful one. Nothing
|
|
9
|
+
* here names a product: the stack declares its services, this reads them.
|
|
10
|
+
*
|
|
11
|
+
* Which services the test world runs for real: every declared service except the app's own services (component role
|
|
12
|
+
* `service`, or a compose service under the `app` profile) and observability sidecars (role `observability`). The
|
|
13
|
+
* toxiproxy service (the failure-injection proxy the world routes every real service through) is declared like any other
|
|
14
|
+
* service and is returned as `proxy`.
|
|
15
|
+
*/
|
|
16
|
+
export const STACKS_DIRECTORY = '.starcistacks';
|
|
17
|
+
export const STACKS_FILE = 'application-stacks.yaml';
|
|
18
|
+
export const DEFAULT_ENVIRONMENT = 'dev';
|
|
19
|
+
|
|
20
|
+
/** Test-only value the `*_FILE` secret variables of a compose service become (the file variable is replaced by the plain variable of the same name). Never a real secret: the test stack listens on loopback only. */
|
|
21
|
+
export const TEST_SECRET_VALUE = 'starci-test-secret';
|
|
22
|
+
|
|
23
|
+
const NOT_RUN_ROLES = new Set(['service', 'observability']);
|
|
24
|
+
const NOT_RUN_PROFILES = new Set(['app']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the reader knows about the well-known images of a stack, by the repository name of the image: how the test stack
|
|
28
|
+
* tells the service answers (`probe`), which name its fakes could hide behind (`aliases`), and its kind. An image it does not
|
|
29
|
+
* know is probed by TCP and is only matched by its own name.
|
|
30
|
+
*/
|
|
31
|
+
const KNOWN_IMAGES = Object.freeze({
|
|
32
|
+
postgres: { kind: 'database', aliases: ['postgres', 'postgresql', 'pg', 'database', 'db'], probe: { exec: ['pg_isready', '-h', '127.0.0.1'] } },
|
|
33
|
+
redis: { kind: 'cache', aliases: ['redis', 'cache', 'valkey'], probe: { exec: ['redis-cli', 'ping'] } },
|
|
34
|
+
valkey: { kind: 'cache', aliases: ['redis', 'cache', 'valkey'], probe: { exec: ['valkey-cli', 'ping'] } },
|
|
35
|
+
keycloak: { kind: 'identity', aliases: ['keycloak', 'idp', 'oidc'], probe: { http: { path: '/realms/master' } } },
|
|
36
|
+
minio: { kind: 'storage', aliases: ['minio', 's3', 'storage'], probe: { http: { path: '/minio/health/live', containerPort: 9000 } } },
|
|
37
|
+
mailpit: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/livez', containerPort: 8025 } } },
|
|
38
|
+
mailhog: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/', containerPort: 8025 } } },
|
|
39
|
+
maildev: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/healthz', containerPort: 1080 } } },
|
|
40
|
+
toxiproxy: { kind: 'proxy', aliases: ['toxiproxy'], probe: { http: { path: '/version', containerPort: 8474 } } },
|
|
41
|
+
mysql: { kind: 'database', aliases: ['mysql', 'database', 'db'], probe: { tcp: true } },
|
|
42
|
+
mariadb: { kind: 'database', aliases: ['mariadb', 'mysql', 'database', 'db'], probe: { tcp: true } },
|
|
43
|
+
mongo: { kind: 'database', aliases: ['mongo', 'mongodb', 'database', 'db'], probe: { tcp: true } },
|
|
44
|
+
clickhouse: { kind: 'database', aliases: ['clickhouse', 'database', 'db'], probe: { tcp: true } },
|
|
45
|
+
kafka: { kind: 'queue', aliases: ['kafka', 'queue', 'broker'], probe: { tcp: true } },
|
|
46
|
+
redpanda: { kind: 'queue', aliases: ['redpanda', 'kafka', 'queue', 'broker'], probe: { tcp: true } },
|
|
47
|
+
rabbitmq: { kind: 'queue', aliases: ['rabbitmq', 'amqp', 'queue', 'broker'], probe: { tcp: true } },
|
|
48
|
+
nats: { kind: 'queue', aliases: ['nats', 'queue', 'broker'], probe: { tcp: true } },
|
|
49
|
+
elasticsearch: { kind: 'search', aliases: ['elasticsearch', 'elastic', 'search'], probe: { tcp: true } },
|
|
50
|
+
opensearch: { kind: 'search', aliases: ['opensearch', 'search'], probe: { tcp: true } },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The kinds of well-known image that hold data an app reads back. A stack service of one of these kinds (or one the stack gives
|
|
55
|
+
* a persistent volume) is stateful: it always runs real in the test world and can never be faked, even with a declared
|
|
56
|
+
* exception (BE-CONVENTION 1.16: only stateless compute that needs special hardware or an external model may be faked).
|
|
57
|
+
*/
|
|
58
|
+
export const STATEFUL_KINDS = Object.freeze(new Set(['database', 'cache', 'identity', 'storage', 'mail', 'queue', 'search']));
|
|
59
|
+
|
|
60
|
+
/** The repository name of an image reference: `quay.io/keycloak/keycloak:26.0` -> `keycloak`. */
|
|
61
|
+
export const repositoryOf = image => {
|
|
62
|
+
const withoutDigest = image.split('@')[0];
|
|
63
|
+
const lastSegment = withoutDigest.split('/').at(-1) ?? withoutDigest;
|
|
64
|
+
return lastSegment.split(':')[0];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The repository path of an image reference without its tag: `quay.io/keycloak/keycloak:26.0` -> `quay.io/keycloak/keycloak`. */
|
|
68
|
+
export const imagePathOf = image => {
|
|
69
|
+
const withoutDigest = image.split('@')[0];
|
|
70
|
+
const lastSlash = withoutDigest.lastIndexOf('/');
|
|
71
|
+
const colon = withoutDigest.indexOf(':', lastSlash + 1);
|
|
72
|
+
return colon === -1 ? withoutDigest : withoutDigest.slice(0, colon);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** True when the image reference carries a tag or digest (an external image, not a placeholder of the repository's own build). */
|
|
76
|
+
export const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
|
|
77
|
+
|
|
78
|
+
/** The shell-like words of a compose `command` string, double and single quotes grouping a word. */
|
|
79
|
+
const words = text => {
|
|
80
|
+
const out = [];
|
|
81
|
+
let current = '';
|
|
82
|
+
let quote = null;
|
|
83
|
+
let started = false;
|
|
84
|
+
for (const char of text) {
|
|
85
|
+
if (quote) {
|
|
86
|
+
if (char === quote) quote = null;
|
|
87
|
+
else current += char;
|
|
88
|
+
} else if (char === '"' || char === "'") {
|
|
89
|
+
quote = char;
|
|
90
|
+
started = true;
|
|
91
|
+
} else if (/\s/u.test(char)) {
|
|
92
|
+
if (started || current) out.push(current);
|
|
93
|
+
current = '';
|
|
94
|
+
started = false;
|
|
95
|
+
} else current += char;
|
|
96
|
+
}
|
|
97
|
+
if (started || current) out.push(current);
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const asList = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
|
|
102
|
+
|
|
103
|
+
/** A compose `environment` (map or `KEY=VALUE` list) as a plain object; a `*_FILE` variable becomes the plain variable with the test value. */
|
|
104
|
+
const environmentOf = value => {
|
|
105
|
+
const entries = Array.isArray(value)
|
|
106
|
+
? value.map(item => { const text = String(item); const at = text.indexOf('='); return at === -1 ? [text, ''] : [text.slice(0, at), text.slice(at + 1)]; })
|
|
107
|
+
: Object.entries(value ?? {}).map(([key, item]) => [key, item === null ? '' : String(item)]);
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const [key, item] of entries) {
|
|
110
|
+
if (key.endsWith('_FILE')) out[key.slice(0, -'_FILE'.length)] = TEST_SECRET_VALUE;
|
|
111
|
+
else out[key] = item;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** The container ports a compose `ports` list publishes: `"5432:5432"`, `"127.0.0.1:8080:80/tcp"`, `5432`, `{target: 80}`. */
|
|
117
|
+
const containerPortsOf = value => asList(value).map(entry => {
|
|
118
|
+
if (entry !== null && typeof entry === 'object') return Number(entry.target);
|
|
119
|
+
const container = String(entry).split('/')[0].split(':').at(-1);
|
|
120
|
+
return Number(container);
|
|
121
|
+
}).filter(Number.isInteger);
|
|
122
|
+
|
|
123
|
+
/** The bind mounts of a compose `volumes` list whose source is a path (`./realm.json:/x:ro`); named volumes are ephemeral in the test stack and are skipped. */
|
|
124
|
+
const mountsOf = (value, directory) => {
|
|
125
|
+
const mounts = [];
|
|
126
|
+
for (const entry of asList(value)) {
|
|
127
|
+
if (entry !== null && typeof entry === 'object') {
|
|
128
|
+
if (entry.type === 'bind' && typeof entry.source === 'string') mounts.push({ host: path.resolve(directory, entry.source), container: String(entry.target), readOnly: entry.read_only === true });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const parts = String(entry).split(':');
|
|
132
|
+
// A Windows drive letter is not a separator: `C:\x:/y` keeps its drive.
|
|
133
|
+
const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
|
|
134
|
+
const target = parts.shift();
|
|
135
|
+
if (!source || !target || !(source.startsWith('.') || path.isAbsolute(source))) continue;
|
|
136
|
+
mounts.push({ host: path.resolve(directory, source), container: target, readOnly: parts.includes('ro') });
|
|
137
|
+
}
|
|
138
|
+
return mounts;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** True when a compose `volumes` list gives the service somewhere to keep data: a named volume, or a bind mount that is not read-only. */
|
|
142
|
+
const persistsData = value => asList(value).some(entry => {
|
|
143
|
+
if (entry !== null && typeof entry === 'object') return entry.type === 'volume' || (entry.type === 'bind' && entry.read_only !== true);
|
|
144
|
+
const parts = String(entry).split(':');
|
|
145
|
+
const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
|
|
146
|
+
parts.shift();
|
|
147
|
+
if (!source) return false;
|
|
148
|
+
const isPath = source.startsWith('.') || path.isAbsolute(source);
|
|
149
|
+
return !isPath || !parts.includes('ro');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const readYaml = file => parseYaml(fs.readFileSync(file, 'utf8')) ?? {};
|
|
153
|
+
|
|
154
|
+
/** The compose services of `file` and of every file it includes: [{name, definition, directory}]. */
|
|
155
|
+
const composeServices = (file, seen = new Set()) => {
|
|
156
|
+
const resolved = path.resolve(file);
|
|
157
|
+
if (seen.has(resolved) || !fs.existsSync(resolved)) return [];
|
|
158
|
+
seen.add(resolved);
|
|
159
|
+
const document = readYaml(resolved);
|
|
160
|
+
const directory = path.dirname(resolved);
|
|
161
|
+
const included = asList(document.include).flatMap(entry => {
|
|
162
|
+
const target = typeof entry === 'string' ? entry : entry?.path;
|
|
163
|
+
return typeof target === 'string' ? composeServices(path.resolve(directory, target), seen) : [];
|
|
164
|
+
});
|
|
165
|
+
const own = Object.entries(document.services ?? {}).map(([name, definition]) => ({ name, definition: definition ?? {}, directory }));
|
|
166
|
+
return [...included, ...own];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The stack of `environment`, or null when the repository declares none. `{ environment, directory, services, proxy }`:
|
|
171
|
+
* `services` are the services the environment declares (`name`, `image`, `repository`, `tag`, `role`, `kind`, `aliases`,
|
|
172
|
+
* `probe`, `command`, `environment`, `ports`, `mounts`, `persistent`, `runs`), `proxy` the toxiproxy service or null.
|
|
173
|
+
*/
|
|
174
|
+
export function readStack({ root, environment = DEFAULT_ENVIRONMENT }) {
|
|
175
|
+
const stacksFile = path.join(root, STACKS_DIRECTORY, STACKS_FILE);
|
|
176
|
+
if (!fs.existsSync(stacksFile)) return null;
|
|
177
|
+
const declaration = readYaml(stacksFile);
|
|
178
|
+
const env = declaration.environments?.[environment];
|
|
179
|
+
if (!env) return null;
|
|
180
|
+
const directory = path.join(root, STACKS_DIRECTORY, environment);
|
|
181
|
+
const components = declaration.components ?? {};
|
|
182
|
+
const declared = new Map();
|
|
183
|
+
for (const composeFile of asList(env.composeFiles)) {
|
|
184
|
+
for (const { name, definition, directory: composeDirectory } of composeServices(path.join(directory, composeFile))) declared.set(name, { definition, directory: composeDirectory });
|
|
185
|
+
}
|
|
186
|
+
const services = [];
|
|
187
|
+
for (const [name, { definition, directory: composeDirectory }] of declared) {
|
|
188
|
+
const image = typeof definition.image === 'string' ? definition.image : components[name]?.image;
|
|
189
|
+
if (typeof image !== 'string') continue;
|
|
190
|
+
const repository = repositoryOf(image);
|
|
191
|
+
const known = KNOWN_IMAGES[repository];
|
|
192
|
+
const role = components[name]?.role ?? 'stateful';
|
|
193
|
+
const profiles = asList(definition.profiles);
|
|
194
|
+
const command = typeof definition.command === 'string' ? words(definition.command) : asList(definition.command).map(String);
|
|
195
|
+
services.push({
|
|
196
|
+
name,
|
|
197
|
+
image,
|
|
198
|
+
repository,
|
|
199
|
+
tag: isTagged(image) ? image.slice(imagePathOf(image).length + 1) : null,
|
|
200
|
+
role,
|
|
201
|
+
kind: known?.kind ?? 'other',
|
|
202
|
+
aliases: known?.aliases ?? [repository],
|
|
203
|
+
probe: known?.probe ?? { tcp: true },
|
|
204
|
+
command,
|
|
205
|
+
environment: environmentOf(definition.environment),
|
|
206
|
+
ports: containerPortsOf(definition.ports),
|
|
207
|
+
mounts: mountsOf(definition.volumes, composeDirectory),
|
|
208
|
+
persistent: persistsData(definition.volumes),
|
|
209
|
+
runs: !NOT_RUN_ROLES.has(role) && !profiles.some(profile => NOT_RUN_PROFILES.has(profile)),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const proxy = services.find(service => service.kind === 'proxy') ?? null;
|
|
213
|
+
return { environment, directory, services, proxy };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The names a fake of `service` could hide behind: its own name, its image repository and the aliases of a well-known image. */
|
|
217
|
+
export const namesOfService = service => [...new Set([service.name, service.repository, ...service.aliases])];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-secrets.mjs — test credentials follow the product repository's own `.starcistacks` convention (owner ruling
|
|
3
|
+
// push-scan-test-secrets-encrypted, 2026-09-28: they work exactly like the stack custody and a plaintext password is never pushed).
|
|
4
|
+
// There is no store of the runtime's own: a test credential lives at `<repo>/.starcistacks/<stack>/secrets/test/<name>`,
|
|
5
|
+
// its plaintext git-ignored by the repo's `.starcistacks/**` rules and only the sops-encrypted twin `<name>.enc`
|
|
6
|
+
// committed. It is written with the repository's existing command (`node scripts/stack-secret.mjs set
|
|
7
|
+
// <stack>/secrets/test/<name>`, npm run secret:set), which encrypts against the recipients `.sops.yaml` names and
|
|
8
|
+
// removes the plaintext; this module only READS: the local plaintext when present, else the `.enc` decrypted by sops
|
|
9
|
+
// with the shared age identity (SOPS_AGE_KEY_FILE, default ~/.starci/master.identity) into memory.
|
|
10
|
+
// A credential that need not be stable across runs (a disposable account registered per run) is generated per run
|
|
11
|
+
// and stored nowhere. A value is never logged.
|
|
12
|
+
//
|
|
13
|
+
// node scripts/lib/test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal] prints nothing unless --reveal
|
|
14
|
+
//
|
|
15
|
+
// In a script: import { testSecret } from '<runtime>/scripts/lib/test-secrets.mjs';
|
|
16
|
+
// const password = testSecret('login-capture-password', { repo });
|
|
17
|
+
// The push secret scan (scripts/supervisor/push-mains.mjs) passes a `.enc` only when isSopsEnvelope holds.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
24
|
+
|
|
25
|
+
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
26
|
+
const STACK = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
27
|
+
/** One sops-encrypted leaf: `ENC[AES256_GCM,data:...,iv:...,tag:...,type:...]` (data may be empty). */
|
|
28
|
+
const SOPS_VALUE = /^ENC\[AES256_GCM,data:[A-Za-z0-9+/=]*,iv:[A-Za-z0-9+/=]+,tag:[A-Za-z0-9+/=]+,type:[a-z]+\]$/;
|
|
29
|
+
|
|
30
|
+
/** Where test credential `name` of `stack` lives in `repo`: {plain, enc, rel}. `rel` is the path the repo's
|
|
31
|
+
* `stack-secret.mjs set` takes. */
|
|
32
|
+
export function testSecretPaths(name, { repo, stack = 'dev' } = {}) {
|
|
33
|
+
if (!NAME.test(String(name ?? ''))) throw new Error(`test secret name must match ${NAME} (got ${JSON.stringify(name)})`);
|
|
34
|
+
if (!STACK.test(String(stack ?? ''))) throw new Error(`stack must match ${STACK} (got ${JSON.stringify(stack)})`);
|
|
35
|
+
if (!repo) throw new Error('test secret needs {repo}: the product repository whose .starcistacks holds it');
|
|
36
|
+
const rel = `${stack}/secrets/test/${name}`;
|
|
37
|
+
const plain = path.join(path.resolve(String(repo)), '.starcistacks', stack, 'secrets', 'test', name);
|
|
38
|
+
return { plain, enc: `${plain}.enc`, rel };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The command that stores a test credential, in the product repository's own tooling. */
|
|
42
|
+
export const setCommand = (name = '<name>', stack = 'dev') => `node scripts/stack-secret.mjs set ${stack}/secrets/test/${name}`;
|
|
43
|
+
|
|
44
|
+
/** sops' format for a plaintext path, as the repository's stack-secret.mjs formatFor decides it. */
|
|
45
|
+
export const sopsFormatFor = (file) => (file.endsWith('.env') ? 'dotenv' : /\.json$/.test(file) ? 'json' : /\.(ya?ml|kubeconfig)$/.test(file) ? 'yaml' : 'binary');
|
|
46
|
+
|
|
47
|
+
/** The sops binary: PATH, then winget's Links and Packages (spawn without a shell ignores PATHEXT). */
|
|
48
|
+
export function resolveSops(env = process.env) {
|
|
49
|
+
const win = process.platform === 'win32';
|
|
50
|
+
const names = win ? ['sops.exe', 'sops'] : ['sops'];
|
|
51
|
+
const dirs = String(env.PATH ?? '').split(win ? ';' : ':').filter(Boolean);
|
|
52
|
+
if (win && env.LOCALAPPDATA) {
|
|
53
|
+
const winget = path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
|
|
54
|
+
dirs.push(path.join(winget, 'Links'));
|
|
55
|
+
const packages = path.join(winget, 'Packages');
|
|
56
|
+
try { for (const e of fs.readdirSync(packages)) if (/sops/i.test(e)) dirs.push(path.join(packages, e)); } catch { /* none */ }
|
|
57
|
+
}
|
|
58
|
+
for (const dir of dirs) for (const n of names) { const f = path.join(dir, n); try { if (fs.statSync(f).isFile()) return f; } catch { /* next */ } }
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read test credential `name`: the local plaintext when present, else its committed `.enc` through sops. */
|
|
63
|
+
export function testSecret(name, { repo, stack = 'dev', env = process.env, sops = null } = {}) {
|
|
64
|
+
const { plain, enc, rel } = testSecretPaths(name, { repo, stack });
|
|
65
|
+
if (fs.existsSync(plain)) return fs.readFileSync(plain, 'utf8').replace(/\r?\n$/, '');
|
|
66
|
+
if (!fs.existsSync(enc)) throw new Error(`test secret ${name} is not in .starcistacks/${rel}.enc; store it with \`${setCommand(name, stack)}\` in ${repo}`);
|
|
67
|
+
const bin = sops ?? resolveSops(env);
|
|
68
|
+
if (!bin) throw new Error('sops is not installed (Windows: winget install Mozilla.SOPS)');
|
|
69
|
+
const format = sopsFormatFor(plain);
|
|
70
|
+
const r = spawnSync(bin, ['--decrypt', '--input-type', format, '--output-type', format, enc], {
|
|
71
|
+
cwd: path.resolve(String(repo)), encoding: 'utf8', windowsHide: true,
|
|
72
|
+
env: { ...env, SOPS_AGE_KEY_FILE: env.SOPS_AGE_KEY_FILE || path.join(os.homedir(), '.starci', 'master.identity') },
|
|
73
|
+
});
|
|
74
|
+
if (r.status !== 0) throw new Error(`test secret ${name}: sops could not decrypt .starcistacks/${rel}.enc (${String(r.stderr ?? r.error?.message ?? '').trim().split(/\r?\n/).pop()})`);
|
|
75
|
+
return String(r.stdout).replace(/\r?\n$/, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every leaf outside the `sops` block is an ENC[...] value (or null); the block itself carries a mac. */
|
|
79
|
+
const allEncrypted = (node) => {
|
|
80
|
+
if (node === null) return true;
|
|
81
|
+
if (Array.isArray(node)) return node.every(allEncrypted);
|
|
82
|
+
if (typeof node === 'object') return Object.values(node).every(allEncrypted);
|
|
83
|
+
return typeof node === 'string' && SOPS_VALUE.test(node);
|
|
84
|
+
};
|
|
85
|
+
const sopsTree = (doc) => !!doc && typeof doc === 'object' && !Array.isArray(doc) && !!doc.sops && typeof doc.sops === 'object'
|
|
86
|
+
&& typeof doc.sops.mac === 'string' && SOPS_VALUE.test(doc.sops.mac)
|
|
87
|
+
&& Object.keys(doc).length > 1 && Object.entries(doc).every(([k, v]) => k === 'sops' || allEncrypted(v));
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True only for a sops-encrypted file with no plaintext value: binary/json form ({"data": ENC[...], "sops": {...}}),
|
|
91
|
+
* yaml form (every value ENC[...] plus the sops block) or dotenv form (KEY=ENC[...] lines plus sops_* metadata).
|
|
92
|
+
* The sops metadata must carry its mac. A key kept plaintext through `unencrypted_suffix` does not pass.
|
|
93
|
+
*/
|
|
94
|
+
export function isSopsEnvelope(text) {
|
|
95
|
+
const src = String(text ?? '').replace(/^/, '');
|
|
96
|
+
if (!src.trim()) return false;
|
|
97
|
+
try { return sopsTree(JSON.parse(src)); } catch { /* not JSON */ }
|
|
98
|
+
const lines = src.split(/\r?\n/).filter((l) => l.trim() && !/^\s*#/.test(l));
|
|
99
|
+
if (lines.length && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))) {
|
|
100
|
+
const pairs = lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]);
|
|
101
|
+
const meta = pairs.filter(([k]) => k.startsWith('sops_'));
|
|
102
|
+
const vals = pairs.filter(([k]) => !k.startsWith('sops_'));
|
|
103
|
+
return vals.length > 0 && vals.every(([, v]) => SOPS_VALUE.test(v)) && meta.some(([k, v]) => k === 'sops_mac' && SOPS_VALUE.test(v));
|
|
104
|
+
}
|
|
105
|
+
try { return sopsTree(parseYaml(src)); } catch { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
109
|
+
const [cmd, name, ...rest] = process.argv.slice(2);
|
|
110
|
+
const opt = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
|
|
111
|
+
try {
|
|
112
|
+
if (cmd === 'get') {
|
|
113
|
+
const value = testSecret(name, { repo: opt('--repo'), stack: opt('--stack') ?? 'dev' });
|
|
114
|
+
if (rest.includes('--reveal')) process.stdout.write(`${value}\n`);
|
|
115
|
+
} else {
|
|
116
|
+
console.error(`usage: test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal]\nstore one with the product repo's own command: ${setCommand()}`);
|
|
117
|
+
process.exitCode = 2;
|
|
118
|
+
}
|
|
119
|
+
} catch (error) { console.error(String(error?.message ?? error)); process.exitCode = 1; }
|
|
120
|
+
}
|