@fabrika/provider-zerops 0.8.0 → 0.8.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,14 +43,14 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "0.8.0",
47
- "@fabrika/auth-core": "0.8.0",
48
- "@fabrika/email": "0.8.0",
49
- "@fabrika/iam-contract": "0.8.0",
50
- "@fabrika/installation-init": "0.8.0",
51
- "@fabrika/operations-contract": "0.8.0",
52
- "@fabrika/provider-contract": "0.8.0",
53
- "@fabrika/proxy-core": "0.8.0",
46
+ "@fabrika/auth": "0.8.2",
47
+ "@fabrika/auth-core": "0.8.2",
48
+ "@fabrika/email": "0.8.2",
49
+ "@fabrika/iam-contract": "0.8.2",
50
+ "@fabrika/installation-init": "0.8.2",
51
+ "@fabrika/operations-contract": "0.8.2",
52
+ "@fabrika/provider-contract": "0.8.2",
53
+ "@fabrika/proxy-core": "0.8.2",
54
54
  "yaml": "^2.9.1"
55
55
  }
56
56
  }
package/src/api/client.ts CHANGED
@@ -5,6 +5,7 @@ import { waitForProcess } from './polling'
5
5
  import {
6
6
  appVersionListSchema,
7
7
  appVersionSchema,
8
+ backupConfigSchema,
8
9
  importResultSchema,
9
10
  integrationTokenSchema,
10
11
  logAccessSchema,
@@ -364,6 +365,17 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
364
365
  return readResponse('configure custom autoscaling', processNilSchema, payload).process ?? null
365
366
  },
366
367
 
368
+ getBackupConfig: async ({ serviceId, signal }) =>
369
+ readResponse('list backups', backupConfigSchema, await request('list backups', 'GET', `/service-stack/${serviceId}/backup`, { signal })),
370
+
371
+ configureBackup: async ({ serviceId, backupPeriod, retentionPolicy, signal }) => {
372
+ const payload = await request('configure backup', 'PUT', `/service-stack/${serviceId}/backup-config`, {
373
+ body: { backupPeriod, retentionPolicy },
374
+ signal,
375
+ })
376
+ return readResponse('configure backup', processNilSchema, payload).process ?? null
377
+ },
378
+
367
379
  getService: async ({ serviceId, signal }) =>
368
380
  readResponse('get service', serviceSchema, await request('get service', 'GET', `/service-stack/${serviceId}`, { signal })),
369
381
 
