@fabrika/provider-zerops 0.0.26 → 0.3.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 (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +75 -20
  14. package/src/control.ts +217 -447
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +24 -128
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +208 -315
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1260
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +399 -254
  73. package/src/service-references.ts +28 -0
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
package/src/control.ts CHANGED
@@ -1,84 +1,64 @@
1
1
  import type {
2
2
  ControlProvider,
3
3
  JsonValue,
4
+ ProviderApp,
5
+ ProviderCancelOutcome,
4
6
  ProviderCodec,
5
7
  ProviderDeployInput,
6
8
  ProviderDeploymentNamespace,
9
+ ProviderDeployOutcome,
7
10
  ProviderEnvelope,
8
11
  ProviderEnvironment,
9
- ProviderReconcileInput,
10
12
  ProviderReconcileOutcome,
11
13
  ProviderRegistration,
12
14
  ProviderRegistrationInput,
15
+ ProviderSourceBinding,
13
16
  ProviderSourceResolution,
14
17
  ProviderSourceResolutionInput,
15
- ProviderTerminalOutcome,
16
- RuntimeProvider,
17
- RuntimeProviderRun,
18
18
  SchemaReconciler,
19
19
  } from '@fabrika/provider-contract'
20
- import {
21
- createZeropsApi,
22
- ZEROPS_ACTIVE,
23
- ZEROPS_PROCESS_FINISHED,
24
- ZEROPS_PROCESS_TERMINAL,
25
- ZEROPS_TERMINAL,
26
- type ZeropsApi,
27
- ZeropsApiError,
28
- type ZeropsAppVersion,
29
- type ZeropsProcess,
30
- } from './api'
20
+ import { decodeEnvelope, ProviderSecretError } from '@fabrika/provider-contract'
21
+ import { z } from 'zod'
22
+ import { createZeropsApi, waitForProcess, ZEROPS_ACTIVE, ZEROPS_TERMINAL, type ZeropsApi, type ZeropsAppVersion } from './api'
31
23
  import { defaultSleep, defaultZeropsCollaborators, type Sleeper } from './collaborators'
24
+ import { assertAuthoredManagedInputs, assertAuthoredManagedName, parseManagedValues } from './managed-env'
32
25
  import {
26
+ assertFabrikaManifestCoordinates,
33
27
  type FabrikaManifest,
34
28
  manifestServiceHostnames,
35
- parseFabrikaManifest,
36
29
  renderFabrikaImportYaml,
37
30
  renderFabrikaProvisioningYaml,
38
31
  verifyZeropsArtifactSourceDescriptor,
39
32
  zeropsArtifactCodec,
40
33
  } from './manifest'
41
- import { createZeropsNamespaceCapabilities, type ZeropsNamespaceTarget, zeropsNamespaceTargetCodec } from './namespace'
42
- import { createZeropsProvider, type ZeropsSourceTransportBinding } from './provider'
43
- import { normalizeZeropsSourceRepository, type ZeropsSourceClient, type ZeropsSourceClientV2, type ZeropsSourceResolveResult } from './source'
34
+ import {
35
+ createZeropsNamespaceCapabilities,
36
+ type ZeropsNamespaceProxySource,
37
+ type ZeropsNamespaceTarget,
38
+ zeropsNamespaceTargetCodec,
39
+ } from './namespace'
40
+ import { createZeropsDeployer, ZEROPS_VERSION_DEADLINE_MS } from './provider'
41
+ import { allowedReferenceHosts, foreignReferenceHost, serviceReferenceHosts } from './service-references'
42
+ import { normalizeZeropsSourceRepository } from './source'
43
+ import type { ZeropsSourcePort, ZeropsSourcePrivateBinding } from './source/port'
44
44
  import type { ZeropsRunState, ZeropsRuntimeTarget } from './types'
45
+ import { deleteVersion, isVersionNotFound } from './version-attempt'
45
46
 
46
47
  /** Provider coordinates that are safe to persist. Credentials are composed only for a live run. */
47
48
  export interface ZeropsStoredTarget {
48
49
  serviceId: string
49
50
  }
50
51
 
51
- const property = (payload: JsonValue, key: string): JsonValue | undefined => {
52
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
53
- throw new Error('Zerops stored target must be an object')
54
- }
55
- return payload[key]
56
- }
57
-
58
- const requiredString = (payload: JsonValue, key: string): string => {
59
- const value = property(payload, key)
60
- if (typeof value !== 'string' || value === '') {
61
- throw new Error(`Zerops stored target ${key} must be a non-empty string`)
62
- }
63
- return value
64
- }
52
+ const storedTargetSchema = z.object({ serviceId: z.string().min(1) })
65
53
 
66
54
  export const zeropsStoredTargetCodec: ProviderCodec<ZeropsStoredTarget> = {
67
55
  version: 2,
68
56
  encode: (target) => ({ serviceId: target.serviceId }),
69
- decode: (payload) => ({
70
- serviceId: requiredString(payload, 'serviceId'),
71
- }),
72
- }
73
-
74
- const decodeEnvelope = <T>(kind: string, envelope: ProviderEnvelope, codec: ProviderCodec<T>): T => {
75
- if (envelope.provider !== 'zerops') {
76
- throw new Error(`${kind} belongs to provider "${envelope.provider}", expected "zerops"`)
77
- }
78
- if (envelope.version !== codec.version) {
79
- throw new Error(`${kind} schema version ${envelope.version} is not supported by provider "zerops"`)
80
- }
81
- return codec.decode(envelope.payload)
57
+ decode: (payload) => {
58
+ const result = storedTargetSchema.safeParse(payload)
59
+ if (!result.success) throw new Error('Zerops stored target serviceId must be a non-empty string')
60
+ return result.data
61
+ },
82
62
  }
83
63
 
