@fabrika/provider-zerops 0.4.2 → 0.4.4

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 (49) hide show
  1. package/package.json +8 -7
  2. package/src/api/client.ts +11 -14
  3. package/src/api/polling.ts +42 -0
  4. package/src/api/readers.ts +9 -2
  5. package/src/api/service-env-limit.ts +29 -0
  6. package/src/api/service-env-response.ts +34 -2
  7. package/src/api/types.ts +39 -9
  8. package/src/api.ts +10 -1
  9. package/src/control.ts +23 -8
  10. package/src/execution.ts +44 -5
  11. package/src/index.ts +5 -0
  12. package/src/installation/admin.ts +4 -2
  13. package/src/installation/checker-release.ts +44 -0
  14. package/src/installation/deploy.ts +95 -37
  15. package/src/installation/generated/platform-console-schema.ts +18 -2
  16. package/src/installation/generated/platform-proxy-manifest.ts +20 -0
  17. package/src/installation/guide.md +169 -52
  18. package/src/installation/index.ts +22 -15
  19. package/src/installation/init-options.ts +2 -2
  20. package/src/installation/init.ts +8 -2
  21. package/src/installation/install-collaborators.ts +4 -0
  22. package/src/installation/install-environment.ts +52 -30
  23. package/src/installation/install-options.ts +20 -19
  24. package/src/installation/install-project.ts +23 -15
  25. package/src/installation/install-provisioning.ts +12 -12
  26. package/src/installation/install-secrets.ts +89 -0
  27. package/src/installation/install-token.ts +37 -0
  28. package/src/installation/install.ts +34 -21
  29. package/src/installation/machine-key.ts +3 -2
  30. package/src/installation/platform-archive.ts +87 -0
  31. package/src/installation/platform-upload.ts +2 -2
  32. package/src/installation/secrets.ts +3 -4
  33. package/src/installation/sidecar.ts +38 -2
  34. package/src/installation/templates/README.md +1 -1
  35. package/src/installation/templates/platform.yml +23 -2
  36. package/src/installation/topology/setups.ts +15 -27
  37. package/src/installation/topology/topology.ts +105 -104
  38. package/src/installation/upgrade.ts +24 -7
  39. package/src/managed-env.ts +11 -0
  40. package/src/manifest.ts +25 -3
  41. package/src/namespace/codec.ts +6 -1
  42. package/src/namespace/errors.ts +3 -1
  43. package/src/namespace/provisioning.ts +18 -5
  44. package/src/namespace/proxy-attempt.ts +56 -14
  45. package/src/namespace/update.ts +50 -8
  46. package/src/provider.ts +57 -13
  47. package/src/proxy-manifest-carrier.ts +32 -0
  48. package/src/source/port.ts +9 -1
  49. package/src/installation/manifest.ts +0 -95
@@ -1,45 +1,61 @@
1
- import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
1
+ import { encodeProxyManifestJson } from '@fabrika/proxy-core/manifest'
2
2
  import type { ZeropsNamespaceProxySource } from '../index'
3
3
  import type { PlatformDeployService, ResolvedService } from './deploy'
4
4
  import { type PlatformHosts, platformOrigin } from './hosts'
5
5
  import { assertRunning, type InstallCollaborators } from './install-collaborators'
6
6
  import { FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource } from './installation-source'
7
7
  import type { InstallationSecrets } from './secrets'
8
+ import type { PlatformTier } from './topology/topology'
8
9
 
9
10
  /** The private address the proxy dials IAM at inside the project, and pass 1's placeholder issuer. */
10
11
  export const PRIVATE_IAM_UPSTREAM = 'iam:3000'
11
12
  const PRIVATE_IAM_URL = `http://${PRIVATE_IAM_UPSTREAM}`
12
13
 
