@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.
- 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 +0 -5
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +75 -20
- package/src/control.ts +217 -447
- package/src/descriptor.ts +135 -0
- package/src/index.ts +24 -128
- package/src/installation/admin-options.ts +128 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +155 -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 +366 -0
- package/src/installation/init.ts +521 -0
- package/src/installation/install-collaborators.ts +28 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +193 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +286 -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 +122 -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 +108 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +105 -0
- package/src/manifest.ts +208 -315
- 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 -1260
- package/src/plan.ts +21 -23
- package/src/provider.ts +399 -254
- package/src/service-references.ts +28 -0
- 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,668 @@
|
|
|
1
|
+
// `fabrika platform deploy --provider=zerops` — the WHOLE ordered sequence, in code.
|
|
2
|
+
//
|
|
3
|
+
// [ADR-0027](../../../docs/decisions/0027-platform-deploy-is-as-wide-as-the-provider-needs.md) decided
|
|
4
|
+
// that on Zerops this command owns the order rather than the operator's pipeline, and gave two reasons
|
|
5
|
+
// that both live here:
|
|
6
|
+
//
|
|
7
|
+
// • The proxy manifest is CROSS-SERVICE state that must move with the code it describes. No single
|
|
8
|
+
// pipeline step owns a document spanning every service the pipeline deploys.
|
|
9
|
+
// • **The order carries a security property.** Since ADR-0022 the application enforces nothing, so
|
|
10
|
+
// deploying control at a new version while the previous, more permissive manifest is still in
|
|
11
|
+
// front of it leaves `/api/*` open for the length of the deploy. The sequence is therefore
|
|
12
|
+
// (IAM + Operations) → **proxy** → control: the two that order nothing against each
|
|
13
|
+
// other built at once, with the enforcement point ahead of the service whose gates just widened.
|
|
14
|
+
// `__tests__/deploy.test.ts` pins it.
|
|
15
|
+
//
|
|
16
|
+
// Bringing `fabrika-test` to HEAD on 2026-08-05 took `zops push` from a laptop, once per service, in a
|
|
17
|
+
// hand-chosen order, and everything that made that run correct stayed in a run log. This file is that
|
|
18
|
+
// run log, executable.
|
|
19
|
+
//
|
|
20
|
+
// ── What this command writes, and what it deliberately does not ───────────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// It writes the composed proxy manifest, the exact namespace planning source default, and the
|
|
23
|
+
// per-installation configuration derived from the resolved public hosts (each service's own origin,
|
|
24
|
+
// the origins it must know about, and the installation's environment name). **It writes no credential at all** — every
|
|
25
|
+
// secret an installation holds is `platform init`'s to place, and a deploy that cannot write one cannot
|
|
26
|
+
// leak one. The two credentials this command HOLDS are the Zerops access token and the IAM admin key,
|
|
27
|
+
// both from the environment, neither ever logged.
|
|
28
|
+
//
|
|
29
|
+
// ── Idempotence ───────────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// Every variable is READ before it is written and skipped when it already matches, so a second run
|
|
32
|
+
// writes nothing; the subdomain is decided by reading `subdomainAccess` back, so a second run enables
|
|
33
|
+
// nothing; and the schema reconcile is an upsert. What a re-run does do is redeploy — that is what
|
|
34
|
+
// "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
|
|
35
|
+
// readiness gate.
|
|
36
|
+
|
|
37
|
+
import { readEnvironmentName } from '@fabrika/auth-core'
|
|
38
|
+
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
39
|
+
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
|
|
40
|
+
import {
|
|
41
|
+
prepareZeropsDescriptor,
|
|
42
|
+
type Sleeper,
|
|
43
|
+
ZEROPS_ACTIVE,
|
|
44
|
+
ZEROPS_SERVICE_NOT_HTTP,
|
|
45
|
+
ZEROPS_TERMINAL,
|
|
46
|
+
type ZeropsApi,
|
|
47
|
+
ZeropsApiError,
|
|
48
|
+
type ZeropsNamespaceProxySource,
|
|
49
|
+
type ZeropsService,
|
|
50
|
+
} from '../index'
|
|
51
|
+
import type { PlatformDeployInput } from './deploy-options'
|
|
52
|
+
import { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
|
|
53
|
+
import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
|
|
54
|
+
import { derivePlatformHosts, platformOrigin, type PlatformPlacement, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
|
|
55
|
+
import { PRIVATE_IAM_UPSTREAM } from './install-environment'
|
|
56
|
+
import {
|
|
57
|
+
FABRIKA_ZEROPS_PROXY_SOURCE_JSON,
|
|
58
|
+
type PlatformSourcePreparer,
|
|
59
|
+
prepareNamespaceProxySource,
|
|
60
|
+
serializeNamespaceProxySource,
|
|
61
|
+
validateInstallationSetups,
|
|
62
|
+
} from './installation-source'
|
|
63
|
+
import type { DeployLog } from './log'
|
|
64
|
+
import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
|
|
65
|
+
import type { PreparedPlatformSource } from './platform-source'
|
|
66
|
+
import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
|
|
67
|
+
import { platformProxyAppFor, resolvePlatformProxyManifest } from './proxy-manifest'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The four services this command touches, in the order it RESOLVES and WRITES them.
|
|
71
|
+
*
|
|
72
|
+
* It is no longer the order they are deployed in — that is `PLATFORM_CONCURRENT_DEPLOY` followed by
|
|
73
|
+
* `PLATFORM_SEQUENTIAL_DEPLOY`, and the two stages concatenate back to this list (pinned by
|
|
74
|
+
* `deploy-order.test.ts` in `@fabrika/platform-artifacts`). What this list still decides is which services must exist
|
|
75
|
+
* and which environment is written, both of which happen for ALL FOUR before any of them is deployed.
|
|
76
|
+
*
|
|
77
|
+
* Each name is a service hostname in the topology AND the `zerops.yaml` setup name the pipeline
|
|
78
|
+
* selects — the platform's own "setup name defaults to the hostname" rule.
|
|
79
|
+
* `deploy-order.test.ts` in `@fabrika/platform-artifacts` pins both against the declarations that own them.
|
|
80
|
+
*/
|
|
81
|
+
export const PLATFORM_DEPLOY_ORDER = ['iam', 'operations', 'proxy', 'control'] as const
|
|
82
|
+
|
|
83
|
+
export type PlatformDeployService = (typeof PLATFORM_DEPLOY_ORDER)[number]
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The two services built AT ONCE, because nothing orders them against each other.
|
|
87
|
+
*
|
|
88
|
+
* Neither calls a sibling at boot: IAM holds no sibling origin at all, and Operations only
|
|
89
|
+
* CONSTRUCTS its IAM client (the first request is the first call). Each readiness gate hits the
|
|
90
|
+
* service's own `/healthz`, and no deploy-written variable of one is read by another's BUILD — the
|
|
91
|
+
* only build-time read on the whole path is the proxy reading its own manifest. So the order among them was arbitrary, and paying for it sequentially cost about 3 min 45 s
|
|
92
|
+
* of the ~9 min 35 s a warm five-service roll took (two measured runs, 2026-08-21).
|
|
93
|
+
*
|
|
94
|
+
* What is NOT arbitrary stays: every environment write happens before any of these starts, the proxy
|
|
95
|
+
* is deployed after both, and control after the proxy (ADR-0022 — the enforcement point ahead of
|
|
96
|
+
* the service whose gates just widened). `deploy-order.test.ts` in `@fabrika/platform-artifacts` and
|
|
97
|
+
* `__tests__/deploy.test.ts` fail if that sequence changes.
|
|
98
|
+
*/
|
|
99
|
+
export const PLATFORM_CONCURRENT_DEPLOY = ['iam', 'operations'] as const
|
|
100
|
+
|
|
101
|
+
/** What is deployed after the concurrent stage, one at a time and in this order. */
|
|
102
|
+
export const PLATFORM_SEQUENTIAL_DEPLOY = ['proxy', 'control'] as const
|
|
103
|
+
|
|
104
|
+
/** The proxy's hostname. The only publicly routed service, and the one that carries the manifest. */
|
|
105
|
+
export const PLATFORM_PROXY_SERVICE = 'proxy'
|
|
106
|
+
|
|
107
|
+
/** How often a triggered pipeline is polled. */
|
|
108
|
+
const POLL_INTERVAL_MS = 5_000
|
|
109
|
+
/** Zerops caps a build pipeline at one hour; poll past that so the timeout is ours, not a coincidence. */
|
|
110
|
+
const POLL_ATTEMPTS = (70 * 60_000) / POLL_INTERVAL_MS
|
|
111
|
+
/** One live run read `subdomainAccess: false` right after a successful enable and `true` 3 s later. */
|
|
112
|
+
const SUBDOMAIN_READBACK_ATTEMPTS = 10
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The platform's code for "a userData synchronisation is already running for this service".
|
|
116
|
+
*
|
|
117
|
+
* Observed live (`docs/reference/zerops-platform.md`): a deploy racing an environment write dies on it
|
|
118
|
+
* and leaves the app version stuck `UPLOADING`, which nothing recovers from. This command writes
|
|
119
|
+
* variables and then deploys, so it is exactly the caller that race was recorded against — retrying
|
|
120
|
+
* the TRIGGER is the whole mitigation, and it is bounded so a genuine misconfiguration still fails.
|
|
121
|
+
*/
|
|
122
|
+
const USER_DATA_SYNC_RUNNING = 'userDataSyncRunning'
|
|
123
|
+
const TRIGGER_RETRY_ATTEMPTS = 6
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What deploying ONE service needs. Narrower than the whole command's collaborators so the bring-up
|
|
127
|
+
* (`install.ts`), which deploys the proxy alone before any manifest exists, can reuse `deployPlatformService`
|
|
128
|
+
* without inventing an IAM reconciler it has no use for.
|
|
129
|
+
*/
|
|
130
|
+
export interface PlatformServiceDeployCollaborators {
|
|
131
|
+
readonly uploadFetch: PlatformUploadFetch
|
|
132
|
+
readonly api: ZeropsApi
|
|
133
|
+
readonly sleep: Sleeper
|
|
134
|
+
readonly log: DeployLog
|
|
135
|
+
readonly signal: AbortSignal
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Everything `deployPlatform` reaches the outside world through. */
|
|
139
|
+
export interface PlatformDeployCollaborators extends PlatformServiceDeployCollaborators {
|
|
140
|
+
readonly prepareSource: PlatformSourcePreparer
|
|
141
|
+
readonly reconcileSchema: SchemaReconciler
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The prepared snapshot shared by every build in an installation run. */
|
|
145
|
+
export interface PlatformServiceDeployInput {
|
|
146
|
+
readonly source: PreparedPlatformSource
|
|
147
|
+
readonly dryRun: boolean
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One resolved platform service. */
|
|
151
|
+
export interface ResolvedService {
|
|
152
|
+
readonly hostname: PlatformDeployService
|
|
153
|
+
readonly id: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const assertRunning = (signal: AbortSignal): void => {
|
|
157
|
+
if (signal.aborted) {
|
|
158
|
+
throw new Error('the Zerops platform deploy was cancelled')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const resolveProject = async (
|
|
163
|
+
input: PlatformDeployInput,
|
|
164
|
+
{ api, signal }: PlatformDeployCollaborators,
|
|
165
|
+
): Promise<{ id: string; name: string }> => {
|
|
166
|
+
if (input.project.kind === 'id') {
|
|
167
|
+
const project = await api.getProject({ projectId: input.project.projectId, signal })
|
|
168
|
+
if (project.id === '') {
|
|
169
|
+
throw new Error(`Zerops project ${input.project.projectId} could not be read`)
|
|
170
|
+
}
|
|
171
|
+
return { id: project.id, name: project.name }
|
|
172
|
+
}
|
|
173
|
+
const matches = await api.findProjects({ clientId: input.project.clientId, name: input.project.name, signal })
|
|
174
|
+
if (matches.length === 0) {
|
|
175
|
+
throw new Error(`no Zerops project named \`${input.project.name}\` under this client`)
|
|
176
|
+
}
|
|
177
|
+
if (matches.length > 1) {
|
|
178
|
+
// Zerops allows duplicate project names, so picking one would make the deploy's target depend on
|
|
179
|
+
// the API's list order. Name the id instead.
|
|
180
|
+
throw new Error(`${matches.length} Zerops projects are named \`${input.project.name}\` — select one with --project-id`)
|
|
181
|
+
}
|
|
182
|
+
const project = matches[0]
|
|
183
|
+
if (project === undefined || project.id === '') {
|
|
184
|
+
throw new Error(`the Zerops project named \`${input.project.name}\` has no id`)
|
|
185
|
+
}
|
|
186
|
+
return { id: project.id, name: project.name }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The private source service of an earlier layout; Control now streams sources itself (ADR-0029). */
|
|
190
|
+
const LEFTOVER_SOURCE_SERVICE = 'source'
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Find every platform service by hostname in one listing.
|
|
194
|
+
*
|
|
195
|
+
* One call rather than four `findService` lookups, so a missing service is reported as the whole set
|
|
196
|
+
* that is missing — an installation that was never imported fails once, naming all of it, rather than
|
|
197
|
+
* four runs later. Build runtimes (`build<hostname>v<n>`) share the project and are ignored by the
|
|
198
|
+
* exact-name match.
|
|
199
|
+
*/
|
|
200
|
+
const resolveServices = async (
|
|
201
|
+
projectId: string,
|
|
202
|
+
{ api, log, signal }: PlatformDeployCollaborators,
|
|
203
|
+
): Promise<Map<PlatformDeployService, ResolvedService>> => {
|
|
204
|
+
const services = await api.listProjectServices({ projectId, signal })
|
|
205
|
+
if (services.some((service: ZeropsService) => service.name === LEFTOVER_SOURCE_SERVICE)) {
|
|
206
|
+
log.warn('a `source` service from an earlier layout is present and unused; it may still hold GitHub App keys; delete it or reinstall')
|
|
207
|
+
}
|
|
208
|
+
const resolved = new Map<PlatformDeployService, ResolvedService>()
|
|
209
|
+
const missing: string[] = []
|
|
210
|
+
for (const hostname of PLATFORM_DEPLOY_ORDER) {
|
|
211
|
+
const matches = services.filter((service: ZeropsService) => service.name === hostname)
|
|
212
|
+
if (matches.length > 1) {
|
|
213
|
+
throw new Error(`Zerops project ${projectId} has ${matches.length} services named \`${hostname}\``)
|
|
214
|
+
}
|
|
215
|
+
const match = matches[0]
|
|
216
|
+
if (match === undefined || match.id === '') {
|
|
217
|
+
missing.push(hostname)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
resolved.set(hostname, { hostname, id: match.id })
|
|
221
|
+
}
|
|
222
|
+
if (missing.length > 0) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Zerops project ${projectId} has no ${missing.join(', ')} service — \`platform deploy\` updates an installation, `
|
|
225
|
+
+ 'it does not create one',
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
return resolved
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const serviceOf = (services: ReadonlyMap<PlatformDeployService, ResolvedService>, hostname: PlatformDeployService): ResolvedService => {
|
|
232
|
+
const service = services.get(hostname)
|
|
233
|
+
if (service === undefined) {
|
|
234
|
+
throw new Error(`the \`${hostname}\` service was not resolved`)
|
|
235
|
+
}
|
|
236
|
+
return service
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Read one service's environment as a key → value map. Values are never logged. */
|
|
240
|
+
export const readServiceEnv = async (
|
|
241
|
+
serviceId: string,
|
|
242
|
+
{ api, signal }: Pick<PlatformServiceDeployCollaborators, 'api' | 'signal'>,
|
|
243
|
+
): Promise<Map<string, string>> => {
|
|
244
|
+
const variables = await api.listServiceEnv({ serviceId, signal })
|
|
245
|
+
return new Map(variables.map((variable) => [variable.key, variable.content]))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Decide where this installation answers.
|
|
250
|
+
*
|
|
251
|
+
* Naming no host derives them from the proxy's Zerops subdomains AND makes this a `zerops-subdomain`
|
|
252
|
+
* installation, whose public entry point the deploy ensures. Naming all three makes it a
|
|
253
|
+
* `custom-domain` installation, where the domains are bound to the project balancer out of band and
|
|
254
|
+
* publishing a subdomain would add a public address nobody asked for.
|
|
255
|
+
*/
|
|
256
|
+
const resolvePlacement = (input: PlatformDeployInput, proxyEnv: ReadonlyMap<string, string>): PlatformPlacement => {
|
|
257
|
+
if (input.hosts !== undefined) {
|
|
258
|
+
return { hosts: input.hosts, scheme: input.scheme, zeropsSubdomain: false }
|
|
259
|
+
}
|
|
260
|
+
const subdomains = proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
|
|
261
|
+
if (subdomains === undefined || subdomains.trim() === '') {
|
|
262
|
+
throw new Error(
|
|
263
|
+
'the proxy service publishes no Zerops subdomain — deploy it once so it has HTTP ports, or name every host '
|
|
264
|
+
+ 'explicitly with --iam-host / --console-host / --operations-host',
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
const listeners = PLATFORM_PROXY_MANIFEST_TEMPLATE.apps.map((app) => ({ service: app.service, port: app.port }))
|
|
268
|
+
return { hosts: derivePlatformHosts(subdomains, listeners), scheme: input.scheme, zeropsSubdomain: true }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The per-installation configuration each service needs, derived from the resolved hosts.
|
|
273
|
+
*
|
|
274
|
+
* The namespace source default comes from the verified snapshot. Values are compared but never logged.
|
|
275
|
+
*/
|
|
276
|
+
const desiredServiceEnv = (
|
|
277
|
+
environment: string,
|
|
278
|
+
placement: PlatformPlacement,
|
|
279
|
+
manifestJson: string,
|
|
280
|
+
proxySource: ZeropsNamespaceProxySource,
|
|
281
|
+
): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
|
|
282
|
+
const iamOrigin = platformOrigin(placement.scheme, placement.hosts.iam)
|
|
283
|
+
const operationsOrigin = platformOrigin(placement.scheme, placement.hosts.operations)
|
|
284
|
+
return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
|
|
285
|
+
[
|
|
286
|
+
'iam',
|
|
287
|
+
new Map([
|
|
288
|
+
['ENVIRONMENT', environment],
|
|
289
|
+
// The `iss` of every minted token AND the OIDC redirect base, so it must be the address
|
|
290
|
+
// routed to the proxy in front of this service.
|
|
291
|
+
['ISSUER', iamOrigin],
|
|
292
|
+
]),
|
|
293
|
+
],
|
|
294
|
+
[
|
|
295
|
+
'operations',
|
|
296
|
+
new Map([
|
|
297
|
+
['ENVIRONMENT', environment],
|
|
298
|
+
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
299
|
+
['FABRIKA_OPERATIONS_PUBLIC_HOST', placement.hosts.operations],
|
|
300
|
+
]),
|
|
301
|
+
],
|
|
302
|
+
[
|
|
303
|
+
'proxy',
|
|
304
|
+
new Map([
|
|
305
|
+
['ENVIRONMENT', environment],
|
|
306
|
+
// Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
|
|
307
|
+
// private `iam:3000` the manifest dials — `readProxyEnv` canonicalizes it to an origin.
|
|
308
|
+
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
309
|
+
[FABRIKA_PROXY_MANIFEST_JSON, manifestJson],
|
|
310
|
+
['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
|
|
311
|
+
]),
|
|
312
|
+
],
|
|
313
|
+
[
|
|
314
|
+
'control',
|
|
315
|
+
new Map([
|
|
316
|
+
[FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(proxySource)],
|
|
317
|
+
['ENVIRONMENT', environment],
|
|
318
|
+
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
319
|
+
// A bare host: `controlPublicOrigin` accepts either spelling, and this is also what the
|
|
320
|
+
// same-origin check on both first-party gateways is decided against.
|
|
321
|
+
['FABRIKA_CONTROL_DOMAIN', placement.hosts.control],
|
|
322
|
+
['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
|
|
323
|
+
// The public IAM origin an APPLICATION project's proxy reaches — there is no private
|
|
324
|
+
// network between projects (ADR-0006).
|
|
325
|
+
['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
|
|
326
|
+
]),
|
|
327
|
+
],
|
|
328
|
+
])
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Write only what differs. Returns the KEYS written — never a value, not even in a debug line. */
|
|
332
|
+
const applyServiceEnv = async (
|
|
333
|
+
service: ResolvedService,
|
|
334
|
+
desired: ReadonlyMap<string, string>,
|
|
335
|
+
live: ReadonlyMap<string, string>,
|
|
336
|
+
input: PlatformDeployInput,
|
|
337
|
+
collaborators: PlatformDeployCollaborators,
|
|
338
|
+
): Promise<string[]> => {
|
|
339
|
+
const written: string[] = []
|
|
340
|
+
for (const [key, value] of desired) {
|
|
341
|
+
if (live.get(key) === value) {
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
written.push(key)
|
|
345
|
+
if (input.dryRun) {
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
assertRunning(collaborators.signal)
|
|
349
|
+
await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, signal: collaborators.signal })
|
|
350
|
+
}
|
|
351
|
+
return written
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Only a known rejection permits another request against the same reservation. */
|
|
355
|
+
const triggerPipeline = async (
|
|
356
|
+
service: ResolvedService,
|
|
357
|
+
appVersionId: string,
|
|
358
|
+
input: PlatformServiceDeployInput,
|
|
359
|
+
{ api, sleep, log, signal }: PlatformServiceDeployCollaborators,
|
|
360
|
+
): Promise<void> => {
|
|
361
|
+
const descriptor = prepareZeropsDescriptor(input.source.descriptor.contents, service.hostname)
|
|
362
|
+
let mayHaveStarted = false
|
|
363
|
+
try {
|
|
364
|
+
for (let attempt = 0;; attempt += 1) {
|
|
365
|
+
assertRunning(signal)
|
|
366
|
+
try {
|
|
367
|
+
await api.buildAndDeployAppVersion({
|
|
368
|
+
appVersionId,
|
|
369
|
+
zeropsYaml: descriptor.contents,
|
|
370
|
+
zeropsYamlSetup: descriptor.setup,
|
|
371
|
+
signal,
|
|
372
|
+
})
|
|
373
|
+
mayHaveStarted = true
|
|
374
|
+
return
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_SYNC_RUNNING) {
|
|
377
|
+
mayHaveStarted = true
|
|
378
|
+
log.warn(`${service.hostname}: build response unresolved; observing app-version ${appVersionId}`)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
if (attempt >= TRIGGER_RETRY_ATTEMPTS) {
|
|
382
|
+
throw new Error(`${service.hostname}: variable synchronisation still running; app-version ${appVersionId} was not triggered`)
|
|
383
|
+
}
|
|
384
|
+
log.info(`${service.hostname}: the platform is still synchronising this service's variables — retrying the trigger`)
|
|
385
|
+
await sleep(POLL_INTERVAL_MS, signal)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} finally {
|
|
389
|
+
if (!mayHaveStarted) await cleanupReservation(service, appVersionId, { api, log })
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const cleanupReservation = async (
|
|
394
|
+
service: ResolvedService,
|
|
395
|
+
appVersionId: string,
|
|
396
|
+
{ api, log }: Pick<PlatformServiceDeployCollaborators, 'api' | 'log'>,
|
|
397
|
+
): Promise<void> => {
|
|
398
|
+
try {
|
|
399
|
+
await api.deleteAppVersion({ appVersionId, signal: AbortSignal.timeout(5_000) })
|
|
400
|
+
} catch {
|
|
401
|
+
log.warn(`${service.hostname}: cleanup unresolved for app-version ${appVersionId}`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Deploy ONE service and wait for it to become the active version.
|
|
407
|
+
*
|
|
408
|
+
* A failed deploy throws, which stops the sequence: the platform keeps the previous version serving
|
|
409
|
+
* (`deploy.readinessCheck` is a real gate, live-verified), so a failure here leaves the installation
|
|
410
|
+
* consistent — old code behind the manifest that was written for it.
|
|
411
|
+
*
|
|
412
|
+
* Exported for the BRING-UP, which builds the proxy on its own before any manifest exists so that the
|
|
413
|
+
* service publishes the HTTP ports its public hostnames are named after (`install.ts`, pass 1). That is
|
|
414
|
+
* one service, not an order — the order below stays this file's.
|
|
415
|
+
*/
|
|
416
|
+
export const deployPlatformService = async (
|
|
417
|
+
service: ResolvedService,
|
|
418
|
+
input: PlatformServiceDeployInput,
|
|
419
|
+
collaborators: PlatformServiceDeployCollaborators,
|
|
420
|
+
): Promise<void> => {
|
|
421
|
+
const { api, sleep, log, signal } = collaborators
|
|
422
|
+
if (input.dryRun) {
|
|
423
|
+
log.info(`${service.hostname}: would trigger its pipeline and wait for the new version to become ACTIVE`)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
prepareZeropsDescriptor(input.source.descriptor.contents, service.hostname)
|
|
427
|
+
const reserved = await api.createAppVersion({ serviceId: service.id, name: input.source.commitSha, signal })
|
|
428
|
+
const appVersionId = reserved.id
|
|
429
|
+
log.info(`${service.hostname}: app-version ${appVersionId}, commit ${input.source.commitSha}, setup ${service.hostname}`)
|
|
430
|
+
try {
|
|
431
|
+
await uploadPlatformArchive(reserved.uploadUrl, input.source.archivePath, signal, collaborators.uploadFetch)
|
|
432
|
+
assertRunning(signal)
|
|
433
|
+
} catch {
|
|
434
|
+
await cleanupReservation(service, appVersionId, collaborators)
|
|
435
|
+
throw new Error(`${service.hostname}: archive upload failed before build for app-version ${appVersionId}`)
|
|
436
|
+
}
|
|
437
|
+
await triggerPipeline(service, appVersionId, input, collaborators)
|
|
438
|
+
for (let attempt = 0;; attempt += 1) {
|
|
439
|
+
assertRunning(signal)
|
|
440
|
+
const version = await api.getAppVersion({ appVersionId, signal })
|
|
441
|
+
// An unknown status means this build does not recognise what Zerops sent — keep waiting, never
|
|
442
|
+
// read it as success or failure.
|
|
443
|
+
if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
|
|
444
|
+
if (version.status !== ZEROPS_ACTIVE) {
|
|
445
|
+
throw new Error(`zerops: the \`${service.hostname}\` deploy ${appVersionId} finished as ${version.status}`)
|
|
446
|
+
}
|
|
447
|
+
log.info(`${service.hostname}: ACTIVE`)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (attempt >= POLL_ATTEMPTS) {
|
|
451
|
+
throw new Error(`zerops: the \`${service.hostname}\` deploy ${appVersionId} is unresolved after timeout; no retry or deletion was attempted`)
|
|
452
|
+
}
|
|
453
|
+
await sleep(POLL_INTERVAL_MS, signal)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Build `PLATFORM_CONCURRENT_DEPLOY` at once, and let every one of them finish before failing.
|
|
459
|
+
*
|
|
460
|
+
* `allSettled` rather than `all`: a rejection from `all` would leave the other build running with
|
|
461
|
+
* nobody polling it, so the command would exit while the platform was still writing app versions —
|
|
462
|
+
* and the second rejection would be unhandled. Here every build is followed to a terminal status.
|
|
463
|
+
*
|
|
464
|
+
* EVERY failure is logged and the first is thrown, where "first" is this list's order and not the order
|
|
465
|
+
* they failed in — builds that started together have no meaningful chronology, and a command that
|
|
466
|
+
* reported only one of two broken services would send the operator back for a second run to find the
|
|
467
|
+
* other. `deployPlatformService` names the service in each message.
|
|
468
|
+
*/
|
|
469
|
+
const deployConcurrently = async (
|
|
470
|
+
services: ReadonlyMap<PlatformDeployService, ResolvedService>,
|
|
471
|
+
input: PlatformServiceDeployInput,
|
|
472
|
+
collaborators: PlatformDeployCollaborators,
|
|
473
|
+
): Promise<void> => {
|
|
474
|
+
const outcomes = await Promise.allSettled(
|
|
475
|
+
PLATFORM_CONCURRENT_DEPLOY.map((hostname) => deployPlatformService(serviceOf(services, hostname), input, collaborators)),
|
|
476
|
+
)
|
|
477
|
+
const failures = outcomes.flatMap((outcome) =>
|
|
478
|
+
outcome.status === 'rejected' ? [outcome.reason instanceof Error ? outcome.reason : new Error(String(outcome.reason))] : []
|
|
479
|
+
)
|
|
480
|
+
for (const failure of failures) {
|
|
481
|
+
collaborators.log.warn(failure.message)
|
|
482
|
+
}
|
|
483
|
+
const first = failures[0]
|
|
484
|
+
if (first !== undefined) {
|
|
485
|
+
throw first
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Publish the proxy on its `*.zerops.app` subdomain — the one thing an import document cannot do.
|
|
491
|
+
*
|
|
492
|
+
* The decision is the READ-BACK and never the call returning: on an already-published service the
|
|
493
|
+
* platform answers 2xx and then fails the process it hands back, and a freshly-enabled one can still
|
|
494
|
+
* read `false` on the next read. Both live-verified. Runs on every deploy, so a subdomain someone
|
|
495
|
+
* turned off in the GUI comes back, and throws rather than leaving the installation unreachable.
|
|
496
|
+
*/
|
|
497
|
+
const ensureSubdomainAccess = async (
|
|
498
|
+
proxy: ResolvedService,
|
|
499
|
+
input: PlatformDeployInput,
|
|
500
|
+
{ api, sleep, log, signal }: PlatformDeployCollaborators,
|
|
501
|
+
): Promise<void> => {
|
|
502
|
+
const published = async (): Promise<boolean> => (await api.getService({ serviceId: proxy.id, signal })).subdomainAccess === true
|
|
503
|
+
if (await published()) {
|
|
504
|
+
log.info('the proxy is already published on its Zerops subdomain')
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
if (input.dryRun) {
|
|
508
|
+
log.info('the proxy is NOT published on its Zerops subdomain; would enable it')
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
await api.enableSubdomainAccess({ serviceId: proxy.id, signal })
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`the proxy cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
throw error
|
|
520
|
+
}
|
|
521
|
+
for (let attempt = 0;; attempt += 1) {
|
|
522
|
+
assertRunning(signal)
|
|
523
|
+
if (await published()) {
|
|
524
|
+
log.info('the proxy is published on its Zerops subdomain')
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
if (attempt >= SUBDOMAIN_READBACK_ATTEMPTS) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
'the proxy accepted enable-subdomain-access but still reads subdomainAccess: false — the installation has no public entry point',
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
await sleep(POLL_INTERVAL_MS, signal)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The installation's environment name, refused when a public origin contradicts it.
|
|
538
|
+
*
|
|
539
|
+
* The same rule the services enforce at boot (`readEnvironmentName`, `@fabrika/auth-core`), applied
|
|
540
|
+
* HERE so a deploy that would make IAM or control refuse to start fails before it writes anything.
|
|
541
|
+
* Both origins are checked because both services state one: IAM's `ISSUER` and control's
|
|
542
|
+
* `FABRIKA_CONTROL_DOMAIN` are what each of them enforces against.
|
|
543
|
+
*/
|
|
544
|
+
const checkedEnvironmentName = (environment: string, placement: PlatformPlacement): string => {
|
|
545
|
+
readEnvironmentName(environment, platformOrigin(placement.scheme, placement.hosts.iam))
|
|
546
|
+
return readEnvironmentName(environment, platformOrigin(placement.scheme, placement.hosts.control))
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Bring one Zerops platform installation to this checkout, unattended.
|
|
551
|
+
*
|
|
552
|
+
* Every step is idempotent and the whole sequence fails closed: nothing is deployed until the manifest
|
|
553
|
+
* and the environment name have been applied, and nothing after a failed step runs.
|
|
554
|
+
*/
|
|
555
|
+
export const deployPlatform = async (
|
|
556
|
+
input: PlatformDeployInput,
|
|
557
|
+
collaborators: PlatformDeployCollaborators,
|
|
558
|
+
): Promise<void> => {
|
|
559
|
+
const source = await collaborators.prepareSource(input.sourceDir, collaborators.signal)
|
|
560
|
+
try {
|
|
561
|
+
validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
|
|
562
|
+
await deployPreparedPlatform(input, collaborators, source)
|
|
563
|
+
} finally {
|
|
564
|
+
await source.dispose()
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export const deployPreparedPlatform = async (
|
|
569
|
+
input: PlatformDeployInput,
|
|
570
|
+
collaborators: PlatformDeployCollaborators,
|
|
571
|
+
source: PreparedPlatformSource,
|
|
572
|
+
): Promise<void> => {
|
|
573
|
+
const { log, signal } = collaborators
|
|
574
|
+
assertRunning(signal)
|
|
575
|
+
validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
|
|
576
|
+
const proxySource = await prepareNamespaceProxySource(source)
|
|
577
|
+
log.info(`source commit ${source.commitSha}`)
|
|
578
|
+
|
|
579
|
+
log.step('Resolve the installation')
|
|
580
|
+
const project = await resolveProject(input, collaborators)
|
|
581
|
+
const services = await resolveServices(project.id, collaborators)
|
|
582
|
+
log.info(`project ${project.name} (${project.id}) — ${PLATFORM_DEPLOY_ORDER.join(', ')}`)
|
|
583
|
+
|
|
584
|
+
const proxy = serviceOf(services, PLATFORM_PROXY_SERVICE)
|
|
585
|
+
const proxyEnv = await readServiceEnv(proxy.id, collaborators)
|
|
586
|
+
const placement = resolvePlacement(input, proxyEnv)
|
|
587
|
+
const environment = checkedEnvironmentName(input.environment, placement)
|
|
588
|
+
log.info(`environment \`${environment}\`, ${placement.scheme}://${placement.hosts.control} (console)`)
|
|
589
|
+
log.info(`iam ${placement.hosts.iam} · operations ${placement.hosts.operations}`)
|
|
590
|
+
|
|
591
|
+
log.step('Compose the proxy manifest')
|
|
592
|
+
const platformManifest = resolvePlatformProxyManifest(PLATFORM_PROXY_MANIFEST_TEMPLATE, {
|
|
593
|
+
scheme: placement.scheme,
|
|
594
|
+
placement: {
|
|
595
|
+
iam: { hosts: [placement.hosts.iam] },
|
|
596
|
+
control: { hosts: [placement.hosts.control] },
|
|
597
|
+
operations: { hosts: [placement.hosts.operations] },
|
|
598
|
+
},
|
|
599
|
+
})
|
|
600
|
+
const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
|
|
601
|
+
log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
|
|
602
|
+
if (merged.carried.length > 0) {
|
|
603
|
+
log.info(`carried through from the live manifest: ${merged.carried.join(', ')}`)
|
|
604
|
+
}
|
|
605
|
+
for (const displaced of merged.superseded) {
|
|
606
|
+
log.warn(
|
|
607
|
+
`the live manifest entry \`${displaced.id}\` stood on the platform host ${displaced.host} and was replaced — `
|
|
608
|
+
+ 'that host now answers for the platform app that owns it',
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
log.step('Write the service environment')
|
|
613
|
+
const desired = desiredServiceEnv(environment, placement, encodeProxyManifestJson(merged.manifest), proxySource)
|
|
614
|
+
for (const hostname of PLATFORM_DEPLOY_ORDER) {
|
|
615
|
+
const service = serviceOf(services, hostname)
|
|
616
|
+
const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
|
|
617
|
+
const keys = desired.get(hostname) ?? new Map<string, string>()
|
|
618
|
+
const written = await applyServiceEnv(service, keys, live, input, collaborators)
|
|
619
|
+
log.info(
|
|
620
|
+
written.length === 0
|
|
621
|
+
? `${hostname}: ${keys.size} variable(s) already correct`
|
|
622
|
+
: `${hostname}: ${input.dryRun ? 'would write' : 'wrote'} ${written.join(', ')}`,
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// EVERY deploy is after EVERY write. That is what makes the sequence fail closed: a manifest this
|
|
627
|
+
// command could not apply leaves the previous manifest in front of the previous code, never in front
|
|
628
|
+
// of new code — and `ENVIRONMENT` is in place before any service that refuses `local` restarts.
|
|
629
|
+
log.step(`Deploy ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')}`)
|
|
630
|
+
const buildInput = { source, dryRun: input.dryRun }
|
|
631
|
+
await deployConcurrently(services, buildInput, collaborators)
|
|
632
|
+
for (const hostname of PLATFORM_SEQUENTIAL_DEPLOY) {
|
|
633
|
+
await deployPlatformService(serviceOf(services, hostname), buildInput, collaborators)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// BEFORE the console registration, not after: that step talks to IAM over its PUBLIC host, and on a
|
|
637
|
+
// first bring-up the proxy has no public address until this runs — every call 502s. On an already
|
|
638
|
+
// published installation this is a no-op, which is why the old order survived until `platform install`
|
|
639
|
+
// ran it on an empty project. Publishing first is not a widening: the proxy enforces the manifest it
|
|
640
|
+
// was just built with (ADR-0022), and registration only teaches IAM where a session may return to.
|
|
641
|
+
log.step('Ensure the public entry point')
|
|
642
|
+
if (placement.zeropsSubdomain) {
|
|
643
|
+
await ensureSubdomainAccess(proxy, input, collaborators)
|
|
644
|
+
} else {
|
|
645
|
+
log.info('custom-domain installation: the operator binds each domain to the project balancer; no subdomain is published')
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
log.step('Register the console with IAM')
|
|
649
|
+
const consoleApp = platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control')
|
|
650
|
+
const consoleOrigin = platformOrigin(placement.scheme, placement.hosts.control)
|
|
651
|
+
if (input.dryRun) {
|
|
652
|
+
log.info(`would reconcile \`${consoleApp.id}\` and register its return origin ${consoleOrigin}`)
|
|
653
|
+
} else {
|
|
654
|
+
// The schema PUT is what REGISTERS the app, and `apps.setReturnOrigins` 404s for an app IAM has
|
|
655
|
+
// never heard of — so both travel in this one call, in that order (ADR-0022/ADR-0023).
|
|
656
|
+
await collaborators.reconcileSchema({
|
|
657
|
+
url: platformOrigin(placement.scheme, placement.hosts.iam),
|
|
658
|
+
app: consoleApp.id,
|
|
659
|
+
schema: PLATFORM_CONSOLE_APP_SCHEMA,
|
|
660
|
+
returnOrigins: [consoleOrigin],
|
|
661
|
+
adminKey: input.iamAdminKey,
|
|
662
|
+
signal,
|
|
663
|
+
})
|
|
664
|
+
log.info(`\`${consoleApp.id}\` reconciled; return origin ${consoleOrigin}`)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
log.step(input.dryRun ? 'Dry run complete — nothing was written' : 'Installation deployed')
|
|
668
|
+
}
|