@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
@@ -35,10 +35,14 @@
35
35
  // readiness gate.
36
36
 
37
37
  import { FABRIKA_SERVICE_TABLE_DIGEST, readEnvironmentName } from '@fabrika/auth-core'
38
+ import { IAM_APP_ID, IAM_APP_SCHEMA } from '@fabrika/iam-contract'
38
39
  import type { SchemaReconciler } from '@fabrika/provider-contract'
39
40
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
41
+ import { assertServiceEnvValueFits } from '../api'
40
42
  import {
43
+ enableSubdomainAccessWhenHttp,
41
44
  prepareZeropsDescriptor,
45
+ readableContent,
42
46
  type Sleeper,
43
47
  ZEROPS_ACTIVE,
44
48
  ZEROPS_SERVICE_NOT_HTTP,
@@ -47,7 +51,10 @@ import {
47
51
  ZeropsApiError,
48
52
  type ZeropsNamespaceProxySource,
49
53
  type ZeropsService,
54
+ type ZeropsServiceEnv,
50
55
  } from '../index'
56
+ import { ZEROPS_PROXY_MANIFEST_PATH } from '../proxy-manifest-carrier'
57
+ import { assertCheckerPublished, type CheckerPublication } from './checker-release'
51
58
  import type { PlatformDeployInput } from './deploy-options'
52
59
  import { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
53
60
  import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
@@ -63,6 +70,7 @@ import {
63
70
  import type { DeployLog } from './log'
64
71
  import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
65
72
  import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
73
+ import { archiveWithDataFile } from './platform-archive'
66
74
  import type { PreparedPlatformSource } from './platform-source'
67
75
  import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
68
76
  import { platformProxyAppFor, type PlatformProxyComposition, platformServiceTableDigest, resolvePlatformProxyManifest } from './proxy-manifest'
@@ -139,6 +147,7 @@ export interface PlatformServiceDeployCollaborators {
139
147
  /** Everything `deployPlatform` reaches the outside world through. */
140
148
  export interface PlatformDeployCollaborators extends PlatformServiceDeployCollaborators {
141
149
  readonly prepareSource: PlatformSourcePreparer
150
+ readonly checkerPublished: CheckerPublication
142
151
  readonly reconcileSchema: SchemaReconciler
143
152
  }
144
153
 
@@ -146,6 +155,16 @@ export interface PlatformDeployCollaborators extends PlatformServiceDeployCollab
146
155
  export interface PlatformServiceDeployInput {
147
156
  readonly source: PreparedPlatformSource
148
157
  readonly dryRun: boolean
158
+ /** The proxy's manifest, which its archive carries as the one data file (ADR-0045); the proxy is refused without it. */
159
+ readonly proxyManifest?: string
160
+ }
161
+
162
+ /** The archive one service builds from: the snapshot, and for the proxy the snapshot plus its manifest. */
163
+ const serviceArchive = async (service: ResolvedService, input: PlatformServiceDeployInput): Promise<Blob> => {
164
+ const snapshot = Bun.file(input.source.archivePath)
165
+ if (service.hostname !== PLATFORM_PROXY_SERVICE) return snapshot
166
+ if (input.proxyManifest === undefined) throw new Error('the proxy is built only with the manifest its archive carries')
167
+ return new Blob([archiveWithDataFile(await snapshot.bytes(), { path: ZEROPS_PROXY_MANIFEST_PATH, contents: input.proxyManifest })])
149
168
  }
150
169
 
151
170
  /** One resolved platform service. */
@@ -237,13 +256,16 @@ export const serviceOf = (services: ReadonlyMap<PlatformDeployService, ResolvedS
237
256
  return service
238
257
  }
239
258
 
240
- /** Read one service's environment as a key → value map. Values are never logged. */
259
+ /** One service's environment by key. A sensitive record carries no value. Values are never logged. */
260
+ export type LiveServiceEnv = ReadonlyMap<string, ZeropsServiceEnv>
261
+
262
+ /** Read one service's environment by key. */
241
263
  export const readServiceEnv = async (
242
264
  serviceId: string,
243
265
  { api, signal }: Pick<PlatformServiceDeployCollaborators, 'api' | 'signal'>,
244
- ): Promise<Map<string, string>> => {
266
+ ): Promise<LiveServiceEnv> => {
245
267
  const variables = await api.listServiceEnv({ serviceId, signal })
246
- return new Map(variables.map((variable) => [variable.key, variable.content]))
268
+ return new Map(variables.map((variable) => [variable.key, variable]))
247
269
  }
248
270
 
249
271
  /**
@@ -254,11 +276,11 @@ export const readServiceEnv = async (
254
276
  * `custom-domain` installation, where the domains are bound to the project balancer out of band and
255
277
  * publishing a subdomain would add a public address nobody asked for.
256
278
  */
257
- const resolvePlacement = (input: PlatformDeployInput, proxyEnv: ReadonlyMap<string, string>): PlatformPlacement => {
279
+ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: LiveServiceEnv): PlatformPlacement => {
258
280
  if (input.hosts !== undefined) {
259
281
  return { hosts: input.hosts, scheme: input.scheme, zeropsSubdomain: false }
260
282
  }
261
- const subdomains = proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
283
+ const subdomains = readableContent(proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE))
262
284
  if (subdomains === undefined || subdomains.trim() === '') {
263
285
  throw new Error(
264
286
  'the proxy service publishes no Zerops subdomain — deploy it once so it has HTTP ports, or name every host '
@@ -281,7 +303,6 @@ const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
281
303
  const desiredServiceEnv = (
282
304
  environment: string,
283
305
  placement: PlatformPlacement,
284
- manifestJson: string,
285
306
  proxySource: ZeropsNamespaceProxySource,
286
307
  oauthConfig: IamOAuthConfig | undefined,
287
308
  controlTableDigest: string,
@@ -314,7 +335,6 @@ const desiredServiceEnv = (
314
335
  // Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
315
336
  // private `iam:3000` the manifest dials — `readProxyEnv` canonicalizes it to an origin.
316
337
  ['FABRIKA_IAM_ISSUER', iamOrigin],
317
- [FABRIKA_PROXY_MANIFEST_JSON, manifestJson],
318
338
  ['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
319
339
  ]),
320
340
  ],
@@ -338,25 +358,32 @@ const desiredServiceEnv = (
338
358
  ])
339
359
  }
340
360
 
341
- /** Write only what differs. Returns the KEYS written — never a value, not even in a debug line. */
361
+ /**
362
+ * Write only what differs. Returns the KEYS written — never a value, not even in a debug line.
363
+ *
364
+ * Nothing here is a credential, so everything is written readable. A record an older release wrote
365
+ * sensitive reads as different and is rewritten readable, which is what lets the next deploy compare it.
366
+ */
342
367
  const applyServiceEnv = async (
343
368
  service: ResolvedService,
344
369
  desired: ReadonlyMap<string, string>,
345
- live: ReadonlyMap<string, string>,
370
+ live: LiveServiceEnv,
346
371
  input: PlatformDeployInput,
347
372
  collaborators: PlatformDeployCollaborators,
348
373
  ): Promise<string[]> => {
349
374
  const written: string[] = []
350
375
  for (const [key, value] of desired) {
351
- if (live.get(key) === value) {
376
+ if (readableContent(live.get(key)) === value) {
352
377
  continue
353
378
  }
354
379
  written.push(key)
380
+ // The client refuses it too; checking here lets a dry run report what the real write would refuse.
381
+ assertServiceEnvValueFits(key, value)
355
382
  if (input.dryRun) {
356
383
  continue
357
384
  }
358
385
  assertRunning(collaborators.signal)
359
- await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, signal: collaborators.signal })
386
+ await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, sensitive: false, signal: collaborators.signal })
360
387
  }
361
388
  return written
362
389
  }
@@ -434,11 +461,12 @@ export const deployPlatformService = async (
434
461
  return
435
462
  }
436
463
  prepareZeropsDescriptor(input.source.descriptor.contents, service.hostname)
464
+ const archive = await serviceArchive(service, input)
437
465
  const reserved = await api.createAppVersion({ serviceId: service.id, name: input.source.commitSha, signal })
438
466
  const appVersionId = reserved.id
439
467
  log.info(`${service.hostname}: app-version ${appVersionId}, commit ${input.source.commitSha}, setup ${service.hostname}`)
440
468
  try {
441
- await uploadPlatformArchive(reserved.uploadUrl, input.source.archivePath, signal, collaborators.uploadFetch)
469
+ await uploadPlatformArchive(reserved.uploadUrl, archive, signal, collaborators.uploadFetch)
442
470
  assertRunning(signal)
443
471
  } catch {
444
472
  await cleanupReservation(service, appVersionId, collaborators)
@@ -502,7 +530,8 @@ const deployConcurrently = async (
502
530
  * The decision is the READ-BACK and never the call returning: on an already-published service the
503
531
  * platform answers 2xx and then fails the process it hands back, and a freshly-enabled one can still
504
532
  * read `false` on the next read. Both live-verified. Runs on every deploy, so a subdomain someone
505
- * turned off in the GUI comes back, and throws rather than leaving the installation unreachable.
533
+ * turned off in the GUI comes back, and throws rather than leaving the installation unreachable. The
534
+ * HTTP ports lag a fresh ACTIVE version, so `serviceStackIsNotHttp` is retried for a bounded window first.
506
535
  */
507
536
  const ensureSubdomainAccess = async (
508
537
  proxy: ResolvedService,
@@ -519,11 +548,11 @@ const ensureSubdomainAccess = async (
519
548
  return
520
549
  }
521
550
  try {
522
- await api.enableSubdomainAccess({ serviceId: proxy.id, signal })
551
+ await enableSubdomainAccessWhenHttp({ api, serviceId: proxy.id, sleep, signal })
523
552
  } catch (error) {
524
553
  if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
525
554
  throw new Error(
526
- `the proxy cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
555
+ `the proxy cannot be published on a zerops.app subdomain: it still exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP}) minutes after its deploy`,
527
556
  )
528
557
  }
529
558
  throw error
@@ -569,6 +598,7 @@ export const deployPlatform = async (
569
598
  const source = await collaborators.prepareSource(input.sourceDir, collaborators.signal)
570
599
  try {
571
600
  validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
601
+ await assertCheckerPublished(collaborators.checkerPublished, collaborators.signal)
572
602
  await deployPreparedPlatform(input, collaborators, source)
573
603
  } finally {
574
604
  await source.dispose()
@@ -617,6 +647,7 @@ export const deployPreparedPlatform = async (
617
647
  ? 'MCP authoring: off'
618
648
  : `MCP authoring: on, ${oauthConfig.clients.length} client(s), resource(s) ${oauthConfig.resources.map((entry) => entry.resource).join(', ')}`,
619
649
  )
650
+ // The variable is the application entries the platform proxy carries, held by the operator; nothing here writes it.
620
651
  const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
621
652
  log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
622
653
  if (merged.carried.length > 0) {
@@ -631,12 +662,14 @@ export const deployPreparedPlatform = async (
631
662
 
632
663
  log.step('Write the service environment')
633
664
  const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
634
- const desired = desiredServiceEnv(environment, placement, encodeProxyManifestJson(merged.manifest), proxySource, oauthConfig, controlTableDigest)
665
+ const desired = desiredServiceEnv(environment, placement, proxySource, oauthConfig, controlTableDigest)
635
666
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
636
667
  const service = serviceOf(services, hostname)
637
668
  const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
638
669
  const keys = desired.get(hostname) ?? new Map<string, string>()
639
- if (hostname === 'iam' && oauthConfig === undefined && (live.get(IAM_OAUTH_CONFIG) ?? '').trim() !== '') {
670
+ const liveOAuthConfig = live.get(IAM_OAUTH_CONFIG)
671
+ const oauthStillSet = liveOAuthConfig !== undefined && (liveOAuthConfig.sensitive || liveOAuthConfig.content.trim() !== '')
672
+ if (hostname === 'iam' && oauthConfig === undefined && oauthStillSet) {
640
673
  log.warn(`iam: MCP authoring is off but ${IAM_OAUTH_CONFIG} is still set; IAM keeps issuing MCP tokens until it is removed`)
641
674
  }
642
675
  const written = await applyServiceEnv(service, keys, live, input, collaborators)
@@ -647,11 +680,12 @@ export const deployPreparedPlatform = async (
647
680
  )
648
681
  }
649
682
 
650
- // EVERY deploy is after EVERY write. That is what makes the sequence fail closed: a manifest this
651
- // command could not apply leaves the previous manifest in front of the previous code, never in front
652
- // of new code — and `ENVIRONMENT` is in place before any service that refuses `local` restarts.
683
+ // EVERY deploy is after EVERY write, and the manifest rides in the proxy's own archive. That is what makes
684
+ // the sequence fail closed: a proxy build that does not go ACTIVE leaves the previous manifest in front of
685
+ // the previous code, because control builds only after it — and `ENVIRONMENT` is in place before any
686
+ // service that refuses `local` restarts.
653
687
  log.step(`Deploy ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')}`)
654
- const buildInput = { source, dryRun: input.dryRun }
688
+ const buildInput = { source, dryRun: input.dryRun, proxyManifest: encodeProxyManifestJson(merged.manifest) }
655
689
  await deployConcurrently(services, buildInput, collaborators)
656
690
  for (const hostname of PLATFORM_SEQUENTIAL_DEPLOY) {
657
691
  await deployPlatformService(serviceOf(services, hostname), buildInput, collaborators)
@@ -669,11 +703,12 @@ export const deployPreparedPlatform = async (
669
703
  log.info('custom-domain installation: the operator binds each domain to the project balancer; no subdomain is published')
670
704
  }
671
705
 
672
- log.step('Register the console with IAM')
706
+ log.step('Register the console and IAM with IAM')
673
707
  const consoleApp = platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control')
674
708
  const consoleOrigin = platformOrigin(placement.scheme, placement.hosts.control)
675
709
  if (input.dryRun) {
676
710
  log.info(`would reconcile \`${consoleApp.id}\` and register its return origin ${consoleOrigin}`)
711
+ log.info(`would reconcile \`${IAM_APP_ID}\`'s own vocabulary`)
677
712
  } else {
678
713
  // The schema PUT is what REGISTERS the app, and `apps.setReturnOrigins` 404s for an app IAM has
679
714
  // never heard of — so both travel in this one call, in that order (ADR-0022/ADR-0023).
@@ -686,6 +721,15 @@ export const deployPreparedPlatform = async (
686
721
  signal,
687
722
  })
688
723
  log.info(`\`${consoleApp.id}\` reconciled; return origin ${consoleOrigin}`)
724
+ // IAM's own vocabulary, which `user-admin` is granted from (ADR-0058). It has no return origin.
725
+ await collaborators.reconcileSchema({
726
+ url: platformOrigin(placement.scheme, placement.hosts.iam),
727
+ app: IAM_APP_ID,
728
+ schema: IAM_APP_SCHEMA,
729
+ adminKey: input.iamAdminKey,
730
+ signal,
731
+ })
732
+ log.info(`\`${IAM_APP_ID}\` reconciled`)
689
733
  }
690
734
 
691
735
  log.step(input.dryRun ? 'Dry run complete — nothing was written' : 'Installation deployed')
@@ -11,6 +11,7 @@
11
11
  import type { AppSchema } from '@fabrika/auth-core'
12
12
 
13
13
  export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
14
+ displayName: 'Console',
14
15
  scopes: [
15
16
  { type: 'app', label: 'App' },
16
17
  { type: 'environment', label: 'Environment' },
@@ -31,14 +32,29 @@ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
31
32
  { action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },
32
33
  ],
33
34
  roles: {
35
+ observer: {
36
+ name: 'Observer',
37
+ description: 'Read Operations errors and activity.',
38
+ permissions: ['operations.read'],
39
+ },
40
+ triager: {
41
+ name: 'Triager',
42
+ description: 'Read and triage Operations errors.',
43
+ permissions: ['operations.read', 'operations.triage'],
44
+ },
45
+ deployer: {
46
+ name: 'Deployer',
47
+ description: 'Trigger, read and cancel deploys (no registry, namespace or secret management).',
48
+ permissions: ['deploy.*'],
49
+ },
34
50
  operator: {
35
51
  name: 'Operator',
36
- description: 'Trigger and read any deploy (no registry or secret management).',
52
+ description: 'Deploy, and read, triage and manage Operations (no registry or secret management).',
37
53
  permissions: ['deploy.*', 'operations.*'],
38
54
  },
39
55
  admin: {
40
56
  name: 'Admin',
41
- description: 'Full access to every vozka action in every scope.',
57
+ description: 'Every console action in every scope.',
42
58
  permissions: ['*'],
43
59
  },
44
60
  },
@@ -278,6 +278,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
278
278
  admission: null,
279
279
  oauth: null,
280
280
  },
281
+ {
282
+ id: 'github-setup',
283
+ kind: 'service',
284
+ method: 'GET',
285
+ path: '/api/source/github/setup/:connectionId',
286
+ callers: ['human'],
287
+ requirement: { kind: 'authenticated' },
288
+ admission: null,
289
+ oauth: null,
290
+ },
281
291
  {
282
292
  id: 'rpc',
283
293
  kind: 'service',
@@ -453,6 +463,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
453
463
  admission: null,
454
464
  oauth: null,
455
465
  },
466
+ {
467
+ id: 'github-setup',
468
+ kind: 'service',
469
+ method: 'GET',
470
+ path: '/api/source/github/setup/:connectionId',
471
+ callers: ['human'],
472
+ requirement: { kind: 'authenticated' },
473
+ admission: null,
474
+ oauth: null,
475
+ },
456
476
  {
457
477
  id: 'rpc',
458
478
  kind: 'service',
@@ -0,0 +1,319 @@
1
+ # Installing fabrika on Zerops — the operator's guide
2
+
3
+ From an empty Zerops account to a serving application. This guide ships with the CLI, so it matches the
4
+ release you run. It gives the order and the traps. Each command's flags are in its own help, and that text
5
+ is authoritative:
6
+
7
+ ```bash
8
+ fabrika platform --provider=zerops --help
9
+ fabrika control --help
10
+ ```
11
+
12
+ Run the steps in order and check each one before the next. Every command refuses a missing value with a
13
+ message that names the flag or the variable to set. Read the message: it names the fix.
14
+
15
+ ## Before you start
16
+
17
+ **Ask the human for these first.** You cannot produce them, and each one blocks a later step:
18
+
19
+ | What | Needed at |
20
+ | ------------------------------------------------------------------------------------- | ---------- |
21
+ | A Zerops PERSONAL access token of a client OWNER, generated in the Zerops GUI | step 1 |
22
+ | A verified Zerops account (credit topped up once); an unverified one owns ONE project | step 5 |
23
+ | A `gh` login allowed to create a repository in the organization that owns it | step 3 |
24
+ | The administrator's e-mail address, and the human to open the enrollment URL | step 2 |
25
+ | For a PRIVATE application repository: the GitHub App connected in the console | step 5 |
26
+ | The fabrika release tag to install, for example `v0.4.3` (a branch is refused) | steps 1, 3 |
27
+ | The four decisions below | steps 1, 5 |
28
+
29
+ Every namespace is a Zerops project of its own, so the first one fails with `clientProjectCountLimitExceededTopUp`
30
+ on an unverified account (observed 2026-10-15).
31
+
32
+ Also needed on the machine: [Bun](https://bun.sh) ≥ 1.3, `git`, `gh`, `curl` and `jq`.
33
+
34
+ ## Decisions: ask the human, with the prices
35
+
36
+ Each choice trades price against stability, and two of them cannot be undone. Show the human this section
37
+ and let them choose; do not pick for them. Prices are Zerops' list prices as of 2026-10-15
38
+ ([zerops.io/pricing](https://zerops.io/pricing)): a shared core $0.60, a dedicated core $6.00, RAM $3.00 per
39
+ GB, disk $0.10 per GB and object storage $0.01 per GB, each per 30 days and billed per minute. A Lightweight
40
+ project core is free (15 build hours, 100 GB egress); a Serious one is $10 (150 build hours, 3 TB egress, a
41
+ highly available balancer). The figures below are the idle FLOOR. Every service scales up under load, to 8
42
+ cores and 48 GB per container, and costs more while it does.
43
+
44
+ **1. Platform tier** (`platform install --tier`, step 1). Cannot be changed later: a project's core package
45
+ is upgrade-only, and the data services differ.
46
+
47
+ | Tier | What runs | Idle floor | Fails when |
48
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------- |
49
+ | `light` (default) | Lightweight core. One single-node PostgreSQL (`oltp-hobby`) and one bucket shared by IAM, control and Operations; one container each. | about $6 | any one container or the database is down: sign-in stops |
50
+ | `standard` | Serious core. HA PostgreSQL (3 nodes, `oltp-production`, dedicated CPU) for IAM and control; a second HA PostgreSQL and bucket for Operations; two containers of IAM and the proxy. | about $100 | survives the loss of one node of each |
51
+
52
+ Most of `standard`'s price is the identity database's dedicated floor (about $72). `light` is right for an
53
+ evaluation and a small fleet that tolerates an outage; `standard` for sign-in that must stay up.
54
+
55
+ **2. Namespace preset** (`control namespaces create --preset`, step 5). A namespace is the Zerops project
56
+ applications run in. Each one adds its own proxy, about $1.08.
57
+
58
+ | Preset | Where an application's database lives | Use with |
59
+ | --------------- | ----------------------------------------------------------------------- | ------------------------------------ |
60
+ | `cheap` | One shared single-node PostgreSQL in the namespace, about $1.45 for all | `fabrika.cheap.config.ts` |
61
+ | `mid` (default) | Each application imports its own database into the namespace | `fabrika.config.ts` |
62
+ | `full` | The namespace belongs to ONE application (`--exclusive-app=<id>`) | an application that must be isolated |
63
+
64
+ **3. The application's database** (the application's `fabrika.config.ts`, before step 5). The example
65
+ declares `postgresql:single@18` at `oltp-hobby`, about $1.45, plus its runtime from about $1.08 per
66
+ container. `postgresql:ha@18` survives a node failure: about $11 at `oltp-staging` (shared CPU), about $72 at
67
+ `oltp-production` (dedicated). Single to HA later means a new service and a data migration.
68
+
69
+ **4. Namespace core package** (`--core-package=LIGHT|SERIOUS`, step 5). `LIGHT` is free and the default on
70
+ every environment. `SERIOUS` adds $10 per namespace and a highly available balancer in front of its proxy,
71
+ and it can never be downgraded again.
72
+
73
+ ## Secrets: rules for an agent
74
+
75
+ - Every credential is read from the ENVIRONMENT ONLY. No command takes one as a flag.
76
+ - Never print a credential, never put one in a command line you echo, never commit one.
77
+ - `platform install` writes every credential it creates to `--secrets-file`, mode `0600`, before it places
78
+ the first one. That file is the ONLY readable copy. Zerops stores them as sensitive variables and answers
79
+ `REDACTED` when they are read back, so never try to read a secret from the Zerops API, and never ask the
80
+ human for sudo mode to do it. Back the file up where the human keeps credentials.
81
+ - Load the file in a subshell, so its integration token never replaces the personal one in your shell:
82
+ `( set -a && . <secrets file> && set +a && fabrika … )`.
83
+ - Keep any other credential in its own `0600` file (`umask 077` first) and pass it as `NAME="$(cat <file>)"`.
84
+ Check a file with `wc -c`, never with `cat`.
85
+
86
+ ## Step 0. Get the CLI at the release tag
87
+
88
+ `platform install` and `platform deploy` upload the source of the checkout the CLI runs from, and refuse
89
+ anything else (`--source-dir`). So the bring-up runs from a clean clone at the tag, not from a CLI installed
90
+ with `bun add --global`:
91
+
92
+ ```bash
93
+ git clone --depth 1 --branch <tag> https://github.com/contember/fabrika-platform.git fabrika-platform
94
+ cd fabrika-platform && bun install --frozen-lockfile
95
+ ```
96
+
97
+ In this guide, `fabrika` means `bun <clone>/packages/cli/src/index.ts`. Do not edit the clone: a dirty
98
+ checkout is refused, and a local commit is not on GitHub, so namespaces cannot fetch it.
99
+
100
+ Install a released tag, not a branch. Every application build fetches `@fabrika/check` at the version of the
101
+ checkout, so `install` and `deploy` refuse a checkout whose version npm does not have yet: `this checkout is
102
+ version X, which is not published yet`. A `main` between a release's version bump and its publish is such a
103
+ checkout.
104
+
105
+ ## Step 1. Install the platform
106
+
107
+ ```bash
108
+ export FABRIKA_ZEROPS_ACCESS_TOKEN="$(cat <personal token file>)"
109
+ curl -s -H "Authorization: Bearer $FABRIKA_ZEROPS_ACCESS_TOKEN" \
110
+ https://api.app-prg1.zerops.io/api/rest/public/user/info | jq '{email, clients: [.clientUserList[] | {clientId, name: .client.accountName, role: .roleCode}]}'
111
+ fabrika platform install --provider=zerops --create-project --client-id=<clientId> --env=<name> --tier=<light|standard> \
112
+ --secrets-file=<path outside the clone> --source-dir=. --yes > install.log 2>&1
113
+ ```
114
+
115
+ - Check the token kind first. The `email` of a personal token is the person's address, and its `role` on the
116
+ client is `OWNER`. An `email` of `token-<id>@zerops.io` is an INTEGRATION token. The install refuses one before
117
+ it creates anything, because an integration token may not mint the token the control plane needs. Ask the
118
+ human for a personal token instead.
119
+ - Run it from the clone root. It runs for many minutes: it imports the tier's services, builds the proxy
120
+ once to learn the public hosts, then deploys everything.
121
+ - `--yes` is required when stdin is not a terminal.
122
+ - `--secrets-file` names a NEW file outside the clone, for example `~/fabrika-<env>.secrets.env`. An existing
123
+ file is refused. The log prints no credential.
124
+ - The project id is printed BEFORE the long wait. If the run is interrupted, re-run with
125
+ `--project-id=<id>` instead of `--create-project`.
126
+ - A project that already holds generated secrets is refused. Do not try to force it: a second bring-up
127
+ over the same project would destroy the vault key. Start over in a new empty project.
128
+ - The account must be in the default region, or set `FABRIKA_ZEROPS_API_URL` for every command.
129
+
130
+ **Check.** The log ends with the `Hand off` step: where the credentials are, `The console answers at
131
+ https://<console host>`, and an `ADMIT YOURSELF` box that holds the step 2 command with `--iam-host` filled
132
+ in. The secrets file exists with mode `0600`. The three hosts are printed earlier, right after pass 1, as
133
+ `✓ iam <host>`, `✓ console <host>` and `✓ operations <host>`. Write them down.
134
+
135
+ ## Step 2. Admit the first administrator
136
+
137
+ ```bash
138
+ ( set -a && . <secrets file> && set +a && \
139
+ fabrika platform admin --provider=zerops --email=<address> --iam-host=<IAM host from step 1> )
140
+ ```
141
+
142
+ It prints one enrollment URL and the time it expires. Nothing is e-mailed: the installation has no mail
143
+ service, so do not tell the human to wait for a message. The URL is a credential: give it to the human only,
144
+ and do not log it. The human opens it, sets a password and signs in at the console origin.
145
+
146
+ The URL is valid for ONE HOUR. Hand it over at once. A re-run creates nothing new: while the enrollment is
147
+ unused it only reports that one is outstanding, and it never prints the URL again. To get a new URL, after
148
+ expiry or after a lost one, run the same command with `--reissue`. It replaces the old enrollment, so the old
149
+ URL stops working.
150
+
151
+ ## Step 3. Create the sidecar repository
152
+
153
+ The sidecar is a GitHub repository the operator owns. Its workflow runs `platform deploy` at the pinned tag.
154
+
155
+ Steps 4 and 5 do not depend on this step: the installation from step 1 already runs. The sidecar is what
156
+ upgrades it later (step 6). Run this step before or after step 5. Only `platform upgrade` needs it.
157
+
158
+ `init` takes the integration token step 1 minted, never the personal one. The secrets file holds it as
159
+ `FABRIKA_ZEROPS_ACCESS_TOKEN`, beside the provisioning key. Run `init` from a directory that is NOT inside
160
+ any git repository. The fabrika clone is a repository, and a home directory under dotfiles version control is
161
+ one too. Otherwise pass `--checkout=<dir>` outside one:
162
+
163
+ ```bash
164
+ ( set -a && . <secrets file> && set +a && \
165
+ fabrika platform init --provider=zerops <installation> --repo=<owner>/<name> --tag=<tag> \
166
+ --project-id=<project id> --env=<name from step 1> --yes < /dev/null )
167
+ ```
168
+
169
+ - Without `--repo` the repository is `contember/fabrika-zerops-<installation>`. Pass `--repo` for any
170
+ other organization.
171
+ - Keep the local checkout path. `platform upgrade` needs it later (`--sidecar=<dir>`).
172
+
173
+ **Check.** The `platform` workflow run in the sidecar repository ends green:
174
+ `gh run list --repo <owner>/<name>`.
175
+
176
+ ## Step 4. Mint a machine key for `fabrika control`
177
+
178
+ ```bash
179
+ ( umask 077 && set -a && . <secrets file> && set +a && FABRIKA_IAM_RPC_URL=https://<IAM host> \
180
+ fabrika control key issue --label=<name> --permissions=app.manage,namespace.manage,deploy.trigger,deploy.read \
181
+ --expires-in=2592000 > control.key )
182
+ wc -c control.key && ls -l control.key
183
+ ```
184
+
185
+ The secrets file supplies `FABRIKA_IAM_RPC_KEY` and `FABRIKA_IAM_PROVISIONING_KEY`.
186
+
187
+ - stdout carries the key and nothing else; the credential id goes to stderr. Keep the redirect inside the
188
+ `umask 077` subshell: `control.key` is then created with mode `0600` (`-rw-------`). Outside it, the file
189
+ is readable by other users.
190
+ - `FABRIKA_IAM_RPC_URL` is IAM's PUBLIC `https://` origin. The private `http://iam:3000` answers only inside
191
+ the Zerops project.
192
+ - A key expires after at most 30 days. Mint a new one when it does.
193
+
194
+ For every `fabrika control` command from here on:
195
+
196
+ ```bash
197
+ export FABRIKA_CONTROL_URL=https://<console host>
198
+ FABRIKA_CONTROL_KEY="$(cat control.key)" fabrika control apps list
199
+ ```
200
+
201
+ **Check.** On a fresh installation `apps list` prints `no applications registered yet` (on stderr) and
202
+ exits 0. An authentication error means the key or the console origin is wrong.
203
+
204
+ ## Step 5. Deploy the first application
205
+
206
+ The example is [`contember/fabrika-example-zerops`](https://github.com/contember/fabrika-example-zerops).
207
+ Its `fabrika.config.ts` places the app with its own database (`mid` preset), and `fabrika.cheap.config.ts`
208
+ places it on the namespace's shared PostgreSQL (`cheap` preset). Use the config that matches the preset.
209
+
210
+ **Repository access decides how you register** (step 5.2). Check it first:
211
+ `curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/<owner>/<name>`.
212
+
213
+ - `200`: the repository is public. Register it with `--installation-id=none`. Control reads it without a token.
214
+ - `404`: GitHub hides it from an anonymous caller, so it is private (or the name is wrong). `register` refuses
215
+ it with `--installation-id=none`. Ask the human to connect the GitHub App in the console first (Settings →
216
+ Source, a browser flow), then register without `--installation-id`.
217
+
218
+ **Use the installation's release in the application.** The application's `@fabrika/*` packages, the CLI
219
+ among them, must be the version of the tag you installed: for `v0.4.3`, `^0.4.3`. Another version can build
220
+ a manifest this installation's control refuses or reads differently. From the fabrika clone of step 0, run
221
+ `bun scripts/release.ts example-pin <application checkout> --tag=<tag>`. It rewrites every `@fabrika/*` range
222
+ and commits nothing. Commit and push the change in the application repository, then run `bun install` there.
223
+ Check with `bun pm ls | grep @fabrika/cli`.
224
+
225
+ 1. **Create a namespace** with the preset and core package the human chose (see Decisions). Without a
226
+ custom domain bound to its proxy, pass `--public-access=zerops-subdomain`; the default is
227
+ `custom-domain`, which serves no host until you bind one.
228
+
229
+ ```bash
230
+ fabrika control namespaces plan --namespace=<id> --env=<name> --preset=<cheap|mid|full> --public-access=zerops-subdomain
231
+ fabrika control namespaces create --namespace=<id> --env=<name> --preset=<cheap|mid|full> --public-access=zerops-subdomain
232
+ fabrika control namespaces get --namespace=<id>
233
+ ```
234
+
235
+ `create` returns while the state is `pending`. Provisioning takes several minutes. Poll `get` every
236
+ 30 seconds until it is `ready`.
237
+
238
+ - If the state is `failed`, `get` prints the cause and the command that resumes it on stderr. Run
239
+ `fabrika control namespaces reconcile --namespace=<id>` and poll `get` again. Provisioning resumes from
240
+ where it stopped. Do not create a second namespace.
241
+ - `get` lists one host per line as `<host> <port> free` or `<host> <port> taken by <app>/<env>`. A
242
+ `zerops-subdomain` namespace has six hosts, one per public port of its proxy: 8080 and 8082–8086. No
243
+ port is special or reserved; 8081 is the proxy's health port and is never listed. Use ANY `free` host,
244
+ for example the first one. Each host serves exactly one application environment, so six is the limit
245
+ per namespace.
246
+
247
+ 2. **Build and register**, from the application's repository root. The repository must be pushed: control
248
+ deploys the commit on GitHub, not your working tree.
249
+
250
+ `--app=<id>` is the application id the manifest carries: `jq -r .app.id fabrika.manifest.json` after the
251
+ build. The application's `fabrika.config.ts` sets it (`defineApp({ id })`); the example takes it from its
252
+ contract's vocabulary, and it is `notes`.
253
+
254
+ ```bash
255
+ bun install
256
+ bunx fabrika app build --env=<name> # cheap: add --config=fabrika.cheap.config.ts
257
+ FABRIKA_CONTROL_KEY="$(cat control.key)" bunx fabrika control register --provider=zerops --app=<id> \
258
+ --repo=https://github.com/<owner>/<name> --env=<name> --manifest=fabrika.manifest.json \
259
+ --namespace=<namespace id> --domain=<free host> --installation-id=none
260
+ ```
261
+
262
+ - `--installation-id=none` works only for a repository GitHub serves without a token. `register` reads it
263
+ once and refuses one that is not publicly readable, so the failure shows here and not at deploy. A
264
+ private repository needs the GitHub App connected in the console first (Settings → Source, a browser
265
+ flow for the human); then omit the flag.
266
+ - `register` CREATES and refuses a second registration. A changed manifest goes through
267
+ `control apps environments put` with the same placement flags.
268
+
269
+ 3. **Deploy and follow it.**
270
+
271
+ ```bash
272
+ FABRIKA_CONTROL_KEY="$(cat control.key)" bunx fabrika control deploy --app=<id> --env=<name>
273
+ FABRIKA_CONTROL_KEY="$(cat control.key)" bunx fabrika control runs list --app=<id>
274
+ FABRIKA_CONTROL_KEY="$(cat control.key)" bunx fabrika control runs log --run=<run id>
275
+ ```
276
+
277
+ A failed run's log ends with `deploy failed: <reason>`. For example, `source resolve failed
278
+ (ref_not_found)` means control could not read the repository or the ref.
279
+
280
+ The deploy fails when the repository at the deployed commit does not match the registered manifest.
281
+ Commit and push, rebuild the manifest, `apps environments put` it, and deploy again.
282
+
283
+ On an environment named `prod`, a change that widens access waits for a person to approve it in the
284
+ console before it deploys.
285
+
286
+ **Check.** The run ends `succeeded`. `https://<host>/healthz` answers 200, and `/` redirects to the IAM
287
+ sign-in. A green run proves only that Zerops accepted the build: have the human sign in and use the app.
288
+
289
+ ## Step 6. Upgrade later
290
+
291
+ ```bash
292
+ fabrika platform upgrade --provider=zerops --to=<newer tag> --sidecar=<sidecar checkout>
293
+ ```
294
+
295
+ It commits the tag to the sidecar's `fabrika.ref`, pushes, and follows the workflow run it triggers. It
296
+ needs only the `gh` login. Existing namespaces keep their proxy version. To move one to the proxy of the
297
+ new release, after the run succeeded:
298
+
299
+ ```bash
300
+ fabrika control namespaces update --namespace=<id> --proxy-source=current
301
+ ```
302
+
303
+ It re-pins the namespace and queues the reconcile that rebuilds its proxy; poll `namespaces get` until it is
304
+ `ready`. A namespace pinned to a release before the proxy manifest moved into the build archive carries it in a
305
+ service variable, which Zerops caps at about 32 KB: when a deploy fails with `serviceEnvValueTooLarge`, this is
306
+ the fix, and it also accepts a namespace in `failed`.
307
+
308
+ ## When something fails
309
+
310
+ - Every refusal names its flag or variable. Fix that one value and re-run. `init`, `admin`, `deploy` and
311
+ `upgrade` are safe to re-run. `install` is not: see step 1.
312
+ - A failed install pass 2 is finished with `platform deploy`, with the credentials from the secrets file. The
313
+ install prints the command.
314
+ - Lost the secrets file? Nothing in fabrika can recover it, and Zerops will not hand the values back. Start a
315
+ new installation in a new project.
316
+ - A Zerops build or deploy failure: read the service log in the Zerops GUI or with
317
+ `zcli service log -S <service id> --show-build-logs`.
318
+ - Do not work around a refusal by editing the clone or the generated sidecar. Report what failed, with the
319
+ command and its message, to the people who maintain fabrika.