@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 +9 -8
- package/src/database-budget.ts +68 -0
- package/src/execution.ts +21 -14
- package/src/installation/guide.md +32 -3
- package/src/installation/index.ts +17 -3
- package/src/installation/install-environment.ts +11 -5
- package/src/installation/install-options.ts +27 -2
- package/src/installation/install.ts +7 -3
- package/src/installation/topology/setups.ts +6 -4
- package/src/managed-env.ts +19 -5
- package/src/manifest.ts +26 -4
- package/src/plan.ts +6 -0
- package/src/provider.ts +112 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
47
|
-
"@fabrika/auth-core": "0.
|
|
48
|
-
"@fabrika/
|
|
49
|
-
"@fabrika/
|
|
50
|
-
"@fabrika/
|
|
51
|
-
"@fabrika/
|
|
52
|
-
"@fabrika/
|
|
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 (
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
270
|
-
yourself. It is a credential: it is printed once, on a line of its own, and
|
|
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
|
|
200
|
-
*
|
|
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:
|
|
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 {
|
|
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 = [
|
|
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
|
-
|
|
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`,
|
|
171
|
-
* `
|
|
172
|
-
* `
|
|
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
|
-
* `
|
|
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),
|
package/src/managed-env.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
424
|
+
const applicationDatabase = (
|
|
418
425
|
services: FabrikaManifestImportDocument['services'],
|
|
419
426
|
namespaceResources: readonly ZeropsNamespaceResourceRequirement[] | undefined,
|
|
420
|
-
):
|
|
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
|
-
|
|
427
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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)`)
|