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