@fabrika/provider-zerops 0.4.2 → 0.4.3

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.
Files changed (45) hide show
  1. package/package.json +8 -7
  2. package/src/api/client.ts +11 -14
  3. package/src/api/polling.ts +42 -0
  4. package/src/api/readers.ts +9 -2
  5. package/src/api/service-env-limit.ts +29 -0
  6. package/src/api/service-env-response.ts +34 -2
  7. package/src/api/types.ts +39 -9
  8. package/src/api.ts +10 -1
  9. package/src/control.ts +23 -8
  10. package/src/execution.ts +41 -3
  11. package/src/index.ts +5 -0
  12. package/src/installation/admin.ts +4 -2
  13. package/src/installation/checker-release.ts +44 -0
  14. package/src/installation/deploy.ts +66 -22
  15. package/src/installation/generated/platform-console-schema.ts +18 -2
  16. package/src/installation/generated/platform-proxy-manifest.ts +20 -0
  17. package/src/installation/guide.md +164 -52
  18. package/src/installation/index.ts +22 -15
  19. package/src/installation/init-options.ts +2 -2
  20. package/src/installation/init.ts +8 -2
  21. package/src/installation/install-collaborators.ts +4 -0
  22. package/src/installation/install-environment.ts +52 -30
  23. package/src/installation/install-options.ts +20 -19
  24. package/src/installation/install-project.ts +23 -15
  25. package/src/installation/install-provisioning.ts +12 -12
  26. package/src/installation/install-secrets.ts +89 -0
  27. package/src/installation/install-token.ts +37 -0
  28. package/src/installation/install.ts +34 -21
  29. package/src/installation/machine-key.ts +3 -2
  30. package/src/installation/manifest.ts +29 -12
  31. package/src/installation/platform-archive.ts +87 -0
  32. package/src/installation/platform-upload.ts +2 -2
  33. package/src/installation/secrets.ts +3 -4
  34. package/src/installation/topology/setups.ts +15 -27
  35. package/src/installation/topology/topology.ts +105 -104
  36. package/src/managed-env.ts +11 -0
  37. package/src/manifest.ts +25 -3
  38. package/src/namespace/codec.ts +6 -1
  39. package/src/namespace/errors.ts +3 -1
  40. package/src/namespace/provisioning.ts +18 -5
  41. package/src/namespace/proxy-attempt.ts +56 -14
  42. package/src/namespace/update.ts +50 -8
  43. package/src/provider.ts +57 -13
  44. package/src/proxy-manifest-carrier.ts +32 -0
  45. package/src/source/port.ts +9 -1
@@ -35,10 +35,14 @@
35
35
  // readiness gate.
36
36
 
37
37
  import { FABRIKA_SERVICE_TABLE_DIGEST, readEnvironmentName } from '@fabrika/auth-core'
38
+ import { IAM_APP_ID, IAM_APP_SCHEMA } from '@fabrika/iam-contract'
38
39
  import type { SchemaReconciler } from '@fabrika/provider-contract'
