@fabrika/provider-zerops 0.4.1 → 0.4.3

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 (45) 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 +41 -3
  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 +66 -22
  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 +319 -0
  18. package/src/installation/index.ts +42 -16
  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/manifest.ts +29 -12
  31. package/src/installation/platform-archive.ts +87 -0
  32. package/src/installation/platform-upload.ts +2 -2
  33. package/src/installation/secrets.ts +3 -4
  34. package/src/installation/topology/setups.ts +15 -27
  35. package/src/installation/topology/topology.ts +105 -104
  36. package/src/managed-env.ts +11 -0
  37. package/src/manifest.ts +25 -3
  38. package/src/namespace/codec.ts +6 -1
  39. package/src/namespace/errors.ts +3 -1
  40. package/src/namespace/provisioning.ts +18 -5
  41. package/src/namespace/proxy-attempt.ts +56 -14
  42. package/src/namespace/update.ts +50 -8
  43. package/src/provider.ts +57 -13
  44. package/src/proxy-manifest-carrier.ts +32 -0
  45. package/src/source/port.ts +9 -1
@@ -9,9 +9,11 @@ import {
9
9
  } from '@fabrika/installation-init'
10
10
  import type { SchemaReconciler } from '@fabrika/provider-contract'
11
11
  import type { InstallationCli, InstallationCommand } from '@fabrika/provider-contract/installation'
12
+ import { fileURLToPath } from 'node:url'
12
13
  import { createZeropsApi, defaultSleep } from '../index'
13
14
  import { consoleAdminCollaborators, runPlatformAdmin } from './admin'
14
15
  import { parsePlatformAdminArgs, REISSUE_FLAG } from './admin-options'
16
+ import { npmCheckerPublication } from './checker-release'
15
17
  import { deployPlatform, PLATFORM_CONCURRENT_DEPLOY, PLATFORM_DEPLOY_ORDER, PLATFORM_SEQUENTIAL_DEPLOY } from './deploy'
16
18
  import { parsePlatformDeployArgs } from './deploy-options'
17
19
  import { consoleInitCollaborators, runInit } from './init'
