@fabrika/provider-zerops 0.4.6 → 0.4.8

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.4.6",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,13 +43,13 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "^0.4.6",
47
- "@fabrika/auth-core": "^0.4.6",
48
- "@fabrika/iam-contract": "^0.4.6",
49
- "@fabrika/installation-init": "^0.4.6",
50
- "@fabrika/operations-contract": "^0.4.6",
51
- "@fabrika/provider-contract": "^0.4.6",
52
- "@fabrika/proxy-core": "^0.4.6",
46
+ "@fabrika/auth": "0.4.8",
47
+ "@fabrika/auth-core": "0.4.8",
48
+ "@fabrika/iam-contract": "0.4.8",
49
+ "@fabrika/installation-init": "0.4.8",
50
+ "@fabrika/operations-contract": "0.4.8",
51
+ "@fabrika/provider-contract": "0.4.8",
52
+ "@fabrika/proxy-core": "0.4.8",
53
53
  "yaml": "^2.9.1"
54
54
  }
55
55
  }
package/src/api/client.ts CHANGED
@@ -354,6 +354,16 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
354
354
  return readResponse('configure autoscaling', processNilSchema, payload).process ?? null
355
355
  },
356
356
 
357
+ configureCustomAutoscaling: async ({ serviceId, autoscaling, signal }) => {
358
+ const payload = await request('configure custom autoscaling', 'PUT', `/service-stack/${serviceId}/autoscaling`, {
359
+ body: {
360
+ customAutoscaling: { verticalAutoscaling: autoscaling.vertical, horizontalAutoscaling: autoscaling.horizontal },
361
+ },
362
+ signal,
363
+ })
364
+ return readResponse('configure custom autoscaling', processNilSchema, payload).process ?? null
365
+ },
366
+
357
367
  getService: async ({ serviceId, signal }) =>
358
368
  readResponse('get service', serviceSchema, await request('get service', 'GET', `/service-stack/${serviceId}`, { signal })),
359
369
 
