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