39
40
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
41
+ import { assertServiceEnvValueFits } from '../api'
40
42
  import {
43
+ enableSubdomainAccessWhenHttp,
41
44
  prepareZeropsDescriptor,
45
+ readableContent,
42
46
  type Sleeper,
43
47
  ZEROPS_ACTIVE,
44
48
  ZEROPS_SERVICE_NOT_HTTP,
@@ -47,7 +51,10 @@ import {
47
51
  ZeropsApiError,
48
52
  type ZeropsNamespaceProxySource,
49
53
  type ZeropsService,
54
+ type ZeropsServiceEnv,
50
55
  } from '../index'
56
+ import { ZEROPS_PROXY_MANIFEST_PATH } from '../proxy-manifest-carrier'
57
+ import { assertCheckerPublished, type CheckerPublication } from './checker-release'
51
58
  import type { PlatformDeployInput } from './deploy-options'
52
59
  import { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
53
60
  import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
@@ -63,6 +70,7 @@ import {
63
70
  import type { DeployLog } from './log'
64
71
  import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
65
72
  import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
73
+ import { archiveWithDataFile } from './platform-archive'
66
74
  import type { PreparedPlatformSource } from './platform-source'
67
75
  import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
68
76
  import { platformProxyAppFor, type PlatformProxyComposition, platformServiceTableDigest, resolvePlatformProxyManifest } from './proxy-manifest'
@@ -139,6 +147,7 @@ export interface PlatformServiceDeployCollaborators {
139
147
  /** Everything `deployPlatform` reaches the outside world through. */
140
148
  export interface PlatformDeployCollaborators extends PlatformServiceDeployCollaborators {
141
149
  readonly prepareSource: PlatformSourcePreparer
150
+ readonly checkerPublished: CheckerPublication
142
151
  readonly reconcileSchema: SchemaReconciler
143
152
  }
144
153
 
@@ -146,6 +155,16 @@ export interface PlatformDeployCollaborators extends PlatformServiceDeployCollab
146
155
  export interface PlatformServiceDeployInput {
147
156
  readonly source: PreparedPlatformSource
148
157
  readonly dryRun: boolean
158
+ /** The proxy's manifest, which its archive carries as the one data file (ADR-0045); the proxy is refused without it. */
159
+ readonly proxyManifest?: string
160
+ }
161
+
162
+ /** The archive one service builds from: the snapshot, and for the proxy the snapshot plus its manifest. */
163
+ const serviceArchive = async (service: ResolvedService, input: PlatformServiceDeployInput): Promise<Blob> => {
164
+ const snapshot = Bun.file(input.source.archivePath)
165
+ if (service.hostname !== PLATFORM_PROXY_SERVICE) return snapshot
166
+ if (input.proxyManifest === undefined) throw new Error('the proxy is built only with the manifest its archive carries')
167
+ return new Blob([archiveWithDataFile(await snapshot.bytes(), { path: ZEROPS_PROXY_MANIFEST_PATH, contents: input.proxyManifest })])
149
168
  }
150
169
 
151
170
  /** One resolved platform service. */
@@ -237,13 +256,16 @@ export const serviceOf = (services: ReadonlyMap<PlatformDeployService, ResolvedS
237
256
  return service
238
257
  }
239
258
 
240
- /** Read one service's environment as a key → value map. Values are never logged. */
259
+ /** One service's environment by key. A sensitive record carries no value. Values are never logged. */
260
+ export type LiveServiceEnv = ReadonlyMap<string, ZeropsServiceEnv>
261
+
262
+ /** Read one service's environment by key. */
241
263
  export const readServiceEnv = async (
242
264
  serviceId: string,
243
265
  { api, signal }: Pick<PlatformServiceDeployCollaborators, 'api' | 'signal'>,
244
- ): Promise<Map<string, string>> => {
266
+ ): Promise<LiveServiceEnv> => {
245
267
  const variables = await api.listServiceEnv({ serviceId, signal })
246
- return new Map(variables.map((variable) => [variable.key, variable.content]))
268
+ return new Map(variables.map((variable) => [variable.key, variable]))
247
269
  }
248
270
 
249
271
  /**
@@ -254,11 +276,11 @@ export const readServiceEnv = async (
254
276
  * `custom-domain` installation, where the domains are bound to the project balancer out of band and
255
277
  * publishing a subdomain would add a public address nobody asked for.
256
278
  */
257
- const resolvePlacement = (input: PlatformDeployInput, proxyEnv: ReadonlyMap<string, string>): PlatformPlacement => {
279
+ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: LiveServiceEnv): PlatformPlacement => {
258
280
  if (input.hosts !== undefined) {
259
281
  return { hosts: input.hosts, scheme: input.scheme, zeropsSubdomain: false }
260
282
  }
261
- const subdomains = proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
283
+ const subdomains = readableContent(proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE))
262
284
  if (subdomains === undefined || subdomains.trim() === '') {
263
285
  throw new Error(
264
286
  'the proxy service publishes no Zerops subdomain — deploy it once so it has HTTP ports, or name every host '
@@ -281,7 +303,6 @@ const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
281
303
  const desiredServiceEnv = (
282
304
  environment: string,
283
305
  placement: PlatformPlacement,
284
- manifestJson: string,
285
306
  proxySource: ZeropsNamespaceProxySource,
286
307
  oauthConfig: IamOAuthConfig | undefined,
287
308
  controlTableDigest: string,
@@ -314,7 +335,6 @@ const desiredServiceEnv = (
314
335
  // Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
315
336
  // private `iam:3000` the manifest dials — `readProxyEnv` canonicalizes it to an origin.
316
337
  ['FABRIKA_IAM_ISSUER', iamOrigin],
317
- [FABRIKA_PROXY_MANIFEST_JSON, manifestJson],
318
338
  ['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
319
339
  ]),
320
340
  ],
@@ -338,25 +358,32 @@ const desiredServiceEnv = (
338
358
  ])
339
359
  }
340
360
 
341
- /** Write only what differs. Returns the KEYS written — never a value, not even in a debug line. */
361
+ /**
362
+ * Write only what differs. Returns the KEYS written — never a value, not even in a debug line.
363
+ *
364
+ * Nothing here is a credential, so everything is written readable. A record an older release wrote
365
+ * sensitive reads as different and is rewritten readable, which is what lets the next deploy compare it.
366
+ */
342
367
  const applyServiceEnv = async (
343
368
  service: ResolvedService,
344
369
  desired: ReadonlyMap<string, string>,
345
- live: ReadonlyMap<string, string>,
370
+ live: LiveServiceEnv,
346
371
  input: PlatformDeployInput,
347
372
  collaborators: PlatformDeployCollaborators,
348
373
  ): Promise<string[]> => {
349
374
  const written: string[] = []
350
375
  for (const [key, value] of desired) {
351
- if (live.get(key) === value) {
376
+ if (readableContent(live.get(key)) === value) {
352
377
  continue
353
378
  }
354
379
  written.push(key)
380
+ // The client refuses it too; checking here lets a dry run report what the real write would refuse.
381
+ assertServiceEnvValueFits(key, value)
355
382
  if (input.dryRun) {
356
383
  continue
357
384
  }
358
385
  assertRunning(collaborators.signal)
359
- await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, signal: collaborators.signal })
386
+ await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, sensitive: false, signal: collaborators.signal })
360
387
  }
361
388
  return written
362
389
  }
@@ -434,11 +461,12 @@ export const deployPlatformService = async (
434
461
  return
435
462
  }
436
463
  prepareZeropsDescriptor(input.source.descriptor.contents, service.hostname)
464
+ const archive = await serviceArchive(service, input)
437
465
  const reserved = await api.createAppVersion({ serviceId: service.id, name: input.source.commitSha, signal })
438
466
  const appVersionId = reserved.id
439
467
  log.info(`${service.hostname}: app-version ${appVersionId}, commit ${input.source.commitSha}, setup ${service.hostname}`)
440
468
  try {
441
- await uploadPlatformArchive(reserved.uploadUrl, input.source.archivePath, signal, collaborators.uploadFetch)
469
+ await uploadPlatformArchive(reserved.uploadUrl, archive, signal, collaborators.uploadFetch)
442
470
  assertRunning(signal)
443
471
  } catch {
444
472
  await cleanupReservation(service, appVersionId, collaborators)
@@ -502,7 +530,8 @@ const deployConcurrently = async (
502
530
  * The decision is the READ-BACK and never the call returning: on an already-published service the
503
531
  * platform answers 2xx and then fails the process it hands back, and a freshly-enabled one can still
504
532
  * read `false` on the next read. Both live-verified. Runs on every deploy, so a subdomain someone
505
- * turned off in the GUI comes back, and throws rather than leaving the installation unreachable.
533
+ * turned off in the GUI comes back, and throws rather than leaving the installation unreachable. The
534
+ * HTTP ports lag a fresh ACTIVE version, so `serviceStackIsNotHttp` is retried for a bounded window first.
506
535
  */
507
536
  const ensureSubdomainAccess = async (
508
537
  proxy: ResolvedService,
@@ -519,11 +548,11 @@ const ensureSubdomainAccess = async (
519
548
  return
520
549
  }
521
550
  try {
522
- await api.enableSubdomainAccess({ serviceId: proxy.id, signal })
551
+ await enableSubdomainAccessWhenHttp({ api, serviceId: proxy.id, sleep, signal })
523
552
  } catch (error) {
524
553
  if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
525
554
  throw new Error(
526
- `the proxy cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
555
+ `the proxy cannot be published on a zerops.app subdomain: it still exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP}) minutes after its deploy`,
527
556
  )
528
557
  }
529
558
  throw error
@@ -569,6 +598,7 @@ export const deployPlatform = async (
569
598
  const source = await collaborators.prepareSource(input.sourceDir, collaborators.signal)
570
599
  try {
571
600
  validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
601
+ await assertCheckerPublished(collaborators.checkerPublished, collaborators.signal)
572
602
  await deployPreparedPlatform(input, collaborators, source)
573
603
  } finally {
574
604
  await source.dispose()
@@ -617,6 +647,7 @@ export const deployPreparedPlatform = async (
617
647
  ? 'MCP authoring: off'
618
648
  : `MCP authoring: on, ${oauthConfig.clients.length} client(s), resource(s) ${oauthConfig.resources.map((entry) => entry.resource).join(', ')}`,
619
649
  )
650
+ // The variable is the application entries the platform proxy carries, held by the operator; nothing here writes it.
620
651
  const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
621
652
  log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
622
653
  if (merged.carried.length > 0) {
@@ -631,12 +662,14 @@ export const deployPreparedPlatform = async (
631
662
 
632
663
  log.step('Write the service environment')
633
664
  const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
634
- const desired = desiredServiceEnv(environment, placement, encodeProxyManifestJson(merged.manifest), proxySource, oauthConfig, controlTableDigest)
665
+ const desired = desiredServiceEnv(environment, placement, proxySource, oauthConfig, controlTableDigest)
635
666
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
636
667
  const service = serviceOf(services, hostname)
637
668
  const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
638
669
  const keys = desired.get(hostname) ?? new Map<string, string>()
639
- if (hostname === 'iam' && oauthConfig === undefined && (live.get(IAM_OAUTH_CONFIG) ?? '').trim() !== '') {
670
+ const liveOAuthConfig = live.get(IAM_OAUTH_CONFIG)
671
+ const oauthStillSet = liveOAuthConfig !== undefined && (liveOAuthConfig.sensitive || liveOAuthConfig.content.trim() !== '')
672
+ if (hostname === 'iam' && oauthConfig === undefined && oauthStillSet) {
640
673
  log.warn(`iam: MCP authoring is off but ${IAM_OAUTH_CONFIG} is still set; IAM keeps issuing MCP tokens until it is removed`)
641
674
  }
642
675
  const written = await applyServiceEnv(service, keys, live, input, collaborators)
@@ -647,11 +680,12 @@ export const deployPreparedPlatform = async (
647
680
  )
648
681
  }
649
682
 
650
- // EVERY deploy is after EVERY write. That is what makes the sequence fail closed: a manifest this
651
- // command could not apply leaves the previous manifest in front of the previous code, never in front
652
- // of new code — and `ENVIRONMENT` is in place before any service that refuses `local` restarts.
683
+ // EVERY deploy is after EVERY write, and the manifest rides in the proxy's own archive. That is what makes
684
+ // the sequence fail closed: a proxy build that does not go ACTIVE leaves the previous manifest in front of
685
+ // the previous code, because control builds only after it — and `ENVIRONMENT` is in place before any
686
+ // service that refuses `local` restarts.
653
687
  log.step(`Deploy ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')}`)
654
- const buildInput = { source, dryRun: input.dryRun }
688
+ const buildInput = { source, dryRun: input.dryRun, proxyManifest: encodeProxyManifestJson(merged.manifest) }
655
689
  await deployConcurrently(services, buildInput, collaborators)
656
690
  for (const hostname of PLATFORM_SEQUENTIAL_DEPLOY) {
657
691
  await deployPlatformService(serviceOf(services, hostname), buildInput, collaborators)
@@ -669,11 +703,12 @@ export const deployPreparedPlatform = async (
669
703
  log.info('custom-domain installation: the operator binds each domain to the project balancer; no subdomain is published')
670
704
  }
671
705
 
672
- log.step('Register the console with IAM')
706
+ log.step('Register the console and IAM with IAM')
673
707
  const consoleApp = platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control')
674
708
  const consoleOrigin = platformOrigin(placement.scheme, placement.hosts.control)
675
709
  if (input.dryRun) {
676
710
  log.info(`would reconcile \`${consoleApp.id}\` and register its return origin ${consoleOrigin}`)
711
+ log.info(`would reconcile \`${IAM_APP_ID}\`'s own vocabulary`)
677
712
  } else {
678
713
  // The schema PUT is what REGISTERS the app, and `apps.setReturnOrigins` 404s for an app IAM has
679
714
  // never heard of — so both travel in this one call, in that order (ADR-0022/ADR-0023).
@@ -686,6 +721,15 @@ export const deployPreparedPlatform = async (
686
721
  signal,
687
722
  })
688
723
  log.info(`\`${consoleApp.id}\` reconciled; return origin ${consoleOrigin}`)
724
+ // IAM's own vocabulary, which `user-admin` is granted from (ADR-0058). It has no return origin.
725
+ await collaborators.reconcileSchema({
726
+ url: platformOrigin(placement.scheme, placement.hosts.iam),
727
+ app: IAM_APP_ID,
728
+ schema: IAM_APP_SCHEMA,
729
+ adminKey: input.iamAdminKey,
730
+ signal,
731
+ })
732
+ log.info(`\`${IAM_APP_ID}\` reconciled`)
689
733
  }
690
734
 
691
735
  log.step(input.dryRun ? 'Dry run complete — nothing was written' : 'Installation deployed')
@@ -11,6 +11,7 @@
11
11
  import type { AppSchema } from '@fabrika/auth-core'
12
12
 
13
13
  export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
14
+ displayName: 'Console',
14
15
  scopes: [
15
16
  { type: 'app', label: 'App' },
16
17
  { type: 'environment', label: 'Environment' },
@@ -31,14 +32,29 @@ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
31
32
  { action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },
32
33
  ],
33
34
  roles: {
35
+ observer: {
36
+ name: 'Observer',
37
+ description: 'Read Operations errors and activity.',
38
+ permissions: ['operations.read'],
39
+ },
40
+ triager: {
41
+ name: 'Triager',
42
+ description: 'Read and triage Operations errors.',
43
+ permissions: ['operations.read', 'operations.triage'],
44
+ },
45
+ deployer: {
46
+ name: 'Deployer',
47
+ description: 'Trigger, read and cancel deploys (no registry, namespace or secret management).',
48
+ permissions: ['deploy.*'],
49
+ },
34
50
  operator: {
35
51
  name: 'Operator',
36
- description: 'Trigger and read any deploy (no registry or secret management).',
52
+ description: 'Deploy, and read, triage and manage Operations (no registry or secret management).',
37
53
  permissions: ['deploy.*', 'operations.*'],
38
54
  },
39
55
  admin: {
40
56
  name: 'Admin',
41
- description: 'Full access to every vozka action in every scope.',
57
+ description: 'Every console action in every scope.',
42
58
  permissions: ['*'],
43
59
  },
44
60
  },
@@ -278,6 +278,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
278
278
  admission: null,
279
279
  oauth: null,
280
280
  },
281
+ {
282
+ id: 'github-setup',
283
+ kind: 'service',
284
+ method: 'GET',
285
+ path: '/api/source/github/setup/:connectionId',
286
+ callers: ['human'],
287
+ requirement: { kind: 'authenticated' },
288
+ admission: null,
289
+ oauth: null,
290
+ },
281
291
  {
282
292
  id: 'rpc',
283
293
  kind: 'service',
@@ -453,6 +463,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
453
463
  admission: null,
454
464
  oauth: null,
455
465
  },
466
+ {
467
+ id: 'github-setup',
468
+ kind: 'service',
469
+ method: 'GET',
470
+ path: '/api/source/github/setup/:connectionId',
471
+ callers: ['human'],
472
+ requirement: { kind: 'authenticated' },
473
+ admission: null,
474
+ oauth: null,
475
+ },
456
476
  {
457
477
  id: 'rpc',
458
478
  kind: 'service',