@fabrika/provider-zerops 0.0.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.
- package/package.json +40 -0
- package/src/api.ts +825 -0
- package/src/authoring.ts +33 -0
- package/src/cli-args.ts +101 -0
- package/src/cli.ts +82 -0
- package/src/codec.ts +50 -0
- package/src/collaborators.ts +47 -0
- package/src/compile.ts +382 -0
- package/src/control.ts +390 -0
- package/src/index.ts +98 -0
- package/src/manifest.ts +442 -0
- package/src/namespace-command.ts +209 -0
- package/src/namespace.ts +1028 -0
- package/src/plan.ts +51 -0
- package/src/provider.ts +311 -0
- package/src/schema.generated.ts +607 -0
- package/src/service-names.ts +38 -0
- package/src/types.ts +125 -0
package/src/namespace.ts
ADDED
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JsonValue,
|
|
3
|
+
ProviderCodec,
|
|
4
|
+
ProviderDeploymentNamespace,
|
|
5
|
+
ProviderEnvelope,
|
|
6
|
+
ProviderNamespaceCapabilities,
|
|
7
|
+
ProviderNamespaceMutationInput,
|
|
8
|
+
ProviderNamespaceOperator,
|
|
9
|
+
ProviderNamespacePlanInput,
|
|
10
|
+
ProviderNamespacePresentation,
|
|
11
|
+
ProviderNamespacePreset,
|
|
12
|
+
ProviderRegistration,
|
|
13
|
+
} from '@fabrika/provider-contract'
|
|
14
|
+
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
|
|
15
|
+
import {
|
|
16
|
+
ZEROPS_ACTIVE,
|
|
17
|
+
ZEROPS_SERVICE_NOT_HTTP,
|
|
18
|
+
ZEROPS_TERMINAL,
|
|
19
|
+
type ZeropsApi,
|
|
20
|
+
ZeropsApiError,
|
|
21
|
+
type ZeropsAppVersion,
|
|
22
|
+
type ZeropsProject,
|
|
23
|
+
type ZeropsProjectMode,
|
|
24
|
+
type ZeropsService,
|
|
25
|
+
} from './api'
|
|
26
|
+
import { defaultSleep, type Sleeper } from './collaborators'
|
|
27
|
+
import { compileImportYaml, compileProvisioningYaml } from './compile'
|
|
28
|
+
import { manifestServiceHostnames, parseFabrikaManifest, zeropsArtifactCodec } from './manifest'
|
|
29
|
+
import { zeropsSharedServicePrefix } from './service-names'
|
|
30
|
+
import type { ZeropsServiceSpec, ZeropsSourceTarget } from './types'
|
|
31
|
+
|
|
32
|
+
export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
|
|
33
|
+
export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
|
|
34
|
+
export const ZEROPS_NAMESPACE_IAM_URL_VARIABLE = 'FABRIKA_IAM_URL'
|
|
35
|
+
export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
|
|
36
|
+
/**
|
|
37
|
+
* How an app reaches the namespace-owned PostgreSQL service — database and TLS mode NAMED, not defaulted.
|
|
38
|
+
*
|
|
39
|
+
* Three facts settled against a live account, none of them safe to infer:
|
|
40
|
+
*
|
|
41
|
+
* • `${x_connectionString}` is `postgresql://${user}:${password}@${hostname}:${port}` — no database
|
|
42
|
+
* path. With none, the libpq/postgres.js convention falls back to the USER name.
|
|
43
|
+
* • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname
|
|
44
|
+
* (checked on a service named `wu2db`). So the fallback lands right today — by construction rather
|
|
45
|
+
* than by luck, but still by a rule nobody wrote down. `/${x_dbName}` says it out loud.
|
|
46
|
+
* • Port 5432 speaks TLS. `sslmode=require` connects with `pg_stat_ssl.ssl = true`; `sslmode=disable`
|
|
47
|
+
* connects in the clear. `verify-full` fails (`self signed certificate`), so `require` is the
|
|
48
|
+
* strongest mode available and the one worth pinning — the alternative is whatever the driver
|
|
49
|
+
* happens to default to.
|
|
50
|
+
*/
|
|
51
|
+
export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionString}/${postgres_dbName}?sslmode=require'
|
|
52
|
+
|
|
53
|
+
const PROXY_TYPE = 'alpine@3.21'
|
|
54
|
+
const EMPTY_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
|
|
55
|
+
const POLL_INTERVAL_MS = 3000
|
|
56
|
+
const POLL_TIMEOUT_MS = 70 * 60 * 1000
|
|
57
|
+
/** The live enable finishes in under a second, but one run still read `false` once before flipping; ten reads is slack. */
|
|
58
|
+
const SUBDOMAIN_READBACK_ATTEMPTS = 10
|
|
59
|
+
|
|
60
|
+
export type ZeropsNamespacePublicAccess = 'custom-domain' | 'zerops-subdomain'
|
|
61
|
+
|
|
62
|
+
export type ZeropsNamespacePostgres =
|
|
63
|
+
| {
|
|
64
|
+
readonly type: 'postgresql:ha@18'
|
|
65
|
+
readonly profile?: 'oltp-staging' | 'oltp-production' | 'oltp-enterprise' | 'olap-production' | 'writeheavy-production'
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
readonly type: 'postgresql:single@18'
|
|
69
|
+
readonly profile?: 'oltp-hobby' | 'oltp-staging' | 'oltp-production' | 'olap-production' | 'writeheavy-production'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Provider-owned namespace state. Optional fields are durable progress checkpoints; values that are
|
|
74
|
+
* absent are resolved by `normalize()` before a mutation starts.
|
|
75
|
+
*/
|
|
76
|
+
export interface ZeropsNamespaceTarget {
|
|
77
|
+
readonly projectName?: string
|
|
78
|
+
readonly corePackage?: ZeropsProjectMode
|
|
79
|
+
readonly publicAccess?: ZeropsNamespacePublicAccess
|
|
80
|
+
readonly proxyBuildFromGit?: string
|
|
81
|
+
readonly postgres?: ZeropsNamespacePostgres
|
|
82
|
+
readonly managed?: boolean
|
|
83
|
+
readonly projectId?: string
|
|
84
|
+
readonly proxyServiceId?: string
|
|
85
|
+
readonly postgresServiceId?: string
|
|
86
|
+
readonly proxyConfigured?: boolean
|
|
87
|
+
readonly proxyBaselineSequence?: number
|
|
88
|
+
readonly proxyAppVersionId?: string
|
|
89
|
+
readonly ready?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface NormalizedZeropsNamespaceTarget extends ZeropsNamespaceTarget {
|
|
93
|
+
readonly projectName: string
|
|
94
|
+
readonly corePackage: ZeropsProjectMode
|
|
95
|
+
readonly publicAccess: ZeropsNamespacePublicAccess
|
|
96
|
+
readonly proxyBuildFromGit: string
|
|
97
|
+
readonly managed: boolean
|
|
98
|
+
readonly ready: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ZeropsNamespaceOptions {
|
|
102
|
+
readonly clientId: string
|
|
103
|
+
readonly proxyBuildFromGit: string
|
|
104
|
+
/** Public IAM origin reachable from an app project. */
|
|
105
|
+
readonly iamUrl: string
|
|
106
|
+
/** Same credential IAM receives as `FABRIKA_IAM_PROXY_KEY`. */
|
|
107
|
+
readonly iamKey: string
|
|
108
|
+
readonly api: ZeropsApi
|
|
109
|
+
readonly sleep?: Sleeper
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ZeropsNamespaceTopology {
|
|
113
|
+
readonly source: ZeropsSourceTarget
|
|
114
|
+
readonly createYaml: string
|
|
115
|
+
readonly servicesProvisionYaml: string
|
|
116
|
+
readonly servicesSteadyYaml: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ZeropsNamespacePresetInput {
|
|
120
|
+
readonly preset: 'cheap' | 'mid' | 'full'
|
|
121
|
+
readonly env: string
|
|
122
|
+
readonly projectName: string
|
|
123
|
+
readonly corePackage?: ZeropsProjectMode
|
|
124
|
+
readonly publicAccess?: ZeropsNamespacePublicAccess
|
|
125
|
+
readonly proxyBuildFromGit: string
|
|
126
|
+
readonly postgres?: ZeropsNamespacePostgres
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ZeropsNamespaceOperatorOptions {
|
|
130
|
+
readonly proxyBuildFromGit: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ZeropsNamespacePlanOptions {
|
|
134
|
+
readonly projectName?: string
|
|
135
|
+
readonly corePackage?: ZeropsProjectMode
|
|
136
|
+
readonly publicAccess?: ZeropsNamespacePublicAccess
|
|
137
|
+
readonly postgres?: ZeropsNamespacePostgres
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const property = (payload: JsonValue, key: string): JsonValue | undefined => {
|
|
141
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
142
|
+
throw new Error('Zerops namespace target must be an object')
|
|
143
|
+
}
|
|
144
|
+
return payload[key]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const optionalString = (payload: JsonValue, key: string): string | undefined => {
|
|
148
|
+
const value = property(payload, key)
|
|
149
|
+
if (value === undefined) return undefined
|
|
150
|
+
if (typeof value !== 'string' || value === '') {
|
|
151
|
+
throw new Error(`Zerops namespace target ${key} must be a non-empty string`)
|
|
152
|
+
}
|
|
153
|
+
return value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const optionalBoolean = (payload: JsonValue, key: string): boolean | undefined => {
|
|
157
|
+
const value = property(payload, key)
|
|
158
|
+
if (value === undefined) return undefined
|
|
159
|
+
if (typeof value !== 'boolean') {
|
|
160
|
+
throw new Error(`Zerops namespace target ${key} must be a boolean`)
|
|
161
|
+
}
|
|
162
|
+
return value
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const optionalInteger = (payload: JsonValue, key: string): number | undefined => {
|
|
166
|
+
const value = property(payload, key)
|
|
167
|
+
if (value === undefined) return undefined
|
|
168
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < -1) {
|
|
169
|
+
throw new Error(`Zerops namespace target ${key} must be an integer greater than or equal to -1`)
|
|
170
|
+
}
|
|
171
|
+
return value
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const postgresProfiles = {
|
|
175
|
+
'postgresql:ha@18': new Set(['oltp-staging', 'oltp-production', 'oltp-enterprise', 'olap-production', 'writeheavy-production']),
|
|
176
|
+
'postgresql:single@18': new Set(['oltp-hobby', 'oltp-staging', 'oltp-production', 'olap-production', 'writeheavy-production']),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const parsePostgres = (payload: JsonValue): ZeropsNamespacePostgres | undefined => {
|
|
180
|
+
const value = property(payload, 'postgres')
|
|
181
|
+
if (value === undefined) return undefined
|
|
182
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
183
|
+
throw new Error('Zerops namespace target postgres must be an object')
|
|
184
|
+
}
|
|
185
|
+
const type = property(value, 'type')
|
|
186
|
+
const profile = property(value, 'profile')
|
|
187
|
+
if (type !== 'postgresql:ha@18' && type !== 'postgresql:single@18') {
|
|
188
|
+
throw new Error('Zerops namespace PostgreSQL type must be postgresql:ha@18 or postgresql:single@18')
|
|
189
|
+
}
|
|
190
|
+
if (profile !== undefined && (typeof profile !== 'string' || !postgresProfiles[type].has(profile))) {
|
|
191
|
+
throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
|
|
192
|
+
}
|
|
193
|
+
if (type === 'postgresql:ha@18') {
|
|
194
|
+
if (
|
|
195
|
+
profile !== undefined
|
|
196
|
+
&& profile !== 'oltp-staging'
|
|
197
|
+
&& profile !== 'oltp-production'
|
|
198
|
+
&& profile !== 'oltp-enterprise'
|
|
199
|
+
&& profile !== 'olap-production'
|
|
200
|
+
&& profile !== 'writeheavy-production'
|
|
201
|
+
) {
|
|
202
|
+
throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
|
|
203
|
+
}
|
|
204
|
+
return profile === undefined ? { type } : { type, profile }
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
profile !== undefined
|
|
208
|
+
&& profile !== 'oltp-hobby'
|
|
209
|
+
&& profile !== 'oltp-staging'
|
|
210
|
+
&& profile !== 'oltp-production'
|
|
211
|
+
&& profile !== 'olap-production'
|
|
212
|
+
&& profile !== 'writeheavy-production'
|
|
213
|
+
) {
|
|
214
|
+
throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
|
|
215
|
+
}
|
|
216
|
+
return profile === undefined ? { type } : { type, profile }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
|
|
220
|
+
version: 1,
|
|
221
|
+
encode: (target) => ({
|
|
222
|
+
...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
|
|
223
|
+
...(target.corePackage !== undefined ? { corePackage: target.corePackage } : {}),
|
|
224
|
+
...(target.publicAccess !== undefined ? { publicAccess: target.publicAccess } : {}),
|
|
225
|
+
...(target.proxyBuildFromGit !== undefined ? { proxyBuildFromGit: target.proxyBuildFromGit } : {}),
|
|
226
|
+
...(target.postgres !== undefined
|
|
227
|
+
? { postgres: { type: target.postgres.type, ...(target.postgres.profile !== undefined ? { profile: target.postgres.profile } : {}) } }
|
|
228
|
+
: {}),
|
|
229
|
+
...(target.managed !== undefined ? { managed: target.managed } : {}),
|
|
230
|
+
...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
|
|
231
|
+
...(target.proxyServiceId !== undefined ? { proxyServiceId: target.proxyServiceId } : {}),
|
|
232
|
+
...(target.postgresServiceId !== undefined ? { postgresServiceId: target.postgresServiceId } : {}),
|
|
233
|
+
...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
|
|
234
|
+
...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
|
|
235
|
+
...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
|
|
236
|
+
...(target.ready !== undefined ? { ready: target.ready } : {}),
|
|
237
|
+
}),
|
|
238
|
+
decode: (payload) => {
|
|
239
|
+
const corePackage = optionalString(payload, 'corePackage')
|
|
240
|
+
if (corePackage !== undefined && corePackage !== 'LIGHT' && corePackage !== 'SERIOUS') {
|
|
241
|
+
throw new Error('Zerops namespace target corePackage must be LIGHT or SERIOUS')
|
|
242
|
+
}
|
|
243
|
+
const publicAccess = optionalString(payload, 'publicAccess')
|
|
244
|
+
if (publicAccess !== undefined && publicAccess !== 'custom-domain' && publicAccess !== 'zerops-subdomain') {
|
|
245
|
+
throw new Error('Zerops namespace target publicAccess is not supported')
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
...(optionalString(payload, 'projectName') !== undefined ? { projectName: optionalString(payload, 'projectName') } : {}),
|
|
249
|
+
...(corePackage !== undefined ? { corePackage } : {}),
|
|
250
|
+
...(publicAccess !== undefined ? { publicAccess } : {}),
|
|
251
|
+
...(optionalString(payload, 'proxyBuildFromGit') !== undefined
|
|
252
|
+
? { proxyBuildFromGit: optionalString(payload, 'proxyBuildFromGit') }
|
|
253
|
+
: {}),
|
|
254
|
+
...(parsePostgres(payload) !== undefined ? { postgres: parsePostgres(payload) } : {}),
|
|
255
|
+
...(optionalBoolean(payload, 'managed') !== undefined ? { managed: optionalBoolean(payload, 'managed') } : {}),
|
|
256
|
+
...(optionalString(payload, 'projectId') !== undefined ? { projectId: optionalString(payload, 'projectId') } : {}),
|
|
257
|
+
...(optionalString(payload, 'proxyServiceId') !== undefined ? { proxyServiceId: optionalString(payload, 'proxyServiceId') } : {}),
|
|
258
|
+
...(optionalString(payload, 'postgresServiceId') !== undefined
|
|
259
|
+
? { postgresServiceId: optionalString(payload, 'postgresServiceId') }
|
|
260
|
+
: {}),
|
|
261
|
+
...(optionalBoolean(payload, 'proxyConfigured') !== undefined
|
|
262
|
+
? { proxyConfigured: optionalBoolean(payload, 'proxyConfigured') }
|
|
263
|
+
: {}),
|
|
264
|
+
...(optionalInteger(payload, 'proxyBaselineSequence') !== undefined
|
|
265
|
+
? { proxyBaselineSequence: optionalInteger(payload, 'proxyBaselineSequence') }
|
|
266
|
+
: {}),
|
|
267
|
+
...(optionalString(payload, 'proxyAppVersionId') !== undefined
|
|
268
|
+
? { proxyAppVersionId: optionalString(payload, 'proxyAppVersionId') }
|
|
269
|
+
: {}),
|
|
270
|
+
...(optionalBoolean(payload, 'ready') !== undefined ? { ready: optionalBoolean(payload, 'ready') } : {}),
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const envelope = (target: ZeropsNamespaceTarget): ProviderEnvelope => ({
|
|
276
|
+
provider: 'zerops',
|
|
277
|
+
version: zeropsNamespaceTargetCodec.version,
|
|
278
|
+
payload: zeropsNamespaceTargetCodec.encode(target),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const decodeEnvelope = (value: ProviderEnvelope): ZeropsNamespaceTarget => {
|
|
282
|
+
if (value.provider !== 'zerops') {
|
|
283
|
+
throw new Error(`namespace belongs to provider "${value.provider}", expected "zerops"`)
|
|
284
|
+
}
|
|
285
|
+
if (value.version !== zeropsNamespaceTargetCodec.version) {
|
|
286
|
+
throw new Error(`namespace schema version ${value.version} is not supported by provider "zerops"`)
|
|
287
|
+
}
|
|
288
|
+
return zeropsNamespaceTargetCodec.decode(value.payload)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const namespaceResourceClaims = (namespace: ProviderDeploymentNamespace): string[] => {
|
|
292
|
+
const target = decodeEnvelope(namespace.target)
|
|
293
|
+
return [
|
|
294
|
+
`service:${ZEROPS_NAMESPACE_PROXY_HOSTNAME}`,
|
|
295
|
+
...(target.postgres === undefined ? [] : [`service:${ZEROPS_NAMESPACE_POSTGRES_HOSTNAME}`]),
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const registrationResourceClaims = (registration: ProviderRegistration): string[] => {
|
|
300
|
+
const namespace = registration.environment.namespace
|
|
301
|
+
if (namespace === undefined) {
|
|
302
|
+
throw new Error('Zerops registration requires a deployment namespace')
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
namespace.exclusiveAppId !== undefined
|
|
306
|
+
&& namespace.exclusiveAppId !== registration.app.id
|
|
307
|
+
) {
|
|
308
|
+
throw new Error(`Zerops namespace is exclusive to app \`${namespace.exclusiveAppId}\``)
|
|
309
|
+
}
|
|
310
|
+
const artifactEnvelope = registration.environment.artifact
|
|
311
|
+
if (artifactEnvelope.provider !== 'zerops' || artifactEnvelope.version !== zeropsArtifactCodec.version) {
|
|
312
|
+
throw new Error('Zerops registration artifact envelope is not supported')
|
|
313
|
+
}
|
|
314
|
+
const manifest = parseFabrikaManifest(zeropsArtifactCodec.decode(artifactEnvelope.payload), {
|
|
315
|
+
appId: registration.app.id,
|
|
316
|
+
env: registration.environment.env,
|
|
317
|
+
})
|
|
318
|
+
const namespaceClaims = new Set(namespaceResourceClaims(namespace))
|
|
319
|
+
for (const requirement of manifest.target.namespaceResources ?? []) {
|
|
320
|
+
if (!namespaceClaims.has(requirement.resourceKey)) {
|
|
321
|
+
throw new Error(`Zerops namespace does not provide required resource \`${requirement.resourceKey}\``)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const hostnames = manifestServiceHostnames(manifest)
|
|
325
|
+
for (const hostname of hostnames) {
|
|
326
|
+
const key = `service:${hostname}`
|
|
327
|
+
if (namespaceClaims.has(key)) {
|
|
328
|
+
throw new Error(`Zerops service hostname \`${hostname}\` is reserved by the namespace`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (namespace.exclusiveAppId === undefined) {
|
|
332
|
+
const prefix = zeropsSharedServicePrefix(registration.app.id)
|
|
333
|
+
for (const hostname of hostnames) {
|
|
334
|
+
if (!hostname.startsWith(prefix) || hostname.length === prefix.length) {
|
|
335
|
+
throw new Error(`Zerops shared namespace service \`${hostname}\` must use app prefix \`${prefix}\``)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return hostnames.map((hostname) => `service:${hostname}`).sort()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The namespace-owned PostgreSQL service, sized rather than defaulted.
|
|
344
|
+
*
|
|
345
|
+
* Omitting `profile` is not neutral: an HA service silently gets `oltp-production` (two DEDICATED cores
|
|
346
|
+
* and 4 GB per container, three containers) and a single one gets `oltp-staging`. Both verified by
|
|
347
|
+
* reading the profile back off a live service. Production takes the production preset deliberately;
|
|
348
|
+
* everything else takes the cheapest one its type offers, because a namespace database for a staging
|
|
349
|
+
* app should cost what staging is worth.
|
|
350
|
+
*/
|
|
351
|
+
const defaultPostgres = (env: string): ZeropsNamespacePostgres =>
|
|
352
|
+
env === 'prod' ? { type: 'postgresql:ha@18', profile: 'oltp-production' } : { type: 'postgresql:single@18', profile: 'oltp-hobby' }
|
|
353
|
+
|
|
354
|
+
export const zeropsNamespacePreset = (input: ZeropsNamespacePresetInput): ZeropsNamespaceTarget => ({
|
|
355
|
+
projectName: input.projectName,
|
|
356
|
+
corePackage: input.corePackage ?? (input.env === 'prod' ? 'SERIOUS' : 'LIGHT'),
|
|
357
|
+
publicAccess: input.publicAccess ?? 'custom-domain',
|
|
358
|
+
proxyBuildFromGit: input.proxyBuildFromGit,
|
|
359
|
+
managed: true,
|
|
360
|
+
ready: false,
|
|
361
|
+
...(input.preset === 'cheap' ? { postgres: input.postgres ?? defaultPostgres(input.env) } : {}),
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
const projectMarker = (namespace: ProviderDeploymentNamespace): string => `Managed by Fabrika namespace ${namespace.id} (${namespace.env}).`
|
|
365
|
+
|
|
366
|
+
// No `zeropsSetup`: the platform reads it as pipeline configuration and rejects the whole import unless
|
|
367
|
+
// the service also names a `buildFromGit`, which an import that only PROVISIONS does not. The setup name
|
|
368
|
+
// is supplied where it is actually needed — the `triggerPipeline` call in the namespace lifecycle below,
|
|
369
|
+
// which passes both. `assertZeropsInvariants` refuses the invalid combination.
|
|
370
|
+
//
|
|
371
|
+
// `enableSubdomainAccess` is a DECLARATION here and not a mechanism: the platform accepts it and drops it,
|
|
372
|
+
// including on the service this document creates. `ensureSubdomainAccess` below is what establishes the
|
|
373
|
+
// subdomain, after the proxy's first deploy. Written anyway because it is what ADR-0007's
|
|
374
|
+
// `assertOnlyPublicService` reads to prove no other service is claimed public.
|
|
375
|
+
const proxyService = (publicAccess: ZeropsNamespacePublicAccess): ZeropsServiceSpec => ({
|
|
376
|
+
hostname: ZEROPS_NAMESPACE_PROXY_HOSTNAME,
|
|
377
|
+
type: PROXY_TYPE,
|
|
378
|
+
priority: 10,
|
|
379
|
+
enableSubdomainAccess: publicAccess === 'zerops-subdomain',
|
|
380
|
+
minContainers: 2,
|
|
381
|
+
maxContainers: 6,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
const postgresService = (postgres: ZeropsNamespacePostgres): ZeropsServiceSpec => ({
|
|
385
|
+
hostname: ZEROPS_NAMESPACE_POSTGRES_HOSTNAME,
|
|
386
|
+
type: postgres.type,
|
|
387
|
+
priority: 100,
|
|
388
|
+
...(postgres.profile !== undefined ? { profile: postgres.profile } : {}),
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
/** Compile the one project and its namespace-owned services without depending on private deploy code. */
|
|
392
|
+
export const compileZeropsNamespaceTopology = (
|
|
393
|
+
namespace: ProviderDeploymentNamespace,
|
|
394
|
+
target: ZeropsNamespaceTarget,
|
|
395
|
+
): ZeropsNamespaceTopology => {
|
|
396
|
+
const projectName = target.projectName
|
|
397
|
+
const corePackage = target.corePackage
|
|
398
|
+
const publicAccess = target.publicAccess
|
|
399
|
+
if (projectName === undefined || corePackage === undefined || publicAccess === undefined) {
|
|
400
|
+
throw new Error('Zerops namespace topology requires normalized project policy')
|
|
401
|
+
}
|
|
402
|
+
const services = (): ZeropsServiceSpec[] => [
|
|
403
|
+
...(target.postgres === undefined ? [] : [postgresService(target.postgres)]),
|
|
404
|
+
proxyService(publicAccess),
|
|
405
|
+
]
|
|
406
|
+
const source: ZeropsSourceTarget = {
|
|
407
|
+
platform: 'zerops',
|
|
408
|
+
project: {
|
|
409
|
+
name: projectName,
|
|
410
|
+
description: projectMarker(namespace),
|
|
411
|
+
corePackage,
|
|
412
|
+
tags: ['fabrika', 'namespace', namespace.id, namespace.env],
|
|
413
|
+
},
|
|
414
|
+
services,
|
|
415
|
+
}
|
|
416
|
+
const serviceSource: ZeropsSourceTarget = { platform: 'zerops', services }
|
|
417
|
+
return {
|
|
418
|
+
source,
|
|
419
|
+
createYaml: compileProvisioningYaml({ target: source, ctx: { env: namespace.env } }).yaml,
|
|
420
|
+
servicesProvisionYaml: compileProvisioningYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
|
|
421
|
+
servicesSteadyYaml: compileImportYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const ZEROPS_NAMESPACE_PRESETS: readonly ProviderNamespacePreset[] = [
|
|
426
|
+
{
|
|
427
|
+
id: 'cheap',
|
|
428
|
+
label: 'Cheap',
|
|
429
|
+
description: 'Several apps share one project, proxy, and namespace-owned PostgreSQL service.',
|
|
430
|
+
requiresExclusiveApp: false,
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
id: 'mid',
|
|
434
|
+
label: 'Mid',
|
|
435
|
+
description: 'Several apps share one project and proxy; each app owns its prefixed database services.',
|
|
436
|
+
requiresExclusiveApp: false,
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
id: 'full',
|
|
440
|
+
label: 'Full',
|
|
441
|
+
description: 'One project and proxy are reserved for exactly one app.',
|
|
442
|
+
requiresExclusiveApp: true,
|
|
443
|
+
},
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
const parsePlanOptions = (value: JsonValue | undefined): ZeropsNamespacePlanOptions => {
|
|
447
|
+
if (value === undefined) return {}
|
|
448
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
449
|
+
throw new Error('Zerops namespace plan options must be an object')
|
|
450
|
+
}
|
|
451
|
+
const supported = new Set(['projectName', 'corePackage', 'publicAccess', 'postgres'])
|
|
452
|
+
for (const key of Object.keys(value)) {
|
|
453
|
+
if (!supported.has(key)) {
|
|
454
|
+
throw new Error(`Zerops namespace plan option ${key} is not supported`)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const projectName = optionalString(value, 'projectName')
|
|
458
|
+
const corePackage = optionalString(value, 'corePackage')
|
|
459
|
+
if (corePackage !== undefined && corePackage !== 'LIGHT' && corePackage !== 'SERIOUS') {
|
|
460
|
+
throw new Error('Zerops namespace plan corePackage must be LIGHT or SERIOUS')
|
|
461
|
+
}
|
|
462
|
+
const publicAccess = optionalString(value, 'publicAccess')
|
|
463
|
+
if (publicAccess !== undefined && publicAccess !== 'custom-domain' && publicAccess !== 'zerops-subdomain') {
|
|
464
|
+
throw new Error('Zerops namespace plan publicAccess is not supported')
|
|
465
|
+
}
|
|
466
|
+
const postgres = parsePostgres(value)
|
|
467
|
+
return {
|
|
468
|
+
...(projectName === undefined ? {} : { projectName }),
|
|
469
|
+
...(corePackage === undefined ? {} : { corePackage }),
|
|
470
|
+
...(publicAccess === undefined ? {} : { publicAccess }),
|
|
471
|
+
...(postgres === undefined ? {} : { postgres }),
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const namespacePresentation = (
|
|
476
|
+
namespace: ProviderDeploymentNamespace,
|
|
477
|
+
target: ZeropsNamespaceTarget,
|
|
478
|
+
): ProviderNamespacePresentation => {
|
|
479
|
+
const preset = target.postgres !== undefined ? 'cheap' : namespace.exclusiveAppId === undefined ? 'mid' : 'full'
|
|
480
|
+
const projectName = target.projectName ?? namespace.id
|
|
481
|
+
const corePackage = target.corePackage ?? (namespace.env === 'prod' ? 'SERIOUS' : 'LIGHT')
|
|
482
|
+
const publicAccess = target.publicAccess ?? 'custom-domain'
|
|
483
|
+
const postgres = target.postgres === undefined
|
|
484
|
+
? 'App-owned database services'
|
|
485
|
+
: `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
|
|
486
|
+
const instructions = [
|
|
487
|
+
...(publicAccess === 'custom-domain'
|
|
488
|
+
? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
|
|
489
|
+
: ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
|
|
490
|
+
...(target.postgres === undefined
|
|
491
|
+
? []
|
|
492
|
+
: ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
|
|
493
|
+
]
|
|
494
|
+
return {
|
|
495
|
+
preset,
|
|
496
|
+
title: `${ZEROPS_NAMESPACE_PRESETS.find((candidate) => candidate.id === preset)?.label ?? preset} namespace`,
|
|
497
|
+
facts: [
|
|
498
|
+
{ label: 'Project', value: projectName },
|
|
499
|
+
{ label: 'Environment', value: namespace.env },
|
|
500
|
+
{ label: 'Core package', value: corePackage },
|
|
501
|
+
{ label: 'Placement', value: namespace.exclusiveAppId === undefined ? 'Shared' : `Exclusive to ${namespace.exclusiveAppId}` },
|
|
502
|
+
{ label: 'PostgreSQL', value: postgres },
|
|
503
|
+
{ label: 'Public access', value: publicAccess },
|
|
504
|
+
],
|
|
505
|
+
instructions,
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Build the provider-owned namespace planning surface without any network collaborators. */
|
|
510
|
+
export const createZeropsNamespaceOperator = (
|
|
511
|
+
options: ZeropsNamespaceOperatorOptions,
|
|
512
|
+
): ProviderNamespaceOperator => {
|
|
513
|
+
if (options.proxyBuildFromGit.trim() === '') {
|
|
514
|
+
throw new Error('Zerops namespace proxyBuildFromGit must be a non-empty string')
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
presets: ZEROPS_NAMESPACE_PRESETS,
|
|
518
|
+
plan(input: ProviderNamespacePlanInput) {
|
|
519
|
+
if (input.id.trim() === '' || input.env.trim() === '') {
|
|
520
|
+
throw new Error('Zerops namespace plan id and env must be non-empty')
|
|
521
|
+
}
|
|
522
|
+
if (input.preset !== 'cheap' && input.preset !== 'mid' && input.preset !== 'full') {
|
|
523
|
+
throw new Error(`Zerops namespace preset ${input.preset} is not supported`)
|
|
524
|
+
}
|
|
525
|
+
const exclusiveAppId = input.exclusiveAppId?.trim()
|
|
526
|
+
if (input.preset === 'full' && (exclusiveAppId === undefined || exclusiveAppId === '')) {
|
|
527
|
+
throw new Error('Zerops full namespace plan requires exclusiveAppId')
|
|
528
|
+
}
|
|
529
|
+
if (input.preset !== 'full' && exclusiveAppId !== undefined) {
|
|
530
|
+
throw new Error(`Zerops ${input.preset} namespace plan cannot reserve an exclusive app`)
|
|
531
|
+
}
|
|
532
|
+
const planOptions = parsePlanOptions(input.options)
|
|
533
|
+
if (input.preset !== 'cheap' && planOptions.postgres !== undefined) {
|
|
534
|
+
throw new Error(`Zerops ${input.preset} namespace plan cannot own PostgreSQL`)
|
|
535
|
+
}
|
|
536
|
+
const target = zeropsNamespacePreset({
|
|
537
|
+
preset: input.preset,
|
|
538
|
+
env: input.env,
|
|
539
|
+
projectName: planOptions.projectName ?? input.id,
|
|
540
|
+
proxyBuildFromGit: options.proxyBuildFromGit,
|
|
541
|
+
...(planOptions.corePackage === undefined ? {} : { corePackage: planOptions.corePackage }),
|
|
542
|
+
...(planOptions.publicAccess === undefined ? {} : { publicAccess: planOptions.publicAccess }),
|
|
543
|
+
...(planOptions.postgres === undefined ? {} : { postgres: planOptions.postgres }),
|
|
544
|
+
})
|
|
545
|
+
const namespace: ProviderDeploymentNamespace = {
|
|
546
|
+
id: input.id,
|
|
547
|
+
env: input.env,
|
|
548
|
+
...(exclusiveAppId === undefined ? {} : { exclusiveAppId }),
|
|
549
|
+
target: envelope(target),
|
|
550
|
+
}
|
|
551
|
+
return { namespace, presentation: namespacePresentation(namespace, target) }
|
|
552
|
+
},
|
|
553
|
+
present(namespace) {
|
|
554
|
+
return namespacePresentation(namespace, decodeEnvelope(namespace.target))
|
|
555
|
+
},
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const normalizeTarget = (
|
|
560
|
+
namespace: ProviderDeploymentNamespace,
|
|
561
|
+
options: ZeropsNamespaceOptions,
|
|
562
|
+
): NormalizedZeropsNamespaceTarget => {
|
|
563
|
+
if (namespace.id.trim() === '' || namespace.env.trim() === '') {
|
|
564
|
+
throw new Error('Zerops namespace id and env must be non-empty')
|
|
565
|
+
}
|
|
566
|
+
const decoded = decodeEnvelope(namespace.target)
|
|
567
|
+
const proxyBuildFromGit = decoded.proxyBuildFromGit ?? options.proxyBuildFromGit
|
|
568
|
+
if (proxyBuildFromGit.trim() === '') {
|
|
569
|
+
throw new Error('Zerops namespace proxy source must be a non-empty public Git URL')
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
...decoded,
|
|
573
|
+
projectName: decoded.projectName ?? namespace.id,
|
|
574
|
+
corePackage: decoded.corePackage ?? (namespace.env === 'prod' ? 'SERIOUS' : 'LIGHT'),
|
|
575
|
+
publicAccess: decoded.publicAccess ?? 'custom-domain',
|
|
576
|
+
proxyBuildFromGit,
|
|
577
|
+
managed: decoded.managed ?? decoded.projectId === undefined,
|
|
578
|
+
ready: decoded.ready ?? false,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const normalizedNamespace = (
|
|
583
|
+
namespace: ProviderDeploymentNamespace,
|
|
584
|
+
options: ZeropsNamespaceOptions,
|
|
585
|
+
): ProviderDeploymentNamespace => ({
|
|
586
|
+
...namespace,
|
|
587
|
+
target: envelope(normalizeTarget(namespace, options)),
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
const withTarget = (
|
|
591
|
+
namespace: ProviderDeploymentNamespace,
|
|
592
|
+
target: ZeropsNamespaceTarget,
|
|
593
|
+
): ProviderDeploymentNamespace => ({ ...namespace, target: envelope(target) })
|
|
594
|
+
|
|
595
|
+
const checkpoint = async (
|
|
596
|
+
input: ProviderNamespaceMutationInput,
|
|
597
|
+
namespace: ProviderDeploymentNamespace,
|
|
598
|
+
target: ZeropsNamespaceTarget,
|
|
599
|
+
): Promise<ProviderDeploymentNamespace> => {
|
|
600
|
+
const updated = withTarget(namespace, target)
|
|
601
|
+
await input.events.checkpoint(updated)
|
|
602
|
+
return updated
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const validateProject = (
|
|
606
|
+
namespace: ProviderDeploymentNamespace,
|
|
607
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
608
|
+
project: ZeropsProject,
|
|
609
|
+
adopting: boolean,
|
|
610
|
+
): void => {
|
|
611
|
+
if (project.id === '') {
|
|
612
|
+
throw new Error('Zerops namespace project returned an empty id')
|
|
613
|
+
}
|
|
614
|
+
if (target.projectName !== project.name) {
|
|
615
|
+
throw new Error(`Zerops namespace project name mismatch: expected \`${target.projectName}\`, got \`${project.name}\``)
|
|
616
|
+
}
|
|
617
|
+
if (project.mode !== target.corePackage) {
|
|
618
|
+
throw new Error(`Zerops namespace project ${project.id} uses ${project.mode ?? 'an unknown core package'}, expected ${target.corePackage}`)
|
|
619
|
+
}
|
|
620
|
+
// Zerops does not expose project envIsolation. Every managed service import writes its own override.
|
|
621
|
+
if (!adopting && target.managed && project.description?.includes(projectMarker(namespace)) !== true) {
|
|
622
|
+
throw new Error(`Zerops namespace project ${project.id} has a different Fabrika ownership marker`)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const managedProjectByMarker = async (
|
|
627
|
+
namespace: ProviderDeploymentNamespace,
|
|
628
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
629
|
+
options: ZeropsNamespaceOptions,
|
|
630
|
+
signal: AbortSignal,
|
|
631
|
+
): Promise<ZeropsProject | null> => {
|
|
632
|
+
const matches = await options.api.findProjects({
|
|
633
|
+
clientId: options.clientId,
|
|
634
|
+
name: target.projectName,
|
|
635
|
+
signal,
|
|
636
|
+
})
|
|
637
|
+
const owned: ZeropsProject[] = []
|
|
638
|
+
for (const match of matches) {
|
|
639
|
+
const project = await options.api.getProject({ projectId: match.id, signal })
|
|
640
|
+
if (project.description?.includes(projectMarker(namespace)) === true) {
|
|
641
|
+
owned.push(project)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (owned.length > 1) {
|
|
645
|
+
throw new Error(`Zerops namespace project name \`${target.projectName}\` has multiple matching Fabrika ownership markers`)
|
|
646
|
+
}
|
|
647
|
+
if (owned.length === 1) {
|
|
648
|
+
return owned[0] ?? null
|
|
649
|
+
}
|
|
650
|
+
if (matches.length > 0) {
|
|
651
|
+
throw new Error(`Zerops namespace project name \`${target.projectName}\` is already used without this namespace's ownership marker`)
|
|
652
|
+
}
|
|
653
|
+
return null
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const uniqueService = (services: ZeropsService[], hostname: string): ZeropsService | null => {
|
|
657
|
+
const matches = services.filter((service) => service.name === hostname)
|
|
658
|
+
if (matches.length > 1) {
|
|
659
|
+
throw new Error(`Zerops namespace has multiple services named \`${hostname}\``)
|
|
660
|
+
}
|
|
661
|
+
return matches[0] ?? null
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const validateService = (
|
|
665
|
+
service: ZeropsService,
|
|
666
|
+
projectId: string,
|
|
667
|
+
expectedBase: string,
|
|
668
|
+
adopting: boolean,
|
|
669
|
+
expectedPublic?: boolean,
|
|
670
|
+
expectedProfile?: string,
|
|
671
|
+
): void => {
|
|
672
|
+
if (service.id === '') {
|
|
673
|
+
throw new Error(`Zerops namespace service \`${service.name}\` returned an empty id`)
|
|
674
|
+
}
|
|
675
|
+
if (service.projectId !== undefined && service.projectId !== projectId) {
|
|
676
|
+
throw new Error(`Zerops namespace service \`${service.name}\` belongs to another project`)
|
|
677
|
+
}
|
|
678
|
+
if ((adopting || service.base !== undefined) && service.base !== expectedBase) {
|
|
679
|
+
throw new Error(`Zerops namespace service \`${service.name}\` uses ${service.base ?? 'an unknown type'}, expected ${expectedBase}`)
|
|
680
|
+
}
|
|
681
|
+
if (expectedPublic !== undefined && adopting && service.subdomainAccess !== expectedPublic) {
|
|
682
|
+
throw new Error(`Zerops namespace service \`${service.name}\` has incompatible public access`)
|
|
683
|
+
}
|
|
684
|
+
if (expectedProfile !== undefined && service.autoscalingProfileId !== expectedProfile) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`Zerops namespace service \`${service.name}\` uses profile ${service.autoscalingProfileId ?? 'unknown'}, expected ${expectedProfile}`,
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const resolveServices = async (
|
|
692
|
+
namespace: ProviderDeploymentNamespace,
|
|
693
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
694
|
+
options: ZeropsNamespaceOptions,
|
|
695
|
+
signal: AbortSignal,
|
|
696
|
+
adopting: boolean,
|
|
697
|
+
): Promise<{ proxy: ZeropsService; postgres?: ZeropsService }> => {
|
|
698
|
+
const projectId = target.projectId
|
|
699
|
+
if (projectId === undefined) {
|
|
700
|
+
throw new Error('Zerops namespace has no project id')
|
|
701
|
+
}
|
|
702
|
+
const services = await options.api.listProjectServices({ projectId, signal })
|
|
703
|
+
const proxy = uniqueService(services, ZEROPS_NAMESPACE_PROXY_HOSTNAME)
|
|
704
|
+
if (proxy === null) {
|
|
705
|
+
throw new Error(`Zerops namespace project ${projectId} has no proxy service`)
|
|
706
|
+
}
|
|
707
|
+
validateService(
|
|
708
|
+
proxy,
|
|
709
|
+
projectId,
|
|
710
|
+
PROXY_TYPE,
|
|
711
|
+
adopting,
|
|
712
|
+
target.publicAccess === 'zerops-subdomain',
|
|
713
|
+
)
|
|
714
|
+
const postgres = uniqueService(services, ZEROPS_NAMESPACE_POSTGRES_HOSTNAME)
|
|
715
|
+
if (target.postgres === undefined && postgres !== null && adopting) {
|
|
716
|
+
throw new Error(`Zerops namespace project ${projectId} has an undeclared namespace PostgreSQL service`)
|
|
717
|
+
}
|
|
718
|
+
if (target.postgres !== undefined) {
|
|
719
|
+
if (postgres === null) {
|
|
720
|
+
throw new Error(`Zerops namespace project ${projectId} has no shared PostgreSQL service`)
|
|
721
|
+
}
|
|
722
|
+
validateService(postgres, projectId, target.postgres.type, adopting, undefined, target.postgres.profile)
|
|
723
|
+
}
|
|
724
|
+
if (adopting && namespace.exclusiveAppId !== undefined) {
|
|
725
|
+
const reserved = new Set([
|
|
726
|
+
ZEROPS_NAMESPACE_PROXY_HOSTNAME,
|
|
727
|
+
...(target.postgres === undefined ? [] : [ZEROPS_NAMESPACE_POSTGRES_HOSTNAME]),
|
|
728
|
+
])
|
|
729
|
+
const unexpected = services.filter((service) => !reserved.has(service.name))
|
|
730
|
+
if (unexpected.length > 0) {
|
|
731
|
+
throw new Error(`exclusive Zerops namespace project ${projectId} contains unmanaged service \`${unexpected[0]?.name ?? 'unknown'}\``)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return postgres === null ? { proxy } : { proxy, postgres }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const ensureProxyConfiguration = async (
|
|
738
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
739
|
+
options: ZeropsNamespaceOptions,
|
|
740
|
+
signal: AbortSignal,
|
|
741
|
+
): Promise<boolean> => {
|
|
742
|
+
const proxyServiceId = target.proxyServiceId
|
|
743
|
+
if (proxyServiceId === undefined) {
|
|
744
|
+
throw new Error('Zerops namespace has no proxy service id')
|
|
745
|
+
}
|
|
746
|
+
const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
|
|
747
|
+
const byKey = new Map(variables.map((variable) => [variable.key, variable]))
|
|
748
|
+
let deployRequired = !target.ready
|
|
749
|
+
const manifest = byKey.get(FABRIKA_PROXY_MANIFEST_JSON)
|
|
750
|
+
if (!target.ready || manifest === undefined) {
|
|
751
|
+
await options.api.putServiceEnv({
|
|
752
|
+
serviceId: proxyServiceId,
|
|
753
|
+
key: FABRIKA_PROXY_MANIFEST_JSON,
|
|
754
|
+
value: EMPTY_PROXY_MANIFEST,
|
|
755
|
+
signal,
|
|
756
|
+
})
|
|
757
|
+
deployRequired = true
|
|
758
|
+
}
|
|
759
|
+
const iamUrl = byKey.get(ZEROPS_NAMESPACE_IAM_URL_VARIABLE)
|
|
760
|
+
if (iamUrl?.content !== options.iamUrl) {
|
|
761
|
+
await options.api.putServiceEnv({
|
|
762
|
+
serviceId: proxyServiceId,
|
|
763
|
+
key: ZEROPS_NAMESPACE_IAM_URL_VARIABLE,
|
|
764
|
+
value: options.iamUrl,
|
|
765
|
+
signal,
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
// Secret read-back is not trustworthy, so overwrite it idempotently without inspecting its value.
|
|
769
|
+
await options.api.putServiceEnv({
|
|
770
|
+
serviceId: proxyServiceId,
|
|
771
|
+
key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE,
|
|
772
|
+
value: options.iamKey,
|
|
773
|
+
signal,
|
|
774
|
+
})
|
|
775
|
+
return deployRequired
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const latestAfterBaseline = async (
|
|
779
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
780
|
+
options: ZeropsNamespaceOptions,
|
|
781
|
+
signal: AbortSignal,
|
|
782
|
+
): Promise<ZeropsAppVersion | null> => {
|
|
783
|
+
const proxyServiceId = target.proxyServiceId
|
|
784
|
+
if (proxyServiceId === undefined) {
|
|
785
|
+
throw new Error('Zerops namespace has no proxy service id')
|
|
786
|
+
}
|
|
787
|
+
const latest = await options.api.latestAppVersion({ serviceId: proxyServiceId, signal })
|
|
788
|
+
const baseline = target.proxyBaselineSequence
|
|
789
|
+
if (latest === null || baseline === undefined || latest.sequence === undefined || latest.sequence <= baseline) {
|
|
790
|
+
return null
|
|
791
|
+
}
|
|
792
|
+
return latest
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const terminalFailure = (version: ZeropsAppVersion): Error =>
|
|
796
|
+
new Error(`Zerops namespace proxy deploy ${version.id} finished as ${version.status ?? 'unknown'}`)
|
|
797
|
+
|
|
798
|
+
const waitForProxy = async (
|
|
799
|
+
appVersionId: string,
|
|
800
|
+
options: ZeropsNamespaceOptions,
|
|
801
|
+
signal: AbortSignal,
|
|
802
|
+
): Promise<ZeropsAppVersion> => {
|
|
803
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS
|
|
804
|
+
for (;;) {
|
|
805
|
+
if (signal.aborted) {
|
|
806
|
+
throw new Error('Zerops namespace provisioning was cancelled')
|
|
807
|
+
}
|
|
808
|
+
const version = await options.api.getAppVersion({ appVersionId, signal })
|
|
809
|
+
if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
|
|
810
|
+
return version
|
|
811
|
+
}
|
|
812
|
+
if (Date.now() > deadline) {
|
|
813
|
+
throw new Error(`Zerops namespace proxy deploy ${appVersionId} timed out`)
|
|
814
|
+
}
|
|
815
|
+
await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const deployProxy = async (
|
|
820
|
+
input: ProviderNamespaceMutationInput,
|
|
821
|
+
namespace: ProviderDeploymentNamespace,
|
|
822
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
823
|
+
options: ZeropsNamespaceOptions,
|
|
824
|
+
): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
|
|
825
|
+
let currentNamespace = namespace
|
|
826
|
+
let currentTarget = target
|
|
827
|
+
const proxyServiceId = currentTarget.proxyServiceId
|
|
828
|
+
if (proxyServiceId === undefined) {
|
|
829
|
+
throw new Error('Zerops namespace has no proxy service id')
|
|
830
|
+
}
|
|
831
|
+
if (currentTarget.proxyAppVersionId !== undefined) {
|
|
832
|
+
const version = await waitForProxy(currentTarget.proxyAppVersionId, options, input.signal)
|
|
833
|
+
if (version.status === ZEROPS_ACTIVE) {
|
|
834
|
+
return { namespace: currentNamespace, target: currentTarget }
|
|
835
|
+
}
|
|
836
|
+
currentTarget = {
|
|
837
|
+
...currentTarget,
|
|
838
|
+
proxyAppVersionId: undefined,
|
|
839
|
+
proxyBaselineSequence: undefined,
|
|
840
|
+
ready: false,
|
|
841
|
+
}
|
|
842
|
+
currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
let recovered: ZeropsAppVersion | null = null
|
|
846
|
+
if (currentTarget.proxyBaselineSequence !== undefined) {
|
|
847
|
+
recovered = await latestAfterBaseline(currentTarget, options, input.signal)
|
|
848
|
+
}
|
|
849
|
+
if (recovered === null) {
|
|
850
|
+
if (currentTarget.proxyBaselineSequence === undefined) {
|
|
851
|
+
const prior = await options.api.latestAppVersion({ serviceId: proxyServiceId, signal: input.signal })
|
|
852
|
+
currentTarget = {
|
|
853
|
+
...currentTarget,
|
|
854
|
+
proxyBaselineSequence: prior?.sequence ?? -1,
|
|
855
|
+
}
|
|
856
|
+
currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
|
|
857
|
+
}
|
|
858
|
+
const process = await options.api.triggerPipeline({
|
|
859
|
+
serviceId: proxyServiceId,
|
|
860
|
+
buildFromGit: currentTarget.proxyBuildFromGit,
|
|
861
|
+
zeropsSetup: ZEROPS_NAMESPACE_PROXY_HOSTNAME,
|
|
862
|
+
signal: input.signal,
|
|
863
|
+
})
|
|
864
|
+
recovered = process?.appVersionId === undefined
|
|
865
|
+
? await latestAfterBaseline(currentTarget, options, input.signal)
|
|
866
|
+
: { id: process.appVersionId }
|
|
867
|
+
}
|
|
868
|
+
if (recovered === null || recovered.id === '') {
|
|
869
|
+
throw new Error('Zerops namespace proxy pipeline did not expose a new app-version')
|
|
870
|
+
}
|
|
871
|
+
currentTarget = { ...currentTarget, proxyAppVersionId: recovered.id }
|
|
872
|
+
currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
|
|
873
|
+
const version = await waitForProxy(recovered.id, options, input.signal)
|
|
874
|
+
if (version.status !== ZEROPS_ACTIVE) {
|
|
875
|
+
throw terminalFailure(version)
|
|
876
|
+
}
|
|
877
|
+
return { namespace: currentNamespace, target: currentTarget }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Publish the proxy on its `*.zerops.app` subdomain — the ONE thing the import document cannot do.
|
|
882
|
+
*
|
|
883
|
+
* `enableSubdomainAccess: true` is accepted by the import and then silently dropped, on a service the
|
|
884
|
+
* document CREATES as much as on one `override` leaves alone; a service imported with it reads back
|
|
885
|
+
* `subdomainAccess: false` for ever. The subdomain is established by
|
|
886
|
+
* `PUT /service-stack/{id}/enable-subdomain-access` and by nothing else, and that call needs a DEPLOYED
|
|
887
|
+
* HTTP port — which is why this runs after `deployProxy` rather than beside the import.
|
|
888
|
+
*
|
|
889
|
+
* It runs on RECONCILE too, so a subdomain someone turned off in the GUI comes back. What it must never
|
|
890
|
+
* do is finish quietly: `zerops-subdomain` is the only public entry a namespace has, so this either
|
|
891
|
+
* observes `subdomainAccess === true` or throws naming what is missing.
|
|
892
|
+
*
|
|
893
|
+
* The decision is the READ-BACK, never the call returning. On an already-published service the platform
|
|
894
|
+
* answers 2xx and then fails the process it handed back — live-verified — so a 2xx proves nothing.
|
|
895
|
+
*/
|
|
896
|
+
const ensureSubdomainAccess = async (
|
|
897
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
898
|
+
options: ZeropsNamespaceOptions,
|
|
899
|
+
signal: AbortSignal,
|
|
900
|
+
): Promise<void> => {
|
|
901
|
+
const proxyServiceId = target.proxyServiceId
|
|
902
|
+
if (proxyServiceId === undefined) {
|
|
903
|
+
throw new Error('Zerops namespace has no proxy service id')
|
|
904
|
+
}
|
|
905
|
+
const published = async (): Promise<boolean> => (await options.api.getService({ serviceId: proxyServiceId, signal })).subdomainAccess === true
|
|
906
|
+
if (await published()) {
|
|
907
|
+
return
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
await options.api.enableSubdomainAccess({ serviceId: proxyServiceId, signal })
|
|
911
|
+
} catch (error) {
|
|
912
|
+
if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
`Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
throw error
|
|
918
|
+
}
|
|
919
|
+
for (let attempt = 0;; attempt += 1) {
|
|
920
|
+
if (signal.aborted) {
|
|
921
|
+
throw new Error('Zerops namespace provisioning was cancelled')
|
|
922
|
+
}
|
|
923
|
+
if (await published()) {
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
if (attempt >= SUBDOMAIN_READBACK_ATTEMPTS) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
`Zerops namespace proxy ${proxyServiceId} accepted enable-subdomain-access but still reads subdomainAccess: false — the namespace has no public entry point`,
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const mutateNamespace = async (
|
|
936
|
+
input: ProviderNamespaceMutationInput,
|
|
937
|
+
options: ZeropsNamespaceOptions,
|
|
938
|
+
mode: 'provision' | 'reconcile',
|
|
939
|
+
): Promise<ProviderDeploymentNamespace> => {
|
|
940
|
+
const suppliedTarget = decodeEnvelope(input.namespace.target)
|
|
941
|
+
let namespace = normalizedNamespace(input.namespace, options)
|
|
942
|
+
let target = normalizeTarget(namespace, options)
|
|
943
|
+
const initiallyAdopted = target.projectId !== undefined && target.managed === false && !target.ready
|
|
944
|
+
const topology = compileZeropsNamespaceTopology(namespace, target)
|
|
945
|
+
|
|
946
|
+
if (target.projectId === undefined) {
|
|
947
|
+
const recovered = await managedProjectByMarker(namespace, target, options, input.signal)
|
|
948
|
+
const result = recovered === null
|
|
949
|
+
? await options.api.importProject({ clientId: options.clientId, yaml: topology.createYaml, signal: input.signal })
|
|
950
|
+
: { projectId: recovered.id, projectName: recovered.name, services: [] }
|
|
951
|
+
if (result.projectId === '') {
|
|
952
|
+
throw new Error('Zerops namespace project import returned an empty project id')
|
|
953
|
+
}
|
|
954
|
+
target = { ...target, projectId: result.projectId, managed: true }
|
|
955
|
+
namespace = await checkpoint(input, namespace, target)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const projectId = target.projectId
|
|
959
|
+
if (projectId === undefined) {
|
|
960
|
+
throw new Error('Zerops namespace project id was not checkpointed')
|
|
961
|
+
}
|
|
962
|
+
const project = await options.api.getProject({ projectId, signal: input.signal })
|
|
963
|
+
if (target.projectName !== project.name && initiallyAdopted && suppliedTarget.projectName === undefined) {
|
|
964
|
+
target = { ...target, projectName: project.name }
|
|
965
|
+
namespace = await checkpoint(input, namespace, target)
|
|
966
|
+
}
|
|
967
|
+
validateProject(namespace, target, project, initiallyAdopted)
|
|
968
|
+
|
|
969
|
+
if (initiallyAdopted) {
|
|
970
|
+
// Validate before the first mutation; the import below then enforces service-level envIsolation.
|
|
971
|
+
await resolveServices(namespace, target, options, input.signal, true)
|
|
972
|
+
}
|
|
973
|
+
await options.api.importServices({
|
|
974
|
+
projectId,
|
|
975
|
+
yaml: mode === 'provision' && !target.ready ? topology.servicesProvisionYaml : topology.servicesSteadyYaml,
|
|
976
|
+
signal: input.signal,
|
|
977
|
+
})
|
|
978
|
+
const resolved = await resolveServices(namespace, target, options, input.signal, false)
|
|
979
|
+
target = {
|
|
980
|
+
...target,
|
|
981
|
+
proxyServiceId: resolved.proxy.id,
|
|
982
|
+
...(resolved.postgres !== undefined ? { postgresServiceId: resolved.postgres.id } : {}),
|
|
983
|
+
}
|
|
984
|
+
namespace = await checkpoint(input, namespace, target)
|
|
985
|
+
|
|
986
|
+
const deployRequired = await ensureProxyConfiguration(target, options, input.signal)
|
|
987
|
+
target = { ...target, proxyConfigured: true }
|
|
988
|
+
namespace = await checkpoint(input, namespace, target)
|
|
989
|
+
|
|
990
|
+
if (deployRequired || !target.ready) {
|
|
991
|
+
const deployed = await deployProxy(input, namespace, target, options)
|
|
992
|
+
namespace = deployed.namespace
|
|
993
|
+
target = deployed.target
|
|
994
|
+
}
|
|
995
|
+
// After the deploy, because the platform refuses a service with no HTTP port — and never before `ready`
|
|
996
|
+
// is checkpointed, because a namespace with no public entry point is not ready.
|
|
997
|
+
if (target.publicAccess === 'zerops-subdomain') {
|
|
998
|
+
await ensureSubdomainAccess(target, options, input.signal)
|
|
999
|
+
}
|
|
1000
|
+
target = {
|
|
1001
|
+
...target,
|
|
1002
|
+
proxyBaselineSequence: undefined,
|
|
1003
|
+
proxyAppVersionId: undefined,
|
|
1004
|
+
ready: true,
|
|
1005
|
+
}
|
|
1006
|
+
return checkpoint(input, namespace, target)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** Build the provider-owned namespace lifecycle capability. */
|
|
1010
|
+
export const createZeropsNamespaceCapabilities = (options: ZeropsNamespaceOptions): ProviderNamespaceCapabilities => {
|
|
1011
|
+
const required = (name: string, value: string): void => {
|
|
1012
|
+
if (value.trim() === '') {
|
|
1013
|
+
throw new Error(`Zerops namespace ${name} must be a non-empty string`)
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
required('clientId', options.clientId)
|
|
1017
|
+
required('proxyBuildFromGit', options.proxyBuildFromGit)
|
|
1018
|
+
required('iamUrl', options.iamUrl)
|
|
1019
|
+
required('iamKey', options.iamKey)
|
|
1020
|
+
return {
|
|
1021
|
+
normalize: (namespace) => normalizedNamespace(namespace, options),
|
|
1022
|
+
namespaceResourceClaims,
|
|
1023
|
+
registrationResourceClaims,
|
|
1024
|
+
provision: (input) => mutateNamespace(input, options, 'provision'),
|
|
1025
|
+
reconcile: (input) => mutateNamespace(input, options, 'reconcile'),
|
|
1026
|
+
operator: createZeropsNamespaceOperator({ proxyBuildFromGit: options.proxyBuildFromGit }),
|
|
1027
|
+
}
|
|
1028
|
+
}
|