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