@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 +9 -9
- package/src/api/client.ts +12 -0
- package/src/api/readers.ts +29 -0
- package/src/api/types.ts +52 -0
- package/src/api.ts +2 -0
- package/src/control.ts +2 -0
- package/src/database-budget.ts +2 -2
- package/src/execution.ts +30 -23
- package/src/index.ts +2 -0
- package/src/installation/deploy.ts +204 -25
- package/src/installation/guide.md +39 -12
- package/src/installation/index.ts +13 -3
- package/src/installation/install-environment.ts +5 -2
- package/src/installation/install.ts +1 -0
- package/src/installation/topology/setups.ts +10 -4
- package/src/installation/topology/topology.ts +31 -21
- package/src/namespace/provisioning.ts +9 -1
- package/src/namespace/proxy-reads.ts +2 -2
- package/src/namespace/types.ts +2 -0
- package/src/plan.ts +1 -1
- package/src/provider.ts +23 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
47
|
-
"@fabrika/auth-core": "0.8.
|
|
48
|
-
"@fabrika/email": "0.8.
|
|
49
|
-
"@fabrika/iam-contract": "0.8.
|
|
50
|
-
"@fabrika/installation-init": "0.8.
|
|
51
|
-
"@fabrika/operations-contract": "0.8.
|
|
52
|
-
"@fabrika/provider-contract": "0.8.
|
|
53
|
-
"@fabrika/proxy-core": "0.8.
|
|
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
|
|
package/src/api/readers.ts
CHANGED
|
@@ -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
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
|
}
|
package/src/database-budget.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
285
|
-
// schema
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
const
|
|
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
|
-
|
|
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
|
-
...(
|
|
337
|
-
writeFileCommand(START_SCRIPT, startScript
|
|
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
|
-
|
|
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
|
@@ -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)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
|
32
|
-
// writes
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
364
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
48
|
-
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
-
| `light` (default) | Lightweight core.
|
|
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
|
|
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
|
|
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=<
|
|
131
|
-
|
|
132
|
-
|
|
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`.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
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: '
|
|
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
|
|
@@ -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 `${
|
|
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 `${
|
|
295
|
-
*
|
|
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
|
|
86
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
99
|
-
*
|
|
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;
|
|
192
|
-
// because a second HA Postgres triples
|
|
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
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
22
|
-
*
|
|
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
|
|
package/src/namespace/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|