@@ -44,7 +46,11 @@ export {
44
46
 
45
47
  const USAGE = `Zerops installation
46
48
 
49
+ New here, or an agent? Run \`fabrika platform guide --provider=zerops\` first: the whole bring-up in order,
50
+ what to ask the human for, and the traps this text cannot show.
51
+
47
52
  Commands:
53
+ fabrika platform guide --provider=zerops
48
54
  fabrika platform install --provider=zerops [options]
49
55
  fabrika platform init --provider=zerops <installation> --tag=<tag> --project-id=<id> [options]
50
56
  fabrika platform plan --provider=zerops
@@ -61,9 +67,9 @@ deploy that push triggers.
61
67
 
62
68
  ── platform install ──────────────────────────────────────────────────────────────────────────────
63
69
 
64
- The from-scratch bring-up. With \`--create-project\` it creates the project too, with core package
65
- LIGHT and \`envIsolation: service\` — the two settings Zerops accepts at project CREATION only and never
66
- afterwards. \`--project-id=<id>\` installs into an empty project you created yourself instead, with both
70
+ The from-scratch bring-up. With \`--create-project\` it creates the project too, with the tier's core
71
+ package (LIGHT or SERIOUS) and \`envIsolation: service\` — the two settings Zerops accepts at project
72
+ CREATION only and never afterwards. \`--project-id=<id>\` installs into an empty project you created yourself instead, with both
67
73
  already right; nothing here can read \`envIsolation\` back or correct it.
68
74
 
69
75
  Interactive and laptop-side. It CONFIRMS before every step that leaves your machine: reading the
@@ -74,27 +80,30 @@ is not consent.
74
80
 
75
81
  1. create the project and wait for it to be ACTIVE (\`--create-project\`; its id is printed before
76
82
  the wait), or read the one you named — and refuse a core package the tier does not match
77
- 2. import ${PLATFORM_DEPLOY_ORDER.length + 2} services without code, and wait for the import's processes
83
+ 2. import the tier's services without code, and wait for the import's processes
78
84
  3. generate six secrets and mint the control plane's Zerops INTEGRATION token
79
- (client role NO_ACCESS, ADMIN on this project only — never your personal token)
85
+ (client role NO_ACCESS, ADMIN on this project only — never your personal token), and write
86
+ all of them to \`--secrets-file\` (mode 0600) before the first one is placed
80
87
  4. pass 1: give the proxy an EMPTY manifest and build it, so it publishes one public hostname per
81
88
  HTTP port; the three platform hosts are then READ off those, never composed
82
89
  5. write every remaining variable on all four runtime services
83
90
  6. pass 2: hand the whole ordered sequence to \`platform deploy\`
84
- 7. print the provisioning key — the CLI keeps no copy; it is held by iam and control in Zerops
91
+ 7. print the next commands; they read their credentials from the secrets file. Zerops holds the
92
+ same values as SENSITIVE variables and does not hand them back, so that file is the only copy
85
93
 
86
94
  It REFUSES a project that already holds an installation's generated secrets: a second bring-up would
87
95
  roll a new vault key over the old one, which is unrecoverable, and new signing keys, which logs
88
96
  everyone out. Once every bootstrap variable is placed, \`platform deploy\` can update the installation
89
- or finish a failed pass 2. Read FABRIKA_IAM_PROVISIONING_KEY from iam or control and
90
- FABRIKA_ZEROPS_ACCESS_TOKEN from control in Zerops if the handoff was interrupted.
97
+ or finish a failed pass 2, with the credentials from the secrets file.
91
98
  Neither deploy nor init completes a partial bootstrap. If interrupted after a generated secret was
92
99
  placed but before every bootstrap variable was placed, preserve the existing keys and arrange
93
100
  operator-owned recovery, or start a fresh installation in a separate empty project.
94
101
  Re-running install after any generated secret was placed is refused.
95
102
 
96
- Only the LIGHT tier can be installed this way — it is the one topology that emits a services-only
97
- import document. Authentication is PASSWORD ONLY; no administrator exists yet when this finishes.
103
+ Two tiers, one project each. \`light\` (core LIGHT): one single-node PostgreSQL and one bucket shared by
104
+ IAM, control and Operations, one container per service. \`standard\` (core SERIOUS): an HA PostgreSQL for
105
+ IAM and control, a second HA PostgreSQL and bucket for Operations, two containers of IAM and the proxy.
106
+ Authentication is PASSWORD ONLY; no administrator exists yet when this finishes.
98
107
 
99
108
  Options (a flag beats the environment variable beside it):
100
109
 
@@ -107,7 +116,8 @@ Options (a flag beats the environment variable beside it):
107
116
  --env=<name> FABRIKA_PLATFORM_ENVIRONMENT written to every service as ENVIRONMENT
108
117
  --scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
109
118
  --source-dir=<checkout> required clean full checkout running this CLI
110
- --tier=light FABRIKA_PLATFORM_TIER default light, and the only value
119
+ --tier=<light|standard> FABRIKA_PLATFORM_TIER default light
120
+ --secrets-file=<path> FABRIKA_PLATFORM_SECRETS_FILE required: a NEW file outside the checkout
111
121
  ${UNATTENDED_FLAG} answer every confirmation yes —
112
122
  required when stdin is not a terminal
113
123
 
@@ -243,23 +253,24 @@ commit availability before its effects; a local commit is not thereby published
243
253
  The first human. A fresh installation has none — nothing seeds an admission list, and this command is
244
254
  why it does not have to. It is unattended, prompts for nothing, and changes nothing on a re-run:
245
255
 
246
- 1. find the principal holding that mailbox, or invite one
256
+ 1. find the principal holding that address, or create one
247
257
  2. grant it the CROSS-APP \`admin\` role, unless it already holds one
248
258
  3. issue ONE password enrollment and print its URL
249
259
 
250
260
  The grant is cross-app (\`app: null\`) and that is load-bearing: grants filter to the calling app, so an
251
261
  \`admin\` grant scoped to the console's own app id leaves Delivery and Operations working while the
252
- Access plane refuses. A re-run invites nobody twice, grants nothing twice and issues no second
262
+ Access plane refuses. A re-run creates nobody twice, grants nothing twice and issues no second
253
263
  enrollment — a password that is already set, or an enrollment already outstanding, is REPORTED. Pass
254
264
  \`${REISSUE_FLAG}\` to replace an outstanding one, which is the way out when the first expired unopened.
255
265
 
256
- The enrollment URL is a credential: it is printed once, on a line of its own, and stored nowhere.
266
+ Nothing is e-mailed: a Zerops installation has no mail service. Hand the URL to the administrator
267
+ yourself. It is a credential: it is printed once, on a line of its own, and stored nowhere.
257
268
 
258
269
  \`install\` reports the host this needs as \`✓ iam <host>\` and ends with this command ready to run.
259
270
 
260
271
  Options (a flag beats the environment variable beside it):
261
272
 
262
- --email=<address> FABRIKA_PLATFORM_ADMIN_EMAIL the administrator's mailbox
273
+ --email=<address> FABRIKA_PLATFORM_ADMIN_EMAIL the administrator's sign-in address
263
274
  --iam-host=<host> FABRIKA_PLATFORM_IAM_HOST IAM's public hostname, as \`deploy\` names it
264
275
  --scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
265
276
  ${REISSUE_FLAG} replace an outstanding enrollment
@@ -305,6 +316,16 @@ laptop and not in the pipeline it triggers.
305
316
  /** No cancellation source exists behind a CLI invocation; the ports all take a signal regardless. */
306
317
  const neverAborted = (): AbortSignal => new AbortController().signal
307
318
 
319
+ /** The operator's guide of this release, shipped beside this module. */
320
+ export const readInstallationGuide = (): Promise<string> => Bun.file(fileURLToPath(new URL('./guide.md', import.meta.url))).text()
321
+
322
+ const runGuide = async (argv: readonly string[]): Promise<void> => {
323
+ if (argv.length > 0) {
324
+ throw new Error(`Unexpected Zerops guide arguments: ${argv.map(describeArgument).join(' ')}`)
325
+ }
326
+ console.info(await readInstallationGuide())
327
+ }
328
+
308
329
  const runPlan = (argv: readonly string[]): void => {
309
330
  if (argv.length > 0) {
310
331
  throw new Error(`Unexpected Zerops plan arguments: ${argv.map(describeArgument).join(' ')}`)
@@ -332,6 +353,7 @@ const runDeploy = async (argv: readonly string[]): Promise<void> => {
332
353
  const input = parsePlatformDeployArgs(argv, process.env)
333
354
  await deployPlatform(input, {
334
355
  prepareSource: prepareInstallationSource,
356
+ checkerPublished: npmCheckerPublication(),
335
357
  uploadFetch: (url, init) => fetch(url, init),
336
358
  api: createZeropsApi({
337
359
  token: input.accessToken,
@@ -368,9 +390,13 @@ const runPlatformUpgrade = async (argv: readonly string[]): Promise<void> => {
368
390
 
369
391
  export const installationCli: InstallationCli = {
370
392
  provider: 'zerops',
371
- commands: ['install', 'init', 'plan', 'deploy', 'admin', 'upgrade'],
393
+ commands: ['install', 'init', 'plan', 'deploy', 'admin', 'upgrade', 'guide'],
372
394
  usage: USAGE,
373
395
  run: async (command: InstallationCommand, argv: readonly string[]) => {
396
+ if (command === 'guide') {
397
+ await runGuide(argv)
398
+ return
399
+ }
374
400
  if (command === 'install') {
375
401
  await runPlatformInstall(argv)
376
402
  return
@@ -138,9 +138,9 @@ export const parseInitArgs = (argv: readonly string[], env: CommandEnv, cwd: str
138
138
  accessToken: credential(
139
139
  env,
140
140
  'FABRIKA_ZEROPS_ACCESS_TOKEN',
141
- "the Zerops INTEGRATION token `platform install` minted, read from the `control` service's environment",
141
+ 'the Zerops INTEGRATION token `platform install` minted, from its secrets file',
142
142
  ),
143
- provisioningKey: credential(env, 'FABRIKA_IAM_PROVISIONING_KEY', 'the px_ key `platform install` printed'),
143
+ provisioningKey: credential(env, 'FABRIKA_IAM_PROVISIONING_KEY', 'the px_ key from the secrets file `platform install` wrote'),
144
144
  unattended: argv.includes(UNATTENDED_FLAG),
145
145
  ...(hosts === undefined ? {} : { hosts }),
146
146
  ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
@@ -95,8 +95,14 @@ export const inspectPlatform = async (projectId: string, api: ZeropsApi, signal:
95
95
  if (control === undefined || control.id === '') throw new Error(`Zerops project ${projectId} has no \`control\` service`)
