@fabrika/provider-zerops 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/database-budget.ts +68 -0
- package/src/execution.ts +20 -13
- package/src/manifest.ts +26 -4
- package/src/plan.ts +6 -0
- package/src/provider.ts +111 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"zod": "^3.25.76",
|
|
46
|
-
"@fabrika/auth": "0.4.
|
|
47
|
-
"@fabrika/auth-core": "0.4.
|
|
48
|
-
"@fabrika/iam-contract": "0.4.
|
|
49
|
-
"@fabrika/installation-init": "0.4.
|
|
50
|
-
"@fabrika/operations-contract": "0.4.
|
|
51
|
-
"@fabrika/provider-contract": "0.4.
|
|
52
|
-
"@fabrika/proxy-core": "0.4.
|
|
46
|
+
"@fabrika/auth": "0.4.8",
|
|
47
|
+
"@fabrika/auth-core": "0.4.8",
|
|
48
|
+
"@fabrika/iam-contract": "0.4.8",
|
|
49
|
+
"@fabrika/installation-init": "0.4.8",
|
|
50
|
+
"@fabrika/operations-contract": "0.4.8",
|
|
51
|
+
"@fabrika/provider-contract": "0.4.8",
|
|
52
|
+
"@fabrika/proxy-core": "0.4.8",
|
|
53
53
|
"yaml": "^2.9.1"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -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[]
|
|
@@ -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 = [
|
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. */
|
|
@@ -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)`)
|