@@ -12,6 +12,7 @@ import type {
12
12
  ZeropsProjectStatus,
13
13
  ZeropsRoleCode,
14
14
  ZeropsService,
15
+ ZeropsServiceAutoscaling,
15
16
  ZeropsServiceEnv,
16
17
  ZeropsServiceStatus,
17
18
  ZeropsUserInfo,
@@ -121,6 +122,41 @@ export const processSchema = z.object({
121
122
  appVersion: nestedIdSchema,
122
123
  }).transform(({ appVersion, ...process }): ZeropsProcess => ({ ...process, appVersionId: appVersion?.id }))
123
124
 
125
+ const nullableNumber = z.number().nullish().transform((value) => value ?? null)
126
+
127
+ const scalingResourceSchema = z.object({ cpuCoreCount: nullableNumber, memoryGBytes: nullableNumber, diskGBytes: nullableNumber }).nullish()
128
+ .transform((resource) => resource ?? { cpuCoreCount: null, memoryGBytes: null, diskGBytes: null })
129
+
130
+ const scalingFreeResourceSchema = z.object({
131
+ cpuCoreCount: nullableNumber,
132
+ cpuCorePercent: nullableNumber,
133
+ memoryGBytes: nullableNumber,
134
+ memoryPercent: nullableNumber,
135
+ }).nullish().transform((resource) => resource ?? { cpuCoreCount: null, cpuCorePercent: null, memoryGBytes: null, memoryPercent: null })
136
+
137
+ /**
138
+ * Strict where `optional` is lenient: the custom autoscaling is sent back whole, so a field read as absent would be
139
+ * reset. One that does not parse makes the whole object absent, and nothing is sent.
140
+ */
141
+ const verticalAutoscalingSchema = z.object({
142
+ cpuMode: z.enum(['DEDICATED', 'SHARED']).nullish().transform((value) => value ?? null),
143
+ minResource: scalingResourceSchema,
144
+ maxResource: scalingResourceSchema,
145
+ minFreeResource: scalingFreeResourceSchema,
146
+ startCpuCoreCount: nullableNumber,
147
+ swapEnabled: z.boolean().nullish().transform((value) => value ?? null),
148
+ })
149
+
150
+ const serviceAutoscalingSchema = z.object({
151
+ // A service with no custom vertical autoscaling reads `null` here (measured 2026-10-15): every field inherits.
152
+ verticalAutoscaling: verticalAutoscalingSchema.nullish().transform((vertical) => vertical ?? verticalAutoscalingSchema.parse({})),
153
+ horizontalAutoscaling: z.object({ minContainerCount: nullableNumber, maxContainerCount: nullableNumber }).nullish()
154
+ .transform((horizontal) => horizontal ?? { minContainerCount: null, maxContainerCount: null }),
155
+ }).transform(({ verticalAutoscaling, horizontalAutoscaling }): ZeropsServiceAutoscaling => ({
156
+ vertical: verticalAutoscaling,
157
+ horizontal: horizontalAutoscaling,
158
+ }))
159
+
124
160
  export const serviceSchema = z.object({
125
161
  id: idSchema,
126
162
  name: z.string(),
@@ -130,6 +166,8 @@ export const serviceSchema = z.object({
130
166
  activeAppVersion: nestedIdSchema,
131
167
  subdomainAccess: optional(z.boolean()),
132
168
  autoscalingProfileId: optional(z.string()),
169
+ customAutoscaling: optional(serviceAutoscalingSchema),
170
+ currentAutoscaling: optional(serviceAutoscalingSchema),
133
171
  }).transform(({ activeAppVersion, ...service }): ZeropsService => ({ ...service, activeAppVersionId: activeAppVersion?.id }))
134
172
 
135
173
  export const projectSchema = z.object({
package/src/api/types.ts CHANGED
@@ -162,6 +162,40 @@ export type ZeropsServiceStatus =
162
162
  | 'SCALING'
163
163
  | 'REPAIR_FAILED'
164
164
 
165
+ /** One bound of vertical autoscaling. `null` inherits the service type's value. */
166
+ export interface ZeropsScalingResource {
167
+ cpuCoreCount: number | null
168
+ memoryGBytes: number | null
169
+ diskGBytes: number | null
170
+ }
171
+
172
+ /** The free capacity below which vertical autoscaling scales up. `null` inherits the service type's value. */
173
+ export interface ZeropsScalingFreeResource {
174
+ cpuCoreCount: number | null
175
+ cpuCorePercent: number | null
176
+ memoryGBytes: number | null
177
+ memoryPercent: number | null
178
+ }
179
+
180
+ /**
181
+ * A service's autoscaling, as `ResponseServiceStack.customAutoscaling` (what was set) and `.currentAutoscaling` (what
182
+ * applies) carry it, every field nullable. VERIFIED live 2026-10-15 (`docs/reference/zerops-platform.md`).
183
+ */
184
+ export interface ZeropsServiceAutoscaling {
185
+ vertical: {
186
+ cpuMode: 'DEDICATED' | 'SHARED' | null
187
+ minResource: ZeropsScalingResource
188
+ maxResource: ZeropsScalingResource
189
+ minFreeResource: ZeropsScalingFreeResource
190
+ startCpuCoreCount: number | null
191
+ swapEnabled: boolean | null
192
+ }
193
+ horizontal: {
194
+ minContainerCount: number | null
195
+ maxContainerCount: number | null
196
+ }
197
+ }
198
+
165
199
  /** One service. VERIFIED: `ResponseServiceStack` (narrowed to the fields fabrika reads). */
166
200
  export interface ZeropsService {
167
201
  id: string
@@ -175,6 +209,10 @@ export interface ZeropsService {
175
209
  subdomainAccess?: boolean
176
210
  /** Provider profile identifier, e.g. `oltp-production`. VERIFIED: `ResponseServiceStack.autoscalingProfileId`. */
177
211
  autoscalingProfileId?: string
212
+ /** The autoscaling set on the service; absent when the platform sent none or one this client cannot read whole. */
213
+ customAutoscaling?: ZeropsServiceAutoscaling
214
+ /** The autoscaling that applies, the service type's values included; absent like `customAutoscaling`. */
215
+ currentAutoscaling?: ZeropsServiceAutoscaling
178
216
  }
179
217
 
180
218
  /** One project. VERIFIED: `ResponseProject` (narrowed). */
@@ -447,6 +485,15 @@ export interface ZeropsApi {
447
485
  */
448
486
  configureAutoscaling(input: { serviceId: string; autoscalingProfileId: string; signal: AbortSignal }): Promise<ZeropsProcess | null>
449
487
 
488
+ /**
489
+ * Replace a service's custom autoscaling: the same `PUT /service-stack/{id}/autoscaling`, body
490
+ * `{ customAutoscaling: { verticalAutoscaling, horizontalAutoscaling } }`. VERIFIED live 2026-10-15: it answers
491
+ * `{ process: null }` and `currentAutoscaling` reads the change at once. The platform replaces the whole object: a
492
+ * field left out, or under a key it does not know, is reset to the service type's value and the call still answers
493
+ * 200, so the caller sends what `getService().customAutoscaling` read, changed where it means to.
494
+ */
495
+ configureCustomAutoscaling(input: { serviceId: string; autoscaling: ZeropsServiceAutoscaling; signal: AbortSignal }): Promise<ZeropsProcess | null>
496
+
450
497
  // ── service-stack / project: reading state ────────────────────────────────────
451
498
 
452
499
  /** One service by id. VERIFIED: `GET /service-stack/{id}`. */
package/src/api.ts CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  type ZeropsProjectStatus,
31
31
  type ZeropsRoleCode,
32
32
  type ZeropsService,
33
+ type ZeropsServiceAutoscaling,
33
34
  type ZeropsServiceEnv,
34
35
  type ZeropsServiceEnvWrite,
35
36
  type ZeropsServiceStatus,
package/src/control.ts CHANGED
@@ -434,7 +434,9 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
434
434
  if (!checkpoint.triggered) {
435
435
  // A throw keeps the run running; the next maintenance pass retries the deletion.
436
436
  await deleteVersion(api, checkpoint.appVersionId, signal)
437
- await log(`deleted app-version ${checkpoint.appVersionId}; its build was never requested`)
437
+ await log(
438
+ `deploy interrupted: Control stopped before it requested the build; deleted app-version ${checkpoint.appVersionId}; run the deploy again`,
439
+ )
438
440
  return { state: 'failed' }
439
441
  }
440
442
  let version: ZeropsAppVersion | null
@@ -0,0 +1,68 @@
1
+ // The connection budget of a contract application's PostgreSQL on Zerops (ADR-0043): how many connections each of the
2
+ // application's processes may open, and the RAM its database needs for that to be at least the minimum. PURE.
3
+
4
+ /**
5
+ * The RAM steps of a Zerops PostgreSQL service in GB; its `max_connections` follows the step its RAM is in, and a
6
+ * service between two steps has the lower one's (`docs/reference/zerops-platform.md`, "Connection limits"). Above 8 GB
7
+ * the steps are multiples of 8 GB; 16 GB is the first with the highest limit.
8
+ */
9
+ export const ZEROPS_POSTGRES_RAM_STEPS_GB: readonly number[] = [0.25, 0.5, 1, 2, 4, 8, 16]
10
+
11
+ /** The lowest step, and the smallest RAM a PostgreSQL service is given (`oltp-hobby`'s floor). */
12
+ export const ZEROPS_POSTGRES_MIN_RAM_GB = 0.25
13
+
14
+ /** Of a service's `max_connections`, what no application role can take: 3 kept for superusers, up to 4 Zerops' own. */
15
+ export const ZEROPS_POSTGRES_UNAVAILABLE_CONNECTIONS = 7
16
+
17
+ /**
18
+ * The most connections one container's start script holds at once: `fabrika db test-isolation` peaks at 4 since the
19
+ * test kit opens a file's connections together, and at 5 with a `@fabrika/db` before that (measured locally,
20
+ * 2026-10-16, with the Notes and CRM examples). Migrating and reading the schema take fewer, one after the other.
21
+ */
22
+ export const ZEROPS_START_CONNECTIONS = 5
23
+
24
+ /** The fewest connections a process may have: a job's fenced write and its cancellation each need one at once. */
25
+ export const MIN_PROCESS_CONNECTIONS = 2
26
+ /** The most a process gets, however large the database: the pool every process had before the platform sized it. */
27
+ export const MAX_PROCESS_CONNECTIONS = 10
28
+
29
+ /** Zerops' own maximum, which a runtime whose horizontal autoscaling reads nothing may scale to. */
30
+ export const ZEROPS_MAX_CONTAINERS = 10
31
+
32
+ /** What opens connections to the application's database at once. */
33
+ export interface ZeropsDatabaseDemand {
34
+ /** The deploy service's maximum container count. */
35
+ readonly maxContainers: number
36
+ /** The crontab lines that run `fabrika job run`, each its own process on one container. */
37
+ readonly jobs: number
38
+ }
39
+
40
+ /** `max_connections` of a Zerops PostgreSQL service with `ramGB` of RAM and this autoscaling profile. */
41
+ export function zeropsPostgresMaxConnections(ramGB: number, profile: string | undefined): number {
42
+ const step = ramGB < 8
43
+ ? Math.max(...ZEROPS_POSTGRES_RAM_STEPS_GB.filter((candidate) => candidate <= ramGB), ZEROPS_POSTGRES_MIN_RAM_GB)
44
+ : Math.floor(ramGB / 8) * 8
45
+ const [perGB, lowest, highest] = profile?.startsWith('olap-') === true ? [25, 10, 200] : [50, 20, 500]
46
+ return Math.min(highest, Math.max(lowest, Math.floor(perGB * step)))
47
+ }
48
+
49
+ /**
50
+ * The processes that may each hold a full pool at once: while a deploy replaces the containers, every old one still
51
+ * serves beside its replacement, and each crontab job runs in a process of its own.
52
+ */
53
+ const poolHolders = (demand: ZeropsDatabaseDemand): number => 2 * demand.maxContainers + demand.jobs
54
+
55
+ /**
56
+ * The connections each process may open so that all of them, with one container's start script, stay within what the
57
+ * application's roles can take; `null` when that is below `MIN_PROCESS_CONNECTIONS`.
58
+ */
59
+ export function zeropsProcessConnections(maxConnections: number, demand: ZeropsDatabaseDemand): number | null {
60
+ const available = maxConnections - ZEROPS_POSTGRES_UNAVAILABLE_CONNECTIONS - ZEROPS_START_CONNECTIONS
61
+ const share = Math.floor(available / poolHolders(demand))
62
+ return share < MIN_PROCESS_CONNECTIONS ? null : Math.min(share, MAX_PROCESS_CONNECTIONS)
63
+ }
64
+
65
+ /** The smallest RAM step at which each process gets at least `MIN_PROCESS_CONNECTIONS`, or null when none does. */
66
+ export function zeropsDatabaseMinRam(demand: ZeropsDatabaseDemand, profile: string | undefined): number | null {
67
+ return ZEROPS_POSTGRES_RAM_STEPS_GB.find((ram) => zeropsProcessConnections(zeropsPostgresMaxConnections(ram, profile), demand) !== null) ?? null
68
+ }
package/src/execution.ts CHANGED
@@ -55,6 +55,20 @@ const FILE_SWEEP_SCHEDULE = '41 * * * *'
55
55
  */
56
56
  export const ZEROPS_SCAN_MIN_RAM_GB = 2
57
57
 
58
+ /**
59
+ * The RAM in GB a container of an application with a database starts with. Its start script runs `fabrika db
60
+ * test-isolation`, whose CLI and `bun test` child together peak at 161 MiB of anonymous memory for the Notes example
61
+ * and 225 MiB for the CRM example (measured locally, 2026-10-15), beside Bun's own mapped binary. Zerops' 0.125 GB
62
+ * floor kills that within a second, faster than vertical autoscaling reacts.
63
+ */
64
+ export const ZEROPS_START_MIN_RAM_GB = 0.5
65
+
66
+ /** The minimum RAM in GB the deploy service's containers need to start, or null when Zerops' floor suffices. */
67
+ export function zeropsStartMinRam(execution: ZeropsLockedExecution): number | null {
68
+ if (execution.contract.scan === true) return ZEROPS_SCAN_MIN_RAM_GB
69
+ return execution.databaseUrl === null ? null : ZEROPS_START_MIN_RAM_GB
70
+ }
71
+
58
72
  /** What a scanning application adds: ClamAV in the image with its first signatures, refreshed at start and daily. */
59
73
  const SCANNER_PACKAGE = 'clamav'
60
74
  const FRESHCLAM = 'sudo freshclam --stdout --quiet'
@@ -79,6 +93,11 @@ export interface ZeropsLockedExecution {
79
93
  readonly contract: ZeropsContractRun
80
94
  /** A `${host_…}` reference to the application's PostgreSQL service, or null when it has none. */
81
95
  readonly databaseUrl: string | null
96
+ /**
97
+ * The most connections each process of the application opens (`FABRIKA_APP_DATABASE_CONNECTIONS`), as the deploy's
98
+ * `size-database` step sized it from the database's limit; null until then, and without a database.
99
+ */
100
+ readonly databaseConnections: number | null
82
101
  /** The hostname of the application's one object-storage service, or null when it has none. */
83
102
  readonly storageHostname: string | null
84
103
  readonly runtimePackages: readonly ZeropsRuntimePackage[]
@@ -113,7 +132,7 @@ export function zeropsContractRun(artifact: ContractArtifact): ZeropsContractRun
113
132
  /**
114
133
  * A scanning application's deploy service must start its containers with `ZEROPS_SCAN_MIN_RAM_GB`: below it the
115
134
  * scanner is killed for memory, and every upload stays quarantined. Zerops ignores a changed `verticalAutoscaling` of
116
- * an existing service on a re-import, so an existing service is raised through Zerops itself.
135
+ * an existing service on a re-import, so the deploy's `raise-start-memory` step raises an existing service.
117
136
  */
118
137
  export function assertScanMemory(services: readonly { readonly [key: string]: JsonValue }[], deployService: string, run: ZeropsContractRun): void {
119
138
  if (run.scan !== true) return
@@ -323,6 +342,18 @@ const storageVariables = (hostname: string): Record<StorageVariable, string> =>
323
342
  FABRIKA_APP_STORAGE_SECRET_ACCESS_KEY: `\${${hostname}_secretAccessKey}`,
324
343
  })
325
344
 
345
+ /** The crontab lines that run a job of the contract or of the framework, each on one container. */
346
+ const jobCrontab = (contract: ZeropsContractRun): { readonly timing: string; readonly command: string; readonly allContainers: boolean }[] => [
347
+ ...contract.jobs.map((job) => ({ timing: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}`, allContainers: false })),
348
+ ...(contract.files === true
349
+ ? [{ timing: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SWEEP_JOB}`, allContainers: false }]
350
+ : []),
351
+ ...(contract.scan === true ? [{ timing: '* * * * *', command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SCAN_JOB}`, allContainers: false }] : []),
352
+ ]
353
+
354
+ /** How many job processes the crontab may run at once, each opening the application's database. */
355
+ export const zeropsJobProcesses = (contract: ZeropsContractRun): number => jobCrontab(contract).length
356
+
326
357
  /** The generated selected setup: the authored build kept, everything else the platform's. */
327
358
  export function renderLockedSetup(
328
359
  setup: string,
@@ -338,23 +369,13 @@ export function renderLockedSetup(
338
369
  [FABRIKA_CONTRACT_DIGEST]: contract.digest,
339
370
  ...(release === null ? {} : { FABRIKA_RELEASE: release }),
340
371
  ...(execution.databaseUrl === null ? {} : { FABRIKA_APP_DATABASE_URL: execution.databaseUrl }),
372
+ ...(execution.databaseConnections === null ? {} : { FABRIKA_APP_DATABASE_CONNECTIONS: String(execution.databaseConnections) }),
341
373
  ...(execution.storageHostname === null ? {} : storageVariables(execution.storageHostname)),
342
374
  }
343
375
  const scan = contract.scan === true
344
376
  const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
345
- const crontab = [
346
- ...contract.jobs.map((job) => ({ timing: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}`, allContainers: false })),
347
- ...(contract.files === true
348
- ? [{ timing: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SWEEP_JOB}`, allContainers: false }]
349
- : []),
350
- // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
351
- ...(scan
352
- ? [
353
- { timing: '* * * * *', command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SCAN_JOB}`, allContainers: false },
354
- { timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true },
355
- ]
356
- : []),
357
- ]
377
+ // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
378
+ const crontab = [...jobCrontab(contract), ...(scan ? [{ timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true }] : [])]
358
379
  // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
359
380
  // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
360
381
  const initCommands = [
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export type {
29
29
  ZeropsProjectStatus,
30
30
  ZeropsRoleCode,
31
31
  ZeropsService,
32
+ ZeropsServiceAutoscaling,
32
33
  ZeropsServiceEnv,
33
34
  ZeropsServiceEnvWrite,
34
35
  ZeropsServiceStatus,
@@ -108,6 +109,7 @@ export { ZEROPS_PROXY_MANIFEST_PATH, type ZeropsProxyManifestCarrier, zeropsProx
108
109
  export type * from './schema.generated'
109
110
  export { isZeropsUploadDestination, normalizeZeropsSourceRepository } from './source'
110
111
  export type { ZeropsSourceRepository } from './source'
112
+ export { ZeropsSourceRateLimitedError } from './source/port'
111
113
  export type {
112
114
  ZeropsSourceArchive,
113
115
  ZeropsSourcePort,
@@ -19,7 +19,7 @@ export interface PlatformAdminInput {
19
19
  readonly iamHost: string
20
20
  /** The scheme a browser speaks to this installation. With `iamHost` it composes IAM's public origin. */
21
21
  readonly scheme: 'http' | 'https'
22
- /** The `px_` provisioning key `platform install` printed. Environment only. */
22
+ /** The `px_` provisioning key `platform install` wrote to its secrets file. Environment only. */
23
23
  readonly provisioningKey: string
24
24
  /** Replace an outstanding enrollment instead of reporting it. */
25
25
  readonly reissue: boolean
@@ -78,7 +78,7 @@ const provisioningKey = (env: Record<string, string | undefined>): string => {
78
78
  const value = readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY')
79
79
  if (value === undefined) {
80
80
  throw new Error(
81
- 'FABRIKA_IAM_PROVISIONING_KEY is required. It has no flag: the key platform install printed comes from the environment only.',
81
+ 'FABRIKA_IAM_PROVISIONING_KEY is required. It has no flag: load it from the secrets file platform install wrote, into the environment only.',
82
82
  )
83
83
  }
84
84
  return value
@@ -152,8 +152,10 @@ URL stops working.
152
152
 
153
153
  The sidecar is a GitHub repository the operator owns. Its workflow runs `platform deploy` at the pinned tag.
154
154
 
155
- Steps 4 and 5 do not depend on this step: the installation from step 1 already runs. The sidecar is what
156
- upgrades it later (step 6). Run this step before or after step 5. Only `platform upgrade` needs it.
155
+ The installation from step 1 already runs, and the sidecar is what upgrades it later (step 6). But `init`
156
+ triggers the workflow, and its run redeploys every platform service, control included. A deploy that is in
157
+ flight when control restarts fails, and its log names no reason. So run this step BEFORE step 5, and wait for
158
+ its run to end before the first `control deploy` (see the check below). Step 4 can run meanwhile.
157
159
 
158
160
  `init` takes the integration token step 1 minted, never the personal one. The secrets file holds it as
159
161
  `FABRIKA_ZEROPS_ACCESS_TOKEN`, beside the provisioning key. Run `init` from a directory that is NOT inside
@@ -169,9 +171,16 @@ one too. Otherwise pass `--checkout=<dir>` outside one:
169
171
  - Without `--repo` the repository is `contember/fabrika-zerops-<installation>`. Pass `--repo` for any
170
172
  other organization.
171
173
  - Keep the local checkout path. `platform upgrade` needs it later (`--sidecar=<dir>`).
174
+ - `--tag` is written only into a NEW sidecar. An existing one keeps its `fabrika.ref`. When that pins another
175
+ tag, `init` writes the GitHub Environment and then refuses to trigger the run, which would deploy the other
176
+ tag. Its message names the fix: `fabrika platform upgrade --provider=zerops --to=<tag> --sidecar=<dir>`.
172
177
 
173
- **Check.** The `platform` workflow run in the sidecar repository ends green:
174
- `gh run list --repo <owner>/<name>`.
178
+ **Check.** The `platform` workflow run in the sidecar repository ends green. Wait for it before step 5:
179
+
180
+ ```bash
181
+ gh run list --repo <owner>/<name> --workflow=platform.yml --limit=1 # its id, status and conclusion
182
+ gh run watch <run id> --repo <owner>/<name> --exit-status # returns when the run ends; non-zero if it failed
183
+ ```
175
184
 
176
185
  ## Step 4. Mint a machine key for `fabrika control`
177
186
 
@@ -210,17 +219,22 @@ places it on the namespace's shared PostgreSQL (`cheap` preset). Use the config
210
219
  **Repository access decides how you register** (step 5.2). Check it first:
211
220
  `curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/<owner>/<name>`.
212
221
 
213
- - `200`: the repository is public. Register it with `--installation-id=none`. Control reads it without a token.
222
+ - `200`: the repository is public. Register it with `--installation-id=none`. Control reads it without a token,
223
+ or with the organization's GitHub App once that App is connected and installed on the repository.
214
224
  - `404`: GitHub hides it from an anonymous caller, so it is private (or the name is wrong). `register` refuses
215
225
  it with `--installation-id=none`. Ask the human to connect the GitHub App in the console first (Settings →
216
226
  Source, a browser flow), then register without `--installation-id`.
217
227
 
218
228
  **Use the installation's release in the application.** The application's `@fabrika/*` packages, the CLI
219
- among them, must be the version of the tag you installed: for `v0.4.3`, `^0.4.3`. Another version can build
220
- a manifest this installation's control refuses or reads differently. From the fabrika clone of step 0, run
221
- `bun scripts/release.ts example-pin <application checkout> --tag=<tag>`. It rewrites every `@fabrika/*` range
222
- and commits nothing. Commit and push the change in the application repository, then run `bun install` there.
223
- Check with `bun pm ls | grep @fabrika/cli`.
229
+ among them, must be exactly the version of the tag you installed: for `v0.4.3`, `0.4.3`, not a range such as
230
+ `^0.4.3`, which resolves a later patch. Another version can build a manifest this installation's control
231
+ refuses or reads differently. The example's `main` pins the latest release; for an older installation, from
232
+ the fabrika clone of step 0, run `bun scripts/release.ts example-pin <application checkout> --tag=<tag>`. It
233
+ pins every `@fabrika/*` package exactly and commits nothing. Run `bun install` in the application repository,
234
+ then commit and push `package.json` and `bun.lock`. Check that every `@fabrika/*` package, the transitive ones
235
+ included, is the tag's version: `bun pm ls --all | grep @fabrika/`. Releases up to v0.4.6 name their own
236
+ `@fabrika/*` dependencies by a range, so a transitive one can still resolve a later patch of that line. Then
237
+ install the newest patch instead (step 6 upgrades an installation).
224
238
 
225
239
  1. **Create a namespace** with the preset and core package the human chose (see Decisions). Without a
226
240
  custom domain bound to its proxy, pass `--public-access=zerops-subdomain`; the default is
@@ -232,6 +246,9 @@ Check with `bun pm ls | grep @fabrika/cli`.
232
246
  fabrika control namespaces get --namespace=<id>
233
247
  ```
234
248
 
249
+ The namespace's Zerops project is `fabrika-<env>-<namespace id>`, beside the installation's
250
+ `fabrika-<env>`. Pass `--project=<name>` for another name; it cannot change after `create`.
251
+
235
252
  `create` returns while the state is `pending`. Provisioning takes several minutes. Poll `get` every
236
253
  30 seconds until it is `ready`.
237
254
 
@@ -263,8 +280,12 @@ Check with `bun pm ls | grep @fabrika/cli`.
263
280
  once and refuses one that is not publicly readable, so the failure shows here and not at deploy. A
264
281
  private repository needs the GitHub App connected in the console first (Settings → Source, a browser
265
282
  flow for the human); then omit the flag.
283
+ - The public origin is where IAM returns a person after sign-in; without one, sign-in to the application
284
+ fails with "… is not a registered return origin for this app". For a host the namespace lists, `register`
285
+ sets `https://<free host>` itself and prints `public origin https://<host>` on stderr. For a custom domain
286
+ the namespace cannot list, add `--public-origin=https://<domain>`.
266
287
  - `register` CREATES and refuses a second registration. A changed manifest goes through
267
- `control apps environments put` with the same placement flags.
288
+ `control apps environments put` with the same placement flags. `put` derives no public origin.
268
289
 
269
290
  3. **Deploy and follow it.**
270
291
 
@@ -275,7 +296,13 @@ Check with `bun pm ls | grep @fabrika/cli`.
275
296
  ```
276
297
 
277
298
  A failed run's log ends with `deploy failed: <reason>`. For example, `source resolve failed
278
- (ref_not_found)` means control could not read the repository or the ref.
299
+ (ref_not_found)` means control could not read the repository or the ref. `GitHub rate limit for
300
+ anonymous reads reached; resets at <time>` means GitHub refused control's tokenless reads: every tenant
301
+ behind the cloud's egress address shares that limit. Deploy again after the reset, or connect the GitHub
302
+ App in the console so reads of that organization's repositories carry a token. A namespace that hits the
303
+ limit stays `provisioning` and retries on its own. A run that ends with
304
+ `deploy interrupted: …; run the deploy again` lost Control mid-run, typically to step 3's workflow
305
+ redeploying it: deploy again once Control is back.
279
306
 
280
307
  The deploy fails when the repository at the deployed commit does not match the registered manifest.
281
308
  Commit and push, rebuild the manifest, `apps environments put` it, and deploy again.
@@ -283,6 +310,13 @@ Check with `bun pm ls | grep @fabrika/cli`.
283
310
  On an environment named `prod`, a change that widens access waits for a person to approve it in the
284
311
  console before it deploys.
285
312
 
313
+ 4. **Give the administrator a role in the application.** The step 2 grant administers the installation; it
314
+ holds none of the application's actions, so the application answers `forbidden` to the administrator.
315
+ No `fabrika` command grants an application role. The human does it in the console: Access → Users → the
316
+ administrator → Add grant. Pick the application `<id>@<env>` (the example: `notes@<env>`), Named role / policy,
317
+ a role the application declares (the example's `editor` reads, writes and deletes notes), the global scope,
318
+ and Add grant. It takes effect at the next sign-in.
319
+
286
320
  **Check.** The run ends `succeeded`. `https://<host>/healthz` answers 200, and `/` redirects to the IAM
287
321
  sign-in. A green run proves only that Zerops accepted the build: have the human sign in and use the app.
288
322
 
@@ -172,13 +172,16 @@ It writes TWO secrets into that Environment and GENERATES NEITHER — both alrea
172
172
  installation, so a value invented here would not be one it accepts. Both are environment only:
173
173
 
174
174
  FABRIKA_ZEROPS_ACCESS_TOKEN required the INTEGRATION token install minted (the \`control\` service's env)
175
- FABRIKA_IAM_PROVISIONING_KEY required the px_ key install printed
175
+ FABRIKA_IAM_PROVISIONING_KEY required the px_ key install wrote to its --secrets-file
176
176
  FABRIKA_ZEROPS_API_URL optional region API base, when not the default
177
177
 
178
178
  Each is sent to GitHub over \`gh\` stdin, and never printed and never written to disk.
179
179
 
180
180
  The generated pipeline pins a published TAG of contember/fabrika-platform and refuses a branch, at
181
181
  init and again in the workflow. It uploads that checkout's exact committed archive to every installation service.
182
+ \`--tag\` is written only to a new sidecar: an existing \`fabrika.ref\` is the operator's and stays. When it pins
183
+ another tag, init writes the Environment and then refuses to trigger the run, which would deploy that other tag;
184
+ \`platform upgrade --to=<tag>\` moves the pin and starts the run.
182
185
 
183
186
  ── platform deploy ───────────────────────────────────────────────────────────────────────────────
184
187
 
@@ -275,7 +278,7 @@ Options (a flag beats the environment variable beside it):
275
278
  --scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
276
279
  ${REISSUE_FLAG} replace an outstanding enrollment
277
280
 
278
- FABRIKA_IAM_PROVISIONING_KEY required the px_ admin key \`install\` printed, environment only
281
+ FABRIKA_IAM_PROVISIONING_KEY required the px_ admin key \`install\` wrote to its --secrets-file, environment only
279
282
 
280
283
  ── platform upgrade ──────────────────────────────────────────────────────────────────────────────
281
284
 
@@ -258,6 +258,12 @@ const readPlatform = async (input: InitInput, { log, prompts, effects }: InitCol
258
258
  })
259
259
  }
260
260
 
261
+ /** The local checkout and the tag its `fabrika.ref` pins, which an existing sidecar keeps whatever `--tag` says. */
262
+ interface ScaffoldedSidecar {
263
+ readonly dir: string
264
+ readonly pinned: string
265
+ }
266
+
261
267
  /**
262
268
  * Create or refresh the sidecar repository — the first step that leaves this disk.
263
269
  *
@@ -265,7 +271,7 @@ const readPlatform = async (input: InitInput, { log, prompts, effects }: InitCol
265
271
  * outward step: an Environment cannot be written to a repository that does not exist, and a workflow
266
272
  * cannot be triggered without one.
267
273
  */
268
- const scaffold = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<string | undefined> => {
274
+ const scaffold = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<ScaffoldedSidecar | undefined> => {
269
275
  log.step('Scaffold the sidecar repository')
270
276
  const exists = await effects.repositoryExists(input.repo)
271
277
  const question = exists
@@ -293,7 +299,22 @@ const scaffold = async (input: InitInput, { log, prompts, effects }: InitCollabo
293
299
  if (pinned !== input.fabrikaRef) {
294
300
  log.warn(`the existing pin ${pinned} was kept; ${input.fabrikaRef} was not written, because fabrika.ref is operator-owned`)
295
301
  }
296
- return result.dir
302
+ return { dir: result.dir, pinned }
303
+ }
304
+
305
+ /**
306
+ * Refuse to trigger a run that would deploy a tag other than `--tag`: the run deploys the kept pin, so a sidecar reused
307
+ * for a new installation would roll an older release over it. The Environment is already written, so the pin moves by
308
+ * `upgrade`, whose push triggers the run with it.
309
+ */
310
+ const assertPinnedAsRequested = (input: InitInput, sidecar: ScaffoldedSidecar): void => {
311
+ if (sidecar.pinned === input.fabrikaRef) return
312
+ throw new Error(
313
+ `the platform workflow was NOT triggered: ${input.repo} pins ${sidecar.pinned}, not ${input.fabrikaRef}, and a run deploys the pin. `
314
+ + `The ${input.installation} Environment is written. To deploy ${input.fabrikaRef}, move the pin: `
315
+ + `fabrika platform upgrade --provider=zerops --to=${input.fabrikaRef} --sidecar=${sidecar.dir}. `
316
+ + `To deploy ${sidecar.pinned}, run init again with --tag=${sidecar.pinned}`,
317
+ )
297
318
  }
298
319
 
299
320
  /** Run the whole thing for `<installation>`. */
@@ -320,8 +341,8 @@ export const runInit = async (input: InitInput, collaborators: InitCollaborators
320
341
  : [url(settingsUrl)],
321
342
  )
322
343
 
323
- const dir = await scaffold(input, collaborators)
324
- if (dir === undefined) {
344
+ const sidecar = await scaffold(input, collaborators)
345
+ if (sidecar === undefined) {
325
346
  return
326
347
  }
327
348
 
@@ -338,6 +359,7 @@ export const runInit = async (input: InitInput, collaborators: InitCollaborators
338
359
  log.ok(`Environment ${input.installation} written: ${Object.keys(config.secrets).join(', ')} and ${Object.keys(config.vars).join(', ')}.`)
339
360
 
340
361
  log.step('Trigger the deploy')
362
+ assertPinnedAsRequested(input, sidecar)
341
363
  log.info("The pipeline runs the whole ordered sequence in the operator's CI; this machine deploys nothing.")
342
364
  if (!(await prompts.confirm(`Run the platform workflow on ${input.repo} now?`, true))) {
343
365
  log.action('OPERATOR ACTION — run it when ready', [
@@ -351,7 +373,7 @@ export const runInit = async (input: InitInput, collaborators: InitCollaborators
351
373
  }
352
374
 
353
375
  log.step('Done')
354
- log.ok(`Sidecar repository: ${input.repo} (local checkout: ${dir})`)
376
+ log.ok(`Sidecar repository: ${input.repo} (local checkout: ${sidecar.dir})`)
355
377
  log.info('Roll this installation forward by bumping fabrika.ref to a newer published tag and pushing.')
356
378
  log.info("Who may administer the installation is IAM's to say: this repository holds no admission list to close later.")
357
379
  // The origin comes off the live control service, never from what was typed here: an operator who
@@ -131,7 +131,7 @@ const installPreparedPlatform = async (
131
131
  log.ok(
132
132
  'IAM signing keys (1 × ES256), the IAM RPC key, the proxy key, the provisioning key, the Operations sync key and the vault key',
133
133
  )
134
- log.info('None of them is written to disk. Only the provisioning key is printed, once, at the end.')
134
+ log.info(`None of them is printed: every one goes to ${input.secretsFile} (mode 0600) before the first is placed.`)
135
135
 
136
136
  log.step("Mint the control plane's Zerops token")
137
137
  log.info('An INTEGRATION token, client role NO_ACCESS with ADMIN on this one project — never the personal token you authenticated with.')
package/src/manifest.ts CHANGED
@@ -410,23 +410,43 @@ const isDescriptorSetup = (value: JsonValue, setup: string): value is { readonly
410
410
  const zeropsProvisioningDatabaseUrl = (host: string): string =>
411
411
  `postgresql://\${${host}_superUser}:\${${host}_superUserPassword}@\${${host}_hostname}:\${${host}_port}/\${${host}_dbName}?sslmode=disable`
412
412
 
413
+ /** The PostgreSQL service a contract application uses: its own, or the namespace's shared one. */
414
+ export interface ApplicationDatabaseService {
415
+ readonly hostname: string
416
+ /** The namespace's `postgres`, which every application of a `cheap` namespace shares and the namespace sizes. */
417
+ readonly shared: boolean
418
+ }
419
+
413
420
  /**
414
421
  * The PostgreSQL service `FABRIKA_APP_DATABASE_URL` is generated for: the namespace's shared one, or the application's
415
422
  * one PostgreSQL service. Two candidates would make the generated migration pick one.
416
423
  */
417
- const applicationDatabaseUrl = (
424
+ const applicationDatabase = (
418
425
  services: FabrikaManifestImportDocument['services'],
419
426
  namespaceResources: readonly ZeropsNamespaceResourceRequirement[] | undefined,
420
- ): string | null => {
427
+ ): ApplicationDatabaseService | null => {
421
428
  const own = services.filter((service) => service.type.startsWith('postgresql')).map((service) => service.hostname)
422
429
  const shared = namespaceResources?.find((resource) => resource.resourceKey === 'service:postgres')
423
430
  if (own.length + (shared === undefined ? 0 : 1) > 1) {
424
431
  throw new Error('invalid fabrika manifest: a contract application has one PostgreSQL database; it declares more than one')
425
432
  }
426
- const host = shared?.hostname ?? own[0]
427
- return host === undefined ? null : zeropsProvisioningDatabaseUrl(host)
433
+ if (shared !== undefined) return { hostname: shared.hostname, shared: true }
434
+ const [hostname] = own
435
+ return hostname === undefined ? null : { hostname, shared: false }
428
436
  }
429
437
 
438
+ const applicationDatabaseUrl = (
439
+ services: FabrikaManifestImportDocument['services'],
440
+ namespaceResources: readonly ZeropsNamespaceResourceRequirement[] | undefined,
441
+ ): string | null => {
442
+ const database = applicationDatabase(services, namespaceResources)
443
+ return database === null ? null : zeropsProvisioningDatabaseUrl(database.hostname)
444
+ }
445
+
446
+ /** The PostgreSQL service of the application this manifest deploys, or null when it has none. */
447
+ export const applicationDatabaseOf = (manifest: FabrikaManifest): ApplicationDatabaseService | null =>
448
+ applicationDatabase(manifest.target.importDocument.services, manifest.target.namespaceResources)
449
+
430
450
  /** The hostname the `FABRIKA_APP_STORAGE_*` references name: the application's one object-storage service. */
431
451
  const applicationStorageHostname = (services: FabrikaManifestImportDocument['services']): string | null => {
432
452
  const own = services.filter((service) => OBJECT_STORAGE_TYPES.has(service.type)).map((service) => service.hostname)
@@ -444,6 +464,7 @@ export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecut
444
464
  appId: manifest.app.id,
445
465
  contract,
446
466
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
467
+ databaseConnections: null,
447
468
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
448
469
  runtimePackages: manifest.target.runtimePackages ?? [],
449
470
  sourceMaps: null,
@@ -475,6 +496,7 @@ export function lockedExecutionFor(
475
496
  appId: manifest.app.id,
476
497
  contract,
477
498
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
499
+ databaseConnections: null,
478
500
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
479
501
  runtimePackages: manifest.target.runtimePackages ?? [],
480
502
  sourceMaps: artifactUpload === undefined ? null : sourceMapUpload(artifactUpload, access.contract),
@@ -1,5 +1,6 @@
1
1
  import { ProviderNamespaceError } from '@fabrika/provider-contract'
2
2
  import { ZeropsApiError, ZeropsServiceEnvValueTooLargeError } from '../api'
3
+ import { ZeropsSourceRateLimitedError } from '../source/port'
3
4
 
4
5
  /**
5
6
  * Lifecycle codes this provider defines where the platform has none of its own. They are an
@@ -16,6 +17,8 @@ export const ZEROPS_PROXY_DEPLOY_FAILED = 'proxyDeployFailed'
16
17
 
17
18
  export const ZEROPS_SUBDOMAIN_NOT_PUBLISHED = 'subdomainNotPublished'
18
19
 
20
+ export const ZEROPS_SOURCE_RATE_LIMITED = 'githubRateLimited'
21
+
19
22
  export const namespaceError = (code: string, message: string, retryable: boolean, detail?: string): ProviderNamespaceError =>
20
23
  new ProviderNamespaceError(message, code, retryable, detail)
21
24
 
@@ -43,6 +46,8 @@ export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile'
43
46
  if (cause instanceof ProviderNamespaceError) return cause
44
47
  // The same value is refused again on every retry; only a smaller manifest or a new carrier fixes it.
45
48
  if (cause instanceof ZeropsServiceEnvValueTooLargeError) return namespaceError(cause.code, cause.message, false)
49
+ // The limit resets on its own: the namespace stays `provisioning` and the cron's reconcile retries it.
50
+ if (cause instanceof ZeropsSourceRateLimitedError) return namespaceError(ZEROPS_SOURCE_RATE_LIMITED, cause.message, true)
46
51
  if (cause instanceof ZeropsApiError) {
47
52
  const { summary, detail } = splitApiMessage(cause.message, cause.code)
48
53
  return namespaceError(
@@ -103,6 +103,13 @@ const namespacePresentation = (
103
103
  }
104
104
  }
105
105
 
106
+ /**
107
+ * The Zerops project a namespace creates when the plan names none: `fabrika-<env>-<id>`, beside the `fabrika-<env>` that
108
+ * `platform install` creates for the installation, so a namespace reads as fabrika's in the Zerops GUI.
109
+ */
110
+ export const defaultZeropsNamespaceProjectName = (namespace: { readonly id: string; readonly env: string }): string =>
111
+ `fabrika-${namespace.env}-${namespace.id}`
112
+
106
113
  /** Build the provider-owned namespace planning surface without any network collaborators. */
107
114
  export const createZeropsNamespaceOperator = (
108
115
  options: ZeropsNamespaceOperatorOptions,
@@ -130,7 +137,7 @@ export const createZeropsNamespaceOperator = (
130
137
  const target = zeropsNamespacePreset({
131
138
  preset: input.preset,
132
139
  env: input.env,
133
- projectName: planOptions.projectName ?? input.id,
140
+ projectName: planOptions.projectName ?? defaultZeropsNamespaceProjectName(input),
134
141
  proxySource: options.proxySource,
135
142
  ...(planOptions.corePackage === undefined ? {} : { corePackage: planOptions.corePackage }),
136
143
  ...(planOptions.publicAccess === undefined ? {} : { publicAccess: planOptions.publicAccess }),
package/src/plan.ts CHANGED
@@ -5,6 +5,8 @@ export type ZeropsStepKind =
5
5
  | 'apply-import'
6
6
  | 'verify-exposure'
7
7
  | 'write-service-env'
8
+ | 'raise-start-memory'
9
+ | 'size-database'
8
10
  | 'reserve-version'
9
11
  | 'upload-source'
10
12
  | 'trigger-build'
@@ -43,6 +45,16 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
43
45
  description: 'Read back that no service has subdomain access and every bucket is one the platform created private',
44
46
  })
45
47
  steps.push({ id: 'write-service-env', kind: 'write-service-env', description: 'Write the managed service variables' })
48
+ steps.push({
49
+ id: 'raise-start-memory',
50
+ kind: 'raise-start-memory',
51
+ description: "Raise the deploy service's minimum RAM to what its start script needs",
52
+ })
53
+ steps.push({
54
+ id: 'size-database',
55
+ kind: 'size-database',
56
+ description: "Size each process's database connections to the database's limit, raising its minimum RAM where it is too low",
57
+ })
46
58
  steps.push({ id: 'reserve-version', kind: 'reserve-version', description: 'Reserve a Zerops app version' })
47
59
  steps.push({ id: 'upload-source', kind: 'upload-source', description: 'Upload the resolved repository snapshot' })
48
60
  steps.push({ id: 'trigger-build', kind: 'trigger-build', description: 'Trigger the Zerops build+deploy pipeline' })
package/src/provider.ts CHANGED
@@ -33,10 +33,28 @@ import {
33
33
  type ZeropsAppVersion,
34
34
  type ZeropsLogAccess,
35
35
  type ZeropsProcess,
36
+ type ZeropsService,
37
+ type ZeropsServiceAutoscaling,
36
38
  } from './api'
37
39
  import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
38
40
  import { assertZeropsInvariants, renderYaml } from './compile'
39
- import { FABRIKA_CHECK_VERSION, PLATFORM_TYPESCRIPT_VERSION, renderLockedExecutionDescriptor } from './execution'
41
+ import {
42
+ MIN_PROCESS_CONNECTIONS,
43
+ ZEROPS_MAX_CONTAINERS,
44
+ ZEROPS_POSTGRES_MIN_RAM_GB,
45
+ type ZeropsDatabaseDemand,
46
+ zeropsDatabaseMinRam,
47
+ zeropsPostgresMaxConnections,
48
+ zeropsProcessConnections,
49
+ } from './database-budget'
50
+ import {
51
+ FABRIKA_CHECK_VERSION,
52
+ PLATFORM_TYPESCRIPT_VERSION,
53
+ renderLockedExecutionDescriptor,
54
+ zeropsJobProcesses,
55
+ type ZeropsLockedExecution,
56
+ zeropsStartMinRam,
57
+ } from './execution'
40
58
  import {
41
59
  createdServices,
42
60
  type ExposureFinding,
@@ -60,6 +78,7 @@ import {
60
78
  SERVICE_MANAGED_KEYS,
61
79
  } from './managed-env'
62
80
  import {
81
+ applicationDatabaseOf,
63
82
  approvedSchemaOf,
64
83
  type FabrikaImportDocument,
65
84
  type FabrikaManifest,
@@ -287,6 +306,8 @@ interface Attempt {
287
306
  triggered: boolean
288
307
  /** What the build's source-map upload reported in its log, once a relayed line said so. */
289
308
  sourceMaps?: OperationsSourceMapUploadState
309
+ /** The most connections each process of the application opens, as `size-database` sized them. */
310
+ databaseConnections?: number
290
311
  }
291
312
 
292
313
  /** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
@@ -642,6 +663,140 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
642
663
  }
643
664
  }
644
665
 
666
+ /** The custom autoscaling with its minimum RAM raised to `minRam`; every other field is sent back as it was read. */
667
+ const withMinRam = (custom: ZeropsServiceAutoscaling, minRam: number): ZeropsServiceAutoscaling => ({
668
+ ...custom,
669
+ vertical: { ...custom.vertical, minResource: { ...custom.vertical.minResource, memoryGBytes: minRam } },
670
+ })
671
+
672
+ /**
673
+ * Raises the deploy service's minimum RAM to what the generated start script needs (`zeropsStartMinRam`), before the
674
+ * version's containers start. An import cannot: Zerops ignores a changed `verticalAutoscaling` of an existing service,
675
+ * so an existing service converges here, on every deploy. It only raises, and decides by reading the service back.
676
+ */
677
+ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
678
+ const { run, zerops, log, signal, dryRun } = env
679
+ const locked = lockedExecutionFor(run.artifact, run, dryRun)
680
+ const minRam = locked === null ? null : zeropsStartMinRam(locked)
681
+ const hostname = run.artifact.target.deployService
682
+ if (minRam === null) {
683
+ log(` ${hostname} needs no more than the platform's minimum RAM`)
684
+ return
685
+ }
686
+ if (dryRun) {
687
+ log(` [dry-run] would raise the minimum RAM of ${hostname} to ${minRam} GB where it is lower`)
688
+ return
689
+ }
690
+ assertRunning(signal)
691
+ const service = await zerops.api.getService({ serviceId: run.target.serviceId, signal })
692
+ const before = service.currentAutoscaling?.vertical.minResource.memoryGBytes ?? null
693
+ if (before !== null && before >= minRam) {
694
+ log(` minimum RAM of ${hostname}: ${before} GB, unchanged (its start needs ${minRam} GB)`)
695
+ return
696
+ }
697
+ const after = await raiseMinRam(env, service, hostname, minRam, 'its start needs')
698
+ log(` minimum RAM of ${hostname}: ${before ?? 'the platform default'} → ${after} GB`)
699
+ }
700
+
701
+ /** What raising a minimum RAM calls; the deploy's collaborators, and the live platform-facts probe of the same path. */
702
+ export interface MinRamRaise {
703
+ zerops: Pick<ZeropsCollaborators, 'api' | 'sleep'>
704
+ signal: AbortSignal
705
+ }
706
+
707
+ /**
708
+ * Sets `service`'s minimum RAM to `minRam` and answers the minimum it reads back. It sends the custom autoscaling it
709
+ * read WHOLE with only that field changed: the platform resets every field a request leaves out.
710
+ */
711
+ export const raiseMinRam = async (env: MinRamRaise, service: ZeropsService, hostname: string, minRam: number, need: string): Promise<number> => {
712
+ const { zerops, signal } = env
713
+ const custom = service.customAutoscaling
714
+ const current = service.currentAutoscaling
715
+ if (custom === undefined || current === undefined) {
716
+ throw new Error(`zerops: ${hostname} did not report its autoscaling whole, so its minimum RAM cannot be raised to ${minRam} GB`)
717
+ }
718
+ const maxRam = current.vertical.maxResource.memoryGBytes
719
+ if (maxRam !== null && maxRam < minRam) {
720
+ throw new Error(`zerops: ${hostname} may use at most ${maxRam} GB of RAM and ${need} ${minRam} GB; raise verticalAutoscaling.maxRam`)
721
+ }
722
+ assertRunning(signal)
723
+ let process: ZeropsProcess | null
724
+ try {
725
+ process = await zerops.api.configureCustomAutoscaling({ serviceId: service.id, autoscaling: withMinRam(custom, minRam), signal })
726
+ } catch (error) {
727
+ if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500 && error.status !== 408) {
728
+ throw new Error(`zerops: raising the minimum RAM of ${hostname} to ${minRam} GB was refused (${error.code === '' ? error.status : error.code})`)
729
+ }
730
+ throw error
731
+ }
732
+ if (process !== null) {
733
+ await waitForProcess({ api: zerops.api, processId: process.id, sleep: zerops.sleep, signal, label: `the autoscaling change of ${hostname}` })
734
+ }
735
+ const after = (await zerops.api.getService({ serviceId: service.id, signal })).currentAutoscaling?.vertical.minResource.memoryGBytes ?? null
736
+ if (after === null || after < minRam) {
737
+ throw new Error(`zerops: ${hostname} reads back a minimum RAM of ${after ?? 'nothing'} GB after it was raised to ${minRam} GB`)
738
+ }
739
+ return after
740
+ }
741
+
742
+ /**
743
+ * Sizes the connections each process of the application opens (`zeropsProcessConnections`) from its database's
744
+ * `max_connections`, which follows the database's minimum RAM, and the deploy service's maximum container count. When
745
+ * that leaves a process fewer than `MIN_PROCESS_CONNECTIONS`, it raises the minimum RAM of the application's own
746
+ * database to the smallest step that does not (`zeropsDatabaseMinRam`). A `cheap` namespace's shared database is the
747
+ * namespace's to size, so it is never raised: each process then gets the minimum, and the run log says it may not fit.
748
+ */
749
+ const sizeDatabase = async (env: StepEnv): Promise<void> => {
750
+ const { run, zerops, log, signal, dryRun, attempt } = env
751
+ const locked = lockedExecutionFor(run.artifact, run, dryRun)
752
+ const database = locked === null ? null : applicationDatabaseOf(run.artifact)
753
+ if (locked === null || database === null) {
754
+ log(' the application has no database')
755
+ return
756
+ }
757
+ const deployHostname = run.artifact.target.deployService
758
+ if (dryRun) {
759
+ log(` [dry-run] would size each process of ${deployHostname} to the connection limit of ${database.hostname}`)
760
+ return
761
+ }
762
+ assertRunning(signal)
763
+ const deploy = await zerops.api.getService({ serviceId: run.target.serviceId, signal })
764
+ const demand: ZeropsDatabaseDemand = {
765
+ maxContainers: deploy.currentAutoscaling?.horizontal.maxContainerCount ?? ZEROPS_MAX_CONTAINERS,
766
+ jobs: zeropsJobProcesses(locked.contract),
767
+ }
768
+ const service = await zerops.api.findService({ projectId: run.target.projectId, hostname: database.hostname, signal })
769
+ if (service === null) throw new Error(`zerops: the database ${database.hostname} does not exist, so its connections cannot be sized`)
770
+ const profile = service.autoscalingProfileId
771
+ // A minimum the service does not report is taken as the smallest step: its limit is then never overstated.
772
+ let ram = service.currentAutoscaling?.vertical.minResource.memoryGBytes ?? ZEROPS_POSTGRES_MIN_RAM_GB
773
+ const holders = `${demand.maxContainers} container(s) of ${deployHostname}, twice while a deploy replaces them, and ${demand.jobs} job process(es)`
774
+ let connections = zeropsProcessConnections(zeropsPostgresMaxConnections(ram, profile), demand)
775
+ if (connections === null && database.shared) {
776
+ connections = MIN_PROCESS_CONNECTIONS
777
+ log(` [warn] ${database.hostname} at ${ram} GB has too few connections for ${holders}; the namespace shares it, so raise its profile`)
778
+ }
779
+ if (connections === null) {
780
+ const minRam = zeropsDatabaseMinRam(demand, profile)
781
+ if (minRam === null) throw new Error(`zerops: no RAM of ${database.hostname} has enough connections for ${holders}`)
782
+ const before = ram
783
+ ram = await raiseMinRam(env, service, database.hostname, minRam, `${holders} need`)
784
+ log(` minimum RAM of ${database.hostname}: ${before} → ${ram} GB, for its connection limit`)
785
+ connections = zeropsProcessConnections(zeropsPostgresMaxConnections(ram, profile), demand) ?? MIN_PROCESS_CONNECTIONS
786
+ }
787
+ attempt.databaseConnections = connections
788
+ const limit = zeropsPostgresMaxConnections(ram, profile)
789
+ log(` ${database.hostname}: ${limit} connections at ${ram} GB; each process opens at most ${connections}, for ${holders}`)
790
+ }
791
+
792
+ /** The locked execution with the connections `size-database` sized; an application with a database is never sent without. */
793
+ const sizedExecution = (locked: ZeropsLockedExecution, attempt: Attempt): ZeropsLockedExecution => {
794
+ if (locked.databaseUrl === null) return locked
795
+ const connections = attempt.databaseConnections
796
+ if (connections === undefined) throw new Error('zerops: trigger-build has no database connection size (size-database did not run)')
797
+ return { ...locked, databaseConnections: connections }
798
+ }
799
+
645
800
  /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by `runSteps`). */
646
801
  const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
647
802
  const { run, zerops, compiled, log, signal, dryRun, attempt } = env
@@ -696,6 +851,16 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
696
851
  return
697
852
  }
698
853
 
854
+ case 'raise-start-memory': {
855
+ await raiseStartMemory(env)
856
+ return
857
+ }
858
+
859
+ case 'size-database': {
860
+ await sizeDatabase(env)
861
+ return
862
+ }
863
+
699
864
  case 'reserve-version': {
700
865
  if (dryRun) {
701
866
  log(` [dry-run] would reserve an app version on service ${target.serviceId}`)
@@ -763,7 +928,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
763
928
  const release = env.managedValues.FABRIKA_RELEASE
764
929
  const descriptor = locked === null
765
930
  ? overlayRelease(contents, artifact.target.zeropsSetup, release)
766
- : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, locked)
931
+ : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, sizedExecution(locked, attempt))
767
932
  if (locked !== null) {
768
933
  log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
769
934
  if (locked.sourceMaps !== null) log(` the build uploads the source maps of ${locked.sourceMaps.assetRoots.length} asset root(s)`)
@@ -55,3 +55,14 @@ export interface ZeropsSourcePort {
55
55
  resolve(input: ZeropsSourceResolveInput): Promise<ZeropsSourceResolution>
56
56
  upload(input: ZeropsSourceUploadInput): Promise<ZeropsSourceUploadResult>
57
57
  }
58
+
59
+ /**
60
+ * The source refused a read for GitHub's rate limit. The read succeeds again once the limit resets, so a namespace
61
+ * mutation that hits it stays retryable. The message is the user's: whose limit, when it resets, how to avoid it.
62
+ */
63
+ export class ZeropsSourceRateLimitedError extends Error {
64
+ constructor(message: string) {
65
+ super(message)
66
+ this.name = 'ZeropsSourceRateLimitedError'
67
+ }
68
+ }