96
96
  const proxy = oneService(services, 'proxy')
97
97
  if (proxy === undefined || proxy.id === '') throw new Error(`Zerops project ${projectId} has no \`proxy\` service`)
98
- const readEnv = async (serviceId: string): Promise<Map<string, string>> =>
99
- new Map((await api.listServiceEnv({ serviceId, signal })).map((item) => [item.key, item.content]))
98
+ // Only the readable values: everything init prints is derived from non-secret configuration.
99
+ const readEnv = async (serviceId: string): Promise<Map<string, string>> => {
100
+ const readable = new Map<string, string>()
101
+ for (const item of await api.listServiceEnv({ serviceId, signal })) {
102
+ if (!item.sensitive) readable.set(item.key, item.content)
103
+ }
104
+ return readable
105
+ }
100
106
  return {
101
107
  controlEnv: await readEnv(control.id),
102
108
  proxyEnv: await readEnv(proxy.id),
@@ -1,6 +1,7 @@
1
1
  import type { Confirmations } from '@fabrika/installation-init'
2
2
  import type { SchemaReconciler } from '@fabrika/provider-contract'
3
3
  import type { Sleeper, ZeropsApi } from '../index'
4
+ import type { CheckerPublication } from './checker-release'
4
5
  import type { PlatformSourcePreparer } from './installation-source'
5
6
  import type { InitLog } from './log'
6
7
  import type { PlatformUploadFetch } from './platform-upload'
@@ -11,6 +12,7 @@ export type InstallPrompts = Confirmations
11
12
  /** Everything `runInstall` reaches the outside world through. */
12
13
  export interface InstallCollaborators {
13
14
  readonly prepareSource: PlatformSourcePreparer
15
+ readonly checkerPublished: CheckerPublication
14
16
  readonly uploadFetch: PlatformUploadFetch
15
17
  readonly api: ZeropsApi
16
18
  readonly reconcileSchema: SchemaReconciler
@@ -18,6 +20,8 @@ export interface InstallCollaborators {
18
20
  readonly log: InitLog
19
21
  readonly prompts: InstallPrompts
20
22
  readonly signal: AbortSignal
23
+ /** Create the local secrets file (`--secrets-file`) with this text. Never logged. */
24
+ readonly storeSecrets: (text: string) => Promise<void>
21
25
  }
22
26
 
23
27
  export const assertRunning = (signal: AbortSignal): void => {
@@ -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
  }