@@ -3,6 +3,8 @@ import { withoutSensitiveContent } from './service-env-response'
3
3
  import type {
4
4
  ZeropsAppVersion,
5
5
  ZeropsAppVersionStatus,
6
+ ZeropsBackupConfig,
7
+ ZeropsBackupRetentionPolicy,
6
8
  ZeropsImportResult,
7
9
  ZeropsIntegrationToken,
8
10
  ZeropsLogAccess,
@@ -170,6 +172,33 @@ export const serviceSchema = z.object({
170
172
  currentAutoscaling: optional(serviceAutoscalingSchema),
171
173
  }).transform(({ activeAppVersion, ...service }): ZeropsService => ({ ...service, activeAppVersionId: activeAppVersion?.id }))
172
174
 
175
+ const backupCountSchema = z.number().int().nonnegative()
176
+
177
+ /** Strict like the custom autoscaling, for the same reason: it is sent back whole, so no field may read as absent. */
178
+ const backupRetentionPolicySchema = z.object({
179
+ maxTotalFiles: backupCountSchema,
180
+ maxTotalGiB: backupCountSchema,
181
+ // A Go API may serialise an empty list as null; read as the empty list it is.
182
+ protectedTags: z.array(z.string()).nullable().transform((tags) => tags ?? []),
183
+ minDaily: backupCountSchema,
184
+ maxDaily: backupCountSchema,
185
+ minWeekly: backupCountSchema,
186
+ maxWeekly: backupCountSchema,
187
+ minMonthly: backupCountSchema,
188
+ maxMonthly: backupCountSchema,
189
+ }) satisfies z.ZodType<ZeropsBackupRetentionPolicy, z.ZodTypeDef, unknown>
190
+
191
+ /** A service's own retention that does not parse fails the whole read: reading it as absent would send the default over it. */
192
+ export const backupConfigSchema = z.object({
193
+ backupPeriod: z.string(),
194
+ defaultRetentionPolicy: backupRetentionPolicySchema,
195
+ retentionPolicy: backupRetentionPolicySchema.nullish(),
196
+ }).transform(({ backupPeriod, defaultRetentionPolicy, retentionPolicy }): ZeropsBackupConfig => ({
197
+ backupPeriod,
198
+ defaultRetentionPolicy,
199
+ ...(retentionPolicy === null || retentionPolicy === undefined ? {} : { retentionPolicy }),
200
+ }))
201
+
173
202
  export const projectSchema = z.object({
174
203
  id: idSchema,
175
204
  name: z.string(),
package/src/api/types.ts CHANGED
@@ -196,6 +196,34 @@ export interface ZeropsServiceAutoscaling {
196
196
  }
197
197
  }
198
198
 
199
+ /**
200
+ * How many backups of a managed service the platform keeps. VERIFIED AS A SHAPE: `OutDtoBackupRetentionPolicy` and
201
+ * `InDtoBodyBackupRetentionPolicy` carry these nine fields, all required. The `min*`/`max*` counts are per backup TAG
202
+ * (`daily`, `weekly`, `monthly`); UNVERIFIED: which tags an hourly automatic backup gets.
203
+ */
204
+ export interface ZeropsBackupRetentionPolicy {
205
+ maxTotalFiles: number
206
+ maxTotalGiB: number
207
+ /** Backups with one of these tags are never deleted automatically. */
208
+ protectedTags: string[]
209
+ minDaily: number
210
+ maxDaily: number
211
+ minWeekly: number
212
+ maxWeekly: number
213
+ minMonthly: number
214
+ maxMonthly: number
215
+ }
216
+
217
+ /** A managed service's backup schedule and retention. VERIFIED AS A SHAPE: `ResponseServiceStackBackupFileList`, without its `files`. */
218
+ export interface ZeropsBackupConfig {
219
+ /** UNVERIFIED: the format. Zerops' GUI documentation describes a 5-field cron (`min hour dom mon dow`). */
220
+ backupPeriod: string
221
+ /** What the platform applies to a service with no retention of its own. UNVERIFIED: that it is what applies then. */
222
+ defaultRetentionPolicy: ZeropsBackupRetentionPolicy
223
+ /** The service's own retention; absent when none was set. */
224
+ retentionPolicy?: ZeropsBackupRetentionPolicy
225
+ }
226
+
199
227
  /** One service. VERIFIED: `ResponseServiceStack` (narrowed to the fields fabrika reads). */
200
228
  export interface ZeropsService {
201
229
  id: string
@@ -494,6 +522,30 @@ export interface ZeropsApi {
494
522
  */
495
523
  configureCustomAutoscaling(input: { serviceId: string; autoscaling: ZeropsServiceAutoscaling; signal: AbortSignal }): Promise<ZeropsProcess | null>
496
524
 
525
+ // ── service-stack: backups ────────────────────────────────────────────────────
526
+
527
+ /**
528
+ * A managed service's backup schedule and retention. VERIFIED AS A SHAPE: `GET /service-stack/{id}/backup`
529
+ * (`ListBackups`), response `ResponseServiceStackBackupFileList`; the backup files it also lists are not read.
530
+ */
531
+ getBackupConfig(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsBackupConfig>
532
+
533
+ /**
534
+ * Set a managed service's backup schedule and retention. VERIFIED AS A SHAPE: `PUT /service-stack/{id}/backup-config`
535
+ * (`ConfigureBackup`), body `RequestServiceStackBackupConfig`, response `ResponseProcessNil`. The platform stores the
536
+ * period as the service's `BACKUP_PERIOD` variable and may start a user-data sync, whose process this answers.
537
+ *
538
+ * `retentionPolicy` is required here although the body makes it optional. UNVERIFIED: whether a request without one
539
+ * resets the service's retention; a PUT elsewhere on this API resets every field it leaves out, so the caller sends
540
+ * the retention it read, whole.
541
+ */
542
+ configureBackup(input: {
543
+ serviceId: string
544
+ backupPeriod: string
545
+ retentionPolicy: ZeropsBackupRetentionPolicy
546
+ signal: AbortSignal
547
+ }): Promise<ZeropsProcess | null>
548
+
497
549
  // ── service-stack / project: reading state ────────────────────────────────────
498
550
 
499
551
  /** One service by id. VERIFIED: `GET /service-stack/{id}`. */
package/src/api.ts CHANGED
@@ -19,6 +19,8 @@ export {
19
19
  type ZeropsAppVersion,
20
20
  type ZeropsAppVersionStatus,
21
21
  type ZeropsAppVersionUpload,
22
+ type ZeropsBackupConfig,
23
+ type ZeropsBackupRetentionPolicy,
22
24
  type ZeropsImportedService,
23
25
  type ZeropsImportResult,
24
26
  type ZeropsIntegrationToken,
package/src/control.ts CHANGED
@@ -102,6 +102,8 @@ export interface ZeropsControlProviderOptions {
102
102
  readonly proxySource: ZeropsNamespaceProxySource
103
103
  readonly iamUrl: string
104
104
  readonly iamKey: string
105
+ /** The installation's own project, which no namespace may adopt. */
106
+ readonly installationProjectId?: string
105
107
  /** The manifest parsers of the older releases' proxies, which a namespace pinned to an older source may run. */
106
108
  readonly olderProxies: OlderProxyReaders
107
109
  }
@@ -15,7 +15,7 @@ export const ZEROPS_POSTGRES_MIN_RAM_GB = 0.25
15
15
  export const ZEROPS_POSTGRES_UNAVAILABLE_CONNECTIONS = 7
16
16
 
17
17
  /**
18
- * The most connections one container's start script holds at once: `fabrika db test-isolation` peaks at 4 since the
18
+ * The most connections one container's init commands hold at once: `fabrika db test-isolation` peaks at 4 since the
19
19
  * test kit opens a file's connections together, and at 5 with a `@fabrika/db` before that (measured locally,
20
20
  * 2026-10-16, with the Notes and CRM examples). Migrating and reading the schema take fewer, one after the other.
21
21
  */
@@ -53,7 +53,7 @@ export function zeropsPostgresMaxConnections(ramGB: number, profile: string | un
53
53
  const poolHolders = (demand: ZeropsDatabaseDemand): number => 2 * demand.maxContainers + demand.jobs
54
54
 
55
55
  /**
56
- * The connections each process may open so that all of them, with one container's start script, stay within what the
56
+ * The connections each process may open so that all of them, with one container's init commands, stay within what the
57
57
  * application's roles can take; `null` when that is below `MIN_PROCESS_CONNECTIONS`.
58
58
  */
59
59
  export function zeropsProcessConnections(maxConnections: number, demand: ZeropsDatabaseDemand): number | null {
package/src/execution.ts CHANGED
@@ -55,7 +55,7 @@ const FILE_SWEEP_SCHEDULE = '41 * * * *'
55
55
  export const ZEROPS_SCAN_MIN_RAM_GB = 2
56
56
 
57
57
  /**
58
- * The RAM in GB a container of an application with a database starts with. Its start script runs `fabrika db
58
+ * The RAM in GB a container of an application with a database starts with. Its init commands run `fabrika db
59
59
  * test-isolation`, whose CLI and `bun test` child together peak at 161 MiB of anonymous memory for the Notes example
60
60
  * and 225 MiB for the CRM example (measured locally, 2026-10-15), beside Bun's own mapped binary. Zerops' 0.125 GB
61
61
  * floor kills that within a second, faster than vertical autoscaling reacts.
@@ -243,6 +243,7 @@ const SOURCE_ROOT = '/build/source'
243
243
  const RUNTIME_ROOT = '/var/www'
244
244
  const FABRIKA_BIN = './node_modules/.bin/fabrika'
245
245
  const MIGRATE_SCRIPT = 'fabrika/migrate.generated.sh'
246
+ const VERIFY_SCRIPT = 'fabrika/verify.generated.sh'
246
247
  const START_SCRIPT = 'fabrika/start.generated.sh'
247
248
  const PLATFORM_TSCONFIG = 'fabrika/tsconfig.platform.json'
248
249
 
@@ -281,24 +282,26 @@ const migrateScript = (appId: string): readonly string[] => [
281
282
  `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db migrate`,
282
283
  ]
283
284
 
284
- // The server starts only after the migrations it expects are applied, the committed schema types match the migrated
285
- // schema and every generated isolation test passed on a throwaway schema, so any failure fails the readiness check,
286
- // whether or not a failed `initCommands` stops the activation (it does, verified live). The build has no
287
- // database, so this is where the types and the generated tests run.
288
- const startScript = (appId: string, database: boolean): readonly string[] => [
285
+ // The committed schema types must match the migrated schema and every generated isolation test must pass on a throwaway
286
+ // schema. The build has no database, so they run at container start, after the migrations, as init commands: a failed
287
+ // init command stops the activation, and the readiness window opens only with `run.start`, after the init commands (both
288
+ // verified live), so a run as long as the CRM's ~3 minutes of generated cases does not race it.
289
+ const verifyScript = (appId: string): readonly string[] => [
289
290
  '#!/bin/sh',
290
291
  'set -eu',
291
292
  `cd ${RUNTIME_ROOT}`,
292
- ...(database
293
- ? [
294
- `sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`,
295
- `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`,
296
- `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db test-isolation`,
297
- ]
298
- : []),
299
- 'exec bun fabrika/entry.generated.ts',
293
+ `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`,
294
+ `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`,
300
295
  ]
301
296
 
297
+ const startScript: readonly string[] = ['#!/bin/sh', 'set -eu', `cd ${RUNTIME_ROOT}`, 'exec bun fabrika/entry.generated.ts']
298
+
299
+ /** The scripts the build writes and every container's init commands run, in this order. */
300
+ const initScripts = (execution: ZeropsLockedExecution): readonly { readonly path: string; readonly lines: readonly string[] }[] =>
301
+ execution.databaseUrl === null
302
+ ? []
303
+ : [{ path: MIGRATE_SCRIPT, lines: migrateScript(execution.appId) }, { path: VERIFY_SCRIPT, lines: verifyScript(execution.appId) }]
304
+
302
305
  const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
303
306
 
304
307
  // After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
@@ -324,7 +327,6 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
324
327
  export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
325
328
  if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
326
329
  if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
327
- const database = execution.databaseUrl !== null
328
330
  return [
329
331
  INSTALL,
330
332
  'bun test --isolate --pass-with-no-tests',
@@ -333,8 +335,8 @@ export function generatedBuildCommands(execution: ZeropsLockedExecution): readon
333
335
  `cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
334
336
  writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
335
337
  `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
336
- ...(database ? [writeFileCommand(MIGRATE_SCRIPT, migrateScript(execution.appId))] : []),
337
- writeFileCommand(START_SCRIPT, startScript(execution.appId, database)),
338
+ ...initScripts(execution).map((script) => writeFileCommand(script.path, script.lines)),
339
+ writeFileCommand(START_SCRIPT, startScript),
338
340
  ...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
339
341
  ]
340
342
  }
@@ -363,6 +365,16 @@ const jobCrontab = (contract: ZeropsContractRun): { readonly timing: string; rea
363
365
  /** How many job processes the crontab may run at once, each opening the application's database. */
364
366
  export const zeropsJobProcesses = (contract: ZeropsContractRun): number => jobCrontab(contract).length
365
367
 
368
+ /** The commands every container runs before `run.start`, in order; any failure stops the activation. */
369
+ export function generatedInitCommands(execution: ZeropsLockedExecution): string[] {
370
+ return [
371
+ // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
372
+ // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
373
+ ...(execution.contract.scan === true ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
374
+ ...initScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script.path}`),
375
+ ]
376
+ }
377
+
366
378
  /** The generated selected setup: the authored build kept, everything else the platform's. */
367
379
  export function renderLockedSetup(
368
380
  setup: string,
@@ -385,12 +397,7 @@ export function renderLockedSetup(
385
397
  const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
386
398
  // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
387
399
  const crontab = [...jobCrontab(contract), ...(scan ? [{ timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true }] : [])]
388
- // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
389
- // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
390
- const initCommands = [
391
- ...(scan ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
392
- ...(execution.databaseUrl === null ? [] : [`sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`]),
393
- ]
400
+ const initCommands = generatedInitCommands(execution)
394
401
  const prepareCommands = packages.length === 0 ? [] : [`sudo apk add --no-cache ${packages.join(' ')}`, ...(scan ? [FRESHCLAM] : [])]
395
402
  return {
396
403
  setup,
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export type {
18
18
  ZeropsAppVersion,
19
19
  ZeropsAppVersionStatus,
20
20
  ZeropsAppVersionUpload,
21
+ ZeropsBackupConfig,
22
+ ZeropsBackupRetentionPolicy,
21
23
  ZeropsImportedService,
22
24
  ZeropsImportResult,
23
25
  ZeropsIntegrationToken,
@@ -21,35 +21,41 @@
21
21
  //
22
22
  // It composes the proxy manifest and ships it in the proxy's build archive. It writes the exact namespace
23
23
  // planning source default and the per-installation configuration derived from the resolved public hosts
24
- // (each service's own origin, the origins it must know about, and the installation's environment name). **It writes no credential at all** — every
25
- // secret an installation holds is `platform init`'s to place, and a deploy that cannot write one cannot
26
- // leak one. The two credentials this command HOLDS are the Zerops access token and the IAM admin key,
27
- // both from the environment, neither ever logged.
24
+ // (each service's own origin, the origins it must know about, and the installation's environment name),
25
+ // and the platform database's backup schedule. **It writes one credential, and only a derived one**: the
26
+ // secret the proxy proves its requests to IAM with, HMAC-SHA256 of a fixed label keyed by the IAM admin key,
27
+ // written sensitive to `proxy` and `iam` and never read back. Every other secret an installation holds is
28
+ // `platform install`'s to place. The two credentials this command HOLDS are the Zerops access token and the
29
+ // IAM admin key, both from the environment, neither ever logged.
28
30
  //
29
31
  // ── Idempotence ───────────────────────────────────────────────────────────────────────────────────
30
32
  //
31
- // Every variable is READ before it is written and skipped when it already matches, so a second run
32
- // writes nothing; the subdomain is decided by reading `subdomainAccess` back, so a second run enables
33
- // nothing; and the schema reconcile is an upsert. What a re-run does do is redeploy — that is what
34
- // "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
33
+ // Every readable variable is READ before it is written and skipped when it already matches, so a second
34
+ // run writes only the derived secret, which it cannot read back; the subdomain is decided by reading
35
+ // `subdomainAccess` back, so a second run enables nothing; the database's backup period is read and set
36
+ // only when it differs; and the schema reconcile is an upsert. What a re-run does do is redeploy — that is
37
+ // what "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
35
38
  // readiness gate.
36
39
 
37
- import { FABRIKA_SERVICE_TABLE_DIGEST, readEnvironmentName } from '@fabrika/auth-core'
40
+ import { FABRIKA_SERVICE_TABLE_DIGEST, IAM_PROXY_SECRET_VARIABLE, readEnvironmentName } from '@fabrika/auth-core'
38
41
  import { IAM_APP_ID, IAM_APP_SCHEMA } from '@fabrika/iam-contract'
39
42
  import type { SchemaReconciler } from '@fabrika/provider-contract'
40
43
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
44
+ import { createHmac } from 'node:crypto'
41
45
  import { assertServiceEnvValueFits } from '../api'
42
46
  import {
43
47
  enableSubdomainAccessWhenHttp,
44
48
  prepareZeropsDescriptor,
45
49
  readableContent,
46
50
  type Sleeper,
51
+ waitForProcess,
47
52
  ZEROPS_ACTIVE,
48
53
  ZEROPS_SERVICE_NOT_HTTP,
49
54
  ZEROPS_TERMINAL,
50
55
  type ZeropsApi,
51
56
  ZeropsApiError,
52
57
  type ZeropsNamespaceProxySource,
58
+ type ZeropsProcess,
53
59
  type ZeropsService,
54
60
  type ZeropsServiceEnv,
55
61
  } from '../index'
@@ -112,6 +118,18 @@ export const PLATFORM_SEQUENTIAL_DEPLOY = ['proxy', 'control'] as const
112
118
  /** The proxy's hostname. The only publicly routed service, and the one that carries the manifest. */
113
119
  export const PLATFORM_PROXY_SERVICE = 'proxy'
114
120
 
121
+ /** IAM's and control's database, on both tiers. This command deploys nothing to it; it sets its backup schedule. */
122
+ export const PLATFORM_DATABASE_SERVICE = 'db'
123
+
124
+ /** Operations' own database. A light installation created before it existed keeps Operations in `db`. */
125
+ export const OPERATIONS_DATABASE_SERVICE = 'operationsdb'
126
+
127
+ /**
128
+ * Hourly. Zerops has no point-in-time recovery: a single-node database loses everything since its last `pg_dump`, which
129
+ * by default is daily. UNVERIFIED: that `backupPeriod` takes this 5-field cron, as the GUI documentation describes.
130
+ */
131
+ export const PLATFORM_DATABASE_BACKUP_PERIOD = '0 * * * *'
132
+
115
133
  /** How often a triggered pipeline is polled. */
116
134
  const POLL_INTERVAL_MS = 5_000
117
135
  /** Zerops caps a build pipeline at one hour; poll past that so the timeout is ours, not a coincidence. */
@@ -208,43 +226,58 @@ const resolveProject = async (
208
226
  /** The private source service of an earlier layout; Control now streams sources itself (ADR-0029). */
209
227
  const LEFTOVER_SOURCE_SERVICE = 'source'
210
228
 
229
+ /** The services this command touches: the four it deploys, and the database whose backups it schedules. */
230
+ interface ResolvedInstallation {
231
+ readonly services: ReadonlyMap<PlatformDeployService, ResolvedService>
232
+ readonly databaseId: string
233
+ readonly hasOperationsDatabase: boolean
234
+ }
235
+
211
236
  /**
212
237
  * Find every platform service by hostname in one listing.
213
238
  *
214
- * One call rather than four `findService` lookups, so a missing service is reported as the whole set
239
+ * One call rather than five `findService` lookups, so a missing service is reported as the whole set
215
240
  * that is missing — an installation that was never imported fails once, naming all of it, rather than
216
- * four runs later. Build runtimes (`build<hostname>v<n>`) share the project and are ignored by the
241
+ * five runs later. Build runtimes (`build<hostname>v<n>`) share the project and are ignored by the
217
242
  * exact-name match.
218
243
  */
219
244
  const resolveServices = async (
220
245
  projectId: string,
221
246
  { api, log, signal }: PlatformDeployCollaborators,
222
- ): Promise<Map<PlatformDeployService, ResolvedService>> => {
247
+ ): Promise<ResolvedInstallation> => {
223
248
  const services = await api.listProjectServices({ projectId, signal })
224
249
  if (services.some((service: ZeropsService) => service.name === LEFTOVER_SOURCE_SERVICE)) {
225
250
  log.warn('a `source` service from an earlier layout is present and unused; it may still hold GitHub App keys; delete it or reinstall')
226
251
  }
227
- const resolved = new Map<PlatformDeployService, ResolvedService>()
228
- const missing: string[] = []
229
- for (const hostname of PLATFORM_DEPLOY_ORDER) {
252
+ const idOf = (hostname: string): string | undefined => {
230
253
  const matches = services.filter((service: ZeropsService) => service.name === hostname)
231
254
  if (matches.length > 1) {
232
255
  throw new Error(`Zerops project ${projectId} has ${matches.length} services named \`${hostname}\``)
233
256
  }
234
257
  const match = matches[0]
235
- if (match === undefined || match.id === '') {
258
+ return match === undefined || match.id === '' ? undefined : match.id
259
+ }
260
+ const resolved = new Map<PlatformDeployService, ResolvedService>()
261
+ const missing: string[] = []
262
+ for (const hostname of PLATFORM_DEPLOY_ORDER) {
263
+ const id = idOf(hostname)
264
+ if (id === undefined) {
236
265
  missing.push(hostname)
237
266
  continue
238
267
  }
239
- resolved.set(hostname, { hostname, id: match.id })
268
+ resolved.set(hostname, { hostname, id })
269
+ }
270
+ const databaseId = idOf(PLATFORM_DATABASE_SERVICE)
271
+ if (databaseId === undefined) {
272
+ missing.push(PLATFORM_DATABASE_SERVICE)
240
273
  }
241
- if (missing.length > 0) {
274
+ if (missing.length > 0 || databaseId === undefined) {
242
275
  throw new Error(
243
276
  `Zerops project ${projectId} has no ${missing.join(', ')} service — \`platform deploy\` updates an installation, `
244
277
  + 'it does not create one',
245
278
  )
246
279
  }
247
- return resolved
280
+ return { services: resolved, databaseId, hasOperationsDatabase: idOf(OPERATIONS_DATABASE_SERVICE) !== undefined }
248
281
  }
249
282
 
250
283
  export const serviceOf = (services: ReadonlyMap<PlatformDeployService, ResolvedService>, hostname: PlatformDeployService): ResolvedService => {
@@ -292,6 +325,17 @@ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: LiveServiceEnv):
292
325
 
293
326
  const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
294
327
 
328
+ /**
329
+ * The secret the platform proxy sends IAM's route, so IAM honours the client address and request id only from the proxy.
330
+ * Derived from the IAM admin key, which every deploy holds, so each deploy writes the same value and nothing stores or
331
+ * reads one back. Never from the proxy key: namespace proxies hold that one.
332
+ */
333
+ const deriveIamProxySecret = (iamAdminKey: string): string =>
334
+ createHmac('sha256', iamAdminKey).update('fabrika-installation-proxy-secret:iam').digest('hex')
335
+
336
+ /** The credentials this command writes. Sensitive, so they cannot be compared and are rewritten on every deploy. */
337
+ const SENSITIVE_DEPLOY_KEYS: ReadonlySet<string> = new Set([IAM_PROXY_SECRET_VARIABLE])
338
+
295
339
  /**
296
340
  * The per-installation configuration each service needs, derived from the resolved hosts.
297
341
  *
@@ -301,10 +345,12 @@ const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
301
345
  */
302
346
  const desiredServiceEnv = (
303
347
  environment: string,
348
+ projectId: string,
304
349
  placement: PlatformPlacement,
305
350
  proxySource: ZeropsNamespaceProxySource,
306
351
  oauthConfig: IamOAuthConfig | undefined,
307
352
  controlTableDigest: string,
353
+ iamProxySecret: string,
308
354
  ): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
309
355
  const iamOrigin = platformOrigin(placement.scheme, placement.hosts.iam)
310
356
  const operationsOrigin = platformOrigin(placement.scheme, placement.hosts.operations)
@@ -313,6 +359,7 @@ const desiredServiceEnv = (
313
359
  // The `iss` of every minted token AND the OIDC redirect base, so it must be the address
314
360
  // routed to the proxy in front of this service.
315
361
  ['ISSUER', iamOrigin],
362
+ [IAM_PROXY_SECRET_VARIABLE, iamProxySecret],
316
363
  ])
317
364
  if (oauthConfig !== undefined) {
318
365
  iam.set(IAM_OAUTH_CONFIG, JSON.stringify(oauthConfig))
@@ -335,6 +382,9 @@ const desiredServiceEnv = (
335
382
  // private `iam:3000` the manifest dials — `readProxyEnv` canonicalizes it to an origin.
336
383
  ['FABRIKA_IAM_ISSUER', iamOrigin],
337
384
  ['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
385
+ // Caddy sends it on the session owner's route. Until the proxy deploys, IAM keys every request on one shared
386
+ // bucket, which is what the balancer's address already does on Zerops.
387
+ [IAM_PROXY_SECRET_VARIABLE, iamProxySecret],
338
388
  ]),
339
389
  ],
340
390
  [
@@ -352,6 +402,9 @@ const desiredServiceEnv = (
352
402
  // The public IAM origin an APPLICATION project's proxy reaches — there is no private
353
403
  // network between projects (ADR-0006).
354
404
  ['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
405
+ // The installation's own project, which no deployment namespace may adopt: its proxy is the
406
+ // platform's enforcement point, and a namespace reconcile would rebuild it with only app members.
407
+ ['FABRIKA_ZEROPS_PLATFORM_PROJECT_ID', projectId],
355
408
  ]),
356
409
  ],
357
410
  ])
@@ -360,8 +413,8 @@ const desiredServiceEnv = (
360
413
  /**
361
414
  * Write only what differs. Returns the KEYS written — never a value, not even in a debug line.
362
415
  *
363
- * Nothing here is a credential, so everything is written readable. A record an older release wrote
364
- * sensitive reads as different and is rewritten readable, which is what lets the next deploy compare it.
416
+ * Everything but `SENSITIVE_DEPLOY_KEYS` is written readable. A record an older release wrote sensitive
417
+ * reads as different and is rewritten readable, which is what lets the next deploy compare it.
365
418
  */
366
419
  const applyServiceEnv = async (
367
420
  service: ResolvedService,
@@ -372,7 +425,8 @@ const applyServiceEnv = async (
372
425
  ): Promise<string[]> => {
373
426
  const written: string[] = []
374
427
  for (const [key, value] of desired) {
375
- if (readableContent(live.get(key)) === value) {
428
+ const sensitive = SENSITIVE_DEPLOY_KEYS.has(key)
429
+ if (!sensitive && readableContent(live.get(key)) === value) {
376
430
  continue
377
431
  }
378
432
  written.push(key)
@@ -382,7 +436,7 @@ const applyServiceEnv = async (
382
436
  continue
383
437
  }
384
438
  assertRunning(collaborators.signal)
385
- await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, sensitive: false, signal: collaborators.signal })
439
+ await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, sensitive, signal: collaborators.signal })
386
440
  }
387
441
  return written
388
442
  }
@@ -591,6 +645,111 @@ const ensureSubdomainAccess = async (
591
645
  }
592
646
  }
593
647
 
648
+ /** What `ensureBackupPeriod` found and did. */
649
+ export type BackupPeriodOutcome =
650
+ | { readonly kind: 'unchanged' }
651
+ | { readonly kind: 'kept'; readonly current: string }
652
+ | { readonly kind: 'would-change'; readonly from: string }
653
+ | { readonly kind: 'changed'; readonly from: string }
654
+ | { readonly kind: 'unconfirmed'; readonly readBack: string }
655
+
656
+ /**
657
+ * Whether a period is one the operator did not choose: empty, or once a day (`<minute> <hour> * * *`), which is the
658
+ * shape of the platform's daily default. Anything else is kept. UNVERIFIED: how the platform spells its default.
659
+ */
660
+ const isDailyBackupPeriod = (period: string): boolean => period.trim() === '' || /^\d+ \d+ \* \* \*$/.test(period.trim())
661
+
662
+ /**
663
+ * Set a managed service's backup period, read-compare-write, replacing only a daily one.
664
+ *
665
+ * A different period is sent with the retention the service reads — its own, else the default — WHOLE, because a
666
+ * PUT on this API resets what it leaves out. The decision is the read-back, never the PUT returning.
667
+ */
668
+ export const ensureBackupPeriod = async (input: {
669
+ readonly api: Pick<ZeropsApi, 'getBackupConfig' | 'configureBackup' | 'getProcess'>
670
+ readonly sleep: Sleeper
671
+ readonly signal: AbortSignal
672
+ readonly serviceId: string
673
+ readonly hostname: string
674
+ readonly period: string
675
+ readonly dryRun: boolean
676
+ }): Promise<BackupPeriodOutcome> => {
677
+ const { api, sleep, signal, serviceId, hostname, period } = input
678
+ const current = await api.getBackupConfig({ serviceId, signal })
679
+ if (current.backupPeriod === period) {
680
+ return { kind: 'unchanged' }
681
+ }
682
+ if (!isDailyBackupPeriod(current.backupPeriod)) {
683
+ return { kind: 'kept', current: current.backupPeriod }
684
+ }
685
+ if (input.dryRun) {
686
+ return { kind: 'would-change', from: current.backupPeriod }
687
+ }
688
+ assertRunning(signal)
689
+ let process: ZeropsProcess | null
690
+ try {
691
+ process = await api.configureBackup({
692
+ serviceId,
693
+ backupPeriod: period,
694
+ retentionPolicy: current.retentionPolicy ?? current.defaultRetentionPolicy,
695
+ signal,
696
+ })
697
+ } catch (error) {
698
+ if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500) {
699
+ throw new Error(`zerops: setting the backup schedule of ${hostname} was refused (${error.code === '' ? error.status : error.code})`)
700
+ }
701
+ throw error
702
+ }
703
+ if (process !== null) {
704
+ await waitForProcess({ api, processId: process.id, sleep, signal, label: `the backup schedule change of ${hostname}` })
705
+ }
706
+ const after = await api.getBackupConfig({ serviceId, signal })
707
+ if (after.backupPeriod !== period) {
708
+ return { kind: 'unconfirmed', readBack: after.backupPeriod }
709
+ }
710
+ return { kind: 'changed', from: current.backupPeriod }
711
+ }
712
+
713
+ /** `ensureBackupPeriod` on the platform database, reported as one log line; nothing it meets fails the deploy. */
714
+ const scheduleDatabaseBackups = async (databaseId: string, dryRun: boolean, collaborators: PlatformDeployCollaborators): Promise<void> => {
715
+ const { log, signal } = collaborators
716
+ const db = PLATFORM_DATABASE_SERVICE
717
+ const schedule = `\`${PLATFORM_DATABASE_BACKUP_PERIOD}\``
718
+ let backup: BackupPeriodOutcome
719
+ try {
720
+ backup = await ensureBackupPeriod({
721
+ api: collaborators.api,
722
+ sleep: collaborators.sleep,
723
+ signal,
724
+ serviceId: databaseId,
725
+ hostname: db,
726
+ period: PLATFORM_DATABASE_BACKUP_PERIOD,
727
+ dryRun,
728
+ })
729
+ } catch (error) {
730
+ if (signal.aborted) throw error
731
+ log.warn(`${db}: the backup schedule was not set (${error instanceof Error ? error.message : 'unknown error'}); set it to ${schedule} by hand`)
732
+ return
733
+ }
734
+ switch (backup.kind) {
735
+ case 'unchanged':
736
+ log.info(`${db}: backups already run on ${schedule}`)
737
+ return
738
+ case 'kept':
739
+ log.info(`${db}: backups keep the schedule \`${backup.current}\` someone chose instead of the daily default`)
740
+ return
741
+ case 'would-change':
742
+ log.info(`${db}: backups would run on ${schedule}, were \`${backup.from}\``)
743
+ return
744
+ case 'changed':
745
+ log.info(`${db}: backups now run on ${schedule}, were \`${backup.from}\``)
746
+ return
747
+ case 'unconfirmed':
748
+ log.warn(`${db}: backups were set to ${schedule} but read back \`${backup.readBack}\`; check the schedule by hand`)
749
+ return
750
+ }
751
+ }
752
+
594
753
  /**
595
754
  * The installation's environment name, refused when a public origin contradicts it.
596
755
  *
@@ -637,7 +796,7 @@ export const deployPreparedPlatform = async (
637
796
 
638
797
  log.step('Resolve the installation')
639
798
  const project = await resolveProject(input, collaborators)
640
- const services = await resolveServices(project.id, collaborators)
799
+ const { services, databaseId, hasOperationsDatabase } = await resolveServices(project.id, collaborators)
641
800
  log.info(`project ${project.name} (${project.id}) — ${PLATFORM_DEPLOY_ORDER.join(', ')}`)
642
801
 
643
802
  const proxy = serviceOf(services, PLATFORM_PROXY_SERVICE)
@@ -670,7 +829,15 @@ export const deployPreparedPlatform = async (
670
829
 
671
830
  log.step('Write the service environment')
672
831
  const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
673
- const desired = desiredServiceEnv(environment, placement, proxySource, oauthConfig, controlTableDigest)
832
+ const desired = desiredServiceEnv(
833
+ environment,
834
+ project.id,
835
+ placement,
836
+ proxySource,
837
+ oauthConfig,
838
+ controlTableDigest,
839
+ deriveIamProxySecret(input.iamAdminKey),
840
+ )
674
841
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
675
842
  const service = serviceOf(services, hostname)
676
843
  const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
@@ -746,5 +913,17 @@ export const deployPreparedPlatform = async (
746
913
  log.info(`\`${IAM_APP_ID}\` reconciled`)
747
914
  }
748
915
 
916
+ // Last, and a warning rather than a failure: the backup API's behaviour is not yet measured on a live account
917
+ // (docs/reference/zerops-platform.md), and every service is already deployed, published and registered.
918
+ log.step('Schedule the platform database backups')
919
+ if (hasOperationsDatabase) {
920
+ await scheduleDatabaseBackups(databaseId, input.dryRun, collaborators)
921
+ } else {
922
+ log.warn(
923
+ `${PLATFORM_DATABASE_SERVICE}: backups keep their schedule — Operations shares this database on an installation `
924
+ + `created before \`${OPERATIONS_DATABASE_SERVICE}\`, and hourly dumps would copy its whole error history`,
925
+ )
926
+ }
927
+
749
928
  log.step(input.dryRun ? 'Dry run complete — nothing was written' : 'Installation deployed')
750
929
  }
@@ -44,16 +44,41 @@ cores and 48 GB per container, and costs more while it does.
44
44
  **1. Platform tier** (`platform install --tier`, step 1). Cannot be changed later: a project's core package
45
45
  is upgrade-only, and the data services differ.
46
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 |
47
+ | Tier | What runs | Idle floor | Fails when |
48
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------ |
49
+ | `light` (default) | Lightweight core. Two single-node PostgreSQL (`oltp-hobby`): `db` for IAM and control, `operationsdb` for Operations; one shared bucket; one container each. | about $7.50 | any one container or `db` 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
51
 
52
52
  Most of `standard`'s price is the identity database's dedicated floor (about $72). `light` is right for an
53
53
  evaluation and a small fleet that tolerates an outage; `standard` for sign-in that must stay up.
54
54
 
55
+ **What a lost node costs.** Zerops has no point-in-time recovery. Its backups are logical dumps of a whole
56
+ database service, daily between 00:00 and 01:00 UTC unless the service sets another schedule, and a restore is
57
+ manual (download, then `pg_restore`). A single-node PostgreSQL whose node fails loses everything written since
58
+ its last backup and stays down until someone restores it. An HA PostgreSQL (three nodes) keeps both its data and
59
+ its service. Zerops documents that a deploy starts the new container before it stops the old one, so a deploy
60
+ should not take a one-container runtime down; neither that nor how fast Zerops replaces a container lost with its
61
+ node is measured here. `platform install` and every `platform deploy` set the platform database `db` (users,
62
+ grants, deploy records) to back up hourly, unless someone chose another schedule; a light installation created
63
+ before `operationsdb` existed keeps its schedule. Hourly backups may shorten how far back the restore points
64
+ reach: if Zerops tags each one `daily`, the retention's daily count counts hours. Check `db`'s retention in the GUI. Operations' own `operationsdb`
65
+ keeps the daily default: its error history does not need that schedule.
66
+
67
+ **Presets.** The choices below combine into three shapes. Prices are the idle floor for three applications in
68
+ one production namespace; an application with a database runs at a 0.5 GB floor, about $2.10 per container.
69
+
70
+ | Preset | Platform tier | Production namespace | Idle floor | A lost node costs |
71
+ | ---------- | ------------- | --------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------- |
72
+ | evaluation | `light` | `cheap`, single-node PostgreSQL, one container each | about $16 | the data written since that database's last backup, and an outage |
73
+ | data | `light` | `cheap` with `--postgres-type=postgresql:ha@18 --postgres-profile=oltp-staging`, one container each | about $26 | applications' data survives; platform data since its last backup |
74
+ | stable | `standard` | `cheap` HA as above, `--core-package=SERIOUS`, two containers of each application | $130+ | a short failover; the namespace proxy has one container |
75
+
76
+ A staging namespace (`cheap`, single-node) adds about $9 to any of them.
77
+
55
78
  **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.
79
+ applications run in. Each one adds its own proxy, about $1.08. A `cheap` namespace's shared PostgreSQL is
80
+ single-node unless `--postgres-type=postgresql:ha@18` asks for HA (about $11 at `--postgres-profile=oltp-staging`; the two flags go together);
81
+ the type is fixed once the namespace exists, the profile is not.
57
82
 
58
83
  | Preset | Where an application's database lives | Use with |
59
84
  | --------------- | ----------------------------------------------------------------------- | ------------------------------------ |
@@ -62,8 +87,8 @@ applications run in. Each one adds its own proxy, about $1.08.
62
87
  | `full` | The namespace belongs to ONE application (`--exclusive-app=<id>`) | an application that must be isolated |
63
88
 
64
89
  **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
90
+ declares `postgresql:single@18` at `oltp-hobby`, about $1.45, plus its runtime at about $2.10 per
91
+ container (a deploy raises the floor of an application with a database to 0.5 GB). `postgresql:ha@18` survives a node failure: about $11 at `oltp-staging` (shared CPU), about $72 at
67
92
  `oltp-production` (dedicated). Single to HA later means a new service and a data migration.
68
93
 
69
94
  **4. Namespace core package** (`--core-package=LIGHT|SERIOUS`, step 5). `LIGHT` is free and the default on
@@ -127,10 +152,9 @@ fabrika platform install --provider=zerops --create-project --client-id=<clientI
127
152
  over the same project would destroy the vault key. Start over in a new empty project.
128
153
  - The account must be in the default region, or set `FABRIKA_ZEROPS_API_URL` for every command.
129
154
  - Mail is optional. Without `--email-provider` the installation sends no mail. With one, add
130
- `--email-provider=<resend|smartemailing> --email-from=<address>`, optionally `--email-from-name=<name>`, and
131
- export the provider's variables (`FABRIKA_EMAIL_RESEND_API_KEY`, or `FABRIKA_EMAIL_SMARTEMAILING_USERNAME`,
132
- `FABRIKA_EMAIL_SMARTEMAILING_API_KEY` and optionally `FABRIKA_EMAIL_SMARTEMAILING_TAG`) from their own `0600`
133
- files. They have no flag. The install writes the set on `iam` and `control`, the credentials sensitive. They
155
+ `--email-provider=<provider> --email-from=<address>`, optionally `--email-from-name=<name>`, and export the
156
+ provider's variables from their own `0600` files. `fabrika platform install --provider=zerops --help` lists the
157
+ providers and their variables, each named after its provider (for Resend `FABRIKA_EMAIL_RESEND_API_KEY`). They have no flag. The install writes the set on `iam` and `control`, the credentials sensitive. They
134
158
  are the human's own, so they are not copied into the secrets file.
135
159
 
136
160
  **Check.** The log ends with the `Hand off` step: where the credentials are, `The console answers at
@@ -352,7 +376,10 @@ fabrika control namespaces update --namespace=<id> --proxy-source=current
352
376
  ```
353
377
 
354
378
  It re-pins the namespace and queues the reconcile that rebuilds its proxy; poll `namespaces get` until it is
355
- `ready`. A namespace pinned to a release before the proxy manifest moved into the build archive carries it in a
379
+ `ready`. Re-pin every namespace after each upgrade. Until then, Control refuses a deploy into it that uses a form
380
+ newer than 0.6 (a public procedure, a file output, a capabilities requirement, a forwarded webhook `Authorization`),
381
+ also when its proxy reads the form, and a proxy older than the replacement of the caller's `Authorization` keeps
382
+ forwarding a caller's `px_` key to the application (`docs/reference/deployment-namespaces.md`). A namespace pinned to a release before the proxy manifest moved into the build archive carries it in a
356
383
  service variable, which Zerops caps at about 32 KB: when a deploy fails with `serviceEnvValueTooLarge`, this is
357
384
  the fix, and it also accepts a namespace in `failed`.
358
385
 
@@ -46,7 +46,16 @@ export {
46
46
  } from './proxy-manifest'
47
47
 
48
48
  const EMAIL_PROVIDERS = Object.keys(builtinEmailProviders).join(', ')
49
- const EMAIL_PROVIDER_VARIABLES = Object.values(builtinEmailProviders).flatMap((provider) => provider.variables.map((variable) => variable.name))
49
+ /** One line per provider: its variables, each marked secret or optional where it is. */
50
+ const EMAIL_PROVIDER_VARIABLES = Object.entries(builtinEmailProviders)
51
+ .map(([name, provider]) => {
52
+ const variables = provider.variables.map((variable) => {
53
+ const marks = [variable.secret ? 'secret' : '', variable.required ? '' : 'optional'].filter((mark) => mark !== '')
54
+ return marks.length === 0 ? variable.name : `${variable.name} (${marks.join(', ')})`
55
+ })
56
+ return `\t ${name}: ${variables.join(', ')}`
57
+ })
58
+ .join('\n')
50
59
 
51
60
  const USAGE = `Zerops installation
52
61
 
@@ -132,7 +141,7 @@ Options (a flag beats the environment variable beside it):
132
141
  FABRIKA_ZEROPS_ACCESS_TOKEN required Zerops access token, environment only and no flag
133
142
  FABRIKA_ZEROPS_API_URL optional region API base, when not the default
134
143
  FABRIKA_EMAIL_<PROVIDER>_* the selected provider's variables, environment only and no flag:
135
- ${EMAIL_PROVIDER_VARIABLES.join(', ')}
144
+ ${EMAIL_PROVIDER_VARIABLES}
136
145
 
137
146
  With a mail provider, IAM sends its own mail and Control hands the same transport to every application
138
147
  whose job declares \`mailer: 'installation'\`. To set or rotate it later, see \`platform guide\`.
@@ -254,7 +263,8 @@ process listing:
254
263
  (a --dry-run does not need it)
255
264
  FABRIKA_ZEROPS_API_URL optional region API base, when not the default
256
265
 
257
- This command writes NO credential. Every secret an installation holds is placed at bring-up.
266
+ This command writes one credential, derived from FABRIKA_IAM_PROVISIONING_KEY: the secret the proxy
267
+ proves its requests to IAM with. Every other secret an installation holds is placed at bring-up.
258
268
 
259
269
  Install and deploy require --source-dir explicitly. Run the CLI from that same clean full checkout;
260
270
  a packaged CLI cannot supply another revision's manifest or schema. --from-git and
@@ -30,9 +30,9 @@ const storageOf = (service: string): StorageReferences => ({
30
30
  endpoint: `\${${service}_apiUrl}`,
31
31
  })
32
32
 
33
- /** IAM and control share `db` and `storage` on both tiers; `standard` gives Operations its own pair. */
33
+ /** IAM and control share `db` and `storage` on both tiers; Operations has its own database on both, and its own bucket on `standard`. */
34
34
  const OPERATIONS_DATA: Readonly<Record<PlatformTier, { readonly database: string; readonly storage: string }>> = {
35
- light: { database: 'db', storage: 'storage' },
35
+ light: { database: 'operationsdb', storage: 'storage' },
36
36
  standard: { database: 'operationsdb', storage: 'operationsstorage' },
37
37
  }
38
38
 
@@ -56,6 +56,8 @@ export const PASS_ONE_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
56
56
  /** What `installationServiceEnv` needs. Everything in it is settled before the first write. */
57
57
  export interface InstallationEnvInput {
58
58
  readonly environment: string
59
+ /** The installation's own Zerops project, which no deployment namespace may adopt. */
60
+ readonly projectId: string
59
61
  readonly tier: PlatformTier
60
62
  readonly scheme: 'http' | 'https'
61
63
  readonly hosts: PlatformHosts
@@ -117,6 +119,7 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
117
119
  ['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
118
120
  ['FABRIKA_ZEROPS_PROXY_IAM_KEY', secrets.proxyKey],
119
121
  ['FABRIKA_ZEROPS_ACCESS_TOKEN', input.zeropsAccessToken],
122
+ ['FABRIKA_ZEROPS_PLATFORM_PROJECT_ID', input.projectId],
120
123
  ])
121
124
  if (input.apiBaseUrl !== undefined) {
122
125
  // Only when the installation is not on the default region. Control spells it `_API_BASE_URL` while
@@ -170,6 +170,7 @@ const installPreparedPlatform = async (
170
170
  log.step('Write every remaining variable')
171
171
  const desired = installationServiceEnv({
172
172
  environment,
173
+ projectId: project.id,
173
174
  tier: input.tier,
174
175
  scheme: input.scheme,
175
176
  hosts,
@@ -47,7 +47,9 @@
47
47
  // into the platform's variable store rather than carrying a credential. That reasoning is still sound
48
48
  // and it is no longer sufficient, because a reference names a SERVICE — and which data services exist
49
49
  // is exactly what differs between the `standard` and `light` tiers (`./topology.ts`). One committed
50
- // file cannot say `${operationsdb_connectionString}` on behalf of a tier that has no `operationsdb`.
50
+ // file cannot say `${operationsstorage_bucketName}` on behalf of a tier that has no `operationsstorage`,
51
+ // nor `${operationsdb_connectionString}` on behalf of a light installation created before Operations
52
+ // had a database of its own.
51
53
  // So the file's own rule decides it: a data-service reference is not true for every installation, and
52
54
  // it moves to the env API alongside the origins and the secrets.
53
55
  //
@@ -181,6 +183,8 @@ const WORKSPACE_DEPLOY_FILES = ['package.json', 'bun.lock', 'node_modules', 'pac
181
183
  * since the proxy is the only publicly routed component and must not also be able to write audit rows),
182
184
  * `FABRIKA_IAM_PROVISIONING_KEY`. Both shared secrets must be at least 32 characters; a shorter one fails
183
185
  * the boot rather than standing in front of an internet-reachable surface while looking configured.
186
+ * `FABRIKA_INSTALLATION_PROXY_SECRET_IAM`, which `platform deploy` derives and writes, lets IAM tell the proxy's
187
+ * client-address and request-id headers from a private caller's.
184
188
  */
185
189
  const iam: ZeropsYamlSetupSpec = {
186
190
  setup: 'iam',
@@ -291,8 +295,9 @@ const control: ZeropsYamlSetupSpec = {
291
295
  * `FABRIKA_OPERATIONS_BLOB_*` coordinates, `ENVIRONMENT`, `FABRIKA_OPERATIONS_PUBLIC_HOST` and
292
296
  * `FABRIKA_IAM_ISSUER` (the public IAM issuer). `FABRIKA_OPERATIONS_DATABASE_URL` takes the canonical
293
297
  * form above. On the `standard` tier the data references are `${operationsdb_*}` and
294
- * `${operationsstorage_*}`; on `light` they are `${db_*}` and `${storage_*}`, which is precisely why
295
- * they cannot be written here.
298
+ * `${operationsstorage_*}`; on `light` they are `${operationsdb_*}` and `${storage_*}` (`${db_*}` on a
299
+ * light installation created before Operations had its own database), which is precisely why they cannot
300
+ * be written here.
296
301
  *
297
302
  * Secrets (`envSecrets`): `OPERATIONS_SYNC_KEY`, shared only with control for catalog projection, and
298
303
  * `FABRIKA_IAM_RPC_KEY`, used only for Operations → IAM management RPC.
@@ -331,7 +336,8 @@ const operations: ZeropsYamlSetupSpec = {
331
336
  * IAM service's PUBLIC origin in an apps project, because there is no private network between projects
332
337
  * (ADR-0006). The manifest is not a variable; see below.
333
338
  *
334
- * Secret (`envSecrets`): `FABRIKA_IAM_KEY` — the value IAM knows as `FABRIKA_IAM_PROXY_KEY`.
339
+ * Secret (`envSecrets`): `FABRIKA_IAM_KEY` — the value IAM knows as `FABRIKA_IAM_PROXY_KEY`. In the platform project
340
+ * also `FABRIKA_INSTALLATION_PROXY_SECRET_IAM`, which Caddy sends on IAM's route and IAM holds too.
335
341
  *
336
342
  * ── How the manifest reaches the build ───────────────────────────────────────────────────────────
337
343
  *
@@ -82,22 +82,27 @@ export type PublicAccess = 'custom-domain' | 'zerops-subdomain'
82
82
  * `standard` is the production shape: HA Postgres, a separate database and bucket for Operations, and
83
83
  * enough containers that no single one is a point of failure.
84
84
  *
85
- * `light` is one project, one `postgresql:single@18` and one bucket shared by IAM, Operations, control
86
- * AND the apps deployed alongside them, at one container per service. This tier exists because the standard
85
+ * `light` is one project with two `postgresql:single@18` services — `db` for IAM and control, `operationsdb`
86
+ * for Operations — and one bucket, at one container per service. This tier exists because the standard
87
87
  * shape costs ~13 containers before a single application is deployed, which is more platform than a
88
- * small fleet — or an evaluation — can justify.
88
+ * small fleet — or an evaluation — can justify. Applications run in their own namespace projects
89
+ * (ADR-0034); a production namespace that must keep its data through a node loss asks for HA there.
89
90
  *
90
- * Sharing is safe rather than merely cheap, and that is a property of the code, not of this comment:
91
+ * Operations gets its own database, not a schema beside IAM's, because Zerops backs up a whole service on
92
+ * one schedule: identity and deploy state is worth an hourly dump, error history is not, and only two
93
+ * services can carry two schedules. It also keeps Operations' high-volume writes off the identity path.
91
94
  *
92
- * • The three services' table names do not intersect (IAM 10, Operations 24, control 16), and
93
- * ADR-0017 already gives each its own migration ledger and advisory lock, so one database holds
94
- * all three without coordination.
95
+ * Sharing the rest is safe rather than merely cheap, and that is a property of the code, not of this comment:
96
+ *
97
+ * • IAM's and control's table names do not intersect, and ADR-0017 gives each its own migration ledger
98
+ * and advisory lock, so one database holds both without coordination. A light installation created
99
+ * before Operations moved out still holds all three in `db`, which the same property covers.
95
100
  * • Their blob keys are prefix-disjoint — control writes `runs/`, Operations writes `events/`,
96
101
  * `dead/` and `source-maps/` — so one bucket cannot collide.
97
102
  *
98
- * What `light` genuinely gives up is FAILURE-DOMAIN isolation: Operations' high-volume error history
99
- * now shares capacity with the identity path, and losing the one database loses all three services.
100
- * That is the trade, and it is why `standard` remains the default rather than the special case.
103
+ * What `light` genuinely gives up is AVAILABILITY and DURABILITY: one container per service, and a
104
+ * single-node database loses everything since its last backup when its node fails (Zerops has no
105
+ * point-in-time recovery). That is the trade, and it is why `standard` remains the default rather than the special case.
101
106
  */
102
107
  export type PlatformTier = 'standard' | 'light'
103
108
 
@@ -188,9 +193,9 @@ export const platformTopology = (options: TopologyOptions): ProjectTopology => {
188
193
  hostname: 'db',
189
194
  // HA is encoded in the TYPE. The deprecated `mode` field is not merely avoided here — it
190
195
  // is absent from `ZeropsServiceSpec`, so it cannot be written. One database serves both
191
- // IAM and the control plane; they are separate schemas in it, not separate services,
192
- // because a second HA Postgres triples the cost of the isolation the private network
193
- // already gives us.
196
+ // IAM and the control plane; both use its default schema with disjoint tables and their own
197
+ // migration ledgers (ADR-0017), not separate services, because a second HA Postgres triples
198
+ // the cost of the isolation the private network already gives us.
194
199
  type: 'postgresql:ha@18',
195
200
  // The identity and control-plane database: every login, every token mint, every run's
196
201
  // state. `oltp-production` is also what an HA service gets by DEFAULT — stated here so
@@ -315,10 +320,9 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
315
320
  // that creates the project. Same shape as `compileZeropsNamespaceTopology` in `src/namespace/topology.ts`.
316
321
  const services = (): ZeropsServiceSpec[] => [
317
322
  {
318
- // ONE database for IAM, control, Operations and every app in this project. Which schema each
319
- // one gets is not expressed here — it is `FABRIKA_*_DATABASE_URL`, written per installation
320
- // through the env API, because this same repository-root `zerops.yaml` serves both tiers and
321
- // a `${operationsdb_connectionString}` baked into it would name a service `light` does not have.
323
+ // IAM and control. Which schema each one gets is not expressed here — it is `FABRIKA_*_DATABASE_URL`,
324
+ // written per installation through the env API, because this same repository-root `zerops.yaml`
325
+ // serves both tiers.
322
326
  hostname: 'db',
323
327
  type: 'postgresql:single@18',
324
328
  // `oltp-hobby`, written out because a default is not a choice — this type's default is
@@ -326,15 +330,21 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
326
330
  //
327
331
  // A floor is not headroom. Every PostgreSQL profile shares the same 8-core / 48 GB / 250 GB
328
332
  // ceiling and the same vertical autoscaler, so `oltp-staging` does not let this service grow
329
- // any further than `oltp-hobby` does — it only pre-pays for memory the tier is idle on. That
330
- // this single service holds IAM, control, Operations and the apps beside them is an argument
331
- // about the ceiling, which is unchanged.
333
+ // any further than `oltp-hobby` does — it only pre-pays for memory the tier is idle on.
332
334
  //
333
335
  // An installation that wants a specific floor sets one on the service — `minRam` accepts a
334
336
  // float — which is where a per-installation value belongs (ADR-0004), not in this document.
335
337
  profile: 'oltp-hobby',
336
338
  priority: 100,
337
339
  },
340
+ {
341
+ // Operations' own database: its own backup schedule (the Zerops default, daily) and its own
342
+ // capacity, off the identity path. Same type and profile as `db`, for the same reasons.
343
+ hostname: 'operationsdb',
344
+ type: 'postgresql:single@18',
345
+ profile: 'oltp-hobby',
346
+ priority: 100,
347
+ },
338
348
  {
339
349
  // ONE bucket. Prefix-disjoint by construction: `runs/` (control) vs `events/`, `dead/` and
340
350
  // `source-maps/` (Operations).
@@ -367,7 +377,7 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
367
377
  platform: 'zerops',
368
378
  project: {
369
379
  name: 'platform',
370
- description: 'fabrika light tier: control plane, Operations, IAM, the auth proxy and the apps they serve, on shared data services.',
380
+ description: 'fabrika light tier: control plane, Operations, IAM and the auth proxy, on single-node data services.',
371
381
  corePackage: 'LIGHT',
372
382
  tags: ['fabrika', 'platform', 'light', options.env],
373
383
  },
@@ -67,10 +67,18 @@ const validateProject = (
67
67
  target: NormalizedZeropsNamespaceTarget,
68
68
  project: ZeropsProject,
69
69
  adopting: boolean,
70
+ installationProjectId: string | undefined,
70
71
  ): void => {
71
72
  if (project.id === '') {
72
73
  throw new Error('Zerops namespace project returned an empty id')
73
74
  }
75
+ // Checked on every reconcile, before the import and the proxy build, so a namespace that already adopted it stops
76
+ // here instead of rebuilding the platform's enforcement point with an empty manifest (ADR-0034).
77
+ if (project.id === installationProjectId) {
78
+ throw new Error(
79
+ `Zerops project ${project.id} is the installation's own project; a deployment namespace needs a project of its own`,
80
+ )
81
+ }
74
82
  if (target.projectName !== project.name) {
75
83
  throw new Error(`Zerops namespace project name mismatch: expected \`${target.projectName}\`, got \`${project.name}\``)
76
84
  }
