@fabrika/provider-zerops 0.2.0 → 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 (82) 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 +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +23 -127
  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 +191 -344
  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 -1271
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +266 -241
  73. package/src/source/constants.ts +11 -0
  74. package/src/source/port.ts +49 -0
  75. package/src/source/types.ts +4 -0
  76. package/src/source/validation.ts +72 -0
  77. package/src/source.ts +2 -1220
  78. package/src/types.ts +8 -7
  79. package/src/version-attempt.ts +18 -0
  80. package/src/codec.ts +0 -108
  81. package/src/namespace-command.ts +0 -209
  82. package/src/source-connection.ts +0 -344
@@ -0,0 +1,185 @@
1
+ import { waitForProcess, type ZeropsImportResult, type ZeropsService, type ZeropsServiceStatus } from '../index'
2
+ import { PLATFORM_DEPLOY_ORDER, type PlatformDeployService, readServiceEnv, type ResolvedService } from './deploy'
3
+ import { assertRunning, type InstallCollaborators } from './install-collaborators'
4
+ import { GENERATED_SECRET_KEYS } from './install-environment'
5
+ import { type CompiledDocument, type CompiledTopology, compileTopology, platformTopology } from './topology/topology'
6
+
7
+ /** The one topology this command installs, so the project document and the services document cannot drift. */
8
+ export const lightTopology = (environment: string): CompiledTopology =>
9
+ compileTopology(platformTopology({ env: environment, tier: 'light', publicAccess: 'zerops-subdomain' }), environment)
10
+
11
+ /**
12
+ * The services-only provisioning document (WU2), compiled from the declaration the committed artifact
13
+ * is rendered from.
14
+ *
15
+ * Not read off disk: `gen:check` already proves
16
+ * `packages/platform-artifacts/generated/platform-light.services.provision.zerops-import.yaml` is this exact text, and a
17
+ * value needs no filesystem in a published package.
18
+ */
19
+ export const provisioningDocument = (environment: string): CompiledDocument => {
20
+ const document = lightTopology(environment).servicesProvision
21
+ if (document === undefined) {
22
+ throw new Error('the light platform topology declares no services-only provisioning document')
23
+ }
24
+ return document
25
+ }
26
+
27
+ /** One service of the provisioning document that the project already has. */
28
+ interface ExistingService {
29
+ readonly id: string
30
+ readonly status?: ZeropsServiceStatus
31
+ }
32
+
33
+ /**
34
+ * Every service of the provisioning document that the project already has, by hostname.
35
+ *
36
+ * The STATUS travels with the id because `NEW` is not a service this command may touch: measured live,
37
+ * a service holds NO environment at all — not even the platform's generated keys — until it leaves that
38
+ * state, so both a read and a write against one are meaningless rather than merely early.
39
+ */
40
+ export const existingServices = (declared: readonly string[], services: readonly ZeropsService[]): Map<string, ExistingService> => {
41
+ const found = new Map<string, ExistingService>()
42
+ for (const hostname of declared) {
43
+ const matches = services.filter((service) => service.name === hostname)
44
+ if (matches.length > 1) {
45
+ throw new Error(`Zerops project has ${matches.length} services named \`${hostname}\``)
46
+ }
47
+ const match = matches[0]
48
+ if (match !== undefined && match.id !== '') {
49
+ found.set(hostname, { id: match.id, ...(match.status === undefined ? {} : { status: match.status }) })
50
+ }
51
+ }
52
+ return found
53
+ }
54
+
55
+ /**
56
+ * Refuse a service the platform is still creating.
57
+ *
58
+ * Not a wait: this command holds no process id for a service some earlier run imported, and a sleep
59
+ * would be a guess at a number the platform never promised. The whole light topology settles in about a
60
+ * minute, so "run it again shortly" is both true and cheap.
61
+ */
62
+ export const assertNoServiceIsBeingCreated = (existing: ReadonlyMap<string, ExistingService>): void => {
63
+ const creating = [...existing].filter(([, service]) => service.status === 'NEW').map(([hostname]) => hostname)
64
+ if (creating.length > 0) {
65
+ throw new Error(
66
+ `Zerops is still creating ${creating.join(', ')} — a service reads NEW with no environment at all, so nothing can be `
67
+ + 'read from it or written to it yet. The light topology settles in about a minute; run the install again then',
68
+ )
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Refuse a project that already carries this installation's generated secrets.
74
+ *
75
+ * By KEY, never by value. Re-running a bring-up would roll a fresh vault KEK — whose loss is
76
+ * unrecoverable by design — and fresh signing keys, invalidating every live token, so the safe answer to
77
+ * "was this already installed?" is to stop and say so. `platform deploy` is the command for an
78
+ * installation that exists.
79
+ */
80
+ export const assertNotAlreadyInstalled = async (
81
+ existing: ReadonlyMap<string, ExistingService>,
82
+ collaborators: InstallCollaborators,
83
+ ): Promise<void> => {
84
+ const placed: string[] = []
85
+ for (const hostname of PLATFORM_DEPLOY_ORDER) {
86
+ const service = existing.get(hostname)
87
+ if (service === undefined) {
88
+ continue
89
+ }
90
+ const live = await readServiceEnv(service.id, collaborators)
91
+ for (const key of GENERATED_SECRET_KEYS.get(hostname) ?? []) {
92
+ if (live.has(key)) {
93
+ placed.push(`${hostname}.${key}`)
94
+ }
95
+ }
96
+ }
97
+ if (placed.length > 0) {
98
+ throw new Error(
99
+ `this project already holds an installation's generated secrets — ${placed.join(', ')} ${placed.length === 1 ? 'is' : 'are'} already set. `
100
+ + '`platform install` is a BRING-UP and would roll a new vault key and new signing keys over them, which is unrecoverable '
101
+ + 'for the vault and logs everyone out. If every bootstrap variable was placed, use `fabrika platform deploy --provider=zerops` '
102
+ + 'to update or finish deploying the installation; recover FABRIKA_IAM_PROVISIONING_KEY from `iam` or `control` and '
103
+ + 'FABRIKA_ZEROPS_ACCESS_TOKEN from `control` in Zerops. Neither deploy nor init completes a partial bootstrap. '
104
+ + 'For partial bootstrap, preserve the existing keys and arrange operator-owned recovery, or start a fresh installation in a separate empty project',
105
+ )
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Import the topology, unless the project already has it.
111
+ *
112
+ * Three outcomes, and the middle one is the one that costs something to get wrong: re-applying a
113
+ * `startWithoutCode: true` document at a service that carries code activates an EMPTY app version and
114
+ * demotes the running one to `BACKUP` (`docs/reference/zerops-platform.md`). A PARTIAL set is refused
115
+ * rather than completed, because an import applies as one document and cannot skip the services that
116
+ * are already there.
117
+ */
118
+ export const provision = async (
119
+ projectId: string,
120
+ document: CompiledDocument,
121
+ existing: ReadonlyMap<string, ExistingService>,
122
+ collaborators: InstallCollaborators,
123
+ ): Promise<void> => {
124
+ const { api, log, prompts, sleep, signal } = collaborators
125
+ const declared = document.document.services.map((service) => service.hostname)
126
+ if (existing.size === declared.length) {
127
+ log.info(`all ${declared.length} services already exist — skipping the import, which would activate an empty app version on each`)
128
+ return
129
+ }
130
+ if (existing.size > 0) {
131
+ const missing = declared.filter((hostname) => !existing.has(hostname))
132
+ throw new Error(
133
+ `Zerops project ${projectId} has ${[...existing.keys()].join(', ')} but not ${missing.join(', ')}. An import applies as ONE `
134
+ + 'document, so completing it would re-apply `startWithoutCode` at the services that already exist and activate an empty app '
135
+ + 'version on each. Create the missing services by hand, or start from an empty project',
136
+ )
137
+ }
138
+ if (!(await prompts.confirm(`Import ${declared.length} services (${declared.join(', ')}) into project ${projectId}?`, true))) {
139
+ throw new Error('declined: nothing has been created, and re-running the install is safe')
140
+ }
141
+ assertRunning(signal)
142
+ const result: ZeropsImportResult = await api.importServices({ projectId, yaml: document.yaml, signal })
143
+ const pending = result.services.flatMap((service) => service.processes.map((process) => ({ service: service.name, id: process.id })))
144
+ log.ok(`imported ${result.services.length} service(s); waiting for ${pending.length} process(es)`)
145
+ // WAIT BEFORE READING OR WRITING ANYTHING, and this is measured rather than careful.
146
+ //
147
+ // `importServices` returns immediately and the services are not usable yet. Live (2026-08-10, a
148
+ // project created empty): the four runtime services read `status: NEW` with ZERO environment keys the
149
+ // instant the call returned — not even the platform's own generated ones. The platform then creates
150
+ // them SEQUENTIALLY in the document's `priority` order, ~15 s apart, and a service gains its ten
151
+ // generated keys (`zeropsSubdomain` among them) only once it leaves `NEW`. So WU1's "zeropsSubdomain is
152
+ // present before any deploy" is true, and true only after that transition.
153
+ //
154
+ // Waiting on every process id the import handed back is the mechanism; a sleep would be a guess at a
155
+ // number the platform never promised. `waitForProcess`'s default budget is ~5 minutes per process
156
+ // against a measured ~60 s for the whole light topology, which is the slack that measurement asks for.
157
+ for (const process of pending) {
158
+ await waitForProcess({ api, processId: process.id, sleep, signal, label: `the ${process.service} import` })
159
+ }
160
+ log.ok(`every import process finished (${pending.length})`)
161
+ }
162
+
163
+ /** Resolve the four platform services by hostname, naming the whole set that is missing rather than the first. */
164
+ export const resolvePlatformServices = async (
165
+ projectId: string,
166
+ { api, signal }: InstallCollaborators,
167
+ ): Promise<Map<PlatformDeployService, ResolvedService>> => {
168
+ const services = await api.listProjectServices({ projectId, signal })
169
+ const resolved = new Map<PlatformDeployService, ResolvedService>()
170
+ const missing: string[] = []
171
+ for (const hostname of PLATFORM_DEPLOY_ORDER) {
172
+ const match = services.find((service) => service.name === hostname)
173
+ if (match === undefined || match.id === '') {
174
+ missing.push(hostname)
175
+ continue
176
+ }
177
+ resolved.set(hostname, { hostname, id: match.id })
178
+ }
179
+ if (missing.length > 0) {
180
+ throw new Error(`Zerops project ${projectId} has no ${missing.join(', ')} service after provisioning`)
181
+ }
182
+ return resolved
183
+ }
184
+
185
+ export { serviceOf } from './deploy'
@@ -0,0 +1,286 @@
1
+ // Public hosts exist only after the proxy deploys HTTP ports. Pass 1 builds an empty-manifest proxy;
2
+ // pass 2 writes the full environment and delegates the ordered deploy to `deployPlatform` (ADR-0027).
3
+ // This is bring-up only: existing generated-secret keys and partial topologies are refused.
4
+
5
+ import {
6
+ action as consoleAction,
7
+ confirm as promptConfirm,
8
+ info as consoleInfo,
9
+ ok as consoleOk,
10
+ step as consoleStep,
11
+ warn as consoleWarn,
12
+ } from '@fabrika/installation-init'
13
+ import type { SchemaReconciler } from '@fabrika/provider-contract'
14
+ import { createZeropsApi, defaultSleep, type ZeropsNamespaceProxySource } from '../index'
15
+ import {
16
+ deployPlatformService,
17
+ deployPreparedPlatform,
18
+ PLATFORM_CONCURRENT_DEPLOY,
19
+ PLATFORM_DEPLOY_ORDER,
20
+ PLATFORM_PROXY_SERVICE,
21
+ PLATFORM_SEQUENTIAL_DEPLOY,
22
+ readServiceEnv,
23
+ type ResolvedService,
24
+ } from './deploy'
25
+ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
26
+ import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
27
+ import { checkedEnvironmentName } from './init'
28
+ import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
29
+ import { installationServiceEnv, passOneProxyEnv, writeServiceEnv } from './install-environment'
30
+ import { type PlatformInstallInput, UNATTENDED_FLAG } from './install-options'
31
+ import { resolveInstallationProject } from './install-project'
32
+ import {
33
+ assertNoServiceIsBeingCreated,
34
+ assertNotAlreadyInstalled,
35
+ existingServices,
36
+ provision,
37
+ provisioningDocument,
38
+ resolvePlatformServices,
39
+ serviceOf,
40
+ } from './install-provisioning'
41
+ import { prepareInstallationSource, prepareNamespaceProxySource, validateInstallationSetups } from './installation-source'
42
+ import type { PreparedPlatformSource } from './platform-source'
43
+ import { generateInstallationSecrets, type InstallationSecrets } from './secrets'
44
+
45
+ export type { InstallCollaborators, InstallPrompts } from './install-collaborators'
46
+ export { GENERATED_SECRET_KEYS, type InstallationEnvInput, installationServiceEnv } from './install-environment'
47
+
48
+ /**
49
+ * Pass 1 — give the proxy its ports, so the installation has public hostnames.
50
+ *
51
+ * Returns the three derived hosts. `derivePlatformHosts` refuses to compose a hostname, so a proxy that
52
+ * did not publish its six listeners fails here rather than producing a manifest pointing at an address
53
+ * nobody answers.
54
+ */
55
+ const passOne = async (
56
+ source: PreparedPlatformSource,
57
+ proxy: ResolvedService,
58
+ secrets: InstallationSecrets,
59
+ collaborators: InstallCollaborators,
60
+ ): Promise<PlatformHosts> => {
61
+ const { log, prompts } = collaborators
62
+ log.step('Pass 1 — build the proxy so the installation has public hostnames')
63
+ log.info('An empty manifest is a legal one: the proxy comes up answering 404 and publishes one hostname per HTTP port.')
64
+ if (!(await prompts.confirm(`Write 4 variables on \`proxy\` and build commit ${source.commitSha}? (about 4 minutes)`, true))) {
65
+ throw new Error('declined: no variable was written, and re-running the install is safe')
66
+ }
67
+ await writeServiceEnv(proxy, passOneProxyEnv(secrets), collaborators)
68
+ await deployPlatformService(proxy, { source, dryRun: false }, collaborators)
69
+
70
+ const proxyEnv = await readServiceEnv(proxy.id, collaborators)
71
+ const subdomains = proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
72
+ if (subdomains === undefined || subdomains.trim() === '') {
73
+ throw new Error('the proxy published no Zerops subdomain after its first deploy — this installation has no address to be named by')
74
+ }
75
+ const listeners = PLATFORM_PROXY_MANIFEST_TEMPLATE.apps.map((app) => ({ service: app.service, port: app.port }))
76
+ return derivePlatformHosts(subdomains, listeners)
77
+ }
78
+
79
+ /**
80
+ * Bring up one Zerops platform installation, in a project the operator created empty or in one created
81
+ * here.
82
+ *
83
+ * Every step that leaves this machine is confirmed first, and declining any of them stops there with
84
+ * what to run instead. Nothing after the project step and before the import is a mutation, so a declined
85
+ * install leaves the project exactly as it was.
86
+ */
87
+ export const runInstall = async (input: PlatformInstallInput, collaborators: InstallCollaborators): Promise<void> => {
88
+ const source = await collaborators.prepareSource(input.sourceDir, collaborators.signal)
89
+ try {
90
+ validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
91
+ const proxySource = await prepareNamespaceProxySource(source)
92
+ await installPreparedPlatform(input, collaborators, source, proxySource)
93
+ } finally {
94
+ await source.dispose()
95
+ }
96
+ }
97
+
98
+ const installPreparedPlatform = async (
99
+ input: PlatformInstallInput,
100
+ collaborators: InstallCollaborators,
101
+ source: PreparedPlatformSource,
102
+ proxySource: ZeropsNamespaceProxySource,
103
+ ): Promise<void> => {
104
+ const { log, prompts, signal } = collaborators
105
+ assertRunning(signal)
106
+ // Refused HERE, before anything is contacted: `local` is the name every development bypass is gated
107
+ // on, and IAM and control both refuse to boot with it on a public origin.
108
+ const environment = checkedEnvironmentName(input.environment)
109
+
110
+ log.info(`fabrika platform install — the ${environment} Zerops installation, on the ${input.tier} tier`)
111
+ log.info('This CREATES an installation. It generates every credential it needs and prints one of them at the end.')
112
+
113
+ const project = await resolveInstallationProject(input, environment, collaborators)
114
+ log.ok(`project ${project.name} (${project.id})`)
115
+
116
+ log.step('Provision the topology')
117
+ const document = provisioningDocument(environment)
118
+ const declared = document.document.services.map((service) => service.hostname)
119
+ const existing = existingServices(declared, await collaborators.api.listProjectServices({ projectId: project.id, signal }))
120
+ assertNoServiceIsBeingCreated(existing)
121
+ await assertNotAlreadyInstalled(existing, collaborators)
122
+ await provision(project.id, document, existing, collaborators)
123
+ const services = await resolvePlatformServices(project.id, collaborators)
124
+
125
+ log.step("Generate this installation's secrets")
126
+ const secrets = generateInstallationSecrets()
127
+ log.ok(
128
+ 'IAM signing keys (1 × ES256), the IAM RPC key, the proxy key, the provisioning key, the Operations sync key and the vault key',
129
+ )
130
+ log.info('None of them is written to disk. Only the provisioning key is printed, once, at the end.')
131
+
132
+ log.step("Mint the control plane's Zerops token")
133
+ log.info('An INTEGRATION token, client role NO_ACCESS with ADMIN on this one project — never the personal token you authenticated with.')
134
+ if (!(await prompts.confirm(`Mint an integration token named \`fabrika-${environment}\` on client ${input.clientId}?`, true))) {
135
+ throw new Error('declined: nothing was minted, and re-running the install is safe')
136
+ }
137
+ const token = await collaborators.api.createIntegrationToken({
138
+ clientId: input.clientId,
139
+ name: `fabrika-${environment}`,
140
+ projects: [{ projectId: project.id, roleCode: 'ADMIN' }],
141
+ roleCode: 'NO_ACCESS',
142
+ // An app namespace IS a project, so a control plane that cannot create one cannot deploy an
143
+ // application at all (ADR-0034). `NO_ACCESS` still keeps every project it did not create out of reach.
144
+ canCreateProjects: true,
145
+ signal,
146
+ })
147
+ if (token.token === '') {
148
+ throw new Error('Zerops minted an integration token with no value — the control plane cannot deploy anything without one')
149
+ }
150
+ log.ok(`integration token ${token.id} minted, ${token.projects.length} project grant(s)`)
151
+
152
+ const hosts = await passOne(source, serviceOf(services, PLATFORM_PROXY_SERVICE), secrets, collaborators)
153
+ log.ok(`iam ${hosts.iam}`)
154
+ log.ok(`console ${hosts.control}`)
155
+ log.ok(`operations ${hosts.operations}`)
156
+
157
+ log.step('Write every remaining variable')
158
+ const desired = installationServiceEnv({
159
+ environment,
160
+ scheme: input.scheme,
161
+ hosts,
162
+ clientId: input.clientId,
163
+ proxySource,
164
+ secrets,
165
+ zeropsAccessToken: token.token,
166
+ ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
167
+ })
168
+ const total = [...desired.values()].reduce((sum, keys) => sum + keys.size, 0)
169
+ if (!(await prompts.confirm(`Write ${total} variables across ${desired.size} services?`, true))) {
170
+ throw new Error(
171
+ 'declined: the proxy is built but the installation is not configured. Its generated proxy key is already placed, so '
172
+ + 're-running `platform install` is refused. Neither `platform deploy` nor `platform init` completes a partial bootstrap. '
173
+ + 'Preserve the existing keys and arrange operator-owned recovery, or start a fresh installation in a separate empty project',
174
+ )
175
+ }
176
+ for (const hostname of PLATFORM_DEPLOY_ORDER) {
177
+ await writeServiceEnv(serviceOf(services, hostname), desired.get(hostname) ?? new Map<string, string>(), collaborators)
178
+ }
179
+
180
+ log.step('Pass 2 — deploy the installation')
181
+ log.info(
182
+ 'Every bootstrap variable is placed. If this deploy fails, read FABRIKA_IAM_PROVISIONING_KEY from `iam` or `control` '
183
+ + 'and FABRIKA_ZEROPS_ACCESS_TOKEN from `control` in Zerops, then run `fabrika platform deploy --provider=zerops` with this project and source checkout.',
184
+ )
185
+ log.info(
186
+ `\`platform deploy\` owns the whole ordered sequence: ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${
187
+ PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')
188
+ } — the proxy ahead of control (ADR-0027).`,
189
+ )
190
+ if (!(await prompts.confirm('Deploy the installation now? (about 15 minutes)', true))) {
191
+ log.action('OPERATOR ACTION — every variable is placed; the installation is one command from running', [
192
+ `FABRIKA_ZEROPS_ACCESS_TOKEN=… FABRIKA_IAM_PROVISIONING_KEY=… fabrika platform deploy --provider=zerops --source-dir=. --project-id=${project.id} --env=${environment}`,
193
+ ])
194
+ } else {
195
+ await deployPreparedPlatform(
196
+ {
197
+ project: { kind: 'id', projectId: project.id },
198
+ accessToken: input.accessToken,
199
+ environment,
200
+ scheme: input.scheme,
201
+ sourceDir: input.sourceDir,
202
+ iamAdminKey: secrets.provisioningKey,
203
+ dryRun: false,
204
+ ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
205
+ },
206
+ collaborators,
207
+ source,
208
+ )
209
+ }
210
+
211
+ log.step('Hand off')
212
+ // The intentional secret-output exception: init needs the key IAM was seeded with, never a new value.
213
+ log.action('CAPTURE THIS NOW — the CLI keeps no copy; recover it from the `iam` or `control` service environment in Zerops', [
214
+ `FABRIKA_IAM_PROVISIONING_KEY=${secrets.provisioningKey}`,
215
+ ])
216
+ log.info('`fabrika platform init --provider=zerops <installation>` asks for two required deployment secrets:')
217
+ log.info(' FABRIKA_IAM_PROVISIONING_KEY the key above')
218
+ log.info(' FABRIKA_ZEROPS_ACCESS_TOKEN the integration token just minted — read it back from the `control` service, never printed here')
219
+ log.info(`The console answers at ${platformOrigin(input.scheme, hosts.control)}, once someone can sign in: nobody can yet.`)
220
+ // Through the SAME boxed path as the key above, deliberately: `action` is already the one output
221
+ // that carries a secret value, so the ready-to-run command does not become a second place one is
222
+ // printed. `platform admin` is idempotent, so re-running it is safe.
223
+ log.action('ADMIT YOURSELF — the console has no administrator until this runs', [
224
+ `FABRIKA_IAM_PROVISIONING_KEY=${secrets.provisioningKey} fabrika platform admin --provider=zerops --email=<you> --iam-host=${hosts.iam}`,
225
+ ])
226
+ }
227
+
228
+ /**
229
+ * How this command's confirmations are answered — and the refusal when nothing can answer them.
230
+ *
231
+ * The confirmations are the only thing standing between a stray invocation and an installation: this
232
+ * command generates six credentials, mints a Zerops token on the account and deploys. Every one of
233
+ * them defaults to yes, so a pipe carrying blank lines answers all six of them with nobody reading
234
+ * what they agree to. So a run with no terminal is refused unless the operator asked for it by name.
235
+ */
236
+ export const installPrompts = (
237
+ input: PlatformInstallInput,
238
+ stdin: { readonly isTTY?: boolean },
239
+ announce: (line: string) => void,
240
+ ): InstallPrompts => {
241
+ if (input.unattended) {
242
+ // The flag IS the yes, given once for the whole sequence; nothing reads stdin after it. Each
243
+ // question is still printed with the answer it was given, so an unattended run leaves the same
244
+ // record of what was agreed to that an interactive one does.
245
+ return {
246
+ confirm: (question) => {
247
+ announce(`${question} yes (${UNATTENDED_FLAG})`)
248
+ return Promise.resolve(true)
249
+ },
250
+ }
251
+ }
252
+ if (stdin.isTTY !== true) {
253
+ throw new Error(
254
+ "stdin is not a terminal, so nobody is here to confirm what this command does — it generates this installation's "
255
+ + 'credentials, mints a Zerops integration token and deploys. Run it from a terminal, or pass `'
256
+ + UNATTENDED_FLAG
257
+ + '` to answer every confirmation yes unattended',
258
+ )
259
+ }
260
+ return { confirm: (question, defaultYes) => promptConfirm(question, defaultYes) }
261
+ }
262
+
263
+ /** The real collaborators: a TTY for the confirmations, the Zerops API, and IAM's own schema reconciler. */
264
+ export const consoleInstallCollaborators = (input: PlatformInstallInput, reconcileSchema: SchemaReconciler): InstallCollaborators => {
265
+ // First, before anything is built or contacted: a bring-up nobody can confirm does not start.
266
+ const prompts = installPrompts(input, process.stdin, (line) => consoleInfo(line))
267
+ return {
268
+ prepareSource: prepareInstallationSource,
269
+ uploadFetch: (url, init) => fetch(url, init),
270
+ api: createZeropsApi({
271
+ token: input.accessToken,
272
+ ...(input.apiBaseUrl === undefined ? {} : { baseUrl: input.apiBaseUrl }),
273
+ }),
274
+ reconcileSchema,
275
+ sleep: defaultSleep,
276
+ log: {
277
+ step: (title) => consoleStep(title),
278
+ info: (message) => consoleInfo(message),
279
+ warn: (message) => consoleWarn(message),
280
+ ok: (message) => consoleOk(message),
281
+ action: (title, lines) => consoleAction(title, [...lines]),
282
+ },
283
+ prompts,
284
+ signal: new AbortController().signal,
285
+ }
286
+ }
@@ -0,0 +1,42 @@
1
+ import { realpath } from 'node:fs/promises'
2
+ import { fileURLToPath } from 'node:url'
3
+ import {
4
+ decodeZeropsNamespaceProxySource,
5
+ normalizeZeropsSourceRepository,
6
+ prepareZeropsDescriptor,
7
+ verifyZeropsNamespaceProxySource,
8
+ type ZeropsNamespaceProxySource,
9
+ } from '../index'
10
+ import { FABRIKA_REPOSITORY_URL } from './install-options'
11
+ import { type PreparedPlatformSource, preparePlatformSource } from './platform-source'
12
+
13
+ export type PlatformSourcePreparer = (sourceDir: string, signal: AbortSignal) => Promise<PreparedPlatformSource>
14
+
15
+ export const prepareInstallationSource: PlatformSourcePreparer = async (sourceDir, signal) => {
16
+ const workspace = await realpath(fileURLToPath(new URL('../../../../', import.meta.url)))
17
+ if (await realpath(sourceDir) !== workspace) {
18
+ throw new Error(
19
+ '--source-dir must be the full checkout containing this installation module; run bun packages/cli/src/index.ts from that checkout instead of a packaged CLI',
20
+ )
21
+ }
22
+ return preparePlatformSource(sourceDir, signal)
23
+ }
24
+
25
+ export const validateInstallationSetups = (source: PreparedPlatformSource, setups: readonly string[]): void => {
26
+ for (const setup of setups) prepareZeropsDescriptor(source.descriptor.contents, setup)
27
+ }
28
+
29
+ export const FABRIKA_ZEROPS_PROXY_SOURCE_JSON = 'FABRIKA_ZEROPS_PROXY_SOURCE_JSON'
30
+
31
+ // Zerops interpolates environment values; JSON escapes preserve descriptor bytes after parsing.
32
+ export const serializeNamespaceProxySource = (source: ZeropsNamespaceProxySource): string => JSON.stringify(source).replaceAll('$', '\\u0024')
33
+
34
+ export const prepareNamespaceProxySource = async (source: PreparedPlatformSource): Promise<ZeropsNamespaceProxySource> => {
35
+ const identity = decodeZeropsNamespaceProxySource({
36
+ repository: normalizeZeropsSourceRepository(FABRIKA_REPOSITORY_URL),
37
+ commitSha: source.commitSha,
38
+ descriptor: source.descriptor,
39
+ })
40
+ await verifyZeropsNamespaceProxySource(identity)
41
+ return identity
42
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Progress reporting for `fabrika platform deploy --provider=zerops` and for `platform init`.
3
+ *
4
+ * HARD RULE, the same one `@fabrika/installation-init`'s `log.ts` states: **nothing here ever
5
+ * prints a secret VALUE, and there is deliberately no helper that accepts one.** Callers may pass a
6
+ * variable NAME, a count, or a "✓ written" confirmation. The Zerops access token and the IAM
7
+ * provisioning key are the two credentials this command holds; neither has a formatting path.
8
+ *
9
+ * A caught error is never passed through here either — `ZeropsApiError` (`src/api/errors.ts`)
10
+ * already redacts the platform's message on the calls that carry a value, but a raw error from
11
+ * anywhere else may quote a URL with an embedded token. Log a short sentence, never an error object.
12
+ *
13
+ * Only INTERFACES and test doubles live here. The console bindings live with the flow that uses them,
14
+ * so nothing on the deploy path imports the interactive `platform init` machinery.
15
+ */
16
+
17
+ /** Where the command's progress goes. Injected so a test can read the transcript instead of stdout. */
18
+ export interface DeployLog {
19
+ /** A numbered stage of the sequence. */
20
+ step(title: string): void
21
+ /** A detail line under the current stage. */
22
+ info(message: string): void
23
+ /** Something the operator should notice but which does not stop the deploy. */
24
+ warn(message: string): void
25
+ }
26
+
27
+ /** The real console log. Stateful only in its step counter. */
28
+ export const consoleDeployLog = (): DeployLog => {
29
+ let step = 0
30
+ return {
31
+ step(title: string): void {
32
+ step += 1
33
+ console.info(`\n[${step}] ${title}`)
34
+ },
35
+ info(message: string): void {
36
+ console.info(` ${message}`)
37
+ },
38
+ warn(message: string): void {
39
+ console.warn(` ! ${message}`)
40
+ },
41
+ }
42
+ }
43
+
44
+ /** A log that records every line, for tests and for `--dry-run` assertions. */
45
+ export const recordingDeployLog = (): DeployLog & { readonly lines: readonly string[] } => {
46
+ const lines: string[] = []
47
+ return {
48
+ lines,
49
+ step: (title) => void lines.push(`step: ${title}`),
50
+ info: (message) => void lines.push(`info: ${message}`),
51
+ warn: (message) => void lines.push(`warn: ${message}`),
52
+ }
53
+ }
54
+
55
+ /**
56
+ * What `platform init` reports. Two lines more than a deploy: a confirmation of something that
57
+ * happened, and the boxed hand-off an operator must not scroll past.
58
+ */
59
+ export interface InitLog extends DeployLog {
60
+ /** Something outward-facing succeeded. Names a key or a repository, never a value. */
61
+ ok(message: string): void
62
+ /** A boxed OPERATOR ACTION — what to run when a confirmation was declined, or what is left to do. */
63
+ action(title: string, lines: readonly string[]): void
64
+ }
65
+
66
+ /** A log that records every line, so a test can assert on the transcript — including what is NOT in it. */
67
+ export const recordingInitLog = (): InitLog & { readonly lines: readonly string[] } => {
68
+ const lines: string[] = []
69
+ return {
70
+ lines,
71
+ step: (title) => void lines.push(`step: ${title}`),
72
+ info: (message) => void lines.push(`info: ${message}`),
73
+ warn: (message) => void lines.push(`warn: ${message}`),
74
+ ok: (message) => void lines.push(`ok: ${message}`),
75
+ action: (title, actionLines) => void lines.push(`action: ${title}${actionLines.map((line) => `\n ${line}`).join('')}`),
76
+ }
77
+ }
@@ -0,0 +1,65 @@
1
+ // The block `platform admin` and `platform init` both end with: what `fabrika control key issue`
2
+ // needs, and where an operator goes to find each part of it.
3
+ //
4
+ // It lives here, in the Zerops package rather than in `@fabrika/installation-init`, because only the
5
+ // PLACES are worth printing and every place is Zerops-shaped — a service's env in a Zerops project,
6
+ // the sidecar repository's Environment. The variable names are IAM's, but a Cloudflare installation
7
+ // would have to name entirely different locations for them.
8
+ //
9
+ // ── It is PASTEABLE, which decides its shape ──────────────────────────────────────────────────────
10
+ //
11
+ // The acceptance is a fresh shell holding nothing: paste the block, supply the two keys, mint a key on
12
+ // the first try. So the origin is an `export` — a bare `NAME=value` sets a shell variable the child
13
+ // process never sees — and EVERY other line is a `#` comment. The command line is commented too, and
14
+ // that is not timidity: `--label=<name>` is a shell redirection, and `[--expires-in=<seconds>]` ends in
15
+ // a `>` that would create a file called `]` in whatever directory the operator pasted into.
16
+ //
17
+ // HARD RULE: no function here takes a key. The block is composed from an ORIGIN and variable NAMES,
18
+ // so a caller that is holding the real values (both verbs are) cannot leak one through it.
19
+
20
+ /** What the calling verb knows about this installation. Both fields are omitted when it would guess. */
21
+ export interface MachineKeyNextStep {
22
+ /** IAM's PUBLIC origin — the address `FABRIKA_IAM_RPC_URL` takes. */
23
+ readonly iamOrigin?: string
24
+ /** The sidecar GitHub Environment that also holds the provisioning key, when its name is known. */
25
+ readonly sidecarEnvironment?: string
26
+ }
27
+
28
+ /** The boxed hand-off's title. Exported so a test names it once. */
29
+ export const MACHINE_KEY_NEXT_STEP_TITLE = 'NEXT — mint a machine key for `fabrika control`'
30
+
31
+ /** The command the block leads to, with the two flags `key issue` requires. */
32
+ export const MACHINE_KEY_ISSUE_COMMAND = 'fabrika control key issue --label=<name> --permissions=<a,b,c> [--expires-in=<seconds>]'
33
+
34
+ const WIDEST_NAME = 'FABRIKA_IAM_PROVISIONING_KEY'.length + 1
35
+
36
+ /** A variable an operator must SUPPLY: a comment, never an assignment, so pasting the block is safe. */
37
+ const readFrom = (variable: string, where: string): string => `# ${variable.padEnd(WIDEST_NAME)}read from: ${where}`
38
+
39
+ /** A continuation of the line above, indented under its `read from:` column. */
40
+ const continued = (rest: string): string => `# ${' '.repeat(WIDEST_NAME + 'read from: '.length)}${rest}`
41
+
42
+ /**
43
+ * The lines of the block, in the order an operator uses them.
44
+ *
45
+ * The origin is the one VALUE printed, because it is not a credential: it is where the installation
46
+ * answers, and the rebuild that motivated this block failed precisely because an operator guessed it.
47
+ * The two keys are named and located; a value for either would come from the environment anyway.
48
+ */
49
+ export const machineKeyNextStepLines = (step: MachineKeyNextStep = {}): readonly string[] => [
50
+ step.iamOrigin === undefined
51
+ // Never a guessed origin: the control service records the real one, and reading it is one command.
52
+ ? readFrom('FABRIKA_IAM_RPC_URL', "`control`'s own FABRIKA_IAM_ISSUER, in the Zerops project")
53
+ : `export FABRIKA_IAM_RPC_URL=${step.iamOrigin}`,
54
+ readFrom('FABRIKA_IAM_RPC_KEY', "the `iam` or `control` service's env, in the Zerops project"),
55
+ readFrom('FABRIKA_IAM_PROVISIONING_KEY', 'the same env — the copy anyone with the project can read;'),
56
+ continued(
57
+ step.sidecarEnvironment === undefined
58
+ ? "the sidecar repository's Environment holds the copy the pipeline uses"
59
+ : `the sidecar's \`${step.sidecarEnvironment}\` Environment holds the copy the pipeline uses`,
60
+ ),
61
+ '# then, with the label and the permissions filled in:',
62
+ `# ${MACHINE_KEY_ISSUE_COMMAND}`,
63
+ '# Both keys are environment-only and have no flag; this block prints NAMES, never values.',
64
+ '# A private hostname such as http://iam:3000 answers only inside the project — use the origin above.',
65
+ ]