@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.
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 +159 -19
  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
@@ -77,8 +99,6 @@ const POLL_INTERVAL_MS = 3000
77
99
  /** A run stops waiting for its version this long after it started. Zerops' own pipeline limit is 1 hour. */
78
100
  export const ZEROPS_VERSION_DEADLINE_MS = 75 * 60 * 1000
79
101
 
80
- const RELEASE_KEY = 'FABRIKA_RELEASE'
81
-
82
102
  /** Abandon the step if the run was cancelled. Checked before every real mutation and each poll iteration. */
83
103
  const assertRunning = (signal: AbortSignal): void => {
84
104
  if (signal.aborted) {
@@ -243,6 +263,10 @@ const terminalFailure = (version: ZeropsAppVersion): string => {
243
263
  * just before the build request is sent; together they decide whether a failure may delete the version.
244
264
  */
245
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
246
270
  appVersionId?: string
247
271
  uploadUrl?: string
248
272
  processId?: string
@@ -260,6 +284,8 @@ interface StepEnv {
260
284
  dryRun: boolean
261
285
  attempt: Attempt
262
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>>
263
289
  /** `await-version` gives up at this instant. */
264
290
  deadlineMs: number
265
291
  }
@@ -411,15 +437,78 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
411
437
  }
412
438
  }
413
439
 
414
- /** 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). */
415
501
  const writeServiceEnv = async (env: StepEnv): Promise<void> => {
416
- const { zerops, log, signal, managedValues } = env
502
+ const { zerops, log, signal, managedValues, familyVariables } = env
417
503
  const serviceId = env.run.target.serviceId
418
504
  if (env.dryRun) {
419
- const written = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null)
505
+ const written = [...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null), ...Object.keys(familyVariables)]
420
506
  const removed = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null)
421
507
  log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
422
- log(` [dry-run] would remove service variables on ${serviceId} if present: ${[...removed, RELEASE_KEY].join(', ')}`)
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
+ }
423
512
  return
424
513
  }
425
514
  assertRunning(signal)
@@ -439,11 +528,34 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
439
528
  log(` ${key}: unchanged`)
440
529
  }
441
530
  }
442
- const strayRelease = records.find((record) => record.key === RELEASE_KEY)
443
- if (strayRelease !== undefined) {
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) {
444
538
  assertRunning(signal)
445
- await zerops.api.deleteServiceEnv({ envId: strayRelease.id, signal })
446
- log(` ${RELEASE_KEY}: removed`)
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
+ }
447
559
  }
448
560
  }
449
561
 
@@ -466,6 +578,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
466
578
  // `override: true` on every service (written by the compiler) is what makes re-applying safe.
467
579
  const result = await zerops.api.importServices({ projectId: target.projectId, yaml: compiled.yaml, signal })
468
580
  log(` imported: ${result.services.map((service) => `${service.name} (${service.id})`).join(', ') || '(none reported)'}`)
581
+ attempt.created = createdServices(result)
469
582
  // An import ANSWERS before its services exist. Triggering a pipeline on a service Zerops is
470
583
  // still creating answers 400 `projectImportInvalidParameter`, which is what the first deploy
471
584
  // into a fresh namespace hit; a re-apply onto services that already exist reports none.
@@ -480,6 +593,11 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
480
593
  return
481
594
  }
482
595
 
596
+ case 'verify-exposure': {
597
+ await verifyExposure(env)
598
+ return
599
+ }
600
+
483
601
  case 'write-service-env': {
484
602
  await writeServiceEnv(env)
485
603
  return
@@ -537,13 +655,25 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
537
655
  }
538
656
 
539
657
  case 'trigger-build': {
658
+ const locked = lockedExecutionFor(artifact, run, dryRun)
540
659
  if (dryRun) {
541
- 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
+ )
542
665
  return
543
666
  }
544
667
  const appVersionId = requireVersion(env, 'trigger-build')
545
668
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
546
- 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
+ }
547
677
  assertRunning(signal)
548
678
  // Recorded BEFORE the request: from here on the version is settled only by polling it (ADR-0044).
549
679
  const checkpoint: ZeropsRunState = { appVersionId, triggered: true }
@@ -587,7 +717,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
587
717
  }
588
718
 
589
719
  case 'reconcile-schema': {
590
- const schema = artifact.app.schema
720
+ const schema = approvedSchemaOf(run)
591
721
  const propustkaUrl = target.propustkaUrl
592
722
  if (schema === undefined || propustkaUrl === undefined) {
593
723
  return
@@ -595,17 +725,18 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
595
725
  // Return origins ride this step because `apps.setReturnOrigins` 404s for an app IAM has never
596
726
  // heard of, and the schema reconcile is what registers it. Separate call, same moment.
597
727
  const returnOrigins = run.returnOrigins !== undefined && run.returnOrigins.length > 0 ? run.returnOrigins : undefined
728
+ const iamApp = appEnvironmentId(artifact.app.id, artifact.app.env)
598
729
  if (dryRun) {
599
- log(` [dry-run] would reconcile schema for \`${artifact.app.id}\` against ${propustkaUrl}`)
730
+ log(` [dry-run] would reconcile schema for \`${iamApp}\` against ${propustkaUrl}`)
600
731
  if (returnOrigins !== undefined) {
601
- 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(', ')}`)
602
733
  }
603
734
  return
604
735
  }
605
736
  assertRunning(signal)
606
737
  await zerops.reconcileSchema({
607
738
  url: propustkaUrl,
608
- app: artifact.app.id,
739
+ app: iamApp,
609
740
  schema,
610
741
  ...(returnOrigins === undefined ? {} : { returnOrigins }),
611
742
  adminKey: target.adminKey,
@@ -634,7 +765,15 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
634
765
  throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
635
766
  }
636
767
  assertAuthoredManagedInputs({ variableNames: Object.keys(run.vars), secretNames: Object.keys(run.secrets) })
637
- 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
+ }
638
777
  const hostnames = manifestServiceHostnames(run.artifact)
639
778
  const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
640
779
  const compiled = { yaml: renderYaml(document), hostnames }
@@ -649,6 +788,7 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
649
788
  dryRun: run.dryRun,
650
789
  attempt: { triggered: false },
651
790
  managedValues,
791
+ familyVariables,
652
792
  deadlineMs: Date.now() + ZEROPS_VERSION_DEADLINE_MS,
653
793
  }
654
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