@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,193 @@
1
+ import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
2
+ import type { ZeropsNamespaceProxySource } from '../index'
3
+ import type { PlatformDeployService, ResolvedService } from './deploy'
4
+ import { type PlatformHosts, platformOrigin } from './hosts'
5
+ import { assertRunning, type InstallCollaborators } from './install-collaborators'
6
+ import { FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource } from './installation-source'
7
+ import type { InstallationSecrets } from './secrets'
8
+
9
+ /** The private address the proxy dials IAM at inside the project, and pass 1's placeholder issuer. */
10
+ export const PRIVATE_IAM_UPSTREAM = 'iam:3000'
11
+ const PRIVATE_IAM_URL = `http://${PRIVATE_IAM_UPSTREAM}`
12
+
13
+ /** The canonical fabrika PostgreSQL DSN on Zerops — see `src/topology/setups.ts` for why both suffixes exist. */
14
+ const POSTGRES_URL = '${db_connectionTlsString}/${db_dbName}?sslmode=require'
15
+
16
+ /** The light tier's one object-storage service, as the platform's own `${service_variable}` references. */
17
+ const STORAGE = {
18
+ bucket: '${storage_bucketName}',
19
+ accessKeyId: '${storage_accessKeyId}',
20
+ secretAccessKey: '${storage_secretAccessKey}',
21
+ endpoint: '${storage_apiUrl}',
22
+ } as const
23
+
24
+ /**
25
+ * The four variables pass 1 writes on the proxy, and nothing else.
26
+ *
27
+ * The manifest is EMPTY on purpose: it is the only legal document that can be written before the hosts
28
+ * exist, and the build bakes whatever is here (`src/topology/setups.ts`). `FABRIKA_IAM_ISSUER` is the private
29
+ * address rather than the public issuer for the same reason — the public one is not known yet — and
30
+ * `deployPlatform` corrects it in pass 2, before the manifest it belongs to is applied.
31
+ */
32
+ export const passOneProxyEnv = (secrets: InstallationSecrets): ReadonlyMap<string, string> =>
33
+ new Map([
34
+ [FABRIKA_PROXY_MANIFEST_JSON, encodeProxyManifestJson({ apps: [] })],
35
+ ['FABRIKA_IAM_ISSUER', PRIVATE_IAM_URL],
36
+ ['FABRIKA_IAM_KEY', secrets.proxyKey],
37
+ ['FABRIKA_PROXY_SESSION_OWNER_JSON', 'null'],
38
+ ])
39
+
40
+ /** What `installationServiceEnv` needs. Everything in it is settled before the first write. */
41
+ export interface InstallationEnvInput {
42
+ readonly environment: string
43
+ readonly scheme: 'http' | 'https'
44
+ readonly hosts: PlatformHosts
45
+ readonly clientId: string
46
+ readonly proxySource: ZeropsNamespaceProxySource
47
+ readonly apiBaseUrl?: string
48
+ readonly secrets: InstallationSecrets
49
+ /** The Zerops integration token minted for the control plane. Never logged, never printed. */
50
+ readonly zeropsAccessToken: string
51
+ }
52
+
53
+ /**
54
+ * EVERY variable this command places, per service.
55
+ *
56
+ * Pure, and exported, because the matrix is the thing most worth pinning: a name that is right on
57
+ * Cloudflare and wrong on Bun is a silent no-op rather than a failure. Two spelling rules run through
58
+ * it, both verified against the runtimes rather than assumed:
59
+ *
60
+ * • IAM's Bun runtime reads `ISSUER` and `ENVIRONMENT` UNPREFIXED and everything else with the
61
+ * `FABRIKA_IAM_` prefix (`packages/iam/src/node/runtime.ts`). The prefixed spellings below are the
62
+ * ones that file actually reads; the unprefixed ones are the ones it actually reads too.
63
+ * • No key any service declares in its own `zerops.yaml` appears here — `PORT`,
64
+ * `FABRIKA_IAM_RPC_URL`, `FABRIKA_OPERATIONS_URL`, `FABRIKA_CONTROL_ASSETS_DIR`,
65
+ * `FABRIKA_CONTROL_URL`, `FABRIKA_PROXY_MANIFEST`, `FABRIKA_PROXY_PORT`. The env API refuses those
66
+ * (`provider-zerops/src/api.ts`), because the conflict cannot be resolved to a record id.
67
+ *
68
+ * The proxy manifest is deliberately absent: pass 1 writes an empty one and `deployPlatform` owns the
69
+ * real one, in the same run as the code it describes (ADR-0027).
70
+ */
71
+ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
72
+ const { secrets } = input
73
+ const iamOrigin = platformOrigin(input.scheme, input.hosts.iam)
74
+ const operationsOrigin = platformOrigin(input.scheme, input.hosts.operations)
75
+ const control = new Map([
76
+ ['FABRIKA_CONTROL_DATABASE_URL', POSTGRES_URL],
77
+ ['ENVIRONMENT', input.environment],
78
+ // A bare HOST, not an origin: `controlPublicOrigin` accepts either, and this is also what the
79
+ // same-origin check on both first-party gateways is decided against.
80
+ ['FABRIKA_CONTROL_DOMAIN', input.hosts.control],
81
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
82
+ ['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
83
+ ['FABRIKA_CONTROL_RUN_LOGS_BUCKET', STORAGE.bucket],
84
+ ['FABRIKA_CONTROL_RUN_LOGS_ACCESS_KEY_ID', STORAGE.accessKeyId],
85
+ ['FABRIKA_CONTROL_RUN_LOGS_SECRET_ACCESS_KEY', STORAGE.secretAccessKey],
86
+ ['FABRIKA_CONTROL_RUN_LOGS_ENDPOINT', STORAGE.endpoint],
87
+ ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
88
+ ['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
89
+ ['FABRIKA_CONTROL_VAULT_KEY', secrets.vaultKey],
90
+ ['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
91
+ ['FABRIKA_ZEROPS_CLIENT_ID', input.clientId],
92
+ [FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(input.proxySource)],
93
+ // The PUBLIC IAM origin an application project's proxy reaches: there is no private network
94
+ // between Zerops projects (ADR-0006).
95
+ ['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
96
+ ['FABRIKA_ZEROPS_PROXY_IAM_KEY', secrets.proxyKey],
97
+ ['FABRIKA_ZEROPS_ACCESS_TOKEN', input.zeropsAccessToken],
98
+ ])
99
+ if (input.apiBaseUrl !== undefined) {
100
+ // Only when the installation is not on the default region. Control spells it `_API_BASE_URL` while
101
+ // the CLI reads `FABRIKA_ZEROPS_API_URL`; they are different processes reading different sources.
102
+ control.set('FABRIKA_ZEROPS_API_BASE_URL', input.apiBaseUrl)
103
+ }
104
+ return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
105
+ [
106
+ 'iam',
107
+ new Map([
108
+ ['FABRIKA_IAM_DATABASE_URL', POSTGRES_URL],
109
+ ['ENVIRONMENT', input.environment],
110
+ ['ISSUER', iamOrigin],
111
+ // Password only, by decision: OIDC needs an identity provider the operator has not been
112
+ // asked for. Both are STATED because IAM's own defaults are the other way round — OIDC on,
113
+ // password off — and it refuses to boot with neither.
114
+ ['FABRIKA_IAM_OIDC_ENABLED', 'false'],
115
+ ['FABRIKA_IAM_PASSWORD_ENABLED', 'true'],
116
+ ['FABRIKA_IAM_SIGNING_KEYS', secrets.signingKeys],
117
+ ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
118
+ ['FABRIKA_IAM_PROXY_KEY', secrets.proxyKey],
119
+ ['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
120
+ ]),
121
+ ],
122
+ [
123
+ 'operations',
124
+ new Map([
125
+ ['FABRIKA_OPERATIONS_DATABASE_URL', POSTGRES_URL],
126
+ ['FABRIKA_OPERATIONS_BLOB_BUCKET', STORAGE.bucket],
127
+ ['FABRIKA_OPERATIONS_BLOB_ACCESS_KEY_ID', STORAGE.accessKeyId],
128
+ ['FABRIKA_OPERATIONS_BLOB_SECRET_ACCESS_KEY', STORAGE.secretAccessKey],
129
+ ['FABRIKA_OPERATIONS_BLOB_ENDPOINT', STORAGE.endpoint],
130
+ ['FABRIKA_OPERATIONS_PUBLIC_HOST', input.hosts.operations],
131
+ ['ENVIRONMENT', input.environment],
132
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
133
+ ['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
134
+ ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
135
+ ]),
136
+ ],
137
+ ['control', control],
138
+ [
139
+ 'proxy',
140
+ new Map([
141
+ ['ENVIRONMENT', input.environment],
142
+ // Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
143
+ // private `iam:3000` pass 1 wrote.
144
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
145
+ ['FABRIKA_IAM_KEY', secrets.proxyKey],
146
+ ['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
147
+ ]),
148
+ ],
149
+ ])
150
+ }
151
+
152
+ /**
153
+ * The keys whose VALUE this command generates, per service.
154
+ *
155
+ * Their presence on a live service is what "this project already holds an installation" means, and it is
156
+ * checked by KEY only — a value is never read back for comparison. `__tests__/install.test.ts` pins this
157
+ * against the matrix above, so a new generated secret cannot be added without appearing here.
158
+ */
159
+ export const GENERATED_SECRET_KEYS: ReadonlyMap<PlatformDeployService, readonly string[]> = new Map<PlatformDeployService, readonly string[]>([
160
+ ['iam', ['FABRIKA_IAM_SIGNING_KEYS', 'FABRIKA_IAM_RPC_KEY', 'FABRIKA_IAM_PROXY_KEY', 'FABRIKA_IAM_PROVISIONING_KEY']],
161
+ ['operations', ['OPERATIONS_SYNC_KEY', 'FABRIKA_IAM_RPC_KEY']],
162
+ [
163
+ 'control',
164
+ [
165
+ 'FABRIKA_IAM_RPC_KEY',
166
+ 'OPERATIONS_SYNC_KEY',
167
+ 'FABRIKA_CONTROL_VAULT_KEY',
168
+ 'FABRIKA_IAM_PROVISIONING_KEY',
169
+ 'FABRIKA_ZEROPS_PROXY_IAM_KEY',
170
+ 'FABRIKA_ZEROPS_ACCESS_TOKEN',
171
+ ],
172
+ ],
173
+ ['proxy', ['FABRIKA_IAM_KEY']],
174
+ ])
175
+
176
+ /**
177
+ * Write a set of variables, reporting the KEYS — never a value, not even in a debug line.
178
+ *
179
+ * Write-first and unconditional: half of these are generated secrets, and a read-compare-write over a
180
+ * value the platform may or may not hand back verbatim would make the outcome depend on something
181
+ * nobody has pinned. `PUT /user-data/{id}` replaces in place, so a repeat costs a call and nothing else.
182
+ */
183
+ export const writeServiceEnv = async (
184
+ service: ResolvedService,
185
+ desired: ReadonlyMap<string, string>,
186
+ { api, log, signal }: InstallCollaborators,
187
+ ): Promise<void> => {
188
+ for (const [key, value] of desired) {
189
+ assertRunning(signal)
190
+ await api.putServiceEnv({ serviceId: service.id, key, value, signal })
191
+ }
192
+ log.ok(`${service.hostname}: wrote ${[...desired.keys()].join(', ')}`)
193
+ }
@@ -0,0 +1,193 @@
1
+ // The CLI surface of `fabrika platform install --provider=zerops`, parsed once.
2
+ //
3
+ // The same two rules `deploy-options.ts` states, for the same reasons: every CREDENTIAL comes from the
4
+ // environment and only from the environment (there is no `--token`, and an unknown flag is an error, so
5
+ // one cannot arrive on a command line), and everything else may come from a flag OR the environment.
6
+ //
7
+ // Unlike a deploy this command is INTERACTIVE — it confirms before every step that leaves the
8
+ // operator's disk — but nothing here prompts: a missing required value is an error naming both the flag
9
+ // and the variable that would supply it, so a mistyped install fails before it contacts anything.
10
+
11
+ import { parsePlatformScheme } from './placement-options'
12
+ import { readInstallationSourceDir } from './source-options'
13
+
14
+ /** The only tier a bootstrap can install. See `assertInstallableTier`. */
15
+ export type InstallablePlatformTier = 'light'
16
+
17
+ /**
18
+ * Where the installation's project comes from.
19
+ *
20
+ * A UNION rather than an optional id beside a boolean, because the two mistakes worth making
21
+ * unrepresentable are "neither" and "both": the parser is the only place that can tell them apart, and
22
+ * downstream code must not have to ask a second time.
23
+ */
24
+ export type InstallProjectSource =
25
+ /** One the operator created, empty, by hand. */
26
+ | { readonly kind: 'existing'; readonly projectId: string }
27
+ /** One this command creates, from the same declaration the provisioning import is compiled from. */
28
+ | { readonly kind: 'create'; readonly projectName: string }
29
+
30
+ /** Every input `runInstall` takes from the operator. */
31
+ export interface PlatformInstallInput {
32
+ /** The project this installation is brought up in — named, or created here. */
33
+ readonly project: InstallProjectSource
34
+ /** The client the project belongs to — required, because the control plane's token is minted on it. */
35
+ readonly clientId: string
36
+ /** Zerops personal access token. Environment only. */
37
+ readonly accessToken: string
38
+ /** Region API base, when the installation is not on the default one. */
39
+ readonly apiBaseUrl?: string
40
+ /** The name this installation calls itself — written to every platform service as `ENVIRONMENT`. */
41
+ readonly environment: string
42
+ /** The scheme a browser speaks to this installation. */
43
+ readonly scheme: 'http' | 'https'
44
+ readonly sourceDir: string
45
+ readonly tier: InstallablePlatformTier
46
+ /** `--yes`: answer every confirmation yes, for a bring-up with nobody at the keyboard. */
47
+ readonly unattended: boolean
48
+ }
49
+
50
+ /** The canonical public release repository used for namespace proxy source identities. */
51
+ export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-platform'
52
+
53
+ const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier'] as const
54
+
55
+ /**
56
+ * The one way this command runs with nobody watching it.
57
+ *
58
+ * Every confirmation defaults to yes, so a pipe carrying blank lines answers all six of them with
59
+ * nobody reading what they agree to — which is why saying yes unattended is a FLAG and never an
60
+ * inference: it makes the choice explicit, and it puts it in the shell history of whoever ran it.
61
+ */
62
+ export const UNATTENDED_FLAG = '--yes'
63
+
64
+ /** `--create-project`: bring the project up too, instead of naming one the operator made. */
65
+ export const CREATE_PROJECT_FLAG = '--create-project'
66
+
67
+ /** The two flags that carry no value. Written with one, they are `unexpected argument` like any other. */
68
+ const BOOLEAN_FLAGS = [UNATTENDED_FLAG, CREATE_PROJECT_FLAG] as const
69
+
70
+ const readFlag = (argv: readonly string[], name: string): string | undefined => {
71
+ const prefix = `${name}=`
72
+ const matches = argv.filter((arg) => arg.startsWith(prefix))
73
+ if (matches.length > 1) {
74
+ throw new Error(`${name} was given more than once`)
75
+ }
76
+ const value = matches[0]?.slice(prefix.length)
77
+ return value === undefined || value.trim() === '' ? undefined : value.trim()
78
+ }
79
+
80
+ const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
81
+ const value = env[name]
82
+ return value === undefined || value.trim() === '' ? undefined : value.trim()
83
+ }
84
+
85
+ const setting = (
86
+ argv: readonly string[],
87
+ env: Record<string, string | undefined>,
88
+ flag: string,
89
+ variable: string,
90
+ ): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
91
+
92
+ const required = (value: string | undefined, flag: string, variable: string): string => {
93
+ if (value === undefined) {
94
+ throw new Error(`${flag}=<value> or ${variable} is required`)
95
+ }
96
+ return value
97
+ }
98
+
99
+ const assertKnownArguments = (argv: readonly string[]): void => {
100
+ for (const arg of argv) {
101
+ if (BOOLEAN_FLAGS.some((flag) => flag === arg)) {
102
+ continue
103
+ }
104
+ const name = arg.split('=')[0] ?? arg
105
+ if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
106
+ throw new Error(
107
+ `unexpected argument \`${arg}\`. Credentials are read from the environment and have no flag; run `
108
+ + '`fabrika platform install --provider=zerops --help` for the surface',
109
+ )
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The tier this command can bring up.
116
+ *
117
+ * Only `light`, and that is a property of the topology rather than a limitation of the flow: the light
118
+ * tier is the only one that emits a services-only provisioning document, because it is the only one an
119
+ * operator installs into a project they created themselves. The standard tier is TWO projects that
120
+ * fabrika creates, which is a different command with a different failure mode.
121
+ */
122
+ const assertInstallableTier = (raw: string | undefined): InstallablePlatformTier => {
123
+ if (raw === undefined || raw === 'light') {
124
+ return 'light'
125
+ }
126
+ throw new Error(
127
+ `--tier=${raw} cannot be installed into a project you created: only the light tier emits a services-only `
128
+ + 'provisioning document. The standard tier is two projects fabrika creates itself',
129
+ )
130
+ }
131
+
132
+ const readScheme = (argv: readonly string[], env: Record<string, string | undefined>): 'http' | 'https' => {
133
+ const raw = setting(argv, env, '--scheme', 'FABRIKA_PLATFORM_SCHEME')
134
+ return parsePlatformScheme(raw)
135
+ }
136
+
137
+ const readEnvironment = (argv: readonly string[], env: Record<string, string | undefined>): string =>
138
+ required(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT')
139
+
140
+ /** The name a created project takes when the operator did not choose one: the installation's own. */
141
+ const defaultProjectName = (environment: string): string => `fabrika-${environment}`
142
+
143
+ /**
144
+ * Decide which project this run installs into, and refuse the two ways of asking for both.
145
+ *
146
+ * `--project-id` and `--create-project` are alternatives, not a pair — one of them names a project
147
+ * that exists and the other makes one, and a run that was given both cannot be resolved to the operator's
148
+ * intent. The refusal names the SOURCE the id came from, because an id inherited from a shell that
149
+ * exported `FABRIKA_ZEROPS_PROJECT_ID` is the case where the collision is invisible on the command line.
150
+ */
151
+ const readProjectSource = (argv: readonly string[], env: Record<string, string | undefined>): InstallProjectSource => {
152
+ const projectId = setting(argv, env, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID')
153
+ const projectName = readFlag(argv, '--project-name')
154
+ if (!argv.includes(CREATE_PROJECT_FLAG)) {
155
+ if (projectName !== undefined) {
156
+ throw new Error(
157
+ `--project-name names a project to CREATE, so it needs ${CREATE_PROJECT_FLAG} beside it. Installing into a project that already `
158
+ + 'exists takes --project-id=<id>, which names it by id',
159
+ )
160
+ }
161
+ return { kind: 'existing', projectId: required(projectId, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID') }
162
+ }
163
+ if (projectId !== undefined) {
164
+ const source = readFlag(argv, '--project-id') === undefined ? 'FABRIKA_ZEROPS_PROJECT_ID' : '--project-id'
165
+ throw new Error(
166
+ `${CREATE_PROJECT_FLAG} creates the project and ${source} names one that already exists — pass one or the other, never both`,
167
+ )
168
+ }
169
+ return { kind: 'create', projectName: projectName ?? defaultProjectName(readEnvironment(argv, env)) }
170
+ }
171
+
172
+ /** Parse the command's arguments and environment into the one input `runInstall` takes. */
173
+ export const parsePlatformInstallArgs = (
174
+ argv: readonly string[],
175
+ env: Record<string, string | undefined>,
176
+ ): PlatformInstallInput => {
177
+ const sourceDir = readInstallationSourceDir(argv, env)
178
+ assertKnownArguments(argv)
179
+ const apiBaseUrl = readEnv(env, 'FABRIKA_ZEROPS_API_URL')
180
+ return {
181
+ project: readProjectSource(argv, env),
182
+ // Named even when the project id is known: `POST /client/{id}/integration-token` is addressed by
183
+ // CLIENT, and the project read does not hand one back.
184
+ clientId: required(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'),
185
+ accessToken: required(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
186
+ environment: readEnvironment(argv, env),
187
+ scheme: readScheme(argv, env),
188
+ sourceDir,
189
+ tier: assertInstallableTier(setting(argv, env, '--tier', 'FABRIKA_PLATFORM_TIER')),
190
+ unattended: argv.includes(UNATTENDED_FLAG),
191
+ ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
192
+ }
193
+ }
@@ -0,0 +1,201 @@
1
+ import { ENV_ISOLATION, renderYaml, type ZeropsProjectMode, type ZeropsProjectStatus } from '../index'
2
+ import { assertRunning, type InstallCollaborators } from './install-collaborators'
3
+ import type { PlatformInstallInput } from './install-options'
4
+ import { lightTopology } from './install-provisioning'
5
+
6
+ /** The project compute tier each installable platform tier requires. `corePackage` is upgrade-only. */
7
+ const REQUIRED_PROJECT_MODE: ZeropsProjectMode = 'LIGHT'
8
+
9
+ /** The one project state this command may install into. */
10
+ const PROJECT_ACTIVE: ZeropsProjectStatus = 'ACTIVE'
11
+
12
+ /**
13
+ * Project states a bring-up cannot come back from, so waiting them out is only a slower failure.
14
+ *
15
+ * `NEW`, `CREATING` and `STARTING` are on the way to `ACTIVE` and are waited on; so is a status this
16
+ * build has never seen, which can therefore cost time and never be read as success. These four are the
17
+ * other direction, and the message must NOT offer `--project-id`: resuming into a `FAILED` or `DELETING`
18
+ * project installs nothing.
19
+ */
20
+ const PROJECT_UNUSABLE: ReadonlySet<ZeropsProjectStatus> = new Set<ZeropsProjectStatus>(['FAILED', 'DELETING', 'STOPPING', 'STOPPED'])
21
+
22
+ /** Live-measured: a created project reads `NEW` at t+1 s and `ACTIVE` at about t+20 s. */
23
+ const PROJECT_POLL_INTERVAL_MS = 2_000
24
+
25
+ /** The number of SLEEPS, so the wait is 60 × 2 s = two minutes spread between 61 reads. */
26
+ const PROJECT_POLL_ATTEMPTS = (2 * 60_000) / PROJECT_POLL_INTERVAL_MS
27
+
28
+ /** Consecutive `getProject` failures ridden out before the wait gives the run back to the operator. */
29
+ const PROJECT_READ_FAILURES = 3
30
+
31
+ /**
32
+ * The document `--create-project` applies, carrying a `project:` block and NO services.
33
+ *
34
+ * Taken from the same declaration the provisioning import is compiled from, so `envIsolation: service`
35
+ * and `corePackage: LIGHT` — the two settings a project accepts at CREATION only and never afterwards —
36
+ * have exactly one source. Only the name is this run's. `renderImportYaml` is not the renderer here
37
+ * because it refuses a document with no services, and this one deliberately has none: the services arrive
38
+ * through the services-only import, into the project this creates.
39
+ */
40
+ const createProjectYaml = (environment: string, projectName: string): string => {
41
+ const declared = lightTopology(environment).provision.document.project
42
+ if (declared === undefined || declared.corePackage !== REQUIRED_PROJECT_MODE || declared.envIsolation !== ENV_ISOLATION) {
43
+ throw new Error(`the light platform topology declares no \`${REQUIRED_PROJECT_MODE}\` project with \`envIsolation: ${ENV_ISOLATION}\` to create`)
44
+ }
45
+ return renderYaml({ project: { ...declared, name: projectName }, services: [] })
46
+ }
47
+
48
+ /** One poll of the created project: its status, or the failure the read itself ended in. */
49
+ type ProjectRead =
50
+ | { readonly ok: true; readonly status: ZeropsProjectStatus | undefined }
51
+ | { readonly ok: false; readonly failure: unknown }
52
+
53
+ /**
54
+ * Wait for a created project to be `ACTIVE`.
55
+ *
56
+ * Live-measured (2026-08-21): the import answers in about a second and the project then reads `NEW` →
57
+ * `CREATING` → `ACTIVE` at about t+20 s. Whether a service import into a `CREATING` project succeeds is
58
+ * NOT measured, so this waits rather than finds out.
59
+ *
60
+ * Three outcomes, and the difference between them is what the operator is told to do next. `ACTIVE`
61
+ * returns. A state in `PROJECT_UNUSABLE` throws WITHOUT a resume hint, because the project is spent. A
62
+ * transient state, an unknown one, or a read that fails outright is waited out — a 5xx on the read says
63
+ * nothing about the project, so a few in a row are ridden out before the run is handed back, and it is
64
+ * handed back with the id, because by then a project exists that the operator would otherwise leak.
65
+ */
66
+ const waitForProjectActive = async (projectId: string, { api, sleep, signal }: InstallCollaborators): Promise<void> => {
67
+ let failures = 0
68
+ let last: ZeropsProjectStatus | 'unreadable' | undefined
69
+ for (let attempt = 0;; attempt += 1) {
70
+ assertRunning(signal)
71
+ const read: ProjectRead = await api.getProject({ projectId, signal }).then(
72
+ (project): ProjectRead => ({ ok: true, status: project.status }),
73
+ (failure: unknown): ProjectRead => ({ ok: false, failure }),
74
+ )
75
+ if (!read.ok) {
76
+ failures += 1
77
+ last = 'unreadable'
78
+ if (failures > PROJECT_READ_FAILURES) {
79
+ throw new Error(
80
+ `Zerops project ${projectId} was created but could not be read ${failures} times in a row. Re-run the install with `
81
+ + `--project-id=${projectId} instead of --create-project`,
82
+ { cause: read.failure },
83
+ )
84
+ }
85
+ } else {
86
+ failures = 0
87
+ last = read.status
88
+ if (read.status === PROJECT_ACTIVE) {
89
+ return
90
+ }
91
+ if (read.status !== undefined && PROJECT_UNUSABLE.has(read.status)) {
92
+ throw new Error(
93
+ `Zerops project ${projectId} reads \`${read.status}\`, which a bring-up cannot recover from: the project exists and nothing `
94
+ + 'can be installed into it. Delete it and run the install again — do NOT resume into this one',
95
+ )
96
+ }
97
+ }
98
+ if (attempt >= PROJECT_POLL_ATTEMPTS) {
99
+ throw new Error(
100
+ `Zerops project ${projectId} still reads \`${last ?? 'no status'}\` after two minutes (${PROJECT_POLL_ATTEMPTS + 1} reads, `
101
+ + `${PROJECT_POLL_INTERVAL_MS / 1_000} s apart). It exists and may yet settle — re-run the install with `
102
+ + `--project-id=${projectId} instead of --create-project`,
103
+ )
104
+ }
105
+ await sleep(PROJECT_POLL_INTERVAL_MS, signal)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Create the project this installation is brought up in, and wait for it to be usable.
111
+ *
112
+ * The id is reported BEFORE the wait, and that ordering is the whole point of the line: an interrupted
113
+ * run has already spent a project on the account, and this id is the only way back to it.
114
+ */
115
+ const createProject = async (
116
+ input: PlatformInstallInput,
117
+ environment: string,
118
+ projectName: string,
119
+ collaborators: InstallCollaborators,
120
+ ): Promise<string> => {
121
+ const { api, log, signal } = collaborators
122
+ assertRunning(signal)
123
+ const result = await api.importProject({
124
+ clientId: input.clientId,
125
+ yaml: createProjectYaml(environment, projectName),
126
+ signal,
127
+ })
128
+ if (result.projectId === '') {
129
+ throw new Error(`Zerops created a project named ${projectName} with no id — nothing can be installed into it`)
130
+ }
131
+ log.ok(
132
+ `created Zerops project ${result.projectId} (${result.projectName ?? projectName}) — resume with --project-id=${result.projectId} if this `
133
+ + 'run is interrupted',
134
+ )
135
+ await waitForProjectActive(result.projectId, collaborators)
136
+ return result.projectId
137
+ }
138
+
139
+ /** Read the project and refuse anything the topology cannot be installed into. */
140
+ const resolveProject = async (
141
+ input: PlatformInstallInput,
142
+ projectId: string,
143
+ { api, log, signal }: InstallCollaborators,
144
+ ): Promise<{ id: string; name: string }> => {
145
+ const project = await api.getProject({ projectId, signal })
146
+ if (project.id === '') {
147
+ throw new Error(`Zerops project ${projectId} could not be read with this token`)
148
+ }
149
+ if (project.mode === undefined) {
150
+ // Not a mismatch, so not a refusal: the client narrows an unknown value to absent, and blocking an
151
+ // install on a field the API did not state would be refusing on an absence of evidence.
152
+ log.warn(`Zerops did not state project ${project.name}'s mode; the ${input.tier} tier expects ${REQUIRED_PROJECT_MODE}`)
153
+ } else if (project.mode !== REQUIRED_PROJECT_MODE) {
154
+ throw new Error(
155
+ `Zerops project ${project.name} is ${project.mode} and the ${input.tier} tier needs ${REQUIRED_PROJECT_MODE}. \`corePackage\` is `
156
+ + 'upgrade-only and its upgrade is partly destructive, so this is not something an install may change — create the project '
157
+ + `with core package ${REQUIRED_PROJECT_MODE}, or install a tier that matches`,
158
+ )
159
+ }
160
+ return { id: project.id, name: project.name }
161
+ }
162
+
163
+ /**
164
+ * Settle which project this run installs into — the operator's, or one created here.
165
+ *
166
+ * ONE confirmation either way, and both cover the read that follows: a created project is read back
167
+ * through the same `resolveProject` an operator-made one goes through, so the core package is checked on
168
+ * the project that will actually be installed into rather than on the document that asked for it.
169
+ */
170
+ export const resolveInstallationProject = async (
171
+ input: PlatformInstallInput,
172
+ environment: string,
173
+ collaborators: InstallCollaborators,
174
+ ): Promise<{ id: string; name: string }> => {
175
+ const { log, prompts } = collaborators
176
+ const source = input.project
177
+ if (source.kind === 'existing') {
178
+ log.step('Resolve the project the operator created')
179
+ // One confirmation covers every READ this step makes — the project, its services, and which variable
180
+ // KEYS those services already carry. None of them changes anything.
181
+ if (
182
+ !(await prompts.confirm(`Read Zerops project ${source.projectId} and its services? (the token, the id, the core package, what exists)`, true))
183
+ ) {
184
+ throw new Error('declined: nothing was contacted, and re-running the install is safe')
185
+ }
186
+ return resolveProject(input, source.projectId, collaborators)
187
+ }
188
+ log.step('Create the project this installation is brought up in')
189
+ log.info('`envIsolation` and `corePackage` are settable at project CREATION only, which is why the project is created from here.')
190
+ if (
191
+ !(await prompts.confirm(
192
+ `CREATE Zerops project \`${source.projectName}\` (core package ${REQUIRED_PROJECT_MODE}, envIsolation ${ENV_ISOLATION}) on client `
193
+ + `${input.clientId}, then read it back?`,
194
+ true,
195
+ ))
196
+ ) {
197
+ throw new Error('declined: no project was created, and re-running the install is safe')
198
+ }
199
+ const projectId = await createProject(input, environment, source.projectName, collaborators)
200
+ return resolveProject(input, projectId, collaborators)
201
+ }