@fabrika/provider-zerops 0.3.2 → 0.4.0
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 +7 -6
- package/src/api/client.ts +11 -2
- package/src/api/types.ts +13 -0
- package/src/cli.ts +21 -4
- package/src/collaborators.ts +4 -0
- package/src/control.ts +37 -8
- package/src/execution.ts +371 -0
- package/src/exposure.ts +95 -0
- package/src/installation/deploy-options.ts +12 -1
- package/src/installation/deploy.ts +42 -18
- package/src/installation/generated/platform-console-schema.ts +4 -0
- package/src/installation/generated/platform-proxy-manifest.ts +533 -33
- package/src/installation/index.ts +11 -0
- package/src/installation/install-environment.ts +1 -2
- package/src/installation/machine-key.ts +3 -4
- package/src/installation/manifest.ts +1 -1
- package/src/installation/mcp.ts +102 -0
- package/src/installation/placement-options.ts +2 -1
- package/src/installation/proxy-manifest.ts +124 -23
- package/src/installation/secrets.ts +2 -2
- package/src/installation/templates/README.md +20 -11
- package/src/installation/templates/platform.yml +2 -0
- package/src/installation/topology/setups.ts +5 -5
- package/src/managed-env.ts +94 -6
- package/src/manifest.ts +248 -14
- package/src/namespace/codec.ts +23 -12
- package/src/namespace/errors.ts +1 -1
- package/src/namespace/manifest.ts +25 -6
- package/src/namespace/provisioning.ts +10 -29
- package/src/namespace/proxy-attempt.ts +13 -2
- package/src/namespace/proxy-secrets.ts +21 -0
- package/src/namespace/update.ts +212 -0
- package/src/namespace/validation.ts +43 -0
- package/src/plan.ts +6 -0
- package/src/provider.ts +159 -19
- package/src/types.ts +14 -3
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROVIDER_NAMESPACE_IMMUTABLE,
|
|
3
|
+
PROVIDER_NAMESPACE_UPDATE_UNCONFIRMED,
|
|
4
|
+
type ProviderDeploymentNamespace,
|
|
5
|
+
type ProviderEnvelope,
|
|
6
|
+
type ProviderNamespaceUpdateInput,
|
|
7
|
+
type ProviderNamespaceUpdatePlan,
|
|
8
|
+
type ProviderNamespaceUpdates,
|
|
9
|
+
} from '@fabrika/provider-contract'
|
|
10
|
+
import { waitForProcess, ZeropsApiError, type ZeropsProcess } from '../api'
|
|
11
|
+
import { defaultSleep } from '../collaborators'
|
|
12
|
+
import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, zeropsNamespaceTargetCodec } from './codec'
|
|
13
|
+
import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED } from './errors'
|
|
14
|
+
import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespacePostgres, ZeropsNamespaceTarget } from './types'
|
|
15
|
+
import { validateService } from './validation'
|
|
16
|
+
|
|
17
|
+
// Budget: ≤ 60 s on the autoscaling process plus ≤ 30 s of read-back — inside one request, and inside the lease TTL.
|
|
18
|
+
const PROCESS_POLL_INTERVAL_MS = 2_000
|
|
19
|
+
const PROCESS_POLL_ATTEMPTS = 30
|
|
20
|
+
const READBACK_INTERVAL_MS = 3_000
|
|
21
|
+
/** UNVERIFIED: how soon `autoscalingProfileId` reads the new profile after the PUT and its process finish. */
|
|
22
|
+
const READBACK_ATTEMPTS = 10
|
|
23
|
+
|
|
24
|
+
type FixedField = Exclude<keyof ZeropsNamespaceTarget, 'postgres'>
|
|
25
|
+
|
|
26
|
+
/** Every target field but `postgres`, whose profile is the one thing an update may move. A new field fails to compile until listed. */
|
|
27
|
+
const FIXED_FIELDS: { readonly [K in FixedField]: (target: ZeropsNamespaceTarget) => ZeropsNamespaceTarget[K] } = {
|
|
28
|
+
proxySource: (target) => target.proxySource,
|
|
29
|
+
proxyBuild: (target) => target.proxyBuild,
|
|
30
|
+
proxyDeployment: (target) => target.proxyDeployment,
|
|
31
|
+
projectName: (target) => target.projectName,
|
|
32
|
+
corePackage: (target) => target.corePackage,
|
|
33
|
+
publicAccess: (target) => target.publicAccess,
|
|
34
|
+
managed: (target) => target.managed,
|
|
35
|
+
projectId: (target) => target.projectId,
|
|
36
|
+
proxyServiceId: (target) => target.proxyServiceId,
|
|
37
|
+
postgresServiceId: (target) => target.postgresServiceId,
|
|
38
|
+
proxyConfigured: (target) => target.proxyConfigured,
|
|
39
|
+
proxyBaselineSequence: (target) => target.proxyBaselineSequence,
|
|
40
|
+
proxyAppVersionId: (target) => target.proxyAppVersionId,
|
|
41
|
+
proxyHosts: (target) => target.proxyHosts,
|
|
42
|
+
ready: (target) => target.ready,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const immutable = (message: string) => namespaceError(PROVIDER_NAMESPACE_IMMUTABLE, message, false)
|
|
46
|
+
|
|
47
|
+
/** The stored target with the requested payload's top-level fields laid over it, validated by the one codec. */
|
|
48
|
+
const mergedTarget = (
|
|
49
|
+
namespace: ProviderDeploymentNamespace,
|
|
50
|
+
current: NormalizedZeropsNamespaceTarget,
|
|
51
|
+
requested: ProviderEnvelope,
|
|
52
|
+
options: ZeropsNamespaceOptions,
|
|
53
|
+
): NormalizedZeropsNamespaceTarget => {
|
|
54
|
+
const payload = requested.payload
|
|
55
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
56
|
+
throw new Error('Zerops namespace update payload must be an object')
|
|
57
|
+
}
|
|
58
|
+
const checked = namespaceUpdatePayloadSchema.safeParse(payload)
|
|
59
|
+
if (!checked.success) {
|
|
60
|
+
const issue = checked.error.issues[0]
|
|
61
|
+
const path = issue?.path.join('.') ?? ''
|
|
62
|
+
if (issue?.code === 'unrecognized_keys') {
|
|
63
|
+
const field = [path, issue.keys[0] ?? ''].filter((part) => part !== '').join('.')
|
|
64
|
+
throw new Error(`Zerops namespace update field ${field} is not a namespace target field`)
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Zerops namespace update field ${path === '' ? 'payload' : path} is invalid`)
|
|
67
|
+
}
|
|
68
|
+
const stored = zeropsNamespaceTargetCodec.encode(current)
|
|
69
|
+
if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
|
|
70
|
+
throw new Error('Zerops namespace target did not encode to an object')
|
|
71
|
+
}
|
|
72
|
+
return normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...payload } } }, options)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Refuse every difference except the PostgreSQL profile, naming the fields. */
|
|
76
|
+
const planUpdate = (
|
|
77
|
+
namespace: ProviderDeploymentNamespace,
|
|
78
|
+
requested: ProviderEnvelope,
|
|
79
|
+
options: ZeropsNamespaceOptions,
|
|
80
|
+
): ProviderNamespaceUpdatePlan => {
|
|
81
|
+
const current = normalizeTarget(namespace, options)
|
|
82
|
+
const merged = mergedTarget(namespace, current, requested, options)
|
|
83
|
+
const changed = Object.entries(FIXED_FIELDS)
|
|
84
|
+
.filter(([, read]) => JSON.stringify(read(current)) !== JSON.stringify(read(merged)))
|
|
85
|
+
.map(([field]) => field)
|
|
86
|
+
if (changed.length > 0) {
|
|
87
|
+
throw immutable(`Zerops namespace ${namespace.id} cannot change ${changed.join(', ')} in place; only the PostgreSQL profile can`)
|
|
88
|
+
}
|
|
89
|
+
const was = current.postgres
|
|
90
|
+
const wanted = merged.postgres
|
|
91
|
+
const unchanged: ProviderNamespaceUpdatePlan = { namespace: { ...namespace, target: envelope(current) }, change: null }
|
|
92
|
+
// The merge starts from the stored target, so `wanted` is absent only when `was` is.
|
|
93
|
+
if (wanted === undefined) return unchanged
|
|
94
|
+
if (was === undefined) {
|
|
95
|
+
throw immutable(`Zerops namespace ${namespace.id} has no PostgreSQL service, and one cannot be added in place`)
|
|
96
|
+
}
|
|
97
|
+
if (was.type !== wanted.type) {
|
|
98
|
+
throw immutable(`Zerops namespace ${namespace.id} cannot change its PostgreSQL type from ${was.type} to ${wanted.type} in place`)
|
|
99
|
+
}
|
|
100
|
+
if (wanted.profile === was.profile) return unchanged
|
|
101
|
+
if (wanted.profile === undefined) {
|
|
102
|
+
throw immutable(`Zerops namespace ${namespace.id} cannot return its PostgreSQL service to the platform default profile in place`)
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
namespace: { ...namespace, target: envelope({ ...current, postgres: wanted }) },
|
|
106
|
+
change: { field: 'postgres.profile', from: was.profile ?? null, to: wanted.profile },
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const unconfirmed = (serviceId: string, profile: string, cause: unknown) =>
|
|
111
|
+
namespaceError(
|
|
112
|
+
PROVIDER_NAMESPACE_UPDATE_UNCONFIRMED,
|
|
113
|
+
`Zerops PostgreSQL service ${serviceId} was asked to move to profile ${profile}, and the change is not confirmed`,
|
|
114
|
+
true,
|
|
115
|
+
cause instanceof Error ? cause.message : undefined,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** A platform refusal answered before anything was accepted; everything else once the PUT is sent is ambiguous. */
|
|
119
|
+
const refusedOutright = (cause: unknown): boolean =>
|
|
120
|
+
cause instanceof ZeropsApiError && cause.status >= 400 && cause.status < 500 && cause.status !== 429
|
|
121
|
+
|
|
122
|
+
const readBack = async (serviceId: string, profile: string, options: ZeropsNamespaceOptions, signal: AbortSignal): Promise<void> => {
|
|
123
|
+
const sleep = options.sleep ?? defaultSleep
|
|
124
|
+
for (let attempt = 0;; attempt += 1) {
|
|
125
|
+
if (signal.aborted) {
|
|
126
|
+
throw namespaceError(ZEROPS_NAMESPACE_CANCELLED, 'Zerops namespace update was cancelled', true)
|
|
127
|
+
}
|
|
128
|
+
const live = (await options.api.getService({ serviceId, signal })).autoscalingProfileId
|
|
129
|
+
if (live === profile) return
|
|
130
|
+
if (attempt >= READBACK_ATTEMPTS) {
|
|
131
|
+
throw new Error(`the service still reads ${live ?? 'no profile'}`)
|
|
132
|
+
}
|
|
133
|
+
await sleep(READBACK_INTERVAL_MS, signal)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const applyProfile = async (
|
|
138
|
+
current: NormalizedZeropsNamespaceTarget,
|
|
139
|
+
wanted: ZeropsNamespacePostgres,
|
|
140
|
+
profile: string,
|
|
141
|
+
unconfirmedProfile: string | undefined,
|
|
142
|
+
options: ZeropsNamespaceOptions,
|
|
143
|
+
signal: AbortSignal,
|
|
144
|
+
): Promise<void> => {
|
|
145
|
+
const projectId = current.projectId
|
|
146
|
+
const serviceId = current.postgresServiceId
|
|
147
|
+
if (projectId === undefined || serviceId === undefined) {
|
|
148
|
+
throw new Error('Zerops namespace has no PostgreSQL service id')
|
|
149
|
+
}
|
|
150
|
+
const service = await options.api.getService({ serviceId, signal })
|
|
151
|
+
validateService(service, projectId, wanted.type, false, undefined, {
|
|
152
|
+
...(current.postgres?.profile === undefined ? {} : { declared: current.postgres.profile }),
|
|
153
|
+
changingTo: profile,
|
|
154
|
+
...(unconfirmedProfile === undefined ? {} : { unconfirmed: unconfirmedProfile }),
|
|
155
|
+
})
|
|
156
|
+
if (service.autoscalingProfileId !== profile) {
|
|
157
|
+
let process: ZeropsProcess | null
|
|
158
|
+
try {
|
|
159
|
+
process = await options.api.configureAutoscaling({ serviceId, autoscalingProfileId: profile, signal })
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
if (refusedOutright(cause)) throw cause
|
|
162
|
+
throw unconfirmed(serviceId, profile, cause)
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
if (process !== null && process.id !== '') {
|
|
166
|
+
await waitForProcess({
|
|
167
|
+
api: options.api,
|
|
168
|
+
processId: process.id,
|
|
169
|
+
sleep: options.sleep ?? defaultSleep,
|
|
170
|
+
signal,
|
|
171
|
+
label: 'the PostgreSQL autoscaling change',
|
|
172
|
+
intervalMs: PROCESS_POLL_INTERVAL_MS,
|
|
173
|
+
attempts: PROCESS_POLL_ATTEMPTS,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
} catch (cause) {
|
|
177
|
+
throw unconfirmed(serviceId, profile, cause)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
await readBack(serviceId, profile, options, signal)
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
throw unconfirmed(serviceId, profile, cause)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const applyUpdate = async (input: ProviderNamespaceUpdateInput, options: ZeropsNamespaceOptions): Promise<ProviderDeploymentNamespace> => {
|
|
188
|
+
const current = normalizeTarget(input.namespace, options)
|
|
189
|
+
if (!current.ready) {
|
|
190
|
+
throw new Error(`Zerops namespace ${input.namespace.id} is not ready`)
|
|
191
|
+
}
|
|
192
|
+
const plan = planUpdate(input.namespace, input.next.target, options)
|
|
193
|
+
const wanted = normalizeTarget(plan.namespace, options).postgres
|
|
194
|
+
// Checked even with no planned change: after an unconfirmed update, confirming the live service is
|
|
195
|
+
// what lets core return the namespace to ready.
|
|
196
|
+
if (wanted?.profile === undefined) return plan.namespace
|
|
197
|
+
const unconfirmedProfile = input.unconfirmed === undefined ? undefined : normalizeTarget(input.unconfirmed, options).postgres?.profile
|
|
198
|
+
await applyProfile(current, wanted, wanted.profile, unconfirmedProfile, options, input.signal)
|
|
199
|
+
return plan.namespace
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Move a ready namespace's PostgreSQL service to another profile — the one field Zerops changes in place. */
|
|
203
|
+
export const createZeropsNamespaceUpdates = (options: ZeropsNamespaceOptions): ProviderNamespaceUpdates => ({
|
|
204
|
+
plan: (namespace, requested) => planUpdate(namespace, requested, options),
|
|
205
|
+
apply: async (input) => {
|
|
206
|
+
try {
|
|
207
|
+
return await applyUpdate(input, options)
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
throw asNamespaceError(cause, 'update')
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ZeropsService } from '../api'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The profile a service is checked against. `declared` is what the stored target says. `changingTo` is
|
|
5
|
+
* set only while an update moves the service, and `unconfirmed` while it retries an earlier update whose
|
|
6
|
+
* outcome is unknown: a live service already on either reads as the change in progress, not as drift.
|
|
7
|
+
*/
|
|
8
|
+
export interface ZeropsExpectedProfile {
|
|
9
|
+
readonly declared?: string
|
|
10
|
+
readonly changingTo?: string
|
|
11
|
+
readonly unconfirmed?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const validateService = (
|
|
15
|
+
service: ZeropsService,
|
|
16
|
+
projectId: string,
|
|
17
|
+
expectedBase: string,
|
|
18
|
+
adopting: boolean,
|
|
19
|
+
expectedPublic?: boolean,
|
|
20
|
+
expectedProfile: ZeropsExpectedProfile = {},
|
|
21
|
+
): void => {
|
|
22
|
+
if (service.id === '') {
|
|
23
|
+
throw new Error(`Zerops namespace service \`${service.name}\` returned an empty id`)
|
|
24
|
+
}
|
|
25
|
+
if (service.projectId !== undefined && service.projectId !== projectId) {
|
|
26
|
+
throw new Error(`Zerops namespace service \`${service.name}\` belongs to another project`)
|
|
27
|
+
}
|
|
28
|
+
if ((adopting || service.base !== undefined) && service.base !== expectedBase) {
|
|
29
|
+
throw new Error(`Zerops namespace service \`${service.name}\` uses ${service.base ?? 'an unknown type'}, expected ${expectedBase}`)
|
|
30
|
+
}
|
|
31
|
+
if (expectedPublic !== undefined && adopting && service.subdomainAccess !== expectedPublic) {
|
|
32
|
+
throw new Error(`Zerops namespace service \`${service.name}\` has incompatible public access`)
|
|
33
|
+
}
|
|
34
|
+
const live = service.autoscalingProfileId
|
|
35
|
+
if (live !== undefined && (live === expectedProfile.changingTo || live === expectedProfile.unconfirmed)) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (expectedProfile.declared !== undefined && live !== expectedProfile.declared) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Zerops namespace service \`${service.name}\` uses profile ${live ?? 'unknown'}, but its namespace declares ${expectedProfile.declared}`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/plan.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ZeropsRuntimeTarget } from './types'
|
|
|
3
3
|
|
|
4
4
|
export type ZeropsStepKind =
|
|
5
5
|
| 'apply-import'
|
|
6
|
+
| 'verify-exposure'
|
|
6
7
|
| 'write-service-env'
|
|
7
8
|
| 'reserve-version'
|
|
8
9
|
| 'upload-source'
|
|
@@ -36,6 +37,11 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
|
|
|
36
37
|
kind: 'apply-import',
|
|
37
38
|
description: `Apply the Zerops import for ${serviceHostnames.length} service(s): ${serviceHostnames.join(', ')}`,
|
|
38
39
|
})
|
|
40
|
+
steps.push({
|
|
41
|
+
id: 'verify-exposure',
|
|
42
|
+
kind: 'verify-exposure',
|
|
43
|
+
description: 'Read back that no service has subdomain access and every bucket is one the platform created private',
|
|
44
|
+
})
|
|
39
45
|
steps.push({ id: 'write-service-env', kind: 'write-service-env', description: 'Write the managed service variables' })
|
|
40
46
|
steps.push({ id: 'reserve-version', kind: 'reserve-version', description: 'Reserve a Zerops app version' })
|
|
41
47
|
steps.push({ id: 'upload-source', kind: 'upload-source', description: 'Upload the resolved repository snapshot' })
|
package/src/provider.ts
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
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
12
|
import {
|
|
12
13
|
type DeployStepResult,
|
|
13
14
|
followLeaseLoss,
|
|
14
15
|
isLeaseLoss,
|
|
15
16
|
type JsonValue,
|
|
17
|
+
type ProviderApprovedAccess,
|
|
16
18
|
ProviderInvocationAbort,
|
|
17
19
|
type ProviderManagedEnvironment,
|
|
18
20
|
type ProviderRunEvents,
|
|
@@ -31,15 +33,33 @@ import {
|
|
|
31
33
|
} from './api'
|
|
32
34
|
import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
|
|
33
35
|
import { assertZeropsInvariants, renderYaml } from './compile'
|
|
36
|
+
import { FABRIKA_CHECK_VERSION, PLATFORM_TYPESCRIPT_VERSION, renderLockedExecutionDescriptor } from './execution'
|
|
37
|
+
import {
|
|
38
|
+
createdServices,
|
|
39
|
+
type ExposureFinding,
|
|
40
|
+
formatExposureFindings,
|
|
41
|
+
formatStorageProvenance,
|
|
42
|
+
isObjectStorageType,
|
|
43
|
+
parseStorageProvenance,
|
|
44
|
+
STORAGE_PROVENANCE_KEY,
|
|
45
|
+
} from './exposure'
|
|
34
46
|
import {
|
|
35
47
|
assertAuthoredManagedDocument,
|
|
36
48
|
assertAuthoredManagedInputs,
|
|
49
|
+
isFrameworkFamilyVariable,
|
|
37
50
|
type ManagedValues,
|
|
38
51
|
overlayRelease,
|
|
39
|
-
|
|
52
|
+
parseManagedEnvironment,
|
|
40
53
|
SERVICE_MANAGED_KEYS,
|
|
41
54
|
} from './managed-env'
|
|
42
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
approvedSchemaOf,
|
|
57
|
+
type FabrikaImportDocument,
|
|
58
|
+
type FabrikaManifest,
|
|
59
|
+
lockedExecutionFor,
|
|
60
|
+
manifestServiceHostnames,
|
|
61
|
+
verifyZeropsArtifactSourceDescriptor,
|
|
62
|
+
} from './manifest'
|
|
43
63
|
import { buildZeropsPlan, resolveDeployHostname, type ZeropsStep } from './plan'
|
|
44
64
|
import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
|
|
45
65
|
import { isZeropsUploadDestination } from './source'
|
|
@@ -59,6 +79,8 @@ export interface ZeropsDeployRun {
|
|
|
59
79
|
readonly managedEnvironment: ProviderManagedEnvironment
|
|
60
80
|
readonly target: ZeropsRuntimeTarget
|
|
61
81
|
readonly artifact: FabrikaManifest
|
|
82
|
+
/** Control's approved access for the environment (ADR-0050); the lock-down and the IAM schema come from it. */
|
|
83
|
+
readonly access?: ProviderApprovedAccess
|
|
62
84
|
readonly dryRun: boolean
|
|
63
85
|
readonly signal: AbortSignal
|
|
64
86
|
readonly events: ProviderRunEvents
|
|
@@ -77,8 +99,6 @@ const POLL_INTERVAL_MS = 3000
|
|
|
77
99
|
/** A run stops waiting for its version this long after it started. Zerops' own pipeline limit is 1 hour. */
|
|
78
100
|
export const ZEROPS_VERSION_DEADLINE_MS = 75 * 60 * 1000
|
|
79
101
|
|
|
80
|
-
const RELEASE_KEY = 'FABRIKA_RELEASE'
|
|
81
|
-
|
|
82
102
|
/** Abandon the step if the run was cancelled. Checked before every real mutation and each poll iteration. */
|
|
83
103
|
const assertRunning = (signal: AbortSignal): void => {
|
|
84
104
|
if (signal.aborted) {
|
|
@@ -243,6 +263,10 @@ const terminalFailure = (version: ZeropsAppVersion): string => {
|
|
|
243
263
|
* just before the build request is sent; together they decide whether a failure may delete the version.
|
|
244
264
|
*/
|
|
245
265
|
interface Attempt {
|
|
266
|
+
/** Hostname → id of every service this run's import created. */
|
|
267
|
+
created?: ReadonlyMap<string, string>
|
|
268
|
+
/** The provenance marker `write-service-env` writes, or null to remove a marker no bucket needs any more. */
|
|
269
|
+
storageProvenance?: string | null
|
|
246
270
|
appVersionId?: string
|
|
247
271
|
uploadUrl?: string
|
|
248
272
|
processId?: string
|
|
@@ -260,6 +284,8 @@ interface StepEnv {
|
|
|
260
284
|
dryRun: boolean
|
|
261
285
|
attempt: Attempt
|
|
262
286
|
managedValues: ManagedValues
|
|
287
|
+
/** The key of each job and webhook of the approved contract, by its variable (ADR-0022 "Principal kinds"). */
|
|
288
|
+
familyVariables: Readonly<Record<string, string>>
|
|
263
289
|
/** `await-version` gives up at this instant. */
|
|
264
290
|
deadlineMs: number
|
|
265
291
|
}
|
|
@@ -411,15 +437,78 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
|
|
|
411
437
|
}
|
|
412
438
|
}
|
|
413
439
|
|
|
414
|
-
/**
|
|
440
|
+
/**
|
|
441
|
+
* The exposure read-back (ADR-0043): before a version can activate, every declared service must read
|
|
442
|
+
* `subdomainAccess: false`, and every object-storage service must be one this import created or the provenance
|
|
443
|
+
* marker names. A refusal raises an Operations alert, best-effort, and fails the run before any version exists.
|
|
444
|
+
*/
|
|
445
|
+
const verifyExposure = async (env: StepEnv): Promise<void> => {
|
|
446
|
+
const { zerops, log, signal, run } = env
|
|
447
|
+
const { artifact, target } = run
|
|
448
|
+
if (env.dryRun) {
|
|
449
|
+
log(' [dry-run] would read back subdomain access and bucket provenance of every declared service')
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
const created = env.attempt.created ?? new Map<string, string>()
|
|
453
|
+
const buckets = artifact.target.importDocument.services.filter((service) => isObjectStorageType(service.type)).map((service) => service.hostname)
|
|
454
|
+
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?.content), ...(target.createdBuckets ?? [])])
|
|
457
|
+
const findings: ExposureFinding[] = []
|
|
458
|
+
const bucketIds: string[] = []
|
|
459
|
+
for (const hostname of manifestServiceHostnames(artifact)) {
|
|
460
|
+
assertRunning(signal)
|
|
461
|
+
const service = await zerops.api.findService({ projectId: target.projectId, hostname, signal })
|
|
462
|
+
if (service === null) {
|
|
463
|
+
findings.push({ hostname, reason: 'is absent after the import, so its exposure cannot be read back' })
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
if (service.subdomainAccess !== false) {
|
|
467
|
+
findings.push({
|
|
468
|
+
hostname,
|
|
469
|
+
reason: service.subdomainAccess === true
|
|
470
|
+
? 'has a `.zerops.app` subdomain, which reaches it past the namespace proxy; disable it in Zerops'
|
|
471
|
+
: 'did not report its subdomain access',
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
if (!buckets.includes(hostname)) continue
|
|
475
|
+
if (created.get(hostname) === service.id || known.has(service.id)) {
|
|
476
|
+
bucketIds.push(service.id)
|
|
477
|
+
} else {
|
|
478
|
+
findings.push({ hostname, reason: 'is a bucket the platform did not create private (no provenance); create it through a Fabrika import' })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (findings.length > 0) {
|
|
482
|
+
const dsn = env.managedValues.FABRIKA_OPERATIONS_DSN
|
|
483
|
+
if (dsn !== null) {
|
|
484
|
+
const alert = { appId: run.appId, env: run.env, release: env.managedValues.FABRIKA_RELEASE, findings }
|
|
485
|
+
await zerops.alertExposure({ dsn, alert, signal }).then(
|
|
486
|
+
() => log(' raised an Operations alert'),
|
|
487
|
+
() => log(' [warn] the Operations alert could not be sent'),
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
throw new Error(formatExposureFindings(findings))
|
|
491
|
+
}
|
|
492
|
+
env.attempt.storageProvenance = bucketIds.length > 0 ? formatStorageProvenance(bucketIds) : null
|
|
493
|
+
log(
|
|
494
|
+
` ${manifestServiceHostnames(artifact).length} service(s) without subdomain access${
|
|
495
|
+
bucketIds.length === 0 ? '' : `; ${bucketIds.length} bucket(s) of platform provenance`
|
|
496
|
+
}`,
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Write the nine service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
|
|
415
501
|
const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
416
|
-
const { zerops, log, signal, managedValues } = env
|
|
502
|
+
const { zerops, log, signal, managedValues, familyVariables } = env
|
|
417
503
|
const serviceId = env.run.target.serviceId
|
|
418
504
|
if (env.dryRun) {
|
|
419
|
-
const written = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null)
|
|
505
|
+
const written = [...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null), ...Object.keys(familyVariables)]
|
|
420
506
|
const removed = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null)
|
|
421
507
|
log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
|
|
422
|
-
log(` [dry-run] would remove service variables on ${serviceId} if present: ${
|
|
508
|
+
log(` [dry-run] would remove service variables on ${serviceId} if present: ${removed.join(', ') || '(none)'}`)
|
|
509
|
+
if (Object.keys(familyVariables).length > 0) {
|
|
510
|
+
log(` [dry-run] would remove every other job, webhook and connector variable on ${serviceId}`)
|
|
511
|
+
}
|
|
423
512
|
return
|
|
424
513
|
}
|
|
425
514
|
assertRunning(signal)
|
|
@@ -439,11 +528,34 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
|
439
528
|
log(` ${key}: unchanged`)
|
|
440
529
|
}
|
|
441
530
|
}
|
|
442
|
-
const
|
|
443
|
-
|
|
531
|
+
const provenance = env.attempt.storageProvenance
|
|
532
|
+
const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
|
|
533
|
+
if (typeof provenance === 'string' && marker?.content !== provenance) {
|
|
534
|
+
assertRunning(signal)
|
|
535
|
+
await zerops.api.putServiceEnv({ serviceId, key: STORAGE_PROVENANCE_KEY, value: provenance, signal })
|
|
536
|
+
log(` ${STORAGE_PROVENANCE_KEY}: updated`)
|
|
537
|
+
} else if (provenance === null && marker !== undefined) {
|
|
444
538
|
assertRunning(signal)
|
|
445
|
-
await zerops.api.deleteServiceEnv({ envId:
|
|
446
|
-
log(` ${
|
|
539
|
+
await zerops.api.deleteServiceEnv({ envId: marker.id, signal })
|
|
540
|
+
log(` ${STORAGE_PROVENANCE_KEY}: removed`)
|
|
541
|
+
}
|
|
542
|
+
// A workload key is written like a managed key; one whose workload is gone is removed, so no stale key stays readable.
|
|
543
|
+
for (const [key, value] of Object.entries(familyVariables)) {
|
|
544
|
+
const existing = records.find((record) => record.key === key)
|
|
545
|
+
assertRunning(signal)
|
|
546
|
+
if (existing?.content !== value) {
|
|
547
|
+
await zerops.api.putServiceEnv({ serviceId, key, value, signal })
|
|
548
|
+
log(` ${key}: updated`)
|
|
549
|
+
} else {
|
|
550
|
+
log(` ${key}: unchanged`)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const record of records) {
|
|
554
|
+
if (isFrameworkFamilyVariable(record.key) && !Object.hasOwn(familyVariables, record.key)) {
|
|
555
|
+
assertRunning(signal)
|
|
556
|
+
await zerops.api.deleteServiceEnv({ envId: record.id, signal })
|
|
557
|
+
log(` ${record.key}: removed`)
|
|
558
|
+
}
|
|
447
559
|
}
|
|
448
560
|
}
|
|
449
561
|
|
|
@@ -466,6 +578,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
466
578
|
// `override: true` on every service (written by the compiler) is what makes re-applying safe.
|
|
467
579
|
const result = await zerops.api.importServices({ projectId: target.projectId, yaml: compiled.yaml, signal })
|
|
468
580
|
log(` imported: ${result.services.map((service) => `${service.name} (${service.id})`).join(', ') || '(none reported)'}`)
|
|
581
|
+
attempt.created = createdServices(result)
|
|
469
582
|
// An import ANSWERS before its services exist. Triggering a pipeline on a service Zerops is
|
|
470
583
|
// still creating answers 400 `projectImportInvalidParameter`, which is what the first deploy
|
|
471
584
|
// into a fresh namespace hit; a re-apply onto services that already exist reports none.
|
|
@@ -480,6 +593,11 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
480
593
|
return
|
|
481
594
|
}
|
|
482
595
|
|
|
596
|
+
case 'verify-exposure': {
|
|
597
|
+
await verifyExposure(env)
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
483
601
|
case 'write-service-env': {
|
|
484
602
|
await writeServiceEnv(env)
|
|
485
603
|
return
|
|
@@ -537,13 +655,25 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
537
655
|
}
|
|
538
656
|
|
|
539
657
|
case 'trigger-build': {
|
|
658
|
+
const locked = lockedExecutionFor(artifact, run, dryRun)
|
|
540
659
|
if (dryRun) {
|
|
541
|
-
log(
|
|
660
|
+
log(
|
|
661
|
+
locked === null
|
|
662
|
+
? ' [dry-run] would trigger build+deploy with FABRIKA_RELEASE written into the execution descriptor'
|
|
663
|
+
: ' [dry-run] would trigger build+deploy with the generated run section and build checks',
|
|
664
|
+
)
|
|
542
665
|
return
|
|
543
666
|
}
|
|
544
667
|
const appVersionId = requireVersion(env, 'trigger-build')
|
|
545
668
|
await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
|
|
546
|
-
const
|
|
669
|
+
const { contents } = artifact.target.sourceDescriptor
|
|
670
|
+
const release = env.managedValues.FABRIKA_RELEASE
|
|
671
|
+
const descriptor = locked === null
|
|
672
|
+
? overlayRelease(contents, artifact.target.zeropsSetup, release)
|
|
673
|
+
: renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, locked)
|
|
674
|
+
if (locked !== null) {
|
|
675
|
+
log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
|
|
676
|
+
}
|
|
547
677
|
assertRunning(signal)
|
|
548
678
|
// Recorded BEFORE the request: from here on the version is settled only by polling it (ADR-0044).
|
|
549
679
|
const checkpoint: ZeropsRunState = { appVersionId, triggered: true }
|
|
@@ -587,7 +717,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
587
717
|
}
|
|
588
718
|
|
|
589
719
|
case 'reconcile-schema': {
|
|
590
|
-
const schema =
|
|
720
|
+
const schema = approvedSchemaOf(run)
|
|
591
721
|
const propustkaUrl = target.propustkaUrl
|
|
592
722
|
if (schema === undefined || propustkaUrl === undefined) {
|
|
593
723
|
return
|
|
@@ -595,17 +725,18 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
595
725
|
// Return origins ride this step because `apps.setReturnOrigins` 404s for an app IAM has never
|
|
596
726
|
// heard of, and the schema reconcile is what registers it. Separate call, same moment.
|
|
597
727
|
const returnOrigins = run.returnOrigins !== undefined && run.returnOrigins.length > 0 ? run.returnOrigins : undefined
|
|
728
|
+
const iamApp = appEnvironmentId(artifact.app.id, artifact.app.env)
|
|
598
729
|
if (dryRun) {
|
|
599
|
-
log(` [dry-run] would reconcile schema for \`${
|
|
730
|
+
log(` [dry-run] would reconcile schema for \`${iamApp}\` against ${propustkaUrl}`)
|
|
600
731
|
if (returnOrigins !== undefined) {
|
|
601
|
-
log(` [dry-run] would register return origins for \`${
|
|
732
|
+
log(` [dry-run] would register return origins for \`${iamApp}\`: ${returnOrigins.join(', ')}`)
|
|
602
733
|
}
|
|
603
734
|
return
|
|
604
735
|
}
|
|
605
736
|
assertRunning(signal)
|
|
606
737
|
await zerops.reconcileSchema({
|
|
607
738
|
url: propustkaUrl,
|
|
608
|
-
app:
|
|
739
|
+
app: iamApp,
|
|
609
740
|
schema,
|
|
610
741
|
...(returnOrigins === undefined ? {} : { returnOrigins }),
|
|
611
742
|
adminKey: target.adminKey,
|
|
@@ -634,7 +765,15 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
|
|
|
634
765
|
throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
|
|
635
766
|
}
|
|
636
767
|
assertAuthoredManagedInputs({ variableNames: Object.keys(run.vars), secretNames: Object.keys(run.secrets) })
|
|
637
|
-
|
|
768
|
+
// The proxy serves an endpoint table exactly when Control approved a contract, whatever the artifact says.
|
|
769
|
+
const contractProxy = run.access !== undefined
|
|
770
|
+
const { values: handed, familyVariables } = parseManagedEnvironment(run.managedEnvironment)
|
|
771
|
+
// Only a contract application checks the secret; any other would merely hold a credential its own code can print.
|
|
772
|
+
const managedValues: ManagedValues = contractProxy ? handed : { ...handed, FABRIKA_PROXY_SECRET: null }
|
|
773
|
+
if (!run.dryRun && contractProxy && managedValues.FABRIKA_PROXY_SECRET === null) {
|
|
774
|
+
// The application would refuse every request its proxy sends (ADR-0022 rule 5).
|
|
775
|
+
throw new Error('zerops: a contract application needs its proxy secret, and Control supplied none')
|
|
776
|
+
}
|
|
638
777
|
const hostnames = manifestServiceHostnames(run.artifact)
|
|
639
778
|
const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
|
|
640
779
|
const compiled = { yaml: renderYaml(document), hostnames }
|
|
@@ -649,6 +788,7 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
|
|
|
649
788
|
dryRun: run.dryRun,
|
|
650
789
|
attempt: { triggered: false },
|
|
651
790
|
managedValues,
|
|
791
|
+
familyVariables,
|
|
652
792
|
deadlineMs: Date.now() + ZEROPS_VERSION_DEADLINE_MS,
|
|
653
793
|
}
|
|
654
794
|
const steps = buildZeropsPlan(run.artifact, run.target)
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ContractArtifact } from '@fabrika/auth-core'
|
|
2
2
|
import type { AppConfigBase } from '@fabrika/provider-contract'
|
|
3
|
+
import type { ContractProxyApp } from '@fabrika/proxy-core/manifest'
|
|
4
|
+
import type { ZeropsRuntimePackage } from './execution'
|
|
3
5
|
import type {
|
|
4
6
|
ZeropsHealthCheck,
|
|
5
7
|
ZeropsImportProject,
|
|
@@ -82,9 +84,9 @@ export type ZeropsServiceSpec = Omit<ZeropsImportService, ZeropsCompilerOwnedSer
|
|
|
82
84
|
|
|
83
85
|
export type ZeropsProjectSpec = Omit<ZeropsImportProject, ZeropsCompilerOwnedProjectField>
|
|
84
86
|
|
|
85
|
-
|
|
87
|
+
/** What the namespace proxy applies to the app: the manifest form of its compiled contract, from `contractProxyTable`. */
|
|
88
|
+
export interface ZeropsProxySpec extends Pick<ContractProxyApp, 'endpoints' | 'reservedPrefixes' | 'snapshot'> {
|
|
86
89
|
upstream: string
|
|
87
|
-
gates: AppGates
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
/**
|
|
@@ -107,6 +109,13 @@ export interface ZeropsSourceTarget {
|
|
|
107
109
|
zeropsSetup?: string
|
|
108
110
|
proxy?: ZeropsProxySpec
|
|
109
111
|
namespaceResources?: ZeropsNamespaceResourceRequirement[]
|
|
112
|
+
/**
|
|
113
|
+
* The compiled contract of a contract application. Its presence locks the setup down (ADR-0043): the platform
|
|
114
|
+
* generates the run section and the server build, and the authored `zerops.yaml` keeps only the SPA build.
|
|
115
|
+
*/
|
|
116
|
+
contract?: ContractArtifact
|
|
117
|
+
/** System packages for the runtime image of a contract application, from a closed list. */
|
|
118
|
+
runtimePackages?: ZeropsRuntimePackage[]
|
|
110
119
|
services: (ctx: ZeropsResourceContext) => ZeropsServiceSpec[]
|
|
111
120
|
project?: ZeropsProjectSpec
|
|
112
121
|
}
|
|
@@ -134,6 +143,8 @@ export interface ZeropsRunState {
|
|
|
134
143
|
export interface ZeropsRuntimeTarget {
|
|
135
144
|
projectId: string
|
|
136
145
|
serviceId: string
|
|
146
|
+
/** The buckets the environment's registration created, from its stored target (creation evidence, ADR-0043). */
|
|
147
|
+
createdBuckets?: readonly string[]
|
|
137
148
|
accessToken: string
|
|
138
149
|
/** Absent only for dry runs, which perform no source or Zerops mutation. */
|
|
139
150
|
source?: ZeropsRuntimeSource
|