@fabrika/provider-zerops 0.2.0 → 0.3.2

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.
Files changed (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +2 -6
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +179 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +100 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +127 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +390 -0
  25. package/src/installation/init-options.ts +148 -0
  26. package/src/installation/init.ts +394 -0
  27. package/src/installation/install-collaborators.ts +27 -0
  28. package/src/installation/install-environment.ts +193 -0
  29. package/src/installation/install-options.ts +156 -0
  30. package/src/installation/install-project.ts +201 -0
  31. package/src/installation/install-provisioning.ts +185 -0
  32. package/src/installation/install.ts +262 -0
  33. package/src/installation/installation-source.ts +42 -0
  34. package/src/installation/log.ts +77 -0
  35. package/src/installation/machine-key.ts +65 -0
  36. package/src/installation/manifest.ts +95 -0
  37. package/src/installation/placement-options.ts +33 -0
  38. package/src/installation/platform-source.ts +106 -0
  39. package/src/installation/platform-upload.ts +34 -0
  40. package/src/installation/proxy-manifest.ts +113 -0
  41. package/src/installation/secrets.ts +79 -0
  42. package/src/installation/sidecar.ts +125 -0
  43. package/src/installation/source-options.ts +12 -0
  44. package/src/installation/templates/README.md +116 -0
  45. package/src/installation/templates/fabrika.ref +1 -0
  46. package/src/installation/templates/gitignore +3 -0
  47. package/src/installation/templates/platform.yml +144 -0
  48. package/src/installation/topology/artifacts.ts +141 -0
  49. package/src/installation/topology/index.ts +13 -0
  50. package/src/installation/topology/installation-schemas.ts +225 -0
  51. package/src/installation/topology/invariants.ts +86 -0
  52. package/src/installation/topology/setups.ts +442 -0
  53. package/src/installation/topology/topology.ts +501 -0
  54. package/src/installation/topology/validate.ts +26 -0
  55. package/src/installation/upgrade-options.ts +99 -0
  56. package/src/installation/upgrade-output.ts +31 -0
  57. package/src/installation/upgrade.ts +373 -0
  58. package/src/managed-env.ts +113 -0
  59. package/src/manifest.ts +194 -344
  60. package/src/namespace/claims.ts +77 -0
  61. package/src/namespace/codec.ts +179 -0
  62. package/src/namespace/constants.ts +30 -0
  63. package/src/namespace/errors.ts +60 -0
  64. package/src/namespace/manifest.ts +28 -0
  65. package/src/namespace/operator.ts +151 -0
  66. package/src/namespace/provisioning.ts +482 -0
  67. package/src/namespace/proxy-attempt.ts +220 -0
  68. package/src/namespace/proxy-source.ts +22 -0
  69. package/src/namespace/topology.ts +115 -0
  70. package/src/namespace/types.ts +114 -0
  71. package/src/namespace.ts +34 -1271
  72. package/src/plan.ts +21 -23
  73. package/src/provider.ts +266 -241
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
@@ -0,0 +1,116 @@
1
+ # {{REPO}} — the {{INSTALLATION}} fabrika installation (Zerops)
2
+
3
+ The **per-installation root of trust** for the `{{INSTALLATION}}` fabrika installation on Zerops. Its
4
+ GitHub pipeline deploys the platform so that the platform never deploys itself: the credentials for
5
+ this Zerops account live here, in a GitHub Environment, and not in the public
6
+ [contember/fabrika-platform](https://github.com/contember/fabrika-platform) repository that many
7
+ operators install from.
8
+
9
+ ## This pipeline UPDATES an installation. It does not create one.
10
+
11
+ Running it against a project that has no `iam`, `operations`, `proxy` and `control` service fails and
12
+ changes nothing. The first bring-up is `fabrika platform install --provider=zerops --source-dir=.`, run once from the
13
+ operator's machine: it imports the Zerops topology without code, generates and writes every secret each
14
+ service holds, and deploys, so the proxy has HTTP ports and therefore a public address.
15
+ `fabrika platform admin --provider=zerops` then admits the first administrator. The whole procedure is
16
+ in [installing-on-zerops.md](https://github.com/contember/fabrika-platform/blob/main/docs/reference/installing-on-zerops.md).
17
+
18
+ From then on, this pipeline owns the installation.
19
+
20
+ ## One step, and why
21
+
22
+ The whole ordered sequence lives inside `fabrika platform deploy --provider=zerops`:
23
+
24
+ ```
25
+ resolve the project and its services
26
+ → write every service's environment (the composed proxy manifest, the environment name, the origins)
27
+ → deploy iam + operations together, then proxy → control, waiting for each
28
+ → ensure the public entry point
29
+ → reconcile the console's app schema with IAM
30
+ ```
31
+
32
+ That order is a security property, not just a dependency: a fabrika application enforces nothing by
33
+ itself, so the enforcement proxy is deployed **before** the control plane whose gates just changed. It
34
+ therefore lives in code with a test, rather than in a workflow file that can be edited. The Cloudflare
35
+ sidecar repository keeps its order in the workflow instead; the two providers differ deliberately.
36
+
37
+ The run is unattended and idempotent. A re-run redeploys; it writes only the variables that differ,
38
+ enables no subdomain that is already published, and reconciles the same schema. A run that cannot
39
+ apply the proxy manifest never reaches the deploy, so new code never ends up behind an older, more
40
+ permissive manifest.
41
+
42
+ ## What `fabrika.ref` pins
43
+
44
+ [`fabrika.ref`](./fabrika.ref) pins a **published tag** of `contember/fabrika-platform` — never a
45
+ branch. The workflow refuses anything that does not look like a tag.
46
+
47
+ That tag decides everything this pipeline DOES: the deploy order, the proxy manifest it composes (the
48
+ gate rules that enforce the whole installation), the console schema it reconciles, and every variable
49
+ it writes.
50
+
51
+ The workflow runs with `--source-dir=.` from the selected checkout and uploads one exact committed
52
+ archive for every installation build. The summary reports the resolved commit. Install and deploy
53
+ require a clean full checkout containing the executing CLI, generated manifest and console schema.
54
+ They reject `--from-git` and `FABRIKA_ZEROPS_BUILD_FROM_GIT`; remove those settings before upgrading.
55
+
56
+ Install and deploy write `FABRIKA_ZEROPS_PROXY_SOURCE_JSON` on Control as the default for new
57
+ namespace planning: the public `contember/fabrika-platform` repository, this snapshot's exact commit,
58
+ and its verified descriptor. Existing namespace pins are retained when that default changes.
59
+ Namespace source resolution verifies public commit availability before its effects. Preparing a local snapshot does not publish its commit to GitHub.
60
+
61
+ ## The `{{INSTALLATION}}` GitHub Environment
62
+
63
+ Written by `fabrika platform init --provider=zerops {{INSTALLATION}}`, not by hand.
64
+
65
+ Public repositories deploy without a GitHub App. Configure an organization-owned GitHub App in the
66
+ authenticated Control console at **Settings → Source**. Its private key and webhook secret stay in
67
+ Control's encrypted vault; neither is copied into this repository or Environment. Init never reads or
68
+ writes a GitHub App credential or a local credential recovery file.
69
+ If an older init left such a file, adopt a complete remote credential set in Control and delete the
70
+ file only after the UI reports connected. If the remote state is empty or partial and the file is the
71
+ only complete copy, use the last compatible CLI release to finish remote persistence first.
72
+
73
+ **Secrets** — both belong to the installation and are placed on it at bring-up. `init` copies them
74
+ here; it never generates one, because a value this repository invented would not be the value the
75
+ installation already holds.
76
+
77
+ | Secret | What it is |
78
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
79
+ | `FABRIKA_ZEROPS_ACCESS_TOKEN` | A Zerops **integration** token scoped to this installation's projects (client role `NO_ACCESS`, per-project `ADMIN`) — never a personal access token. |
80
+ | `FABRIKA_IAM_PROVISIONING_KEY` | The `px_` admin key the installation's IAM already holds as `FABRIKA_IAM_PROVISIONING_KEY`. The deploy authenticates the console's schema PUT with it. |
81
+
82
+ **Variables** — the rest of the configuration, all non-secret.
83
+
84
+ | Variable | Required | What it is |
85
+ | ---------------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
86
+ | `FABRIKA_ZEROPS_PROJECT_ID` | yes | The Zerops project holding the four platform runtime services. |
87
+ | `FABRIKA_PLATFORM_ENVIRONMENT` | yes | The name this installation calls itself; written to every service as `ENVIRONMENT`. Never `local`. |
88
+ | `FABRIKA_PLATFORM_SCHEME` | yes | `https` for anything reachable from a browser. |
89
+ | `FABRIKA_PLATFORM_IAM_HOST` | no | Where IAM answers publicly. |
90
+ | `FABRIKA_PLATFORM_CONSOLE_HOST` | no | Where the console answers publicly. |
91
+ | `FABRIKA_PLATFORM_OPERATIONS_HOST` | no | Where Operations ingest answers publicly. |
92
+ | `FABRIKA_ZEROPS_API_URL` | no | Region API base, when this installation is not on the default one. |
93
+
94
+ The three host variables go together: **all three, or none.** Naming none derives them from the
95
+ proxy's generated Zerops subdomains, and the deploy publishes that subdomain. Naming all three makes
96
+ this a custom-domain installation — the domains are bound to the project balancer out of band, and the
97
+ deploy publishes no subdomain, because that would add a second public address nobody asked for.
98
+
99
+ There is deliberately **no bootstrap-admin variable here.** This pipeline writes no credential and no
100
+ admission list — it configures where the installation answers and deploys it. Who may administer the
101
+ installation is IAM's to say, and it is said through IAM's own admin surface. Nothing about this
102
+ repository has to be closed off later.
103
+
104
+ ## Roll forward, or redeploy
105
+
106
+ - Run `fabrika platform upgrade --provider=zerops --to=<tag> {{INSTALLATION}}`, or bump
107
+ [`fabrika.ref`](./fabrika.ref) to a newer published tag and push. That is the version bump.
108
+ - Or run _Actions → platform → Run workflow_ for a redeploy at the same tag.
109
+ - Tick **dry_run** on that manual run to read the installation and report what would change without
110
+ writing or deploying anything. It is the safe first run after editing the Environment.
111
+
112
+ Only a change to `fabrika.ref` or to the workflow triggers a push-driven run; editing this README does
113
+ not deploy.
114
+
115
+ `fabrika.ref` is operator-owned: `fabrika platform init` writes it once and never overwrites a later
116
+ version pin.
@@ -0,0 +1 @@
1
+ {{FABRIKA_REF}}
@@ -0,0 +1,3 @@
1
+ # `fabrika platform init --provider=zerops` writes no credential to disk. Nothing generated here needs
2
+ # to be ignored — this entry exists so a stray local .env can never be committed by accident.
3
+ .env
@@ -0,0 +1,144 @@
1
+ name: platform
2
+
3
+ # Deploys the {{INSTALLATION}} fabrika installation on Zerops. THIS repository is the per-installation
4
+ # root of trust: it holds the account's credentials, so the platform never deploys itself (ADR-0025).
5
+ #
6
+ # IT UPDATES AN INSTALLATION THAT ALREADY EXISTS — it never creates one. `fabrika platform install`
7
+ # owns the first bring-up: import the topology without code, write every secret, then deploy.
8
+ #
9
+ # ONE step, deliberately. On Zerops `fabrika platform deploy` owns the whole ordered sequence: resolve
10
+ # the project and its services, write every service's environment (including the composed proxy
11
+ # manifest), deploy iam + operations together and then proxy -> control waiting for each,
12
+ # ensure the public entry point, and reconcile the console's app schema. That sequence carries a security
13
+ # property — since ADR-0022 the application enforces nothing, so the enforcement point is deployed BEFORE
14
+ # the service whose gates just widened — and it therefore lives in code with a test rather than in this
15
+ # file, which an operator owns and may edit (ADR-0027). The Cloudflare sidecar keeps three steps here;
16
+ # the asymmetry is deliberate.
17
+ #
18
+ # Generated and maintained by `fabrika platform init --provider=zerops {{INSTALLATION}}`. Secrets and
19
+ # variables live in the `{{INSTALLATION}}` GitHub Environment (Settings -> Environments -> {{INSTALLATION}})
20
+ # and are written there by that command — do not hand-edit them.
21
+ #
22
+ # contember/fabrika-platform is public and checked out tokenless at the TAG pinned in `fabrika.ref`.
23
+ # Bump that tag and push to roll this installation forward — `fabrika platform upgrade --provider=zerops
24
+ # --to=<tag> {{INSTALLATION}}` does exactly that and then follows the run this file starts.
25
+
26
+ on:
27
+ workflow_dispatch:
28
+ inputs:
29
+ dry_run:
30
+ description: >-
31
+ Read the installation and report what would change, writing nothing and deploying nothing.
32
+ The safe first run after an Environment edit.
33
+ type: boolean
34
+ default: false
35
+ push:
36
+ branches: [main]
37
+ # Only a version bump (or a workflow edit) redeploys — not a README tweak.
38
+ paths: ['fabrika.ref', '.github/workflows/platform.yml']
39
+
40
+ concurrency:
41
+ group: platform-deploy
42
+ cancel-in-progress: false
43
+
44
+ permissions:
45
+ contents: read
46
+
47
+ jobs:
48
+ deploy:
49
+ runs-on: ubuntu-latest
50
+ # MEASURED (2026-08-21, two warm-cache runs): ~9 min 35 s for five builds one after another, when a
51
+ # `source` service still existed. With four services and iam + operations built at once the critical
52
+ # path is shorter still — projected, not yet measured. The command itself polls each build for up to 70
53
+ # minutes (Zerops caps a pipeline at one hour), so this is the outer guard, not the gate.
54
+ timeout-minutes: 90
55
+ # Secrets + variables resolve from this GitHub Environment (created + populated by `fabrika platform init`).
56
+ environment: {{INSTALLATION}}
57
+ steps:
58
+ - name: Checkout this repository (the pinned ref and this workflow)
59
+ uses: actions/checkout@v5
60
+ with:
61
+ persist-credentials: false
62
+
63
+ - name: Resolve the pinned fabrika tag
64
+ id: ref
65
+ # A published TAG, never a branch, selects the installation checkout.
66
+ run: |
67
+ ref="$(tr -d '[:space:]' < fabrika.ref)"
68
+ case "$ref" in
69
+ v[0-9]*) ;;
70
+ *)
71
+ echo "fabrika.ref must pin a published tag such as v0.1.0, not '$ref'." >&2
72
+ exit 1
73
+ ;;
74
+ esac
75
+ echo "fabrika=$ref" >> "$GITHUB_OUTPUT"
76
+
77
+ - name: Checkout contember/fabrika-platform@${{ steps.ref.outputs.fabrika }}
78
+ env:
79
+ PINNED_REF: ${{ steps.ref.outputs.fabrika }}
80
+ GIT_TERMINAL_PROMPT: '0'
81
+ run: |
82
+ git clone --no-checkout https://github.com/contember/fabrika-platform.git fabrika-platform
83
+ git -C fabrika-platform checkout --detach "refs/tags/$PINNED_REF"
84
+
85
+ - name: Record the resolved source commit
86
+ id: source
87
+ working-directory: fabrika-platform
88
+ run: echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
89
+
90
+ - uses: oven-sh/setup-bun@v2
91
+ with:
92
+ bun-version: latest
93
+
94
+ - name: Install fabrika
95
+ working-directory: fabrika-platform
96
+ run: bun install --frozen-lockfile
97
+
98
+ - name: Put the fabrika workspace bin on PATH
99
+ working-directory: fabrika-platform
100
+ # Asserted, not assumed: `bun install` links a workspace package's bin only when the root package
101
+ # depends on it, so an older pinned tag can install cleanly and still have no `fabrika` at all.
102
+ # Without this the failure is a bare `command not found` two steps later, which names nothing.
103
+ run: |
104
+ if [ ! -x node_modules/.bin/fabrika ]; then
105
+ echo "fabrika-platform@${{ steps.ref.outputs.fabrika }} installed no \`fabrika\` binary." >&2
106
+ echo "Pin a tag whose root package.json depends on @fabrika/cli." >&2
107
+ exit 1
108
+ fi
109
+ echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
110
+
111
+ - name: Deploy the platform
112
+ id: platform
113
+ working-directory: fabrika-platform
114
+ env:
115
+ # ── Credentials ──────────────────────────────────────────────────────────────────────────────
116
+ # `platform deploy` reads every credential from the environment and offers no flag for one, so
117
+ # none of these can reach a CI log line or a process listing. It writes no credential either.
118
+ FABRIKA_ZEROPS_ACCESS_TOKEN: ${{ secrets.FABRIKA_ZEROPS_ACCESS_TOKEN }}
119
+ FABRIKA_IAM_PROVISIONING_KEY: ${{ secrets.FABRIKA_IAM_PROVISIONING_KEY }}
120
+ # ── Which installation ───────────────────────────────────────────────────────────────────────
121
+ # An empty variable is treated as absent, so an unset optional one below changes nothing.
122
+ FABRIKA_ZEROPS_PROJECT_ID: ${{ vars.FABRIKA_ZEROPS_PROJECT_ID }}
123
+ FABRIKA_PLATFORM_ENVIRONMENT: ${{ vars.FABRIKA_PLATFORM_ENVIRONMENT }}
124
+ FABRIKA_PLATFORM_SCHEME: ${{ vars.FABRIKA_PLATFORM_SCHEME }}
125
+ # All three, or none. None derives them from the proxy's Zerops subdomains and publishes one;
126
+ # all three make this a custom-domain installation and publish nothing.
127
+ FABRIKA_PLATFORM_IAM_HOST: ${{ vars.FABRIKA_PLATFORM_IAM_HOST }}
128
+ FABRIKA_PLATFORM_CONSOLE_HOST: ${{ vars.FABRIKA_PLATFORM_CONSOLE_HOST }}
129
+ FABRIKA_PLATFORM_OPERATIONS_HOST: ${{ vars.FABRIKA_PLATFORM_OPERATIONS_HOST }}
130
+ # Only for an installation outside the default region.
131
+ FABRIKA_ZEROPS_API_URL: ${{ vars.FABRIKA_ZEROPS_API_URL }}
132
+ run: fabrika platform deploy --provider=zerops --source-dir=. ${{ inputs.dry_run && '--dry-run' || '' }}
133
+
134
+ - name: Summary
135
+ if: always()
136
+ run: |
137
+ {
138
+ echo "### platform deploy → {{INSTALLATION}} (zerops)"
139
+ echo ""
140
+ echo "- fabrika tag: \`${{ steps.ref.outputs.fabrika || '(unresolved)' }}\`"
141
+ echo "- source commit: \`${{ steps.source.outputs.commit || '(unresolved)' }}\`"
142
+ echo "- mode: \`${{ inputs.dry_run && 'dry-run' || 'deploy' }}\`"
143
+ echo "- platform deploy: \`${{ steps.platform.outcome || 'not run' }}\`"
144
+ } >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,141 @@
1
+ // The generated artifacts, as pure values: every file `render.ts` in `@fabrika/platform-artifacts` writes,
2
+ // computed with no filesystem access at all. That package's tests compare these against what is on disk, so
3
+ // a committed artifact that has drifted from the declarations fails `bun test` rather than surprising an
4
+ // operator who applies it.
5
+ //
6
+ // Generated-but-COMMITTED is the same call the repo already makes for `wrangler.jsonc`: the artifact is
7
+ // what an operator actually feeds to the platform, so it should be reviewable in a diff — but nobody
8
+ // should hand-edit it, because the types are upstream of it.
9
+
10
+ import { renderYaml, type ZeropsImportDocument } from '../../index'
11
+ import { fabrikaZeropsYaml } from './setups'
12
+ import { type CompiledDocument, compileTopology, fabrikaTopologies } from './topology'
13
+
14
+ /** One file to write, with a path relative to the repository root. */
15
+ export interface Artifact {
16
+ /** Repo-relative, POSIX-separated. */
17
+ path: string
18
+ content: string
19
+ }
20
+
21
+ const banner = (lines: string[]): string => `${lines.map((line) => (line === '' ? '#' : `# ${line}`)).join('\n')}\n#\n`
22
+
23
+ /**
24
+ * Which endpoint takes THIS document, read off the document rather than off the form it was compiled in.
25
+ *
26
+ * A `project:` block is only valid at the endpoint that CREATES a project; the service-stack endpoint
27
+ * imports into one that already exists. Both forms of a project-bearing topology therefore name the
28
+ * project-import endpoint — the steady form used to claim the other one, which was wrong in the file's
29
+ * own first instruction.
30
+ */
31
+ const applyTo = (document: ZeropsImportDocument): string =>
32
+ document.project === undefined
33
+ ? 'POST /project/{projectId}/service-stack/import { yaml }'
34
+ : 'POST /client/{clientId}/project/import { yaml }'
35
+
36
+ /**
37
+ * ADR-0004's line, told truthfully for each shape.
38
+ *
39
+ * A services-only document sets no project-level isolation and cannot: `envIsolation` is settable at
40
+ * project CREATION only. The per-service value the compiler writes overrides the project's, which is
41
+ * what makes dropping the block safe — but the project it lands in still has to have been created right,
42
+ * and nothing in this document can check that.
43
+ */
44
+ const isolationNote = (document: ZeropsImportDocument): string[] =>
45
+ document.project === undefined
46
+ ? [
47
+ '`envIsolation: service` on EVERY service is written by the compiler, not by the declaration, and',
48
+ "re-checked before serialization; a service-level value overrides the project's (ADR-0004). This",
49
+ 'document sets NO project-level isolation and cannot — the field is settable at project CREATION',
50
+ 'only, so the project this is imported into must already carry `envIsolation: service`.',
51
+ ]
52
+ : [
53
+ '`envIsolation: service` at BOTH levels and the absence of any project-level `envVariables` are',
54
+ 'written by the compiler, not by the declaration, and re-checked before serialization (ADR-0004).',
55
+ ]
56
+
57
+ const IMPORT_HEADER = (source: string, document: ZeropsImportDocument, notes: string[]): string =>
58
+ banner([
59
+ 'GENERATED FILE — DO NOT EDIT BY HAND.',
60
+ '',
61
+ `Source: packages/provider-zerops/src/installation/topology/${source}`,
62
+ 'Regenerate: bun run --filter @fabrika/platform-artifacts gen',
63
+ '',
64
+ `Apply with: ${applyTo(document)}`,
65
+ '',
66
+ ...isolationNote(document),
67
+ ...notes,
68
+ ])
69
+
70
+ /**
71
+ * The step applying this file does NOT perform, stated in the file itself.
72
+ *
73
+ * `enableSubdomainAccess: true` is accepted by the import and then silently dropped — live-verified, on a
74
+ * service the document creates as well as on one it overrides. An operator who applies a subdomain
75
+ * topology and stops there gets a project with no public entry point and no error, which is the one
76
+ * outcome worth spending header lines on. (The namespace lifecycle makes this call itself; the platform
77
+ * installation has no such driver, so here it is a hand step.)
78
+ */
79
+ const publicAccessNote = (publicService: string): string[] => [
80
+ '',
81
+ `APPLYING THIS FILE PUBLISHES NOTHING. \`enableSubdomainAccess: true\` on \`${publicService}\` is a`,
82
+ 'declaration of intent that the platform accepts and drops; the service reads back',
83
+ `\`subdomainAccess: false\`. Once \`${publicService}\` has deployed an HTTP port, publish it with`,
84
+ '`PUT /service-stack/{id}/enable-subdomain-access` and confirm `subdomainAccess: true` on the service.',
85
+ ]
86
+
87
+ /**
88
+ * The forms every project topology is emitted in.
89
+ *
90
+ * *.provision.* every service `startWithoutCode: true`. FIRST bring-up: the services exist, so their
91
+ * secrets can be written through the env API (which is addressed BY SERVICE), and only
92
+ * then does anything build. This is the cost ADR-0004 accepted, paid.
93
+ * (plain) steady state. Re-applying it is how a Zerops deploy is idempotent — every service
94
+ * carries `override: true`, so an existing hostname is tolerated rather than a
95
+ * collision. It does NOT reconcile: a changed field in this document is ignored.
96
+ * *.services.* the same two, with no `project:` block, for a project the OPERATOR created. Emitted
97
+ * only for a topology that declares a `servicesTarget`.
98
+ */
99
+ const topologyArtifacts = (): Artifact[] =>
100
+ fabrikaTopologies().flatMap((topology) => {
101
+ const compiled = compileTopology(topology, 'prod')
102
+ const subdomain = compiled.steady.document.services.find((service) => service.enableSubdomainAccess === true)
103
+ const notes = subdomain === undefined ? [] : publicAccessNote(subdomain.hostname)
104
+ const forms: Array<{ suffix: string; form: CompiledDocument }> = [
105
+ { suffix: '.provision', form: compiled.provision },
106
+ { suffix: '', form: compiled.steady },
107
+ ...(compiled.servicesProvision === undefined ? [] : [{ suffix: '.services.provision', form: compiled.servicesProvision }]),
108
+ ...(compiled.servicesSteady === undefined ? [] : [{ suffix: '.services', form: compiled.servicesSteady }]),
109
+ ]
110
+ return forms.map(({ suffix, form }) => ({
111
+ path: `packages/platform-artifacts/generated/${topology.id}${suffix}.zerops-import.yaml`,
112
+ content: IMPORT_HEADER('topology.ts', form.document, notes) + form.yaml,
113
+ }))
114
+ })
115
+
116
+ /**
117
+ * The repository-root `zerops.yaml`. One file, four named setups, selected by `zeropsSetup` (which
118
+ * defaults to the service hostname) — see `./setups.ts` for why this rather than inline `zeropsYaml`.
119
+ */
120
+ const zeropsYamlArtifact = (): Artifact => ({
121
+ path: 'zerops.yaml',
122
+ content: banner([
123
+ 'GENERATED FILE — DO NOT EDIT BY HAND.',
124
+ '',
125
+ 'Source: packages/provider-zerops/src/installation/topology/setups.ts (which is where the per-field commentary lives)',
126
+ 'Regenerate: bun run --filter @fabrika/platform-artifacts gen',
127
+ '',
128
+ "Zerops reads `zerops.yaml` from the REPOSITORY ROOT, so fabrika's four deployable services share",
129
+ 'ONE file with four named setups. A service selects its setup by name; the name defaults to the',
130
+ 'service hostname, which is why `iam`, `operations`, `proxy` and `control` below are also hostnames in',
131
+ 'packages/platform-artifacts/generated/*.zerops-import.yaml.',
132
+ '',
133
+ 'NO SECRETS AND NO PER-INSTALLATION VALUES ARE IN HERE — including no `${x_y}` reference to a',
134
+ 'DATA service, because which data services exist differs between the standard and light tiers.',
135
+ 'Database and object-storage coordinates, public origins, auth/email coordinates, ENVIRONMENT and every',
136
+ 'secret are service-level variables written through the env API (ADR-0004).',
137
+ ]) + renderYaml(fabrikaZeropsYaml),
138
+ })
139
+
140
+ /** Every artifact this package generates, in a stable order. */
141
+ export const generatedArtifacts = (): Artifact[] => [...topologyArtifacts(), zeropsYamlArtifact()]
@@ -0,0 +1,13 @@
1
+ export { type Artifact, generatedArtifacts } from './artifacts'
2
+ export { assertCorePackageIsExplicit, assertOnlyPublicService, assertZeropsHostnames } from './invariants'
3
+ export { fabrikaZeropsYaml, PROXY_HEALTH_PORT, PROXY_PUBLIC_PORTS } from './setups'
4
+ export {
5
+ appsTopology,
6
+ type CompiledDocument,
7
+ type CompiledTopology,
8
+ compileTopology,
9
+ fabrikaTopologies,
10
+ platformTopology,
11
+ PROXY_HOSTNAME,
12
+ } from './topology'
13
+ export { assertArtifactMatchesSchema, parseYaml, validateYaml } from './validate'
@@ -0,0 +1,225 @@
1
+ import { z } from 'zod'
2
+
3
+ const strings = z.array(z.string())
4
+ const stringOrList = z.union([z.string(), strings])
5
+ const environment = z.record(z.union([z.string(), z.number(), z.boolean()]))
6
+ // Installation images only; the provider's public catalog remains independent.
7
+ const runtime = z.enum(['alpine/bun@1.3', 'alpine@3.21', 'alpine/go@latest', 'alpine/nodejs@22', 'ubuntu/bun@1.3'])
8
+ // Live probes require strings; generated-value tests enforce the installation's [10s, 1h] policy.
9
+ const duration = z.string()
10
+ const httpGet = z.object({ scheme: z.string().optional(), host: z.string().optional(), port: z.number().int(), path: z.string() }).strict()
11
+ const exec = z.object({ command: z.string() }).strict()
12
+ const readinessCheck = z.object({
13
+ httpGet: httpGet.optional(),
14
+ exec: exec.optional(),
15
+ failureTimeout: duration.optional(),
16
+ retryPeriod: duration.optional(),
17
+ }).strict()
18
+ const healthCheck = z.object({
19
+ httpGet: httpGet.optional(),
20
+ exec: exec.optional(),
21
+ failureTimeout: duration.optional(),
22
+ disconnectTimeout: duration.optional(),
23
+ recoveryTimeout: duration.optional(),
24
+ execPeriod: duration.optional(),
25
+ }).strict()
26
+ const routing = z.object({
27
+ root: z.string().optional(),
28
+ cors: z.string().optional(),
29
+ redirects: z.array(
30
+ z.object({
31
+ from: z.string(),
32
+ to: z.string(),
33
+ status: z.number().int().optional(),
34
+ preservePath: z.boolean().optional(),
35
+ preserveQuery: z.boolean().optional(),
36
+ }).strict(),
37
+ ).optional(),
38
+ headers: z.array(z.object({ for: z.string(), values: z.record(z.string()) }).strict()).optional(),
39
+ }).strict()
40
+ export const setupSchema = z.object({
41
+ setup: z.string(),
42
+ extends: stringOrList.optional(),
43
+ build: z.object({
44
+ os: z.enum(['alpine', 'ubuntu']).optional(),
45
+ base: z.union([runtime, z.array(runtime)]).optional(),
46
+ prepareCommands: strings.optional(),
47
+ buildCommands: strings.optional(),
48
+ deploy: stringOrList.optional(),
49
+ deployFiles: stringOrList,
50
+ cache: z.union([z.boolean(), stringOrList]).optional(),
51
+ addToRunPrepare: stringOrList.optional(),
52
+ envVariables: environment.optional(),
53
+ }).strict().optional(),
54
+ deploy: z.object({ readinessCheck: readinessCheck.optional(), temporaryShutdown: z.boolean().optional() }).strict().optional(),
55
+ run: z.object({
56
+ initCommands: strings.optional(),
57
+ prepareCommands: strings.optional(),
58
+ os: z.enum(['alpine', 'ubuntu']).optional(),
59
+ base: runtime.optional(),
60
+ start: z.string().optional(),
61
+ documentRoot: z.string().optional(),
62
+ siteConfigPath: z.string().optional(),
63
+ envReplace: z.object({ target: stringOrList, delimiter: stringOrList }).strict().optional(),
64
+ healthCheck: healthCheck.optional(),
65
+ routing: routing.optional(),
66
+ ports: z.array(z.object({ protocol: z.enum(['tcp', 'udp']).optional(), port: z.number().int(), httpSupport: z.boolean().optional() }).strict())
67
+ .optional(),
68
+ envVariables: environment.optional(),
69
+ startCommands: z.array(
70
+ z.object({ name: z.string().optional(), command: z.string(), workingDir: z.string().optional(), initCommands: strings.optional() }).strict(),
71
+ ).optional(),
72
+ crontab: z.array(z.object({ timing: z.string(), command: z.string(), workingDir: z.string().optional(), allContainers: z.boolean() }).strict())
73
+ .optional(),
74
+ }).strict().optional(),
75
+ }).strict()
76
+ export const zeropsYamlSchema = z.object({ zerops: z.array(setupSchema) }).strict()
77
+
78
+ const autoscaling = z.object({
79
+ cpu: z.number().int().min(1).max(8).optional(),
80
+ minCpu: z.number().int().min(1).max(8).optional(),
81
+ maxCpu: z.number().int().min(1).max(8).optional(),
82
+ ram: z.number().min(0.125).max(48).optional(),
83
+ minRam: z.number().min(0.125).max(48).optional(),
84
+ maxRam: z.number().min(0.125).max(48).optional(),
85
+ disk: z.number().min(1).max(250).optional(),
86
+ minDisk: z.number().min(1).max(250).optional(),
87
+ maxDisk: z.number().min(1).max(250).optional(),
88
+ cpuMode: z.enum(['DEDICATED', 'SHARED']).optional(),
89
+ startCpuCoreCount: z.number().int().optional(),
90
+ swapEnabled: z.boolean().optional(),
91
+ minFreeCpuCores: z.number().optional(),
92
+ minFreeCpuPercent: z.number().optional(),
93
+ minFreeRamGB: z.number().optional(),
94
+ minFreeRamPercent: z.number().optional(),
95
+ minVCpu: z.number().int().optional(),
96
+ maxVCpu: z.number().int().optional(),
97
+ }).strict()
98
+ const postgresOverrides = z.object({
99
+ autovacuum_analyze_scale_factor: z.number().optional(),
100
+ autovacuum_max_workers: z.number().int().optional(),
101
+ autovacuum_naptime: z.number().int().optional(),
102
+ autovacuum_vacuum_cost_delay: z.number().int().optional(),
103
+ autovacuum_vacuum_cost_limit: z.number().int().optional(),
104
+ autovacuum_vacuum_scale_factor: z.number().optional(),
105
+ autovacuum_work_mem: z.number().int().optional(),
106
+ commit_delay: z.number().int().optional(),
107
+ commit_siblings: z.number().int().optional(),
108
+ default_statistics_target: z.number().int().optional(),
109
+ effective_cache_size: z.number().int().optional(),
110
+ effective_io_concurrency: z.number().int().optional(),
111
+ hash_mem_multiplier: z.number().int().optional(),
112
+ idle_in_transaction_session_timeout: z.number().int().optional(),
113
+ jit_above_cost: z.number().optional(),
114
+ maintenance_io_concurrency: z.number().int().optional(),
115
+ maintenance_work_mem: z.number().int().optional(),
116
+ max_parallel_maintenance_workers: z.number().int().optional(),
117
+ max_parallel_workers: z.number().int().optional(),
118
+ max_parallel_workers_per_gather: z.number().int().optional(),
119
+ max_wal_size: z.number().int().optional(),
120
+ max_worker_processes: z.number().int().optional(),
121
+ min_wal_size: z.number().int().optional(),
122
+ random_page_cost: z.number().optional(),
123
+ temp_buffers: z.number().int().optional(),
124
+ temp_file_limit: z.number().int().optional(),
125
+ vacuum_cost_delay: z.number().int().optional(),
126
+ wal_buffers: z.number().int().optional(),
127
+ wal_compression: z.string().optional(),
128
+ wal_writer_delay: z.number().int().optional(),
129
+ wal_writer_flush_after: z.number().int().optional(),
130
+ work_mem: z.number().int().optional(),
131
+ }).strict()
132
+ const commonService = z.object({
133
+ hostname: z.string(),
134
+ mode: z.string().optional(),
135
+ envSecrets: z.record(z.string()).optional(),
136
+ dotEnvSecrets: z.string().optional(),
137
+ nginxConfig: z.string().optional(),
138
+ objectStorageSize: z.number().optional(),
139
+ objectStoragePolicy: z.enum(['custom', 'private', 'public-read', 'public-objects-read', 'public-write', 'public-read-write']).optional(),
140
+ objectStorageRawPolicy: z.string().optional(),
141
+ buildFromGit: z.string().optional(),
142
+ startWithoutCode: z.boolean().optional(),
143
+ enableSubdomainAccess: z.boolean().optional(),
144
+ priority: z.number().int().optional(),
145
+ mount: strings.optional(),
146
+ enableCdn: z.boolean().optional(),
147
+ envIsolation: z.string().optional(),
148
+ sshIsolation: z.string().optional(),
149
+ os: z.string().optional(),
150
+ zeropsSetup: z.string().optional(),
151
+ override: z.boolean().optional(),
152
+ }).strict()
153
+ const runtimeService = commonService.extend({
154
+ type: runtime,
155
+ minContainers: z.number().int().min(1).max(10).optional(),
156
+ maxContainers: z.number().int().min(1).max(10).optional(),
157
+ verticalAutoscaling: autoscaling.optional(),
158
+ })
159
+ const postgresService = commonService.extend({
160
+ type: z.enum([
161
+ 'postgresql:ha@14',
162
+ 'postgresql:ha@16',
163
+ 'postgresql:ha@17',
164
+ 'postgresql:ha@18',
165
+ 'postgresql:single@14',
166
+ 'postgresql:single@16',
167
+ 'postgresql:single@17',
168
+ 'postgresql:single@18',
169
+ ]),
170
+ profile: z.enum(['oltp-hobby', 'oltp-staging', 'oltp-production', 'oltp-enterprise', 'olap-production', 'writeheavy-production', 'custom'])
171
+ .optional(),
172
+ // Upstream leaves overrides unconstrained when no profile is selected.
173
+ profileOverrides: z.unknown().optional(),
174
+ verticalAutoscaling: autoscaling.extend({
175
+ ram: z.number().min(0.25).max(48).optional(),
176
+ minRam: z.number().min(0.25).max(48).optional(),
177
+ maxRam: z.number().min(0.25).max(48).optional(),
178
+ }).optional(),
179
+ }).superRefine((service, ctx) => {
180
+ if (
181
+ (service.type.startsWith('postgresql:ha@') && service.profile === 'oltp-hobby')
182
+ || (service.type.startsWith('postgresql:single@') && service.profile === 'oltp-enterprise')
183
+ ) {
184
+ ctx.addIssue({ code: 'custom', path: ['profile'], message: 'profile is not supported by this PostgreSQL availability mode' })
185
+ }
186
+ if (service.profile !== undefined && service.profile !== 'custom' && service.profileOverrides !== undefined) {
187
+ ctx.addIssue({ code: 'custom', path: ['profileOverrides'], message: 'profileOverrides requires the custom PostgreSQL profile' })
188
+ }
189
+ if (service.profile === 'custom' && service.profileOverrides !== undefined) {
190
+ const overrides = postgresOverrides.safeParse(service.profileOverrides)
191
+ if (!overrides.success) {
192
+ for (const issue of overrides.error.issues) {
193
+ ctx.addIssue({ ...issue, path: ['profileOverrides', ...issue.path] })
194
+ }
195
+ }
196
+ }
197
+ })
198
+ const storageService = commonService.extend({
199
+ type: z.enum(['object-storage', 'objectstorage']),
200
+ verticalAutoscaling: autoscaling.omit({
201
+ cpu: true,
202
+ minCpu: true,
203
+ maxCpu: true,
204
+ ram: true,
205
+ minRam: true,
206
+ maxRam: true,
207
+ disk: true,
208
+ minDisk: true,
209
+ maxDisk: true,
210
+ }).optional(),
211
+ })
212
+ export const importSchema = z.object({
213
+ project: z.object({
214
+ name: z.string(),
215
+ description: z.string().optional(),
216
+ corePackage: z.enum(['LIGHT', 'SERIOUS']).optional(),
217
+ tags: strings.optional(),
218
+ envVariables: z.record(z.string()).optional(),
219
+ envIsolation: z.string().optional(),
220
+ sshIsolation: z.string().optional(),
221
+ sharedIpv4: z.boolean().optional(),
222
+ location: z.enum(['eu-central', 'us-east-1', 'us-west-1']).optional(),
223
+ }).strict().optional(),
224
+ services: z.array(z.union([runtimeService, postgresService, storageService])).optional(),
225
+ }).strict()