13
14
  /** 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
+ const postgresUrl = (service: string): string => `\${${service}_connectionTlsString}/\${${service}_dbName}?sslmode=require`
15
16
 
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
17
+ /** An object-storage service's coordinates, as the platform's own `${service_variable}` references. */
18
+ interface StorageReferences {
19
+ readonly bucket: string
20
+ readonly accessKeyId: string
21
+ readonly secretAccessKey: string
22
+ readonly endpoint: string
23
+ }
24
+
25
+ const storageOf = (service: string): StorageReferences => ({
26
+ bucket: `\${${service}_bucketName}`,
27
+ accessKeyId: `\${${service}_accessKeyId}`,
28
+ secretAccessKey: `\${${service}_secretAccessKey}`,
29
+ endpoint: `\${${service}_apiUrl}`,
30
+ })
31
+
32
+ /** IAM and control share `db` and `storage` on both tiers; `standard` gives Operations its own pair. */
33
+ const OPERATIONS_DATA: Readonly<Record<PlatformTier, { readonly database: string; readonly storage: string }>> = {
34
+ light: { database: 'db', storage: 'storage' },
35
+ standard: { database: 'operationsdb', storage: 'operationsstorage' },
36
+ }
23
37
 
24
38
  /**
25
- * The four variables pass 1 writes on the proxy, and nothing else.
39
+ * The three variables pass 1 writes on the proxy, and nothing else.
26
40
  *
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.
41
+ * `FABRIKA_IAM_ISSUER` is the private address rather than the public issuer because the public one is not known
42
+ * yet; `deployPlatform` corrects it in pass 2, before the manifest it belongs to is applied. The pass-1 manifest is
43
+ * `PASS_ONE_PROXY_MANIFEST`, which the proxy's archive carries.
31
44
  */
32
45
  export const passOneProxyEnv = (secrets: InstallationSecrets): ReadonlyMap<string, string> =>
33
46
  new Map([
34
- [FABRIKA_PROXY_MANIFEST_JSON, encodeProxyManifestJson({ apps: [] })],
35
47
  ['FABRIKA_IAM_ISSUER', PRIVATE_IAM_URL],
36
48
  ['FABRIKA_IAM_KEY', secrets.proxyKey],
37
49
  ['FABRIKA_PROXY_SESSION_OWNER_JSON', 'null'],
38
50
  ])
39
51
 
52
+ /** EMPTY on purpose: the only legal document before the hosts exist. The proxy comes up answering 404. */
53
+ export const PASS_ONE_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
54
+
40
55
  /** What `installationServiceEnv` needs. Everything in it is settled before the first write. */
