@fabrika/provider-zerops 0.6.0 → 0.7.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 +9 -9
- package/src/control.ts +4 -0
- package/src/execution.ts +22 -13
- package/src/index.ts +2 -0
- package/src/installation/generated/platform-console-schema.ts +3 -0
- package/src/installation/generated/platform-proxy-manifest.ts +20 -0
- package/src/installation/topology/setups.ts +5 -1
- package/src/managed-env.ts +47 -16
- package/src/manifest.ts +12 -4
- package/src/namespace/manifest.ts +1 -1
- package/src/namespace/proxy-reads.ts +54 -0
- package/src/provider.ts +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"zod": "^3.25.76",
|
|
46
|
-
"@fabrika/auth": "0.
|
|
47
|
-
"@fabrika/auth-core": "0.
|
|
48
|
-
"@fabrika/email": "0.
|
|
49
|
-
"@fabrika/iam-contract": "0.
|
|
50
|
-
"@fabrika/installation-init": "0.
|
|
51
|
-
"@fabrika/operations-contract": "0.
|
|
52
|
-
"@fabrika/provider-contract": "0.
|
|
53
|
-
"@fabrika/proxy-core": "0.
|
|
46
|
+
"@fabrika/auth": "0.7.0",
|
|
47
|
+
"@fabrika/auth-core": "0.7.0",
|
|
48
|
+
"@fabrika/email": "0.7.0",
|
|
49
|
+
"@fabrika/iam-contract": "0.7.0",
|
|
50
|
+
"@fabrika/installation-init": "0.7.0",
|
|
51
|
+
"@fabrika/operations-contract": "0.7.0",
|
|
52
|
+
"@fabrika/provider-contract": "0.7.0",
|
|
53
|
+
"@fabrika/proxy-core": "0.7.0",
|
|
54
54
|
"yaml": "^2.9.1"
|
|
55
55
|
}
|
|
56
56
|
}
|
package/src/control.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
type ZeropsNamespaceTarget,
|
|
49
49
|
zeropsNamespaceTargetCodec,
|
|
50
50
|
} from './namespace'
|
|
51
|
+
import { assertNamespaceProxyReads, type OlderProxyRowReaders } from './namespace/proxy-reads'
|
|
51
52
|
import { createZeropsDeployer, ZEROPS_VERSION_DEADLINE_MS } from './provider'
|
|
52
53
|
import { allowedReferenceHosts, assertNoPlatformOnlyReference, foreignReferenceHost, serviceReferenceHosts } from './service-references'
|
|
53
54
|
import { normalizeZeropsSourceRepository } from './source'
|
|
@@ -101,6 +102,8 @@ export interface ZeropsControlProviderOptions {
|
|
|
101
102
|
readonly proxySource: ZeropsNamespaceProxySource
|
|
102
103
|
readonly iamUrl: string
|
|
103
104
|
readonly iamKey: string
|
|
105
|
+
/** The row parsers of the older releases' proxies, which a namespace pinned to an older source may run. */
|
|
106
|
+
readonly olderProxies: OlderProxyRowReaders
|
|
104
107
|
}
|
|
105
108
|
}
|
|
106
109
|
|
|
@@ -345,6 +348,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
345
348
|
const binding = privateBinding(input.sourceBinding)
|
|
346
349
|
assertEnvironmentOfApp(input.app, input.environment)
|
|
347
350
|
const placement = resolvedEnvironment(input.environment, { requireReady: true })
|
|
351
|
+
assertNamespaceProxyReads(placement.namespace.id, input.environment.access, placement.namespaceTarget.proxySource, options.namespaces)
|
|
348
352
|
const artifact = artifactFor(input.environment)
|
|
349
353
|
await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
|
|
350
354
|
let source: ZeropsRuntimeTarget['source']
|
package/src/execution.ts
CHANGED
|
@@ -4,14 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
type ContractArtifact,
|
|
7
|
-
contractDigest,
|
|
8
7
|
FABRIKA_CONTRACT_DIGEST,
|
|
9
8
|
FABRIKA_SOURCE_MAP_UPLOAD,
|
|
10
9
|
FABRIKA_SOURCE_MAP_UPLOAD_KEY,
|
|
11
10
|
type FrameworkVariableName,
|
|
12
11
|
} from '@fabrika/auth-core'
|
|
13
12
|
import type { OperationsAssetRoot, OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
|
|
14
|
-
import type
|
|
13
|
+
import { FRAMEWORK_RELEASE_PATTERN, type JsonValue } from '@fabrika/provider-contract'
|
|
15
14
|
import packageManifest from '../package.json'
|
|
16
15
|
import { renderYaml } from './compile'
|
|
17
16
|
import { parseZeropsDescriptor, type PreparedZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
|
|
@@ -24,9 +23,9 @@ export const ZEROPS_APPLICATION_PORT = 3000
|
|
|
24
23
|
export const ZEROPS_APPLICATION_RUNTIME = 'alpine/bun@1.3'
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* This package's own version. Control runs the git checkout of its release tag, so this reads the source version,
|
|
27
|
+
* which `release:validate` holds equal to the tag; the public packages are released together, so it is the checker
|
|
28
|
+
* release of the installation. A build checks with the application's own release instead (`checkRelease`).
|
|
30
29
|
*/
|
|
31
30
|
export const FABRIKA_CHECK_VERSION: string = packageManifest.version
|
|
32
31
|
|
|
@@ -91,6 +90,11 @@ export interface ZeropsContractRun {
|
|
|
91
90
|
export interface ZeropsLockedExecution {
|
|
92
91
|
readonly appId: string
|
|
93
92
|
readonly contract: ZeropsContractRun
|
|
93
|
+
/**
|
|
94
|
+
* The release of the reader that read the application (`ProviderApprovedAccess.checker`), the newest patch of the
|
|
95
|
+
* framework minor it pins: the build runs `fabrika-check` and `fabrika-source-maps` of `@fabrika/check` at it.
|
|
96
|
+
*/
|
|
97
|
+
readonly checkRelease: string
|
|
94
98
|
/** A `${host_…}` reference to the application's PostgreSQL service, or null when it has none. */
|
|
95
99
|
readonly databaseUrl: string | null
|
|
96
100
|
/**
|
|
@@ -112,20 +116,24 @@ export const CONTRACT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/
|
|
|
112
116
|
|
|
113
117
|
const byName = <T extends { readonly name: string }>(left: T, right: T): number => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
|
114
118
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
119
|
+
/**
|
|
120
|
+
* The run facts of a contract, with the digest the application's own release computes for it. A contract without a
|
|
121
|
+
* health endpoint has nothing a readiness check can request.
|
|
122
|
+
*/
|
|
123
|
+
export function zeropsContractRun(artifact: ContractArtifact, digest: string): ZeropsContractRun {
|
|
117
124
|
const health = artifact.endpoints.filter((row) => row.kind === 'health').sort((left, right) => left.id < right.id ? -1 : 1)[0]
|
|
118
125
|
if (health === undefined) {
|
|
119
126
|
throw new Error(
|
|
120
127
|
"fabrika app build: a contract application on Zerops needs a health endpoint for its readiness check (allowed form: health: c.health('/healthz'))",
|
|
121
128
|
)
|
|
122
129
|
}
|
|
130
|
+
const fileKinds = Object.values(artifact.fileKinds ?? {})
|
|
123
131
|
return {
|
|
124
|
-
digest
|
|
132
|
+
digest,
|
|
125
133
|
readinessPath: health.path,
|
|
126
134
|
jobs: Object.entries(artifact.jobs).map(([name, job]) => ({ name, schedule: job.schedule })).sort(byName),
|
|
127
|
-
...(Object.keys(artifact.files).length > 0 ? { files: true } : {}),
|
|
128
|
-
...(Object.values(artifact.files).some((
|
|
135
|
+
...(Object.keys(artifact.files).length > 0 || fileKinds.length > 0 ? { files: true } : {}),
|
|
136
|
+
...([...Object.values(artifact.files), ...fileKinds].some((declared) => declared.scan === true) ? { scan: true } : {}),
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -295,7 +303,7 @@ const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
|
|
|
295
303
|
|
|
296
304
|
// After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
|
|
297
305
|
// provider reads the state from the report line.
|
|
298
|
-
const
|
|
306
|
+
const sourceMapUpload = (release: string): string => `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-source-maps ${SOURCE_ROOT} || true`
|
|
299
307
|
|
|
300
308
|
/**
|
|
301
309
|
* The upload's build variables: the destination and asset roots as a literal, and the bearer lifted from the service
|
|
@@ -315,18 +323,19 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
|
|
|
315
323
|
*/
|
|
316
324
|
export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
|
|
317
325
|
if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
|
|
326
|
+
if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
|
|
318
327
|
const database = execution.databaseUrl !== null
|
|
319
328
|
return [
|
|
320
329
|
INSTALL,
|
|
321
330
|
'bun test --isolate --pass-with-no-tests',
|
|
322
331
|
`rm -rf node_modules && ${INSTALL}`,
|
|
323
332
|
`bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"`,
|
|
324
|
-
`cd /tmp && bun x --bun -p @fabrika/check@${
|
|
333
|
+
`cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
|
|
325
334
|
writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
|
|
326
335
|
`cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
|
|
327
336
|
...(database ? [writeFileCommand(MIGRATE_SCRIPT, migrateScript(execution.appId))] : []),
|
|
328
337
|
writeFileCommand(START_SCRIPT, startScript(execution.appId, database)),
|
|
329
|
-
...(execution.sourceMaps === null ? [] : [
|
|
338
|
+
...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
|
|
330
339
|
]
|
|
331
340
|
}
|
|
332
341
|
|
package/src/index.ts
CHANGED
|
@@ -101,6 +101,8 @@ export type {
|
|
|
101
101
|
ZeropsProxyRepublishInput,
|
|
102
102
|
} from './namespace'
|
|
103
103
|
export { postgresSchema as zeropsNamespacePostgresSchema } from './namespace/codec'
|
|
104
|
+
export { rowsNeedingCurrentProxy } from './namespace/proxy-reads'
|
|
105
|
+
export type { OlderProxyRowReader, OlderProxyRowReaders } from './namespace/proxy-reads'
|
|
104
106
|
export { buildZeropsPlan, resolveDeployHostname } from './plan'
|
|
105
107
|
export type { ZeropsStep, ZeropsStepKind } from './plan'
|
|
106
108
|
export { CANCELLED, createZeropsDeployer, interpolateManifest } from './provider'
|
|
@@ -27,6 +27,9 @@ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
|
|
|
27
27
|
{ action: 'source.workspace.write', description: 'Create and commit to own Git workspaces on an authoring target' },
|
|
28
28
|
{ action: 'source.pull-request.create', description: 'Open a pull request from an own Git workspace' },
|
|
29
29
|
{ action: 'access.approve', description: 'Approve an access change and protect an environment (a person in the console only)' },
|
|
30
|
+
{ action: 'mail.read', description: 'List relayed mail and read its metadata' },
|
|
31
|
+
{ action: 'mail.content.read', description: 'Read the recipient, subject and bodies of relayed mail' },
|
|
32
|
+
{ action: 'mail.manage', description: 'Release or drop held mail and manage the mail rules' },
|
|
30
33
|
{ action: 'operations.read', description: 'Read Operations errors and activity' },
|
|
31
34
|
{ action: 'operations.triage', description: 'Triage Operations errors' },
|
|
32
35
|
{ action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },
|
|
@@ -248,6 +248,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
|
|
|
248
248
|
admission: null,
|
|
249
249
|
oauth: null,
|
|
250
250
|
},
|
|
251
|
+
{
|
|
252
|
+
id: 'mail-relay',
|
|
253
|
+
kind: 'protocol',
|
|
254
|
+
method: 'POST',
|
|
255
|
+
path: '/platform/mail/*',
|
|
256
|
+
callers: [],
|
|
257
|
+
requirement: null,
|
|
258
|
+
admission: null,
|
|
259
|
+
oauth: null,
|
|
260
|
+
},
|
|
251
261
|
{
|
|
252
262
|
id: 'webhooks-root',
|
|
253
263
|
kind: 'service',
|
|
@@ -433,6 +443,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
|
|
|
433
443
|
admission: null,
|
|
434
444
|
oauth: null,
|
|
435
445
|
},
|
|
446
|
+
{
|
|
447
|
+
id: 'mail-relay',
|
|
448
|
+
kind: 'protocol',
|
|
449
|
+
method: 'POST',
|
|
450
|
+
path: '/platform/mail/*',
|
|
451
|
+
callers: [],
|
|
452
|
+
requirement: null,
|
|
453
|
+
admission: null,
|
|
454
|
+
oauth: null,
|
|
455
|
+
},
|
|
436
456
|
{
|
|
437
457
|
id: 'webhooks-root',
|
|
438
458
|
kind: 'service',
|
|
@@ -216,7 +216,11 @@ const iam: ZeropsYamlSetupSpec = {
|
|
|
216
216
|
// The Cloudflare `scheduled` handler's replacement, running the same code path. `allContainers:
|
|
217
217
|
// false` so a horizontally scaled service still prunes once.
|
|
218
218
|
crontab: [{ timing: '0 3 * * *', command: 'bun packages/iam/src/node/prune.ts', allContainers: false }],
|
|
219
|
-
envVariables: {
|
|
219
|
+
envVariables: {
|
|
220
|
+
PORT: '3000',
|
|
221
|
+
// Control's private origin: with a provider configured, IAM hands its mail to Control's relay.
|
|
222
|
+
FABRIKA_CONTROL_URL: 'http://control:3000',
|
|
223
|
+
},
|
|
220
224
|
},
|
|
221
225
|
}
|
|
222
226
|
|
package/src/managed-env.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
isConnectUrlVariable,
|
|
7
7
|
isEmailTransportVariable,
|
|
8
8
|
isFrameworkVariableName,
|
|
9
|
+
isPlatformTransportVariable,
|
|
9
10
|
isWorkloadKeyVariable,
|
|
11
|
+
PLATFORM_RECEIVERS,
|
|
12
|
+
platformTransportVariables,
|
|
10
13
|
} from '@fabrika/auth-core'
|
|
11
14
|
import type { JsonValue } from '@fabrika/provider-contract'
|
|
12
15
|
import { z } from 'zod'
|
|
@@ -32,6 +35,8 @@ export type ManagedKey = Extract<
|
|
|
32
35
|
| 'FABRIKA_PROXY_SECRET'
|
|
33
36
|
| 'FABRIKA_IAM_RPC_URL'
|
|
34
37
|
| 'FABRIKA_IAM_RPC_KEY'
|
|
38
|
+
| 'FABRIKA_APP_ORIGIN'
|
|
39
|
+
| 'FABRIKA_TOKEN_KEYS'
|
|
35
40
|
>
|
|
36
41
|
|
|
37
42
|
export const MANAGED_KEYS: readonly ManagedKey[] = [
|
|
@@ -45,6 +50,8 @@ export const MANAGED_KEYS: readonly ManagedKey[] = [
|
|
|
45
50
|
'FABRIKA_PROXY_SECRET',
|
|
46
51
|
'FABRIKA_IAM_RPC_URL',
|
|
47
52
|
'FABRIKA_IAM_RPC_KEY',
|
|
53
|
+
'FABRIKA_APP_ORIGIN',
|
|
54
|
+
'FABRIKA_TOKEN_KEYS',
|
|
48
55
|
]
|
|
49
56
|
|
|
50
57
|
/** The managed keys written as service variables; `FABRIKA_RELEASE` travels only in the execution descriptor (ADR-0044). */
|
|
@@ -58,6 +65,8 @@ export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE
|
|
|
58
65
|
'FABRIKA_PROXY_SECRET',
|
|
59
66
|
'FABRIKA_IAM_RPC_URL',
|
|
60
67
|
'FABRIKA_IAM_RPC_KEY',
|
|
68
|
+
'FABRIKA_APP_ORIGIN',
|
|
69
|
+
'FABRIKA_TOKEN_KEYS',
|
|
61
70
|
]
|
|
62
71
|
|
|
63
72
|
/** The managed keys that carry a credential, written sensitive and therefore never compared. */
|
|
@@ -66,10 +75,16 @@ export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKe
|
|
|
66
75
|
'FABRIKA_SERVICE_KEY',
|
|
67
76
|
'FABRIKA_PROXY_SECRET',
|
|
68
77
|
'FABRIKA_IAM_RPC_KEY',
|
|
78
|
+
'FABRIKA_TOKEN_KEYS',
|
|
69
79
|
])
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
const PLATFORM_URL_VARIABLES: ReadonlySet<string> = new Set(PLATFORM_RECEIVERS.map((receiver) => platformTransportVariables(receiver).url))
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A workload, connect or platform transport key and every mail transport variable are written sensitive; a connect or
|
|
85
|
+
* platform transport URL is an origin.
|
|
86
|
+
*/
|
|
87
|
+
export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name) && !PLATFORM_URL_VARIABLES.has(name)
|
|
73
88
|
|
|
74
89
|
export type ManagedValues = Record<ManagedKey, string | null>
|
|
75
90
|
|
|
@@ -85,36 +100,45 @@ const managedValuesSchema = z.object({
|
|
|
85
100
|
// IAM's membership transport (ADR-0022 rule 6): its public origin and this environment's key, or both null.
|
|
86
101
|
FABRIKA_IAM_RPC_URL: z.string().nullable(),
|
|
87
102
|
FABRIKA_IAM_RPC_KEY: z.string().nullable(),
|
|
103
|
+
// The environment's canonical origin (`ctx.url`), or null when it has no public origin.
|
|
104
|
+
FABRIKA_APP_ORIGIN: z.string().nullable(),
|
|
105
|
+
// The keys of the application's signed tokens, or null when its contract declares no token purpose.
|
|
106
|
+
FABRIKA_TOKEN_KEYS: z.string().nullable(),
|
|
88
107
|
}).strict()
|
|
89
108
|
|
|
90
109
|
export function parseManagedValues(value: unknown): ManagedValues {
|
|
91
110
|
const result = managedValuesSchema.safeParse(value)
|
|
92
|
-
if (!result.success) throw new Error('zerops: managed environment must name exactly the
|
|
111
|
+
if (!result.success) throw new Error('zerops: managed environment must name exactly the twelve managed keys')
|
|
93
112
|
return result.data
|
|
94
113
|
}
|
|
95
114
|
|
|
96
115
|
/**
|
|
97
|
-
* What Control hands a deploy: the
|
|
116
|
+
* What Control hands a deploy: the twelve managed keys, the key of each job and webhook of the approved contract, the
|
|
98
117
|
* key and owner origin of each connector import, and the installation's mail transport when a job sends through it.
|
|
99
118
|
*/
|
|
100
119
|
export interface ManagedEnvironment {
|
|
101
120
|
readonly values: ManagedValues
|
|
102
121
|
/**
|
|
103
|
-
* `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`,
|
|
104
|
-
* `FABRIKA_EMAIL_*` of the installation's mail transport
|
|
122
|
+
* `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`, each
|
|
123
|
+
* `FABRIKA_EMAIL_*` of the installation's mail transport, and `FABRIKA_PLATFORM_<RECEIVER>_URL` / `_KEY`.
|
|
105
124
|
*/
|
|
106
125
|
readonly familyVariables: Readonly<Record<string, string>>
|
|
107
126
|
}
|
|
108
127
|
|
|
109
128
|
const keySchema = z.string().startsWith('px_').min(4)
|
|
110
129
|
const mailValueSchema = z.string().min(1)
|
|
111
|
-
const originSchema =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
const originSchema = (schemes: readonly string[]) =>
|
|
131
|
+
z.string().refine((value) => {
|
|
132
|
+
try {
|
|
133
|
+
const url = new URL(value)
|
|
134
|
+
return url.origin === value && schemes.includes(url.protocol)
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
const connectOriginSchema = originSchema(['https:'])
|
|
140
|
+
// Control's public origin is http in the local stack (`http://control.fabrika.localhost:18080`).
|
|
141
|
+
const platformOriginSchema = originSchema(['http:', 'https:'])
|
|
118
142
|
|
|
119
143
|
export function parseManagedEnvironment(value: Readonly<Record<string, string | null>>): ManagedEnvironment {
|
|
120
144
|
const managed: Record<string, string | null> = {}
|
|
@@ -130,11 +154,18 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
|
|
|
130
154
|
familyVariables[name] = parsed.data
|
|
131
155
|
continue
|
|
132
156
|
}
|
|
157
|
+
if (isPlatformTransportVariable(name)) {
|
|
158
|
+
const address = PLATFORM_URL_VARIABLES.has(name)
|
|
159
|
+
const parsed = (address ? platformOriginSchema : keySchema).safeParse(entry)
|
|
160
|
+
if (!parsed.success) throw new Error(`zerops: ${name} must be ${address ? 'an http(s) origin' : 'a px_ key'}`)
|
|
161
|
+
familyVariables[name] = parsed.data
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
133
164
|
const origin = isConnectUrlVariable(name)
|
|
134
165
|
if (!origin && !isWorkloadKeyVariable(name) && !isConnectKeyVariable(name)) {
|
|
135
166
|
throw new Error(`zerops: ${name} is not a variable a deploy writes`)
|
|
136
167
|
}
|
|
137
|
-
const parsed = (origin ?
|
|
168
|
+
const parsed = (origin ? connectOriginSchema : keySchema).safeParse(entry)
|
|
138
169
|
if (!parsed.success) {
|
|
139
170
|
throw new Error(`zerops: ${name} must be ${origin ? 'an https origin' : 'a px_ key'}`)
|
|
140
171
|
}
|
|
@@ -144,8 +175,8 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
|
|
|
144
175
|
}
|
|
145
176
|
|
|
146
177
|
/**
|
|
147
|
-
* A service variable only the platform writes per workload, import or the installation's mail
|
|
148
|
-
* removed when the deploy no longer hands it.
|
|
178
|
+
* A service variable only the platform writes per workload, import, platform receiver or the installation's mail
|
|
179
|
+
* transport: a stale one is removed when the deploy no longer hands it.
|
|
149
180
|
*/
|
|
150
181
|
export function isFrameworkFamilyVariable(name: string): boolean {
|
|
151
182
|
return FRAMEWORK_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
|
package/src/manifest.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appSchemaSchema } from '@fabrika/auth'
|
|
2
|
-
import { type AppSchema, type ContractArtifact, isDigest } from '@fabrika/auth-core'
|
|
2
|
+
import { type AppSchema, type ContractArtifact, contractDigest, isDigest } from '@fabrika/auth-core'
|
|
3
3
|
import type { OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
|
|
4
4
|
import type { JsonValue, ProviderCodec, ProviderDeployInput, ProviderEnvironment } from '@fabrika/provider-contract'
|
|
5
5
|
import { proxyEndpointRowSchema } from '@fabrika/proxy-core/manifest'
|
|
@@ -9,6 +9,7 @@ import { assertOwnRunWhenExtending, executionZeropsDocument, parseZeropsDescript
|
|
|
9
9
|
import {
|
|
10
10
|
assertScanMemory,
|
|
11
11
|
CONTRACT_DIGEST_PATTERN,
|
|
12
|
+
FABRIKA_CHECK_VERSION,
|
|
12
13
|
LOCKED_APP_ID_PATTERN,
|
|
13
14
|
lockedAuthoredBuild,
|
|
14
15
|
ZEROPS_RUNTIME_PACKAGES,
|
|
@@ -456,13 +457,17 @@ const applicationStorageHostname = (services: FabrikaManifestImportDocument['ser
|
|
|
456
457
|
return own[0] ?? null
|
|
457
458
|
}
|
|
458
459
|
|
|
459
|
-
/**
|
|
460
|
+
/**
|
|
461
|
+
* What the platform renders a contract application's execution descriptor from, or null for any other application.
|
|
462
|
+
* The manifest is the one this release's `fabrika app build` wrote, so its build checks with this release.
|
|
463
|
+
*/
|
|
460
464
|
export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecution | null => {
|
|
461
465
|
const { contract } = manifest.target
|
|
462
466
|
if (contract === undefined) return null
|
|
463
467
|
return {
|
|
464
468
|
appId: manifest.app.id,
|
|
465
469
|
contract,
|
|
470
|
+
checkRelease: FABRIKA_CHECK_VERSION,
|
|
466
471
|
databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
|
|
467
472
|
databaseConnections: null,
|
|
468
473
|
storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
|
|
@@ -490,11 +495,12 @@ export function lockedExecutionFor(
|
|
|
490
495
|
throw new Error('zerops: Control approved no access for this environment')
|
|
491
496
|
}
|
|
492
497
|
if (!LOCKED_APP_ID_PATTERN.test(manifest.app.id)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
|
|
493
|
-
const contract = zeropsContractRun(access.contract)
|
|
498
|
+
const contract = zeropsContractRun(access.contract, access.contractDigest)
|
|
494
499
|
assertScanMemory(manifest.target.importDocument.services, manifest.target.deployService, contract)
|
|
495
500
|
return {
|
|
496
501
|
appId: manifest.app.id,
|
|
497
502
|
contract,
|
|
503
|
+
checkRelease: access.checker,
|
|
498
504
|
databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
|
|
499
505
|
databaseConnections: null,
|
|
500
506
|
storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
|
|
@@ -658,7 +664,9 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sou
|
|
|
658
664
|
zeropsSetup: config.target.zeropsSetup,
|
|
659
665
|
proxy: config.target.proxy,
|
|
660
666
|
namespaceResources: config.target.namespaceResources,
|
|
661
|
-
contract: config.target.contract === undefined
|
|
667
|
+
contract: config.target.contract === undefined
|
|
668
|
+
? undefined
|
|
669
|
+
: zeropsContractRun(config.target.contract, contractDigest(config.target.contract)),
|
|
662
670
|
runtimePackages: config.target.runtimePackages,
|
|
663
671
|
},
|
|
664
672
|
}
|
|
@@ -47,7 +47,7 @@ export function compileNamespaceProxyManifest(namespaceId: string, members: read
|
|
|
47
47
|
// A Zerops public domain is always served over the project's TLS-terminating L7 balancer, so the
|
|
48
48
|
// browser speaks https even though the proxy itself is reached over plain HTTP (ADR-0022).
|
|
49
49
|
scheme: 'https',
|
|
50
|
-
...contractProxyTable(access.contract),
|
|
50
|
+
...contractProxyTable(access.contract, access.contractDigest),
|
|
51
51
|
...(operationsOrigin === undefined ? {} : { operationsOrigin }),
|
|
52
52
|
...(crossOrigins.length === 0 ? {} : { crossOrigins: [...crossOrigins] }),
|
|
53
53
|
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What a namespace proxy pinned to an older source cannot read. Its manifest parser is strict: one row of a form it does
|
|
2
|
+
// not know refuses the whole manifest, and every member of the namespace loses its proxy at the next republish. So a
|
|
3
|
+
// deploy that would publish such a row is refused while the namespace's proxy source is not this control plane's own.
|
|
4
|
+
|
|
5
|
+
import type { ProviderApprovedAccess } from '@fabrika/provider-contract'
|
|
6
|
+
import { contractProxyTable, type ProxyEndpointRow } from '@fabrika/proxy-core/manifest'
|
|
7
|
+
import type { ZeropsNamespaceProxySource } from './types'
|
|
8
|
+
|
|
9
|
+
/** Whether the namespace proxy of one older platform release parses a manifest row: that release's own row parser. */
|
|
10
|
+
export type OlderProxyRowReader = (row: ProxyEndpointRow) => boolean
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The namespace proxies of the older releases this platform serves (ADR-0062). Control holds their parsers, as it holds
|
|
14
|
+
* their contract readers; a pin's release is not recorded, so a row any of them reads is one the pin may read.
|
|
15
|
+
*/
|
|
16
|
+
export type OlderProxyRowReaders = readonly [OlderProxyRowReader, ...OlderProxyRowReader[]]
|
|
17
|
+
|
|
18
|
+
/** The rows of the approved contract's proxy table that no older release's proxy parses, as the namespace publishes them. */
|
|
19
|
+
export function rowsNeedingCurrentProxy(access: ProviderApprovedAccess, olderProxies: OlderProxyRowReaders): string[] {
|
|
20
|
+
return contractProxyTable(access.contract, access.contractDigest).endpoints
|
|
21
|
+
.filter((row) => !olderProxies.some((reads) => reads(row)))
|
|
22
|
+
.map((row) => row.id)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** This control plane's own proxy source, and the parsers of the older releases a pinned proxy may run. */
|
|
26
|
+
export interface PlatformProxies {
|
|
27
|
+
readonly proxySource: ZeropsNamespaceProxySource
|
|
28
|
+
readonly olderProxies: OlderProxyRowReaders
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Refuses a deploy whose approved contract holds a row the namespace's pinned proxy may not read: its source is not the
|
|
33
|
+
* one this control plane deploys new placements from. `platform` is absent where this control plane manages no
|
|
34
|
+
* namespace proxy, which then has nothing to compare.
|
|
35
|
+
*/
|
|
36
|
+
export function assertNamespaceProxyReads(
|
|
37
|
+
namespace: string,
|
|
38
|
+
access: ProviderApprovedAccess | undefined,
|
|
39
|
+
pinned: ZeropsNamespaceProxySource,
|
|
40
|
+
platform: PlatformProxies | undefined,
|
|
41
|
+
): void {
|
|
42
|
+
if (access === undefined || platform === undefined || pinned.commitSha === platform.proxySource.commitSha) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const rows = rowsNeedingCurrentProxy(access, platform.olderProxies)
|
|
46
|
+
if (rows.length === 0) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
throw new Error(
|
|
50
|
+
`the proxy of deployment namespace \`${namespace}\` is pinned to an older source than this platform's, and no older release's proxy reads ${
|
|
51
|
+
rows.map((row) => `\`${row}\``).join(', ')
|
|
52
|
+
}; re-pin it with \`fabrika control namespaces update --namespace=${namespace} --proxy-source=current\`, then deploy again`,
|
|
53
|
+
)
|
|
54
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -48,7 +48,6 @@ import {
|
|
|
48
48
|
zeropsProcessConnections,
|
|
49
49
|
} from './database-budget'
|
|
50
50
|
import {
|
|
51
|
-
FABRIKA_CHECK_VERSION,
|
|
52
51
|
PLATFORM_TYPESCRIPT_VERSION,
|
|
53
52
|
renderLockedExecutionDescriptor,
|
|
54
53
|
zeropsJobProcesses,
|
|
@@ -581,7 +580,7 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
|
|
|
581
580
|
)
|
|
582
581
|
}
|
|
583
582
|
|
|
584
|
-
/** Write the
|
|
583
|
+
/** Write the eleven service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
|
|
585
584
|
const writeServiceEnv = async (env: StepEnv): Promise<void> => {
|
|
586
585
|
const { zerops, log, signal, managedValues, familyVariables } = env
|
|
587
586
|
const serviceId = env.run.target.serviceId
|
|
@@ -930,7 +929,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
|
|
|
930
929
|
? overlayRelease(contents, artifact.target.zeropsSetup, release)
|
|
931
930
|
: renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, sizedExecution(locked, attempt))
|
|
932
931
|
if (locked !== null) {
|
|
933
|
-
log(` generated run section; build checks @fabrika/check@${
|
|
932
|
+
log(` generated run section; build checks @fabrika/check@${locked.checkRelease}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
|
|
934
933
|
if (locked.sourceMaps !== null) log(` the build uploads the source maps of ${locked.sourceMaps.assetRoots.length} asset root(s)`)
|
|
935
934
|
}
|
|
936
935
|
assertRunning(signal)
|