@fabrika/provider-zerops 0.3.3 → 0.4.1

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.
Files changed (36) hide show
  1. package/package.json +7 -6
  2. package/src/api/client.ts +11 -2
  3. package/src/api/types.ts +13 -0
  4. package/src/cli.ts +21 -4
  5. package/src/collaborators.ts +4 -0
  6. package/src/control.ts +37 -8
  7. package/src/execution.ts +371 -0
  8. package/src/exposure.ts +95 -0
  9. package/src/installation/deploy-options.ts +12 -1
  10. package/src/installation/deploy.ts +42 -18
  11. package/src/installation/generated/platform-console-schema.ts +4 -0
  12. package/src/installation/generated/platform-proxy-manifest.ts +533 -33
  13. package/src/installation/index.ts +11 -0
  14. package/src/installation/install-environment.ts +1 -2
  15. package/src/installation/machine-key.ts +3 -4
  16. package/src/installation/manifest.ts +1 -1
  17. package/src/installation/mcp.ts +102 -0
  18. package/src/installation/placement-options.ts +2 -1
  19. package/src/installation/proxy-manifest.ts +124 -23
  20. package/src/installation/secrets.ts +2 -2
  21. package/src/installation/templates/README.md +20 -11
  22. package/src/installation/templates/platform.yml +2 -0
  23. package/src/installation/topology/setups.ts +5 -5
  24. package/src/managed-env.ts +94 -6
  25. package/src/manifest.ts +248 -14
  26. package/src/namespace/codec.ts +23 -12
  27. package/src/namespace/errors.ts +1 -1
  28. package/src/namespace/manifest.ts +25 -6
  29. package/src/namespace/provisioning.ts +10 -29
  30. package/src/namespace/proxy-attempt.ts +13 -2
  31. package/src/namespace/proxy-secrets.ts +21 -0
  32. package/src/namespace/update.ts +212 -0
  33. package/src/namespace/validation.ts +43 -0
  34. package/src/plan.ts +6 -0
  35. package/src/provider.ts +160 -12
  36. 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 { AppSchema } from '@fabrika/auth-core'
3
- import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
4
- import { normalizeGateRule, proxyGateRuleFieldsSchema } from '@fabrika/proxy-core/manifest'
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
- // Unlike the proxy wire contract, the manifest refuses a null service credential.
99
- const manifestGateSchema = proxyGateRuleFieldsSchema
100
- .refine((rule) => rule.kind !== 'service' || rule.credential !== null)
101
- .transform(normalizeGateRule)
102
- const manifestProxySchema = z.object({ upstream: z.string().min(1), gates: z.object({ rules: z.array(manifestGateSchema) }) })
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
- ).superRefine(forbidFields(['envSecrets', 'dotEnvSecrets'], 'invalid fabrika manifest: Zerops import contains a forbidden environment field'))
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 'invalid fabrika manifest proxy declaration'
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
- throw new Error(targetError(target.error.issues[0]))
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.
@@ -30,21 +30,21 @@ const proxyDeploymentSchema = z.object({
30
30
  descriptorSha256: sha256Schema,
31
31
  }).strict().superRefine(rejectExplicitUndefined)
32
32
 
33
- export const postgresSchema = z.discriminatedUnion('type', [
34
- z.object({
35
- type: z.literal('postgresql:ha@18'),
36
- profile: z.enum(['oltp-staging', 'oltp-production', 'oltp-enterprise', 'olap-production', 'writeheavy-production']).optional(),
37
- }),
38
- z.object({
39
- type: z.literal('postgresql:single@18'),
40
- profile: z.enum(['oltp-hobby', 'oltp-staging', 'oltp-production', 'olap-production', 'writeheavy-production']).optional(),
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 namespaceTargetSchema = z.object({
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
- }).transform((target) => {
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) => ({
@@ -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 type { ProxyManifest } from '@fabrika/proxy-core/manifest'
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
- if (ids.has(member.appId)) throw new Error(`duplicate proxy app id \`${member.appId}\` in deployment namespace ${namespaceId}`)
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(member.appId)
29
+ ids.add(id)
22
30
  hosts.add(host)
23
- // A Zerops public domain is always served over the project's TLS-terminating L7 balancer, so the
24
- // browser speaks https even though the proxy itself is reached over plain HTTP (ADR-0022).
25
- apps.push({ id: member.appId, hosts: [host], upstream: proxy.upstream, gates: proxy.gates, scheme: 'https' })
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, target.postgres.profile)
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: compileNamespaceProxyManifest(namespace.id, input.members),
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 a rotated issuer or key forces a rebuild.
72
- const manifestSha256 = await digest(`${manifest}\n${input.iamUrl}\n${await digest(input.iamKey)}`)
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
+ }