84
64
  const envelope = <T>(codec: ProviderCodec<T>, value: T): ProviderEnvelope => ({
@@ -87,61 +67,23 @@ const envelope = <T>(codec: ProviderCodec<T>, value: T): ProviderEnvelope => ({
87
67
  payload: codec.encode(value),
88
68
  })
89
69
 
90
- export interface ZeropsBeforeDeployInput {
91
- readonly appId: string
92
- readonly env: string
93
- readonly namespaceId: string
94
- readonly target: ZeropsStoredTarget & {
95
- readonly projectId: string
96
- readonly proxyServiceId: string
97
- /** The namespace proxy has no Git integration, so its pipeline must be told what to build. */
98
- readonly proxyBuildFromGit: string
99
- }
100
- readonly artifact: FabrikaManifest
101
- readonly api: ZeropsApi
102
- readonly signal: AbortSignal
103
- }
104
-
105
- export type ZeropsBeforeDeploy = (input: ZeropsBeforeDeployInput) => Promise<void>
106
-
107
- export type ZeropsProviderExecutor = (
108
- provider: RuntimeProvider,
109
- run: RuntimeProviderRun,
110
- ) => Promise<ProviderTerminalOutcome>
111
-
112
70
  export interface ZeropsControlProviderOptions {
113
71
  readonly accessToken: string
114
72
  readonly apiBaseUrl?: string
115
73
  readonly propustkaUrl?: string
116
74
  readonly adminKey?: string
117
75
  readonly api?: ZeropsApi
118
- readonly source: ZeropsSourceClient & Partial<ZeropsSourceClientV2>
76
+ readonly source: ZeropsSourcePort
119
77
  readonly reconcileSchema?: SchemaReconciler
120
78
  readonly sleep?: Sleeper
121
- readonly sourceCancelSleep?: Sleeper
122
- readonly execute: ZeropsProviderExecutor
123
- readonly beforeDeploy?: ZeropsBeforeDeploy
124
79
  readonly namespaces?: {
125
80
  readonly clientId: string
126
- readonly proxyBuildFromGit: string
81
+ readonly proxySource: ZeropsNamespaceProxySource
127
82
  readonly iamUrl: string
128
83
  readonly iamKey: string
129
84
  }
130
85
  }
131
86
 
132
- export interface ZeropsBoundSourceResolutionInput extends ProviderSourceResolutionInput {
133
- readonly sourceBinding: ZeropsSourceTransportBinding
134
- }
135
-
136
- export interface ZeropsBoundDeployInput extends ProviderDeployInput {
137
- readonly sourceBinding: ZeropsSourceTransportBinding
138
- }
139
-
140
- export interface ZeropsControlProvider extends ControlProvider {
141
- resolveSourceWithBinding(input: ZeropsBoundSourceResolutionInput): Promise<ProviderSourceResolution>
142
- deployWithBinding(input: ZeropsBoundDeployInput): Promise<ProviderTerminalOutcome>
143
- }
144
-
145
87
  interface DecodedZeropsEnvironment {
146
88
  readonly target: ZeropsStoredTarget
147
89
  readonly namespace: ProviderDeploymentNamespace
@@ -149,7 +91,7 @@ interface DecodedZeropsEnvironment {
149
91
  }
150
92
 
151
93
  const decodeEnvironment = (environment: ProviderEnvironment): DecodedZeropsEnvironment => {
152
- const target = decodeEnvelope('target', environment.target, zeropsStoredTargetCodec)
94
+ const target = decodeEnvelope('target', 'zerops', environment.target, zeropsStoredTargetCodec)
153
95
  const placement = decodeNamespace(environment)
154
96
  return {
155
97
  target,
@@ -172,14 +114,14 @@ const decodeNamespace = (
172
114
  }
173
115
  return {
174
116
  namespace,
175
- namespaceTarget: decodeEnvelope('namespace target', namespace.target, zeropsNamespaceTargetCodec),
117
+ namespaceTarget: decodeEnvelope('namespace target', 'zerops', namespace.target, zeropsNamespaceTargetCodec),
176
118
  }
177
119
  }
178
120
 
179
121
  const resolvedEnvironment = (
180
122
  environment: ProviderEnvironment,
181
123
  options: { requireReady: boolean },
182
- ): DecodedZeropsEnvironment & { projectId: string; proxyServiceId?: string; proxyBuildFromGit?: string } => {
124
+ ): DecodedZeropsEnvironment & { projectId: string } => {
183
125
  const decoded = decodeEnvironment(environment)
184
126
  const projectId = decoded.namespaceTarget.projectId
185
127
  if (projectId === undefined) {
@@ -188,34 +130,20 @@ const resolvedEnvironment = (
188
130
  if (options.requireReady && decoded.namespaceTarget.ready !== true) {
189
131
  throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` is not ready`)
190
132
  }
191
- const proxyServiceId = decoded.namespaceTarget.proxyServiceId
192
- if (options.requireReady && proxyServiceId === undefined) {
193
- throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no proxy service id`)
194
- }
195
- const proxyBuildFromGit = decoded.namespaceTarget.proxyBuildFromGit
196
- if (options.requireReady && proxyBuildFromGit === undefined) {
197
- throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no proxy build source`)
198
- }
199
- return {
200
- ...decoded,
201
- projectId,
202
- ...(proxyServiceId === undefined ? {} : { proxyServiceId }),
203
- ...(proxyBuildFromGit === undefined ? {} : { proxyBuildFromGit }),
133
+ return { ...decoded, projectId }
134
+ }
135
+
136
+ const assertEnvironmentOfApp = (app: ProviderApp, environment: ProviderEnvironment): void => {
137
+ if (app.id !== environment.appId) {
138
+ throw new Error(`Zerops environment belongs to app \`${environment.appId}\`, expected \`${app.id}\``)
204
139
  }
205
140
  }
206
141
 
207
142
  const normalizeRegistration = (input: ProviderRegistrationInput): ProviderRegistration => {
208
- if (input.app.id !== input.environment.appId) {
209
- throw new Error(`Zerops environment belongs to app \`${input.environment.appId}\`, expected \`${input.app.id}\``)
210
- }
211
- if ((input.app.source.githubConnectionId === undefined) !== (input.app.source.githubInstallationId === undefined)) {
212
- throw new Error('Zerops private source requires both connection and installation coordinates')
213
- }
143
+ assertEnvironmentOfApp(input.app, input.environment)
214
144
  const decoded = decodeEnvironment(input.environment)
215
- const artifact = parseFabrikaManifest(
216
- decodeEnvelope('artifact', input.environment.artifact, zeropsArtifactCodec),
217
- { appId: input.app.id, env: input.environment.env },
218
- )
145
+ const artifact = decodeEnvelope('artifact', 'zerops', input.environment.artifact, zeropsArtifactCodec)
146
+ assertFabrikaManifestCoordinates(artifact.app, { appId: input.app.id, env: input.environment.env })
219
147
  return {
220
148
  app: input.app,
221
149
  environment: {
@@ -232,112 +160,89 @@ const normalizeRegistration = (input: ProviderRegistrationInput): ProviderRegist
232
160
 
233
161
  const abortSignal = (): AbortSignal => new AbortController().signal
234
162
 
163
+ /** Every private Zerops source names its keyed connection (ADR-0029). */
164
+ const privateBinding = (binding: ProviderSourceBinding | undefined): ZeropsSourcePrivateBinding | undefined =>
165
+ binding === undefined ? undefined : { connectionId: binding.connectionId, installationId: binding.installationId }
166
+
235
167
  const IMMUTABLE_GIT_OBJECT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/
236
- const BUILD_TRIGGER_CONSISTENCY_MS = 10_000
237
- const SOURCE_CANCEL_TIMEOUT_MS = 5000
238
168
 
239
- const parseRunState = (value: JsonValue | undefined, externalId: string): ZeropsRunState => {
240
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
241
- throw new Error('Zerops run state must be an object')
242
- }
243
- const appVersionId = value['appVersionId']
244
- const phase = value['phase']
245
- if (typeof appVersionId !== 'string' || appVersionId === '' || appVersionId !== externalId) {
246
- throw new Error('Zerops run state appVersionId must match the external id')
247
- }
248
- if (phase === 'version_created' || phase === 'source_uploaded' || phase === 'build_trigger_requested') {
249
- if (Object.keys(value).some((key) => key !== 'appVersionId' && key !== 'phase')) {
250
- throw new Error('Zerops run state contains an unknown field')
251
- }
252
- return { appVersionId, phase }
253
- }
254
- if (phase !== 'build_triggered') {
255
- throw new Error('Zerops run state phase is invalid')
256
- }
257
- if (Object.keys(value).some((key) => key !== 'appVersionId' && key !== 'phase' && key !== 'processId')) {
258
- throw new Error('Zerops run state contains an unknown field')
259
- }
260
- const processId = value['processId']
261
- if (processId !== undefined && (typeof processId !== 'string' || processId === '')) {
262
- throw new Error('Zerops run state processId must be a non-empty string when present')
263
- }
264
- return { appVersionId, phase, ...(processId === undefined ? {} : { processId }) }
169
+ const checkpointSchema = z.object({ appVersionId: z.string().min(1), triggered: z.boolean() }).strict()
170
+
171
+ /** The run checkpoint (ADR-0044), or null for anything else — an older shape included. */
172
+ export const parseZeropsCheckpoint = (value: unknown): ZeropsRunState | null => {
173
+ const result = checkpointSchema.safeParse(value)
174
+ return result.success ? result.data : null
265
175
  }
266
176
 
267
- const runState = (
268
- appVersionId: string,
269
- phase: Exclude<ZeropsRunState['phase'], 'build_triggered'>,
270
- ): Exclude<ZeropsRunState, { phase: 'build_triggered' }> => ({ appVersionId, phase })
177
+ /** The recorded checkpoint for exactly this run's external id, or null. */
178
+ const runCheckpoint = (input: { providerState?: JsonValue; externalId: string }): ZeropsRunState | null => {
179
+ const checkpoint = parseZeropsCheckpoint(input.providerState)
180
+ return checkpoint !== null && checkpoint.appVersionId === input.externalId ? checkpoint : null
181
+ }
271
182
 
272
- const buildTriggeredState = (
273
- appVersionId: string,
274
- processId?: string,
275
- ): Extract<ZeropsRunState, { phase: 'build_triggered' }> => ({
276
- appVersionId,
277
- phase: 'build_triggered',
278
- ...(processId === undefined ? {} : { processId }),
279
- })
183
+ const pastDeadline = (startedAtMs: number | undefined): boolean => startedAtMs !== undefined && Date.now() > startedAtMs + ZEROPS_VERSION_DEADLINE_MS
280
184
 
281
185
  /** Build the complete Zerops lifecycle bundle without importing control-plane persistence. */
282
- export const createZeropsControlProvider = (options: ZeropsControlProviderOptions): ZeropsControlProvider => {
186
+ export const createZeropsControlProvider = (options: ZeropsControlProviderOptions): ControlProvider => {
283
187
  if (options.accessToken === '') {
284
188
  throw new Error('Zerops access token must be a non-empty string')
285
189
  }
286
190
  const api = options.api ?? createZeropsApi({ token: options.accessToken, baseUrl: options.apiBaseUrl })
287
- const sourceBindings = new Map<string, ZeropsSourceTransportBinding>()
288
- const runtimeProvider = createZeropsProvider((target) => {
191
+ const getVersion = async (input: { appVersionId: string; signal: AbortSignal }): Promise<ZeropsAppVersion | null> => {
192
+ input.signal.throwIfAborted()
193
+ try {
194
+ return await api.getAppVersion(input)
195
+ } catch (error) {
196
+ input.signal.throwIfAborted()
197
+ if (!isVersionNotFound(error)) throw error
198
+ return null
199
+ }
200
+ }
201
+ const deployRun = createZeropsDeployer((target) => {
289
202
  const defaults = defaultZeropsCollaborators(target)
290
203
  return {
291
204
  api,
292
205
  source: options.source,
293
- ...(options.sourceCancelSleep === undefined ? {} : { sourceCancelSleep: options.sourceCancelSleep }),
294
206
  reconcileSchema: options.reconcileSchema ?? defaults.reconcileSchema,
295
207
  sleep: options.sleep ?? defaultSleep,
296
208
  }
297
- }, {
298
- bindingForRun: (runId) => sourceBindings.get(runId),
299
- uploadV2: async (input) => {
300
- const uploadV2 = options.source.uploadV2
301
- if (uploadV2 === undefined) throw new Error('Zerops keyed source transport is unavailable')
302
- return uploadV2.call(options.source, input)
303
- },
304
209
  })
305
210
  const namespaceCapabilities = options.namespaces === undefined
306
211
  ? undefined
307
212
  : createZeropsNamespaceCapabilities({
308
213
  ...options.namespaces,
214
+ source: options.source,
309
215
  api,
310
216
  ...(options.sleep !== undefined ? { sleep: options.sleep } : {}),
311
217
  })
312
218
 
313
- const artifactFor = (environment: ProviderEnvironment): FabrikaManifest =>
314
- parseFabrikaManifest(
315
- decodeEnvelope('artifact', environment.artifact, zeropsArtifactCodec),
316
- { appId: environment.appId, env: environment.env },
317
- )
219
+ const artifactFor = (environment: ProviderEnvironment): FabrikaManifest => {
220
+ const artifact = decodeEnvelope('artifact', 'zerops', environment.artifact, zeropsArtifactCodec)
221
+ assertFabrikaManifestCoordinates(artifact.app, { appId: environment.appId, env: environment.env })
222
+ return artifact
223
+ }
318
224
 
319
- const cancelSourceBestEffort = async (runId: string, appVersionId: string): Promise<void> => {
320
- const sourceController = new AbortController()
321
- const timeoutController = new AbortController()
322
- const cancel = Promise.resolve()
323
- .then(() => options.source.cancel({ runId, appVersionId, signal: sourceController.signal }))
324
- .catch(() => {})
325
- const timeout = Promise.resolve()
326
- .then(() => (options.sourceCancelSleep ?? defaultSleep)(SOURCE_CANCEL_TIMEOUT_MS, timeoutController.signal))
327
- .then(() => sourceController.abort())
328
- .catch(() => {})
329
- await Promise.race([cancel, timeout])
330
- timeoutController.abort()
331
- sourceController.abort()
225
+ /**
226
+ * A variable written through the env API resolves `${host_KEY}` at container start like `run.envVariables`
227
+ * does, across services whatever `envIsolation` says — so an unchecked secret reads the proxy's key.
228
+ */
229
+ const assertOwnReferencesOnly = (environment: ProviderEnvironment, name: string, value: string): void => {
230
+ // A secret naming no service at all must not start failing on an artifact that no longer parses.
231
+ if (serviceReferenceHosts(name).length === 0 && serviceReferenceHosts(value).length === 0) return
232
+ const allowed = allowedReferenceHosts(manifestServiceHostnames(artifactFor(environment)))
233
+ const foreign = foreignReferenceHost(name, allowed) ?? foreignReferenceHost(value, allowed)
234
+ if (foreign !== null) {
235
+ throw new ProviderSecretError(`zerops: secret \`${name}\` references service \`${foreign}\`, which this app does not own`, 'foreignReference')
236
+ }
332
237
  }
333
238
 
334
239
  const reconcileActiveSchema = async (
335
- input: ProviderReconcileInput,
336
- placement: ReturnType<typeof resolvedEnvironment>,
240
+ input: { environment: ProviderEnvironment; returnOrigins?: readonly string[] },
337
241
  signal: AbortSignal,
338
242
  ): Promise<void> => {
339
243
  const artifact = artifactFor(input.environment)
340
244
  if (artifact.app.schema === undefined || options.propustkaUrl === undefined) return
245
+ const placement = resolvedEnvironment(input.environment, { requireReady: false })
341
246
  const runtimeTarget: ZeropsRuntimeTarget = {
342
247
  projectId: placement.projectId,
343
248
  serviceId: placement.target.serviceId,
@@ -347,6 +252,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
347
252
  ...(options.adminKey !== undefined ? { adminKey: options.adminKey } : {}),
348
253
  }
349
254
  const reconcileSchema = options.reconcileSchema ?? defaultZeropsCollaborators(runtimeTarget).reconcileSchema
255
+ signal.throwIfAborted()
350
256
  await reconcileSchema({
351
257
  url: options.propustkaUrl,
352
258
  app: artifact.app.id,
@@ -357,88 +263,44 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
357
263
  })
358
264
  }
359
265
 
360
- const reconcileTriggered = async (
361
- input: ProviderReconcileInput,
362
- state: Extract<ZeropsRunState, { phase: 'build_triggered' }>,
363
- placement: ReturnType<typeof resolvedEnvironment>,
364
- signal: AbortSignal,
365
- ): Promise<ProviderReconcileOutcome> => {
366
- if (state.processId !== undefined) {
367
- const process = await api.getProcess({ processId: state.processId, signal })
368
- if (process.id !== state.processId) throw new Error('Zerops process response has different coordinates')
369
- if (
370
- process.status !== undefined
371
- && ZEROPS_PROCESS_TERMINAL.has(process.status)
372
- && process.status !== ZEROPS_PROCESS_FINISHED
373
- ) {
374
- return { state: 'failed' }
375
- }
266
+ const assertVersionCoordinates = (environment: ProviderEnvironment, appVersionId: string, version: ZeropsAppVersion): void => {
267
+ const serviceId = decodeEnvironment(environment).target.serviceId
268
+ if (version.id !== appVersionId || (version.serviceStackId !== undefined && version.serviceStackId !== serviceId)) {
269
+ throw new Error('Zerops app-version response has different coordinates')
376
270
  }
377
- const version = await api.getAppVersion({ appVersionId: state.appVersionId, signal })
378
- if (version.id !== state.appVersionId) throw new Error('Zerops app-version response has different coordinates')
379
- if (version.status === ZEROPS_ACTIVE) {
380
- await reconcileActiveSchema(input, placement, signal)
381
- return { state: 'succeeded' }
382
- }
383
- if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) return { state: 'failed' }
384
- return { state: 'running' }
385
271
  }
386
272
 
387
- const assertSourceBinding = (
388
- input: Pick<ProviderDeployInput, 'app'>,
389
- binding: ZeropsSourceTransportBinding | undefined,
390
- ): void => {
391
- const connectionId = input.app.source.githubConnectionId
392
- const installationId = input.app.source.githubInstallationId
393
- if (binding === undefined) {
394
- if (connectionId !== undefined || installationId !== undefined) {
395
- throw new Error('Zerops private source requires an explicit transport binding')
396
- }
397
- return
398
- }
399
- if (connectionId !== binding.connectionId || installationId !== binding.installationId) {
400
- throw new Error('Zerops source transport binding has different application coordinates')
401
- }
273
+ /** Best-effort: a refused or slow cancel-build must not decide the outcome. */
274
+ const cancelBuildQuietly = async (appVersionId: string, signal: AbortSignal): Promise<void> => {
275
+ signal.throwIfAborted()
276
+ await api.cancelBuild({ appVersionId, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }).catch(() => {})
402
277
  }
403
278
 
404
- const resolveSource = async (
405
- input: ProviderSourceResolutionInput,
406
- binding?: ZeropsSourceTransportBinding,
407
- ): Promise<ProviderSourceResolution> => {
408
- assertSourceBinding(input, binding)
409
- const registration = normalizeRegistration({ app: input.app, environment: input.environment })
410
- const artifact = decodeEnvelope('artifact', registration.environment.artifact, zeropsArtifactCodec)
279
+ const resolveSource = async (input: ProviderSourceResolutionInput): Promise<ProviderSourceResolution> => {
280
+ const binding = privateBinding(input.sourceBinding)
281
+ assertEnvironmentOfApp(input.app, input.environment)
282
+ decodeEnvironment(input.environment)
283
+ const artifact = artifactFor(input.environment)
411
284
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
412
- const base = {
413
- runId: input.runId,
414
- repository: normalizeZeropsSourceRepository(registration.app.source.repoUrl),
415
- requestedRef: registration.app.source.ref,
285
+ const result = await options.source.resolve({
286
+ repository: normalizeZeropsSourceRepository(input.app.source.repoUrl),
287
+ requestedRef: input.app.source.ref,
416
288
  ...(input.expectedCommitSha === undefined ? {} : { expectedCommitSha: input.expectedCommitSha }),
289
+ ...(binding === undefined ? {} : { privateBinding: binding }),
417
290
  descriptorSha256: artifact.target.sourceDescriptor.sha256,
418
291
  signal: input.signal,
419
- }
420
- let result: ZeropsSourceResolveResult
421
- if (binding === undefined) {
422
- result = await options.source.resolve(base)
423
- } else {
424
- const resolveV2 = options.source.resolveV2
425
- if (resolveV2 === undefined) throw new Error('Zerops keyed source transport is unavailable')
426
- result = await resolveV2.call(options.source, {
427
- ...base,
428
- privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId },
429
- })
430
- }
292
+ })
431
293
  if (
432
- result.runId !== input.runId
433
- || result.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
294
+ result.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
434
295
  || (input.expectedCommitSha !== undefined && result.commitSha !== input.expectedCommitSha)
435
296
  ) throw new Error('Zerops source resolution returned different coordinates')
436
297
  return { commitSha: result.commitSha }
437
298
  }
438
299
 
439
- const controlProvider: ZeropsControlProvider = {
300
+ const controlProvider: ControlProvider = {
440
301
  id: 'zerops',
441
302
  normalizeRegistration,
303
+ source: { binding: 'connection' },
442
304
  /**
443
305
  * A Zerops artifact IS the compiled manifest, so the declaration is right here and core can refuse
444
306
  * a variable this app would never read. An artifact that will not decode answers `undefined`
@@ -447,26 +309,20 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
447
309
  */
448
310
  declaredVariables: ({ artifact }) => {
449
311
  try {
450
- return parseFabrikaManifest(decodeEnvelope('artifact', artifact, zeropsArtifactCodec)).app.pipeline.vars
312
+ return decodeEnvelope('artifact', 'zerops', artifact, zeropsArtifactCodec).app.pipeline.vars
451
313
  } catch {
452
314
  return undefined
453
315
  }
454
316
  },
455
- resolveSource: (input) => resolveSource(input),
456
- resolveSourceWithBinding: (input) => resolveSource(input, input.sourceBinding),
457
- deploy: async (input: ProviderDeployInput): Promise<ProviderTerminalOutcome> => {
458
- assertSourceBinding(input, sourceBindings.get(input.runId))
459
- const registration = normalizeRegistration({ app: input.app, environment: input.environment })
460
- const placement = resolvedEnvironment(registration.environment, { requireReady: true })
461
- const proxyServiceId = placement.proxyServiceId
462
- if (proxyServiceId === undefined) {
463
- throw new Error(`Zerops deployment namespace \`${placement.namespace.id}\` has no proxy service id`)
464
- }
465
- const proxyBuildFromGit = placement.proxyBuildFromGit
466
- if (proxyBuildFromGit === undefined) {
467
- throw new Error(`Zerops deployment namespace \`${placement.namespace.id}\` has no proxy build source`)
468
- }
469
- const artifact = decodeEnvelope('artifact', registration.environment.artifact, zeropsArtifactCodec)
317
+ resolveSource,
318
+ deploy: async (input: ProviderDeployInput): Promise<ProviderDeployOutcome> => {
319
+ input.signal.throwIfAborted()
320
+ assertAuthoredManagedInputs({ variableNames: Object.keys(input.vars), secretNames: Object.keys(input.secrets) })
321
+ const managedEnvironment = parseManagedValues(input.managedEnvironment)
322
+ const binding = privateBinding(input.sourceBinding)
323
+ assertEnvironmentOfApp(input.app, input.environment)
324
+ const placement = resolvedEnvironment(input.environment, { requireReady: true })
325
+ const artifact = artifactFor(input.environment)
470
326
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
471
327
  let source: ZeropsRuntimeTarget['source']
472
328
  if (!input.dryRun) {
@@ -477,54 +333,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
477
333
  runId: input.runId,
478
334
  repository: normalizeZeropsSourceRepository(input.app.source.repoUrl),
479
335
  commitSha: input.app.source.ref,
480
- ...(input.app.source.githubInstallationId === undefined
481
- ? {}
482
- : { githubInstallationId: input.app.source.githubInstallationId }),
483
- }
484
- }
485
- if (!input.dryRun) {
486
- await options.beforeDeploy?.({
487
- appId: input.app.id,
488
- env: input.environment.env,
489
- namespaceId: placement.namespace.id,
490
- target: {
491
- serviceId: placement.target.serviceId,
492
- projectId: placement.projectId,
493
- proxyServiceId,
494
- proxyBuildFromGit,
495
- },
496
- artifact,
497
- api,
498
- signal: input.signal,
499
- })
500
- }
501
- const managedEnvironment = input.managedEnvironment
502
- const managedEntries = Object.entries(managedEnvironment).sort(([left], [right]) => left.localeCompare(right))
503
- const managedNames = managedEntries.map(([name]) => name)
504
- if (input.dryRun) {
505
- if (managedNames.length > 0) {
506
- input.events.log(
507
- ` [dry-run] would reconcile managed environment on service ${placement.target.serviceId}: ${managedNames.join(', ')}`,
508
- )
509
- }
510
- } else {
511
- const removedNames = new Set(managedEntries.filter(([, value]) => value === null).map(([name]) => name))
512
- const existing = removedNames.size === 0
513
- ? []
514
- : await api.listServiceEnv({ serviceId: placement.target.serviceId, signal: input.signal })
515
- for (const [name, value] of managedEntries) {
516
- if (value === null) {
517
- for (const item of existing) {
518
- if (item.key === name) await api.deleteServiceEnv({ envId: item.id, signal: input.signal })
519
- }
520
- } else {
521
- await api.putServiceEnv({
522
- serviceId: placement.target.serviceId,
523
- key: name,
524
- value,
525
- signal: input.signal,
526
- })
527
- }
336
+ ...(binding === undefined ? {} : { privateBinding: binding }),
528
337
  }
529
338
  }
530
339
  const runtimeTarget: ZeropsRuntimeTarget = {
@@ -536,148 +345,100 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
536
345
  ...(options.propustkaUrl !== undefined ? { propustkaUrl: options.propustkaUrl } : {}),
537
346
  ...(options.adminKey !== undefined ? { adminKey: options.adminKey } : {}),
538
347
  }
539
- return options.execute(runtimeProvider.runtime, {
348
+ const result = await deployRun({
540
349
  appId: input.app.id,
541
350
  env: input.environment.env,
542
- ...(input.environment.domain !== undefined ? { domain: input.environment.domain } : {}),
543
351
  ...(input.returnOrigins === undefined ? {} : { returnOrigins: input.returnOrigins }),
544
- cwd: input.app.source.workerDir ?? '.',
545
352
  secrets: input.secrets,
546
353
  vars: input.vars,
547
- managedEnvironment: {},
354
+ managedEnvironment,
355
+ target: runtimeTarget,
356
+ artifact,
548
357
  dryRun: input.dryRun,
549
358
  signal: input.signal,
550
359
  events: input.events,
551
- target: runtimeProvider.encodeTarget(runtimeTarget),
552
- artifact: registration.environment.artifact,
553
360
  })
361
+ input.signal.throwIfAborted()
362
+ return { state: result.status }
554
363
  },
555
- deployWithBinding: async (input) => {
556
- const { sourceBinding, ...deployInput } = input
557
- assertSourceBinding(deployInput, sourceBinding)
558
- if (sourceBindings.has(input.runId)) throw new Error('Zerops source transport binding is already active for this run')
559
- sourceBindings.set(input.runId, sourceBinding)
560
- try {
561
- return await controlProvider.deploy(deployInput)
562
- } finally {
563
- if (sourceBindings.get(input.runId) === sourceBinding) sourceBindings.delete(input.runId)
564
- }
565
- },
566
- cancel: async (input) => {
567
- resolvedEnvironment(input.environment, { requireReady: false })
568
- const state = parseRunState(input.providerState, input.externalId)
569
- await cancelSourceBestEffort(input.runId, state.appVersionId)
570
- if (state.phase === 'build_triggered') {
571
- await api.cancelBuild({ appVersionId: state.appVersionId, signal: abortSignal() })
572
- return
573
- }
574
- if (state.phase === 'build_trigger_requested') {
575
- await (options.sleep ?? defaultSleep)(BUILD_TRIGGER_CONSISTENCY_MS, abortSignal())
576
- const version = await api.getAppVersion({ appVersionId: state.appVersionId, signal: abortSignal() })
577
- if (version.id !== state.appVersionId) throw new Error('Zerops app-version response has different coordinates')
578
- if (version.status === undefined) {
579
- throw new Error('Zerops app-version status is not observable yet')
364
+ cancel: async (input): Promise<ProviderCancelOutcome> => {
365
+ const signal = input.signal
366
+ signal.throwIfAborted()
367
+ const checkpoint = runCheckpoint(input)
368
+ if (checkpoint === null) return { state: 'settled', outcome: 'failed' }
369
+ if (!checkpoint.triggered) {
370
+ try {
371
+ await deleteVersion(api, checkpoint.appVersionId, signal)
372
+ } catch {
373
+ signal.throwIfAborted()
374
+ return { state: 'pending' }
580
375
  }
581
- if (version.status !== 'UPLOADING') {
582
- await api.cancelBuild({ appVersionId: state.appVersionId, signal: abortSignal() })
583
- return
376
+ return { state: 'settled', outcome: 'failed' }
377
+ }
378
+ const version = await getVersion({ appVersionId: checkpoint.appVersionId, signal })
379
+ if (version === null) return { state: 'settled', outcome: 'failed' }
380
+ assertVersionCoordinates(input.environment, checkpoint.appVersionId, version)
381
+ // Cancellation never implies rollback: a version that activated first makes the run a success,
382
+ // which finishes the same IAM touchpoint a deploy does.
383
+ if (version.status === ZEROPS_ACTIVE) {
384
+ try {
385
+ await reconcileActiveSchema(input, signal)
386
+ } catch {
387
+ signal.throwIfAborted()
388
+ return { state: 'pending' }
584
389
  }
390
+ return { state: 'settled', outcome: 'succeeded' }
585
391
  }
586
- await api.deleteAppVersion({ appVersionId: state.appVersionId, signal: abortSignal() })
392
+ if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) return { state: 'settled', outcome: 'failed' }
393
+ await cancelBuildQuietly(checkpoint.appVersionId, signal)
394
+ // A build request that never landed leaves the version UPLOADING for good; the deadline settles it.
395
+ return pastDeadline(input.startedAtMs) ? { state: 'settled', outcome: 'failed' } : { state: 'pending' }
587
396
  },
588
397
  reconcile: async (input): Promise<ProviderReconcileOutcome> => {
589
- const signal = abortSignal()
590
- const placement = resolvedEnvironment(input.environment, { requireReady: false })
591
- const state = parseRunState(input.providerState, input.externalId)
592
- if (state.phase === 'version_created') {
593
- await cancelSourceBestEffort(input.runId, state.appVersionId)
594
- await api.deleteAppVersion({ appVersionId: state.appVersionId, signal: abortSignal() })
398
+ const signal = input.signal
399
+ signal.throwIfAborted()
400
+ const log = async (line: string): Promise<void> => {
401
+ await input.log?.(line).catch(() => {})
402
+ }
403
+ const checkpoint = runCheckpoint(input)
404
+ if (checkpoint === null) {
405
+ await log('unrecognised checkpoint; failing without platform calls')
595
406
  return { state: 'failed' }
596
407
  }
597
- if (state.phase === 'source_uploaded') {
598
- let artifact: FabrikaManifest
599
- try {
600
- artifact = artifactFor(input.environment)
601
- await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
602
- } catch {
603
- await cancelSourceBestEffort(input.runId, state.appVersionId)
604
- await api.deleteAppVersion({ appVersionId: state.appVersionId, signal: abortSignal() })
605
- return { state: 'failed' }
606
- }
607
- try {
608
- await input.checkpoint(runState(state.appVersionId, 'build_trigger_requested'))
609
- } catch {
610
- return { state: 'running' }
611
- }
612
- let process: ZeropsProcess
613
- try {
614
- process = await api.buildAndDeployAppVersion({
615
- appVersionId: state.appVersionId,
616
- zeropsYaml: artifact.target.sourceDescriptor.contents,
617
- ...(artifact.target.zeropsSetup === undefined ? {} : { zeropsYamlSetup: artifact.target.zeropsSetup }),
618
- signal,
619
- })
620
- } catch (error) {
621
- if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500) {
622
- await cancelSourceBestEffort(input.runId, state.appVersionId)
623
- await api.deleteAppVersion({ appVersionId: state.appVersionId, signal: abortSignal() })
624
- return { state: 'failed' }
625
- }
626
- let observed: ZeropsAppVersion
627
- try {
628
- await (options.sleep ?? defaultSleep)(BUILD_TRIGGER_CONSISTENCY_MS, signal)
629
- observed = await api.getAppVersion({ appVersionId: state.appVersionId, signal })
630
- } catch {
631
- return { state: 'running' }
632
- }
633
- if (observed.id !== state.appVersionId || observed.status === undefined) return { state: 'running' }
634
- if (observed.status === 'UPLOADING') {
635
- await cancelSourceBestEffort(input.runId, state.appVersionId)
636
- await api.deleteAppVersion({ appVersionId: state.appVersionId, signal: abortSignal() })
637
- return { state: 'failed' }
638
- }
639
- const triggered = buildTriggeredState(state.appVersionId)
640
- try {
641
- await input.checkpoint(triggered)
642
- } catch {
643
- return { state: 'running' }
644
- }
645
- return reconcileTriggered(input, triggered, placement, signal)
646
- }
647
- const triggered = buildTriggeredState(state.appVersionId, process.id)
648
- try {
649
- await input.checkpoint(triggered)
650
- } catch {
651
- return { state: 'running' }
652
- }
653
- return reconcileTriggered(input, triggered, placement, signal)
408
+ if (!checkpoint.triggered) {
409
+ // A throw keeps the run running; the next maintenance pass retries the deletion.
410
+ await deleteVersion(api, checkpoint.appVersionId, signal)
411
+ await log(`deleted app-version ${checkpoint.appVersionId}; its build was never requested`)
412
+ return { state: 'failed' }
654
413
  }
655
- if (state.phase === 'build_trigger_requested') {
656
- let version: ZeropsAppVersion
657
- try {
658
- await (options.sleep ?? defaultSleep)(BUILD_TRIGGER_CONSISTENCY_MS, signal)
659
- version = await api.getAppVersion({ appVersionId: state.appVersionId, signal })
660
- } catch {
661
- return { state: 'running' }
662
- }
663
- if (version.id !== state.appVersionId || version.status === undefined) return { state: 'running' }
664
- if (version.status === 'UPLOADING') {
665
- await cancelSourceBestEffort(input.runId, state.appVersionId)
666
- await api.deleteAppVersion({ appVersionId: state.appVersionId, signal: abortSignal() })
667
- return { state: 'failed' }
668
- }
669
- const triggered = buildTriggeredState(state.appVersionId)
670
- try {
671
- await input.checkpoint(triggered)
672
- } catch {
673
- return { state: 'running' }
674
- }
675
- return reconcileTriggered(input, triggered, placement, signal)
414
+ let version: ZeropsAppVersion | null
415
+ try {
416
+ version = await getVersion({ appVersionId: checkpoint.appVersionId, signal })
417
+ } catch {
418
+ signal.throwIfAborted()
419
+ if (!pastDeadline(input.startedAtMs)) return { state: 'running' }
420
+ await cancelBuildQuietly(checkpoint.appVersionId, signal)
421
+ await log(`abandoned app-version ${checkpoint.appVersionId}: its status stayed unobservable past the run deadline`)
422
+ return { state: 'failed' }
423
+ }
424
+ if (version === null) return { state: 'failed' }
425
+ assertVersionCoordinates(input.environment, checkpoint.appVersionId, version)
426
+ if (version.status === ZEROPS_ACTIVE) {
427
+ await reconcileActiveSchema(input, signal)
428
+ return { state: 'succeeded' }
676
429
  }
677
- return reconcileTriggered(input, state, placement, signal)
430
+ if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) return { state: 'failed' }
431
+ if (pastDeadline(input.startedAtMs)) {
432
+ await cancelBuildQuietly(checkpoint.appVersionId, signal)
433
+ await log(`abandoned non-terminal app-version ${checkpoint.appVersionId} (${version.status ?? 'unknown'})`)
434
+ return { state: 'failed' }
435
+ }
436
+ return { state: 'running' }
678
437
  },
679
438
  secrets: {
680
439
  put: async (input) => {
440
+ assertAuthoredManagedName(input.name)
441
+ assertOwnReferencesOnly(input.environment, input.name, input.value)
681
442
  const target = decodeEnvironment(input.environment).target
682
443
  await api.putServiceEnv({
683
444
  serviceId: target.serviceId,
@@ -688,6 +449,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
688
449
  return { valueRef: `zerops:${target.serviceId}/${encodeURIComponent(input.name)}` }
689
450
  },
690
451
  delete: async (input) => {
452
+ assertAuthoredManagedName(input.name)
691
453
  const target = decodeEnvironment(input.environment).target
692
454
  const variables = await api.listServiceEnv({ serviceId: target.serviceId, signal: abortSignal() })
693
455
  const found = variables.find((variable) => variable.key === input.name)
@@ -707,13 +469,11 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
707
469
  throw new Error(`Zerops deployment namespace \`${namespace.id}\` is not ready`)
708
470
  }
709
471
  const projectId = namespaceTarget.projectId
710
- const manifest = parseFabrikaManifest(
711
- decodeEnvelope('artifact', input.registration.environment.artifact, zeropsArtifactCodec),
712
- {
713
- appId: input.registration.app.id,
714
- env: input.registration.environment.env,
715
- },
716
- )
472
+ const manifest = decodeEnvelope('artifact', 'zerops', input.registration.environment.artifact, zeropsArtifactCodec)
473
+ assertFabrikaManifestCoordinates(manifest.app, {
474
+ appId: input.registration.app.id,
475
+ env: input.registration.environment.env,
476
+ })
717
477
  const hostnames = manifestServiceHostnames(manifest)
718
478
  const observed = await Promise.all(hostnames.map(async (hostname) => ({
719
479
  hostname,
@@ -723,20 +483,30 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
723
483
  if (present.length !== 0 && present.length !== observed.length) {
724
484
  throw new Error(`Zerops registration found only ${present.length} of ${observed.length} declared services`)
725
485
  }
726
- // startWithoutCode is safe only before the namespace contains an application service.
486
+ const existingDeploy = observed.find((entry) => entry.hostname === manifest.target.deployService)?.service
487
+ if (present.length > 0 && (existingDeploy === null || existingDeploy === undefined)) {
488
+ throw new Error('Zerops registration has no deploy service')
489
+ }
490
+ input.signal.throwIfAborted()
491
+ // The first import refuses name collisions atomically, including services created after the lookup.
727
492
  const imported = await api.importServices({
728
493
  projectId,
729
494
  yaml: present.length === 0 ? renderFabrikaProvisioningYaml(manifest) : renderFabrikaImportYaml(manifest),
730
495
  signal: input.signal,
731
496
  })
497
+ for (const service of imported.services) {
498
+ for (const process of service.processes) {
499
+ await waitForProcess({
500
+ api,
501
+ processId: process.id,
502
+ sleep: options.sleep ?? defaultSleep,
503
+ signal: input.signal,
504
+ label: `the ${service.name} import`,
505
+ })
506
+ }
507
+ }
732
508
  const reported = imported.services.find((service) => service.name === manifest.target.deployService)
733
- const observedDeployService = observed.find((entry) => entry.hostname === manifest.target.deployService)?.service
734
- const discovered = reported ?? observedDeployService
735
- ?? await api.findService({
736
- projectId,
737
- hostname: manifest.target.deployService,
738
- signal: input.signal,
739
- })
509
+ const discovered = present.length === 0 ? reported : existingDeploy
740
510
  if (discovered === null || discovered === undefined || discovered.id === '') {
741
511
  throw new Error(`Zerops import did not create deploy service \`${manifest.target.deployService}\``)
742
512
  }