@fabrika/provider-zerops 0.3.3 → 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.
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
@@ -0,0 +1,212 @@
1
+ import {
2
+ PROVIDER_NAMESPACE_IMMUTABLE,
3
+ PROVIDER_NAMESPACE_UPDATE_UNCONFIRMED,
4
+ type ProviderDeploymentNamespace,
5
+ type ProviderEnvelope,
6
+ type ProviderNamespaceUpdateInput,
7
+ type ProviderNamespaceUpdatePlan,
8
+ type ProviderNamespaceUpdates,
9
+ } from '@fabrika/provider-contract'
10
+ import { waitForProcess, ZeropsApiError, type ZeropsProcess } from '../api'
11
+ import { defaultSleep } from '../collaborators'
12
+ import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, zeropsNamespaceTargetCodec } from './codec'
13
+ import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED } from './errors'
14
+ import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespacePostgres, ZeropsNamespaceTarget } from './types'
15
+ import { validateService } from './validation'
16
+
17
+ // Budget: ≤ 60 s on the autoscaling process plus ≤ 30 s of read-back — inside one request, and inside the lease TTL.
18
+ const PROCESS_POLL_INTERVAL_MS = 2_000
19
+ const PROCESS_POLL_ATTEMPTS = 30
20
+ const READBACK_INTERVAL_MS = 3_000
21
+ /** UNVERIFIED: how soon `autoscalingProfileId` reads the new profile after the PUT and its process finish. */
22
+ const READBACK_ATTEMPTS = 10
23
+
24
+ type FixedField = Exclude<keyof ZeropsNamespaceTarget, 'postgres'>
25
+
26
+ /** Every target field but `postgres`, whose profile is the one thing an update may move. A new field fails to compile until listed. */
27
+ const FIXED_FIELDS: { readonly [K in FixedField]: (target: ZeropsNamespaceTarget) => ZeropsNamespaceTarget[K] } = {
28
+ proxySource: (target) => target.proxySource,
29
+ proxyBuild: (target) => target.proxyBuild,
30
+ proxyDeployment: (target) => target.proxyDeployment,
31
+ projectName: (target) => target.projectName,
32
+ corePackage: (target) => target.corePackage,
33
+ publicAccess: (target) => target.publicAccess,
34
+ managed: (target) => target.managed,
35
+ projectId: (target) => target.projectId,
36
+ proxyServiceId: (target) => target.proxyServiceId,
37
+ postgresServiceId: (target) => target.postgresServiceId,
38
+ proxyConfigured: (target) => target.proxyConfigured,
39
+ proxyBaselineSequence: (target) => target.proxyBaselineSequence,
40
+ proxyAppVersionId: (target) => target.proxyAppVersionId,
41
+ proxyHosts: (target) => target.proxyHosts,
42
+ ready: (target) => target.ready,
43
+ }
44
+
45
+ const immutable = (message: string) => namespaceError(PROVIDER_NAMESPACE_IMMUTABLE, message, false)
46
+
47
+ /** The stored target with the requested payload's top-level fields laid over it, validated by the one codec. */
48
+ const mergedTarget = (
49
+ namespace: ProviderDeploymentNamespace,
50
+ current: NormalizedZeropsNamespaceTarget,
51
+ requested: ProviderEnvelope,
52
+ options: ZeropsNamespaceOptions,
53
+ ): NormalizedZeropsNamespaceTarget => {
54
+ const payload = requested.payload
55
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
56
+ throw new Error('Zerops namespace update payload must be an object')
57
+ }
58
+ const checked = namespaceUpdatePayloadSchema.safeParse(payload)
59
+ if (!checked.success) {
60
+ const issue = checked.error.issues[0]
61
+ const path = issue?.path.join('.') ?? ''
62
+ if (issue?.code === 'unrecognized_keys') {
63
+ const field = [path, issue.keys[0] ?? ''].filter((part) => part !== '').join('.')
64
+ throw new Error(`Zerops namespace update field ${field} is not a namespace target field`)
65
+ }
66
+ throw new Error(`Zerops namespace update field ${path === '' ? 'payload' : path} is invalid`)
67
+ }
68
+ const stored = zeropsNamespaceTargetCodec.encode(current)
69
+ if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
70
+ throw new Error('Zerops namespace target did not encode to an object')
71
+ }
72
+ return normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...payload } } }, options)
73
+ }
74
+
75
+ /** Refuse every difference except the PostgreSQL profile, naming the fields. */
76
+ const planUpdate = (
77
+ namespace: ProviderDeploymentNamespace,
78
+ requested: ProviderEnvelope,
79
+ options: ZeropsNamespaceOptions,
80
+ ): ProviderNamespaceUpdatePlan => {
81
+ const current = normalizeTarget(namespace, options)
82
+ const merged = mergedTarget(namespace, current, requested, options)
83
+ const changed = Object.entries(FIXED_FIELDS)
84
+ .filter(([, read]) => JSON.stringify(read(current)) !== JSON.stringify(read(merged)))
85
+ .map(([field]) => field)
86
+ if (changed.length > 0) {
87
+ throw immutable(`Zerops namespace ${namespace.id} cannot change ${changed.join(', ')} in place; only the PostgreSQL profile can`)
88
+ }
89
+ const was = current.postgres
90
+ const wanted = merged.postgres
91
+ const unchanged: ProviderNamespaceUpdatePlan = { namespace: { ...namespace, target: envelope(current) }, change: null }
92
+ // The merge starts from the stored target, so `wanted` is absent only when `was` is.
93
+ if (wanted === undefined) return unchanged
94
+ if (was === undefined) {
95
+ throw immutable(`Zerops namespace ${namespace.id} has no PostgreSQL service, and one cannot be added in place`)
96
+ }
97
+ if (was.type !== wanted.type) {
98
+ throw immutable(`Zerops namespace ${namespace.id} cannot change its PostgreSQL type from ${was.type} to ${wanted.type} in place`)
99
+ }
100
+ if (wanted.profile === was.profile) return unchanged
101
+ if (wanted.profile === undefined) {
102
+ throw immutable(`Zerops namespace ${namespace.id} cannot return its PostgreSQL service to the platform default profile in place`)
103
+ }
104
+ return {
105
+ namespace: { ...namespace, target: envelope({ ...current, postgres: wanted }) },
106
+ change: { field: 'postgres.profile', from: was.profile ?? null, to: wanted.profile },
107
+ }
108
+ }
109
+
110
+ const unconfirmed = (serviceId: string, profile: string, cause: unknown) =>
111
+ namespaceError(
112
+ PROVIDER_NAMESPACE_UPDATE_UNCONFIRMED,
113
+ `Zerops PostgreSQL service ${serviceId} was asked to move to profile ${profile}, and the change is not confirmed`,
114
+ true,
115
+ cause instanceof Error ? cause.message : undefined,
116
+ )
117
+
118
+ /** A platform refusal answered before anything was accepted; everything else once the PUT is sent is ambiguous. */
119
+ const refusedOutright = (cause: unknown): boolean =>
120
+ cause instanceof ZeropsApiError && cause.status >= 400 && cause.status < 500 && cause.status !== 429
121
+
122
+ const readBack = async (serviceId: string, profile: string, options: ZeropsNamespaceOptions, signal: AbortSignal): Promise<void> => {
123
+ const sleep = options.sleep ?? defaultSleep
124
+ for (let attempt = 0;; attempt += 1) {
125
+ if (signal.aborted) {
126
+ throw namespaceError(ZEROPS_NAMESPACE_CANCELLED, 'Zerops namespace update was cancelled', true)
127
+ }
128
+ const live = (await options.api.getService({ serviceId, signal })).autoscalingProfileId
129
+ if (live === profile) return
130
+ if (attempt >= READBACK_ATTEMPTS) {
131
+ throw new Error(`the service still reads ${live ?? 'no profile'}`)
132
+ }
133
+ await sleep(READBACK_INTERVAL_MS, signal)
134
+ }
135
+ }
136
+
137
+ const applyProfile = async (
138
+ current: NormalizedZeropsNamespaceTarget,
139
+ wanted: ZeropsNamespacePostgres,
140
+ profile: string,
141
+ unconfirmedProfile: string | undefined,
142
+ options: ZeropsNamespaceOptions,
143
+ signal: AbortSignal,
144
+ ): Promise<void> => {
145
+ const projectId = current.projectId
146
+ const serviceId = current.postgresServiceId
147
+ if (projectId === undefined || serviceId === undefined) {
148
+ throw new Error('Zerops namespace has no PostgreSQL service id')
149
+ }
150
+ const service = await options.api.getService({ serviceId, signal })
151
+ validateService(service, projectId, wanted.type, false, undefined, {
152
+ ...(current.postgres?.profile === undefined ? {} : { declared: current.postgres.profile }),
153
+ changingTo: profile,
154
+ ...(unconfirmedProfile === undefined ? {} : { unconfirmed: unconfirmedProfile }),
155
+ })
156
+ if (service.autoscalingProfileId !== profile) {
157
+ let process: ZeropsProcess | null
158
+ try {
159
+ process = await options.api.configureAutoscaling({ serviceId, autoscalingProfileId: profile, signal })
160
+ } catch (cause) {
161
+ if (refusedOutright(cause)) throw cause
162
+ throw unconfirmed(serviceId, profile, cause)
163
+ }
164
+ try {
165
+ if (process !== null && process.id !== '') {
166
+ await waitForProcess({
167
+ api: options.api,
168
+ processId: process.id,
169
+ sleep: options.sleep ?? defaultSleep,
170
+ signal,
171
+ label: 'the PostgreSQL autoscaling change',
172
+ intervalMs: PROCESS_POLL_INTERVAL_MS,
173
+ attempts: PROCESS_POLL_ATTEMPTS,
174
+ })
175
+ }
176
+ } catch (cause) {
177
+ throw unconfirmed(serviceId, profile, cause)
178
+ }
179
+ }
180
+ try {
181
+ await readBack(serviceId, profile, options, signal)
182
+ } catch (cause) {
183
+ throw unconfirmed(serviceId, profile, cause)
184
+ }
185
+ }
186
+
187
+ const applyUpdate = async (input: ProviderNamespaceUpdateInput, options: ZeropsNamespaceOptions): Promise<ProviderDeploymentNamespace> => {
188
+ const current = normalizeTarget(input.namespace, options)
189
+ if (!current.ready) {
190
+ throw new Error(`Zerops namespace ${input.namespace.id} is not ready`)
191
+ }
192
+ const plan = planUpdate(input.namespace, input.next.target, options)
193
+ const wanted = normalizeTarget(plan.namespace, options).postgres
194
+ // Checked even with no planned change: after an unconfirmed update, confirming the live service is
195
+ // what lets core return the namespace to ready.
196
+ if (wanted?.profile === undefined) return plan.namespace
197
+ const unconfirmedProfile = input.unconfirmed === undefined ? undefined : normalizeTarget(input.unconfirmed, options).postgres?.profile
198
+ await applyProfile(current, wanted, wanted.profile, unconfirmedProfile, options, input.signal)
199
+ return plan.namespace
200
+ }
201
+
202
+ /** Move a ready namespace's PostgreSQL service to another profile — the one field Zerops changes in place. */
203
+ export const createZeropsNamespaceUpdates = (options: ZeropsNamespaceOptions): ProviderNamespaceUpdates => ({
204
+ plan: (namespace, requested) => planUpdate(namespace, requested, options),
205
+ apply: async (input) => {
206
+ try {
207
+ return await applyUpdate(input, options)
208
+ } catch (cause) {
209
+ throw asNamespaceError(cause, 'update')
210
+ }
211
+ },
212
+ })
@@ -0,0 +1,43 @@
1
+ import type { ZeropsService } from '../api'
2
+
3
+ /**
4
+ * The profile a service is checked against. `declared` is what the stored target says. `changingTo` is
5
+ * set only while an update moves the service, and `unconfirmed` while it retries an earlier update whose
6
+ * outcome is unknown: a live service already on either reads as the change in progress, not as drift.
7
+ */
8
+ export interface ZeropsExpectedProfile {
9
+ readonly declared?: string
10
+ readonly changingTo?: string
11
+ readonly unconfirmed?: string
12
+ }
13
+
14
+ export const validateService = (
15
+ service: ZeropsService,
16
+ projectId: string,
17
+ expectedBase: string,
18
+ adopting: boolean,
19
+ expectedPublic?: boolean,
20
+ expectedProfile: ZeropsExpectedProfile = {},
21
+ ): void => {
22
+ if (service.id === '') {
23
+ throw new Error(`Zerops namespace service \`${service.name}\` returned an empty id`)
24
+ }
25
+ if (service.projectId !== undefined && service.projectId !== projectId) {
26
+ throw new Error(`Zerops namespace service \`${service.name}\` belongs to another project`)
27
+ }
28
+ if ((adopting || service.base !== undefined) && service.base !== expectedBase) {
29
+ throw new Error(`Zerops namespace service \`${service.name}\` uses ${service.base ?? 'an unknown type'}, expected ${expectedBase}`)
30
+ }
31
+ if (expectedPublic !== undefined && adopting && service.subdomainAccess !== expectedPublic) {
32
+ throw new Error(`Zerops namespace service \`${service.name}\` has incompatible public access`)
33
+ }
34
+ const live = service.autoscalingProfileId
35
+ if (live !== undefined && (live === expectedProfile.changingTo || live === expectedProfile.unconfirmed)) {
36
+ return
37
+ }
38
+ if (expectedProfile.declared !== undefined && live !== expectedProfile.declared) {
39
+ throw new Error(
40
+ `Zerops namespace service \`${service.name}\` uses profile ${live ?? 'unknown'}, but its namespace declares ${expectedProfile.declared}`,
41
+ )
42
+ }
43
+ }
package/src/plan.ts CHANGED
@@ -3,6 +3,7 @@ import type { ZeropsRuntimeTarget } from './types'
3
3
 
