@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
@@ -0,0 +1,482 @@
1
+ import {
2
+ type ProviderDeploymentNamespace,
3
+ type ProviderNamespaceCapabilities,
4
+ type ProviderNamespaceHost,
5
+ type ProviderNamespaceMutationInput,
6
+ type ProviderNamespaceRepublishInput,
7
+ type ProviderNamespaceRepublishResult,
8
+ } from '@fabrika/provider-contract'
9
+ import { waitForProcess, ZEROPS_SERVICE_NOT_HTTP, ZEROPS_TERMINAL, ZeropsApiError, type ZeropsProject, type ZeropsService } from '../api'
10
+ import { defaultSleep } from '../collaborators'
11
+ import { namespaceResourceClaims, registrationResourceClaims } from './claims'
12
+ import { checkpoint, decodeEnvelope, normalizedNamespace, normalizeTarget } from './codec'
13
+ import { ZEROPS_NAMESPACE_POSTGRES_HOSTNAME, ZEROPS_NAMESPACE_PROXY_HOSTNAME, ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE } from './constants'
14
+ import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED, ZEROPS_SUBDOMAIN_NOT_PUBLISHED } from './errors'
15
+ import { compileNamespaceProxyManifest } from './manifest'
16
+ import { createZeropsNamespaceOperator } from './operator'
17
+ import { republishProxy } from './proxy-attempt'
18
+ import { verifyZeropsNamespaceProxySource } from './proxy-source'
19
+ import { compileZeropsNamespaceTopology, projectMarker, PROXY_TYPE } from './topology'
20
+ import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespaceProxySource } from './types'
21
+
22
+ const POLL_INTERVAL_MS = 3000
23
+
24
+ /** The live enable finishes in under a second, but one run still read `false` once before flipping; ten reads is slack. */
25
+ const SUBDOMAIN_READBACK_ATTEMPTS = 10
26
+
27
+ /**
28
+ * One host per published HTTP port, out of the newline-separated URLs the platform writes.
29
+ *
30
+ * The form is `<hostname>-<hash>-<port>.<region>.zerops.app` (e.g. `proxy-2ec8-8080`). Read as URLs
31
+ * rather than split on `-`, so a hostname whose own first label ends in digits cannot be misread; a line
32
+ * that is not a URL, or whose first label carries no trailing port — the single portless line a proxy
33
+ * that has never deployed publishes — is skipped rather than guessed at.
34
+ */
35
+ const parseZeropsProxyHosts = (value: string): ProviderNamespaceHost[] => {
36
+ const hosts: ProviderNamespaceHost[] = []
37
+ for (const line of value.split('\n')) {
38
+ const trimmed = line.trim()
39
+ if (trimmed === '') continue
40
+ let hostname: string
41
+ try {
42
+ hostname = new URL(trimmed).hostname
43
+ } catch {
44
+ continue
45
+ }
46
+ const port = /-(\d+)$/.exec(hostname.split('.')[0] ?? '')?.[1]
47
+ if (port === undefined) continue
48
+ hosts.push({ host: hostname, port: Number.parseInt(port, 10) })
49
+ }
50
+ return hosts.sort((left, right) => left.port - right.port)
51
+ }
52
+
53
+ const validateProject = (
54
+ namespace: ProviderDeploymentNamespace,
55
+ target: NormalizedZeropsNamespaceTarget,
56
+ project: ZeropsProject,
57
+ adopting: boolean,
58
+ ): void => {
59
+ if (project.id === '') {
60
+ throw new Error('Zerops namespace project returned an empty id')
61
+ }
62
+ if (target.projectName !== project.name) {
63
+ throw new Error(`Zerops namespace project name mismatch: expected \`${target.projectName}\`, got \`${project.name}\``)
64
+ }
65
+ if (project.mode !== target.corePackage) {
66
+ throw new Error(`Zerops namespace project ${project.id} uses ${project.mode ?? 'an unknown core package'}, expected ${target.corePackage}`)
67
+ }
68
+ // Zerops does not expose project envIsolation. Every managed service import writes its own override.
69
+ if (!adopting && target.managed && project.description?.includes(projectMarker(namespace)) !== true) {
70
+ throw new Error(`Zerops namespace project ${project.id} has a different Fabrika ownership marker`)
71
+ }
72
+ }
73
+
74
+ const managedProjectByMarker = async (
75
+ namespace: ProviderDeploymentNamespace,
76
+ target: NormalizedZeropsNamespaceTarget,
77
+ options: ZeropsNamespaceOptions,
78
+ signal: AbortSignal,
79
+ ): Promise<ZeropsProject | null> => {
80
+ const matches = await options.api.findProjects({
81
+ clientId: options.clientId,
82
+ name: target.projectName,
83
+ signal,
84
+ })
85
+ const owned: ZeropsProject[] = []
86
+ for (const match of matches) {
87
+ const project = await options.api.getProject({ projectId: match.id, signal })
88
+ if (project.description?.includes(projectMarker(namespace)) === true) {
89
+ owned.push(project)
90
+ }
91
+ }
92
+ if (owned.length > 1) {
93
+ throw new Error(`Zerops namespace project name \`${target.projectName}\` has multiple matching Fabrika ownership markers`)
94
+ }
95
+ if (owned.length === 1) {
96
+ return owned[0] ?? null
97
+ }
98
+ if (matches.length > 0) {
99
+ throw new Error(`Zerops namespace project name \`${target.projectName}\` is already used without this namespace's ownership marker`)
100
+ }
101
+ return null
102
+ }
103
+
104
+ const uniqueService = (services: ZeropsService[], hostname: string): ZeropsService | null => {
105
+ const matches = services.filter((service) => service.name === hostname)
106
+ if (matches.length > 1) {
107
+ throw new Error(`Zerops namespace has multiple services named \`${hostname}\``)
108
+ }
109
+ return matches[0] ?? null
110
+ }
111
+
112
+ const validateService = (
113
+ service: ZeropsService,
114
+ projectId: string,
115
+ expectedBase: string,
116
+ adopting: boolean,
117
+ expectedPublic?: boolean,
118
+ expectedProfile?: string,
119
+ ): void => {
120
+ if (service.id === '') {
121
+ throw new Error(`Zerops namespace service \`${service.name}\` returned an empty id`)
122
+ }
123
+ if (service.projectId !== undefined && service.projectId !== projectId) {
124
+ throw new Error(`Zerops namespace service \`${service.name}\` belongs to another project`)
125
+ }
126
+ if ((adopting || service.base !== undefined) && service.base !== expectedBase) {
127
+ throw new Error(`Zerops namespace service \`${service.name}\` uses ${service.base ?? 'an unknown type'}, expected ${expectedBase}`)
128
+ }
129
+ if (expectedPublic !== undefined && adopting && service.subdomainAccess !== expectedPublic) {
130
+ throw new Error(`Zerops namespace service \`${service.name}\` has incompatible public access`)
131
+ }
132
+ if (expectedProfile !== undefined && service.autoscalingProfileId !== expectedProfile) {
133
+ throw new Error(
134
+ `Zerops namespace service \`${service.name}\` uses profile ${service.autoscalingProfileId ?? 'unknown'}, expected ${expectedProfile}`,
135
+ )
136
+ }
137
+ }
138
+
139
+ const resolveServices = async (
140
+ namespace: ProviderDeploymentNamespace,
141
+ target: NormalizedZeropsNamespaceTarget,
142
+ options: ZeropsNamespaceOptions,
143
+ signal: AbortSignal,
144
+ adopting: boolean,
145
+ ): Promise<{ proxy: ZeropsService; postgres?: ZeropsService }> => {
146
+ const projectId = target.projectId
147
+ if (projectId === undefined) {
148
+ throw new Error('Zerops namespace has no project id')
149
+ }
150
+ const services = await options.api.listProjectServices({ projectId, signal })
151
+ const proxy = uniqueService(services, ZEROPS_NAMESPACE_PROXY_HOSTNAME)
152
+ if (proxy === null) {
153
+ throw new Error(`Zerops namespace project ${projectId} has no proxy service`)
154
+ }
155
+ validateService(
156
+ proxy,
157
+ projectId,
158
+ PROXY_TYPE,
159
+ adopting,
160
+ target.publicAccess === 'zerops-subdomain',
161
+ )
162
+ const postgres = uniqueService(services, ZEROPS_NAMESPACE_POSTGRES_HOSTNAME)
163
+ if (target.postgres === undefined && postgres !== null && adopting) {
164
+ throw new Error(`Zerops namespace project ${projectId} has an undeclared namespace PostgreSQL service`)
165
+ }
166
+ if (target.postgres !== undefined) {
167
+ if (postgres === null) {
168
+ throw new Error(`Zerops namespace project ${projectId} has no shared PostgreSQL service`)
169
+ }
170
+ validateService(postgres, projectId, target.postgres.type, adopting, undefined, target.postgres.profile)
171
+ }
172
+ if (adopting && namespace.exclusiveAppId !== undefined) {
173
+ const reserved = new Set([
174
+ ZEROPS_NAMESPACE_PROXY_HOSTNAME,
175
+ ...(target.postgres === undefined ? [] : [ZEROPS_NAMESPACE_POSTGRES_HOSTNAME]),
176
+ ])
177
+ const unexpected = services.filter((service) => !reserved.has(service.name))
178
+ if (unexpected.length > 0) {
179
+ throw new Error(`exclusive Zerops namespace project ${projectId} contains unmanaged service \`${unexpected[0]?.name ?? 'unknown'}\``)
180
+ }
181
+ }
182
+ return postgres === null ? { proxy } : { proxy, postgres }
183
+ }
184
+
185
+ /**
186
+ * The hosts the proxy publishes, off the platform-owned `zeropsSubdomain` variable.
187
+ *
188
+ * Read through the same `/env` listing every other namespace variable uses, and read AFTER the proxy is
189
+ * published: the variable names a host per DEPLOYED HTTP port, so a proxy that never deployed names none.
190
+ * Recording the answer in the target is what keeps `namespaces get` a database read.
191
+ */
192
+ const readProxyHosts = async (
193
+ namespace: ProviderDeploymentNamespace,
194
+ target: NormalizedZeropsNamespaceTarget,
195
+ options: ZeropsNamespaceOptions,
196
+ signal: AbortSignal,
197
+ ): Promise<readonly ProviderNamespaceHost[]> => {
198
+ const proxyServiceId = target.proxyServiceId
199
+ if (proxyServiceId === undefined) {
200
+ throw new Error('Zerops namespace has no proxy service id')
201
+ }
202
+ try {
203
+ const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
204
+ const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
205
+ return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains.content)
206
+ } catch {
207
+ // A host listing is a convenience; the proxy is already built, deployed and published. Failing the
208
+ // namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
209
+ // upstream text this provider has not redacted.
210
+ console.warn(
211
+ `Zerops namespace ${namespace.id}: reading the published proxy hosts failed; the placement is ready and lists none until its next reconcile`,
212
+ )
213
+ return []
214
+ }
215
+ }
216
+
217
+ const resolveProxySource = async (source: ZeropsNamespaceProxySource, options: ZeropsNamespaceOptions, signal: AbortSignal): Promise<void> => {
218
+ if (options.source === undefined) throw new Error('Zerops namespace source client is required')
219
+ signal.throwIfAborted()
220
+ const resolved = await options.source.resolve({
221
+ repository: source.repository,
222
+ requestedRef: source.commitSha,
223
+ expectedCommitSha: source.commitSha,
224
+ descriptorSha256: source.descriptor.sha256,
225
+ signal,
226
+ })
227
+ if (resolved.commitSha !== source.commitSha || resolved.descriptorSha256 !== source.descriptor.sha256) {
228
+ throw new Error('Proxy source resolution identity mismatch')
229
+ }
230
+ }
231
+
232
+ const deployProxy = async (
233
+ input: ProviderNamespaceMutationInput,
234
+ namespace: ProviderDeploymentNamespace,
235
+ target: NormalizedZeropsNamespaceTarget,
236
+ options: ZeropsNamespaceOptions,
237
+ wait: ProviderNamespaceRepublishInput['wait'],
238
+ ): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
239
+ let currentNamespace = namespace
240
+ const result = await republishProxy({
241
+ target,
242
+ manifest: compileNamespaceProxyManifest(namespace.id, input.members),
243
+ ...(options.source === undefined ? {} : { source: options.source }),
244
+ api: options.api,
245
+ iamUrl: options.iamUrl,
246
+ iamKey: options.iamKey,
247
+ signal: input.signal,
248
+ wait,
249
+ sleep: options.sleep ?? defaultSleep,
250
+ boundary: () => input.events.boundary(),
251
+ checkpoint: async next => {
252
+ currentNamespace = await checkpoint(input, currentNamespace, { ...target, ...next })
253
+ },
254
+ })
255
+ return { namespace: currentNamespace, target: { ...target, ...result } }
256
+ }
257
+
258
+ /** Rebuild a ready namespace's proxy from its current members; provisioning stays with `provision`/`reconcile`. */
259
+ const republishNamespace = async (
260
+ input: ProviderNamespaceRepublishInput,
261
+ options: ZeropsNamespaceOptions,
262
+ ): Promise<ProviderNamespaceRepublishResult> => {
263
+ const namespace = normalizedNamespace(input.namespace, options)
264
+ const target = normalizeTarget(namespace, options)
265
+ if (target.proxyServiceId === undefined || (target.proxyBuild === undefined && !target.ready)) {
266
+ throw new Error(`Namespace ${namespace.id} is not ready`)
267
+ }
268
+ const deployed = await deployProxy(input, namespace, target, options, input.wait)
269
+ return { namespace: deployed.namespace, settled: deployed.target.proxyBuild === undefined && deployed.target.ready }
270
+ }
271
+
272
+ /**
273
+ * Publish the proxy on its `*.zerops.app` subdomain — the ONE thing the import document cannot do.
274
+ *
275
+ * `enableSubdomainAccess: true` is accepted by the import and then silently dropped, on a service the
276
+ * document CREATES as much as on one `override` leaves alone; a service imported with it reads back
277
+ * `subdomainAccess: false` for ever. The subdomain is established by
278
+ * `PUT /service-stack/{id}/enable-subdomain-access` and by nothing else, and that call needs a DEPLOYED
279
+ * HTTP port — which is why this runs after `deployProxy` rather than beside the import.
280
+ *
281
+ * It runs on RECONCILE too, so a subdomain someone turned off in the GUI comes back. What it must never
282
+ * do is finish quietly: `zerops-subdomain` is the only public entry a namespace has, so this either
283
+ * observes `subdomainAccess === true` or throws naming what is missing.
284
+ *
285
+ * The decision is the READ-BACK, never the call returning. On an already-published service the platform
286
+ * answers 2xx and then fails the process it handed back — live-verified — so a 2xx proves nothing.
287
+ */
288
+ const ensureSubdomainAccess = async (
289
+ target: NormalizedZeropsNamespaceTarget,
290
+ options: ZeropsNamespaceOptions,
291
+ signal: AbortSignal,
292
+ ): Promise<void> => {
293
+ const proxyServiceId = target.proxyServiceId
294
+ if (proxyServiceId === undefined) {
295
+ throw new Error('Zerops namespace has no proxy service id')
296
+ }
297
+ const published = async (): Promise<boolean> => (await options.api.getService({ serviceId: proxyServiceId, signal })).subdomainAccess === true
298
+ if (await published()) {
299
+ return
300
+ }
301
+ try {
302
+ await options.api.enableSubdomainAccess({ serviceId: proxyServiceId, signal })
303
+ } catch (error) {
304
+ if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
305
+ throw namespaceError(
306
+ ZEROPS_SERVICE_NOT_HTTP,
307
+ `Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
308
+ false,
309
+ )
310
+ }
311
+ throw error
312
+ }
313
+ for (let attempt = 0;; attempt += 1) {
314
+ if (signal.aborted) {
315
+ throw namespaceError(ZEROPS_NAMESPACE_CANCELLED, 'Zerops namespace provisioning was cancelled', true)
316
+ }
317
+ if (await published()) {
318
+ return
319
+ }
320
+ if (attempt >= SUBDOMAIN_READBACK_ATTEMPTS) {
321
+ throw namespaceError(
322
+ ZEROPS_SUBDOMAIN_NOT_PUBLISHED,
323
+ `Zerops namespace proxy ${proxyServiceId} accepted enable-subdomain-access but still reads subdomainAccess: false — the namespace has no public entry point`,
324
+ true,
325
+ )
326
+ }
327
+ await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
328
+ }
329
+ }
330
+
331
+ /** Every exit from the lifecycle below is a `ProviderNamespaceError`, so a caller never has to guess. */
332
+ const mutateNamespace = async (
333
+ input: ProviderNamespaceMutationInput,
334
+ options: ZeropsNamespaceOptions,
335
+ mode: 'provision' | 'reconcile',
336
+ ): Promise<ProviderDeploymentNamespace> => {
337
+ try {
338
+ return await runNamespaceMutation(input, options)
339
+ } catch (cause) {
340
+ throw asNamespaceError(cause, mode)
341
+ }
342
+ }
343
+
344
+ const runNamespaceMutation = async (
345
+ input: ProviderNamespaceMutationInput,
346
+ options: ZeropsNamespaceOptions,
347
+ ): Promise<ProviderDeploymentNamespace> => {
348
+ const suppliedTarget = decodeEnvelope(input.namespace.target)
349
+ let namespace = normalizedNamespace(input.namespace, options)
350
+ let target = normalizeTarget(namespace, options)
351
+ if (target.proxyBaselineSequence !== undefined || target.proxyAppVersionId !== undefined) {
352
+ throw new Error('Legacy proxy operational checkpoints require explicit migration')
353
+ }
354
+ if (target.proxyBuild !== undefined && (target.projectId === undefined || target.proxyServiceId === undefined)) {
355
+ throw new Error('Proxy build is missing checkpointed project or service identity')
356
+ }
357
+ await verifyZeropsNamespaceProxySource(target.proxySource)
358
+ input.signal.throwIfAborted()
359
+ if (target.proxyBuild === undefined && target.proxyDeployment !== undefined) {
360
+ try {
361
+ const version = await options.api.getAppVersion({ appVersionId: target.proxyDeployment.appVersionId, signal: input.signal })
362
+ if (version.id !== target.proxyDeployment.appVersionId || version.status === undefined || !ZEROPS_TERMINAL.has(version.status)) {
363
+ throw new Error('unresolved')
364
+ }
365
+ } catch {
366
+ input.signal.throwIfAborted()
367
+ await checkpoint(input, namespace, { ...target, ready: false })
368
+ throw namespaceError('proxyAttemptUnresolved', `Proxy version ${target.proxyDeployment.appVersionId} remains unresolved`, true)
369
+ }
370
+ }
371
+ if (target.proxyBuild === undefined && target.proxyDeployment === undefined) await resolveProxySource(target.proxySource, options, input.signal)
372
+ const initiallyAdopted = target.projectId !== undefined && target.managed === false
373
+ && target.proxyServiceId === undefined && target.proxyDeployment === undefined
374
+ const topology = compileZeropsNamespaceTopology(namespace, target)
375
+
376
+ if (target.projectId === undefined) {
377
+ const recovered = await managedProjectByMarker(namespace, target, options, input.signal)
378
+ const result = recovered === null
379
+ ? await options.api.importProject({ clientId: options.clientId, yaml: topology.createYaml, signal: input.signal })
380
+ : { projectId: recovered.id, projectName: recovered.name, services: [] }
381
+ if (result.projectId === '') {
382
+ throw new Error('Zerops namespace project import returned an empty project id')
383
+ }
384
+ target = { ...target, projectId: result.projectId, managed: true }
385
+ namespace = await checkpoint(input, namespace, target)
386
+ for (const service of result.services) {
387
+ for (const process of service.processes) {
388
+ await waitForProcess({
389
+ api: options.api,
390
+ processId: process.id,
391
+ sleep: options.sleep ?? defaultSleep,
392
+ signal: input.signal,
393
+ label: 'namespace import',
394
+ })
395
+ }
396
+ }
397
+ }
398
+
399
+ const projectId = target.projectId
400
+ if (projectId === undefined) {
401
+ throw new Error('Zerops namespace project id was not checkpointed')
402
+ }
403
+ const project = await options.api.getProject({ projectId, signal: input.signal })
404
+ if (target.projectName !== project.name && initiallyAdopted && suppliedTarget.projectName === undefined) {
405
+ target = { ...target, projectName: project.name }
406
+ namespace = await checkpoint(input, namespace, target)
407
+ }
408
+ validateProject(namespace, target, project, initiallyAdopted)
409
+
410
+ if (initiallyAdopted) {
411
+ // Validate before the first mutation; the import below then enforces service-level envIsolation.
412
+ await resolveServices(namespace, target, options, input.signal, true)
413
+ }
414
+ if (target.proxyBuild === undefined) {
415
+ const imported = await options.api.importServices({
416
+ projectId,
417
+ yaml: topology.servicesSteadyYaml,
418
+ signal: input.signal,
419
+ })
420
+ for (const service of imported.services) {
421
+ for (const process of service.processes) {
422
+ await waitForProcess({
423
+ api: options.api,
424
+ processId: process.id,
425
+ sleep: options.sleep ?? defaultSleep,
426
+ signal: input.signal,
427
+ label: 'namespace import',
428
+ })
429
+ }
430
+ }
431
+ }
432
+ const resolved = await resolveServices(namespace, target, options, input.signal, false)
433
+ if (target.proxyServiceId !== undefined && target.proxyServiceId !== resolved.proxy.id) {
434
+ throw new Error('Checkpointed namespace proxy service identity changed')
435
+ }
436
+ target = {
437
+ ...target,
438
+ proxyServiceId: resolved.proxy.id,
439
+ ...(resolved.postgres !== undefined ? { postgresServiceId: resolved.postgres.id } : {}),
440
+ }
441
+ namespace = await checkpoint(input, namespace, target)
442
+
443
+ const deployed = await deployProxy(input, namespace, target, options, 'active')
444
+ namespace = deployed.namespace
445
+ target = { ...deployed.target, proxyConfigured: true }
446
+ // After the deploy, because the platform refuses a service with no HTTP port — and never before `ready`
447
+ // is checkpointed, because a namespace with no public entry point is not ready.
448
+ if (target.publicAccess === 'zerops-subdomain') {
449
+ await ensureSubdomainAccess(target, options, input.signal)
450
+ }
451
+ const proxyHosts = target.publicAccess === 'zerops-subdomain' ? await readProxyHosts(namespace, target, options, input.signal) : []
452
+ target = {
453
+ ...target,
454
+ proxyBuild: undefined,
455
+ // Cleared when the proxy publishes none and on a custom-domain namespace, so the record never
456
+ // outlives what it describes.
457
+ proxyHosts: proxyHosts.length === 0 ? undefined : proxyHosts,
458
+ ready: true,
459
+ }
460
+ return checkpoint(input, namespace, target)
461
+ }
462
+
463
+ /** Build the provider-owned namespace lifecycle capability. */
464
+ export const createZeropsNamespaceCapabilities = (options: ZeropsNamespaceOptions): ProviderNamespaceCapabilities => {
465
+ const required = (name: string, value: string): void => {
466
+ if (value.trim() === '') {
467
+ throw new Error(`Zerops namespace ${name} must be a non-empty string`)
468
+ }
469
+ }
470
+ required('clientId', options.clientId)
471
+ required('iamUrl', options.iamUrl)
472
+ required('iamKey', options.iamKey)
473
+ return {
474
+ normalize: (namespace) => normalizedNamespace(namespace, options),
475
+ namespaceResourceClaims,
476
+ registrationResourceClaims,
477
+ provision: (input) => mutateNamespace(input, options, 'provision'),
478
+ reconcile: (input) => mutateNamespace(input, options, 'reconcile'),
479
+ republish: (input) => republishNamespace(input, options),
480
+ operator: createZeropsNamespaceOperator({ proxySource: options.proxySource }),
481
+ }
482
+ }