@fabrika/provider-zerops 0.0.24 → 0.0.26
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 +5 -5
- package/src/api.ts +79 -22
- package/src/namespace.ts +104 -0
- package/src/provider.ts +26 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.26",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"gen:schema": "bun scripts/generate-zerops-types.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@fabrika/auth": "^0.0.
|
|
36
|
-
"@fabrika/auth-core": "^0.0.
|
|
37
|
-
"@fabrika/provider-contract": "^0.0.
|
|
38
|
-
"@fabrika/proxy-contract": "^0.0.
|
|
35
|
+
"@fabrika/auth": "^0.0.26",
|
|
36
|
+
"@fabrika/auth-core": "^0.0.26",
|
|
37
|
+
"@fabrika/provider-contract": "^0.0.26",
|
|
38
|
+
"@fabrika/proxy-contract": "^0.0.26"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/api.ts
CHANGED
|
@@ -258,7 +258,7 @@ export interface ZeropsServiceEnv {
|
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
260
|
* A short-lived grant for the project's LOG service. VERIFIED: `ResponseProjectLog` — the field names and
|
|
261
|
-
* types are exactly these. What
|
|
261
|
+
* types are exactly these. What the URLs speak was measured separately; see `readBuildLog`.
|
|
262
262
|
*/
|
|
263
263
|
export interface ZeropsLogAccess {
|
|
264
264
|
url: string
|
|
@@ -271,10 +271,14 @@ export interface ZeropsLogAccess {
|
|
|
271
271
|
expiration: string
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
|
|
275
|
+
const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
|
276
|
+
|
|
274
277
|
/** One relayed build/runtime log line. Shape is fabrika's, not the platform's. */
|
|
275
278
|
export interface ZeropsLogLine {
|
|
276
|
-
/**
|
|
279
|
+
/** RFC3339 stamp Zerops put at the head of the line, when the line carried one. */
|
|
277
280
|
timestamp?: string
|
|
281
|
+
/** The line as the log service wrote it, timestamp and tag included. */
|
|
278
282
|
message: string
|
|
279
283
|
}
|
|
280
284
|
|
|
@@ -457,8 +461,9 @@ export interface ZeropsApi {
|
|
|
457
461
|
* Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
|
|
458
462
|
* VERIFIED: `GET /service-stack/{id}/env` (`{ items }`).
|
|
459
463
|
*
|
|
460
|
-
* NOT `GET /service-stack/{id}/user-data
|
|
461
|
-
*
|
|
464
|
+
* NOT `GET /service-stack/{id}/user-data`: it answers a list now (2026-08-21), but `/env` is the
|
|
465
|
+
* endpoint whose record ids `PUT`/`DELETE /user-data/{id}` take, and the list's paging terms were
|
|
466
|
+
* never verified — see `docs/reference/zerops-platform.md`. The `/env` reading omits
|
|
462
467
|
* variables the service's own `zerops.yaml` declares (`type: ENV`); fabrika never writes those.
|
|
463
468
|
* The OpenAPI publishes no lookup/pagination terms and live probes found query terms ignored, so the
|
|
464
469
|
* implementation reads one full response under a byte bound rather than imposing an entry-count cap.
|
|
@@ -548,17 +553,42 @@ export interface ZeropsApi {
|
|
|
548
553
|
getLogAccess(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsLogAccess>
|
|
549
554
|
|
|
550
555
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
+
* One window of the project's log, narrowed to ONE app version.
|
|
557
|
+
*
|
|
558
|
+
* VERIFIED 2026-08-21 by measurement, not by document: the OpenAPI file stops at the grant, so the
|
|
559
|
+
* separate log service was read against a live project. `urlPlain` answers one
|
|
560
|
+
* `<RFC3339 timestamp> <tag> <message>` line per record and accepts `serviceStackId`, `limit`, `tags`
|
|
561
|
+
* and `desc`; `from` and unknown terms are accepted and silently ignored, so nothing may depend on them.
|
|
562
|
+
*
|
|
563
|
+
* ORDERING, also measured: `limit=N` without `desc` returns the NEWEST N records in ASCENDING timestamp
|
|
564
|
+
* order (`limit=3` against a log starting 15:50 answered 17:00:00, 17:00:00, 17:15:00), and `desc=1`
|
|
565
|
+
* returns the same newest N, newest first. So `desc` is needed neither for the `since` cut nor for a
|
|
566
|
+
* build longer than the limit: each poll re-reads the newest window and the caller's dedupe streams it.
|
|
556
567
|
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
568
|
+
* A version's BUILD lines carry the tag `zbuilder@<appVersionId>` and are produced on a separate
|
|
569
|
+
* builder stack, so they are selected by `tags` ALONE — combining that tag with the runtime
|
|
570
|
+
* `serviceStackId` matches nothing. RUNTIME lines carry no version marker at all and the runtime
|
|
571
|
+
* service's window mixes every version that ever ran, so they are selected by `serviceStackId` and cut
|
|
572
|
+
* at `since` (the version's `build.pipelineStart`). Without `since` no runtime line is read: a line
|
|
573
|
+
* from an earlier version is worse in a run log than a missing one. The cut is by TIME, not identity —
|
|
574
|
+
* the outgoing container keeps logging until it is replaced, so its lines stamped after `since` still
|
|
575
|
+
* pass, and only the earlier version's older history is excluded.
|
|
576
|
+
*
|
|
577
|
+
* The two windows are read INDEPENDENTLY: one failing must not discard what the other returned, and only
|
|
578
|
+
* a failure of every window attempted is raised. The driver then treats that as non-fatal anyway — log
|
|
579
|
+
* relay degrades to "no lines" and the deploy succeeds or fails on `/app-version` status alone. A log
|
|
580
|
+
* service must never be able to fail a deploy.
|
|
560
581
|
*/
|
|
561
|
-
readBuildLog(input: {
|
|
582
|
+
readBuildLog(input: {
|
|
583
|
+
access: ZeropsLogAccess
|
|
584
|
+
serviceId: string
|
|
585
|
+
/** Select this version's build lines by their `zbuilder@` tag; without it no build line is read. */
|
|
586
|
+
appVersionId?: string
|
|
587
|
+
/** RFC3339 lower bound for runtime lines, normally `build.pipelineStart`; without it none are read. */
|
|
588
|
+
since?: string
|
|
589
|
+
limit?: number
|
|
590
|
+
signal: AbortSignal
|
|
591
|
+
}): Promise<ZeropsLogLine[]>
|
|
562
592
|
}
|
|
563
593
|
|
|
564
594
|
// ── default (real) implementation ───────────────────────────────────────────────
|
|
@@ -1245,20 +1275,47 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1245
1275
|
}
|
|
1246
1276
|
},
|
|
1247
1277
|
|
|
1248
|
-
readBuildLog: async ({ access, serviceId, limit, signal }) => {
|
|
1249
|
-
// UNVERIFIED (see the interface): this is a best reading of an undocumented log service, not a
|
|
1250
|
-
// contract. It is written so a wrong guess degrades to "no lines" instead of failing a deploy.
|
|
1278
|
+
readBuildLog: async ({ access, serviceId, appVersionId, since, limit, signal }) => {
|
|
1251
1279
|
if (access.urlPlain === '') {
|
|
1252
1280
|
return []
|
|
1253
1281
|
}
|
|
1254
1282
|
const separator = access.urlPlain.includes('?') ? '&' : '?'
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1283
|
+
const readWindow = async (terms: string): Promise<{ lines: ZeropsLogLine[]; error: unknown }> => {
|
|
1284
|
+
try {
|
|
1285
|
+
const url = `${access.urlPlain}${separator}${terms}&limit=${limit ?? 200}`
|
|
1286
|
+
const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
|
|
1287
|
+
if (!response.ok) {
|
|
1288
|
+
throw new Error(`zerops: read build log failed (${response.status})`)
|
|
1289
|
+
}
|
|
1290
|
+
const text = await response.text()
|
|
1291
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
|
|
1292
|
+
const head = line.slice(0, Math.max(line.indexOf(' '), 0))
|
|
1293
|
+
return LOG_LINE_TIMESTAMP.test(head) ? { timestamp: head, message: line } : { message: line }
|
|
1294
|
+
})
|
|
1295
|
+
return { lines, error: undefined }
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
return { lines: [], error }
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// Selected by tag alone: the build runs on a builder stack, so pairing the tag with the runtime
|
|
1301
|
+
// service id matches nothing (see the interface).
|
|
1302
|
+
const build = appVersionId === undefined ? undefined : await readWindow(`tags=${encodeURIComponent(`zbuilder@${appVersionId}`)}`)
|
|
1303
|
+
const runtime = since === undefined ? undefined : await readWindow(`serviceStackId=${encodeURIComponent(serviceId)}`)
|
|
1304
|
+
const attempted = [build, runtime].filter((result) => result !== undefined)
|
|
1305
|
+
if (attempted.length === 0) {
|
|
1306
|
+
return []
|
|
1307
|
+
}
|
|
1308
|
+
// Independent windows: only a failure of every one attempted is worth raising.
|
|
1309
|
+
const failure = attempted.find((result) => result.error !== undefined)
|
|
1310
|
+
if (failure !== undefined && attempted.every((result) => result.error !== undefined)) {
|
|
1311
|
+
throw failure.error
|
|
1259
1312
|
}
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1313
|
+
const boundary = since === undefined ? Number.NaN : Date.parse(since)
|
|
1314
|
+
// The runtime window carries every version that ever ran, so a line the driver cannot date is dropped.
|
|
1315
|
+
const runtimeLines = runtime === undefined || Number.isNaN(boundary)
|
|
1316
|
+
? runtime?.lines ?? []
|
|
1317
|
+
: runtime.lines.filter((line) => line.timestamp !== undefined && Date.parse(line.timestamp) >= boundary)
|
|
1318
|
+
return [...build?.lines ?? [], ...runtimeLines]
|
|
1262
1319
|
},
|
|
1263
1320
|
}
|
|
1264
1321
|
}
|
package/src/namespace.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ProviderNamespaceCapabilities,
|
|
7
7
|
ProviderNamespaceError,
|
|
8
8
|
type ProviderNamespaceFact,
|
|
9
|
+
type ProviderNamespaceHost,
|
|
9
10
|
type ProviderNamespaceMutationInput,
|
|
10
11
|
type ProviderNamespaceOperator,
|
|
11
12
|
type ProviderNamespacePlanInput,
|
|
@@ -35,6 +36,8 @@ export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
|
|
|
35
36
|
export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
|
|
36
37
|
export const ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE = 'FABRIKA_IAM_ISSUER'
|
|
37
38
|
export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
|
|
39
|
+
/** The platform-owned variable naming one URL per published HTTP port. READ_ONLY, written by Zerops. */
|
|
40
|
+
export const ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
|
|
38
41
|
/**
|
|
39
42
|
* How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
|
|
40
43
|
* defaulted. Every part settled against a live account, none of it safe to infer:
|
|
@@ -91,6 +94,8 @@ export interface ZeropsNamespaceTarget {
|
|
|
91
94
|
readonly proxyConfigured?: boolean
|
|
92
95
|
readonly proxyBaselineSequence?: number
|
|
93
96
|
readonly proxyAppVersionId?: string
|
|
97
|
+
/** The hosts the published proxy answers on, recorded once so a namespace read needs no platform call. */
|
|
98
|
+
readonly proxyHosts?: readonly ProviderNamespaceHost[]
|
|
94
99
|
readonly ready?: boolean
|
|
95
100
|
}
|
|
96
101
|
|
|
@@ -221,6 +226,54 @@ const parsePostgres = (payload: JsonValue): ZeropsNamespacePostgres | undefined
|
|
|
221
226
|
return profile === undefined ? { type } : { type, profile }
|
|
222
227
|
}
|
|
223
228
|
|
|
229
|
+
const parseProxyHosts = (payload: JsonValue): readonly ProviderNamespaceHost[] | undefined => {
|
|
230
|
+
const value = property(payload, 'proxyHosts')
|
|
231
|
+
if (value === undefined) return undefined
|
|
232
|
+
if (!Array.isArray(value)) {
|
|
233
|
+
throw new Error('Zerops namespace target proxyHosts must be an array')
|
|
234
|
+
}
|
|
235
|
+
return value.map((entry) => {
|
|
236
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
237
|
+
throw new Error('Zerops namespace target proxyHosts entry must be an object')
|
|
238
|
+
}
|
|
239
|
+
const host = property(entry, 'host')
|
|
240
|
+
const port = property(entry, 'port')
|
|
241
|
+
if (typeof host !== 'string' || host === '') {
|
|
242
|
+
throw new Error('Zerops namespace target proxyHosts host must be a non-empty string')
|
|
243
|
+
}
|
|
244
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) {
|
|
245
|
+
throw new Error('Zerops namespace target proxyHosts port must be a positive integer')
|
|
246
|
+
}
|
|
247
|
+
return { host, port }
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One host per published HTTP port, out of the newline-separated URLs the platform writes.
|
|
253
|
+
*
|
|
254
|
+
* The form is `<hostname>-<hash>-<port>.<region>.zerops.app` (e.g. `proxy-2ec8-8080`). Read as URLs
|
|
255
|
+
* rather than split on `-`, so a hostname whose own first label ends in digits cannot be misread; a line
|
|
256
|
+
* that is not a URL, or whose first label carries no trailing port — the single portless line a proxy
|
|
257
|
+
* that has never deployed publishes — is skipped rather than guessed at.
|
|
258
|
+
*/
|
|
259
|
+
const parseZeropsProxyHosts = (value: string): ProviderNamespaceHost[] => {
|
|
260
|
+
const hosts: ProviderNamespaceHost[] = []
|
|
261
|
+
for (const line of value.split('\n')) {
|
|
262
|
+
const trimmed = line.trim()
|
|
263
|
+
if (trimmed === '') continue
|
|
264
|
+
let hostname: string
|
|
265
|
+
try {
|
|
266
|
+
hostname = new URL(trimmed).hostname
|
|
267
|
+
} catch {
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const port = /-(\d+)$/.exec(hostname.split('.')[0] ?? '')?.[1]
|
|
271
|
+
if (port === undefined) continue
|
|
272
|
+
hosts.push({ host: hostname, port: Number.parseInt(port, 10) })
|
|
273
|
+
}
|
|
274
|
+
return hosts.sort((left, right) => left.port - right.port)
|
|
275
|
+
}
|
|
276
|
+
|
|
224
277
|
export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
|
|
225
278
|
version: 1,
|
|
226
279
|
encode: (target) => ({
|
|
@@ -238,6 +291,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
|
|
|
238
291
|
...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
|
|
239
292
|
...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
|
|
240
293
|
...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
|
|
294
|
+
...(target.proxyHosts === undefined ? {} : { proxyHosts: target.proxyHosts.map((entry) => ({ host: entry.host, port: entry.port })) }),
|
|
241
295
|
...(target.ready !== undefined ? { ready: target.ready } : {}),
|
|
242
296
|
}),
|
|
243
297
|
decode: (payload) => {
|
|
@@ -272,6 +326,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
|
|
|
272
326
|
...(optionalString(payload, 'proxyAppVersionId') !== undefined
|
|
273
327
|
? { proxyAppVersionId: optionalString(payload, 'proxyAppVersionId') }
|
|
274
328
|
: {}),
|
|
329
|
+
...(parseProxyHosts(payload) !== undefined ? { proxyHosts: parseProxyHosts(payload) } : {}),
|
|
275
330
|
...(optionalBoolean(payload, 'ready') !== undefined ? { ready: optionalBoolean(payload, 'ready') } : {}),
|
|
276
331
|
}
|
|
277
332
|
},
|
|
@@ -320,6 +375,14 @@ const registrationResourceClaims = (registration: ProviderRegistration): string[
|
|
|
320
375
|
appId: registration.app.id,
|
|
321
376
|
env: registration.environment.env,
|
|
322
377
|
})
|
|
378
|
+
// A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
|
|
379
|
+
// refuse it here, where nothing has been created yet, instead of at the first deploy's manifest compile.
|
|
380
|
+
if (manifest.target.proxy !== undefined && (registration.environment.domain ?? '').trim() === '') {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` requires a public domain: `
|
|
383
|
+
+ `pass --domain with a host this namespace serves (\`namespaces get ${namespace.id}\` lists them)`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
323
386
|
const namespaceClaims = new Set(namespaceResourceClaims(namespace))
|
|
324
387
|
for (const requirement of manifest.target.namespaceResources ?? []) {
|
|
325
388
|
if (!namespaceClaims.has(requirement.resourceKey)) {
|
|
@@ -503,10 +566,14 @@ const namespacePresentation = (
|
|
|
503
566
|
const postgres = target.postgres === undefined
|
|
504
567
|
? 'App-owned database services'
|
|
505
568
|
: `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
|
|
569
|
+
// Only a published subdomain namespace knows its own hosts; custom domains are bound out of band, so
|
|
570
|
+
// the provider names none and an operator reads them where they were bound.
|
|
571
|
+
const hosts = publicAccess === 'zerops-subdomain' && target.ready === true ? target.proxyHosts ?? [] : []
|
|
506
572
|
const instructions = [
|
|
507
573
|
...(publicAccess === 'custom-domain'
|
|
508
574
|
? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
|
|
509
575
|
: ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
|
|
576
|
+
...(hosts.length === 0 ? [] : ['Register a proxy-target environment with one of the hosts below as its domain; each host serves one app.']),
|
|
510
577
|
...(target.postgres === undefined
|
|
511
578
|
? []
|
|
512
579
|
: ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
|
|
@@ -528,6 +595,7 @@ const namespacePresentation = (
|
|
|
528
595
|
{ label: 'Public access', value: publicAccess },
|
|
529
596
|
],
|
|
530
597
|
instructions,
|
|
598
|
+
...(hosts.length === 0 ? {} : { hosts }),
|
|
531
599
|
}
|
|
532
600
|
}
|
|
533
601
|
|
|
@@ -832,6 +900,38 @@ const ensureProxyConfiguration = async (
|
|
|
832
900
|
return deployRequired
|
|
833
901
|
}
|
|
834
902
|
|
|
903
|
+
/**
|
|
904
|
+
* The hosts the proxy publishes, off the platform-owned `zeropsSubdomain` variable.
|
|
905
|
+
*
|
|
906
|
+
* Read through the same `/env` listing every other namespace variable uses, and read AFTER the proxy is
|
|
907
|
+
* published: the variable names a host per DEPLOYED HTTP port, so a proxy that never deployed names none.
|
|
908
|
+
* Recording the answer in the target is what keeps `namespaces get` a database read.
|
|
909
|
+
*/
|
|
910
|
+
const readProxyHosts = async (
|
|
911
|
+
namespace: ProviderDeploymentNamespace,
|
|
912
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
913
|
+
options: ZeropsNamespaceOptions,
|
|
914
|
+
signal: AbortSignal,
|
|
915
|
+
): Promise<readonly ProviderNamespaceHost[]> => {
|
|
916
|
+
const proxyServiceId = target.proxyServiceId
|
|
917
|
+
if (proxyServiceId === undefined) {
|
|
918
|
+
throw new Error('Zerops namespace has no proxy service id')
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
|
|
922
|
+
const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
|
|
923
|
+
return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains.content)
|
|
924
|
+
} catch {
|
|
925
|
+
// A host listing is a convenience; the proxy is already built, deployed and published. Failing the
|
|
926
|
+
// namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
|
|
927
|
+
// upstream text this provider has not redacted.
|
|
928
|
+
console.warn(
|
|
929
|
+
`Zerops namespace ${namespace.id}: reading the published proxy hosts failed; the placement is ready and lists none until its next reconcile`,
|
|
930
|
+
)
|
|
931
|
+
return []
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
835
935
|
const latestAfterBaseline = async (
|
|
836
936
|
target: NormalizedZeropsNamespaceTarget,
|
|
837
937
|
options: ZeropsNamespaceOptions,
|
|
@@ -1125,10 +1225,14 @@ const runNamespaceMutation = async (
|
|
|
1125
1225
|
if (target.publicAccess === 'zerops-subdomain') {
|
|
1126
1226
|
await ensureSubdomainAccess(target, options, input.signal)
|
|
1127
1227
|
}
|
|
1228
|
+
const proxyHosts = target.publicAccess === 'zerops-subdomain' ? await readProxyHosts(namespace, target, options, input.signal) : []
|
|
1128
1229
|
target = {
|
|
1129
1230
|
...target,
|
|
1130
1231
|
proxyBaselineSequence: undefined,
|
|
1131
1232
|
proxyAppVersionId: undefined,
|
|
1233
|
+
// Cleared when the proxy publishes none and on a custom-domain namespace, so the record never
|
|
1234
|
+
// outlives what it describes.
|
|
1235
|
+
proxyHosts: proxyHosts.length === 0 ? undefined : proxyHosts,
|
|
1132
1236
|
ready: true,
|
|
1133
1237
|
}
|
|
1134
1238
|
return checkpoint(input, namespace, target)
|
package/src/provider.ts
CHANGED
|
@@ -170,23 +170,41 @@ const cleanupPreTriggerVersion = async (env: StepEnv, source: ZeropsRuntimeSourc
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
|
-
* Relay
|
|
173
|
+
* Relay ONE app version's build log into the run's progress sink.
|
|
174
174
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* the platform
|
|
175
|
+
* Scoped to the version being deployed: the runtime service's log window mixes every version that ever ran
|
|
176
|
+
* on it, so an unscoped read stamps a run's log with lines from earlier releases. `appVersionId` selects
|
|
177
|
+
* the build lines and `since` — the version's pipeline start — cuts the runtime ones
|
|
178
|
+
* (see `ZeropsApi.readBuildLog`). Before the platform reports a pipeline start there is nothing to cut
|
|
179
|
+
* against, so no runtime line is relayed yet. The cut is by time, so the OUTGOING container's own lines
|
|
180
|
+
* still arrive until it is replaced; what it removes is every earlier version's history.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately FAILURE-TOLERANT: a log service must never be able to fail a deploy, so an error degrades
|
|
183
|
+
* to "no lines relayed" and the run still succeeds or fails on the platform's process and app-version
|
|
184
|
+
* statuses.
|
|
179
185
|
*
|
|
180
186
|
* Relay is pull-based (ADR-0003), so each poll re-reads a window that overlaps the last one; `seen` keeps
|
|
181
187
|
* the run log from repeating itself. Two genuinely identical lines with no timestamp collapse into one —
|
|
182
188
|
* an acceptable trade for not printing the same window every three seconds.
|
|
183
189
|
*/
|
|
184
|
-
const relayLog = async (
|
|
190
|
+
const relayLog = async (
|
|
191
|
+
env: StepEnv,
|
|
192
|
+
access: ZeropsLogAccess | null,
|
|
193
|
+
seen: Set<string>,
|
|
194
|
+
appVersionId: string,
|
|
195
|
+
since: string | undefined,
|
|
196
|
+
): Promise<void> => {
|
|
185
197
|
if (access === null) {
|
|
186
198
|
return
|
|
187
199
|
}
|
|
188
200
|
try {
|
|
189
|
-
const lines = await env.zerops.api.readBuildLog({
|
|
201
|
+
const lines = await env.zerops.api.readBuildLog({
|
|
202
|
+
access,
|
|
203
|
+
serviceId: env.run.target.serviceId,
|
|
204
|
+
appVersionId,
|
|
205
|
+
...(since === undefined ? {} : { since }),
|
|
206
|
+
signal: env.signal,
|
|
207
|
+
})
|
|
190
208
|
for (const line of lines) {
|
|
191
209
|
const key = `${line.timestamp ?? ''}\u0000${line.message}`
|
|
192
210
|
if (seen.has(key)) {
|
|
@@ -248,7 +266,7 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
|
|
|
248
266
|
log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
|
|
249
267
|
previous = version.status
|
|
250
268
|
}
|
|
251
|
-
await relayLog(env, access, seen)
|
|
269
|
+
await relayLog(env, access, seen, appVersionId, version.build?.pipelineStart)
|
|
252
270
|
|
|
253
271
|
if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
|
|
254
272
|
throw new Error(
|