@fabrika/provider-zerops 0.4.2 → 0.4.4
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 +8 -7
- package/src/api/client.ts +11 -14
- package/src/api/polling.ts +42 -0
- package/src/api/readers.ts +9 -2
- package/src/api/service-env-limit.ts +29 -0
- package/src/api/service-env-response.ts +34 -2
- package/src/api/types.ts +39 -9
- package/src/api.ts +10 -1
- package/src/control.ts +23 -8
- package/src/execution.ts +44 -5
- package/src/index.ts +5 -0
- package/src/installation/admin.ts +4 -2
- package/src/installation/checker-release.ts +44 -0
- package/src/installation/deploy.ts +95 -37
- package/src/installation/generated/platform-console-schema.ts +18 -2
- package/src/installation/generated/platform-proxy-manifest.ts +20 -0
- package/src/installation/guide.md +169 -52
- package/src/installation/index.ts +22 -15
- package/src/installation/init-options.ts +2 -2
- package/src/installation/init.ts +8 -2
- package/src/installation/install-collaborators.ts +4 -0
- package/src/installation/install-environment.ts +52 -30
- package/src/installation/install-options.ts +20 -19
- package/src/installation/install-project.ts +23 -15
- package/src/installation/install-provisioning.ts +12 -12
- package/src/installation/install-secrets.ts +89 -0
- package/src/installation/install-token.ts +37 -0
- package/src/installation/install.ts +34 -21
- package/src/installation/machine-key.ts +3 -2
- package/src/installation/platform-archive.ts +87 -0
- package/src/installation/platform-upload.ts +2 -2
- package/src/installation/secrets.ts +3 -4
- package/src/installation/sidecar.ts +38 -2
- package/src/installation/templates/README.md +1 -1
- package/src/installation/templates/platform.yml +23 -2
- package/src/installation/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- package/src/installation/upgrade.ts +24 -7
- package/src/managed-env.ts +11 -0
- package/src/manifest.ts +25 -3
- package/src/namespace/codec.ts +6 -1
- package/src/namespace/errors.ts +3 -1
- package/src/namespace/provisioning.ts +18 -5
- package/src/namespace/proxy-attempt.ts +56 -14
- package/src/namespace/update.ts +50 -8
- package/src/provider.ts +57 -13
- package/src/proxy-manifest-carrier.ts +32 -0
- package/src/source/port.ts +9 -1
- package/src/installation/manifest.ts +0 -95
package/src/namespace/update.ts
CHANGED
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
} from '@fabrika/provider-contract'
|
|
10
10
|
import { waitForProcess, ZeropsApiError, type ZeropsProcess } from '../api'
|
|
11
11
|
import { defaultSleep } from '../collaborators'
|
|
12
|
-
import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, zeropsNamespaceTargetCodec } from './codec'
|
|
12
|
+
import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, ZEROPS_PROXY_SOURCE_CURRENT, zeropsNamespaceTargetCodec } from './codec'
|
|
13
13
|
import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED } from './errors'
|
|
14
|
+
import { verifyZeropsNamespaceProxySource } from './proxy-source'
|
|
14
15
|
import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespacePostgres, ZeropsNamespaceTarget } from './types'
|
|
15
16
|
import { validateService } from './validation'
|
|
16
17
|
|
|
@@ -21,11 +22,16 @@ const READBACK_INTERVAL_MS = 3_000
|
|
|
21
22
|
/** UNVERIFIED: how soon `autoscalingProfileId` reads the new profile after the PUT and its process finish. */
|
|
22
23
|
const READBACK_ATTEMPTS = 10
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
/** The plan field of a re-pin, which moves only the stored target; the reconcile core queues rebuilds the proxy. */
|
|
26
|
+
export const ZEROPS_PROXY_SOURCE_FIELD = 'proxySource'
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
type FixedField = Exclude<keyof ZeropsNamespaceTarget, 'postgres' | 'proxySource'>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every target field but the two an update may move: the PostgreSQL profile, and the proxy source pin, which only an
|
|
32
|
+
* explicit re-pin to the installation's default moves (ADR-0045). A new field fails to compile until listed.
|
|
33
|
+
*/
|
|
27
34
|
const FIXED_FIELDS: { readonly [K in FixedField]: (target: ZeropsNamespaceTarget) => ZeropsNamespaceTarget[K] } = {
|
|
28
|
-
proxySource: (target) => target.proxySource,
|
|
29
35
|
proxyBuild: (target) => target.proxyBuild,
|
|
30
36
|
proxyDeployment: (target) => target.proxyDeployment,
|
|
31
37
|
projectName: (target) => target.projectName,
|
|
@@ -69,10 +75,17 @@ const mergedTarget = (
|
|
|
69
75
|
if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
|
|
70
76
|
throw new Error('Zerops namespace target did not encode to an object')
|
|
71
77
|
}
|
|
72
|
-
|
|
78
|
+
if (checked.data.proxySource !== ZEROPS_PROXY_SOURCE_CURRENT) {
|
|
79
|
+
return normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...payload } } }, options)
|
|
80
|
+
}
|
|
81
|
+
const fields = Object.fromEntries(Object.entries(payload).filter(([key]) => key !== 'proxySource'))
|
|
82
|
+
return {
|
|
83
|
+
...normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...fields } } }, options),
|
|
84
|
+
proxySource: options.proxySource,
|
|
85
|
+
}
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
/** Refuse every difference except the PostgreSQL profile, naming the fields. */
|
|
88
|
+
/** Refuse every difference except the PostgreSQL profile and a re-pin of the proxy source, naming the fields. */
|
|
76
89
|
const planUpdate = (
|
|
77
90
|
namespace: ProviderDeploymentNamespace,
|
|
78
91
|
requested: ProviderEnvelope,
|
|
@@ -84,8 +97,32 @@ const planUpdate = (
|
|
|
84
97
|
.filter(([, read]) => JSON.stringify(read(current)) !== JSON.stringify(read(merged)))
|
|
85
98
|
.map(([field]) => field)
|
|
86
99
|
if (changed.length > 0) {
|
|
87
|
-
throw immutable(
|
|
100
|
+
throw immutable(
|
|
101
|
+
`Zerops namespace ${namespace.id} cannot change ${changed.join(', ')} in place; only the PostgreSQL profile and the proxy source can`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (JSON.stringify(current.proxySource) === JSON.stringify(merged.proxySource)) return planProfile(namespace, current, merged)
|
|
105
|
+
if (JSON.stringify(merged.proxySource) !== JSON.stringify(options.proxySource)) {
|
|
106
|
+
throw new Error(`Zerops namespace ${namespace.id} can re-pin its proxy source only to the installation's default: proxySource "current"`)
|
|
107
|
+
}
|
|
108
|
+
if (JSON.stringify(current.postgres) !== JSON.stringify(merged.postgres)) {
|
|
109
|
+
throw new Error(`Zerops namespace ${namespace.id}: re-pin the proxy source and change the PostgreSQL profile in separate updates`)
|
|
110
|
+
}
|
|
111
|
+
// Rebuilding under a new pin settles an attempt first, and would record that attempt's version as the new pin's.
|
|
112
|
+
if (current.proxyBuild !== undefined) {
|
|
113
|
+
throw new Error(`Zerops namespace ${namespace.id} has a proxy build in flight; reconcile it before re-pinning the proxy source`)
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
namespace: { ...namespace, target: envelope(merged) },
|
|
117
|
+
change: { field: ZEROPS_PROXY_SOURCE_FIELD, from: current.proxySource.commitSha, to: merged.proxySource.commitSha },
|
|
88
118
|
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const planProfile = (
|
|
122
|
+
namespace: ProviderDeploymentNamespace,
|
|
123
|
+
current: NormalizedZeropsNamespaceTarget,
|
|
124
|
+
merged: NormalizedZeropsNamespaceTarget,
|
|
125
|
+
): ProviderNamespaceUpdatePlan => {
|
|
89
126
|
const was = current.postgres
|
|
90
127
|
const wanted = merged.postgres
|
|
91
128
|
const unchanged: ProviderNamespaceUpdatePlan = { namespace: { ...namespace, target: envelope(current) }, change: null }
|
|
@@ -186,10 +223,15 @@ const applyProfile = async (
|
|
|
186
223
|
|
|
187
224
|
const applyUpdate = async (input: ProviderNamespaceUpdateInput, options: ZeropsNamespaceOptions): Promise<ProviderDeploymentNamespace> => {
|
|
188
225
|
const current = normalizeTarget(input.namespace, options)
|
|
226
|
+
const plan = planUpdate(input.namespace, input.next.target, options)
|
|
227
|
+
// A re-pin touches no platform resource: it stores the pin, and the reconcile core queues builds from it.
|
|
228
|
+
if (plan.change?.field === ZEROPS_PROXY_SOURCE_FIELD) {
|
|
229
|
+
await verifyZeropsNamespaceProxySource(normalizeTarget(plan.namespace, options).proxySource)
|
|
230
|
+
return plan.namespace
|
|
231
|
+
}
|
|
189
232
|
if (!current.ready) {
|
|
190
233
|
throw new Error(`Zerops namespace ${input.namespace.id} is not ready`)
|
|
191
234
|
}
|
|
192
|
-
const plan = planUpdate(input.namespace, input.next.target, options)
|
|
193
235
|
const wanted = normalizeTarget(plan.namespace, options).postgres
|
|
194
236
|
// Checked even with no planned change: after an unconfirmed update, confirming the live service is
|
|
195
237
|
// what lets core return the namespace to ready.
|
package/src/provider.ts
CHANGED
|
@@ -8,19 +8,22 @@
|
|
|
8
8
|
// runs. Recovery never resumes a deploy (ADR-0044): a version reserved but never triggered is deleted, and
|
|
9
9
|
// one whose build may have been requested is settled only by polling it.
|
|
10
10
|
|
|
11
|
-
import { appEnvironmentId } from '@fabrika/auth-core'
|
|
11
|
+
import { appEnvironmentId, FABRIKA_SOURCE_MAP_UPLOAD_KEY } from '@fabrika/auth-core'
|
|
12
|
+
import { type OperationsSourceMapUploadState, readSourceMapUploadReport } from '@fabrika/operations-contract/releases'
|
|
12
13
|
import {
|
|
13
14
|
type DeployStepResult,
|
|
14
15
|
followLeaseLoss,
|
|
15
16
|
isLeaseLoss,
|
|
16
17
|
type JsonValue,
|
|
17
18
|
type ProviderApprovedAccess,
|
|
19
|
+
type ProviderDeployInput,
|
|
18
20
|
ProviderInvocationAbort,
|
|
19
21
|
type ProviderManagedEnvironment,
|
|
20
22
|
type ProviderRunEvents,
|
|
21
23
|
runSteps,
|
|
22
24
|
} from '@fabrika/provider-contract'
|
|
23
25
|
import {
|
|
26
|
+
readableContent,
|
|
24
27
|
waitForProcess,
|
|
25
28
|
ZEROPS_ACTIVE,
|
|
26
29
|
ZEROPS_PROCESS_FINISHED,
|
|
@@ -47,9 +50,11 @@ import {
|
|
|
47
50
|
assertAuthoredManagedDocument,
|
|
48
51
|
assertAuthoredManagedInputs,
|
|
49
52
|
isFrameworkFamilyVariable,
|
|
53
|
+
isSensitiveFamilyVariable,
|
|
50
54
|
type ManagedValues,
|
|
51
55
|
overlayRelease,
|
|
52
56
|
parseManagedEnvironment,
|
|
57
|
+
SENSITIVE_MANAGED_KEYS,
|
|
53
58
|
SERVICE_MANAGED_KEYS,
|
|
54
59
|
} from './managed-env'
|
|
55
60
|
import {
|
|
@@ -81,6 +86,8 @@ export interface ZeropsDeployRun {
|
|
|
81
86
|
readonly artifact: FabrikaManifest
|
|
82
87
|
/** Control's approved access for the environment (ADR-0050); the lock-down and the IAM schema come from it. */
|
|
83
88
|
readonly access?: ProviderApprovedAccess
|
|
89
|
+
/** The run-scoped source-map destination Control minted; the build uploads to it. */
|
|
90
|
+
readonly artifactUpload?: ProviderDeployInput['artifactUpload']
|
|
84
91
|
readonly dryRun: boolean
|
|
85
92
|
readonly signal: AbortSignal
|
|
86
93
|
readonly events: ProviderRunEvents
|
|
@@ -91,6 +98,8 @@ export interface ZeropsDeployResult {
|
|
|
91
98
|
readonly env: string
|
|
92
99
|
readonly status: 'succeeded' | 'failed'
|
|
93
100
|
readonly steps: readonly DeployStepResult[]
|
|
101
|
+
/** Whether the build's source maps reached Operations; absent when the run had no upload to make. */
|
|
102
|
+
readonly artifactState?: OperationsSourceMapUploadState
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/** How often `await-version` asks Zerops for the version's status. */
|
|
@@ -271,6 +280,8 @@ interface Attempt {
|
|
|
271
280
|
uploadUrl?: string
|
|
272
281
|
processId?: string
|
|
273
282
|
triggered: boolean
|
|
283
|
+
/** What the build's source-map upload reported in its log, once a relayed line said so. */
|
|
284
|
+
sourceMaps?: OperationsSourceMapUploadState
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
/** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
|
|
@@ -353,6 +364,8 @@ const relayLog = async (
|
|
|
353
364
|
}
|
|
354
365
|
seen.add(key)
|
|
355
366
|
env.log(` │ ${line.message}`)
|
|
367
|
+
const reported = readSourceMapUploadReport(line.message)
|
|
368
|
+
if (reported !== null) env.attempt.sourceMaps = reported
|
|
356
369
|
}
|
|
357
370
|
} catch (error) {
|
|
358
371
|
env.log(` [warn] build-log relay unavailable: ${error instanceof Error ? error.message : 'unknown error'}`)
|
|
@@ -452,8 +465,8 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
|
|
|
452
465
|
const created = env.attempt.created ?? new Map<string, string>()
|
|
453
466
|
const buckets = artifact.target.importDocument.services.filter((service) => isObjectStorageType(service.type)).map((service) => service.hostname)
|
|
454
467
|
const records = buckets.length === 0 ? [] : await zerops.api.listServiceEnv({ serviceId: target.serviceId, signal })
|
|
455
|
-
const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
|
|
456
|
-
const known = new Set([...parseStorageProvenance(marker
|
|
468
|
+
const marker = readableContent(records.find((record) => record.key === STORAGE_PROVENANCE_KEY))
|
|
469
|
+
const known = new Set([...parseStorageProvenance(marker), ...(target.createdBuckets ?? [])])
|
|
457
470
|
const findings: ExposureFinding[] = []
|
|
458
471
|
const bucketIds: string[] = []
|
|
459
472
|
for (const hostname of manifestServiceHostnames(artifact)) {
|
|
@@ -502,8 +515,13 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
|
502
515
|
const { zerops, log, signal, managedValues, familyVariables } = env
|
|
503
516
|
const serviceId = env.run.target.serviceId
|
|
504
517
|
if (env.dryRun) {
|
|
505
|
-
const
|
|
506
|
-
const
|
|
518
|
+
const upload = env.run.artifactUpload !== undefined
|
|
519
|
+
const written = [
|
|
520
|
+
...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null),
|
|
521
|
+
...Object.keys(familyVariables),
|
|
522
|
+
...(upload ? [FABRIKA_SOURCE_MAP_UPLOAD_KEY] : []),
|
|
523
|
+
]
|
|
524
|
+
const removed = [...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null), ...(upload ? [] : [FABRIKA_SOURCE_MAP_UPLOAD_KEY])]
|
|
507
525
|
log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
|
|
508
526
|
log(` [dry-run] would remove service variables on ${serviceId} if present: ${removed.join(', ') || '(none)'}`)
|
|
509
527
|
if (Object.keys(familyVariables).length > 0) {
|
|
@@ -517,9 +535,10 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
|
517
535
|
for (const key of SERVICE_MANAGED_KEYS) {
|
|
518
536
|
const value = managedValues[key]
|
|
519
537
|
const existing = records.find((record) => record.key === key)
|
|
538
|
+
const sensitive = SENSITIVE_MANAGED_KEYS.has(key)
|
|
520
539
|
assertRunning(signal)
|
|
521
|
-
if (value !== null && existing
|
|
522
|
-
await zerops.api.putServiceEnv({ serviceId, key, value, signal })
|
|
540
|
+
if (value !== null && (sensitive || readableContent(existing) !== value)) {
|
|
541
|
+
await zerops.api.putServiceEnv({ serviceId, key, value, sensitive, signal })
|
|
523
542
|
log(` ${key}: updated`)
|
|
524
543
|
} else if (value === null && existing !== undefined) {
|
|
525
544
|
await zerops.api.deleteServiceEnv({ envId: existing.id, signal })
|
|
@@ -530,9 +549,10 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
|
530
549
|
}
|
|
531
550
|
const provenance = env.attempt.storageProvenance
|
|
532
551
|
const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
|
|
533
|
-
if (typeof provenance === 'string' && marker
|
|
552
|
+
if (typeof provenance === 'string' && readableContent(marker) !== provenance) {
|
|
534
553
|
assertRunning(signal)
|
|
535
|
-
|
|
554
|
+
// Not a credential, and the exposure read-back above reads it: it must stay readable.
|
|
555
|
+
await zerops.api.putServiceEnv({ serviceId, key: STORAGE_PROVENANCE_KEY, value: provenance, sensitive: false, signal })
|
|
536
556
|
log(` ${STORAGE_PROVENANCE_KEY}: updated`)
|
|
537
557
|
} else if (provenance === null && marker !== undefined) {
|
|
538
558
|
assertRunning(signal)
|
|
@@ -542,9 +562,10 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
|
542
562
|
// A workload key is written like a managed key; one whose workload is gone is removed, so no stale key stays readable.
|
|
543
563
|
for (const [key, value] of Object.entries(familyVariables)) {
|
|
544
564
|
const existing = records.find((record) => record.key === key)
|
|
565
|
+
const sensitive = isSensitiveFamilyVariable(key)
|
|
545
566
|
assertRunning(signal)
|
|
546
|
-
if (existing
|
|
547
|
-
await zerops.api.putServiceEnv({ serviceId, key, value, signal })
|
|
567
|
+
if (sensitive || readableContent(existing) !== value) {
|
|
568
|
+
await zerops.api.putServiceEnv({ serviceId, key, value, sensitive, signal })
|
|
548
569
|
log(` ${key}: updated`)
|
|
549
570
|
} else {
|
|
550
571
|
log(` ${key}: unchanged`)
|
|
@@ -557,6 +578,18 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
|
557
578
|
log(` ${record.key}: removed`)
|
|
558
579
|
}
|
|
559
580
|
}
|
|
581
|
+
// The build lifts the upload's bearer from here (`${RUNTIME_…}`), so the descriptor never holds it.
|
|
582
|
+
const bearer = env.run.artifactUpload?.bearer ?? null
|
|
583
|
+
const uploadKey = records.find((record) => record.key === FABRIKA_SOURCE_MAP_UPLOAD_KEY)
|
|
584
|
+
if (bearer !== null) {
|
|
585
|
+
assertRunning(signal)
|
|
586
|
+
await zerops.api.putServiceEnv({ serviceId, key: FABRIKA_SOURCE_MAP_UPLOAD_KEY, value: bearer, sensitive: true, signal })
|
|
587
|
+
log(` ${FABRIKA_SOURCE_MAP_UPLOAD_KEY}: updated`)
|
|
588
|
+
} else if (uploadKey !== undefined) {
|
|
589
|
+
assertRunning(signal)
|
|
590
|
+
await zerops.api.deleteServiceEnv({ envId: uploadKey.id, signal })
|
|
591
|
+
log(` ${FABRIKA_SOURCE_MAP_UPLOAD_KEY}: removed`)
|
|
592
|
+
}
|
|
560
593
|
}
|
|
561
594
|
|
|
562
595
|
/** Run one step's effect. Resolves on success, throws on failure (caught + recorded by `runSteps`). */
|
|
@@ -655,7 +688,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
655
688
|
}
|
|
656
689
|
|
|
657
690
|
case 'trigger-build': {
|
|
658
|
-
const locked = lockedExecutionFor(artifact, run, dryRun)
|
|
691
|
+
const locked = lockedExecutionFor(artifact, run, dryRun, run.artifactUpload)
|
|
659
692
|
if (dryRun) {
|
|
660
693
|
log(
|
|
661
694
|
locked === null
|
|
@@ -673,6 +706,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
673
706
|
: renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, locked)
|
|
674
707
|
if (locked !== null) {
|
|
675
708
|
log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
|
|
709
|
+
if (locked.sourceMaps !== null) log(` the build uploads the source maps of ${locked.sourceMaps.assetRoots.length} asset root(s)`)
|
|
676
710
|
}
|
|
677
711
|
assertRunning(signal)
|
|
678
712
|
// Recorded BEFORE the request: from here on the version is settled only by polling it (ADR-0044).
|
|
@@ -804,5 +838,15 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
|
|
|
804
838
|
run.events.log(` [warn] app-version ${appVersionId} could not be deleted; it was never built`)
|
|
805
839
|
}
|
|
806
840
|
}
|
|
807
|
-
return { appId: run.appId, env: run.env, ...result }
|
|
841
|
+
return { appId: run.appId, env: run.env, ...result, ...artifactStateOf(run, result.status, env.attempt) }
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** A run that uploads reports `complete` only when its build log said so; a missing or unread report is `incomplete`. */
|
|
845
|
+
const artifactStateOf = (
|
|
846
|
+
run: ZeropsDeployRun,
|
|
847
|
+
status: ZeropsDeployResult['status'],
|
|
848
|
+
attempt: Attempt,
|
|
849
|
+
): Pick<ZeropsDeployResult, 'artifactState'> => {
|
|
850
|
+
if (run.artifactUpload === undefined || run.dryRun || status !== 'succeeded') return {}
|
|
851
|
+
return { artifactState: attempt.sourceMaps === 'complete' ? 'complete' : 'incomplete' }
|
|
808
852
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { appliedZeropsSetups, parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
|
|
4
|
+
|
|
5
|
+
/** The path of the proxy manifest in the build archive: the archive's one data file (ADR-0045), read by the proxy build. */
|
|
6
|
+
export const ZEROPS_PROXY_MANIFEST_PATH = 'proxy.manifest.json'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How a proxy build receives its manifest.
|
|
10
|
+
*
|
|
11
|
+
* - `environment`: the build lifts `FABRIKA_PROXY_MANIFEST_JSON` from the service and writes it to the file, as every
|
|
12
|
+
* release before the archive carrier did. A namespace pinned to such a commit keeps its descriptor (ADR-0045), so it
|
|
13
|
+
* gets the variable, bound by the Zerops value limit.
|
|
14
|
+
* - `archive`: the build reads the file the uploaded archive carries.
|
|
15
|
+
*/
|
|
16
|
+
export type ZeropsProxyManifestCarrier = 'environment' | 'archive'
|
|
17
|
+
|
|
18
|
+
const buildSectionSchema = z.object({ build: z.object({ envVariables: z.record(z.string()).optional() }).passthrough() }).passthrough()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The carrier a descriptor's proxy setup reads. Decided by the build section Zerops applies (the selected setup's, else
|
|
22
|
+
* the nearest one it `extends`): a lifted `FABRIKA_PROXY_MANIFEST_JSON` would overwrite an archived file, so it wins.
|
|
23
|
+
*/
|
|
24
|
+
export const zeropsProxyManifestCarrier = (descriptorContents: string, setup = 'proxy'): ZeropsProxyManifestCarrier => {
|
|
25
|
+
const parsed = parseZeropsDescriptor(descriptorContents)
|
|
26
|
+
for (const entry of appliedZeropsSetups(parsed, selectZeropsDescriptorSetup(parsed, setup))) {
|
|
27
|
+
const read = buildSectionSchema.safeParse(entry)
|
|
28
|
+
if (!read.success) continue
|
|
29
|
+
return read.data.build.envVariables?.[FABRIKA_PROXY_MANIFEST_JSON] === undefined ? 'archive' : 'environment'
|
|
30
|
+
}
|
|
31
|
+
return 'archive'
|
|
32
|
+
}
|
package/src/source/port.ts
CHANGED
|
@@ -20,10 +20,16 @@ export interface ZeropsSourceResolution {
|
|
|
20
20
|
readonly descriptorSha256: string
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** ADR-0045: the one generated data file a platform archive carries beside the exact commit, at a path the commit lacks. */
|
|
24
|
+
export interface ZeropsSourceDataFile {
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly contents: string
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
/** ADR-0045: platform archives are anonymous, tree-prechecked, keep relative symlinks. */
|
|
24
30
|
export type ZeropsSourceArchive =
|
|
25
31
|
| { readonly kind: 'application'; readonly privateBinding?: ZeropsSourcePrivateBinding }
|
|
26
|
-
| { readonly kind: 'platform' }
|
|
32
|
+
| { readonly kind: 'platform'; readonly dataFile?: ZeropsSourceDataFile }
|
|
27
33
|
|
|
28
34
|
export interface ZeropsSourceUploadInput {
|
|
29
35
|
readonly repository: ZeropsSourceRepository
|
|
@@ -38,6 +44,8 @@ export interface ZeropsSourceUploadInput {
|
|
|
38
44
|
export interface ZeropsSourceUploadResult {
|
|
39
45
|
readonly commitSha: string
|
|
40
46
|
readonly descriptorSha256: string
|
|
47
|
+
/** The sha256 of the data file the archive carried; absent without one. */
|
|
48
|
+
readonly dataFileSha256?: string
|
|
41
49
|
readonly entryCount: number
|
|
42
50
|
readonly expandedBytes: number
|
|
43
51
|
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// Composing the platform's three apps with whatever else the installation's one proxy already fronts.
|
|
2
|
-
//
|
|
3
|
-
// ── Why this is a MERGE and not a write ───────────────────────────────────────────────────────────
|
|
4
|
-
//
|
|
5
|
-
// On the `light` tier an application shares the platform's project and therefore the platform proxy's
|
|
6
|
-
// single `FABRIKA_PROXY_MANIFEST_JSON`. The live document on `fabrika-test` carries a fourth app —
|
|
7
|
-
// `notes` → `notesapi:3000` on listener 8084 — beside the three platform ones. `platformProxyManifest`
|
|
8
|
-
// deliberately emits only the platform apps (it is generated from the platform's own service contracts and
|
|
9
|
-
// knows nothing about applications), so writing it alone would DELETE every application entry and take
|
|
10
|
-
// the deployed applications offline at the next proxy build.
|
|
11
|
-
//
|
|
12
|
-
// So: read the live value, compose, write. That composition is this command's job and nothing else's.
|
|
13
|
-
//
|
|
14
|
-
// ── What happens to an entry the platform does not know about ─────────────────────────────────────
|
|
15
|
-
//
|
|
16
|
-
// Carried through byte-for-byte, in its live order, after the platform apps — with ONE exception. A
|
|
17
|
-
// host belongs to exactly one app (`parseProxyManifest` refuses two apps claiming one, and rightly:
|
|
18
|
-
// route order would otherwise decide which app answers). The platform's three listeners are addresses
|
|
19
|
-
// the platform OWNS, so a live entry standing on one of them is superseded rather than carried, and
|
|
20
|
-
// the deploy says so out loud.
|
|
21
|
-
//
|
|
22
|
-
// That exception is not hypothetical: WU1 corrected IAM's app id from `iam-local` to `iam`, so the
|
|
23
|
-
// live document's `iam-local` claims the platform's own IAM host under a name the platform no longer
|
|
24
|
-
// uses. Carrying it would produce a manifest `parseProxyManifest` rejects, which the proxy build then
|
|
25
|
-
// fails on — a fail-closed outcome, but a needlessly confusing one.
|
|
26
|
-
//
|
|
27
|
-
// Superseding is itself CLOSED: the dropped host stops resolving to anything, it does not fall through
|
|
28
|
-
// to some other app. A genuinely misplaced application therefore goes dark and is reported, instead of
|
|
29
|
-
// quietly being served by the console.
|
|
30
|
-
|
|
31
|
-
import { findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
32
|
-
|
|
33
|
-
/** The composed document plus what happened to every live entry that was not the platform's. */
|
|
34
|
-
export interface MergedProxyManifest {
|
|
35
|
-
readonly manifest: ProxyManifest
|
|
36
|
-
/** App ids carried through from the live manifest, in their live order. */
|
|
37
|
-
readonly carried: readonly string[]
|
|
38
|
-
/** Live entries the platform's own listeners displaced, with the host that collided. */
|
|
39
|
-
readonly superseded: readonly { readonly id: string; readonly host: string }[]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Compose the platform's apps with the application entries already in a live manifest.
|
|
44
|
-
*
|
|
45
|
-
* `live` is `null` when the proxy carries no manifest yet (a first deploy) — the result is then the
|
|
46
|
-
* platform document alone. An UNPARSEABLE live value is a different thing entirely and throws: it may
|
|
47
|
-
* be a manifest this build is too old to read, and silently replacing it would drop applications this
|
|
48
|
-
* code cannot see. Refusing is what keeps "must not take a deployed app offline" true in the one case
|
|
49
|
-
* where the deploy genuinely cannot tell.
|
|
50
|
-
*/
|
|
51
|
-
export const mergePlatformProxyManifest = (platform: ProxyManifest, live: ProxyManifest | null): MergedProxyManifest => {
|
|
52
|
-
const platformIds = new Set(platform.apps.map((app) => app.id))
|
|
53
|
-
const platformHosts = new Set(platform.apps.flatMap((app) => app.hosts))
|
|
54
|
-
const carried: string[] = []
|
|
55
|
-
const superseded: { id: string; host: string }[] = []
|
|
56
|
-
const apps = [...platform.apps]
|
|
57
|
-
for (const app of live?.apps ?? []) {
|
|
58
|
-
if (platformIds.has(app.id)) {
|
|
59
|
-
continue
|
|
60
|
-
}
|
|
61
|
-
const collision = app.hosts.find((host) => platformHosts.has(host))
|
|
62
|
-
if (collision !== undefined) {
|
|
63
|
-
superseded.push({ id: app.id, host: collision })
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
carried.push(app.id)
|
|
67
|
-
apps.push(app)
|
|
68
|
-
}
|
|
69
|
-
// Both inputs are already parsed manifests, so a duplicate host is the one invariant composing can break.
|
|
70
|
-
// A duplicate host between two APPLICATION entries — which this function cannot fix — is refused
|
|
71
|
-
// here rather than at the proxy's next build.
|
|
72
|
-
if (findDuplicateProxyHost(apps) !== null) {
|
|
73
|
-
throw new Error('the composed proxy manifest is not one the proxy would accept — two application entries claim one host')
|
|
74
|
-
}
|
|
75
|
-
return { manifest: { apps }, carried, superseded }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The live manifest a proxy service currently carries.
|
|
80
|
-
*
|
|
81
|
-
* Absent means "no manifest yet". Present-but-unparseable throws: see `mergePlatformProxyManifest`.
|
|
82
|
-
*/
|
|
83
|
-
export const readLiveProxyManifest = (value: string | undefined): ProxyManifest | null => {
|
|
84
|
-
if (value === undefined || value.trim() === '') {
|
|
85
|
-
return null
|
|
86
|
-
}
|
|
87
|
-
const manifest = parseProxyManifestJson(value)
|
|
88
|
-
if (manifest === null) {
|
|
89
|
-
throw new Error(
|
|
90
|
-
`the proxy's current FABRIKA_PROXY_MANIFEST_JSON cannot be parsed by this build — refusing to replace a document whose `
|
|
91
|
-
+ 'application entries cannot be read',
|
|
92
|
-
)
|
|
93
|
-
}
|
|
94
|
-
return manifest
|
|
95
|
-
}
|