@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/namespace.ts CHANGED
@@ -1,1260 +1,34 @@
1
- import {
2
- type JsonValue,
3
- type ProviderCodec,
4
- type ProviderDeploymentNamespace,
5
- type ProviderEnvelope,
6
- type ProviderNamespaceCapabilities,
7
- ProviderNamespaceError,
8
- type ProviderNamespaceFact,
9
- type ProviderNamespaceHost,
10
- type ProviderNamespaceMutationInput,
11
- type ProviderNamespaceOperator,
12
- type ProviderNamespacePlanInput,
13
- type ProviderNamespacePresentation,
14
- type ProviderNamespacePreset,
15
- type ProviderRegistration,
16
- } from '@fabrika/provider-contract'
17
- import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
18
- import {
19
- ZEROPS_ACTIVE,
20
- ZEROPS_SERVICE_NOT_HTTP,
21
- ZEROPS_TERMINAL,
22
- type ZeropsApi,
23
- ZeropsApiError,
24
- type ZeropsAppVersion,
25
- type ZeropsProject,
26
- type ZeropsProjectMode,
27
- type ZeropsService,
28
- } from './api'
29
- import { defaultSleep, type Sleeper } from './collaborators'
30
- import { compileImportYaml, compileProvisioningYaml } from './compile'
31
- import { manifestServiceHostnames, parseFabrikaManifest, zeropsArtifactCodec } from './manifest'
32
- import { zeropsSharedServicePrefix } from './service-names'
33
- import type { ZeropsServiceSpec, ZeropsSourceTarget } from './types'
34
-
35
- export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
36
- export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
37
- export const ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE = 'FABRIKA_IAM_ISSUER'
38
- export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
39
- /** The platform-owned variable naming one URL per published HTTP port. READ_ONLY, written by Zerops. */
40
- export const ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
41
- /**
42
- * How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
43
- * defaulted. Every part settled against a live account, none of it safe to infer:
44
- *
45
- * • `${x_connectionTlsString}` is `postgresql://${user}:${password}@${hostname}:${portTls}` — no
46
- * database path. With none, the libpq/postgres.js convention falls back to the USER name.
47
- * • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname
48
- * (checked on a service named `wu2db`). So the fallback lands right today — by construction rather
49
- * than by luck, but still by a rule nobody wrote down. `/${x_dbName}` says it out loud.
50
- * • THE TLS PORT, NOT THE DIRECT ONE, and this is the one that changed. `postgresql:single@18`
51
- * speaks TLS on both; `postgresql:ha@18` speaks it ONLY on `portTls` and answers `sslmode=require`
52
- * on the direct port with "Server does not support SSL". A namespace picks `ha` for `prod` and
53
- * `single` everywhere else, so a DSN naming the direct port is one preset away from failing.
54
- * • `portTls` preserves SESSION state on both — same backend pid across statements, `SET SESSION`
55
- * kept, advisory lock held and visible — so the migration runner's session-level lock is safe
56
- * there. It is a TLS endpoint, not the transaction pool the port number suggests.
57
- * • `verify-full` fails (`self signed certificate`), so `require` is the strongest mode available.
58
- */
59
- export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
60
-
61
- const PROXY_TYPE = 'alpine@3.21'
62
- const EMPTY_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
63
- const POLL_INTERVAL_MS = 3000
64
- const POLL_TIMEOUT_MS = 70 * 60 * 1000
65
- /** The live enable finishes in under a second, but one run still read `false` once before flipping; ten reads is slack. */
66
- const SUBDOMAIN_READBACK_ATTEMPTS = 10
67
-
68
- export type ZeropsNamespacePublicAccess = 'custom-domain' | 'zerops-subdomain'
69
-
70
- export type ZeropsNamespacePostgres =
71
- | {
72
- readonly type: 'postgresql:ha@18'
73
- readonly profile?: 'oltp-staging' | 'oltp-production' | 'oltp-enterprise' | 'olap-production' | 'writeheavy-production'
74
- }
75
- | {
76
- readonly type: 'postgresql:single@18'
77
- readonly profile?: 'oltp-hobby' | 'oltp-staging' | 'oltp-production' | 'olap-production' | 'writeheavy-production'
78
- }
79
-
80
- /**
81
- * Provider-owned namespace state. Optional fields are durable progress checkpoints; values that are
82
- * absent are resolved by `normalize()` before a mutation starts.
83
- */
84
- export interface ZeropsNamespaceTarget {
85
- readonly projectName?: string
86
- readonly corePackage?: ZeropsProjectMode
87
- readonly publicAccess?: ZeropsNamespacePublicAccess
88
- readonly proxyBuildFromGit?: string
89
- readonly postgres?: ZeropsNamespacePostgres
90
- readonly managed?: boolean
91
- readonly projectId?: string
92
- readonly proxyServiceId?: string
93
- readonly postgresServiceId?: string
94
- readonly proxyConfigured?: boolean
95
- readonly proxyBaselineSequence?: number
96
- readonly proxyAppVersionId?: string
97
- /** The hosts the published proxy answers on, recorded once so a namespace read needs no platform call. */
98
- readonly proxyHosts?: readonly ProviderNamespaceHost[]
99
- readonly ready?: boolean
100
- }
101
-
102
- interface NormalizedZeropsNamespaceTarget extends ZeropsNamespaceTarget {
103
- readonly projectName: string
104
- readonly corePackage: ZeropsProjectMode
105
- readonly publicAccess: ZeropsNamespacePublicAccess
106
- readonly proxyBuildFromGit: string
107
- readonly managed: boolean
108
- readonly ready: boolean
109
- }
110
-
111
- export interface ZeropsNamespaceOptions {
112
- readonly clientId: string
113
- readonly proxyBuildFromGit: string
114
- /** Public IAM origin reachable from an app project. */
115
- readonly iamUrl: string
116
- /** Same credential IAM receives as `FABRIKA_IAM_PROXY_KEY`. */
117
- readonly iamKey: string
118
- readonly api: ZeropsApi
119
- readonly sleep?: Sleeper
120
- }
121
-
122
- export interface ZeropsNamespaceTopology {
123
- readonly source: ZeropsSourceTarget
124
- readonly createYaml: string
125
- readonly servicesProvisionYaml: string
126
- readonly servicesSteadyYaml: string
127
- }
128
-
129
- export interface ZeropsNamespacePresetInput {
130
- readonly preset: 'cheap' | 'mid' | 'full'
131
- readonly env: string
132
- readonly projectName: string
133
- readonly corePackage?: ZeropsProjectMode
134
- readonly publicAccess?: ZeropsNamespacePublicAccess
135
- readonly proxyBuildFromGit: string
136
- readonly postgres?: ZeropsNamespacePostgres
137
- }
138
-
139
- export interface ZeropsNamespaceOperatorOptions {
140
- readonly proxyBuildFromGit: string
141
- }
142
-
143
- export interface ZeropsNamespacePlanOptions {
144
- readonly projectName?: string
145
- readonly corePackage?: ZeropsProjectMode
146
- readonly publicAccess?: ZeropsNamespacePublicAccess
147
- readonly postgres?: ZeropsNamespacePostgres
148
- }
149
-
150
- const property = (payload: JsonValue, key: string): JsonValue | undefined => {
151
- if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
152
- throw new Error('Zerops namespace target must be an object')
153
- }
154
- return payload[key]
155
- }
156
-
157
- const optionalString = (payload: JsonValue, key: string): string | undefined => {
158
- const value = property(payload, key)
159
- if (value === undefined) return undefined
160
- if (typeof value !== 'string' || value === '') {
161
- throw new Error(`Zerops namespace target ${key} must be a non-empty string`)
162
- }
163
- return value
164
- }
165
-
166
- const optionalBoolean = (payload: JsonValue, key: string): boolean | undefined => {
167
- const value = property(payload, key)
168
- if (value === undefined) return undefined
169
- if (typeof value !== 'boolean') {
170
- throw new Error(`Zerops namespace target ${key} must be a boolean`)
171
- }
172
- return value
173
- }
174
-
175
- const optionalInteger = (payload: JsonValue, key: string): number | undefined => {
176
- const value = property(payload, key)
177
- if (value === undefined) return undefined
178
- if (typeof value !== 'number' || !Number.isInteger(value) || value < -1) {
179
- throw new Error(`Zerops namespace target ${key} must be an integer greater than or equal to -1`)
180
- }
181
- return value
182
- }
183
-
184
- const postgresProfiles = {
185
- 'postgresql:ha@18': new Set(['oltp-staging', 'oltp-production', 'oltp-enterprise', 'olap-production', 'writeheavy-production']),
186
- 'postgresql:single@18': new Set(['oltp-hobby', 'oltp-staging', 'oltp-production', 'olap-production', 'writeheavy-production']),
187
- }
188
-
189
- const parsePostgres = (payload: JsonValue): ZeropsNamespacePostgres | undefined => {
190
- const value = property(payload, 'postgres')
191
- if (value === undefined) return undefined
192
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
193
- throw new Error('Zerops namespace target postgres must be an object')
194
- }
195
- const type = property(value, 'type')
196
- const profile = property(value, 'profile')
197
- if (type !== 'postgresql:ha@18' && type !== 'postgresql:single@18') {
198
- throw new Error('Zerops namespace PostgreSQL type must be postgresql:ha@18 or postgresql:single@18')
199
- }
200
- if (profile !== undefined && (typeof profile !== 'string' || !postgresProfiles[type].has(profile))) {
201
- throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
202
- }
203
- if (type === 'postgresql:ha@18') {
204
- if (
205
- profile !== undefined
206
- && profile !== 'oltp-staging'
207
- && profile !== 'oltp-production'
208
- && profile !== 'oltp-enterprise'
209
- && profile !== 'olap-production'
210
- && profile !== 'writeheavy-production'
211
- ) {
212
- throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
213
- }
214
- return profile === undefined ? { type } : { type, profile }
215
- }
216
- if (
217
- profile !== undefined
218
- && profile !== 'oltp-hobby'
219
- && profile !== 'oltp-staging'
220
- && profile !== 'oltp-production'
221
- && profile !== 'olap-production'
222
- && profile !== 'writeheavy-production'
223
- ) {
224
- throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
225
- }
226
- return profile === undefined ? { type } : { type, profile }
227
- }
228
-
229
- const parseProxyHosts = (payload: JsonValue): readonly ProviderNamespaceHost[] | undefined => {
230
- const value = property(payload, 'proxyHosts')
231
- if (value === undefined) return undefined
232
- if (!Array.isArray(value)) {
233
- throw new Error('Zerops namespace target proxyHosts must be an array')
234
- }
235
- return value.map((entry) => {
236
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
237
- throw new Error('Zerops namespace target proxyHosts entry must be an object')
238
- }
239
- const host = property(entry, 'host')
240
- const port = property(entry, 'port')
241
- if (typeof host !== 'string' || host === '') {
242
- throw new Error('Zerops namespace target proxyHosts host must be a non-empty string')
243
- }
244
- if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) {
245
- throw new Error('Zerops namespace target proxyHosts port must be a positive integer')
246
- }
247
- return { host, port }
248
- })
249
- }
250
-
251
- /**
252
- * One host per published HTTP port, out of the newline-separated URLs the platform writes.
253
- *
254
- * The form is `<hostname>-<hash>-<port>.<region>.zerops.app` (e.g. `proxy-2ec8-8080`). Read as URLs
255
- * rather than split on `-`, so a hostname whose own first label ends in digits cannot be misread; a line
256
- * that is not a URL, or whose first label carries no trailing port — the single portless line a proxy
257
- * that has never deployed publishes — is skipped rather than guessed at.
258
- */
259
- const parseZeropsProxyHosts = (value: string): ProviderNamespaceHost[] => {
260
- const hosts: ProviderNamespaceHost[] = []
261
- for (const line of value.split('\n')) {
262
- const trimmed = line.trim()
263
- if (trimmed === '') continue
264
- let hostname: string
265
- try {
266
- hostname = new URL(trimmed).hostname
267
- } catch {
268
- continue
269
- }
270
- const port = /-(\d+)$/.exec(hostname.split('.')[0] ?? '')?.[1]
271
- if (port === undefined) continue
272
- hosts.push({ host: hostname, port: Number.parseInt(port, 10) })
273
- }
274
- return hosts.sort((left, right) => left.port - right.port)
275
- }
276
-
277
- export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
278
- version: 1,
279
- encode: (target) => ({
280
- ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
281
- ...(target.corePackage !== undefined ? { corePackage: target.corePackage } : {}),
282
- ...(target.publicAccess !== undefined ? { publicAccess: target.publicAccess } : {}),
283
- ...(target.proxyBuildFromGit !== undefined ? { proxyBuildFromGit: target.proxyBuildFromGit } : {}),
284
- ...(target.postgres !== undefined
285
- ? { postgres: { type: target.postgres.type, ...(target.postgres.profile !== undefined ? { profile: target.postgres.profile } : {}) } }
286
- : {}),
287
- ...(target.managed !== undefined ? { managed: target.managed } : {}),
288
- ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
289
- ...(target.proxyServiceId !== undefined ? { proxyServiceId: target.proxyServiceId } : {}),
290
- ...(target.postgresServiceId !== undefined ? { postgresServiceId: target.postgresServiceId } : {}),
291
- ...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
292
- ...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
293
- ...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
294
- ...(target.proxyHosts === undefined ? {} : { proxyHosts: target.proxyHosts.map((entry) => ({ host: entry.host, port: entry.port })) }),
295
- ...(target.ready !== undefined ? { ready: target.ready } : {}),
296
- }),
297
- decode: (payload) => {
298
- const corePackage = optionalString(payload, 'corePackage')
299
- if (corePackage !== undefined && corePackage !== 'LIGHT' && corePackage !== 'SERIOUS') {
300
- throw new Error('Zerops namespace target corePackage must be LIGHT or SERIOUS')
301
- }
302
- const publicAccess = optionalString(payload, 'publicAccess')
303
- if (publicAccess !== undefined && publicAccess !== 'custom-domain' && publicAccess !== 'zerops-subdomain') {
304
- throw new Error('Zerops namespace target publicAccess is not supported')
305
- }
306
- return {
307
- ...(optionalString(payload, 'projectName') !== undefined ? { projectName: optionalString(payload, 'projectName') } : {}),
308
- ...(corePackage !== undefined ? { corePackage } : {}),
309
- ...(publicAccess !== undefined ? { publicAccess } : {}),
310
- ...(optionalString(payload, 'proxyBuildFromGit') !== undefined
311
- ? { proxyBuildFromGit: optionalString(payload, 'proxyBuildFromGit') }
312
- : {}),
313
- ...(parsePostgres(payload) !== undefined ? { postgres: parsePostgres(payload) } : {}),
314
- ...(optionalBoolean(payload, 'managed') !== undefined ? { managed: optionalBoolean(payload, 'managed') } : {}),
315
- ...(optionalString(payload, 'projectId') !== undefined ? { projectId: optionalString(payload, 'projectId') } : {}),
316
- ...(optionalString(payload, 'proxyServiceId') !== undefined ? { proxyServiceId: optionalString(payload, 'proxyServiceId') } : {}),
317
- ...(optionalString(payload, 'postgresServiceId') !== undefined
318
- ? { postgresServiceId: optionalString(payload, 'postgresServiceId') }
319
- : {}),
320
- ...(optionalBoolean(payload, 'proxyConfigured') !== undefined
321
- ? { proxyConfigured: optionalBoolean(payload, 'proxyConfigured') }
322
- : {}),
323
- ...(optionalInteger(payload, 'proxyBaselineSequence') !== undefined
324
- ? { proxyBaselineSequence: optionalInteger(payload, 'proxyBaselineSequence') }
325
- : {}),
326
- ...(optionalString(payload, 'proxyAppVersionId') !== undefined
327
- ? { proxyAppVersionId: optionalString(payload, 'proxyAppVersionId') }
328
- : {}),
329
- ...(parseProxyHosts(payload) !== undefined ? { proxyHosts: parseProxyHosts(payload) } : {}),
330
- ...(optionalBoolean(payload, 'ready') !== undefined ? { ready: optionalBoolean(payload, 'ready') } : {}),
331
- }
332
- },
333
- }
334
-
335
- const envelope = (target: ZeropsNamespaceTarget): ProviderEnvelope => ({
336
- provider: 'zerops',
337
- version: zeropsNamespaceTargetCodec.version,
338
- payload: zeropsNamespaceTargetCodec.encode(target),
339
- })
340
-
341
- const decodeEnvelope = (value: ProviderEnvelope): ZeropsNamespaceTarget => {
342
- if (value.provider !== 'zerops') {
343
- throw new Error(`namespace belongs to provider "${value.provider}", expected "zerops"`)
344
- }
345
- if (value.version !== zeropsNamespaceTargetCodec.version) {
346
- throw new Error(`namespace schema version ${value.version} is not supported by provider "zerops"`)
347
- }
348
- return zeropsNamespaceTargetCodec.decode(value.payload)
349
- }
350
-
351
- const namespaceResourceClaims = (namespace: ProviderDeploymentNamespace): string[] => {
352
- const target = decodeEnvelope(namespace.target)
353
- return [
354
- `service:${ZEROPS_NAMESPACE_PROXY_HOSTNAME}`,
355
- ...(target.postgres === undefined ? [] : [`service:${ZEROPS_NAMESPACE_POSTGRES_HOSTNAME}`]),
356
- ]
357
- }
358
-
359
- const registrationResourceClaims = (registration: ProviderRegistration): string[] => {
360
- const namespace = registration.environment.namespace
361
- if (namespace === undefined) {
362
- throw new Error('Zerops registration requires a deployment namespace')
363
- }
364
- if (
365
- namespace.exclusiveAppId !== undefined
366
- && namespace.exclusiveAppId !== registration.app.id
367
- ) {
368
- throw new Error(`Zerops namespace is exclusive to app \`${namespace.exclusiveAppId}\``)
369
- }
370
- const artifactEnvelope = registration.environment.artifact
371
- if (artifactEnvelope.provider !== 'zerops' || artifactEnvelope.version !== zeropsArtifactCodec.version) {
372
- throw new Error('Zerops registration artifact envelope is not supported')
373
- }
374
- const manifest = parseFabrikaManifest(zeropsArtifactCodec.decode(artifactEnvelope.payload), {
375
- appId: registration.app.id,
376
- env: registration.environment.env,
377
- })
378
- // A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
379
- // refuse it here, where nothing has been created yet, instead of at the first deploy's manifest compile.
380
- if (manifest.target.proxy !== undefined && (registration.environment.domain ?? '').trim() === '') {
381
- throw new Error(
382
- `Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` requires a public domain: `
383
- + `pass --domain with a host this namespace serves (\`namespaces get ${namespace.id}\` lists them)`,
384
- )
385
- }
386
- const namespaceClaims = new Set(namespaceResourceClaims(namespace))
387
- for (const requirement of manifest.target.namespaceResources ?? []) {
388
- if (!namespaceClaims.has(requirement.resourceKey)) {
389
- throw new Error(`Zerops namespace does not provide required resource \`${requirement.resourceKey}\``)
390
- }
391
- }
392
- const hostnames = manifestServiceHostnames(manifest)
393
- for (const hostname of hostnames) {
394
- const key = `service:${hostname}`
395
- if (namespaceClaims.has(key)) {
396
- throw new Error(`Zerops service hostname \`${hostname}\` is reserved by the namespace`)
397
- }
398
- }
399
- if (namespace.exclusiveAppId === undefined) {
400
- const prefix = zeropsSharedServicePrefix(registration.app.id)
401
- for (const hostname of hostnames) {
402
- if (!hostname.startsWith(prefix) || hostname.length === prefix.length) {
403
- throw new Error(`Zerops shared namespace service \`${hostname}\` must use app prefix \`${prefix}\``)
404
- }
405
- }
406
- }
407
- return hostnames.map((hostname) => `service:${hostname}`).sort()
408
- }
409
-
410
- /**
411
- * The namespace-owned PostgreSQL service: the cheapest shape that runs, on EVERY environment.
412
- *
413
- * A profile sets the FLOOR and the tuning preset, never the cap — `oltp-hobby` and `oltp-production`
414
- * share the same 8-core / 48 GB / 250 GB ceiling and the same vertical autoscaler, so the production
415
- * preset does not buy headroom, it buys an idle bill. `oltp-hobby` starts at one SHARED core and
416
- * 0.25 GB and grows under load; the shape is proven by a live app with dozens of tables.
417
- *
418
- * Keying this off `env === 'prod'` is what this default used to do, and it was wrong: most apps are
419
- * small on every environment they have, and a name is not a size. HA and a bigger floor stay
420
- * available and become a DELIBERATE act — `--postgres-type` and `--postgres-profile` on the namespace
421
- * command — because the failure mode of over-provisioning is silent and monthly, while the failure
422
- * mode of under-provisioning is visible and fixable at any time.
423
- *
424
- * Omitting `profile` would not be neutral: an HA service silently gets `oltp-production` (two
425
- * DEDICATED cores and 4 GB per container, three containers) and a single one `oltp-staging`. Both
426
- * read back off a live service.
427
- */
428
- const defaultPostgres = (): ZeropsNamespacePostgres => ({ type: 'postgresql:single@18', profile: 'oltp-hobby' })
429
-
430
- export const zeropsNamespacePreset = (input: ZeropsNamespacePresetInput): ZeropsNamespaceTarget => ({
431
- projectName: input.projectName,
432
- // `LIGHT` on every environment. An APP namespace is not the platform project — the argument for
433
- // `SERIOUS` in `installation-zerops` is that the platform repairs the apps project, which no app
434
- // namespace does. `corePackage` is upgrade-only, so this stays the caller's explicit choice.
435
- corePackage: input.corePackage ?? 'LIGHT',
436
- publicAccess: input.publicAccess ?? 'custom-domain',
437
- proxyBuildFromGit: input.proxyBuildFromGit,
438
- managed: true,
439
- ready: false,
440
- ...(input.preset === 'cheap' ? { postgres: input.postgres ?? defaultPostgres() } : {}),
441
- })
442
-
443
- const projectMarker = (namespace: ProviderDeploymentNamespace): string => `Managed by Fabrika namespace ${namespace.id} (${namespace.env}).`
444
-
445
- // No `zeropsSetup`: the platform reads it as pipeline configuration and rejects the whole import unless
446
- // the service also names a `buildFromGit`, which an import that only PROVISIONS does not. The setup name
447
- // is supplied where it is actually needed — the `triggerPipeline` call in the namespace lifecycle below,
448
- // which passes both. `assertZeropsInvariants` refuses the invalid combination.
449
- //
450
- // `enableSubdomainAccess` is a DECLARATION here and not a mechanism: the platform accepts it and drops it,
451
- // including on the service this document creates. `ensureSubdomainAccess` below is what establishes the
452
- // subdomain, after the proxy's first deploy. Written anyway because it is what ADR-0007's
453
- // `assertOnlyPublicService` reads to prove no other service is claimed public.
454
- const proxyService = (publicAccess: ZeropsNamespacePublicAccess): ZeropsServiceSpec => ({
455
- hostname: ZEROPS_NAMESPACE_PROXY_HOSTNAME,
456
- type: PROXY_TYPE,
457
- priority: 10,
458
- enableSubdomainAccess: publicAccess === 'zerops-subdomain',
459
- // One container is a real availability trade — the proxy is the only enforcement point (ADR-0022),
460
- // so a restart is a short outage rather than a rolling one — taken because it is a whole core of
461
- // idle floor on every namespace, and most namespaces hold one small app. `maxContainers` is
462
- // unchanged, so load still scales out.
463
- minContainers: 1,
464
- maxContainers: 6,
465
- })
466
-
467
- const postgresService = (postgres: ZeropsNamespacePostgres): ZeropsServiceSpec => ({
468
- hostname: ZEROPS_NAMESPACE_POSTGRES_HOSTNAME,
469
- type: postgres.type,
470
- priority: 100,
471
- ...(postgres.profile !== undefined ? { profile: postgres.profile } : {}),
472
- })
473
-
474
- /** Compile the one project and its namespace-owned services without depending on private deploy code. */
475
- export const compileZeropsNamespaceTopology = (
476
- namespace: ProviderDeploymentNamespace,
477
- target: ZeropsNamespaceTarget,
478
- ): ZeropsNamespaceTopology => {
479
- const projectName = target.projectName
480
- const corePackage = target.corePackage
481
- const publicAccess = target.publicAccess
482
- if (projectName === undefined || corePackage === undefined || publicAccess === undefined) {
483
- throw new Error('Zerops namespace topology requires normalized project policy')
484
- }
485
- const services = (): ZeropsServiceSpec[] => [
486
- ...(target.postgres === undefined ? [] : [postgresService(target.postgres)]),
487
- proxyService(publicAccess),
488
- ]
489
- const source: ZeropsSourceTarget = {
490
- platform: 'zerops',
491
- project: {
492
- name: projectName,
493
- description: projectMarker(namespace),
494
- corePackage,
495
- tags: ['fabrika', 'namespace', namespace.id, namespace.env],
496
- },
497
- services,
498
- }
499
- const serviceSource: ZeropsSourceTarget = { platform: 'zerops', services }
500
- return {
501
- source,
502
- createYaml: compileProvisioningYaml({ target: source, ctx: { env: namespace.env } }).yaml,
503
- servicesProvisionYaml: compileProvisioningYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
504
- servicesSteadyYaml: compileImportYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
505
- }
506
- }
507
-
508
- const ZEROPS_NAMESPACE_PRESETS: readonly ProviderNamespacePreset[] = [
509
- {
510
- id: 'cheap',
511
- label: 'Cheap',
512
- description: 'Several apps share one project, proxy, and namespace-owned PostgreSQL service.',
513
- requiresExclusiveApp: false,
514
- },
515
- {
516
- id: 'mid',
517
- label: 'Mid',
518
- description: 'Several apps share one project and proxy; each app owns its prefixed database services.',
519
- requiresExclusiveApp: false,
520
- },
521
- {
522
- id: 'full',
523
- label: 'Full',
524
- description: 'One project and proxy are reserved for exactly one app.',
525
- requiresExclusiveApp: true,
526
- },
527
- ]
528
-
529
- const parsePlanOptions = (value: JsonValue | undefined): ZeropsNamespacePlanOptions => {
530
- if (value === undefined) return {}
531
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
532
- throw new Error('Zerops namespace plan options must be an object')
533
- }
534
- const supported = new Set(['projectName', 'corePackage', 'publicAccess', 'postgres'])
535
- for (const key of Object.keys(value)) {
536
- if (!supported.has(key)) {
537
- throw new Error(`Zerops namespace plan option ${key} is not supported`)
538
- }
539
- }
540
- const projectName = optionalString(value, 'projectName')
541
- const corePackage = optionalString(value, 'corePackage')
542
- if (corePackage !== undefined && corePackage !== 'LIGHT' && corePackage !== 'SERIOUS') {
543
- throw new Error('Zerops namespace plan corePackage must be LIGHT or SERIOUS')
544
- }
545
- const publicAccess = optionalString(value, 'publicAccess')
546
- if (publicAccess !== undefined && publicAccess !== 'custom-domain' && publicAccess !== 'zerops-subdomain') {
547
- throw new Error('Zerops namespace plan publicAccess is not supported')
548
- }
549
- const postgres = parsePostgres(value)
550
- return {
551
- ...(projectName === undefined ? {} : { projectName }),
552
- ...(corePackage === undefined ? {} : { corePackage }),
553
- ...(publicAccess === undefined ? {} : { publicAccess }),
554
- ...(postgres === undefined ? {} : { postgres }),
555
- }
556
- }
557
-
558
- const namespacePresentation = (
559
- namespace: ProviderDeploymentNamespace,
560
- target: ZeropsNamespaceTarget,
561
- ): ProviderNamespacePresentation => {
562
- const preset = target.postgres !== undefined ? 'cheap' : namespace.exclusiveAppId === undefined ? 'mid' : 'full'
563
- const projectName = target.projectName ?? namespace.id
564
- const corePackage = target.corePackage ?? 'LIGHT'
565
- const publicAccess = target.publicAccess ?? 'custom-domain'
566
- const postgres = target.postgres === undefined
567
- ? 'App-owned database services'
568
- : `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
569
- // Only a published subdomain namespace knows its own hosts; custom domains are bound out of band, so
570
- // the provider names none and an operator reads them where they were bound.
571
- const hosts = publicAccess === 'zerops-subdomain' && target.ready === true ? target.proxyHosts ?? [] : []
572
- const instructions = [
573
- ...(publicAccess === 'custom-domain'
574
- ? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
575
- : ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
576
- ...(hosts.length === 0 ? [] : ['Register a proxy-target environment with one of the hosts below as its domain; each host serves one app.']),
577
- ...(target.postgres === undefined
578
- ? []
579
- : ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
580
- ]
581
- // The project is the one fact that names a LIVE resource: it exists at the provider, it outlives the
582
- // namespace row, and removal does not delete it (ADR-0034). Marked only once it actually exists.
583
- const project: ProviderNamespaceFact = target.projectId === undefined
584
- ? { label: 'Project', value: projectName }
585
- : { label: 'Project', value: `${projectName} (${target.projectId})`, resource: true }
586
- return {
587
- preset,
588
- title: `${ZEROPS_NAMESPACE_PRESETS.find((candidate) => candidate.id === preset)?.label ?? preset} namespace`,
589
- facts: [
590
- project,
591
- { label: 'Environment', value: namespace.env },
592
- { label: 'Core package', value: corePackage },
593
- { label: 'Placement', value: namespace.exclusiveAppId === undefined ? 'Shared' : `Exclusive to ${namespace.exclusiveAppId}` },
594
- { label: 'PostgreSQL', value: postgres },
595
- { label: 'Public access', value: publicAccess },
596
- ],
597
- instructions,
598
- ...(hosts.length === 0 ? {} : { hosts }),
599
- }
600
- }
601
-
602
- /** Build the provider-owned namespace planning surface without any network collaborators. */
603
- export const createZeropsNamespaceOperator = (
604
- options: ZeropsNamespaceOperatorOptions,
605
- ): ProviderNamespaceOperator => {
606
- if (options.proxyBuildFromGit.trim() === '') {
607
- throw new Error('Zerops namespace proxyBuildFromGit must be a non-empty string')
608
- }
609
- return {
610
- presets: ZEROPS_NAMESPACE_PRESETS,
611
- plan(input: ProviderNamespacePlanInput) {
612
- if (input.id.trim() === '' || input.env.trim() === '') {
613
- throw new Error('Zerops namespace plan id and env must be non-empty')
614
- }
615
- if (input.preset !== 'cheap' && input.preset !== 'mid' && input.preset !== 'full') {
616
- throw new Error(`Zerops namespace preset ${input.preset} is not supported`)
617
- }
618
- const exclusiveAppId = input.exclusiveAppId?.trim()
619
- if (input.preset === 'full' && (exclusiveAppId === undefined || exclusiveAppId === '')) {
620
- throw new Error('Zerops full namespace plan requires exclusiveAppId')
621
- }
622
- if (input.preset !== 'full' && exclusiveAppId !== undefined) {
623
- throw new Error(`Zerops ${input.preset} namespace plan cannot reserve an exclusive app`)
624
- }
625
- const planOptions = parsePlanOptions(input.options)
626
- if (input.preset !== 'cheap' && planOptions.postgres !== undefined) {
627
- throw new Error(`Zerops ${input.preset} namespace plan cannot own PostgreSQL`)
628
- }
629
- const target = zeropsNamespacePreset({
630
- preset: input.preset,
631
- env: input.env,
632
- projectName: planOptions.projectName ?? input.id,
633
- proxyBuildFromGit: options.proxyBuildFromGit,
634
- ...(planOptions.corePackage === undefined ? {} : { corePackage: planOptions.corePackage }),
635
- ...(planOptions.publicAccess === undefined ? {} : { publicAccess: planOptions.publicAccess }),
636
- ...(planOptions.postgres === undefined ? {} : { postgres: planOptions.postgres }),
637
- })
638
- const namespace: ProviderDeploymentNamespace = {
639
- id: input.id,
640
- env: input.env,
641
- ...(exclusiveAppId === undefined ? {} : { exclusiveAppId }),
642
- target: envelope(target),
643
- }
644
- return { namespace, presentation: namespacePresentation(namespace, target) }
645
- },
646
- present(namespace) {
647
- return namespacePresentation(namespace, decodeEnvelope(namespace.target))
648
- },
649
- }
650
- }
651
-
652
- const normalizeTarget = (
653
- namespace: ProviderDeploymentNamespace,
654
- options: ZeropsNamespaceOptions,
655
- ): NormalizedZeropsNamespaceTarget => {
656
- if (namespace.id.trim() === '' || namespace.env.trim() === '') {
657
- throw new Error('Zerops namespace id and env must be non-empty')
658
- }
659
- const decoded = decodeEnvelope(namespace.target)
660
- const proxyBuildFromGit = decoded.proxyBuildFromGit ?? options.proxyBuildFromGit
661
- if (proxyBuildFromGit.trim() === '') {
662
- throw new Error('Zerops namespace proxy source must be a non-empty public Git URL')
663
- }
664
- return {
665
- ...decoded,
666
- projectName: decoded.projectName ?? namespace.id,
667
- corePackage: decoded.corePackage ?? 'LIGHT',
668
- publicAccess: decoded.publicAccess ?? 'custom-domain',
669
- proxyBuildFromGit,
670
- managed: decoded.managed ?? decoded.projectId === undefined,
671
- ready: decoded.ready ?? false,
672
- }
673
- }
674
-
675
- const normalizedNamespace = (
676
- namespace: ProviderDeploymentNamespace,
677
- options: ZeropsNamespaceOptions,
678
- ): ProviderDeploymentNamespace => ({
679
- ...namespace,
680
- target: envelope(normalizeTarget(namespace, options)),
681
- })
682
-
683
- const withTarget = (
684
- namespace: ProviderDeploymentNamespace,
685
- target: ZeropsNamespaceTarget,
686
- ): ProviderDeploymentNamespace => ({ ...namespace, target: envelope(target) })
687
-
688
- const checkpoint = async (
689
- input: ProviderNamespaceMutationInput,
690
- namespace: ProviderDeploymentNamespace,
691
- target: ZeropsNamespaceTarget,
692
- ): Promise<ProviderDeploymentNamespace> => {
693
- const updated = withTarget(namespace, target)
694
- await input.events.checkpoint(updated)
695
- return updated
696
- }
697
-
698
- const validateProject = (
699
- namespace: ProviderDeploymentNamespace,
700
- target: NormalizedZeropsNamespaceTarget,
701
- project: ZeropsProject,
702
- adopting: boolean,
703
- ): void => {
704
- if (project.id === '') {
705
- throw new Error('Zerops namespace project returned an empty id')
706
- }
707
- if (target.projectName !== project.name) {
708
- throw new Error(`Zerops namespace project name mismatch: expected \`${target.projectName}\`, got \`${project.name}\``)
709
- }
710
- if (project.mode !== target.corePackage) {
711
- throw new Error(`Zerops namespace project ${project.id} uses ${project.mode ?? 'an unknown core package'}, expected ${target.corePackage}`)
712
- }
713
- // Zerops does not expose project envIsolation. Every managed service import writes its own override.
714
- if (!adopting && target.managed && project.description?.includes(projectMarker(namespace)) !== true) {
715
- throw new Error(`Zerops namespace project ${project.id} has a different Fabrika ownership marker`)
716
- }
717
- }
718
-
719
- const managedProjectByMarker = async (
720
- namespace: ProviderDeploymentNamespace,
721
- target: NormalizedZeropsNamespaceTarget,
722
- options: ZeropsNamespaceOptions,
723
- signal: AbortSignal,
724
- ): Promise<ZeropsProject | null> => {
725
- const matches = await options.api.findProjects({
726
- clientId: options.clientId,
727
- name: target.projectName,
728
- signal,
729
- })
730
- const owned: ZeropsProject[] = []
731
- for (const match of matches) {
732
- const project = await options.api.getProject({ projectId: match.id, signal })
733
- if (project.description?.includes(projectMarker(namespace)) === true) {
734
- owned.push(project)
735
- }
736
- }
737
- if (owned.length > 1) {
738
- throw new Error(`Zerops namespace project name \`${target.projectName}\` has multiple matching Fabrika ownership markers`)
739
- }
740
- if (owned.length === 1) {
741
- return owned[0] ?? null
742
- }
743
- if (matches.length > 0) {
744
- throw new Error(`Zerops namespace project name \`${target.projectName}\` is already used without this namespace's ownership marker`)
745
- }
746
- return null
747
- }
748
-
749
- const uniqueService = (services: ZeropsService[], hostname: string): ZeropsService | null => {
750
- const matches = services.filter((service) => service.name === hostname)
751
- if (matches.length > 1) {
752
- throw new Error(`Zerops namespace has multiple services named \`${hostname}\``)
753
- }
754
- return matches[0] ?? null
755
- }
756
-
757
- const validateService = (
758
- service: ZeropsService,
759
- projectId: string,
760
- expectedBase: string,
761
- adopting: boolean,
762
- expectedPublic?: boolean,
763
- expectedProfile?: string,
764
- ): void => {
765
- if (service.id === '') {
766
- throw new Error(`Zerops namespace service \`${service.name}\` returned an empty id`)
767
- }
768
- if (service.projectId !== undefined && service.projectId !== projectId) {
769
- throw new Error(`Zerops namespace service \`${service.name}\` belongs to another project`)
770
- }
771
- if ((adopting || service.base !== undefined) && service.base !== expectedBase) {
772
- throw new Error(`Zerops namespace service \`${service.name}\` uses ${service.base ?? 'an unknown type'}, expected ${expectedBase}`)
773
- }
774
- if (expectedPublic !== undefined && adopting && service.subdomainAccess !== expectedPublic) {
775
- throw new Error(`Zerops namespace service \`${service.name}\` has incompatible public access`)
776
- }
777
- if (expectedProfile !== undefined && service.autoscalingProfileId !== expectedProfile) {
778
- throw new Error(
779
- `Zerops namespace service \`${service.name}\` uses profile ${service.autoscalingProfileId ?? 'unknown'}, expected ${expectedProfile}`,
780
- )
781
- }
782
- }
783
-
784
- const resolveServices = async (
785
- namespace: ProviderDeploymentNamespace,
786
- target: NormalizedZeropsNamespaceTarget,
787
- options: ZeropsNamespaceOptions,
788
- signal: AbortSignal,
789
- adopting: boolean,
790
- ): Promise<{ proxy: ZeropsService; postgres?: ZeropsService }> => {
791
- const projectId = target.projectId
792
- if (projectId === undefined) {
793
- throw new Error('Zerops namespace has no project id')
794
- }
795
- const services = await options.api.listProjectServices({ projectId, signal })
796
- const proxy = uniqueService(services, ZEROPS_NAMESPACE_PROXY_HOSTNAME)
797
- if (proxy === null) {
798
- throw new Error(`Zerops namespace project ${projectId} has no proxy service`)
799
- }
800
- validateService(
801
- proxy,
802
- projectId,
803
- PROXY_TYPE,
804
- adopting,
805
- target.publicAccess === 'zerops-subdomain',
806
- )
807
- const postgres = uniqueService(services, ZEROPS_NAMESPACE_POSTGRES_HOSTNAME)
808
- if (target.postgres === undefined && postgres !== null && adopting) {
809
- throw new Error(`Zerops namespace project ${projectId} has an undeclared namespace PostgreSQL service`)
810
- }
811
- if (target.postgres !== undefined) {
812
- if (postgres === null) {
813
- throw new Error(`Zerops namespace project ${projectId} has no shared PostgreSQL service`)
814
- }
815
- validateService(postgres, projectId, target.postgres.type, adopting, undefined, target.postgres.profile)
816
- }
817
- if (adopting && namespace.exclusiveAppId !== undefined) {
818
- const reserved = new Set([
819
- ZEROPS_NAMESPACE_PROXY_HOSTNAME,
820
- ...(target.postgres === undefined ? [] : [ZEROPS_NAMESPACE_POSTGRES_HOSTNAME]),
821
- ])
822
- const unexpected = services.filter((service) => !reserved.has(service.name))
823
- if (unexpected.length > 0) {
824
- throw new Error(`exclusive Zerops namespace project ${projectId} contains unmanaged service \`${unexpected[0]?.name ?? 'unknown'}\``)
825
- }
826
- }
827
- return postgres === null ? { proxy } : { proxy, postgres }
828
- }
829
-
830
- export interface NamespaceProxyIamInput {
831
- readonly api: Pick<ZeropsApi, 'listServiceEnv' | 'putServiceEnv'>
832
- readonly proxyServiceId: string
833
- /** The public IAM origin the proxy compares a token's `iss` against. */
834
- readonly iamUrl: string
835
- readonly iamKey: string
836
- readonly signal: AbortSignal
837
- }
838
-
839
- /**
840
- * Converge the two variables the namespace proxy's own CODE reads, and call this from everywhere that
841
- * code is replaced — not only where the namespace is created.
842
- *
843
- * `syncZeropsProxy` rebuilds the proxy from git on every app deploy, so a proxy can take new code
844
- * whose configuration nobody rewrote. That is not hypothetical: the `FABRIKA_IAM_URL` →
845
- * `FABRIKA_IAM_ISSUER` rename ([ADR-0035](../../../docs/decisions/0035-the-platform-owns-the-application-iam-issuer.md))
846
- * left a live namespace proxy crash-looping on `FABRIKA_IAM_ISSUER is required`, because provisioning
847
- * had written the old name months earlier and the deploy path only ever wrote the manifest. Writing
848
- * these BEFORE the pipeline is triggered is what makes a rename a flag day rather than an outage
849
- * ([ADR-0024](../../../docs/decisions/0024-retire-the-legacy-environment-name-fallback.md)).
850
- */
851
- export const ensureNamespaceProxyIam = async (input: NamespaceProxyIamInput): Promise<void> => {
852
- const variables = await input.api.listServiceEnv({ serviceId: input.proxyServiceId, signal: input.signal })
853
- const issuer = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE)
854
- if (issuer?.content !== input.iamUrl) {
855
- await input.api.putServiceEnv({
856
- serviceId: input.proxyServiceId,
857
- key: ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE,
858
- value: input.iamUrl,
859
- signal: input.signal,
860
- })
861
- }
862
- // Secret read-back is not trustworthy, so overwrite it idempotently without inspecting its value.
863
- await input.api.putServiceEnv({
864
- serviceId: input.proxyServiceId,
865
- key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE,
866
- value: input.iamKey,
867
- signal: input.signal,
868
- })
869
- }
870
-
871
- const ensureProxyConfiguration = async (
872
- target: NormalizedZeropsNamespaceTarget,
873
- options: ZeropsNamespaceOptions,
874
- signal: AbortSignal,
875
- ): Promise<boolean> => {
876
- const proxyServiceId = target.proxyServiceId
877
- if (proxyServiceId === undefined) {
878
- throw new Error('Zerops namespace has no proxy service id')
879
- }
880
- const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
881
- const byKey = new Map(variables.map((variable) => [variable.key, variable]))
882
- let deployRequired = !target.ready
883
- const manifest = byKey.get(FABRIKA_PROXY_MANIFEST_JSON)
884
- if (!target.ready || manifest === undefined) {
885
- await options.api.putServiceEnv({
886
- serviceId: proxyServiceId,
887
- key: FABRIKA_PROXY_MANIFEST_JSON,
888
- value: EMPTY_PROXY_MANIFEST,
889
- signal,
890
- })
891
- deployRequired = true
892
- }
893
- await ensureNamespaceProxyIam({
894
- api: options.api,
895
- proxyServiceId,
896
- iamUrl: options.iamUrl,
897
- iamKey: options.iamKey,
898
- signal,
899
- })
900
- return deployRequired
901
- }
902
-
903
- /**
904
- * The hosts the proxy publishes, off the platform-owned `zeropsSubdomain` variable.
905
- *
906
- * Read through the same `/env` listing every other namespace variable uses, and read AFTER the proxy is
907
- * published: the variable names a host per DEPLOYED HTTP port, so a proxy that never deployed names none.
908
- * Recording the answer in the target is what keeps `namespaces get` a database read.
909
- */
910
- const readProxyHosts = async (
911
- namespace: ProviderDeploymentNamespace,
912
- target: NormalizedZeropsNamespaceTarget,
913
- options: ZeropsNamespaceOptions,
914
- signal: AbortSignal,
915
- ): Promise<readonly ProviderNamespaceHost[]> => {
916
- const proxyServiceId = target.proxyServiceId
917
- if (proxyServiceId === undefined) {
918
- throw new Error('Zerops namespace has no proxy service id')
919
- }
920
- try {
921
- const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
922
- const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
923
- return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains.content)
924
- } catch {
925
- // A host listing is a convenience; the proxy is already built, deployed and published. Failing the
926
- // namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
927
- // upstream text this provider has not redacted.
928
- console.warn(
929
- `Zerops namespace ${namespace.id}: reading the published proxy hosts failed; the placement is ready and lists none until its next reconcile`,
930
- )
931
- return []
932
- }
933
- }
934
-
935
- const latestAfterBaseline = async (
936
- target: NormalizedZeropsNamespaceTarget,
937
- options: ZeropsNamespaceOptions,
938
- signal: AbortSignal,
939
- ): Promise<ZeropsAppVersion | null> => {
940
- const proxyServiceId = target.proxyServiceId
941
- if (proxyServiceId === undefined) {
942
- throw new Error('Zerops namespace has no proxy service id')
943
- }
944
- const latest = await options.api.latestAppVersion({ serviceId: proxyServiceId, signal })
945
- const baseline = target.proxyBaselineSequence
946
- if (latest === null || baseline === undefined || latest.sequence === undefined || latest.sequence <= baseline) {
947
- return null
948
- }
949
- return latest
950
- }
951
-
952
- const terminalFailure = (version: ZeropsAppVersion): ProviderNamespaceError =>
953
- namespaceError(ZEROPS_PROXY_DEPLOY_FAILED, `Zerops namespace proxy deploy ${version.id} finished as ${version.status ?? 'unknown'}`, false)
954
-
955
- const waitForProxy = async (
956
- appVersionId: string,
957
- options: ZeropsNamespaceOptions,
958
- signal: AbortSignal,
959
- ): Promise<ZeropsAppVersion> => {
960
- const deadline = Date.now() + POLL_TIMEOUT_MS
961
- for (;;) {
962
- if (signal.aborted) {
963
- throw namespaceError(ZEROPS_NAMESPACE_CANCELLED, 'Zerops namespace provisioning was cancelled', true)
964
- }
965
- const version = await options.api.getAppVersion({ appVersionId, signal })
966
- if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
967
- return version
968
- }
969
- if (Date.now() > deadline) {
970
- throw namespaceError(ZEROPS_PROXY_DEPLOY_TIMEOUT, `Zerops namespace proxy deploy ${appVersionId} timed out`, true)
971
- }
972
- await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
973
- }
974
- }
975
-
976
- const deployProxy = async (
977
- input: ProviderNamespaceMutationInput,
978
- namespace: ProviderDeploymentNamespace,
979
- target: NormalizedZeropsNamespaceTarget,
980
- options: ZeropsNamespaceOptions,
981
- ): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
982
- let currentNamespace = namespace
983
- let currentTarget = target
984
- const proxyServiceId = currentTarget.proxyServiceId
985
- if (proxyServiceId === undefined) {
986
- throw new Error('Zerops namespace has no proxy service id')
987
- }
988
- if (currentTarget.proxyAppVersionId !== undefined) {
989
- const version = await waitForProxy(currentTarget.proxyAppVersionId, options, input.signal)
990
- if (version.status === ZEROPS_ACTIVE) {
991
- return { namespace: currentNamespace, target: currentTarget }
992
- }
993
- currentTarget = {
994
- ...currentTarget,
995
- proxyAppVersionId: undefined,
996
- proxyBaselineSequence: undefined,
997
- ready: false,
998
- }
999
- currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
1000
- }
1001
-
1002
- let recovered: ZeropsAppVersion | null = null
1003
- if (currentTarget.proxyBaselineSequence !== undefined) {
1004
- recovered = await latestAfterBaseline(currentTarget, options, input.signal)
1005
- }
1006
- if (recovered === null) {
1007
- if (currentTarget.proxyBaselineSequence === undefined) {
1008
- const prior = await options.api.latestAppVersion({ serviceId: proxyServiceId, signal: input.signal })
1009
- currentTarget = {
1010
- ...currentTarget,
1011
- proxyBaselineSequence: prior?.sequence ?? -1,
1012
- }
1013
- currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
1014
- }
1015
- const process = await options.api.triggerPipeline({
1016
- serviceId: proxyServiceId,
1017
- buildFromGit: currentTarget.proxyBuildFromGit,
1018
- zeropsSetup: ZEROPS_NAMESPACE_PROXY_HOSTNAME,
1019
- signal: input.signal,
1020
- })
1021
- recovered = process?.appVersionId === undefined
1022
- ? await latestAfterBaseline(currentTarget, options, input.signal)
1023
- : { id: process.appVersionId }
1024
- }
1025
- if (recovered === null || recovered.id === '') {
1026
- throw new Error('Zerops namespace proxy pipeline did not expose a new app-version')
1027
- }
1028
- currentTarget = { ...currentTarget, proxyAppVersionId: recovered.id }
1029
- currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
1030
- const version = await waitForProxy(recovered.id, options, input.signal)
1031
- if (version.status !== ZEROPS_ACTIVE) {
1032
- throw terminalFailure(version)
1033
- }
1034
- return { namespace: currentNamespace, target: currentTarget }
1035
- }
1036
-
1037
- /**
1038
- * Lifecycle codes this provider defines where the platform has none of its own. They are an
1039
- * operator-facing vocabulary, so they are stable identifiers rather than prose (backlog 72): a
1040
- * platform code such as `insufficientPermissions` and one of these read the same way downstream.
1041
- */
1042
- export const ZEROPS_NAMESPACE_CANCELLED = 'namespaceCancelled'
1043
- export const ZEROPS_NAMESPACE_LIFECYCLE = 'namespaceLifecycle'
1044
- export const ZEROPS_PROXY_DEPLOY_TIMEOUT = 'proxyDeployTimeout'
1045
- export const ZEROPS_PROXY_DEPLOY_FAILED = 'proxyDeployFailed'
1046
- export const ZEROPS_SUBDOMAIN_NOT_PUBLISHED = 'subdomainNotPublished'
1047
-
1048
- const namespaceError = (code: string, message: string, retryable: boolean, detail?: string): ProviderNamespaceError =>
1049
- new ProviderNamespaceError(message, code, retryable, detail)
1050
-
1051
- /**
1052
- * `apiError` composes `zerops: <label> failed (<status>) — <code>: <server message>`. The half before the
1053
- * first separator is OURS (a literal label and the status); the half after is the platform's, and it
1054
- * REPEATS the code, which travels separately. Splitting them keeps the summary safe to show while the
1055
- * upstream words survive in `detail`, where the control plane redacts them before they are stored or
1056
- * logged — discarding them is what left three different live failures indistinguishable (backlog 72).
1057
- *
1058
- * A `redactDetail: true` call carries the code and nothing else, so it yields no detail at all.
1059
- */
1060
- const splitApiMessage = (message: string, code: string): { summary: string; detail?: string } => {
1061
- const at = message.indexOf(' — ')
1062
- if (at === -1) return { summary: message }
1063
- const summary = message.slice(0, at)
1064
- const rest = message.slice(at + 3)
1065
- if (code === '' || rest === code) return rest === code ? { summary } : { summary, detail: rest }
1066
- const detail = rest.startsWith(`${code}: `) ? rest.slice(code.length + 2) : rest
1067
- return detail === '' ? { summary } : { summary, detail }
1068
- }
1069
-
1070
- /** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
1071
- const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile'): ProviderNamespaceError => {
1072
- if (cause instanceof ProviderNamespaceError) return cause
1073
- if (cause instanceof ZeropsApiError) {
1074
- const { summary, detail } = splitApiMessage(cause.message, cause.code)
1075
- return namespaceError(
1076
- cause.code === '' ? `zeropsHttp${cause.status}` : cause.code,
1077
- summary,
1078
- cause.status === 429 || cause.status >= 500,
1079
- detail,
1080
- )
1081
- }
1082
- if (cause instanceof Error && cause.name === 'AbortError') {
1083
- return namespaceError(ZEROPS_NAMESPACE_CANCELLED, `Zerops namespace ${mode} was cancelled`, true)
1084
- }
1085
- if (cause instanceof Error) {
1086
- return namespaceError(ZEROPS_NAMESPACE_LIFECYCLE, cause.message, false)
1087
- }
1088
- return namespaceError(ZEROPS_NAMESPACE_LIFECYCLE, `Zerops namespace ${mode} failed`, false)
1089
- }
1090
-
1091
- /**
1092
- * Publish the proxy on its `*.zerops.app` subdomain — the ONE thing the import document cannot do.
1093
- *
1094
- * `enableSubdomainAccess: true` is accepted by the import and then silently dropped, on a service the
1095
- * document CREATES as much as on one `override` leaves alone; a service imported with it reads back
1096
- * `subdomainAccess: false` for ever. The subdomain is established by
1097
- * `PUT /service-stack/{id}/enable-subdomain-access` and by nothing else, and that call needs a DEPLOYED
1098
- * HTTP port — which is why this runs after `deployProxy` rather than beside the import.
1099
- *
1100
- * It runs on RECONCILE too, so a subdomain someone turned off in the GUI comes back. What it must never
1101
- * do is finish quietly: `zerops-subdomain` is the only public entry a namespace has, so this either
1102
- * observes `subdomainAccess === true` or throws naming what is missing.
1103
- *
1104
- * The decision is the READ-BACK, never the call returning. On an already-published service the platform
1105
- * answers 2xx and then fails the process it handed back — live-verified — so a 2xx proves nothing.
1106
- */
1107
- const ensureSubdomainAccess = async (
1108
- target: NormalizedZeropsNamespaceTarget,
1109
- options: ZeropsNamespaceOptions,
1110
- signal: AbortSignal,
1111
- ): Promise<void> => {
1112
- const proxyServiceId = target.proxyServiceId
1113
- if (proxyServiceId === undefined) {
1114
- throw new Error('Zerops namespace has no proxy service id')
1115
- }
1116
- const published = async (): Promise<boolean> => (await options.api.getService({ serviceId: proxyServiceId, signal })).subdomainAccess === true
1117
- if (await published()) {
1118
- return
1119
- }
1120
- try {
1121
- await options.api.enableSubdomainAccess({ serviceId: proxyServiceId, signal })
1122
- } catch (error) {
1123
- if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
1124
- throw namespaceError(
1125
- ZEROPS_SERVICE_NOT_HTTP,
1126
- `Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
1127
- false,
1128
- )
1129
- }
1130
- throw error
1131
- }
1132
- for (let attempt = 0;; attempt += 1) {
1133
- if (signal.aborted) {
1134
- throw namespaceError(ZEROPS_NAMESPACE_CANCELLED, 'Zerops namespace provisioning was cancelled', true)
1135
- }
1136
- if (await published()) {
1137
- return
1138
- }
1139
- if (attempt >= SUBDOMAIN_READBACK_ATTEMPTS) {
1140
- throw namespaceError(
1141
- ZEROPS_SUBDOMAIN_NOT_PUBLISHED,
1142
- `Zerops namespace proxy ${proxyServiceId} accepted enable-subdomain-access but still reads subdomainAccess: false — the namespace has no public entry point`,
1143
- true,
1144
- )
1145
- }
1146
- await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
1147
- }
1148
- }
1149
-
1150
- /** Every exit from the lifecycle below is a `ProviderNamespaceError`, so a caller never has to guess. */
1151
- const mutateNamespace = async (
1152
- input: ProviderNamespaceMutationInput,
1153
- options: ZeropsNamespaceOptions,
1154
- mode: 'provision' | 'reconcile',
1155
- ): Promise<ProviderDeploymentNamespace> => {
1156
- try {
1157
- return await runNamespaceMutation(input, options, mode)
1158
- } catch (cause) {
1159
- throw asNamespaceError(cause, mode)
1160
- }
1161
- }
1162
-
1163
- const runNamespaceMutation = async (
1164
- input: ProviderNamespaceMutationInput,
1165
- options: ZeropsNamespaceOptions,
1166
- mode: 'provision' | 'reconcile',
1167
- ): Promise<ProviderDeploymentNamespace> => {
1168
- const suppliedTarget = decodeEnvelope(input.namespace.target)
1169
- let namespace = normalizedNamespace(input.namespace, options)
1170
- let target = normalizeTarget(namespace, options)
1171
- const initiallyAdopted = target.projectId !== undefined && target.managed === false && !target.ready
1172
- const topology = compileZeropsNamespaceTopology(namespace, target)
1173
-
1174
- if (target.projectId === undefined) {
1175
- const recovered = await managedProjectByMarker(namespace, target, options, input.signal)
1176
- const result = recovered === null
1177
- ? await options.api.importProject({ clientId: options.clientId, yaml: topology.createYaml, signal: input.signal })
1178
- : { projectId: recovered.id, projectName: recovered.name, services: [] }
1179
- if (result.projectId === '') {
1180
- throw new Error('Zerops namespace project import returned an empty project id')
1181
- }
1182
- target = { ...target, projectId: result.projectId, managed: true }
1183
- namespace = await checkpoint(input, namespace, target)
1184
- }
1185
-
1186
- const projectId = target.projectId
1187
- if (projectId === undefined) {
1188
- throw new Error('Zerops namespace project id was not checkpointed')
1189
- }
1190
- const project = await options.api.getProject({ projectId, signal: input.signal })
1191
- if (target.projectName !== project.name && initiallyAdopted && suppliedTarget.projectName === undefined) {
1192
- target = { ...target, projectName: project.name }
1193
- namespace = await checkpoint(input, namespace, target)
1194
- }
1195
- validateProject(namespace, target, project, initiallyAdopted)
1196
-
1197
- if (initiallyAdopted) {
1198
- // Validate before the first mutation; the import below then enforces service-level envIsolation.
1199
- await resolveServices(namespace, target, options, input.signal, true)
1200
- }
1201
- await options.api.importServices({
1202
- projectId,
1203
- yaml: mode === 'provision' && !target.ready ? topology.servicesProvisionYaml : topology.servicesSteadyYaml,
1204
- signal: input.signal,
1205
- })
1206
- const resolved = await resolveServices(namespace, target, options, input.signal, false)
1207
- target = {
1208
- ...target,
1209
- proxyServiceId: resolved.proxy.id,
1210
- ...(resolved.postgres !== undefined ? { postgresServiceId: resolved.postgres.id } : {}),
1211
- }
1212
- namespace = await checkpoint(input, namespace, target)
1213
-
1214
- const deployRequired = await ensureProxyConfiguration(target, options, input.signal)
1215
- target = { ...target, proxyConfigured: true }
1216
- namespace = await checkpoint(input, namespace, target)
1217
-
1218
- if (deployRequired || !target.ready) {
1219
- const deployed = await deployProxy(input, namespace, target, options)
1220
- namespace = deployed.namespace
1221
- target = deployed.target
1222
- }
1223
- // After the deploy, because the platform refuses a service with no HTTP port — and never before `ready`
1224
- // is checkpointed, because a namespace with no public entry point is not ready.
1225
- if (target.publicAccess === 'zerops-subdomain') {
1226
- await ensureSubdomainAccess(target, options, input.signal)
1227
- }
1228
- const proxyHosts = target.publicAccess === 'zerops-subdomain' ? await readProxyHosts(namespace, target, options, input.signal) : []
1229
- target = {
1230
- ...target,
1231
- proxyBaselineSequence: undefined,
1232
- proxyAppVersionId: undefined,
1233
- // Cleared when the proxy publishes none and on a custom-domain namespace, so the record never
1234
- // outlives what it describes.
1235
- proxyHosts: proxyHosts.length === 0 ? undefined : proxyHosts,
1236
- ready: true,
1237
- }
1238
- return checkpoint(input, namespace, target)
1239
- }
1240
-
1241
- /** Build the provider-owned namespace lifecycle capability. */
1242
- export const createZeropsNamespaceCapabilities = (options: ZeropsNamespaceOptions): ProviderNamespaceCapabilities => {
1243
- const required = (name: string, value: string): void => {
1244
- if (value.trim() === '') {
1245
- throw new Error(`Zerops namespace ${name} must be a non-empty string`)
1246
- }
1247
- }
1248
- required('clientId', options.clientId)
1249
- required('proxyBuildFromGit', options.proxyBuildFromGit)
1250
- required('iamUrl', options.iamUrl)
1251
- required('iamKey', options.iamKey)
1252
- return {
1253
- normalize: (namespace) => normalizedNamespace(namespace, options),
1254
- namespaceResourceClaims,
1255
- registrationResourceClaims,
1256
- provision: (input) => mutateNamespace(input, options, 'provision'),
1257
- reconcile: (input) => mutateNamespace(input, options, 'reconcile'),
1258
- operator: createZeropsNamespaceOperator({ proxyBuildFromGit: options.proxyBuildFromGit }),
1259
- }
1260
- }
1
+ export { zeropsNamespaceTargetCodec } from './namespace/codec'
2
+ export {
3
+ ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE,
4
+ ZEROPS_NAMESPACE_IAM_KEY_VARIABLE,
5
+ ZEROPS_NAMESPACE_POSTGRES_HOSTNAME,
6
+ ZEROPS_NAMESPACE_PROXY_HOSTNAME,
7
+ ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE,
8
+ ZEROPS_SHARED_POSTGRES_CONNECTION_STRING,
9
+ } from './namespace/constants'
10
+ export {
11
+ ZEROPS_NAMESPACE_CANCELLED,
12
+ ZEROPS_NAMESPACE_LIFECYCLE,
13
+ ZEROPS_PROXY_DEPLOY_FAILED,
14
+ ZEROPS_PROXY_DEPLOY_TIMEOUT,
15
+ ZEROPS_SUBDOMAIN_NOT_PUBLISHED,
16
+ } from './namespace/errors'
17
+ export { createZeropsNamespaceOperator } from './namespace/operator'
18
+ export { createZeropsNamespaceCapabilities } from './namespace/provisioning'
19
+ export { republishProxy, type ZeropsProxyApi, type ZeropsProxyRepublishInput } from './namespace/proxy-attempt'
20
+ export { decodeZeropsNamespaceProxySource, verifyZeropsNamespaceProxySource } from './namespace/proxy-source'
21
+ export { compileZeropsNamespaceTopology, zeropsNamespacePreset } from './namespace/topology'
22
+ export type {
23
+ ZeropsNamespaceOperatorOptions,
24
+ ZeropsNamespaceOptions,
25
+ ZeropsNamespacePlanOptions,
26
+ ZeropsNamespacePostgres,
27
+ ZeropsNamespacePresetInput,
28
+ ZeropsNamespaceProxyBuild,
29
+ ZeropsNamespaceProxyDeployment,
30
+ ZeropsNamespaceProxySource,
31
+ ZeropsNamespacePublicAccess,
32
+ ZeropsNamespaceTarget,
33
+ ZeropsNamespaceTopology,
34
+ } from './namespace/types'