4
4
  export type ZeropsStepKind =
5
5
  | 'apply-import'
6
+ | 'verify-exposure'
6
7
  | 'write-service-env'
7
8
  | 'reserve-version'
8
9
  | 'upload-source'
@@ -36,6 +37,11 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
36
37
  kind: 'apply-import',
37
38
  description: `Apply the Zerops import for ${serviceHostnames.length} service(s): ${serviceHostnames.join(', ')}`,
38
39
  })
40
+ steps.push({
41
+ id: 'verify-exposure',
42
+ kind: 'verify-exposure',
43
+ description: 'Read back that no service has subdomain access and every bucket is one the platform created private',
44
+ })
39
45
  steps.push({ id: 'write-service-env', kind: 'write-service-env', description: 'Write the managed service variables' })
40
46
  steps.push({ id: 'reserve-version', kind: 'reserve-version', description: 'Reserve a Zerops app version' })
41
47
  steps.push({ id: 'upload-source', kind: 'upload-source', description: 'Upload the resolved repository snapshot' })
package/src/provider.ts CHANGED
@@ -8,11 +8,13 @@
8
8
  // runs. Recovery never resumes a deploy (ADR-0044): a version reserved but never triggered is deleted, and
9
9
  // one whose build may have been requested is settled only by polling it.
