@fabrika/provider-zerops 0.0.26 → 0.3.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.
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 +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +75 -20
  14. package/src/control.ts +217 -447
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +24 -128
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -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 +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +208 -315
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1260
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +399 -254
  73. package/src/service-references.ts +28 -0
  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,41 @@
1
+ // GENERATED FILE — DO NOT EDIT BY HAND.
2
+ //
3
+ // Source: packages/platform-artifacts/src/console-schema.ts
4
+ // Regenerate: bun run --filter @fabrika/platform-artifacts gen
5
+ //
6
+ // The console's authorization vocabulary — `controlSchema` in `@fabrika/control` — as DATA. A
7
+ // PUBLISHED deploy command may not import the private package that declares it, and `platform deploy`
8
+ // has to send this document to IAM: the schema PUT is what REGISTERS the app, and
9
+ // `apps.setReturnOrigins` 404s for an app IAM has never heard of. `gen:check` is the drift witness.
10
+
11
+ import type { AppSchema } from '@fabrika/auth-core'
12
+
13
+ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
14
+ scopes: [
15
+ { type: 'app', label: 'App' },
16
+ { type: 'environment', label: 'Environment' },
17
+ ],
18
+ actions: [
19
+ { action: 'deploy.trigger', description: 'Trigger a deploy run' },
20
+ { action: 'deploy.read', description: 'Read deploy runs + their logs' },
21
+ { action: 'app.manage', description: 'Manage the app registry (apps + app_envs)' },
22
+ { action: 'namespace.manage', description: 'Manage deployment namespaces' },
23
+ { action: 'secret.manage', description: 'Manage secret values + their references' },
24
+ { action: 'source.connection.manage', description: 'Manage the platform GitHub source connection' },
25
+ { action: 'operations.read', description: 'Read Operations errors and activity' },
26
+ { action: 'operations.triage', description: 'Triage Operations errors' },
27
+ { action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },
28
+ ],
29
+ roles: {
30
+ operator: {
31
+ name: 'Operator',
32
+ description: 'Trigger and read any deploy (no registry or secret management).',
33
+ permissions: ['deploy.*', 'operations.*'],
34
+ },
35
+ admin: {
36
+ name: 'Admin',
37
+ description: 'Full access to every vozka action in every scope.',
38
+ permissions: ['*'],
39
+ },
40
+ },
41
+ }
@@ -0,0 +1,72 @@
1
+ // GENERATED FILE — DO NOT EDIT BY HAND.
2
+ //
3
+ // Source: packages/platform-artifacts/src/proxy-manifest.ts
4
+ // Regenerate: bun run --filter @fabrika/platform-artifacts gen
5
+ //
6
+ // The installation-independent half of `FABRIKA_PROXY_MANIFEST_JSON`: which apps the platform proxy
7
+ // fronts, at which private upstream, on which public listener, under which gates. The hosts and the
8
+ // browser-facing scheme belong to ONE installation and are supplied at deploy time —
9
+ // `resolvePlatformProxyManifest` binds them.
10
+ //
11
+ // The gate rules below are `CONTROL_PROXY_GATES` and `OPERATIONS_PROXY_GATES` as of the last
12
+ // regeneration, copied on purpose: a PUBLISHED deploy command may not import the private package that
13
+ // owns them. `gen:check` in CI is what makes the copy a fact rather than a hope.
14
+
15
+ import type { PlatformProxyManifestTemplate } from '../proxy-manifest'
16
+
17
+ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
18
+ apps: [
19
+ {
20
+ id: 'iam',
21
+ service: 'iam',
22
+ port: 8080,
23
+ upstream: 'iam:3000',
24
+ gates: {
25
+ rules: [
26
+ { path: '/*', kind: 'public' },
27
+ ],
28
+ },
29
+ },
30
+ {
31
+ id: 'vozka',
32
+ service: 'control',
33
+ port: 8082,
34
+ upstream: 'control:3000',
35
+ gates: {
36
+ rules: [
37
+ { path: '/healthz', kind: 'public' },
38
+ { path: '/.well-known/fabrika-builder', kind: 'public' },
39
+ { path: '/builder/connect', kind: 'public' },
40
+ { path: '/api/health', kind: 'public' },
41
+ { path: '/webhooks/github', kind: 'public' },
42
+ { path: '/webhooks/github/*', kind: 'public' },
43
+ { path: '/iam/admin', kind: 'service' },
44
+ { path: '/iam/admin', kind: 'human' },
45
+ { path: '/iam/admin/*', kind: 'service' },
46
+ { path: '/iam/admin/*', kind: 'human' },
47
+ { path: '/operations/api', kind: 'service' },
48
+ { path: '/operations/api', kind: 'human' },
49
+ { path: '/operations/api/*', kind: 'service' },
50
+ { path: '/operations/api/*', kind: 'human' },
51
+ { path: '/api/source/github/manifest/*', kind: 'human' },
52
+ { path: '/api/source/github/callback', kind: 'human' },
53
+ { path: '/api/*', kind: 'service' },
54
+ { path: '/api/*', kind: 'human' },
55
+ { path: '/*', kind: 'human' },
56
+ ],
57
+ },
58
+ },
59
+ {
60
+ id: 'operations',
61
+ service: 'operations',
62
+ port: 8083,
63
+ upstream: 'operations:3000',
64
+ gates: {
65
+ rules: [
66
+ { path: '/api/*/envelope/', kind: 'public' },
67
+ { path: '/api/artifacts/source-maps/', kind: 'public' },
68
+ ],
69
+ },
70
+ },
71
+ ],
72
+ }
@@ -0,0 +1,117 @@
1
+ // Where one installation's platform services answer, and the two ways a deploy can know.
2
+ //
3
+ // ── Why this is not a constant ────────────────────────────────────────────────────────────────────
4
+ //
5
+ // `resolvePlatformProxyManifest` takes the hosts as an argument precisely because they belong to ONE
6
+ // installation. There are exactly two sources for them, and which one is used decides something else
7
+ // too — whether the proxy should be published on a Zerops subdomain at all:
8
+ //
9
+ // derived the operator names none, and each platform app's host is read off the PROXY service's
10
+ // `zeropsSubdomain` variable, keyed by the listener port the committed template assigns.
11
+ // This is the `zerops-subdomain` installation, and the deploy ensures the subdomain.
12
+ // explicit the operator names all three. This is the `custom-domain` installation: the domains are
13
+ // bound to the project's L7 balancer out of band, and the deploy must NOT publish a
14
+ // subdomain — doing so would add a second public address nobody asked for.
15
+ //
16
+ // All three or none. A mix would leave the deploy unable to say which installation it is looking at.
17
+ //
18
+ // ── `zeropsSubdomain` is a NAME, not a state ──────────────────────────────────────────────────────
19
+ //
20
+ // Live-verified (`docs/reference/zerops-platform.md`): the variable is present and non-empty on a
21
+ // service whose subdomain was never enabled, and unchanged after one is disabled. That is exactly what
22
+ // makes it usable HERE, one step before `enableSubdomainAccess` — the manifest has to carry the hosts
23
+ // before the proxy that serves them is built. What it needs is a DEPLOYED HTTP port: a proxy that has
24
+ // never been deployed publishes none, so the variable names nothing and this refuses rather than
25
+ // guessing at a hostname.
26
+
27
+ import type { PlatformProxyService } from './proxy-manifest'
28
+
29
+ /** The public hostname of each platform service, without a scheme and without a port. */
30
+ export type PlatformHosts = Readonly<Record<PlatformProxyService, string>>
31
+
32
+ /** Everything the deploy needs to know about where this installation answers. */
33
+ export interface PlatformPlacement {
34
+ readonly hosts: PlatformHosts
35
+ /** The scheme the BROWSER speaks. Configuration, never a header — see `ProxyApp.scheme`. */
36
+ readonly scheme: 'http' | 'https'
37
+ /**
38
+ * Whether the proxy's public entry point is Zerops' generated subdomain. True only on the derived
39
+ * path: a custom-domain installation must not additionally publish a `.zerops.app` address.
40
+ */
41
+ readonly zeropsSubdomain: boolean
42
+ }
43
+
44
+ /** The Zerops variable naming one URL per published HTTP port. READ_ONLY, written by the platform. */
45
+ export const ZEROPS_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
46
+
47
+ /**
48
+ * Map the proxy's generated subdomains by the listener port each one fronts.
49
+ *
50
+ * The host form is `<hostname>-<hash>-<port>.<region>.zerops.app`, one line per HTTP port. Read as
51
+ * URLs rather than by splitting on `-`, so a hostname that itself contains a digit-suffixed segment
52
+ * cannot be misread: the port is the trailing `-<digits>` of the FIRST label only.
53
+ */
54
+ export const parseZeropsSubdomains = (value: string): Map<number, string> => {
55
+ const byPort = new Map<number, string>()
56
+ for (const line of value.split('\n')) {
57
+ const trimmed = line.trim()
58
+ if (trimmed === '') {
59
+ continue
60
+ }
61
+ let hostname: string
62
+ try {
63
+ hostname = new URL(trimmed).hostname
64
+ } catch {
65
+ continue
66
+ }
67
+ const label = hostname.split('.')[0] ?? ''
68
+ const match = /-(\d+)$/.exec(label)
69
+ if (match?.[1] === undefined) {
70
+ continue
71
+ }
72
+ byPort.set(Number.parseInt(match[1], 10), hostname)
73
+ }
74
+ return byPort
75
+ }
76
+
77
+ /** One platform app's listener, as the committed template assigns it. */
78
+ export interface PlatformListener {
79
+ readonly service: PlatformProxyService
80
+ readonly port: number
81
+ }
82
+
83
+ /**
84
+ * Bind every platform service to the generated subdomain of its listener.
85
+ *
86
+ * Throws naming every port it could not find, because the alternative — writing a manifest with a
87
+ * guessed or missing host — is a proxy that fronts nothing at an address the console then bounces to.
88
+ */
89
+ export const derivePlatformHosts = (subdomains: string, listeners: readonly PlatformListener[]): PlatformHosts => {
90
+ const byPort = parseZeropsSubdomains(subdomains)
91
+ const resolved = new Map<PlatformProxyService, string>()
92
+ const missing: string[] = []
93
+ for (const listener of listeners) {
94
+ const host = byPort.get(listener.port)
95
+ if (host === undefined) {
96
+ missing.push(`${listener.service} (:${listener.port})`)
97
+ continue
98
+ }
99
+ resolved.set(listener.service, host)
100
+ }
101
+ if (missing.length > 0) {
102
+ throw new Error(
103
+ `the proxy publishes no Zerops subdomain for ${missing.join(', ')} — deploy the proxy once so it has HTTP ports, `
104
+ + 'or name every host explicitly with --iam-host / --console-host / --operations-host',
105
+ )
106
+ }
107
+ const iam = resolved.get('iam')
108
+ const control = resolved.get('control')
109
+ const operations = resolved.get('operations')
110
+ if (iam === undefined || control === undefined || operations === undefined) {
111
+ throw new Error('the platform proxy manifest template does not assign a listener to every platform service')
112
+ }
113
+ return { iam, control, operations }
114
+ }
115
+
116
+ /** `https://<host>` — the origin a browser, a token's `iss` and a return-origin registry all use. */
117
+ export const platformOrigin = (scheme: 'http' | 'https', host: string): string => `${scheme}://${host}`
@@ -0,0 +1,366 @@
1
+ import { reconcileSchema } from '@fabrika/auth'
2
+ import { info as consoleInfo, ok as consoleOk, step as consoleStep, warn as consoleWarn } from '@fabrika/installation-init'
3
+ import type { SchemaReconciler } from '@fabrika/provider-contract'
4
+ import type { InstallationCli, InstallationCommand } from '@fabrika/provider-contract/installation'
5
+ import { createZeropsApi, defaultSleep } from '../index'
6
+ import { consoleAdminCollaborators, runPlatformAdmin } from './admin'
7
+ import { parsePlatformAdminArgs, REISSUE_FLAG } from './admin-options'
8
+ import { deployPlatform, PLATFORM_CONCURRENT_DEPLOY, PLATFORM_DEPLOY_ORDER, PLATFORM_SEQUENTIAL_DEPLOY } from './deploy'
9
+ import { parsePlatformDeployArgs } from './deploy-options'
10
+ import { consoleInitCollaborators, parseInitArgs, runInit } from './init'
11
+ import { consoleInstallCollaborators, runInstall } from './install'
12
+ import { parsePlatformInstallArgs, UNATTENDED_FLAG } from './install-options'
13
+ import { prepareInstallationSource } from './installation-source'
14
+ import { consoleDeployLog } from './log'
15
+ import { generatedArtifacts } from './topology/artifacts'
16
+ import { assertArtifactMatchesSchema } from './topology/validate'
17
+ import { consoleUpgradeCollaborators, FABRIKA_REPOSITORY, runUpgrade, SIDECAR_BRANCH } from './upgrade'
18
+ import { parsePlatformUpgradeArgs, UPGRADE_DRY_RUN_FLAG } from './upgrade-options'
19
+
20
+ export { PLATFORM_CONCURRENT_DEPLOY, PLATFORM_DEPLOY_ORDER, PLATFORM_PROXY_SERVICE, PLATFORM_SEQUENTIAL_DEPLOY } from './deploy'
21
+ export { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
22
+ export { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
23
+ export {
24
+ type PlatformProxyApp,
25
+ platformProxyAppFor,
26
+ type PlatformProxyComposition,
27
+ type PlatformProxyManifestTemplate,
28
+ type PlatformProxyPlacement,
29
+ type PlatformProxyService,
30
+ resolvePlatformProxyManifest,
31
+ } from './proxy-manifest'
32
+
33
+ const USAGE = `Zerops installation
34
+
35
+ Commands:
36
+ fabrika platform install --provider=zerops [options]
37
+ fabrika platform init --provider=zerops <installation> [--repo=<owner>/<name>]
38
+ fabrika platform plan --provider=zerops
39
+ fabrika platform deploy --provider=zerops [options]
40
+ fabrika platform admin --provider=zerops --email=<address> --iam-host=<host> [--scheme=<http|https>] [${REISSUE_FLAG}]
41
+ fabrika platform upgrade --provider=zerops --to=<tag> [<installation>] [--sidecar=<path>|<owner>/<name>] [${UPGRADE_DRY_RUN_FLAG}]
42
+
43
+ \`install\` CREATES an installation in a project you created empty; \`init\` and \`deploy\` UPDATE AN
44
+ INSTALLATION THAT ALREADY EXISTS. Run them in that order: install generates the provisioning key that
45
+ init then writes into the operator's GitHub Environment. \`admin\` comes LAST, once the installation is
46
+ serving: it admits the first human, and nothing before it can sign in. \`upgrade\` is the one you run
47
+ again and again afterwards: it moves the sidecar's pin to a newer published release and follows the
48
+ deploy that push triggers.
49
+
50
+ ── platform install ──────────────────────────────────────────────────────────────────────────────
51
+
52
+ The from-scratch bring-up. With \`--create-project\` it creates the project too, with core package
53
+ LIGHT and \`envIsolation: service\` — the two settings Zerops accepts at project CREATION only and never
54
+ afterwards. \`--project-id=<id>\` installs into an empty project you created yourself instead, with both
55
+ already right; nothing here can read \`envIsolation\` back or correct it.
56
+
57
+ Interactive and laptop-side. It CONFIRMS before every step that leaves your machine: reading the
58
+ project, importing the services, minting the token, building the proxy, writing the variables, and
59
+ deploying. Declining any of them stops there; nothing before the import is a mutation. A stdin that
60
+ is not a terminal is refused unless \`${UNATTENDED_FLAG}\` is given — with nobody there to answer, a default yes
61
+ is not consent.
62
+
63
+ 1. create the project and wait for it to be ACTIVE (\`--create-project\`; its id is printed before
64
+ the wait), or read the one you named — and refuse a core package the tier does not match
65
+ 2. import ${PLATFORM_DEPLOY_ORDER.length + 2} services without code, and wait for the import's processes
66
+ 3. generate six secrets and mint the control plane's Zerops INTEGRATION token
67
+ (client role NO_ACCESS, ADMIN on this project only — never your personal token)
68
+ 4. pass 1: give the proxy an EMPTY manifest and build it, so it publishes one public hostname per
69
+ HTTP port; the three platform hosts are then READ off those, never composed
70
+ 5. write every remaining variable on all four runtime services
71
+ 6. pass 2: hand the whole ordered sequence to \`platform deploy\`
72
+ 7. print the provisioning key — the CLI keeps no copy; it is held by iam and control in Zerops
73
+
74
+ It REFUSES a project that already holds an installation's generated secrets: a second bring-up would
75
+ roll a new vault key over the old one, which is unrecoverable, and new signing keys, which logs
76
+ everyone out. Once every bootstrap variable is placed, \`platform deploy\` can update the installation
77
+ or finish a failed pass 2. Read FABRIKA_IAM_PROVISIONING_KEY from iam or control and
78
+ FABRIKA_ZEROPS_ACCESS_TOKEN from control in Zerops if the handoff was interrupted.
79
+ Neither deploy nor init completes a partial bootstrap. If interrupted after a generated secret was
80
+ placed but before every bootstrap variable was placed, preserve the existing keys and arrange
81
+ operator-owned recovery, or start a fresh installation in a separate empty project.
82
+ Re-running install after any generated secret was placed is refused.
83
+
84
+ Only the LIGHT tier can be installed this way — it is the one topology that emits a services-only
85
+ import document. Authentication is PASSWORD ONLY; no administrator exists yet when this finishes.
86
+
87
+ Options (a flag beats the environment variable beside it):
88
+
89
+ --project-id=<id> FABRIKA_ZEROPS_PROJECT_ID the empty project you created
90
+ --create-project create the project on --client-id instead of
91
+ naming one with --project-id
92
+ --project-name=<name> the created project's name, default
93
+ fabrika-<env>
94
+ --client-id=<id> FABRIKA_ZEROPS_CLIENT_ID the client the token is minted on
95
+ --env=<name> FABRIKA_PLATFORM_ENVIRONMENT written to every service as ENVIRONMENT
96
+ --scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
97
+ --source-dir=<checkout> required clean full checkout running this CLI
98
+ --tier=light FABRIKA_PLATFORM_TIER default light, and the only value
99
+ ${UNATTENDED_FLAG} answer every confirmation yes —
100
+ required when stdin is not a terminal
101
+
102
+ FABRIKA_ZEROPS_ACCESS_TOKEN required Zerops access token, environment only and no flag
103
+ FABRIKA_ZEROPS_API_URL optional region API base, when not the default
104
+
105
+ ── platform init ─────────────────────────────────────────────────────────────────────────────────
106
+
107
+ Creates and maintains the operator's SIDECAR REPOSITORY: the GitHub pipeline that calls
108
+ \`platform deploy\`, the tag it is pinned to, and the GitHub Environment holding this installation's
109
+ credentials. ADR-0025 puts that pipeline in a repository the operator owns — fabrika ships the
110
+ generator, never the pipeline, because one public repository cannot hold every account's credentials.
111
+
112
+ Interactive and laptop-side. It does the whole job and CONFIRMS before every step that leaves the
113
+ operator's disk: reading the project, reading its control and proxy services, creating or pushing the
114
+ repository, writing the Environment, and triggering the run.
115
+ Declining a write stops there and prints what to run instead; a re-run is safe. Declining the read of
116
+ control and proxy only leaves the console and IAM origins out of what init prints.
117
+
118
+ Init writes nothing to Zerops. It reads control and proxy only to print the console and IAM origins
119
+ the installation records, and prints none it cannot verify. Public repositories deploy without a
120
+ GitHub App. GitHub App creation, webhook configuration, and installation verification belong to the
121
+ authenticated Control UI at \`Settings → Source\`; Control keeps the App keys in its vault (ADR-0029).
122
+ Init never prompts for, writes, recovers, or prints GitHub App credentials, and no source credential
123
+ reaches the sidecar or GitHub Environment.
124
+
125
+ If an older init left an owner-only local recovery file, this release does not open or delete it. Since
126
+ ADR-0029 there is nothing left to adopt it into: connect a new source in Control and delete the old
127
+ file afterwards.
128
+
129
+ <installation> names this installation: the GitHub Environment, the default
130
+ repository \`contember/fabrika-zerops-<installation>\`, and the
131
+ local checkout directory
132
+ --repo=<owner>/<name> a different sidecar repository
133
+
134
+ It writes TWO secrets into that Environment and GENERATES NEITHER — both already belong to the
135
+ installation, so a value invented here would not be one it accepts:
136
+
137
+ FABRIKA_ZEROPS_ACCESS_TOKEN a Zerops INTEGRATION token scoped to this installation's projects
138
+ FABRIKA_IAM_PROVISIONING_KEY the px_ admin key this installation's IAM was seeded with
139
+
140
+ Each is read from a hidden prompt or from the environment variable of the same name, sent to GitHub
141
+ over \`gh\` stdin, and never printed and never written to disk.
142
+
143
+ The generated pipeline pins a published TAG of contember/fabrika-platform and refuses a branch, at
144
+ init and again in the workflow. It uploads that checkout's exact committed archive to every installation service.
145
+
146
+ ── platform deploy ───────────────────────────────────────────────────────────────────────────────
147
+
148
+ Unattended and idempotent. It owns the WHOLE ordered sequence, so an operator's pipeline calls this
149
+ ONE step — on Cloudflare the order lives in the scaffolded workflow instead, deliberately
150
+ (ADR-0027). In order:
151
+
152
+ 1. resolve the project and its ${PLATFORM_DEPLOY_ORDER.join(', ')} services by hostname
153
+ 2. write each service's environment: the composed proxy manifest, the installation's environment
154
+ name, and the origins derived from the resolved public hosts
155
+ 3. deploy ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')}, waiting for each to become ACTIVE
156
+ 4. ensure the proxy's public entry point
157
+ 5. reconcile the console's app schema and register its return origin with IAM
158
+
159
+ The order is a security property, not just a dependency: the application enforces nothing
160
+ (ADR-0022), so the proxy is deployed BEFORE control, and every environment write happens before any
161
+ deploy. A run that cannot apply the manifest never reaches step 3. ${PLATFORM_CONCURRENT_DEPLOY.join(', ')} order
162
+ NOTHING against each other — none calls a sibling at boot and none reads a variable another's build
163
+ needs — so they build at once; a failure of one is reported with that service named, and the others
164
+ are still followed to their end.
165
+
166
+ The proxy manifest is MERGED, never replaced. On a shared project an application's entry lives in the
167
+ same document; entries the platform does not own are carried through unchanged. An entry standing on
168
+ one of the platform's own public hosts is replaced, and the run says so.
169
+
170
+ Options (a flag beats the environment variable beside it):
171
+
172
+ --project-id=<id> FABRIKA_ZEROPS_PROJECT_ID the installation's project
173
+ --project-name=<name> FABRIKA_ZEROPS_PROJECT_NAME with --client-id, instead of the id
174
+ --client-id=<id> FABRIKA_ZEROPS_CLIENT_ID
175
+ --env=<name> FABRIKA_PLATFORM_ENVIRONMENT written to every service as ENVIRONMENT
176
+ --iam-host=<host> FABRIKA_PLATFORM_IAM_HOST all three, or none
177
+ --console-host=<host> FABRIKA_PLATFORM_CONSOLE_HOST
178
+ --operations-host=<host> FABRIKA_PLATFORM_OPERATIONS_HOST
179
+ --scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
180
+ --source-dir=<checkout> required clean full checkout running this CLI
181
+ --dry-run read everything, change nothing
182
+
183
+ Naming NO host derives all three from the proxy service's Zerops subdomains and makes this a
184
+ \`zerops-subdomain\` installation, whose public entry point step 4 ensures. Naming all three makes it a
185
+ \`custom-domain\` installation: the domains are bound to the project balancer out of band and no
186
+ subdomain is published.
187
+
188
+ Credentials are read from the ENVIRONMENT ONLY and have no flag, so they cannot reach a CI log or a
189
+ process listing:
190
+
191
+ FABRIKA_ZEROPS_ACCESS_TOKEN required Zerops access token (an INTEGRATION token scoped to
192
+ this installation's projects, never a personal one)
193
+ FABRIKA_IAM_PROVISIONING_KEY required the IAM-issued px_ admin key step 4 authenticates with
194
+ (a --dry-run does not need it)
195
+ FABRIKA_ZEROPS_API_URL optional region API base, when not the default
196
+
197
+ This command writes NO credential. Every secret an installation holds is placed at bring-up.
198
+
199
+ Install and deploy require --source-dir explicitly. Run the CLI from that same clean full checkout;
200
+ a packaged CLI cannot supply another revision's manifest or schema. --from-git and
201
+ FABRIKA_ZEROPS_BUILD_FROM_GIT are refused; remove them. Every build uses one exact source archive.
202
+ Install and deploy write FABRIKA_ZEROPS_PROXY_SOURCE_JSON on Control from the same verified snapshot,
203
+ using the public contember/fabrika-platform repository. This default is for NEW namespace planning;
204
+ upgrading it does not replace persisted namespace pins. Namespace source resolution checks public
205
+ commit availability before its effects; a local commit is not thereby published to GitHub.
206
+
207
+ ── platform admin ────────────────────────────────────────────────────────────────────────────────
208
+
209
+ The first human. A fresh installation has none — nothing seeds an admission list, and this command is
210
+ why it does not have to. It is unattended, prompts for nothing, and changes nothing on a re-run:
211
+
212
+ 1. find the principal holding that mailbox, or invite one
213
+ 2. grant it the CROSS-APP \`admin\` role, unless it already holds one
214
+ 3. issue ONE password enrollment and print its URL
215
+
216
+ The grant is cross-app (\`app: null\`) and that is load-bearing: grants filter to the calling app, so an
217
+ \`admin\` grant scoped to the console's own app id leaves Delivery and Operations working while the
218
+ Access plane refuses. A re-run invites nobody twice, grants nothing twice and issues no second
219
+ enrollment — a password that is already set, or an enrollment already outstanding, is REPORTED. Pass
220
+ \`${REISSUE_FLAG}\` to replace an outstanding one, which is the way out when the first expired unopened.
221
+
222
+ The enrollment URL is a credential: it is printed once, on a line of its own, and stored nowhere.
223
+
224
+ \`install\` reports the host this needs as \`✓ iam <host>\` and ends with this command ready to run.
225
+
226
+ Options (a flag beats the environment variable beside it):
227
+
228
+ --email=<address> FABRIKA_PLATFORM_ADMIN_EMAIL the administrator's mailbox
229
+ --iam-host=<host> FABRIKA_PLATFORM_IAM_HOST IAM's public hostname, as \`deploy\` names it
230
+ --scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
231
+ ${REISSUE_FLAG} replace an outstanding enrollment
232
+
233
+ FABRIKA_IAM_PROVISIONING_KEY required the px_ admin key \`install\` printed, environment only
234
+
235
+ ── platform upgrade ──────────────────────────────────────────────────────────────────────────────
236
+
237
+ Roll a RUNNING installation onto a newer published fabrika release. Five steps in the sidecar
238
+ repository \`platform init\` created, in one command: check that ${FABRIKA_REPOSITORY} carries the tag,
239
+ write it to \`fabrika.ref\`, commit it as \`chore: roll <installation> forward to fabrika <tag>\`, push —
240
+ and the push is what triggers the pipeline — then print the run's URL and watch it to its conclusion.
241
+
242
+ The run URL is printed BEFORE the watch begins, so an operator who closes the terminal can come back
243
+ to it. The generated workflow QUEUES behind an earlier roll rather than cancelling it, so a run may sit
244
+ \`queued\` for a while; that is reported, not waited on silently.
245
+
246
+ A published TAG stays the only acceptable pin (ADR-0025): a branch or a commit SHA is refused here,
247
+ before anything is contacted, and again by the workflow at run time. It also refuses a directory that is
248
+ not a checkout, a checkout not on \`${SIDECAR_BRANCH}\` or tracking no upstream (the workflow triggers on
249
+ \`push: branches: [${SIDECAR_BRANCH}]\`, so a push from anywhere else deploys nothing), a sidecar with uncommitted
250
+ changes to tracked files (the commit stages \`fabrika.ref\` alone), and a pin already equal to \`--to\` —
251
+ though a pin that is committed and NEVER PUSHED says so instead, because that is what a failed push
252
+ leaves behind and no deploy happened.
253
+
254
+ A tag lookup that FAILS is a third answer and never "no such tag": a rate limit or an expired \`gh\`
255
+ login reported as a missing tag sends an operator to check the wrong repository.
256
+
257
+ --to=<tag> required the published fabrika release to roll onto, e.g. v0.1.0
258
+ <installation> names the sidecar \`platform init\` created: the checkout
259
+ ./fabrika-zerops-<installation> under the current directory
260
+ --sidecar=<path> a checkout somewhere else — anything that is not shaped
261
+ \`<owner>/<name>\`, so ./owner/name names a directory
262
+ --sidecar=<owner>/<name> a GitHub repository, cloned into a temporary directory
263
+ whose path is printed
264
+ ${UPGRADE_DRY_RUN_FLAG} report the roll it would commit and stop
265
+
266
+ Name the installation or the sidecar; naming both is fine and the sidecar wins. This command holds no
267
+ credential of its own: \`gh\` and \`git\` carry the operator's own login, which is also why it runs on a
268
+ laptop and not in the pipeline it triggers.
269
+ `
270
+
271
+ /** No cancellation source exists behind a CLI invocation; the ports all take a signal regardless. */
272
+ const neverAborted = (): AbortSignal => new AbortController().signal
273
+
274
+ const runPlan = (argv: readonly string[]): void => {
275
+ if (argv.length > 0) {
276
+ throw new Error(`Unexpected Zerops plan arguments: ${argv.join(' ')}`)
277
+ }
278
+ const artifacts = generatedArtifacts()
279
+ for (const artifact of artifacts) {
280
+ assertArtifactMatchesSchema(artifact)
281
+ console.info(artifact.path)
282
+ }
283
+ console.info(`${artifacts.length} Zerops installation artifact(s) validated`)
284
+ }
285
+
286
+ /** The one collaborator both the deploy and the install reach IAM's provisioning surface through. */
287
+ const consoleSchemaReconciler: SchemaReconciler = (call) =>
288
+ reconcileSchema({
289
+ url: call.url,
290
+ app: call.app,
291
+ schema: call.schema,
292
+ ...(call.returnOrigins === undefined ? {} : { returnOrigins: call.returnOrigins }),
293
+ ...(call.adminKey === undefined ? {} : { adminKey: call.adminKey }),
294
+ signal: call.signal,
295
+ })
296
+
297
+ const runDeploy = async (argv: readonly string[]): Promise<void> => {
298
+ const input = parsePlatformDeployArgs(argv, process.env)
299
+ await deployPlatform(input, {
300
+ prepareSource: prepareInstallationSource,
301
+ uploadFetch: (url, init) => fetch(url, init),
302
+ api: createZeropsApi({
303
+ token: input.accessToken,
304
+ ...(input.apiBaseUrl === undefined ? {} : { baseUrl: input.apiBaseUrl }),
305
+ }),
306
+ reconcileSchema: consoleSchemaReconciler,
307
+ sleep: defaultSleep,
308
+ log: consoleDeployLog(),
309
+ signal: neverAborted(),
310
+ })
311
+ }
312
+
313
+ const runAdmin = async (argv: readonly string[]): Promise<void> => {
314
+ const input = parsePlatformAdminArgs(argv, process.env)
315
+ await runPlatformAdmin(
316
+ input,
317
+ consoleAdminCollaborators(input, {
318
+ step: (title) => consoleStep(title),
319
+ info: (message) => consoleInfo(message),
320
+ warn: (message) => consoleWarn(message),
321
+ ok: (message) => consoleOk(message),
322
+ }),
323
+ )
324
+ }
325
+
326
+ const runPlatformInstall = async (argv: readonly string[]): Promise<void> => {
327
+ const input = parsePlatformInstallArgs(argv, process.env)
328
+ await runInstall(input, consoleInstallCollaborators(input, consoleSchemaReconciler))
329
+ }
330
+
331
+ const runPlatformUpgrade = async (argv: readonly string[]): Promise<void> => {
332
+ await runUpgrade(parsePlatformUpgradeArgs(argv), consoleUpgradeCollaborators())
333
+ }
334
+
335
+ export const installationCli: InstallationCli = {
336
+ provider: 'zerops',
337
+ commands: ['install', 'init', 'plan', 'deploy', 'admin', 'upgrade'],
338
+ usage: USAGE,
339
+ run: async (command: InstallationCommand, argv: readonly string[]) => {
340
+ if (command === 'install') {
341
+ await runPlatformInstall(argv)
342
+ return
343
+ }
344
+ if (command === 'init') {
345
+ await runInit(parseInitArgs(argv), consoleInitCollaborators())
346
+ return
347
+ }
348
+ if (command === 'plan') {
349
+ runPlan(argv)
350
+ return
351
+ }
352
+ if (command === 'deploy') {
353
+ await runDeploy(argv)
354
+ return
355
+ }
356
+ if (command === 'admin') {
357
+ await runAdmin(argv)
358
+ return
359
+ }
360
+ if (command === 'upgrade') {
361
+ await runPlatformUpgrade(argv)
362
+ return
363
+ }
364
+ throw new Error(`Zerops installation does not support \`platform ${command}\``)
365
+ },
366
+ }