@fabrika/provider-zerops 0.4.7 → 0.5.0

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.7",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,13 +43,14 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "0.4.7",
47
- "@fabrika/auth-core": "0.4.7",
48
- "@fabrika/iam-contract": "0.4.7",
49
- "@fabrika/installation-init": "0.4.7",
50
- "@fabrika/operations-contract": "0.4.7",
51
- "@fabrika/provider-contract": "0.4.7",
52
- "@fabrika/proxy-core": "0.4.7",
46
+ "@fabrika/auth": "0.5.0",
47
+ "@fabrika/auth-core": "0.5.0",
48
+ "@fabrika/email": "0.5.0",
49
+ "@fabrika/iam-contract": "0.5.0",
50
+ "@fabrika/installation-init": "0.5.0",
51
+ "@fabrika/operations-contract": "0.5.0",
52
+ "@fabrika/provider-contract": "0.5.0",
53
+ "@fabrika/proxy-core": "0.5.0",
53
54
  "yaml": "^2.9.1"
54
55
  }
55
56
  }
@@ -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
@@ -93,6 +93,11 @@ export interface ZeropsLockedExecution {
93
93
  readonly contract: ZeropsContractRun
94
94
  /** A `${host_…}` reference to the application's PostgreSQL service, or null when it has none. */
95
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
96
101
  /** The hostname of the application's one object-storage service, or null when it has none. */
97
102
  readonly storageHostname: string | null
98
103
  readonly runtimePackages: readonly ZeropsRuntimePackage[]
@@ -270,7 +275,7 @@ const migrateScript = (appId: string): readonly string[] => [
270
275
 
271
276
  // The server starts only after the migrations it expects are applied, the committed schema types match the migrated
272
277
  // schema and every generated isolation test passed on a throwaway schema, so any failure fails the readiness check,
273
- // whether or not a failed `initCommands` stops the activation (an unverified platform fact). The build has no
278
+ // whether or not a failed `initCommands` stops the activation (it does, verified live). The build has no
274
279
  // database, so this is where the types and the generated tests run.
275
280
  const startScript = (appId: string, database: boolean): readonly string[] => [
276
281
  '#!/bin/sh',
@@ -337,6 +342,18 @@ const storageVariables = (hostname: string): Record<StorageVariable, string> =>
337
342
  FABRIKA_APP_STORAGE_SECRET_ACCESS_KEY: `\${${hostname}_secretAccessKey}`,
338
343
  })
339
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
+
340
357
  /** The generated selected setup: the authored build kept, everything else the platform's. */
341
358
  export function renderLockedSetup(
342
359
  setup: string,
@@ -352,23 +369,13 @@ export function renderLockedSetup(
352
369
  [FABRIKA_CONTRACT_DIGEST]: contract.digest,
353
370
  ...(release === null ? {} : { FABRIKA_RELEASE: release }),
354
371
  ...(execution.databaseUrl === null ? {} : { FABRIKA_APP_DATABASE_URL: execution.databaseUrl }),
372
+ ...(execution.databaseConnections === null ? {} : { FABRIKA_APP_DATABASE_CONNECTIONS: String(execution.databaseConnections) }),
355
373
  ...(execution.storageHostname === null ? {} : storageVariables(execution.storageHostname)),
356
374
  }
357
375
  const scan = contract.scan === true
358
376
  const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
359
- const crontab = [
360
- ...contract.jobs.map((job) => ({ timing: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}`, allContainers: false })),
361
- ...(contract.files === true
362
- ? [{ timing: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SWEEP_JOB}`, allContainers: false }]
363
- : []),
364
- // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
365
- ...(scan
366
- ? [
367
- { timing: '* * * * *', command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SCAN_JOB}`, allContainers: false },
368
- { timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true },
369
- ]
370
- : []),
371
- ]
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 }] : [])]
372
379
  // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
373
380
  // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
374
381
  const initCommands = [
@@ -126,6 +126,12 @@ fabrika platform install --provider=zerops --create-project --client-id=<clientI
126
126
  - A project that already holds generated secrets is refused. Do not try to force it: a second bring-up
127
127
  over the same project would destroy the vault key. Start over in a new empty project.
128
128
  - The account must be in the default region, or set `FABRIKA_ZEROPS_API_URL` for every command.
129
+ - Mail is optional. Without `--email-provider` the installation sends no mail. With one, add
130
+ `--email-provider=<resend|smartemailing> --email-from=<address>`, optionally `--email-from-name=<name>`, and
131
+ export the provider's variables (`FABRIKA_EMAIL_RESEND_API_KEY`, or `FABRIKA_EMAIL_SMARTEMAILING_USERNAME`,
132
+ `FABRIKA_EMAIL_SMARTEMAILING_API_KEY` and optionally `FABRIKA_EMAIL_SMARTEMAILING_TAG`) from their own `0600`
133
+ files. They have no flag. The install writes the set on `iam` and `control`, the credentials sensitive. They
134
+ are the human's own, so they are not copied into the secrets file.
129
135
 
130
136
  **Check.** The log ends with the `Hand off` step: where the credentials are, `The console answers at
131
137
  https://<console host>`, and an `ADMIT YOURSELF` box that holds the step 2 command with `--iam-host` filled
@@ -139,9 +145,10 @@ in. The secrets file exists with mode `0600`. The three hosts are printed earlie
139
145
  fabrika platform admin --provider=zerops --email=<address> --iam-host=<IAM host from step 1> )
140
146
  ```
141
147
 
142
- It prints one enrollment URL and the time it expires. Nothing is e-mailed: the installation has no mail
143
- service, so do not tell the human to wait for a message. The URL is a credential: give it to the human only,
144
- and do not log it. The human opens it, sets a password and signs in at the console origin.
148
+ Without a mail provider it prints one enrollment URL and the time it expires. Nothing is e-mailed then, so
149
+ do not tell the human to wait for a message. The URL is a credential: give it to the human only, and do not
150
+ log it. The human opens it, sets a password and signs in at the console origin. With a mail provider, IAM
151
+ e-mails the enrollment instead, and the command reports the address and the expiry.
145
152
 
146
153
  The URL is valid for ONE HOUR. Hand it over at once. A re-run creates nothing new: while the enrollment is
147
154
  unused it only reports that one is outstanding, and it never prints the URL again. To get a new URL, after
@@ -344,6 +351,28 @@ It re-pins the namespace and queues the reconcile that rebuilds its proxy; poll
344
351
  service variable, which Zerops caps at about 32 KB: when a deploy fails with `serviceEnvValueTooLarge`, this is
345
352
  the fix, and it also accepts a namespace in `failed`.
346
353
 
354
+ ## Set or rotate the mail transport
355
+
356
+ The installation's mail transport lives on the `iam` and `control` services. Zerops is its record (ADR-0004),
357
+ and Control hands a copy to every application whose job declares `mailer: 'installation'`. To add one to an
358
+ existing installation, change the provider, or rotate a key:
359
+
360
+ No `fabrika` command does this: `platform upgrade` and `platform deploy` write no `FABRIKA_EMAIL_*` and remove
361
+ none, and `install` cannot be re-run.
362
+
363
+ 1. Set the same `FABRIKA_EMAIL_*` variables on BOTH `iam` and `control`, in the Zerops GUI or with `zcli`:
364
+ `FABRIKA_EMAIL_PROVIDER`, `FABRIKA_EMAIL_FROM`, optionally `FABRIKA_EMAIL_FROM_NAME`, and the provider's
365
+ variables. Mark the credentials sensitive. Delete the variables of a provider you leave.
366
+ 2. Restart `iam` and `control`. Each reads the transport at boot. IAM refuses to start on an invalid one.
367
+ Control starts, logs a line that names the invalid variables, and refuses every deploy that needs the transport
368
+ until you fix them and restart it.
369
+ 3. Redeploy every environment whose contract uses `installation` mail. An application gets the new transport
370
+ only at its next deploy. Until then it sends with the old one, and a revoked key fails its mail with
371
+ `provider_unauthorized`.
372
+
373
+ Without a transport, a contract whose job declares `mailer: 'installation'` still deploys. Its jobs see that mail is
374
+ not available and leave their messages queued. After step 3 the next run sends them.
375
+
347
376
  ## When something fails
348
377
 
349
378
  - Every refusal names its flag or variable. Fix that one value and re-run. `init`, `admin`, `deploy` and
@@ -1,4 +1,5 @@
1
1
  import { reconcileSchema } from '@fabrika/auth'
2
+ import { builtinEmailProviders } from '@fabrika/email/providers'
2
3
  import {
3
4
  describeArgument,
4
5
  info as consoleInfo,
@@ -44,6 +45,9 @@ export {
44
45
  resolvePlatformProxyManifest,
45
46
  } from './proxy-manifest'
46
47
 
48
+ const EMAIL_PROVIDERS = Object.keys(builtinEmailProviders).join(', ')
49
+ const EMAIL_PROVIDER_VARIABLES = Object.values(builtinEmailProviders).flatMap((provider) => provider.variables.map((variable) => variable.name))
50
+
47
51
  const USAGE = `Zerops installation
48
52
 
49
53
  New here, or an agent? Run \`fabrika platform guide --provider=zerops\` first: the whole bring-up in order,
@@ -86,7 +90,8 @@ is not consent.
86
90
  all of them to \`--secrets-file\` (mode 0600) before the first one is placed
87
91
  4. pass 1: give the proxy an EMPTY manifest and build it, so it publishes one public hostname per
88
92
  HTTP port; the three platform hosts are then READ off those, never composed
89
- 5. write every remaining variable on all four runtime services
93
+ 5. write every remaining variable on all four runtime services; the mail transport, when one is
94
+ given, goes to \`iam\` and \`control\` only, its credentials sensitive and not in the secrets file
90
95
  6. pass 2: hand the whole ordered sequence to \`platform deploy\`
91
96
  7. print the next commands; they read their credentials from the secrets file. Zerops holds the
92
97
  same values as SENSITIVE variables and does not hand them back, so that file is the only copy
@@ -118,11 +123,19 @@ Options (a flag beats the environment variable beside it):
118
123
  --source-dir=<checkout> required clean full checkout running this CLI
119
124
  --tier=<light|standard> FABRIKA_PLATFORM_TIER default light
120
125
  --secrets-file=<path> FABRIKA_PLATFORM_SECRETS_FILE required: a NEW file outside the checkout
126
+ --email-provider=<name> FABRIKA_EMAIL_PROVIDER default none; or one of: ${EMAIL_PROVIDERS}
127
+ --email-from=<address> FABRIKA_EMAIL_FROM the sender, required with a provider
128
+ --email-from-name=<name> FABRIKA_EMAIL_FROM_NAME the sender's display name, optional
121
129
  ${UNATTENDED_FLAG} answer every confirmation yes —
122
130
  required when stdin is not a terminal
123
131
 
124
132
  FABRIKA_ZEROPS_ACCESS_TOKEN required Zerops access token, environment only and no flag
125
133
  FABRIKA_ZEROPS_API_URL optional region API base, when not the default
134
+ FABRIKA_EMAIL_<PROVIDER>_* the selected provider's variables, environment only and no flag:
135
+ ${EMAIL_PROVIDER_VARIABLES.join(', ')}
136
+
137
+ With a mail provider, IAM sends its own mail and Control hands the same transport to every application
138
+ whose job declares \`mailer: 'installation'\`. To set or rotate it later, see \`platform guide\`.
126
139
 
127
140
  ── platform init ─────────────────────────────────────────────────────────────────────────────────
128
141
 
@@ -266,8 +279,9 @@ Access plane refuses. A re-run creates nobody twice, grants nothing twice and is
266
279
  enrollment — a password that is already set, or an enrollment already outstanding, is REPORTED. Pass
267
280
  \`${REISSUE_FLAG}\` to replace an outstanding one, which is the way out when the first expired unopened.
268
281
 
269
- Nothing is e-mailed: a Zerops installation has no mail service. Hand the URL to the administrator
270
- yourself. It is a credential: it is printed once, on a line of its own, and stored nowhere.
282
+ Unless \`install\` was given a mail provider, nothing is e-mailed: IAM then has no mail service. Hand the
283
+ URL to the administrator yourself. It is a credential: it is printed once, on a line of its own, and
284
+ stored nowhere. With a mail provider, IAM e-mails the enrollment and the URL is not printed.
271
285
 
272
286
  \`install\` reports the host this needs as \`✓ iam <host>\` and ends with this command ready to run.
273
287
 
@@ -1,3 +1,4 @@
1
+ import type { InstallationEmail } from '@fabrika/installation-init'
1
2
  import { encodeProxyManifestJson } from '@fabrika/proxy-core/manifest'
2
3
  import type { ZeropsNamespaceProxySource } from '../index'
3
4
  import type { PlatformDeployService, ResolvedService } from './deploy'
@@ -64,6 +65,8 @@ export interface InstallationEnvInput {
64
65
  readonly secrets: InstallationSecrets
65
66
  /** The Zerops integration token minted for the control plane. Never logged, never printed. */
66
67
  readonly zeropsAccessToken: string
68
+ /** IAM sends its own mail with it, and Control hands it to applications whose job declares `mailer: 'installation'`. */
69
+ readonly email: InstallationEmail
67
70
  }
68
71
 
69
72
  /**
@@ -120,6 +123,8 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
120
123
  // the CLI reads `FABRIKA_ZEROPS_API_URL`; they are different processes reading different sources.
121
124
  control.set('FABRIKA_ZEROPS_API_BASE_URL', input.apiBaseUrl)
122
125
  }
126
+ const email = input.email.kind === 'none' ? [] : [...input.email.variables]
127
+ for (const [key, value] of email) control.set(key, value)
123
128
  return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
124
129
  [
125
130
  'iam',
@@ -136,6 +141,7 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
136
141
  ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
137
142
  ['FABRIKA_IAM_PROXY_KEY', secrets.proxyKey],
138
143
  ['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
144
+ ...email,
139
145
  ]),
140
146
  ],
141
147
  [
@@ -196,19 +202,19 @@ export const GENERATED_SECRET_KEYS: ReadonlyMap<PlatformDeployService, readonly
196
202
  * Write a set of variables, reporting the KEYS — never a value, not even in a debug line.
197
203
  *
198
204
  * Write-first and unconditional: a generated secret cannot be read back to compare, and `PUT
199
- * /user-data/{id}` replaces in place, so a repeat costs a call and nothing else. Exactly the generated
200
- * secrets are written sensitive; everything else stays readable, because `platform deploy` compares
201
- * and merges it later.
205
+ * /user-data/{id}` replaces in place, so a repeat costs a call and nothing else. Exactly the `sensitive`
206
+ * keys are written sensitive (the generated secrets and the operator's mail credentials); everything else
207
+ * stays readable, because `platform deploy` compares and merges it later.
202
208
  */
203
209
  export const writeServiceEnv = async (
204
210
  service: ResolvedService,
205
211
  desired: ReadonlyMap<string, string>,
206
212
  { api, log, signal }: InstallCollaborators,
213
+ sensitive: ReadonlySet<string>,
207
214
  ): Promise<void> => {
208
- const secrets = GENERATED_SECRET_KEYS.get(service.hostname) ?? []
209
215
  for (const [key, value] of desired) {
210
216
  assertRunning(signal)
211
- await api.putServiceEnv({ serviceId: service.id, key, value, sensitive: secrets.includes(key), signal })
217
+ await api.putServiceEnv({ serviceId: service.id, key, value, sensitive: sensitive.has(key), signal })
212
218
  }
213
219
  log.ok(`${service.hostname}: wrote ${[...desired.keys()].join(', ')}`)
214
220
  }
@@ -8,7 +8,18 @@
8
8
  // operator's disk — but nothing here prompts: a missing required value is an error naming both the flag
9
9
  // and the variable that would supply it, so a mistyped install fails before it contacts anything.
10
10
 
11
- import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
11
+ import { builtinEmailProviders } from '@fabrika/email/providers'
12
+ import {
13
+ describeArgument,
14
+ EMAIL_FLAGS,
15
+ type InstallationEmail,
16
+ readEnv,
17
+ readFlag,
18
+ readInstallationEmail,
19
+ requiredSetting,
20
+ setting,
21
+ UNATTENDED_FLAG,
22
+ } from '@fabrika/installation-init'
12
23
  import { checkedSecretsFilePath, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE } from './install-secrets'
13
24
  import { parsePlatformScheme } from './placement-options'
14
25
  import { readInstallationSourceDir } from './source-options'
@@ -47,6 +58,8 @@ export interface PlatformInstallInput {
47
58
  /** Where every credential this run creates is written, mode 0600, before the first is placed. */
48
59
  readonly secretsFile: string
49
60
  readonly tier: PlatformTier
61
+ /** The installation's mail transport, written on `iam` and `control`; `none` when the operator named no provider. */
62
+ readonly email: InstallationEmail
50
63
  /** `--yes`: answer every confirmation yes, for a bring-up with nobody at the keyboard. */
51
64
  readonly unattended: boolean
52
65
  }
@@ -54,7 +67,17 @@ export interface PlatformInstallInput {
54
67
  /** The canonical public release repository used for namespace proxy source identities. */
55
68
  export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-platform'
56
69
 
57
- const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier', SECRETS_FILE_FLAG] as const
70
+ const FLAGS = [
71
+ '--project-id',
72
+ '--project-name',
73
+ '--client-id',
74
+ '--env',
75
+ '--scheme',
76
+ '--source-dir',
77
+ '--tier',
78
+ SECRETS_FILE_FLAG,
79
+ ...EMAIL_FLAGS,
80
+ ] as const
58
81
 
59
82
  /** `--create-project`: bring the project up too, instead of naming one the operator made. */
60
83
  export const CREATE_PROJECT_FLAG = '--create-project'
@@ -151,6 +174,8 @@ export const parsePlatformInstallArgs = (
151
174
  sourceDir,
152
175
  ),
153
176
  tier: readTier(setting(argv, env, '--tier', 'FABRIKA_PLATFORM_TIER')),
177
+ // Optional, so an existing unattended install keeps working and installs without mail.
178
+ email: readInstallationEmail(argv, env, { registry: builtinEmailProviders, required: false }),
154
179
  unattended: argv.includes(UNATTENDED_FLAG),
155
180
  ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
156
181
  }
@@ -27,7 +27,7 @@ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-man
27
27
  import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
28
28
  import { checkedEnvironmentName } from './init-options'
29
29
  import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
30
- import { installationServiceEnv, PASS_ONE_PROXY_MANIFEST, passOneProxyEnv, writeServiceEnv } from './install-environment'
30
+ import { GENERATED_SECRET_KEYS, installationServiceEnv, PASS_ONE_PROXY_MANIFEST, passOneProxyEnv, writeServiceEnv } from './install-environment'
31
31
  import type { PlatformInstallInput } from './install-options'
32
32
  import { resolveInstallationProject } from './install-project'
33
33
  import {
@@ -67,7 +67,7 @@ const passOne = async (
67
67
  if (!(await prompts.confirm(`Write 3 variables on \`proxy\` and build commit ${source.commitSha}? (about 4 minutes)`, true))) {
68
68
  throw new Error('declined: no variable was written, and re-running the install is safe')
69
69
  }
70
- await writeServiceEnv(proxy, passOneProxyEnv(secrets), collaborators)
70
+ await writeServiceEnv(proxy, passOneProxyEnv(secrets), collaborators, new Set(GENERATED_SECRET_KEYS.get(proxy.hostname)))
71
71
  await deployPlatformService(proxy, { source, dryRun: false, proxyManifest: PASS_ONE_PROXY_MANIFEST }, collaborators)
72
72
 
73
73
  const proxyEnv = await readServiceEnv(proxy.id, collaborators)
@@ -177,6 +177,7 @@ const installPreparedPlatform = async (
177
177
  proxySource,
178
178
  secrets,
179
179
  zeropsAccessToken: token.token,
180
+ email: input.email,
180
181
  ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
181
182
  })
182
183
  const total = [...desired.values()].reduce((sum, keys) => sum + keys.size, 0)
@@ -187,8 +188,11 @@ const installPreparedPlatform = async (
187
188
  + `Keep ${input.secretsFile} and arrange operator-owned recovery, or start a fresh installation in a separate empty project`,
188
189
  )
189
190
  }
191
+ // The mail credentials are the operator's own, so they neither mark an installation nor go to the secrets file.
192
+ const emailSecrets = input.email.kind === 'none' ? [] : [...input.email.secretNames]
190
193
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
191
- await writeServiceEnv(serviceOf(services, hostname), desired.get(hostname) ?? new Map<string, string>(), collaborators)
194
+ const sensitive = new Set([...(GENERATED_SECRET_KEYS.get(hostname) ?? []), ...emailSecrets])
195
+ await writeServiceEnv(serviceOf(services, hostname), desired.get(hostname) ?? new Map<string, string>(), collaborators, sensitive)
192
196
  }
193
197
 
194
198
  log.step('Pass 2 — deploy the installation')
@@ -167,13 +167,15 @@ const WORKSPACE_DEPLOY_FILES = ['package.json', 'bun.lock', 'node_modules', 'pac
167
167
  * `ENVIRONMENT`, `ISSUER` (this service's public origin; it is the `iss` of every minted token AND the
168
168
  * OIDC redirect base, so it must match the domain routed to the proxy in front of it),
169
169
  * `HUMAN_EMAIL_DOMAINS`, `HUMAN_EMAILS`, `IAM_BOOTSTRAP_ADMINS`,
170
- * `FABRIKA_IAM_OIDC_ENABLED`, `FABRIKA_IAM_PASSWORD_ENABLED`, `FABRIKA_EMAIL_PROVIDER`, and
171
- * `FABRIKA_EMAIL_FROM` when email delivery is enabled. OIDC-enabled installations also set
172
- * `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_SCOPES`, `OIDC_REQUIRE_VERIFIED_EMAIL`.
170
+ * `FABRIKA_IAM_OIDC_ENABLED`, `FABRIKA_IAM_PASSWORD_ENABLED`, and the mail transport when email delivery is
171
+ * enabled: `platform install --email-provider` writes `FABRIKA_EMAIL_PROVIDER`, `FABRIKA_EMAIL_FROM`,
172
+ * `FABRIKA_EMAIL_FROM_NAME` and the provider's variables on `iam` and `control`, the provider's secret ones
173
+ * sensitive. OIDC-enabled installations also set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_SCOPES`,
174
+ * `OIDC_REQUIRE_VERIFIED_EMAIL`.
173
175
  *
174
176
  * Secrets (`envSecrets`): `FABRIKA_IAM_SIGNING_KEYS` (ES256 private JWKs — an empty value is refused at
175
177
  * boot rather than silently issuing ephemeral tokens), `OIDC_CLIENT_SECRET` when OIDC is enabled,
176
- * `FABRIKA_EMAIL_RESEND_API_KEY` when Resend is enabled, `FABRIKA_IAM_RPC_KEY` (authenticates
178
+ * `FABRIKA_IAM_RPC_KEY` (authenticates
177
179
  * the management `/rpc/*` surface; unset 404s it, which is the fail-closed default), `FABRIKA_IAM_PROXY_KEY`
178
180
  * (authenticates `/auth/mint/*`, which the proxy calls on the cold path — a SEPARATE secret by least privilege,
179
181
  * since the proxy is the only publicly routed component and must not also be able to write audit rows),
@@ -4,6 +4,7 @@ import {
4
4
  frameworkVariableReference,
5
5
  isConnectKeyVariable,
6
6
  isConnectUrlVariable,
7
+ isEmailTransportVariable,
7
8
  isFrameworkVariableName,
8
9
  isWorkloadKeyVariable,
9
10
  } from '@fabrika/auth-core'
@@ -67,7 +68,7 @@ export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKe
67
68
  'FABRIKA_IAM_RPC_KEY',
68
69
  ])
69
70
 
70
- /** A workload or connect key is a credential; a connect URL is an origin. */
71
+ /** A workload or connect key and every mail transport variable are written sensitive; a connect URL is an origin. */
71
72
  export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name)
72
73
 
73
74
  export type ManagedValues = Record<ManagedKey, string | null>
@@ -93,16 +94,20 @@ export function parseManagedValues(value: unknown): ManagedValues {
93
94
  }
94
95
 
95
96
  /**
96
- * What Control hands a deploy: the ten managed keys, the key of each job and webhook of the approved contract, and the
97
- * key and owner origin of each connector import.
97
+ * What Control hands a deploy: the ten managed keys, the key of each job and webhook of the approved contract, the
98
+ * key and owner origin of each connector import, and the installation's mail transport when a job sends through it.
98
99
  */
99
100
  export interface ManagedEnvironment {
100
101
  readonly values: ManagedValues
101
- /** `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, and `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`. */
102
+ /**
103
+ * `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`, and each
104
+ * `FABRIKA_EMAIL_*` of the installation's mail transport.
105
+ */
102
106
  readonly familyVariables: Readonly<Record<string, string>>
103
107
  }
104
108
 
105
109
  const keySchema = z.string().startsWith('px_').min(4)
110
+ const mailValueSchema = z.string().min(1)
106
111
  const originSchema = z.string().refine((value) => {
107
112
  try {
108
113
  return new URL(value).origin === value && value.startsWith('https://')
@@ -119,6 +124,12 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
119
124
  managed[name] = entry
120
125
  continue
121
126
  }
127
+ if (isEmailTransportVariable(name)) {
128
+ const parsed = mailValueSchema.safeParse(entry)
129
+ if (!parsed.success) throw new Error(`zerops: ${name} must not be empty`)
130
+ familyVariables[name] = parsed.data
131
+ continue
132
+ }
122
133
  const origin = isConnectUrlVariable(name)
123
134
  if (!origin && !isWorkloadKeyVariable(name) && !isConnectKeyVariable(name)) {
124
135
  throw new Error(`zerops: ${name} is not a variable a deploy writes`)
@@ -132,7 +143,10 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
132
143
  return { values: parseManagedValues(managed), familyVariables }
133
144
  }
134
145
 
135
- /** A service variable only the platform writes per workload or import: a stale one is removed with it. */
146
+ /**
147
+ * A service variable only the platform writes per workload, import or the installation's mail transport: a stale one is
148
+ * removed when the deploy no longer hands it.
149
+ */
136
150
  export function isFrameworkFamilyVariable(name: string): boolean {
137
151
  return FRAMEWORK_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
138
152
  }
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),
package/src/plan.ts CHANGED
@@ -6,6 +6,7 @@ export type ZeropsStepKind =
6
6
  | 'verify-exposure'
7
7
  | 'write-service-env'
8
8
  | 'raise-start-memory'
9
+ | 'size-database'
9
10
  | 'reserve-version'
10
11
  | 'upload-source'
11
12
  | 'trigger-build'
@@ -49,6 +50,11 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
49
50
  kind: 'raise-start-memory',
50
51
  description: "Raise the deploy service's minimum RAM to what its start script needs",
51
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
+ })
52
58
  steps.push({ id: 'reserve-version', kind: 'reserve-version', description: 'Reserve a Zerops app version' })
53
59
  steps.push({ id: 'upload-source', kind: 'upload-source', description: 'Upload the resolved repository snapshot' })
54
60
  steps.push({ id: 'trigger-build', kind: 'trigger-build', description: 'Trigger the Zerops build+deploy pipeline' })
package/src/provider.ts CHANGED
@@ -33,11 +33,28 @@ import {
33
33
  type ZeropsAppVersion,
34
34
  type ZeropsLogAccess,
35
35
  type ZeropsProcess,
36
+ type ZeropsService,
36
37
  type ZeropsServiceAutoscaling,
37
38
  } from './api'
38
39
  import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
39
40
  import { assertZeropsInvariants, renderYaml } from './compile'
40
- import { FABRIKA_CHECK_VERSION, PLATFORM_TYPESCRIPT_VERSION, renderLockedExecutionDescriptor, zeropsStartMinRam } 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'
41
58
  import {
42
59
  createdServices,
43
60
  type ExposureFinding,
@@ -61,6 +78,7 @@ import {
61
78
  SERVICE_MANAGED_KEYS,
62
79
  } from './managed-env'
63
80
  import {
81
+ applicationDatabaseOf,
64
82
  approvedSchemaOf,
65
83
  type FabrikaImportDocument,
66
84
  type FabrikaManifest,
@@ -288,6 +306,8 @@ interface Attempt {
288
306
  triggered: boolean
289
307
  /** What the build's source-map upload reported in its log, once a relayed line said so. */
290
308
  sourceMaps?: OperationsSourceMapUploadState
309
+ /** The most connections each process of the application opens, as `size-database` sized them. */
310
+ databaseConnections?: number
291
311
  }
292
312
 
293
313
  /** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
@@ -576,7 +596,7 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
576
596
  log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
577
597
  log(` [dry-run] would remove service variables on ${serviceId} if present: ${removed.join(', ') || '(none)'}`)
578
598
  if (Object.keys(familyVariables).length > 0) {
579
- log(` [dry-run] would remove every other job, webhook and connector variable on ${serviceId}`)
599
+ log(` [dry-run] would remove every other job, webhook, connector and mail variable on ${serviceId}`)
580
600
  }
581
601
  return
582
602
  }
@@ -667,27 +687,42 @@ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
667
687
  log(` [dry-run] would raise the minimum RAM of ${hostname} to ${minRam} GB where it is lower`)
668
688
  return
669
689
  }
670
- const serviceId = run.target.serviceId
671
690
  assertRunning(signal)
672
- const service = await zerops.api.getService({ serviceId, 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
673
713
  const custom = service.customAutoscaling
674
714
  const current = service.currentAutoscaling
675
715
  if (custom === undefined || current === undefined) {
676
716
  throw new Error(`zerops: ${hostname} did not report its autoscaling whole, so its minimum RAM cannot be raised to ${minRam} GB`)
677
717
  }
678
- const before = current.vertical.minResource.memoryGBytes
679
- if (before !== null && before >= minRam) {
680
- log(` minimum RAM of ${hostname}: ${before} GB, unchanged (its start needs ${minRam} GB)`)
681
- return
682
- }
683
718
  const maxRam = current.vertical.maxResource.memoryGBytes
684
719
  if (maxRam !== null && maxRam < minRam) {
685
- throw new Error(`zerops: ${hostname} may use at most ${maxRam} GB of RAM and its start needs ${minRam} GB; raise verticalAutoscaling.maxRam`)
720
+ throw new Error(`zerops: ${hostname} may use at most ${maxRam} GB of RAM and ${need} ${minRam} GB; raise verticalAutoscaling.maxRam`)
686
721
  }
687
722
  assertRunning(signal)
688
723
  let process: ZeropsProcess | null
689
724
  try {
690
- process = await zerops.api.configureCustomAutoscaling({ serviceId, autoscaling: withMinRam(custom, minRam), signal })
725
+ process = await zerops.api.configureCustomAutoscaling({ serviceId: service.id, autoscaling: withMinRam(custom, minRam), signal })
691
726
  } catch (error) {
692
727
  if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500 && error.status !== 408) {
693
728
  throw new Error(`zerops: raising the minimum RAM of ${hostname} to ${minRam} GB was refused (${error.code === '' ? error.status : error.code})`)
@@ -697,11 +732,69 @@ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
697
732
  if (process !== null) {
698
733
  await waitForProcess({ api: zerops.api, processId: process.id, sleep: zerops.sleep, signal, label: `the autoscaling change of ${hostname}` })
699
734
  }
700
- const after = (await zerops.api.getService({ serviceId, signal })).currentAutoscaling?.vertical.minResource.memoryGBytes ?? null
735
+ const after = (await zerops.api.getService({ serviceId: service.id, signal })).currentAutoscaling?.vertical.minResource.memoryGBytes ?? null
701
736
  if (after === null || after < minRam) {
702
737
  throw new Error(`zerops: ${hostname} reads back a minimum RAM of ${after ?? 'nothing'} GB after it was raised to ${minRam} GB`)
703
738
  }
704
- log(` minimum RAM of ${hostname}: ${before ?? 'the platform default'} → ${after} GB`)
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 }
705
798
  }
706
799
 
707
800
  /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by `runSteps`). */
@@ -763,6 +856,11 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
763
856
  return
764
857
  }
765
858
 
859
+ case 'size-database': {
860
+ await sizeDatabase(env)
861
+ return
862
+ }
863
+
766
864
  case 'reserve-version': {
767
865
  if (dryRun) {
768
866
  log(` [dry-run] would reserve an app version on service ${target.serviceId}`)
@@ -830,7 +928,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
830
928
  const release = env.managedValues.FABRIKA_RELEASE
831
929
  const descriptor = locked === null
832
930
  ? overlayRelease(contents, artifact.target.zeropsSetup, release)
833
- : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, locked)
931
+ : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, sizedExecution(locked, attempt))
834
932
  if (locked !== null) {
835
933
  log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
836
934
  if (locked.sourceMaps !== null) log(` the build uploads the source maps of ${locked.sourceMaps.assetRoots.length} asset root(s)`)