@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
package/src/exposure.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// What a deploy reads back before it lets Zerops activate a version (ADR-0043). An import cannot turn subdomain
|
|
2
|
+
// access on, but an earlier enable survives it, and Zerops has no API to read a bucket policy back. So every declared
|
|
3
|
+
// service must read `subdomainAccess: false`, and every bucket must be one the platform created private.
|
|
4
|
+
|
|
5
|
+
import type { ZeropsImportResult } from './api'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The provenance marker: a service variable on the deploy service listing the object-storage service ids the platform
|
|
9
|
+
* created, each from an import that carried `objectStoragePolicy: private`. A bucket adopted by hostname has none.
|
|
10
|
+
*/
|
|
11
|
+
export const STORAGE_PROVENANCE_KEY = 'FABRIKA_STORAGE_PROVENANCE'
|
|
12
|
+
|
|
13
|
+
const OBJECT_STORAGE_TYPES: ReadonlySet<string> = new Set(['object-storage', 'objectstorage'])
|
|
14
|
+
|
|
15
|
+
export const isObjectStorageType = (type: string): boolean => OBJECT_STORAGE_TYPES.has(type)
|
|
16
|
+
|
|
17
|
+
/** The action of the process an import starts for a service it creates (verified live, 2026-10-02). */
|
|
18
|
+
const CREATE_ACTION = 'stack.create'
|
|
19
|
+
|
|
20
|
+
export const parseStorageProvenance = (value: string | undefined): ReadonlySet<string> =>
|
|
21
|
+
new Set((value ?? '').split(',').filter((entry) => entry !== ''))
|
|
22
|
+
|
|
23
|
+
export const formatStorageProvenance = (ids: Iterable<string>): string => [...new Set(ids)].sort().join(',')
|
|
24
|
+
|
|
25
|
+
/** Hostname → service id of every service this import result reports creating. */
|
|
26
|
+
export const createdServices = (result: ZeropsImportResult): ReadonlyMap<string, string> =>
|
|
27
|
+
new Map(
|
|
28
|
+
result.services
|
|
29
|
+
.filter((service) => service.processes.some((process) => process.actionName === CREATE_ACTION))
|
|
30
|
+
.map((service) => [service.name, service.id]),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export interface ExposureFinding {
|
|
34
|
+
readonly hostname: string
|
|
35
|
+
readonly reason: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const formatExposureFindings = (findings: readonly ExposureFinding[]): string =>
|
|
39
|
+
`zerops: activation refused; ${findings.map((finding) => `service \`${finding.hostname}\` ${finding.reason}`).join('; ')}`
|
|
40
|
+
|
|
41
|
+
export interface ExposureAlert {
|
|
42
|
+
readonly appId: string
|
|
43
|
+
readonly env: string
|
|
44
|
+
readonly release: string | null
|
|
45
|
+
readonly findings: readonly ExposureFinding[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DSN_PUBLIC_KEY = /^[0-9a-f]{32}$/
|
|
49
|
+
const DSN_PROJECT = /^\/([1-9][0-9]{0,18})$/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Operations ingest request that raises the alert: an error event in the application's own Operations project,
|
|
53
|
+
* which its `new_issue` alert rules deliver. Null for a DSN of any other form. The public key is not a secret, but it
|
|
54
|
+
* travels only in this request.
|
|
55
|
+
*/
|
|
56
|
+
export function exposureAlertRequest(dsn: string, alert: ExposureAlert, eventId: string): { url: string; body: string } | null {
|
|
57
|
+
let url: URL
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(dsn)
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
const project = DSN_PROJECT.exec(url.pathname)?.[1]
|
|
64
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !DSN_PUBLIC_KEY.test(url.username) || url.password !== '' || project === undefined) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const endpoint = new URL(`/api/${project}/envelope/`, url.origin)
|
|
68
|
+
endpoint.searchParams.set('sentry_key', url.username)
|
|
69
|
+
const event = {
|
|
70
|
+
event_id: eventId,
|
|
71
|
+
level: 'fatal',
|
|
72
|
+
platform: 'other',
|
|
73
|
+
environment: alert.env,
|
|
74
|
+
...(alert.release === null ? {} : { release: alert.release }),
|
|
75
|
+
exception: { values: [{ type: 'FabrikaExposureRefused', value: formatExposureFindings(alert.findings) }] },
|
|
76
|
+
fingerprint: ['fabrika-exposure-refused', alert.appId, alert.env, ...alert.findings.map((finding) => finding.hostname).sort()],
|
|
77
|
+
}
|
|
78
|
+
const body = [JSON.stringify({ event_id: eventId }), JSON.stringify({ type: 'event' }), JSON.stringify(event)].join('\n')
|
|
79
|
+
return { url: endpoint.toString(), body: `${body}\n` }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ExposureAlerter = (input: { readonly dsn: string; readonly alert: ExposureAlert; readonly signal: AbortSignal }) => Promise<void>
|
|
83
|
+
|
|
84
|
+
/** Send the alert. The caller treats a failure as non-fatal: the refusal stands either way. */
|
|
85
|
+
export const sendExposureAlert: ExposureAlerter = async ({ dsn, alert, signal }) => {
|
|
86
|
+
const request = exposureAlertRequest(dsn, alert, crypto.randomUUID().replaceAll('-', ''))
|
|
87
|
+
if (request === null) throw new Error('zerops: the Operations DSN has an unexpected form')
|
|
88
|
+
const response = await fetch(request.url, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'content-type': 'application/x-sentry-envelope' },
|
|
91
|
+
body: request.body,
|
|
92
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
|
|
93
|
+
})
|
|
94
|
+
if (!response.ok) throw new Error(`zerops: the Operations ingest answered ${response.status}`)
|
|
95
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// error naming both the flag and the variable that would supply it.
|
|
14
14
|
|
|
15
15
|
import { describeArgument, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
|
|
16
|
+
import { type McpClient, parseMcpClientsJson } from './mcp'
|
|
16
17
|
import { parsePlatformHosts, parsePlatformScheme } from './placement-options'
|
|
17
18
|
import { readInstallationSourceDir } from './source-options'
|
|
18
19
|
|
|
@@ -35,6 +36,8 @@ export interface PlatformDeployInput {
|
|
|
35
36
|
/** The scheme a browser speaks to this installation. `https` unless a local composition says otherwise. */
|
|
36
37
|
readonly scheme: 'http' | 'https'
|
|
37
38
|
readonly sourceDir: string
|
|
39
|
+
/** The MCP clients this installation trusts. Absent leaves MCP authoring off (ADR-0049). */
|
|
40
|
+
readonly mcpClients?: readonly McpClient[]
|
|
38
41
|
/** IAM-issued `px_` admin key, used only to reconcile the console's schema. Environment only. */
|
|
39
42
|
readonly iamAdminKey: string
|
|
40
43
|
/** Read everything, write nothing, trigger nothing. */
|
|
@@ -51,6 +54,7 @@ const FLAGS = [
|
|
|
51
54
|
'--operations-host',
|
|
52
55
|
'--scheme',
|
|
53
56
|
'--source-dir',
|
|
57
|
+
'--mcp-clients',
|
|
54
58
|
] as const
|
|
55
59
|
|
|
56
60
|
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
@@ -109,6 +113,12 @@ export const parsePlatformDeployArgs = (
|
|
|
109
113
|
const dryRun = argv.includes('--dry-run')
|
|
110
114
|
const hosts = readHosts(argv, env)
|
|
111
115
|
const apiBaseUrl = readEnv(env, 'FABRIKA_ZEROPS_API_URL')
|
|
116
|
+
const mcpClients = setting(argv, env, '--mcp-clients', 'FABRIKA_PLATFORM_MCP_CLIENTS_JSON')
|
|
117
|
+
const scheme = readScheme(argv, env)
|
|
118
|
+
// Control reads a bare `FABRIKA_CONTROL_DOMAIN` as https, so an http resource would never match its own.
|
|
119
|
+
if (mcpClients !== undefined && scheme === 'http') {
|
|
120
|
+
throw new Error('MCP clients need --scheme=https: an OAuth resource on plain http would not match the one Control serves')
|
|
121
|
+
}
|
|
112
122
|
return {
|
|
113
123
|
project: readProject(argv, env),
|
|
114
124
|
// A dry run reads, and reading still needs a token; it is the one credential it cannot do without.
|
|
@@ -118,10 +128,11 @@ export const parsePlatformDeployArgs = (
|
|
|
118
128
|
iamAdminKey: dryRun
|
|
119
129
|
? readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY') ?? ''
|
|
120
130
|
: requiredSetting(readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY'), '(no flag)', 'FABRIKA_IAM_PROVISIONING_KEY'),
|
|
121
|
-
scheme
|
|
131
|
+
scheme,
|
|
122
132
|
dryRun,
|
|
123
133
|
...(hosts === undefined ? {} : { hosts }),
|
|
124
134
|
sourceDir,
|
|
125
135
|
...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
|
|
136
|
+
...(mcpClients === undefined ? {} : { mcpClients: parseMcpClientsJson(mcpClients) }),
|
|
126
137
|
}
|
|
127
138
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// deploying control at a new version while the previous, more permissive manifest is still in
|
|
11
11
|
// front of it leaves `/api/*` open for the length of the deploy. The sequence is therefore
|
|
12
12
|
// (IAM + Operations) → **proxy** → control: the two that order nothing against each
|
|
13
|
-
// other built at once, with the enforcement point ahead of the service whose
|
|
13
|
+
// other built at once, with the enforcement point ahead of the service whose table just widened.
|
|
14
14
|
// `__tests__/deploy.test.ts` pins it.
|
|
15
15
|
//
|
|
16
16
|
// Bringing `fabrika-test` to HEAD on 2026-08-05 took `zops push` from a laptop, once per service, in a
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
// "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
|
|
35
35
|
// readiness gate.
|
|
36
36
|
|
|
37
|
-
import { readEnvironmentName } from '@fabrika/auth-core'
|
|
37
|
+
import { FABRIKA_SERVICE_TABLE_DIGEST, readEnvironmentName } from '@fabrika/auth-core'
|
|
38
38
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
39
39
|
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
|
|
40
40
|
import {
|
|
@@ -62,9 +62,10 @@ import {
|
|
|
62
62
|
} from './installation-source'
|
|
63
63
|
import type { DeployLog } from './log'
|
|
64
64
|
import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
|
|
65
|
+
import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
|
|
65
66
|
import type { PreparedPlatformSource } from './platform-source'
|
|
66
67
|
import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
|
|
67
|
-
import { platformProxyAppFor, resolvePlatformProxyManifest } from './proxy-manifest'
|
|
68
|
+
import { platformProxyAppFor, type PlatformProxyComposition, platformServiceTableDigest, resolvePlatformProxyManifest } from './proxy-manifest'
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
71
|
* The four services this command touches, in the order it RESOLVES and WRITES them.
|
|
@@ -93,7 +94,7 @@ export type PlatformDeployService = (typeof PLATFORM_DEPLOY_ORDER)[number]
|
|
|
93
94
|
*
|
|
94
95
|
* What is NOT arbitrary stays: every environment write happens before any of these starts, the proxy
|
|
95
96
|
* is deployed after both, and control after the proxy (ADR-0022 — the enforcement point ahead of
|
|
96
|
-
* the service whose
|
|
97
|
+
* the service whose table just widened). `deploy-order.test.ts` in `@fabrika/platform-artifacts` and
|
|
97
98
|
* `__tests__/deploy.test.ts` fail if that sequence changes.
|
|
98
99
|
*/
|
|
99
100
|
export const PLATFORM_CONCURRENT_DEPLOY = ['iam', 'operations'] as const
|
|
@@ -268,29 +269,36 @@ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: ReadonlyMap<stri
|
|
|
268
269
|
return { hosts: derivePlatformHosts(subdomains, listeners), scheme: input.scheme, zeropsSubdomain: true }
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
|
|
273
|
+
|
|
271
274
|
/**
|
|
272
275
|
* The per-installation configuration each service needs, derived from the resolved hosts.
|
|
273
276
|
*
|
|
274
277
|
* The namespace source default comes from the verified snapshot. Values are compared but never logged.
|
|
278
|
+
* IAM's OAuth configuration is written only when MCP authoring is enabled; without it the variable is
|
|
279
|
+
* left as it is, and the manifest — whose `/mcp` row then admits no OAuth token — is what turns MCP off.
|
|
275
280
|
*/
|
|
276
281
|
const desiredServiceEnv = (
|
|
277
282
|
environment: string,
|
|
278
283
|
placement: PlatformPlacement,
|
|
279
284
|
manifestJson: string,
|
|
280
285
|
proxySource: ZeropsNamespaceProxySource,
|
|
286
|
+
oauthConfig: IamOAuthConfig | undefined,
|
|
287
|
+
controlTableDigest: string,
|
|
281
288
|
): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
|
|
282
289
|
const iamOrigin = platformOrigin(placement.scheme, placement.hosts.iam)
|
|
283
290
|
const operationsOrigin = platformOrigin(placement.scheme, placement.hosts.operations)
|
|
291
|
+
const iam = new Map([
|
|
292
|
+
['ENVIRONMENT', environment],
|
|
293
|
+
// The `iss` of every minted token AND the OIDC redirect base, so it must be the address
|
|
294
|
+
// routed to the proxy in front of this service.
|
|
295
|
+
['ISSUER', iamOrigin],
|
|
296
|
+
])
|
|
297
|
+
if (oauthConfig !== undefined) {
|
|
298
|
+
iam.set(IAM_OAUTH_CONFIG, JSON.stringify(oauthConfig))
|
|
299
|
+
}
|
|
284
300
|
return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
|
|
285
|
-
[
|
|
286
|
-
'iam',
|
|
287
|
-
new Map([
|
|
288
|
-
['ENVIRONMENT', environment],
|
|
289
|
-
// The `iss` of every minted token AND the OIDC redirect base, so it must be the address
|
|
290
|
-
// routed to the proxy in front of this service.
|
|
291
|
-
['ISSUER', iamOrigin],
|
|
292
|
-
]),
|
|
293
|
-
],
|
|
301
|
+
['iam', iam],
|
|
294
302
|
[
|
|
295
303
|
'operations',
|
|
296
304
|
new Map([
|
|
@@ -316,9 +324,11 @@ const desiredServiceEnv = (
|
|
|
316
324
|
[FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(proxySource)],
|
|
317
325
|
['ENVIRONMENT', environment],
|
|
318
326
|
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
319
|
-
// A bare host: `controlPublicOrigin` accepts either spelling
|
|
320
|
-
// same-origin check on both first-party gateways is decided against.
|
|
327
|
+
// A bare host: `controlPublicOrigin` accepts either spelling.
|
|
321
328
|
['FABRIKA_CONTROL_DOMAIN', placement.hosts.control],
|
|
329
|
+
// The digest of the console table the proxy above publishes: Control picks its own table, MCP or not,
|
|
330
|
+
// by it, and refuses to start under another, so the two cannot disagree at runtime.
|
|
331
|
+
[FABRIKA_SERVICE_TABLE_DIGEST, controlTableDigest],
|
|
322
332
|
['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
|
|
323
333
|
// The public IAM origin an APPLICATION project's proxy reaches — there is no private
|
|
324
334
|
// network between projects (ADR-0006).
|
|
@@ -589,14 +599,24 @@ export const deployPreparedPlatform = async (
|
|
|
589
599
|
log.info(`iam ${placement.hosts.iam} · operations ${placement.hosts.operations}`)
|
|
590
600
|
|
|
591
601
|
log.step('Compose the proxy manifest')
|
|
592
|
-
const
|
|
602
|
+
const composition: PlatformProxyComposition = {
|
|
593
603
|
scheme: placement.scheme,
|
|
594
604
|
placement: {
|
|
595
605
|
iam: { hosts: [placement.hosts.iam] },
|
|
596
606
|
control: { hosts: [placement.hosts.control] },
|
|
597
607
|
operations: { hosts: [placement.hosts.operations] },
|
|
598
608
|
},
|
|
599
|
-
|
|
609
|
+
...(input.mcpClients === undefined ? {} : { authorizationServer: platformOrigin(placement.scheme, placement.hosts.iam) }),
|
|
610
|
+
}
|
|
611
|
+
const platformManifest = resolvePlatformProxyManifest(PLATFORM_PROXY_MANIFEST_TEMPLATE, composition)
|
|
612
|
+
const oauthConfig = input.mcpClients === undefined
|
|
613
|
+
? undefined
|
|
614
|
+
: platformIamOAuthConfig(PLATFORM_PROXY_MANIFEST_TEMPLATE, composition, input.mcpClients)
|
|
615
|
+
log.info(
|
|
616
|
+
oauthConfig === undefined
|
|
617
|
+
? 'MCP authoring: off'
|
|
618
|
+
: `MCP authoring: on, ${oauthConfig.clients.length} client(s), resource(s) ${oauthConfig.resources.map((entry) => entry.resource).join(', ')}`,
|
|
619
|
+
)
|
|
600
620
|
const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
|
|
601
621
|
log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
|
|
602
622
|
if (merged.carried.length > 0) {
|
|
@@ -610,11 +630,15 @@ export const deployPreparedPlatform = async (
|
|
|
610
630
|
}
|
|
611
631
|
|
|
612
632
|
log.step('Write the service environment')
|
|
613
|
-
const
|
|
633
|
+
const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
|
|
634
|
+
const desired = desiredServiceEnv(environment, placement, encodeProxyManifestJson(merged.manifest), proxySource, oauthConfig, controlTableDigest)
|
|
614
635
|
for (const hostname of PLATFORM_DEPLOY_ORDER) {
|
|
615
636
|
const service = serviceOf(services, hostname)
|
|
616
637
|
const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
|
|
617
638
|
const keys = desired.get(hostname) ?? new Map<string, string>()
|
|
639
|
+
if (hostname === 'iam' && oauthConfig === undefined && (live.get(IAM_OAUTH_CONFIG) ?? '').trim() !== '') {
|
|
640
|
+
log.warn(`iam: MCP authoring is off but ${IAM_OAUTH_CONFIG} is still set; IAM keeps issuing MCP tokens until it is removed`)
|
|
641
|
+
}
|
|
618
642
|
const written = await applyServiceEnv(service, keys, live, input, collaborators)
|
|
619
643
|
log.info(
|
|
620
644
|
written.length === 0
|
|
@@ -22,6 +22,10 @@ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
|
|
|
22
22
|
{ action: 'namespace.manage', description: 'Manage deployment namespaces' },
|
|
23
23
|
{ action: 'secret.manage', description: 'Manage secret values + their references' },
|
|
24
24
|
{ action: 'source.connection.manage', description: 'Manage the platform GitHub source connection' },
|
|
25
|
+
{ action: 'source.workspace.read', description: 'Read own Git workspaces on an authoring target' },
|
|
26
|
+
{ action: 'source.workspace.write', description: 'Create and commit to own Git workspaces on an authoring target' },
|
|
27
|
+
{ action: 'source.pull-request.create', description: 'Open a pull request from an own Git workspace' },
|
|
28
|
+
{ action: 'access.approve', description: 'Approve an access change and protect an environment (a person in the console only)' },
|
|
25
29
|
{ action: 'operations.read', description: 'Read Operations errors and activity' },
|
|
26
30
|
{ action: 'operations.triage', description: 'Triage Operations errors' },
|
|
27
31
|
{ action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },
|