@@ -398,7 +406,7 @@ const runNamespaceMutation = async (
398
406
  target = { ...target, projectName: project.name }
399
407
  namespace = await checkpoint(input, namespace, target)
400
408
  }
401
- validateProject(namespace, target, project, initiallyAdopted)
409
+ validateProject(namespace, target, project, initiallyAdopted, options.installationProjectId)
402
410
 
403
411
  if (initiallyAdopted) {
404
412
  // Validate before the first mutation; the import below then enforces service-level envIsolation.
@@ -18,8 +18,8 @@ export interface OlderProxyReader {
18
18
  }
19
19
 
20
20
  /**
21
- * The namespace proxies of the older releases this platform serves (ADR-0062). Control holds their parsers, as it holds
22
- * their contract readers; a pin's release is not recorded, so what any of them reads is what the pin may read.
21
+ * The namespace proxies of the older releases a pin may run whose parsers Control holds (ADR-0062). A pin's release is not
22
+ * recorded, so what any of them reads is what the pin may read.
23
23
  */
24
24
  export type OlderProxyReaders = readonly [OlderProxyReader, ...OlderProxyReader[]]
25
25
 
@@ -81,6 +81,8 @@ export interface ZeropsNamespaceOptions {
81
81
  readonly iamUrl: string
82
82
  /** Same credential IAM receives as `FABRIKA_IAM_PROXY_KEY`. */
83
83
  readonly iamKey: string
84
+ /** The installation's own project, which no namespace may create into or adopt. Absent until `platform deploy` writes it. */
85
+ readonly installationProjectId?: string
84
86
  readonly api: ZeropsApi
85
87
  readonly sleep?: Sleeper
86
88
  }
package/src/plan.ts CHANGED
@@ -48,7 +48,7 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
48
48
  steps.push({
49
49
  id: 'raise-start-memory',
50
50
  kind: 'raise-start-memory',
51
- description: "Raise the deploy service's minimum RAM to what its start script needs",
51
+ description: "Raise the deploy service's minimum RAM to what its init commands need",
52
52
  })
53
53
  steps.push({
54
54
  id: 'size-database',
package/src/provider.ts CHANGED
@@ -280,14 +280,27 @@ export function interpolateManifest(
280
280
  }
281
281
 
282
282
  /** Human-readable failure for a version that finished in a non-`ACTIVE` state. */
283
- const terminalFailure = (version: ZeropsAppVersion): string => {
283
+ const terminalFailure = (version: ZeropsAppVersion, checks: boolean): string => {
284
284
  if (version.status === 'BACKUP') {
285
285
  return `app-version ${version.id} was superseded by a newer version before it went live`
286
286
  }
287
287
  if (version.status === 'CANCELLED') {
288
288
  return `app-version ${version.id} was cancelled`
289
289
  }
290
- return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}`
290
+ return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}${activationHint(version, checks)}`
291
+ }
292
+
293
+ /**
294
+ * What a version that built but did not activate tells the operator. Zerops' own log of it is the refused readiness
295
+ * attempts, which read as a server that never started whatever stopped it. `checks`: the generated init commands run
296
+ * the migrations, the schema check and the isolation tests, as for a contract application with a database.
297
+ */
298
+ const activationHint = (version: ZeropsAppVersion, checks: boolean): string => {
299
+ if (version.status !== 'DEPLOY_FAILED') return ''
300
+ const failed = ': an init command failed or the readiness check never answered'
301
+ return checks
302
+ ? `${failed}. The init commands run the migrations, the schema check and the generated isolation tests; the runtime lines above show how far they got`
303
+ : failed
291
304
  }
292
305
 
293
306
  /**
@@ -415,7 +428,7 @@ const openLog = async (env: StepEnv): Promise<ZeropsLogAccess | null> => {
415
428
  * poll loop without telling it leaves a build container burning through the pipeline's hour. A lost lease
416
429
  * gives no such permission (ADR-0044), so it stops without touching the build.
417
430
  */
418
- const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined): Promise<void> => {
431
+ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined, checks: boolean): Promise<void> => {
419
432
  const { zerops, signal, log } = env
420
433
  const access = await openLog(env)
421
434
  const seen = new Set<string>()
@@ -461,14 +474,16 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
461
474
 
462
475
  if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
463
476
  throw new Error(
464
- `zerops: pipeline process ${process.id} is ${process.status} while app-version ${appVersionId} is ${version.status ?? 'in an unknown state'}`,
477
+ `zerops: pipeline process ${process.id} is ${process.status} while app-version ${appVersionId} is ${version.status ?? 'in an unknown state'}${
478
+ activationHint(version, checks)
479
+ }`,
465
480
  )
466
481
  }
467
482
  if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
468
483
  if (version.status === ZEROPS_ACTIVE) {
469
484
  return
470
485
  }
471
- throw new Error(terminalFailure(version))
486
+ throw new Error(terminalFailure(version, checks))
472
487
  }
473
488
  if (Date.now() > env.deadlineMs) await timedOut()
474
489
  await zerops.sleep(POLL_INTERVAL_MS, signal)
@@ -669,7 +684,7 @@ const withMinRam = (custom: ZeropsServiceAutoscaling, minRam: number): ZeropsSer
669
684
  })
670
685
 
671
686
  /**
672
- * Raises the deploy service's minimum RAM to what the generated start script needs (`zeropsStartMinRam`), before the
687
+ * Raises the deploy service's minimum RAM to what the generated init commands need (`zeropsStartMinRam`), before the
673
688
  * version's containers start. An import cannot: Zerops ignores a changed `verticalAutoscaling` of an existing service,
674
689
  * so an existing service converges here, on every deploy. It only raises, and decides by reading the service back.
675
690
  */
@@ -970,7 +985,8 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
970
985
  }
971
986
  const appVersionId = requireVersion(env, 'await-version')
972
987
  assertRunning(signal)
973
- await awaitVersion(env, appVersionId, attempt.processId)
988
+ const locked = lockedExecutionFor(artifact, run, dryRun)
989
+ await awaitVersion(env, appVersionId, attempt.processId, locked !== null && locked.databaseUrl !== null)
974
990
  return
975
991
  }
976
992