10
10
 
11
+ import { appEnvironmentId } from '@fabrika/auth-core'
11
12
  import {
12
13
  type DeployStepResult,
13
14
  followLeaseLoss,
14
15
  isLeaseLoss,
15
16
  type JsonValue,
17
+ type ProviderApprovedAccess,
16
18
  ProviderInvocationAbort,
17
19
  type ProviderManagedEnvironment,
18
20
  type ProviderRunEvents,
@@ -31,15 +33,33 @@ import {
31
33
  } from './api'
32
34
  import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
33
35
  import { assertZeropsInvariants, renderYaml } from './compile'
36
+ import { FABRIKA_CHECK_VERSION, PLATFORM_TYPESCRIPT_VERSION, renderLockedExecutionDescriptor } from './execution'
37
+ import {
38
+ createdServices,
39
+ type ExposureFinding,
40
+ formatExposureFindings,
41
+ formatStorageProvenance,
42
+ isObjectStorageType,
43
+ parseStorageProvenance,
44
+ STORAGE_PROVENANCE_KEY,
45
+ } from './exposure'
34
46
  import {
35
47
  assertAuthoredManagedDocument,
36
48
  assertAuthoredManagedInputs,
49
+ isFrameworkFamilyVariable,
37
50
  type ManagedValues,
38
51
  overlayRelease,
39
- parseManagedValues,
52
+ parseManagedEnvironment,
40
53
  SERVICE_MANAGED_KEYS,
41
54
  } from './managed-env'
42
- import { type FabrikaImportDocument, type FabrikaManifest, manifestServiceHostnames, verifyZeropsArtifactSourceDescriptor } from './manifest'
55
+ import {
56
+ approvedSchemaOf,
57
+ type FabrikaImportDocument,
58
+ type FabrikaManifest,
59
+ lockedExecutionFor,
60
+ manifestServiceHostnames,
61
+ verifyZeropsArtifactSourceDescriptor,
62
+ } from './manifest'
43
63
  import { buildZeropsPlan, resolveDeployHostname, type ZeropsStep } from './plan'
44
64
  import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
45
65
  import { isZeropsUploadDestination } from './source'
@@ -59,6 +79,8 @@ export interface ZeropsDeployRun {
59
79
  readonly managedEnvironment: ProviderManagedEnvironment
60
80
  readonly target: ZeropsRuntimeTarget
61
81
  readonly artifact: FabrikaManifest
82
+ /** Control's approved access for the environment (ADR-0050); the lock-down and the IAM schema come from it. */
83
+ readonly access?: ProviderApprovedAccess
62
84
  readonly dryRun: boolean
63
85
  readonly signal: AbortSignal
64
86
  readonly events: ProviderRunEvents
@@ -241,6 +263,10 @@ const terminalFailure = (version: ZeropsAppVersion): string => {
241
263
  * just before the build request is sent; together they decide whether a failure may delete the version.
242
264
  */
243
265
  interface Attempt {
266
+ /** Hostname → id of every service this run's import created. */
267
+ created?: ReadonlyMap<string, string>
268
+ /** The provenance marker `write-service-env` writes, or null to remove a marker no bucket needs any more. */
269
+ storageProvenance?: string | null
244
270
  appVersionId?: string
245
271
  uploadUrl?: string
246
272
  processId?: string
@@ -258,6 +284,8 @@ interface StepEnv {
258
284
  dryRun: boolean
259
285
  attempt: Attempt
260
286
  managedValues: ManagedValues
287
+ /** The key of each job and webhook of the approved contract, by its variable (ADR-0022 "Principal kinds"). */
288
+ familyVariables: Readonly<Record<string, string>>
261
289
  /** `await-version` gives up at this instant. */
262
290
  deadlineMs: number
263
291
  }
@@ -409,15 +437,78 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
409
437
  }
410
438
  }
411
439
 
412
- /** Write the five service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
440
+ /**
441
+ * The exposure read-back (ADR-0043): before a version can activate, every declared service must read
442
+ * `subdomainAccess: false`, and every object-storage service must be one this import created or the provenance
443
+ * marker names. A refusal raises an Operations alert, best-effort, and fails the run before any version exists.
444
+ */
445
+ const verifyExposure = async (env: StepEnv): Promise<void> => {
446
+ const { zerops, log, signal, run } = env
447
+ const { artifact, target } = run
448
+ if (env.dryRun) {
449
+ log(' [dry-run] would read back subdomain access and bucket provenance of every declared service')
450
+ return
451
+ }
452
+ const created = env.attempt.created ?? new Map<string, string>()
453
+ const buckets = artifact.target.importDocument.services.filter((service) => isObjectStorageType(service.type)).map((service) => service.hostname)
454
+ const records = buckets.length === 0 ? [] : await zerops.api.listServiceEnv({ serviceId: target.serviceId, signal })
455
+ const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
456
+ const known = new Set([...parseStorageProvenance(marker?.content), ...(target.createdBuckets ?? [])])
457
+ const findings: ExposureFinding[] = []
458
+ const bucketIds: string[] = []
459
+ for (const hostname of manifestServiceHostnames(artifact)) {
460
+ assertRunning(signal)
461
+ const service = await zerops.api.findService({ projectId: target.projectId, hostname, signal })
462
+ if (service === null) {
463
+ findings.push({ hostname, reason: 'is absent after the import, so its exposure cannot be read back' })
464
+ continue
465
+ }
466
+ if (service.subdomainAccess !== false) {
467
+ findings.push({
468
+ hostname,
469
+ reason: service.subdomainAccess === true
470
+ ? 'has a `.zerops.app` subdomain, which reaches it past the namespace proxy; disable it in Zerops'
471
+ : 'did not report its subdomain access',
472
+ })
473
+ }
474
+ if (!buckets.includes(hostname)) continue
475
+ if (created.get(hostname) === service.id || known.has(service.id)) {
476
+ bucketIds.push(service.id)
477
+ } else {
478
+ findings.push({ hostname, reason: 'is a bucket the platform did not create private (no provenance); create it through a Fabrika import' })
479
+ }
480
+ }
481
+ if (findings.length > 0) {
482
+ const dsn = env.managedValues.FABRIKA_OPERATIONS_DSN
483
+ if (dsn !== null) {
484
+ const alert = { appId: run.appId, env: run.env, release: env.managedValues.FABRIKA_RELEASE, findings }
485
+ await zerops.alertExposure({ dsn, alert, signal }).then(
486
+ () => log(' raised an Operations alert'),
487
+ () => log(' [warn] the Operations alert could not be sent'),
488
+ )
489
+ }
490
+ throw new Error(formatExposureFindings(findings))
491
+ }
492
+ env.attempt.storageProvenance = bucketIds.length > 0 ? formatStorageProvenance(bucketIds) : null
493
+ log(
494
+ ` ${manifestServiceHostnames(artifact).length} service(s) without subdomain access${
495
+ bucketIds.length === 0 ? '' : `; ${bucketIds.length} bucket(s) of platform provenance`
496
+ }`,
497
+ )
498
+ }
499
+
500
+ /** Write the nine service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
413
501
  const writeServiceEnv = async (env: StepEnv): Promise<void> => {
414
- const { zerops, log, signal, managedValues } = env
502
+ const { zerops, log, signal, managedValues, familyVariables } = env
415
503
  const serviceId = env.run.target.serviceId
416
504
  if (env.dryRun) {
417
- const written = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null)
505
+ const written = [...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null), ...Object.keys(familyVariables)]
418
506
  const removed = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null)
419
507
  log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
420
508
  log(` [dry-run] would remove service variables on ${serviceId} if present: ${removed.join(', ') || '(none)'}`)
509
+ if (Object.keys(familyVariables).length > 0) {
510
+ log(` [dry-run] would remove every other job, webhook and connector variable on ${serviceId}`)
511
+ }
421
512
  return
422
513
  }
423
514
  assertRunning(signal)
@@ -437,6 +528,35 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
437
528
  log(` ${key}: unchanged`)
438
529
  }
439
530
  }
531
+ const provenance = env.attempt.storageProvenance
532
+ const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
533
+ if (typeof provenance === 'string' && marker?.content !== provenance) {
534
+ assertRunning(signal)
535
+ await zerops.api.putServiceEnv({ serviceId, key: STORAGE_PROVENANCE_KEY, value: provenance, signal })
536
+ log(` ${STORAGE_PROVENANCE_KEY}: updated`)
537
+ } else if (provenance === null && marker !== undefined) {
538
+ assertRunning(signal)
539
+ await zerops.api.deleteServiceEnv({ envId: marker.id, signal })
540
+ log(` ${STORAGE_PROVENANCE_KEY}: removed`)
541
+ }
542
+ // A workload key is written like a managed key; one whose workload is gone is removed, so no stale key stays readable.
543
+ for (const [key, value] of Object.entries(familyVariables)) {
544
+ const existing = records.find((record) => record.key === key)
545
+ assertRunning(signal)
546
+ if (existing?.content !== value) {
547
+ await zerops.api.putServiceEnv({ serviceId, key, value, signal })
548
+ log(` ${key}: updated`)
549
+ } else {
550
+ log(` ${key}: unchanged`)
551
+ }
552
+ }
553
+ for (const record of records) {
554
+ if (isFrameworkFamilyVariable(record.key) && !Object.hasOwn(familyVariables, record.key)) {
555
+ assertRunning(signal)
556
+ await zerops.api.deleteServiceEnv({ envId: record.id, signal })
557
+ log(` ${record.key}: removed`)
558
+ }
559
+ }
440
560
  }
441
561
 
442
562
  /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by `runSteps`). */
@@ -458,6 +578,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
458
578
  // `override: true` on every service (written by the compiler) is what makes re-applying safe.
459
579
  const result = await zerops.api.importServices({ projectId: target.projectId, yaml: compiled.yaml, signal })
460
580
  log(` imported: ${result.services.map((service) => `${service.name} (${service.id})`).join(', ') || '(none reported)'}`)
581
+ attempt.created = createdServices(result)
461
582
  // An import ANSWERS before its services exist. Triggering a pipeline on a service Zerops is
462
583
  // still creating answers 400 `projectImportInvalidParameter`, which is what the first deploy
463
584
  // into a fresh namespace hit; a re-apply onto services that already exist reports none.
@@ -472,6 +593,11 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
472
593
  return
473
594
  }
474
595
 
596
+ case 'verify-exposure': {
597
+ await verifyExposure(env)
598
+ return
599
+ }
600
+
475
601
  case 'write-service-env': {
476
602
  await writeServiceEnv(env)
477
603
  return
@@ -529,13 +655,25 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
529
655
  }
530
656
 
531
657
  case 'trigger-build': {
658
+ const locked = lockedExecutionFor(artifact, run, dryRun)
532
659
  if (dryRun) {
533
- log(' [dry-run] would trigger build+deploy with FABRIKA_RELEASE written into the execution descriptor')
660
+ log(
661
+ locked === null
662
+ ? ' [dry-run] would trigger build+deploy with FABRIKA_RELEASE written into the execution descriptor'
663
+ : ' [dry-run] would trigger build+deploy with the generated run section and build checks',
664
+ )
534
665
  return
535
666
  }
536
667
  const appVersionId = requireVersion(env, 'trigger-build')
537
668
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
538
- const descriptor = overlayRelease(artifact.target.sourceDescriptor.contents, artifact.target.zeropsSetup, env.managedValues.FABRIKA_RELEASE)
669
+ const { contents } = artifact.target.sourceDescriptor
670
+ const release = env.managedValues.FABRIKA_RELEASE
671
+ const descriptor = locked === null
672
+ ? overlayRelease(contents, artifact.target.zeropsSetup, release)
673
+ : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, locked)
674
+ if (locked !== null) {
675
+ log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
676
+ }
539
677
  assertRunning(signal)
540
678
  // Recorded BEFORE the request: from here on the version is settled only by polling it (ADR-0044).
541
679
  const checkpoint: ZeropsRunState = { appVersionId, triggered: true }
@@ -579,7 +717,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
579
717
  }