41
56
  export interface InstallationEnvInput {
42
57
  readonly environment: string
58
+ readonly tier: PlatformTier
43
59
  readonly scheme: 'http' | 'https'
44
60
  readonly hosts: PlatformHosts
45
61
  readonly clientId: string
@@ -72,17 +88,21 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
72
88
  const { secrets } = input
73
89
  const iamOrigin = platformOrigin(input.scheme, input.hosts.iam)
74
90
  const operationsOrigin = platformOrigin(input.scheme, input.hosts.operations)
91
+ const platformDatabase = postgresUrl('db')
92
+ const runLogs = storageOf('storage')
93
+ const operationsData = OPERATIONS_DATA[input.tier]
94
+ const operationsBlobs = storageOf(operationsData.storage)
75
95
  const control = new Map([
76
- ['FABRIKA_CONTROL_DATABASE_URL', POSTGRES_URL],
96
+ ['FABRIKA_CONTROL_DATABASE_URL', platformDatabase],
77
97
  ['ENVIRONMENT', input.environment],
78
98
  // A bare HOST, not an origin: `controlPublicOrigin` accepts either.
79
99
  ['FABRIKA_CONTROL_DOMAIN', input.hosts.control],
80
100
  ['FABRIKA_IAM_ISSUER', iamOrigin],
81
101
  ['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
82
- ['FABRIKA_CONTROL_RUN_LOGS_BUCKET', STORAGE.bucket],
83
- ['FABRIKA_CONTROL_RUN_LOGS_ACCESS_KEY_ID', STORAGE.accessKeyId],
84
- ['FABRIKA_CONTROL_RUN_LOGS_SECRET_ACCESS_KEY', STORAGE.secretAccessKey],
85
- ['FABRIKA_CONTROL_RUN_LOGS_ENDPOINT', STORAGE.endpoint],
102
+ ['FABRIKA_CONTROL_RUN_LOGS_BUCKET', runLogs.bucket],
103
+ ['FABRIKA_CONTROL_RUN_LOGS_ACCESS_KEY_ID', runLogs.accessKeyId],
104
+ ['FABRIKA_CONTROL_RUN_LOGS_SECRET_ACCESS_KEY', runLogs.secretAccessKey],
105
+ ['FABRIKA_CONTROL_RUN_LOGS_ENDPOINT', runLogs.endpoint],
86
106
  ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
87
107
  ['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
88
108
  ['FABRIKA_CONTROL_VAULT_KEY', secrets.vaultKey],
@@ -104,7 +124,7 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
104
124
  [
105
125
  'iam',
106
126
  new Map([
107
- ['FABRIKA_IAM_DATABASE_URL', POSTGRES_URL],
127
+ ['FABRIKA_IAM_DATABASE_URL', platformDatabase],
108
128
  ['ENVIRONMENT', input.environment],
109
129
  ['ISSUER', iamOrigin],
110
130
  // Password only, by decision: OIDC needs an identity provider the operator has not been
@@ -121,11 +141,11 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
121
141
  [
122
142
  'operations',
123
143
  new Map([
124
- ['FABRIKA_OPERATIONS_DATABASE_URL', POSTGRES_URL],
125
- ['FABRIKA_OPERATIONS_BLOB_BUCKET', STORAGE.bucket],
126
- ['FABRIKA_OPERATIONS_BLOB_ACCESS_KEY_ID', STORAGE.accessKeyId],
127
- ['FABRIKA_OPERATIONS_BLOB_SECRET_ACCESS_KEY', STORAGE.secretAccessKey],
128
- ['FABRIKA_OPERATIONS_BLOB_ENDPOINT', STORAGE.endpoint],
144
+ ['FABRIKA_OPERATIONS_DATABASE_URL', postgresUrl(operationsData.database)],
145
+ ['FABRIKA_OPERATIONS_BLOB_BUCKET', operationsBlobs.bucket],
146
+ ['FABRIKA_OPERATIONS_BLOB_ACCESS_KEY_ID', operationsBlobs.accessKeyId],
147
+ ['FABRIKA_OPERATIONS_BLOB_SECRET_ACCESS_KEY', operationsBlobs.secretAccessKey],
148
+ ['FABRIKA_OPERATIONS_BLOB_ENDPOINT', operationsBlobs.endpoint],
129
149
  ['FABRIKA_OPERATIONS_PUBLIC_HOST', input.hosts.operations],
130
150
  ['ENVIRONMENT', input.environment],
131
151
  ['FABRIKA_IAM_ISSUER', iamOrigin],
@@ -175,18 +195,20 @@ export const GENERATED_SECRET_KEYS: ReadonlyMap<PlatformDeployService, readonly
175
195
  /**
176
196
  * Write a set of variables, reporting the KEYS — never a value, not even in a debug line.
177
197
  *
178
- * Write-first and unconditional: half of these are generated secrets, and a read-compare-write over a
179
- * value the platform may or may not hand back verbatim would make the outcome depend on something
180
- * nobody has pinned. `PUT /user-data/{id}` replaces in place, so a repeat costs a call and nothing else.
198
+ * Write-first and unconditional: a generated secret cannot be read back to compare, and `PUT
199
+ * /user-data/{id}` replaces in place, so a repeat costs a call and nothing else. Exactly the generated
200
+ * secrets are written sensitive; everything else stays readable, because `platform deploy` compares
201
+ * and merges it later.
181
202
  */
182
203
  export const writeServiceEnv = async (
183
204
  service: ResolvedService,
184
205
  desired: ReadonlyMap<string, string>,
185
206
  { api, log, signal }: InstallCollaborators,
186
207
  ): Promise<void> => {
208
+ const secrets = GENERATED_SECRET_KEYS.get(service.hostname) ?? []
187
209
  for (const [key, value] of desired) {
188
210
  assertRunning(signal)
189
- await api.putServiceEnv({ serviceId: service.id, key, value, signal })
211
+ await api.putServiceEnv({ serviceId: service.id, key, value, sensitive: secrets.includes(key), signal })
190
212
  }
191
213
  log.ok(`${service.hostname}: wrote ${[...desired.keys()].join(', ')}`)
192
214
  }
@@ -9,11 +9,12 @@
9
9
  // and the variable that would supply it, so a mistyped install fails before it contacts anything.
10
10
 
11
11
  import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
12
+ import { checkedSecretsFilePath, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE } from './install-secrets'
12
13
  import { parsePlatformScheme } from './placement-options'
13
14
  import { readInstallationSourceDir } from './source-options'
15
+ import type { PlatformTier } from './topology/topology'
14
16
 
15
- /** The only tier a bootstrap can install. See `assertInstallableTier`. */
16
- export type InstallablePlatformTier = 'light'
17
+ const PLATFORM_TIERS: readonly PlatformTier[] = ['light', 'standard']
17
18
 
18
19
  /**
19
20
  * Where the installation's project comes from.
@@ -43,7 +44,9 @@ export interface PlatformInstallInput {
43
44
  /** The scheme a browser speaks to this installation. */
44
45
  readonly scheme: 'http' | 'https'
45
46
  readonly sourceDir: string
46
- readonly tier: InstallablePlatformTier
47
+ /** Where every credential this run creates is written, mode 0600, before the first is placed. */
48
+ readonly secretsFile: string
49
+ readonly tier: PlatformTier
47
50
  /** `--yes`: answer every confirmation yes, for a bring-up with nobody at the keyboard. */
48
51
  readonly unattended: boolean
49
52
  }
@@ -51,7 +54,7 @@ export interface PlatformInstallInput {
51
54
  /** The canonical public release repository used for namespace proxy source identities. */
52
55
  export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-platform'
53
56
 
54
- const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier'] as const
57
+ const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier', SECRETS_FILE_FLAG] as const
55
58
 
56
59
  /** `--create-project`: bring the project up too, instead of naming one the operator made. */
57
60
  export const CREATE_PROJECT_FLAG = '--create-project'
@@ -74,22 +77,16 @@ const assertKnownArguments = (argv: readonly string[]): void => {
74
77
  }
75
78
  }
76
79
 
77
- /**
78
- * The tier this command can bring up.
79
- *
80
- * Only `light`, and that is a property of the topology rather than a limitation of the flow: the light
81
- * tier is the only one that emits a services-only provisioning document, because it is the only one an
82
- * operator installs into a project they created themselves. The standard tier is TWO projects that
83
- * fabrika creates, which is a different command with a different failure mode.
84
- */
85
- const assertInstallableTier = (raw: string | undefined): InstallablePlatformTier => {
86
- if (raw === undefined || raw === 'light') {
80
+ /** The platform tier to bring up. `light` stays the default, so an existing invocation installs what it did. */
81
+ const readTier = (raw: string | undefined): PlatformTier => {
82
+ if (raw === undefined) {
87
83
  return 'light'
88
84
  }
89
- throw new Error(
90
- `--tier=${raw} cannot be installed into a project you created: only the light tier emits a services-only `
91
- + 'provisioning document. The standard tier is two projects fabrika creates itself',
92
- )
85
+ const tier = PLATFORM_TIERS.find((candidate) => candidate === raw)
86
+ if (tier === undefined) {
87
+ throw new Error(`--tier=${raw} is not a platform tier — pass --tier=light or --tier=standard`)
88
+ }
89
+ return tier
93
90
  }
94
91
 
95
92
  const readScheme = (argv: readonly string[], env: Record<string, string | undefined>): 'http' | 'https' => {
@@ -149,7 +146,11 @@ export const parsePlatformInstallArgs = (
149
146
  environment: readEnvironment(argv, env),
150
147
  scheme: readScheme(argv, env),
151
148
  sourceDir,
152
- tier: assertInstallableTier(setting(argv, env, '--tier', 'FABRIKA_PLATFORM_TIER')),
149
+ secretsFile: checkedSecretsFilePath(
150
+ requiredSetting(setting(argv, env, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE), SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE),
151
+ sourceDir,
152
+ ),
153
+ tier: readTier(setting(argv, env, '--tier', 'FABRIKA_PLATFORM_TIER')),
153
154
  unattended: argv.includes(UNATTENDED_FLAG),
154
155
  ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
155
156
  }
@@ -1,10 +1,12 @@
1
1
  import { ENV_ISOLATION, renderYaml, type ZeropsProjectMode, type ZeropsProjectStatus } from '../index'
2
2
  import { assertRunning, type InstallCollaborators } from './install-collaborators'
3
3
  import type { PlatformInstallInput } from './install-options'
4
- import { lightTopology } from './install-provisioning'
4
+ import { installationTopology } from './install-provisioning'
5
+ import { assertPersonalAccessToken } from './install-token'
6
+ import type { PlatformTier } from './topology/topology'
5
7
 
6
- /** The project compute tier each installable platform tier requires. `corePackage` is upgrade-only. */
7
- const REQUIRED_PROJECT_MODE: ZeropsProjectMode = 'LIGHT'
8
+ /** The project compute tier each platform tier requires. `corePackage` is upgrade-only. */
9
+ const REQUIRED_PROJECT_MODE: Readonly<Record<PlatformTier, ZeropsProjectMode>> = { light: 'LIGHT', standard: 'SERIOUS' }
8
10
 
9
11
  /** The one project state this command may install into. */
10
12
  const PROJECT_ACTIVE: ZeropsProjectStatus = 'ACTIVE'
@@ -32,15 +34,16 @@ const PROJECT_READ_FAILURES = 3
32
34
  * The document `--create-project` applies, carrying a `project:` block and NO services.
33
35
  *
34
36
  * 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 —
37
+ * and `corePackage` — the two settings a project accepts at CREATION only and never afterwards —
36
38
  * have exactly one source. Only the name is this run's. `renderImportYaml` is not the renderer here
37
39
  * because it refuses a document with no services, and this one deliberately has none: the services arrive
38
40
  * through the services-only import, into the project this creates.
39
41
  */
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`)
42
+ const createProjectYaml = (environment: string, tier: PlatformTier, projectName: string): string => {
43
+ const declared = installationTopology(environment, tier).provision.document.project
44
+ const mode = REQUIRED_PROJECT_MODE[tier]
45
+ if (declared === undefined || declared.corePackage !== mode || declared.envIsolation !== ENV_ISOLATION) {
46
+ throw new Error(`the ${tier} platform topology declares no \`${mode}\` project with \`envIsolation: ${ENV_ISOLATION}\` to create`)
44
47
  }
45
48
  return renderYaml({ project: { ...declared, name: projectName }, services: [] })
46
49
  }
@@ -122,7 +125,7 @@ const createProject = async (
122
125
  assertRunning(signal)
123
126
  const result = await api.importProject({
124
127
  clientId: input.clientId,
125
- yaml: createProjectYaml(environment, projectName),
128
+ yaml: createProjectYaml(environment, input.tier, projectName),
126
129
  signal,
127
130
  })
128
131
  if (result.projectId === '') {
@@ -143,18 +146,19 @@ const resolveProject = async (
143
146
  { api, log, signal }: InstallCollaborators,
144
147
  ): Promise<{ id: string; name: string }> => {
145
148
  const project = await api.getProject({ projectId, signal })
149
+ const mode = REQUIRED_PROJECT_MODE[input.tier]
146
150
  if (project.id === '') {
147
151
  throw new Error(`Zerops project ${projectId} could not be read with this token`)
148
152
  }
149
153
  if (project.mode === undefined) {
150
154
  // Not a mismatch, so not a refusal: the client narrows an unknown value to absent, and blocking an
151
155
  // 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) {
156
+ log.warn(`Zerops did not state project ${project.name}'s mode; the ${input.tier} tier expects ${mode}`)
157
+ } else if (project.mode !== mode) {
154
158
  throw new Error(
155
- `Zerops project ${project.name} is ${project.mode} and the ${input.tier} tier needs ${REQUIRED_PROJECT_MODE}. \`corePackage\` is `
159
+ `Zerops project ${project.name} is ${project.mode} and the ${input.tier} tier needs ${mode}. \`corePackage\` is `
156
160
  + '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`,
161
+ + `with core package ${mode}, or install a tier that matches`,
158
162
  )
159
163
  }
160
164
  return { id: project.id, name: project.name }
@@ -163,7 +167,7 @@ const resolveProject = async (
163
167
  /**
164
168
  * Settle which project this run installs into — the operator's, or one created here.
165
169
  *
166
- * ONE confirmation either way, and both cover the read that follows: a created project is read back
170
+ * ONE confirmation either way, and both cover the token check and the read that follows: a created project is read back
167
171
  * through the same `resolveProject` an operator-made one goes through, so the core package is checked on
168
172
  * the project that will actually be installed into rather than on the document that asked for it.
169
173
  */
@@ -183,19 +187,23 @@ export const resolveInstallationProject = async (
183
187
  ) {
184
188
  throw new Error('declined: nothing was contacted, and re-running the install is safe')
185
189
  }
190
+ await assertPersonalAccessToken(collaborators)
186
191
  return resolveProject(input, source.projectId, collaborators)
187
192
  }
188
193
  log.step('Create the project this installation is brought up in')
189
194
  log.info('`envIsolation` and `corePackage` are settable at project CREATION only, which is why the project is created from here.')
190
195
  if (
191
196
  !(await prompts.confirm(
192
- `CREATE Zerops project \`${source.projectName}\` (core package ${REQUIRED_PROJECT_MODE}, envIsolation ${ENV_ISOLATION}) on client `
197
+ `Check the token, then CREATE Zerops project \`${source.projectName}\` (core package ${
198
+ REQUIRED_PROJECT_MODE[input.tier]
199
+ }, envIsolation ${ENV_ISOLATION}) on client `
193
200
  + `${input.clientId}, then read it back?`,
194
201
  true,
195
202
  ))
196
203
  ) {
197
204
  throw new Error('declined: no project was created, and re-running the install is safe')
198
205
  }
206
+ await assertPersonalAccessToken(collaborators)
199
207
  const projectId = await createProject(input, environment, source.projectName, collaborators)
200
208
  return resolveProject(input, projectId, collaborators)
201
209
  }
@@ -2,24 +2,24 @@ import { waitForProcess, type ZeropsImportResult, type ZeropsService, type Zerop
2
2
  import { PLATFORM_DEPLOY_ORDER, type PlatformDeployService, readServiceEnv, type ResolvedService } from './deploy'
3
3
  import { assertRunning, type InstallCollaborators } from './install-collaborators'
4
4
  import { GENERATED_SECRET_KEYS } from './install-environment'
5
- import { type CompiledDocument, type CompiledTopology, compileTopology, platformTopology } from './topology/topology'
5
+ import { type CompiledDocument, type CompiledTopology, compileTopology, type PlatformTier, platformTopology } from './topology/topology'
6
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)
7
+ /** The topology this command installs for a tier, so the project document and the services document cannot drift. */
8
+ export const installationTopology = (environment: string, tier: PlatformTier): CompiledTopology =>
9
+ compileTopology(platformTopology({ env: environment, tier, publicAccess: 'zerops-subdomain' }), environment)
10
10
 
11
11
  /**
12
12
  * The services-only provisioning document (WU2), compiled from the declaration the committed artifact
13
13
  * is rendered from.
14
14
  *
15
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.
16
+ * `packages/platform-artifacts/generated/platform{,-light}.services.provision.zerops-import.yaml` is this exact text,
17
+ * and a value needs no filesystem in a published package.
18
18
  */
19
- export const provisioningDocument = (environment: string): CompiledDocument => {
20
- const document = lightTopology(environment).servicesProvision
19
+ export const provisioningDocument = (environment: string, tier: PlatformTier): CompiledDocument => {
20
+ const document = installationTopology(environment, tier).servicesProvision
21
21
  if (document === undefined) {
22
- throw new Error('the light platform topology declares no services-only provisioning document')
22
+ throw new Error(`the ${tier} platform topology declares no services-only provisioning document`)
23
23
  }
24
24
  return document
25
25
  }
@@ -64,7 +64,7 @@ export const assertNoServiceIsBeingCreated = (existing: ReadonlyMap<string, Exis
64
64
  if (creating.length > 0) {
65
65
  throw new Error(
66
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',
67
+ + 'read from it or written to it yet. The topology settles in a few minutes; run the install again then',
68
68
  )
69
69
  }
70
70
  }
@@ -99,8 +99,8 @@ export const assertNotAlreadyInstalled = async (
99
99
  `this project already holds an installation's generated secrets — ${placed.join(', ')} ${placed.length === 1 ? 'is' : 'are'} already set. `
100
100
  + '`platform install` is a BRING-UP and would roll a new vault key and new signing keys over them, which is unrecoverable '
101
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. '
102
+ + 'to update or finish deploying the installation, with the credentials from the secrets file the first install wrote. '
103
+ + 'Neither deploy nor init completes a partial bootstrap. '
104
104
  + 'For partial bootstrap, preserve the existing keys and arrange operator-owned recovery, or start a fresh installation in a separate empty project',
105
105
  )
106
106
  }
@@ -0,0 +1,89 @@
1
+ // The local copy of every credential `platform install` creates.
2
+ //
3
+ // The services hold the same values in Zerops, but stored SENSITIVE, and Zerops hands a sensitive value
4
+ // back only to a token in sudo mode (measured 2026-10-15). So the operator's copy is the one this
5
+ // command writes, before the first of them is placed: a run that fails later loses nothing.
6
+
7
+ import { existsSync } from 'node:fs'
8
+ import { open } from 'node:fs/promises'
9
+ import { isAbsolute, relative, resolve } from 'node:path'
10
+ import type { InstallationSecrets } from './secrets'
11
+
12
+ /** `--secrets-file`: where the local copy goes. A new file, outside the source checkout. */
13
+ export const SECRETS_FILE_FLAG = '--secrets-file'
14
+ export const SECRETS_FILE_VARIABLE = 'FABRIKA_PLATFORM_SECRETS_FILE'
15
+
16
+ /**
17
+ * Settle the path before anything is contacted.
18
+ *
19
+ * Refused when it exists, because overwriting one would destroy another installation's only copy, and
20
+ * when it is inside the source checkout, where it would dirty the checkout `platform deploy` uploads
21
+ * and could be committed.
22
+ */
23
+ export const checkedSecretsFilePath = (path: string, sourceDir: string): string => {
24
+ const absolute = resolve(path)
25
+ const fromSource = relative(resolve(sourceDir), absolute)
26
+ if (fromSource === '' || (!fromSource.startsWith('..') && !isAbsolute(fromSource))) {
27
+ throw new Error(`${SECRETS_FILE_FLAG}=${path} is inside the source checkout — name a path outside it, so it is never uploaded or committed`)
28
+ }
29
+ if (existsSync(absolute)) {
30
+ throw new Error(`${SECRETS_FILE_FLAG}=${path} already exists — it may be another installation's only copy; name a new file`)
31
+ }
32
+ return absolute
33
+ }
34
+
35
+ /** What the file records beside the credentials, so it identifies its installation on its own. */
36
+ export interface InstallationSecretsRecord {
37
+ readonly environment: string
38
+ readonly projectId: string
39
+ readonly clientId: string
40
+ readonly secrets: InstallationSecrets
41
+ /** The integration token minted for the control plane. */
42
+ readonly zeropsAccessToken: string
43
+ }
44
+
45
+ /**
46
+ * The file's text: shell-sourceable assignments, named as the commands that read them name them.
47
+ *
48
+ * `FABRIKA_ZEROPS_ACCESS_TOKEN` is the INTEGRATION token, which is what `platform init` and
49
+ * `platform deploy` take; the personal token the install ran with is not written.
50
+ */
51
+ export const renderSecretsFile = (record: InstallationSecretsRecord): string => {
52
+ const { secrets } = record
53
+ const assignments: readonly (readonly [string, string])[] = [
54
+ ['FABRIKA_ZEROPS_PROJECT_ID', record.projectId],
55
+ ['FABRIKA_ZEROPS_CLIENT_ID', record.clientId],
56
+ ['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
57
+ ['FABRIKA_ZEROPS_ACCESS_TOKEN', record.zeropsAccessToken],
58
+ ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
59
+ ['FABRIKA_IAM_PROXY_KEY', secrets.proxyKey],
60
+ ['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
61
+ ['FABRIKA_CONTROL_VAULT_KEY', secrets.vaultKey],
62
+ ['FABRIKA_IAM_SIGNING_KEYS', secrets.signingKeys],
63
+ ]
64
+ const lines = assignments.map(([name, value]) => {
65
+ // Single quotes keep every character literal; none of these values can contain one.
66
+ if (value.includes("'") || value.includes('\n')) {
67
+ throw new Error(`${name} cannot be written to the secrets file as one quoted line`)
68
+ }
69
+ return `${name}='${value}'`
70
+ })
71
+ return [
72
+ `# fabrika on Zerops — installation \`${record.environment}\`, project ${record.projectId}.`,
73
+ '# Written by `fabrika platform install`. Every value below is a credential: keep this file private.',
74
+ '# Zerops holds the same values as sensitive variables and does not hand them back.',
75
+ '# Load it in a subshell: ( set -a && . <this file> && set +a && fabrika … )',
76
+ ...lines,
77
+ '',
78
+ ].join('\n')
79
+ }
80
+
81
+ /** Create the file with mode 0600, failing rather than replacing one that appeared since the check. */
82
+ export const writeSecretsFile = async (path: string, text: string): Promise<void> => {
83
+ const file = await open(path, 'wx', 0o600)
84
+ try {
85
+ await file.writeFile(text)
86
+ } finally {
87
+ await file.close()
88
+ }
89
+ }
@@ -0,0 +1,37 @@
1
+ import { ZeropsApiError } from '../index'
2
+ import type { InstallCollaborators } from './install-collaborators'
3
+
4
+ /** The platform's answer when an integration token without delegation asks to mint another one. */
5
+ const MINT_REFUSED_TO_INTEGRATION_TOKEN = 'notAllowedForIntegrationTokenWithoutDelegation'
6
+
7
+ /** How `/user/info` names an integration token. Reported by an operator, not measured here. */
8
+ const INTEGRATION_TOKEN_EMAIL = /^token-[^@]+@zerops\.io$/i
9
+
10
+ const PERSONAL_TOKEN_REQUIRED = 'FABRIKA_ZEROPS_ACCESS_TOKEN is a Zerops integration token (`/user/info` names it `token-…@zerops.io`), '
11
+ + 'and an integration token may not mint the one the control plane needs. Generate a personal access token in the Zerops GUI, '
12
+ + 'as a user who is OWNER on the client'
13
+
14
+ /**
15
+ * Refuse an integration token before anything is created.
16
+ *
17
+ * The install mints an integration token at step 4, and an integration token is refused that mint. Without
18
+ * this read the mistake costs a billed project and six imported services first.
19
+ */
20
+ export const assertPersonalAccessToken = async ({ api, signal }: InstallCollaborators): Promise<void> => {
21
+ const identity = await api.getUserInfo({ signal })
22
+ if (INTEGRATION_TOKEN_EMAIL.test(identity.email)) {
23
+ throw new Error(`${PERSONAL_TOKEN_REQUIRED}. Nothing was created`)
24
+ }
25
+ }
26
+
27
+ /** Translate the mint's refusal of an integration token into the fix and the way back into this project. */
28
+ export const explainMintFailure = (failure: unknown, projectId: string): unknown => {
29
+ if (!(failure instanceof ZeropsApiError) || failure.code !== MINT_REFUSED_TO_INTEGRATION_TOKEN) {
30
+ return failure
31
+ }
32
+ return new Error(
33
+ `${PERSONAL_TOKEN_REQUIRED}. No generated secret was placed yet: re-run the install with the personal token and `
34
+ + `--project-id=${projectId} instead of --create-project; it keeps the services already imported`,
35
+ { cause: failure },
36
+ )
37
+ }