@fabrika/provider-zerops 0.4.4 → 0.4.6
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/control.ts +3 -1
- package/src/exposure.ts +12 -0
- package/src/manifest.ts +45 -21
- package/src/namespace/constants.ts +16 -16
- package/src/provider.ts +68 -8
- package/src/service-references.ts +27 -1
- package/src/types.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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.6",
|
|
47
|
+
"@fabrika/auth-core": "^0.4.6",
|
|
48
|
+
"@fabrika/iam-contract": "^0.4.6",
|
|
49
|
+
"@fabrika/installation-init": "^0.4.6",
|
|
50
|
+
"@fabrika/operations-contract": "^0.4.6",
|
|
51
|
+
"@fabrika/provider-contract": "^0.4.6",
|
|
52
|
+
"@fabrika/proxy-core": "^0.4.6",
|
|
53
53
|
"yaml": "^2.9.1"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/control.ts
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
zeropsNamespaceTargetCodec,
|
|
50
50
|
} from './namespace'
|
|
51
51
|
import { createZeropsDeployer, ZEROPS_VERSION_DEADLINE_MS } from './provider'
|
|
52
|
-
import { allowedReferenceHosts, foreignReferenceHost, serviceReferenceHosts } from './service-references'
|
|
52
|
+
import { allowedReferenceHosts, assertNoPlatformOnlyReference, foreignReferenceHost, serviceReferenceHosts } from './service-references'
|
|
53
53
|
import { normalizeZeropsSourceRepository } from './source'
|
|
54
54
|
import type { ZeropsSourcePort, ZeropsSourcePrivateBinding } from './source/port'
|
|
55
55
|
import type { ZeropsRunState, ZeropsRuntimeTarget } from './types'
|
|
@@ -466,6 +466,8 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
466
466
|
assertAuthoredManagedName(input.name)
|
|
467
467
|
assertNoFrameworkReference(input.name)
|
|
468
468
|
assertNoFrameworkReference(input.value)
|
|
469
|
+
assertNoPlatformOnlyReference(input.name)
|
|
470
|
+
assertNoPlatformOnlyReference(input.value)
|
|
469
471
|
assertOwnReferencesOnly(input.environment, input.name, input.value)
|
|
470
472
|
const target = decodeEnvironment(input.environment).target
|
|
471
473
|
try {
|
package/src/exposure.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ZeropsImportResult } from './api'
|
|
|
7
7
|
/**
|
|
8
8
|
* The provenance marker: a service variable on the deploy service listing the object-storage service ids the platform
|
|
9
9
|
* created, each from an import that carried `objectStoragePolicy: private`. A bucket adopted by hostname has none.
|
|
10
|
+
* The import's answer is the only evidence of creation, so `apply-import` records it here before any other step runs.
|
|
10
11
|
*/
|
|
11
12
|
export const STORAGE_PROVENANCE_KEY = 'FABRIKA_STORAGE_PROVENANCE'
|
|
12
13
|
|
|
@@ -30,6 +31,17 @@ export const createdServices = (result: ZeropsImportResult): ReadonlyMap<string,
|
|
|
30
31
|
.map((service) => [service.name, service.id]),
|
|
31
32
|
)
|
|
32
33
|
|
|
34
|
+
/** How the deploy service holds the marker: a release up to v0.4.2 wrote it sensitive, which reads back without its value. */
|
|
35
|
+
export type StorageProvenanceMarker = 'absent' | 'readable' | 'sensitive'
|
|
36
|
+
|
|
37
|
+
/** The refusal of a bucket no evidence names, with the two remedies an operator has without sudo. */
|
|
38
|
+
export const unprovenBucketReason = (input: { bucketId: string; deployService: string; marker: StorageProvenanceMarker }): string => {
|
|
39
|
+
const unreadable = input.marker === 'sensitive'
|
|
40
|
+
? `; \`${STORAGE_PROVENANCE_KEY}\` on \`${input.deployService}\` was written sensitive by an earlier release and cannot be read`
|
|
41
|
+
: ''
|
|
42
|
+
return `is a bucket the platform did not create private (no provenance${unreadable}). If a Fabrika import created it (its \`${CREATE_ACTION}\` process ran under the platform's Zerops token), set \`${STORAGE_PROVENANCE_KEY}\` on \`${input.deployService}\` to \`${input.bucketId}\`, not sensitive; otherwise delete the bucket and deploy again`
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
export interface ExposureFinding {
|
|
34
46
|
readonly hostname: string
|
|
35
47
|
readonly reason: string
|
package/src/manifest.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type ZeropsRuntimePackage,
|
|
20
20
|
} from './execution'
|
|
21
21
|
import { assertAuthoredManagedDocument, assertAuthoredManagedInputs } from './managed-env'
|
|
22
|
-
import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
|
|
22
|
+
import { allowedReferenceHosts, foreignReferenceHost, platformOnlyServiceReference } from './service-references'
|
|
23
23
|
import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
|
|
24
24
|
|
|
25
25
|
export const FABRIKA_MANIFEST_VERSION = 3
|
|
@@ -32,11 +32,13 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/
|
|
|
32
32
|
*
|
|
33
33
|
* Restated here rather than imported from `./namespace` — that module imports THIS one, and the
|
|
34
34
|
* literal is part of the manifest's parse contract, so it belongs on the parsing side. The reasoning
|
|
35
|
-
* for the database path and the TLS mode lives with `ZEROPS_SHARED_POSTGRES_CONNECTION_STRING`;
|
|
36
|
-
* asserts the two never diverge.
|
|
35
|
+
* for the port, the database path and the TLS mode lives with `ZEROPS_SHARED_POSTGRES_CONNECTION_STRING`;
|
|
36
|
+
* a test asserts the two never diverge.
|
|
37
37
|
*/
|
|
38
38
|
const SHARED_POSTGRES_CONNECTION_STRING: ZeropsSharedPostgresBinding['connectionString'] =
|
|
39
|
-
'${
|
|
39
|
+
'${postgres_connectionString}/${postgres_dbName}?sslmode=disable'
|
|
40
|
+
/** The form an artifact built before the binding moved to the direct port carries: still decoded, as the current form. */
|
|
41
|
+
const LEGACY_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
|
|
40
42
|
|
|
41
43
|
export interface FabrikaImportDocument {
|
|
42
44
|
readonly project?: { readonly [key: string]: JsonValue }
|
|
@@ -122,7 +124,8 @@ const manifestProxySchema = z.object({
|
|
|
122
124
|
const namespaceResourcesSchema = z.array(z.object({
|
|
123
125
|
resourceKey: z.literal('service:postgres'),
|
|
124
126
|
hostname: z.literal('postgres'),
|
|
125
|
-
connectionString: z.literal(SHARED_POSTGRES_CONNECTION_STRING),
|
|
127
|
+
connectionString: z.union([z.literal(SHARED_POSTGRES_CONNECTION_STRING), z.literal(LEGACY_SHARED_POSTGRES_CONNECTION_STRING)])
|
|
128
|
+
.transform((): ZeropsSharedPostgresBinding['connectionString'] => SHARED_POSTGRES_CONNECTION_STRING),
|
|
126
129
|
})).max(1)
|
|
127
130
|
|
|
128
131
|
const manifestAppSchema = z.object({
|
|
@@ -245,27 +248,34 @@ const importServiceSchema = z.intersection(
|
|
|
245
248
|
const importProjectSchema = z.intersection(z.record(manifestJsonSchema), z.object({ envIsolation: z.literal(ENV_ISOLATION) }))
|
|
246
249
|
.superRefine(forbidFields(['envVariables'], 'invalid fabrika manifest: Zerops import contains a forbidden project environment field'))
|
|
247
250
|
|
|
248
|
-
/** The first
|
|
249
|
-
const
|
|
251
|
+
/** The first match of `find` in any string leaf OR key of `value`, however deeply nested. */
|
|
252
|
+
const firstInValue = (value: unknown, find: (text: string) => string | null): string | null => {
|
|
250
253
|
if (typeof value === 'string') {
|
|
251
|
-
return
|
|
254
|
+
return find(value)
|
|
252
255
|
}
|
|
253
256
|
if (Array.isArray(value)) {
|
|
254
257
|
for (const entry of value) {
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
258
|
+
const found = firstInValue(entry, find)
|
|
259
|
+
if (found !== null) return found
|
|
257
260
|
}
|
|
258
261
|
return null
|
|
259
262
|
}
|
|
260
263
|
if (typeof value === 'object' && value !== null) {
|
|
261
264
|
for (const [key, entry] of Object.entries(value)) {
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
265
|
+
const found = find(key) ?? firstInValue(entry, find)
|
|
266
|
+
if (found !== null) return found
|
|
264
267
|
}
|
|
265
268
|
}
|
|
266
269
|
return null
|
|
267
270
|
}
|
|
268
271
|
|
|
272
|
+
/** The first foreign host referenced by any string leaf OR key of `value`, however deeply nested. */
|
|
273
|
+
const foreignReferenceInValue = (value: unknown, allowed: ReadonlySet<string>): string | null =>
|
|
274
|
+
firstInValue(value, (text) => foreignReferenceHost(text, allowed))
|
|
275
|
+
|
|
276
|
+
const platformOnlyReferenceMessage = (where: string, reference: string): string =>
|
|
277
|
+
`invalid fabrika manifest: ${where} references \`${reference}\`, the PostgreSQL superuser's password, which only the platform uses (ADR-0017)`
|
|
278
|
+
|
|
269
279
|
const importDocumentSchema = z.object({ project: importProjectSchema.optional(), services: z.array(importServiceSchema).min(1) })
|
|
270
280
|
.strict()
|
|
271
281
|
.superRefine(({ services }, ctx) => {
|
|
@@ -288,6 +298,11 @@ const importDocumentSchema = z.object({ project: importProjectSchema.optional(),
|
|
|
288
298
|
})
|
|
289
299
|
return
|
|
290
300
|
}
|
|
301
|
+
const platformOnly = firstInValue(service, platformOnlyServiceReference)
|
|
302
|
+
if (platformOnly !== null) {
|
|
303
|
+
ctx.addIssue({ code: 'custom', message: platformOnlyReferenceMessage(`Zerops service \`${service.hostname}\``, platformOnly) })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
291
306
|
}
|
|
292
307
|
})
|
|
293
308
|
|
|
@@ -386,8 +401,18 @@ const isDescriptorSetup = (value: JsonValue, setup: string): value is { readonly
|
|
|
386
401
|
typeof value === 'object' && value !== null && !Array.isArray(value) && value['setup'] === setup
|
|
387
402
|
|
|
388
403
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
404
|
+
* `FABRIKA_APP_DATABASE_URL`, the provisioning credential of the application's role family (ADR-0017), as `${host_…}`
|
|
405
|
+
* references: the service's superuser, because its own user `db` can neither create roles nor create in the database
|
|
406
|
+
* (measured 2026-10-15). The direct port, because pgBouncer on `portTls` admits only the service's own users and refuses
|
|
407
|
+
* the roles the migration creates, which reuse this URL's host, port and options. `disable`, because `ha` offers no TLS
|
|
408
|
+
* on that port and `Bun.SQL`'s `prefer` does not fall back to plaintext; the port stays inside the project's network.
|
|
409
|
+
*/
|
|
410
|
+
const zeropsProvisioningDatabaseUrl = (host: string): string =>
|
|
411
|
+
`postgresql://\${${host}_superUser}:\${${host}_superUserPassword}@\${${host}_hostname}:\${${host}_port}/\${${host}_dbName}?sslmode=disable`
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The PostgreSQL service `FABRIKA_APP_DATABASE_URL` is generated for: the namespace's shared one, or the application's
|
|
415
|
+
* one PostgreSQL service. Two candidates would make the generated migration pick one.
|
|
391
416
|
*/
|
|
392
417
|
const applicationDatabaseUrl = (
|
|
393
418
|
services: FabrikaManifestImportDocument['services'],
|
|
@@ -398,9 +423,8 @@ const applicationDatabaseUrl = (
|
|
|
398
423
|
if (own.length + (shared === undefined ? 0 : 1) > 1) {
|
|
399
424
|
throw new Error('invalid fabrika manifest: a contract application has one PostgreSQL database; it declares more than one')
|
|
400
425
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
return host === undefined ? null : `\${${host}_connectionTlsString}/\${${host}_dbName}?sslmode=require`
|
|
426
|
+
const host = shared?.hostname ?? own[0]
|
|
427
|
+
return host === undefined ? null : zeropsProvisioningDatabaseUrl(host)
|
|
404
428
|
}
|
|
405
429
|
|
|
406
430
|
/** The hostname the `FABRIKA_APP_STORAGE_*` references name: the application's one object-storage service. */
|
|
@@ -524,13 +548,13 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
524
548
|
}
|
|
525
549
|
// Only the selected setup and what it extends reach this app's service, so a sibling setup for another
|
|
526
550
|
// placement may reference services this app does not own.
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
allowedReferenceHosts(serviceHostnames),
|
|
530
|
-
)
|
|
551
|
+
const executed = executionZeropsDocument(parsedDescriptor, selectedSetup)
|
|
552
|
+
const foreignInDescriptor = foreignReferenceInValue(executed, allowedReferenceHosts(serviceHostnames))
|
|
531
553
|
if (foreignInDescriptor !== null) {
|
|
532
554
|
throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
|
|
533
555
|
}
|
|
556
|
+
const platformOnlyInDescriptor = firstInValue(executed, platformOnlyServiceReference)
|
|
557
|
+
if (platformOnlyInDescriptor !== null) throw new Error(platformOnlyReferenceMessage('zerops.yaml', platformOnlyInDescriptor))
|
|
534
558
|
if (contract === undefined && (proxy !== undefined || schema?.contract === true)) {
|
|
535
559
|
// An endpoint table without the contract would deploy a contract application with an authored run section.
|
|
536
560
|
throw new Error('invalid fabrika manifest: a contract application carries its contract (`target.contract`), which locks its setup down')
|
|
@@ -10,21 +10,21 @@ export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
|
|
|
10
10
|
export const ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED,
|
|
14
|
-
* defaulted. Every part settled against a live account, none of it safe to infer:
|
|
13
|
+
* How an app reaches the namespace-owned PostgreSQL service as its own user `db` — port, database and TLS mode NAMED,
|
|
14
|
+
* not defaulted. Every part settled against a live account, none of it safe to infer:
|
|
15
15
|
*
|
|
16
|
-
* • `${
|
|
17
|
-
*
|
|
18
|
-
* • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
16
|
+
* • `${x_connectionString}` is `postgresql://${user}:${password}@${hostname}:${port}` — no database path. With
|
|
17
|
+
* none, the libpq/postgres.js convention falls back to the USER name.
|
|
18
|
+
* • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname (checked on a
|
|
19
|
+
* service named `wu2db`). `/${x_dbName}` says it out loud rather than relying on that fallback.
|
|
20
|
+
* • THE DIRECT PORT, NOT `portTls`. `portTls` (6432) is pgBouncer with a static user list: only `db`, `postgres` and
|
|
21
|
+
* `zps` log in there, and a role fabrika creates is refused (`08P01`, measured 2026-10-15). A contract
|
|
22
|
+
* application's runtime role reuses its database URL's host and port, so only the direct port serves it.
|
|
23
|
+
* • `sslmode=disable`, the one mode that connects on both service types: `postgresql:ha@18` answers `require` on the
|
|
24
|
+
* direct port with "Server does not support SSL", and `Bun.SQL`'s `prefer` waits out its 30 s connection timeout
|
|
25
|
+
* against a server without TLS instead of falling back. The direct port stays inside the project's network.
|
|
26
|
+
*
|
|
27
|
+
* A contract application does not connect with this: its `FABRIKA_APP_DATABASE_URL` names the service's superuser,
|
|
28
|
+
* the provisioning credential of its role family (ADR-0017, `zeropsProvisioningDatabaseUrl` in `../manifest.ts`).
|
|
29
29
|
*/
|
|
30
|
-
export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${
|
|
30
|
+
export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionString}/${postgres_dbName}?sslmode=disable'
|
package/src/provider.ts
CHANGED
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
isObjectStorageType,
|
|
46
46
|
parseStorageProvenance,
|
|
47
47
|
STORAGE_PROVENANCE_KEY,
|
|
48
|
+
type StorageProvenanceMarker,
|
|
49
|
+
unprovenBucketReason,
|
|
48
50
|
} from './exposure'
|
|
49
51
|
import {
|
|
50
52
|
assertAuthoredManagedDocument,
|
|
@@ -66,7 +68,7 @@ import {
|
|
|
66
68
|
verifyZeropsArtifactSourceDescriptor,
|
|
67
69
|
} from './manifest'
|
|
68
70
|
import { buildZeropsPlan, resolveDeployHostname, type ZeropsStep } from './plan'
|
|
69
|
-
import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
|
|
71
|
+
import { allowedReferenceHosts, assertNoPlatformOnlyReference, foreignReferenceHost } from './service-references'
|
|
70
72
|
import { isZeropsUploadDestination } from './source'
|
|
71
73
|
import type { ZeropsRunState, ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
|
|
72
74
|
import { deleteVersion } from './version-attempt'
|
|
@@ -236,13 +238,16 @@ export function interpolateManifest(
|
|
|
236
238
|
if (foreign !== null) {
|
|
237
239
|
throw new Error(`zerops: manifest variable \`${name}\` references service \`${foreign}\`, which this app does not own`)
|
|
238
240
|
}
|
|
241
|
+
assertNoPlatformOnlyReference(value)
|
|
239
242
|
return value
|
|
240
243
|
})
|
|
244
|
+
if (substituted === text) return substituted
|
|
241
245
|
// Adjacent values can spell a reference neither of them carries alone.
|
|
242
|
-
const foreign =
|
|
246
|
+
const foreign = foreignReferenceHost(substituted, hosts)
|
|
243
247
|
if (foreign !== null) {
|
|
244
248
|
throw new Error(`zerops: a substituted value references service \`${foreign}\`, which this app does not own`)
|
|
245
249
|
}
|
|
250
|
+
assertNoPlatformOnlyReference(substituted)
|
|
246
251
|
return substituted
|
|
247
252
|
}
|
|
248
253
|
const services = document.services.map((service) => substituteRecord(service, resolve))
|
|
@@ -450,6 +455,44 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
|
|
|
450
455
|
}
|
|
451
456
|
}
|
|
452
457
|
|
|
458
|
+
const declaredBuckets = (artifact: FabrikaManifest): string[] =>
|
|
459
|
+
artifact.target.importDocument.services.filter((service) => isObjectStorageType(service.type)).map((service) => service.hostname)
|
|
460
|
+
|
|
461
|
+
interface CreatedBucket {
|
|
462
|
+
readonly hostname: string
|
|
463
|
+
readonly id: string
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The declared buckets this import reports creating. */
|
|
467
|
+
const createdBucketsOf = (artifact: FabrikaManifest, created: ReadonlyMap<string, string>): CreatedBucket[] =>
|
|
468
|
+
declaredBuckets(artifact).flatMap((hostname) => {
|
|
469
|
+
const id = created.get(hostname)
|
|
470
|
+
return id === undefined ? [] : [{ hostname, id }]
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Record this import's buckets in the provenance marker before any other step can fail: the import's answer is the only
|
|
475
|
+
* evidence of creation, since a re-apply reports nothing for a service that exists. Merged here, never narrowed;
|
|
476
|
+
* `write-service-env` sets the verified set. A sensitive marker of an earlier release cannot be merged and is replaced.
|
|
477
|
+
*/
|
|
478
|
+
const recordCreatedBuckets = async (env: StepEnv, buckets: readonly CreatedBucket[], signal: AbortSignal): Promise<void> => {
|
|
479
|
+
const { zerops, log, run } = env
|
|
480
|
+
const serviceId = run.target.serviceId
|
|
481
|
+
try {
|
|
482
|
+
const records = await zerops.api.listServiceEnv({ serviceId, signal })
|
|
483
|
+
const recorded = parseStorageProvenance(readableContent(records.find((record) => record.key === STORAGE_PROVENANCE_KEY)))
|
|
484
|
+
if (buckets.every((bucket) => recorded.has(bucket.id))) return
|
|
485
|
+
const value = formatStorageProvenance([...recorded, ...buckets.map((bucket) => bucket.id)])
|
|
486
|
+
await zerops.api.putServiceEnv({ serviceId, key: STORAGE_PROVENANCE_KEY, value, sensitive: false, signal })
|
|
487
|
+
log(` ${STORAGE_PROVENANCE_KEY}: recorded the bucket(s) this import created`)
|
|
488
|
+
} catch (error) {
|
|
489
|
+
// The run log is then the one record of the evidence; the refusal a later run raises names the remedy.
|
|
490
|
+
const named = buckets.map((bucket) => `\`${bucket.hostname}\` (${bucket.id})`).join(', ')
|
|
491
|
+
log(` [warn] this import created bucket(s) ${named}, but ${STORAGE_PROVENANCE_KEY} on ${serviceId} could not record them`)
|
|
492
|
+
throw error
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
453
496
|
/**
|
|
454
497
|
* The exposure read-back (ADR-0043): before a version can activate, every declared service must read
|
|
455
498
|
* `subdomainAccess: false`, and every object-storage service must be one this import created or the provenance
|
|
@@ -463,10 +506,14 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
|
|
|
463
506
|
return
|
|
464
507
|
}
|
|
465
508
|
const created = env.attempt.created ?? new Map<string, string>()
|
|
466
|
-
const buckets = artifact
|
|
509
|
+
const buckets = declaredBuckets(artifact)
|
|
467
510
|
const records = buckets.length === 0 ? [] : await zerops.api.listServiceEnv({ serviceId: target.serviceId, signal })
|
|
468
|
-
const marker =
|
|
469
|
-
const
|
|
511
|
+
const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
|
|
512
|
+
const markerState: StorageProvenanceMarker = marker === undefined ? 'absent' : marker.sensitive ? 'sensitive' : 'readable'
|
|
513
|
+
if (markerState === 'sensitive') {
|
|
514
|
+
log(` [warn] ${STORAGE_PROVENANCE_KEY} was written sensitive by an earlier release; it names no bucket until it is rewritten readable`)
|
|
515
|
+
}
|
|
516
|
+
const known = new Set([...parseStorageProvenance(readableContent(marker)), ...(target.createdBuckets ?? [])])
|
|
470
517
|
const findings: ExposureFinding[] = []
|
|
471
518
|
const bucketIds: string[] = []
|
|
472
519
|
for (const hostname of manifestServiceHostnames(artifact)) {
|
|
@@ -488,7 +535,10 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
|
|
|
488
535
|
if (created.get(hostname) === service.id || known.has(service.id)) {
|
|
489
536
|
bucketIds.push(service.id)
|
|
490
537
|
} else {
|
|
491
|
-
findings.push({
|
|
538
|
+
findings.push({
|
|
539
|
+
hostname,
|
|
540
|
+
reason: unprovenBucketReason({ bucketId: service.id, deployService: artifact.target.deployService, marker: markerState }),
|
|
541
|
+
})
|
|
492
542
|
}
|
|
493
543
|
}
|
|
494
544
|
if (findings.length > 0) {
|
|
@@ -612,17 +662,27 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
612
662
|
const result = await zerops.api.importServices({ projectId: target.projectId, yaml: compiled.yaml, signal })
|
|
613
663
|
log(` imported: ${result.services.map((service) => `${service.name} (${service.id})`).join(', ') || '(none reported)'}`)
|
|
614
664
|
attempt.created = createdServices(result)
|
|
665
|
+
const createdBuckets = createdBucketsOf(artifact, attempt.created)
|
|
666
|
+
// A cancel may not separate a created bucket from its evidence; only a lost lease stops the step then.
|
|
667
|
+
const settling = createdBuckets.length === 0 ? signal : followLeaseLoss(signal)
|
|
615
668
|
// An import ANSWERS before its services exist. Triggering a pipeline on a service Zerops is
|
|
616
669
|
// still creating answers 400 `projectImportInvalidParameter`, which is what the first deploy
|
|
617
670
|
// into a fresh namespace hit; a re-apply onto services that already exist reports none.
|
|
618
671
|
const pending = result.services.flatMap((service) => service.processes.map((process) => ({ service: service.name, id: process.id })))
|
|
619
672
|
for (const process of pending) {
|
|
620
|
-
assertRunning(
|
|
621
|
-
await waitForProcess({
|
|
673
|
+
assertRunning(settling)
|
|
674
|
+
await waitForProcess({
|
|
675
|
+
api: zerops.api,
|
|
676
|
+
processId: process.id,
|
|
677
|
+
sleep: zerops.sleep,
|
|
678
|
+
signal: settling,
|
|
679
|
+
label: `the ${process.service} import`,
|
|
680
|
+
})
|
|
622
681
|
}
|
|
623
682
|
if (pending.length > 0) {
|
|
624
683
|
log(` awaited ${pending.length} import process(es)`)
|
|
625
684
|
}
|
|
685
|
+
if (createdBuckets.length > 0) await recordCreatedBuckets(env, createdBuckets, settling)
|
|
626
686
|
return
|
|
627
687
|
}
|
|
628
688
|
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
* `${<host>_<KEY>}` — Zerops resolves it across services regardless of `envIsolation`, so the host is the boundary.
|
|
3
3
|
* `${RUNTIME_<host>_<KEY>}`, the build-time bridge, counts as a reference to that host until measured otherwise.
|
|
4
4
|
*/
|
|
5
|
-
const SERVICE_REFERENCE = /\$\{(?:RUNTIME_)?([a-z0-9]{1,25})_[A-Za-z0-9_]
|
|
5
|
+
const SERVICE_REFERENCE = /\$\{(?:RUNTIME_)?([a-z0-9]{1,25})_([A-Za-z0-9_]+)\}/g
|
|
6
6
|
|
|
7
7
|
/** Namespace services every app in it may reference; the proxy is deliberately not one of them. */
|
|
8
8
|
const SHARED_NAMESPACE_HOSTNAMES: readonly string[] = ['postgres']
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Service variables only the platform references: a PostgreSQL service's superuser is the provisioning credential of an
|
|
12
|
+
* application's database roles (ADR-0017), and on Zerops it shares the container's environment with application code.
|
|
13
|
+
*/
|
|
14
|
+
const PLATFORM_ONLY_SERVICE_KEYS: ReadonlySet<string> = new Set(['superuserpassword'])
|
|
15
|
+
|
|
10
16
|
/** Hosts named by cross-service references in `text`, deduplicated, in order of first appearance. */
|
|
11
17
|
export function serviceReferenceHosts(text: string): string[] {
|
|
12
18
|
const hosts: string[] = []
|
|
@@ -26,3 +32,23 @@ export function allowedReferenceHosts(ownHostnames: readonly string[]): Readonly
|
|
|
26
32
|
export function foreignReferenceHost(text: string, allowed: ReadonlySet<string>): string | null {
|
|
27
33
|
return serviceReferenceHosts(text).find((host) => !allowed.has(host)) ?? null
|
|
28
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The first reference in `text` to a service variable only the platform may reference, or null. The key is compared
|
|
38
|
+
* without case, because whether Zerops resolves one case-insensitively is not measured.
|
|
39
|
+
*/
|
|
40
|
+
export function platformOnlyServiceReference(text: string): string | null {
|
|
41
|
+
for (const match of text.matchAll(SERVICE_REFERENCE)) {
|
|
42
|
+
const key = match[2]
|
|
43
|
+
if (key !== undefined && PLATFORM_ONLY_SERVICE_KEYS.has(key.toLowerCase())) return match[0]
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Refuse an application value that references a platform-only service variable, naming only the reference. */
|
|
49
|
+
export function assertNoPlatformOnlyReference(text: string): void {
|
|
50
|
+
const reference = platformOnlyServiceReference(text)
|
|
51
|
+
if (reference !== null) {
|
|
52
|
+
throw new Error(`zerops: application configuration references \`${reference}\`, the PostgreSQL superuser's password, which only the platform uses`)
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -92,13 +92,14 @@ export interface ZeropsProxySpec extends Pick<ContractProxyApp, 'endpoints' | 'r
|
|
|
92
92
|
/**
|
|
93
93
|
* A typed reference to the namespace-owned PostgreSQL service; it never contains its credential.
|
|
94
94
|
*
|
|
95
|
-
* The reference names its DATABASE and its TLS MODE rather than inheriting
|
|
96
|
-
* `ZEROPS_SHARED_POSTGRES_CONNECTION_STRING` in `./namespace.ts` for what the account proved about
|
|
95
|
+
* The reference names its PORT, its DATABASE and its TLS MODE rather than inheriting any from the driver — see
|
|
96
|
+
* `ZEROPS_SHARED_POSTGRES_CONNECTION_STRING` in `./namespace.ts` for what the account proved about each. It is the
|
|
97
|
+
* service user's; a contract application's `FABRIKA_APP_DATABASE_URL` is generated from `hostname` instead.
|
|
97
98
|
*/
|
|
98
99
|
export interface ZeropsSharedPostgresBinding {
|
|
99
100
|
readonly resourceKey: 'service:postgres'
|
|
100
101
|
readonly hostname: 'postgres'
|
|
101
|
-
readonly connectionString: '${
|
|
102
|
+
readonly connectionString: '${postgres_connectionString}/${postgres_dbName}?sslmode=disable'
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
export type ZeropsNamespaceResourceRequirement = ZeropsSharedPostgresBinding
|