580
718
 
581
719
  case 'reconcile-schema': {
582
- const schema = artifact.app.schema
720
+ const schema = approvedSchemaOf(run)
583
721
  const propustkaUrl = target.propustkaUrl
584
722
  if (schema === undefined || propustkaUrl === undefined) {
585
723
  return
@@ -587,17 +725,18 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
587
725
  // Return origins ride this step because `apps.setReturnOrigins` 404s for an app IAM has never
588
726
  // heard of, and the schema reconcile is what registers it. Separate call, same moment.
589
727
  const returnOrigins = run.returnOrigins !== undefined && run.returnOrigins.length > 0 ? run.returnOrigins : undefined
728
+ const iamApp = appEnvironmentId(artifact.app.id, artifact.app.env)
590
729
  if (dryRun) {
591
- log(` [dry-run] would reconcile schema for \`${artifact.app.id}\` against ${propustkaUrl}`)
730
+ log(` [dry-run] would reconcile schema for \`${iamApp}\` against ${propustkaUrl}`)
592
731
  if (returnOrigins !== undefined) {
593
- log(` [dry-run] would register return origins for \`${artifact.app.id}\`: ${returnOrigins.join(', ')}`)
732
+ log(` [dry-run] would register return origins for \`${iamApp}\`: ${returnOrigins.join(', ')}`)
594
733
  }
595
734
  return
596
735
  }
597
736
  assertRunning(signal)
598
737
  await zerops.reconcileSchema({
599
738
  url: propustkaUrl,
600
- app: artifact.app.id,
739
+ app: iamApp,
601
740
  schema,
602
741
  ...(returnOrigins === undefined ? {} : { returnOrigins }),
603
742
  adminKey: target.adminKey,
@@ -626,7 +765,15 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
626
765
  throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
627
766
  }
628
767
  assertAuthoredManagedInputs({ variableNames: Object.keys(run.vars), secretNames: Object.keys(run.secrets) })
629
- const managedValues = parseManagedValues(run.managedEnvironment)
768
+ // The proxy serves an endpoint table exactly when Control approved a contract, whatever the artifact says.
769
+ const contractProxy = run.access !== undefined
770
+ const { values: handed, familyVariables } = parseManagedEnvironment(run.managedEnvironment)
771
+ // Only a contract application checks the secret; any other would merely hold a credential its own code can print.
772
+ const managedValues: ManagedValues = contractProxy ? handed : { ...handed, FABRIKA_PROXY_SECRET: null }
773
+ if (!run.dryRun && contractProxy && managedValues.FABRIKA_PROXY_SECRET === null) {
774
+ // The application would refuse every request its proxy sends (ADR-0022 rule 5).
775
+ throw new Error('zerops: a contract application needs its proxy secret, and Control supplied none')
776
+ }
630
777
  const hostnames = manifestServiceHostnames(run.artifact)
631
778
  const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
632
779
  const compiled = { yaml: renderYaml(document), hostnames }
@@ -641,6 +788,7 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
641
788
  dryRun: run.dryRun,
642
789
  attempt: { triggered: false },
643
790
  managedValues,
791
+ familyVariables,
644
792
  deadlineMs: Date.now() + ZEROPS_VERSION_DEADLINE_MS,
645
793
  }
646
794
  const steps = buildZeropsPlan(run.artifact, run.target)
package/src/types.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { AppGates } from '@fabrika/auth-core'
1
+ import type { ContractArtifact } from '@fabrika/auth-core'
2
2
  import type { AppConfigBase } from '@fabrika/provider-contract'
3
+ import type { ContractProxyApp } from '@fabrika/proxy-core/manifest'
4
+ import type { ZeropsRuntimePackage } from './execution'
3
5
  import type {
4
6
  ZeropsHealthCheck,
5
7
  ZeropsImportProject,
@@ -82,9 +84,9 @@ export type ZeropsServiceSpec = Omit<ZeropsImportService, ZeropsCompilerOwnedSer
82
84
 
83
85
  export type ZeropsProjectSpec = Omit<ZeropsImportProject, ZeropsCompilerOwnedProjectField>
84
86
 
85
- export interface ZeropsProxySpec {
87
+ /** What the namespace proxy applies to the app: the manifest form of its compiled contract, from `contractProxyTable`. */
88
+ export interface ZeropsProxySpec extends Pick<ContractProxyApp, 'endpoints' | 'reservedPrefixes' | 'snapshot'> {
86
89
  upstream: string
87
- gates: AppGates
88
90
  }
89
91
 
90
92
  /**
@@ -107,6 +109,13 @@ export interface ZeropsSourceTarget {
107
109
  zeropsSetup?: string
108
110
  proxy?: ZeropsProxySpec
109
111
  namespaceResources?: ZeropsNamespaceResourceRequirement[]
112
+ /**
113
+ * The compiled contract of a contract application. Its presence locks the setup down (ADR-0043): the platform
114
+ * generates the run section and the server build, and the authored `zerops.yaml` keeps only the SPA build.
115
+ */
116
+ contract?: ContractArtifact
117
+ /** System packages for the runtime image of a contract application, from a closed list. */
118
+ runtimePackages?: ZeropsRuntimePackage[]
110
119
  services: (ctx: ZeropsResourceContext) => ZeropsServiceSpec[]
111
120
  project?: ZeropsProjectSpec
112
121
  }
@@ -134,6 +143,8 @@ export interface ZeropsRunState {
134
143
  export interface ZeropsRuntimeTarget {
135
144
  projectId: string
136
145
  serviceId: string
146
+ /** The buckets the environment's registration created, from its stored target (creation evidence, ADR-0043). */
147
+ createdBuckets?: readonly string[]
137
148
  accessToken: string
138
149
  /** Absent only for dry runs, which perform no source or Zerops mutation. */
139
150
  source?: ZeropsRuntimeSource