@fabrika/provider-zerops 0.3.2 → 0.4.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 +7 -6
- package/src/api/client.ts +11 -2
- package/src/api/types.ts +13 -0
- package/src/cli.ts +21 -4
- package/src/collaborators.ts +4 -0
- package/src/control.ts +37 -8
- package/src/execution.ts +371 -0
- package/src/exposure.ts +95 -0
- package/src/installation/deploy-options.ts +12 -1
- package/src/installation/deploy.ts +42 -18
- package/src/installation/generated/platform-console-schema.ts +4 -0
- package/src/installation/generated/platform-proxy-manifest.ts +533 -33
- package/src/installation/index.ts +11 -0
- package/src/installation/install-environment.ts +1 -2
- package/src/installation/machine-key.ts +3 -4
- package/src/installation/manifest.ts +1 -1
- package/src/installation/mcp.ts +102 -0
- package/src/installation/placement-options.ts +2 -1
- package/src/installation/proxy-manifest.ts +124 -23
- package/src/installation/secrets.ts +2 -2
- package/src/installation/templates/README.md +20 -11
- package/src/installation/templates/platform.yml +2 -0
- package/src/installation/topology/setups.ts +5 -5
- package/src/managed-env.ts +94 -6
- package/src/manifest.ts +248 -14
- package/src/namespace/codec.ts +23 -12
- package/src/namespace/errors.ts +1 -1
- package/src/namespace/manifest.ts +25 -6
- package/src/namespace/provisioning.ts +10 -29
- package/src/namespace/proxy-attempt.ts +13 -2
- package/src/namespace/proxy-secrets.ts +21 -0
- package/src/namespace/update.ts +212 -0
- package/src/namespace/validation.ts +43 -0
- package/src/plan.ts +6 -0
- package/src/provider.ts +159 -19
- package/src/types.ts +14 -3
package/src/manifest.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { appSchemaSchema } from '@fabrika/auth'
|
|
2
|
-
import type
|
|
3
|
-
import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
|
|
4
|
-
import {
|
|
2
|
+
import { type AppSchema, isDigest } from '@fabrika/auth-core'
|
|
3
|
+
import type { JsonValue, ProviderCodec, ProviderEnvironment } from '@fabrika/provider-contract'
|
|
4
|
+
import { proxyEndpointRowSchema } from '@fabrika/proxy-core/manifest'
|
|
5
5
|
import { z } from 'zod'
|
|
6
6
|
import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
|
|
7
7
|
import { assertOwnRunWhenExtending, executionZeropsDocument, parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
|
|
8
|
+
import {
|
|
9
|
+
assertScanMemory,
|
|
10
|
+
CONTRACT_DIGEST_PATTERN,
|
|
11
|
+
LOCKED_APP_ID_PATTERN,
|
|
12
|
+
lockedAuthoredBuild,
|
|
13
|
+
ZEROPS_RUNTIME_PACKAGES,
|
|
14
|
+
type ZeropsContractRun,
|
|
15
|
+
zeropsContractRun,
|
|
16
|
+
type ZeropsLockedExecution,
|
|
17
|
+
type ZeropsRuntimePackage,
|
|
18
|
+
} from './execution'
|
|
8
19
|
import { assertAuthoredManagedDocument, assertAuthoredManagedInputs } from './managed-env'
|
|
9
20
|
import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
|
|
10
21
|
import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
|
|
@@ -57,6 +68,9 @@ export interface FabrikaManifest {
|
|
|
57
68
|
zeropsSetup?: string
|
|
58
69
|
proxy?: ZeropsProxySpec
|
|
59
70
|
namespaceResources?: ZeropsNamespaceResourceRequirement[]
|
|
71
|
+
/** Present for a contract application, whose run section and server build the platform generates. */
|
|
72
|
+
contract?: ZeropsContractRun
|
|
73
|
+
runtimePackages?: ZeropsRuntimePackage[]
|
|
60
74
|
}
|
|
61
75
|
}
|
|
62
76
|
|
|
@@ -95,11 +109,14 @@ const jsonValue = (value: unknown): JsonValue => {
|
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
const nonEmptyStringsSchema = z.array(z.string().min(1))
|
|
98
|
-
//
|
|
99
|
-
const
|
|
100
|
-
.
|
|
101
|
-
.
|
|
102
|
-
|
|
112
|
+
// An application is protected by its contract's endpoint table; a `gates` key is refused, never ignored.
|
|
113
|
+
const manifestProxySchema = z.object({
|
|
114
|
+
upstream: z.string().min(1),
|
|
115
|
+
endpoints: z.array(proxyEndpointRowSchema),
|
|
116
|
+
reservedPrefixes: z.array(z.string()),
|
|
117
|
+
snapshot: z.string().refine(isDigest),
|
|
118
|
+
gates: z.undefined(),
|
|
119
|
+
}).transform(({ gates: _gates, ...proxy }) => proxy) satisfies z.ZodType<ZeropsProxySpec, z.ZodTypeDef, unknown>
|
|
103
120
|
const namespaceResourcesSchema = z.array(z.object({
|
|
104
121
|
resourceKey: z.literal('service:postgres'),
|
|
105
122
|
hostname: z.literal('postgres'),
|
|
@@ -126,6 +143,91 @@ const forbidFields = (fields: readonly string[], message: string) => (record: Re
|
|
|
126
143
|
if (Object.hasOwn(record, field)) ctx.addIssue({ code: 'custom', path: [field], message })
|
|
127
144
|
}
|
|
128
145
|
}
|
|
146
|
+
|
|
147
|
+
interface RefusedServiceField {
|
|
148
|
+
readonly field: string
|
|
149
|
+
readonly accepts: (value: JsonValue) => boolean
|
|
150
|
+
readonly reason: string
|
|
151
|
+
readonly allowedForm: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const refusedOutright = (): boolean => false
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Import fields an APPLICATION service may not set, with no approval path (ADR-0043). The installation and
|
|
158
|
+
* the namespace proxy compile their own documents and never pass through this manifest, so they keep them.
|
|
159
|
+
*/
|
|
160
|
+
const REFUSED_APPLICATION_SERVICE_FIELDS: readonly RefusedServiceField[] = [
|
|
161
|
+
{
|
|
162
|
+
field: 'objectStoragePolicy',
|
|
163
|
+
accepts: (value) => value === 'private',
|
|
164
|
+
reason: 'a bucket that is not private is read past the namespace proxy',
|
|
165
|
+
allowedForm: 'omit it or set `private`',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
field: 'objectStorageRawPolicy',
|
|
169
|
+
accepts: refusedOutright,
|
|
170
|
+
reason: 'a raw bucket policy cannot be checked to be private',
|
|
171
|
+
allowedForm: 'omit it',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
field: 'enableSubdomainAccess',
|
|
175
|
+
accepts: (value) => value === false,
|
|
176
|
+
reason: 'a `.zerops.app` subdomain reaches the service past the namespace proxy',
|
|
177
|
+
allowedForm: 'omit it or set `false`',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
field: 'buildFromGit',
|
|
181
|
+
accepts: refusedOutright,
|
|
182
|
+
reason: "the platform builds the run's recorded commit, never a repository the import names",
|
|
183
|
+
allowedForm: 'omit it',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
field: 'zeropsYaml',
|
|
187
|
+
accepts: refusedOutright,
|
|
188
|
+
reason: "the build descriptor is the repository's `zerops.yaml`, never an inline copy",
|
|
189
|
+
allowedForm: 'omit it',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
field: 'mount',
|
|
193
|
+
accepts: refusedOutright,
|
|
194
|
+
reason: 'a shared-storage mount reaches a volume outside the application',
|
|
195
|
+
allowedForm: 'omit it',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
field: 'startWithoutCode',
|
|
199
|
+
accepts: refusedOutright,
|
|
200
|
+
reason: 'an import that carries it activates an empty version, replacing the running code before any read-back',
|
|
201
|
+
allowedForm: 'omit it; the platform provisions a new service without code itself',
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
field: 'enableCdn',
|
|
205
|
+
accepts: (value) => value === false,
|
|
206
|
+
reason: 'a CDN serves the service past the namespace proxy',
|
|
207
|
+
allowedForm: 'omit it or set `false`',
|
|
208
|
+
},
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
const refuseApplicationExposure = (service: { readonly hostname: string } & Record<string, JsonValue>, ctx: z.RefinementCtx): void => {
|
|
212
|
+
for (const { field, accepts, reason, allowedForm } of REFUSED_APPLICATION_SERVICE_FIELDS) {
|
|
213
|
+
const value = service[field]
|
|
214
|
+
if (value === undefined || accepts(value)) continue
|
|
215
|
+
ctx.addIssue({
|
|
216
|
+
code: 'custom',
|
|
217
|
+
path: [field],
|
|
218
|
+
message: `invalid fabrika manifest: Zerops service \`${service.hostname}\` sets \`${field}\`; ${reason} (${allowedForm})`,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const OBJECT_STORAGE_TYPES: ReadonlySet<string> = new Set(['object-storage', 'objectstorage'])
|
|
224
|
+
|
|
225
|
+
/** Nothing establishes what Zerops does with an omitted policy, so an application bucket always says `private`. */
|
|
226
|
+
const withExplicitPrivateStorage = <Service extends { readonly type: string } & Record<string, JsonValue>>(service: Service): Service =>
|
|
227
|
+
OBJECT_STORAGE_TYPES.has(service.type) && service['objectStoragePolicy'] === undefined
|
|
228
|
+
? { ...service, objectStoragePolicy: 'private' }
|
|
229
|
+
: service
|
|
230
|
+
|
|
129
231
|
const importServiceSchema = z.intersection(
|
|
130
232
|
z.record(manifestJsonSchema),
|
|
131
233
|
z.object({
|
|
@@ -134,7 +236,10 @@ const importServiceSchema = z.intersection(
|
|
|
134
236
|
envIsolation: z.literal(ENV_ISOLATION),
|
|
135
237
|
override: z.literal(OVERRIDE),
|
|
136
238
|
}),
|
|
137
|
-
)
|
|
239
|
+
)
|
|
240
|
+
.superRefine(forbidFields(['envSecrets', 'dotEnvSecrets'], 'invalid fabrika manifest: Zerops import contains a forbidden environment field'))
|
|
241
|
+
.superRefine(refuseApplicationExposure)
|
|
242
|
+
.transform(withExplicitPrivateStorage)
|
|
138
243
|
const importProjectSchema = z.intersection(z.record(manifestJsonSchema), z.object({ envIsolation: z.literal(ENV_ISOLATION) }))
|
|
139
244
|
.superRefine(forbidFields(['envVariables'], 'invalid fabrika manifest: Zerops import contains a forbidden project environment field'))
|
|
140
245
|
|
|
@@ -187,6 +292,21 @@ const importDocumentSchema = z.object({ project: importProjectSchema.optional(),
|
|
|
187
292
|
/** The import document a manifest carries: every service already holds the ADR-0004 invariants. */
|
|
188
293
|
export type FabrikaManifestImportDocument = z.infer<typeof importDocumentSchema>
|
|
189
294
|
|
|
295
|
+
/** A literal path of unreserved characters: it is written into the generated probes unquoted by any shell. */
|
|
296
|
+
const READINESS_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
|
|
297
|
+
const JOB_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
298
|
+
const CRON_SCHEDULE = /^[0-9*/,-]+(?: [0-9*/,-]+){4}$/
|
|
299
|
+
|
|
300
|
+
const contractRunSchema = z.object({
|
|
301
|
+
digest: z.string().regex(CONTRACT_DIGEST_PATTERN),
|
|
302
|
+
readinessPath: z.string().regex(READINESS_PATH),
|
|
303
|
+
jobs: z.array(z.object({ name: z.string().regex(JOB_NAME), schedule: z.string().regex(CRON_SCHEDULE) }).strict()),
|
|
304
|
+
files: z.literal(true).optional(),
|
|
305
|
+
scan: z.literal(true).optional(),
|
|
306
|
+
}).strict()
|
|
307
|
+
|
|
308
|
+
const runtimePackagesSchema = z.array(z.enum(ZEROPS_RUNTIME_PACKAGES)).refine((packages) => new Set(packages).size === packages.length)
|
|
309
|
+
|
|
190
310
|
const manifestTargetSchema = z.object({
|
|
191
311
|
sourceDescriptor: zeropsArtifactSourceDescriptorSchema,
|
|
192
312
|
platform: z.literal('zerops'),
|
|
@@ -195,6 +315,8 @@ const manifestTargetSchema = z.object({
|
|
|
195
315
|
zeropsSetup: z.string().min(1).optional(),
|
|
196
316
|
proxy: manifestProxySchema.optional(),
|
|
197
317
|
namespaceResources: namespaceResourcesSchema.optional(),
|
|
318
|
+
contract: contractRunSchema.optional(),
|
|
319
|
+
runtimePackages: runtimePackagesSchema.optional(),
|
|
198
320
|
})
|
|
199
321
|
|
|
200
322
|
const importDocumentError = (issue: z.ZodIssue, path: readonly (string | number)[]): string => {
|
|
@@ -224,9 +346,15 @@ const targetError = (issue: z.ZodIssue | undefined): string => {
|
|
|
224
346
|
case 'zeropsSetup':
|
|
225
347
|
return 'invalid fabrika manifest zeropsSetup'
|
|
226
348
|
case 'proxy':
|
|
227
|
-
return
|
|
349
|
+
return rest[0] === 'gates'
|
|
350
|
+
? 'invalid fabrika manifest proxy: `gates` is gone; an application is protected by its endpoint table (`contractProxyTable(artifact)`)'
|
|
351
|
+
: 'invalid fabrika manifest proxy declaration'
|
|
228
352
|
case 'namespaceResources':
|
|
229
353
|
return 'invalid fabrika manifest namespace resources'
|
|
354
|
+
case 'contract':
|
|
355
|
+
return 'invalid fabrika manifest contract run'
|
|
356
|
+
case 'runtimePackages':
|
|
357
|
+
return `invalid fabrika manifest runtime packages (allowed: ${ZEROPS_RUNTIME_PACKAGES.join(', ')}, each once)`
|
|
230
358
|
default:
|
|
231
359
|
return 'invalid fabrika manifest Zerops target'
|
|
232
360
|
}
|
|
@@ -252,6 +380,79 @@ export const verifyZeropsArtifactSourceDescriptor = async (descriptor: ZeropsArt
|
|
|
252
380
|
}
|
|
253
381
|
}
|
|
254
382
|
|
|
383
|
+
const isDescriptorSetup = (value: JsonValue, setup: string): value is { readonly [key: string]: JsonValue } =>
|
|
384
|
+
typeof value === 'object' && value !== null && !Array.isArray(value) && value['setup'] === setup
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The `${host_…}` reference `FABRIKA_APP_DATABASE_URL` is generated from: the namespace's shared PostgreSQL, or the
|
|
388
|
+
* application's one PostgreSQL service. Two candidates would make the generated migration pick one.
|
|
389
|
+
*/
|
|
390
|
+
const applicationDatabaseUrl = (
|
|
391
|
+
services: FabrikaManifestImportDocument['services'],
|
|
392
|
+
namespaceResources: readonly ZeropsNamespaceResourceRequirement[] | undefined,
|
|
393
|
+
): string | null => {
|
|
394
|
+
const own = services.filter((service) => service.type.startsWith('postgresql')).map((service) => service.hostname)
|
|
395
|
+
const shared = namespaceResources?.find((resource) => resource.resourceKey === 'service:postgres')
|
|
396
|
+
if (own.length + (shared === undefined ? 0 : 1) > 1) {
|
|
397
|
+
throw new Error('invalid fabrika manifest: a contract application has one PostgreSQL database; it declares more than one')
|
|
398
|
+
}
|
|
399
|
+
if (shared !== undefined) return shared.connectionString
|
|
400
|
+
const host = own[0]
|
|
401
|
+
return host === undefined ? null : `\${${host}_connectionTlsString}/\${${host}_dbName}?sslmode=require`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The hostname the `FABRIKA_APP_STORAGE_*` references name: the application's one object-storage service. */
|
|
405
|
+
const applicationStorageHostname = (services: FabrikaManifestImportDocument['services']): string | null => {
|
|
406
|
+
const own = services.filter((service) => OBJECT_STORAGE_TYPES.has(service.type)).map((service) => service.hostname)
|
|
407
|
+
if (own.length > 1) {
|
|
408
|
+
throw new Error('invalid fabrika manifest: a contract application has one object storage; it declares more than one')
|
|
409
|
+
}
|
|
410
|
+
return own[0] ?? null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** What the platform renders a contract application's execution descriptor from, or null for any other application. */
|
|
414
|
+
export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecution | null => {
|
|
415
|
+
const { contract } = manifest.target
|
|
416
|
+
if (contract === undefined) return null
|
|
417
|
+
return {
|
|
418
|
+
appId: manifest.app.id,
|
|
419
|
+
contract,
|
|
420
|
+
databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
|
|
421
|
+
storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
|
|
422
|
+
runtimePackages: manifest.target.runtimePackages ?? [],
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** What Control lets a provider publish for one environment (`ProviderEnvironment.access`). */
|
|
427
|
+
export type ZeropsAccess = Pick<ProviderEnvironment, 'access'>
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The execution descriptor's lock-down at deploy (ADR-0050): Control's approved contract decides it, not the artifact,
|
|
431
|
+
* so a configuration cannot drop it. Without an approved contract nothing deploys outside a dry run.
|
|
432
|
+
*/
|
|
433
|
+
export function lockedExecutionFor(manifest: FabrikaManifest, granted: ZeropsAccess, dryRun: boolean): ZeropsLockedExecution | null {
|
|
434
|
+
const access = granted.access
|
|
435
|
+
if (access === undefined) {
|
|
436
|
+
if (dryRun) return null
|
|
437
|
+
throw new Error('zerops: Control approved no access for this environment')
|
|
438
|
+
}
|
|
439
|
+
if (!LOCKED_APP_ID_PATTERN.test(manifest.app.id)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
|
|
440
|
+
const contract = zeropsContractRun(access.contract)
|
|
441
|
+
assertScanMemory(manifest.target.importDocument.services, manifest.target.deployService, contract)
|
|
442
|
+
return {
|
|
443
|
+
appId: manifest.app.id,
|
|
444
|
+
contract,
|
|
445
|
+
databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
|
|
446
|
+
storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
|
|
447
|
+
runtimePackages: manifest.target.runtimePackages ?? [],
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The IAM schema a deploy, cancel or reconcile applies: the approved contract's. `undefined` reconciles nothing. */
|
|
452
|
+
export function approvedSchemaOf(granted: ZeropsAccess): AppSchema | undefined {
|
|
453
|
+
return granted.access?.schema
|
|
454
|
+
}
|
|
455
|
+
|
|
255
456
|
export const manifestServiceHostnames = (manifest: FabrikaManifest): string[] =>
|
|
256
457
|
manifest.target.importDocument.services.map((service) => service.hostname)
|
|
257
458
|
|
|
@@ -276,9 +477,11 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
276
477
|
|
|
277
478
|
const target = manifestTargetSchema.safeParse(envelope.data.target)
|
|
278
479
|
if (!target.success) {
|
|
279
|
-
|
|
480
|
+
// A refused `gates` key is the answer to give, whatever else the stale proxy declaration lacks.
|
|
481
|
+
const gates = target.error.issues.find((issue) => issue.path[0] === 'proxy' && issue.path[1] === 'gates')
|
|
482
|
+
throw new Error(targetError(gates ?? target.error.issues[0]))
|
|
280
483
|
}
|
|
281
|
-
const { sourceDescriptor, deployService, importDocument, zeropsSetup, proxy, namespaceResources } = target.data
|
|
484
|
+
const { sourceDescriptor, deployService, importDocument, zeropsSetup, proxy, namespaceResources, contract, runtimePackages } = target.data
|
|
282
485
|
assertAuthoredManagedDocument(importDocument.services)
|
|
283
486
|
if (importDocument.project !== undefined) assertAuthoredManagedDocument(importDocument.project)
|
|
284
487
|
assertAuthoredManagedInputs({ variableNames: vars, secretNames: secrets })
|
|
@@ -288,10 +491,17 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
288
491
|
if (!serviceHostnames.includes(deployService)) {
|
|
289
492
|
throw new Error('invalid fabrika manifest Zerops deploy service')
|
|
290
493
|
}
|
|
494
|
+
const selectedSetup = selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
|
|
495
|
+
if (contract !== undefined) {
|
|
496
|
+
// Refused first: a contract application's setup may not extend another, so its executed setup is the selected one alone.
|
|
497
|
+
const entries = parsedDescriptor.document['zerops']
|
|
498
|
+
const entry = Array.isArray(entries) ? entries.find((candidate) => isDescriptorSetup(candidate, selectedSetup)) : undefined
|
|
499
|
+
if (entry !== undefined) lockedAuthoredBuild(entry, selectedSetup)
|
|
500
|
+
} else {
|
|
501
|
+
assertOwnRunWhenExtending(parsedDescriptor, selectedSetup)
|
|
502
|
+
}
|
|
291
503
|
// Only the selected setup and what it extends reach this app's service, so a sibling setup for another
|
|
292
504
|
// placement may reference services this app does not own.
|
|
293
|
-
const selectedSetup = selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
|
|
294
|
-
assertOwnRunWhenExtending(parsedDescriptor, selectedSetup)
|
|
295
505
|
const foreignInDescriptor = foreignReferenceInValue(
|
|
296
506
|
executionZeropsDocument(parsedDescriptor, selectedSetup),
|
|
297
507
|
allowedReferenceHosts(serviceHostnames),
|
|
@@ -299,6 +509,23 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
299
509
|
if (foreignInDescriptor !== null) {
|
|
300
510
|
throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
|
|
301
511
|
}
|
|
512
|
+
if (contract === undefined && (proxy !== undefined || schema?.contract === true)) {
|
|
513
|
+
// An endpoint table without the contract would deploy a contract application with an authored run section.
|
|
514
|
+
throw new Error('invalid fabrika manifest: a contract application carries its contract (`target.contract`), which locks its setup down')
|
|
515
|
+
}
|
|
516
|
+
if (contract !== undefined && proxy?.snapshot !== contract.digest) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
'invalid fabrika manifest: a contract application is served behind the endpoint table of the same contract (`proxy: { upstream, ...contractProxyTable(artifact) }`)',
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
if (contract !== undefined) {
|
|
522
|
+
if (!LOCKED_APP_ID_PATTERN.test(id)) throw new Error('invalid fabrika manifest: a contract application id must match [a-z0-9][a-z0-9-]*')
|
|
523
|
+
applicationDatabaseUrl(importDocument.services, namespaceResources)
|
|
524
|
+
applicationStorageHostname(importDocument.services)
|
|
525
|
+
assertScanMemory(importDocument.services, deployService, contract)
|
|
526
|
+
} else if (runtimePackages !== undefined) {
|
|
527
|
+
throw new Error('invalid fabrika manifest: runtime packages are generated for a contract application only')
|
|
528
|
+
}
|
|
302
529
|
if (proxy !== undefined) {
|
|
303
530
|
const separator = proxy.upstream.indexOf(':')
|
|
304
531
|
const upstreamHostname = separator === -1 ? proxy.upstream : proxy.upstream.slice(0, separator)
|
|
@@ -326,6 +553,8 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
326
553
|
...(zeropsSetup !== undefined ? { zeropsSetup } : {}),
|
|
327
554
|
...(proxy !== undefined ? { proxy } : {}),
|
|
328
555
|
...(namespaceResources !== undefined ? { namespaceResources } : {}),
|
|
556
|
+
...(contract !== undefined ? { contract } : {}),
|
|
557
|
+
...(runtimePackages !== undefined ? { runtimePackages } : {}),
|
|
329
558
|
},
|
|
330
559
|
}
|
|
331
560
|
}
|
|
@@ -334,6 +563,9 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sou
|
|
|
334
563
|
if (config.target.services === undefined) {
|
|
335
564
|
throw new Error('fabrika app build requires a source Zerops config')
|
|
336
565
|
}
|
|
566
|
+
if (config.target.contract !== undefined && config.target.contract.app !== config.id) {
|
|
567
|
+
throw new Error(`fabrika app build: the contract declares app \`${config.target.contract.app}\`, but the config is app \`${config.id}\``)
|
|
568
|
+
}
|
|
337
569
|
const declaredVars = config.pipeline?.vars ?? []
|
|
338
570
|
const prior: Record<string, string | undefined> = {}
|
|
339
571
|
for (const name of declaredVars) {
|
|
@@ -358,6 +590,8 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sou
|
|
|
358
590
|
zeropsSetup: config.target.zeropsSetup,
|
|
359
591
|
proxy: config.target.proxy,
|
|
360
592
|
namespaceResources: config.target.namespaceResources,
|
|
593
|
+
contract: config.target.contract === undefined ? undefined : zeropsContractRun(config.target.contract),
|
|
594
|
+
runtimePackages: config.target.runtimePackages,
|
|
361
595
|
},
|
|
362
596
|
}
|
|
363
597
|
// The JSON copy is the artifact's own form: authored objects lose inherited fields and undefined keys once, here.
|
package/src/namespace/codec.ts
CHANGED
|
@@ -30,21 +30,21 @@ const proxyDeploymentSchema = z.object({
|
|
|
30
30
|
descriptorSha256: sha256Schema,
|
|
31
31
|
}).strict().superRefine(rejectExplicitUndefined)
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
z.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
z.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
]).transform((value) => {
|
|
33
|
+
const haPostgresSchema = z.object({
|
|
34
|
+
type: z.literal('postgresql:ha@18'),
|
|
35
|
+
profile: z.enum(['oltp-staging', 'oltp-production', 'oltp-enterprise', 'olap-production', 'writeheavy-production']).optional(),
|
|
36
|
+
})
|
|
37
|
+
const singlePostgresSchema = z.object({
|
|
38
|
+
type: z.literal('postgresql:single@18'),
|
|
39
|
+
profile: z.enum(['oltp-hobby', 'oltp-staging', 'oltp-production', 'olap-production', 'writeheavy-production']).optional(),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export const postgresSchema = z.discriminatedUnion('type', [haPostgresSchema, singlePostgresSchema]).transform((value) => {
|
|
43
43
|
if (value.profile === undefined) delete value.profile
|
|
44
44
|
return value
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
const
|
|
47
|
+
const namespaceTargetShape = z.object({
|
|
48
48
|
proxyDeployment: proxyDeploymentSchema.optional(),
|
|
49
49
|
proxySource: namespaceProxySourceSchema,
|
|
50
50
|
proxyBuild: proxyBuildSchema.optional(),
|
|
@@ -61,13 +61,24 @@ const namespaceTargetSchema = z.object({
|
|
|
61
61
|
proxyAppVersionId: z.string().min(1).optional(),
|
|
62
62
|
proxyHosts: z.array(z.object({ host: z.string().min(1), port: z.number().int().positive() })).optional(),
|
|
63
63
|
ready: z.boolean().optional(),
|
|
64
|
-
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const namespaceTargetSchema = namespaceTargetShape.transform((target) => {
|
|
65
67
|
for (const key of Object.keys(target)) {
|
|
66
68
|
if (Reflect.get(target, key) === undefined) Reflect.deleteProperty(target, key)
|
|
67
69
|
}
|
|
68
70
|
return target
|
|
69
71
|
})
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What an update request's payload may name: any target field, each optional, and no field the target
|
|
75
|
+
* does not have — at the top level or inside `postgres`. The target schema STRIPS an unknown key, which
|
|
76
|
+
* would turn a misspelt field into a silent no-op.
|
|
77
|
+
*/
|
|
78
|
+
export const namespaceUpdatePayloadSchema = namespaceTargetShape.partial().extend({
|
|
79
|
+
postgres: z.discriminatedUnion('type', [haPostgresSchema.strict(), singlePostgresSchema.strict()]).optional(),
|
|
80
|
+
}).strict()
|
|
81
|
+
|
|
71
82
|
export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
|
|
72
83
|
version: 1,
|
|
73
84
|
encode: (target) => ({
|
package/src/namespace/errors.ts
CHANGED
|
@@ -39,7 +39,7 @@ const splitApiMessage = (message: string, code: string): { summary: string; deta
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
|
|
42
|
-
export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile'): ProviderNamespaceError => {
|
|
42
|
+
export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile' | 'update'): ProviderNamespaceError => {
|
|
43
43
|
if (cause instanceof ProviderNamespaceError) return cause
|
|
44
44
|
if (cause instanceof ZeropsApiError) {
|
|
45
45
|
const { summary, detail } = splitApiMessage(cause.message, cause.code)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { appEnvironmentId } from '@fabrika/auth-core'
|
|
1
2
|
import { decodeEnvelope, type ProviderNamespaceMember } from '@fabrika/provider-contract'
|
|
2
|
-
import
|
|
3
|
+
import { isPolicyOrigin } from '@fabrika/proxy-core/headers'
|
|
4
|
+
import { contractProxyTable, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
3
5
|
import { assertFabrikaManifestCoordinates, zeropsArtifactCodec } from '../manifest'
|
|
4
6
|
|
|
5
7
|
/** Compile every public member of one deployment namespace into its strict proxy manifest. */
|
|
@@ -8,6 +10,10 @@ export function compileNamespaceProxyManifest(namespaceId: string, members: read
|
|
|
8
10
|
const ids = new Set<string>()
|
|
9
11
|
const hosts = new Set<string>()
|
|
10
12
|
for (const member of members) {
|
|
13
|
+
// Only Control decides what routes a member (ADR-0050): its approved contract's endpoint table. A member without
|
|
14
|
+
// one is not routed, and its artifact is not decoded, so a legacy one cannot stop the others' republish.
|
|
15
|
+
const access = member.access
|
|
16
|
+
if (access === undefined) continue
|
|
11
17
|
const manifest = decodeEnvelope('artifact', 'zerops', member.artifact, zeropsArtifactCodec)
|
|
12
18
|
assertFabrikaManifestCoordinates(manifest.app, { appId: member.appId, env: member.env })
|
|
13
19
|
const proxy = manifest.target.proxy
|
|
@@ -16,13 +22,26 @@ export function compileNamespaceProxyManifest(namespaceId: string, members: read
|
|
|
16
22
|
throw new Error(`Zerops proxy target ${member.appId}/${member.env} requires a public domain`)
|
|
17
23
|
}
|
|
18
24
|
const host = member.domain.toLowerCase()
|
|
19
|
-
|
|
25
|
+
// The proxy mints for the IAM application id of this environment (ADR-0022 rule 6).
|
|
26
|
+
const id = appEnvironmentId(member.appId, member.env)
|
|
27
|
+
if (ids.has(id)) throw new Error(`duplicate proxy app id \`${id}\` in deployment namespace ${namespaceId}`)
|
|
20
28
|
if (hosts.has(host)) throw new Error(`duplicate proxy host \`${host}\` in deployment namespace ${namespaceId}`)
|
|
21
|
-
ids.add(
|
|
29
|
+
ids.add(id)
|
|
22
30
|
hosts.add(host)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
const operationsOrigin = member.operationsOrigin
|
|
32
|
+
if (operationsOrigin !== undefined && !isPolicyOrigin(operationsOrigin, ['https:', 'http:'])) {
|
|
33
|
+
throw new Error(`Zerops proxy target ${member.appId}/${member.env} has an Operations ingest origin that is not an http(s) origin`)
|
|
34
|
+
}
|
|
35
|
+
apps.push({
|
|
36
|
+
id,
|
|
37
|
+
hosts: [host],
|
|
38
|
+
upstream: proxy.upstream,
|
|
39
|
+
// A Zerops public domain is always served over the project's TLS-terminating L7 balancer, so the
|
|
40
|
+
// browser speaks https even though the proxy itself is reached over plain HTTP (ADR-0022).
|
|
41
|
+
scheme: 'https',
|
|
42
|
+
...contractProxyTable(access.contract),
|
|
43
|
+
...(operationsOrigin === undefined ? {} : { operationsOrigin }),
|
|
44
|
+
})
|
|
26
45
|
}
|
|
27
46
|
return { apps }
|
|
28
47
|
}
|
|
@@ -15,9 +15,12 @@ import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED, ZEROPS_SU
|
|
|
15
15
|
import { compileNamespaceProxyManifest } from './manifest'
|
|
16
16
|
import { createZeropsNamespaceOperator } from './operator'
|
|
17
17
|
import { republishProxy } from './proxy-attempt'
|
|
18
|
+
import { namespaceProxySecrets } from './proxy-secrets'
|
|
18
19
|
import { verifyZeropsNamespaceProxySource } from './proxy-source'
|
|
19
20
|
import { compileZeropsNamespaceTopology, projectMarker, PROXY_TYPE } from './topology'
|
|
20
21
|
import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespaceProxySource } from './types'
|
|
22
|
+
import { createZeropsNamespaceUpdates } from './update'
|
|
23
|
+
import { validateService } from './validation'
|
|
21
24
|
|
|
22
25
|
const POLL_INTERVAL_MS = 3000
|
|
23
26
|
|
|
@@ -109,33 +112,6 @@ const uniqueService = (services: ZeropsService[], hostname: string): ZeropsServi
|
|
|
109
112
|
return matches[0] ?? null
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
const validateService = (
|
|
113
|
-
service: ZeropsService,
|
|
114
|
-
projectId: string,
|
|
115
|
-
expectedBase: string,
|
|
116
|
-
adopting: boolean,
|
|
117
|
-
expectedPublic?: boolean,
|
|
118
|
-
expectedProfile?: string,
|
|
119
|
-
): void => {
|
|
120
|
-
if (service.id === '') {
|
|
121
|
-
throw new Error(`Zerops namespace service \`${service.name}\` returned an empty id`)
|
|
122
|
-
}
|
|
123
|
-
if (service.projectId !== undefined && service.projectId !== projectId) {
|
|
124
|
-
throw new Error(`Zerops namespace service \`${service.name}\` belongs to another project`)
|
|
125
|
-
}
|
|
126
|
-
if ((adopting || service.base !== undefined) && service.base !== expectedBase) {
|
|
127
|
-
throw new Error(`Zerops namespace service \`${service.name}\` uses ${service.base ?? 'an unknown type'}, expected ${expectedBase}`)
|
|
128
|
-
}
|
|
129
|
-
if (expectedPublic !== undefined && adopting && service.subdomainAccess !== expectedPublic) {
|
|
130
|
-
throw new Error(`Zerops namespace service \`${service.name}\` has incompatible public access`)
|
|
131
|
-
}
|
|
132
|
-
if (expectedProfile !== undefined && service.autoscalingProfileId !== expectedProfile) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
`Zerops namespace service \`${service.name}\` uses profile ${service.autoscalingProfileId ?? 'unknown'}, expected ${expectedProfile}`,
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
115
|
const resolveServices = async (
|
|
140
116
|
namespace: ProviderDeploymentNamespace,
|
|
141
117
|
target: NormalizedZeropsNamespaceTarget,
|
|
@@ -167,7 +143,9 @@ const resolveServices = async (
|
|
|
167
143
|
if (postgres === null) {
|
|
168
144
|
throw new Error(`Zerops namespace project ${projectId} has no shared PostgreSQL service`)
|
|
169
145
|
}
|
|
170
|
-
validateService(postgres, projectId, target.postgres.type, adopting, undefined,
|
|
146
|
+
validateService(postgres, projectId, target.postgres.type, adopting, undefined, {
|
|
147
|
+
...(target.postgres.profile === undefined ? {} : { declared: target.postgres.profile }),
|
|
148
|
+
})
|
|
171
149
|
}
|
|
172
150
|
if (adopting && namespace.exclusiveAppId !== undefined) {
|
|
173
151
|
const reserved = new Set([
|
|
@@ -237,9 +215,11 @@ const deployProxy = async (
|
|
|
237
215
|
wait: ProviderNamespaceRepublishInput['wait'],
|
|
238
216
|
): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
|
|
239
217
|
let currentNamespace = namespace
|
|
218
|
+
const manifest = compileNamespaceProxyManifest(namespace.id, input.members)
|
|
240
219
|
const result = await republishProxy({
|
|
241
220
|
target,
|
|
242
|
-
manifest
|
|
221
|
+
manifest,
|
|
222
|
+
proxySecrets: namespaceProxySecrets(manifest, input.members),
|
|
243
223
|
...(options.source === undefined ? {} : { source: options.source }),
|
|
244
224
|
api: options.api,
|
|
245
225
|
iamUrl: options.iamUrl,
|
|
@@ -477,6 +457,7 @@ export const createZeropsNamespaceCapabilities = (options: ZeropsNamespaceOption
|
|
|
477
457
|
provision: (input) => mutateNamespace(input, options, 'provision'),
|
|
478
458
|
reconcile: (input) => mutateNamespace(input, options, 'reconcile'),
|
|
479
459
|
republish: (input) => republishNamespace(input, options),
|
|
460
|
+
updates: createZeropsNamespaceUpdates(options),
|
|
480
461
|
operator: createZeropsNamespaceOperator({ proxySource: options.proxySource }),
|
|
481
462
|
}
|
|
482
463
|
}
|
|
@@ -22,6 +22,8 @@ export interface ZeropsProxyRepublishInput {
|
|
|
22
22
|
readonly api: ZeropsProxyApi
|
|
23
23
|
readonly iamUrl: string
|
|
24
24
|
readonly iamKey: string
|
|
25
|
+
/** Proxy service variable → the secret of one contract application (`namespaceProxySecrets`); none without one. */
|
|
26
|
+
readonly proxySecrets?: ReadonlyMap<string, string>
|
|
25
27
|
/** Aborts with `lease_lost` when the namespace lease goes, and with `cancel_requested` when a deploy is cancelled. */
|
|
26
28
|
readonly signal: AbortSignal
|
|
27
29
|
readonly wait: 'started' | 'active'
|
|
@@ -55,8 +57,15 @@ const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string
|
|
|
55
57
|
}
|
|
56
58
|
// Secret read-back is not trustworthy, so overwrite it without inspecting its value.
|
|
57
59
|
await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE, value: input.iamKey, signal })
|
|
60
|
+
for (const [key, value] of input.proxySecrets ?? []) {
|
|
61
|
+
await api.putServiceEnv({ serviceId, key, value, signal })
|
|
62
|
+
}
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
/** One line per secret, sorted, so the attempt's identity changes when any secret does and never carries one. */
|
|
66
|
+
const proxySecretsDigest = async (secrets: ReadonlyMap<string, string>): Promise<string> =>
|
|
67
|
+
digest([...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, value]) => `${key}=${value}`).join('\n'))
|
|
68
|
+
|
|
60
69
|
/**
|
|
61
70
|
* Rebuild the namespace proxy with `manifest` (ADR-0044). The attempt is recorded as `proxyBuild` and never
|
|
62
71
|
* resumed: an inherited attempt not yet `triggered` is deleted, a triggered one is polled to a terminal state.
|
|
@@ -68,8 +77,10 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
|
|
|
68
77
|
if (serviceId === undefined) throw new Error('Zerops namespace has no proxy service id')
|
|
69
78
|
await verifyZeropsNamespaceProxySource(pin)
|
|
70
79
|
const manifest = encodeProxyManifestJson(input.manifest)
|
|
71
|
-
// The attempt's identity covers the IAM configuration too, so
|
|
72
|
-
const
|
|
80
|
+
// The attempt's identity covers the IAM configuration and the proxy secrets too, so rotating any forces a rebuild.
|
|
81
|
+
const proxySecrets = input.proxySecrets ?? new Map<string, string>()
|
|
82
|
+
const secrets = proxySecrets.size === 0 ? '' : `\n${await proxySecretsDigest(proxySecrets)}`
|
|
83
|
+
const manifestSha256 = await digest(`${manifest}\n${input.iamUrl}\n${await digest(input.iamKey)}${secrets}`)
|
|
73
84
|
let target = input.target
|
|
74
85
|
|
|
75
86
|
const save = async (next: ZeropsNamespaceTarget): Promise<void> => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { appEnvironmentId, proxySecretVariable } from '@fabrika/auth-core'
|
|
2
|
+
import type { ProviderNamespaceMember } from '@fabrika/provider-contract'
|
|
3
|
+
import { isApplicationEntry, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The proxy service variables a manifest needs: for each contract application, the secret Caddy sends it
|
|
7
|
+
* (ADR-0022 rule 5), keyed by `proxySecretVariable`. A contract member without a secret is refused, because
|
|
8
|
+
* its application would refuse every request the proxy sends.
|
|
9
|
+
*/
|
|
10
|
+
export function namespaceProxySecrets(manifest: ProxyManifest, members: readonly ProviderNamespaceMember[]): ReadonlyMap<string, string> {
|
|
11
|
+
const secrets = new Map<string, string>()
|
|
12
|
+
for (const app of manifest.apps) {
|
|
13
|
+
if (!isApplicationEntry(app)) continue
|
|
14
|
+
const member = members.find((candidate) => appEnvironmentId(candidate.appId, candidate.env) === app.id)
|
|
15
|
+
if (member?.proxySecret === undefined || member.proxySecret === '') {
|
|
16
|
+
throw new Error(`Zerops proxy target ${app.id} is a contract application, and Control supplied no proxy secret for it`)
|
|
17
|
+
}
|
|
18
|
+
secrets.set(proxySecretVariable(app.id), member.proxySecret)
|
|
19
|
+
}
|
|
20
|
+
return secrets
|
|
21
|
+
}
|