@fabrika/provider-zerops 0.8.2 → 0.9.0-cloudflare.2

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 (128) hide show
  1. package/package.json +16 -14
  2. package/src/api/client.ts +6 -0
  3. package/src/api/types.ts +12 -0
  4. package/src/cli.ts +11 -0
  5. package/src/collaborators.ts +0 -14
  6. package/src/control.ts +33 -57
  7. package/src/execution.ts +113 -102
  8. package/src/installation/admin.ts +1 -1
  9. package/src/installation/deploy-options.ts +1 -2
  10. package/src/installation/deploy.ts +41 -75
  11. package/src/installation/generated/platform-console-schema.ts +3 -0
  12. package/src/installation/generated/platform-proxy-manifest.ts +21 -1
  13. package/src/installation/guide.md +9 -6
  14. package/src/installation/hosts.ts +1 -7
  15. package/src/installation/index.ts +0 -13
  16. package/src/installation/init-options.ts +2 -1
  17. package/src/installation/install-environment.ts +42 -106
  18. package/src/installation/install-options.ts +3 -1
  19. package/src/installation/install-secrets.ts +24 -68
  20. package/src/installation/install.ts +8 -4
  21. package/src/installation/sidecar.ts +5 -26
  22. package/src/installation/topology/setups.ts +5 -0
  23. package/src/installation/upgrade-options.ts +2 -2
  24. package/src/managed-env.ts +1 -174
  25. package/src/manifest.ts +11 -14
  26. package/src/namespace/claims.ts +56 -1
  27. package/src/namespace/errors.ts +1 -1
  28. package/src/namespace/manifest.ts +6 -19
  29. package/src/namespace/provisioning.ts +15 -3
  30. package/src/namespace/proxy-attempt.ts +43 -2
  31. package/src/namespace/proxy-reads.ts +12 -22
  32. package/src/namespace-cli.ts +152 -0
  33. package/src/plan.ts +2 -7
  34. package/src/provider.ts +128 -126
  35. package/src/proxy-manifest-carrier.ts +18 -0
  36. package/src/types.ts +0 -2
  37. package/types/src/api/client.d.ts +5 -0
  38. package/types/src/api/errors.d.ts +24 -0
  39. package/types/src/api/polling.d.ts +41 -0
  40. package/types/src/api/readers.d.ts +1115 -0
  41. package/types/src/api/service-env-limit.d.ts +17 -0
  42. package/types/src/api/service-env-response.d.ts +23 -0
  43. package/types/src/api/types.d.ts +687 -0
  44. package/types/src/api.d.ts +6 -0
  45. package/types/src/authoring.d.ts +9 -0
  46. package/types/src/cli-args.d.ts +19 -0
  47. package/types/src/cli.d.ts +13 -0
  48. package/types/src/collaborators.d.ts +16 -0
  49. package/types/src/compile.d.ts +91 -0
  50. package/types/src/control.d.ts +40 -0
  51. package/types/src/database-budget.d.ts +38 -0
  52. package/types/src/descriptor.d.ts +28 -0
  53. package/types/src/execution.d.ts +125 -0
  54. package/types/src/exposure.d.ts +47 -0
  55. package/types/src/index.d.ts +31 -0
  56. package/types/src/installation/admin-options.d.ts +24 -0
  57. package/types/src/installation/admin.d.ts +35 -0
  58. package/types/src/installation/checker-release.d.ts +12 -0
  59. package/types/src/installation/deploy-options.d.ts +37 -0
  60. package/types/src/installation/deploy.d.ts +136 -0
  61. package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
  62. package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
  63. package/types/src/installation/hosts.d.ts +34 -0
  64. package/types/src/installation/index.d.ts +7 -0
  65. package/types/src/installation/init-options.d.ts +38 -0
  66. package/types/src/installation/init.d.ts +60 -0
  67. package/types/src/installation/install-collaborators.d.ts +24 -0
  68. package/types/src/installation/install-environment.d.ts +63 -0
  69. package/types/src/installation/install-options.d.ts +49 -0
  70. package/types/src/installation/install-project.d.ts +13 -0
  71. package/types/src/installation/install-provisioning.d.ts +58 -0
  72. package/types/src/installation/install-secrets.d.ts +17 -0
  73. package/types/src/installation/install-token.d.ts +10 -0
  74. package/types/src/installation/install.d.ts +23 -0
  75. package/types/src/installation/installation-source.d.ts +8 -0
  76. package/types/src/installation/log.d.ts +44 -0
  77. package/types/src/installation/machine-key.d.ts +19 -0
  78. package/types/src/installation/placement-options.d.ts +11 -0
  79. package/types/src/installation/platform-archive.d.ts +7 -0
  80. package/types/src/installation/platform-source.d.ts +8 -0
  81. package/types/src/installation/platform-upload.d.ts +3 -0
  82. package/types/src/installation/sidecar.d.ts +49 -0
  83. package/types/src/installation/source-options.d.ts +1 -0
  84. package/types/src/installation/topology/artifacts.d.ts +8 -0
  85. package/types/src/installation/topology/index.d.ts +5 -0
  86. package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
  87. package/types/src/installation/topology/invariants.d.ts +23 -0
  88. package/types/src/installation/topology/setups.d.ts +59 -0
  89. package/types/src/installation/topology/topology.d.ts +153 -0
  90. package/types/src/installation/topology/validate.d.ts +11 -0
  91. package/types/src/installation/upgrade-options.d.ts +26 -0
  92. package/types/src/installation/upgrade-output.d.ts +6 -0
  93. package/types/src/installation/upgrade.d.ts +88 -0
  94. package/types/src/managed-env.d.ts +24 -0
  95. package/types/src/manifest.d.ts +204 -0
  96. package/types/src/namespace/claims.d.ts +16 -0
  97. package/types/src/namespace/codec.d.ts +334 -0
  98. package/types/src/namespace/constants.d.ts +25 -0
  99. package/types/src/namespace/errors.d.ts +15 -0
  100. package/types/src/namespace/manifest.d.ts +4 -0
  101. package/types/src/namespace/operator.d.ts +12 -0
  102. package/types/src/namespace/provisioning.d.ts +4 -0
  103. package/types/src/namespace/proxy-attempt.d.ts +39 -0
  104. package/types/src/namespace/proxy-reads.d.ts +22 -0
  105. package/types/src/namespace/proxy-secrets.d.ts +8 -0
  106. package/types/src/namespace/proxy-source.d.ts +74 -0
  107. package/types/src/namespace/topology.d.ts +9 -0
  108. package/types/src/namespace/types.d.ts +101 -0
  109. package/types/src/namespace/update.d.ts +6 -0
  110. package/types/src/namespace/validation.d.ts +12 -0
  111. package/types/src/namespace-cli.d.ts +2 -0
  112. package/types/src/namespace.d.ts +9 -0
  113. package/types/src/plan.d.ts +11 -0
  114. package/types/src/provider.d.ts +63 -0
  115. package/types/src/proxy-manifest-carrier.d.ts +24 -0
  116. package/types/src/schema.generated.d.ts +376 -0
  117. package/types/src/service-names.d.ts +4 -0
  118. package/types/src/service-references.d.ts +13 -0
  119. package/types/src/source/constants.d.ts +6 -0
  120. package/types/src/source/port.d.ts +60 -0
  121. package/types/src/source/types.d.ts +4 -0
  122. package/types/src/source/validation.d.ts +19 -0
  123. package/types/src/source.d.ts +2 -0
  124. package/types/src/types.d.ts +119 -0
  125. package/types/src/version-attempt.d.ts +8 -0
  126. package/src/installation/mcp.ts +0 -102
  127. package/src/installation/proxy-manifest.ts +0 -214
  128. package/src/installation/secrets.ts +0 -78
@@ -0,0 +1,152 @@
1
+ // `fabrika namespace` on Zerops: the provider options, the plan, and the namespace each command hands Control. The
2
+ // `fabrika` command holds Control's origin and credential, and prints the result.
3
+
4
+ import type { JsonValue, ProviderDeploymentNamespace, ProviderNamespacePlan } from '@fabrika/provider-contract'
5
+ import type { NamespaceCli, NamespaceCommandContext, NamespaceCommandResult } from '@fabrika/provider-contract/app-cli'
6
+ import { parseZeropsCliArgs, type ZeropsCliArgs } from './cli-args'
7
+ import {
8
+ createZeropsNamespaceOperator,
9
+ decodeZeropsNamespaceProxySource,
10
+ verifyZeropsNamespaceProxySource,
11
+ zeropsNamespaceTargetCodec,
12
+ } from './namespace'
13
+ import type { ZeropsNamespacePostgres } from './namespace'
14
+ import { postgresSchema } from './namespace/codec'
15
+
16
+ const NAMESPACE_USAGE = `fabrika namespace — Zerops deployment namespaces
17
+
18
+ Usage:
19
+ fabrika namespace plan --provider=zerops --id=<id> --env=<env> --preset=<cheap|mid|full> [namespace options]
20
+ fabrika namespace create --provider=zerops --id=<id> --env=<env> --preset=<cheap|mid|full> [namespace options] [--control-url=<url>]
21
+ fabrika namespace adopt --provider=zerops --id=<id> --env=<env> --preset=<cheap|mid|full> --project-id=<id> [namespace options] [--control-url=<url>]
22
+ fabrika namespace reconcile --provider=zerops --id=<id> [--control-url=<url>]
23
+
24
+ Options:
25
+ --id=<id> Namespace id.
26
+ --env=<env> Environment.
27
+ --preset=<cheap|mid|full> Provider-owned namespace preset.
28
+ --exclusive-app=<id> App reserved by a full namespace.
29
+ --project-id=<id> Existing Zerops project id for adoption.
30
+ --project-name=<name> Zerops project name (create: default fabrika-<env>-<id>;
31
+ adopt: the existing project's name, default the namespace id).
32
+ --core-package=<tier> LIGHT or SERIOUS.
33
+ --public-access=<mode> custom-domain or zerops-subdomain.
34
+ --postgres-type=<type> Cheap-tier PostgreSQL type.
35
+ --postgres-profile=<profile> Cheap-tier PostgreSQL profile.
36
+ --control-url=<url> Control origin; or FABRIKA_CONTROL_URL.
37
+ --json Print the result as JSON instead of rows.
38
+ -h, --help Show this help.
39
+
40
+ \`plan\` prints one \`<path>\\t<value>\` row per field of the plan, keyed by its path in --json; a multi-line
41
+ value such as the proxy's descriptor file is shown by size only. The other commands print one
42
+ \`<id>\\t<env>\\t<state>\\t<exclusive app>\` row.
43
+
44
+ Mutating namespace commands read the Bearer credential from FABRIKA_CONTROL_TOKEN.
45
+ Plan/create/adopt require FABRIKA_ZEROPS_PROXY_SOURCE_JSON:
46
+ {"repository":{"owner":"...","name":"..."},"commitSha":"...","descriptor":{"path":"zerops.yaml","contents":"...","sha256":"..."}}
47
+ Reconcile uses the persisted namespace source.
48
+ `
49
+
50
+ const required = (value: string | undefined, label: string): string => {
51
+ if (value === undefined || value.trim() === '') {
52
+ throw new Error(`${label} is required`)
53
+ }
54
+ return value.trim()
55
+ }
56
+
57
+ const postgres = (args: ZeropsCliArgs): ZeropsNamespacePostgres | undefined => {
58
+ const type = args.postgresType
59
+ const profile = args.postgresProfile
60
+ if (type === undefined && profile === undefined) return undefined
61
+ if (type === undefined) {
62
+ throw new Error('--postgres-type is required with --postgres-profile')
63
+ }
64
+ const result = postgresSchema.safeParse({ type, profile })
65
+ if (result.success) return result.data
66
+ if (result.error.issues[0]?.path[0] === 'type') throw new Error('--postgres-type must be postgresql:ha@18 or postgresql:single@18')
67
+ throw new Error(`--postgres-profile ${profile} is not supported by ${type}`)
68
+ }
69
+
70
+ const planOptions = (args: ZeropsCliArgs): { [key: string]: JsonValue } | undefined => {
71
+ const values: { [key: string]: JsonValue } = {}
72
+ if (args.projectName !== undefined) values['projectName'] = required(args.projectName, '--project-name')
73
+ if (args.corePackage !== undefined) values['corePackage'] = required(args.corePackage, '--core-package')
74
+ if (args.publicAccess !== undefined) values['publicAccess'] = required(args.publicAccess, '--public-access')
75
+ const database = postgres(args)
76
+ if (database !== undefined) {
77
+ values['postgres'] = database.profile === undefined ? { type: database.type } : { type: database.type, profile: database.profile }
78
+ }
79
+ return Object.keys(values).length === 0 ? undefined : values
80
+ }
81
+
82
+ const namespacePlan = async (
83
+ args: ZeropsCliArgs,
84
+ source: Readonly<Record<string, string | undefined>>,
85
+ ): Promise<ProviderNamespacePlan> => {
86
+ // `FABRIKA_ZEROPS_*` because Zerops reserves the bare `ZEROPS_` prefix and refuses to store a custom
87
+ // variable using it (`userDataZeropsPrefixForbidden`).
88
+ const serialized = required(source['FABRIKA_ZEROPS_PROXY_SOURCE_JSON'], 'FABRIKA_ZEROPS_PROXY_SOURCE_JSON')
89
+ let value: unknown
90
+ try {
91
+ value = JSON.parse(serialized)
92
+ } catch {
93
+ throw new Error('FABRIKA_ZEROPS_PROXY_SOURCE_JSON must contain valid JSON')
94
+ }
95
+ const proxySource = decodeZeropsNamespaceProxySource(value)
96
+ await verifyZeropsNamespaceProxySource(proxySource)
97
+ const operator = createZeropsNamespaceOperator({ proxySource })
98
+ const options = planOptions(args)
99
+ return operator.plan({
100
+ id: required(args.namespaceId, '--id'),
101
+ env: required(args.env, '--env'),
102
+ preset: required(args.preset, '--preset'),
103
+ ...(args.exclusiveAppId === undefined ? {} : { exclusiveAppId: required(args.exclusiveAppId, '--exclusive-app') }),
104
+ ...(options === undefined ? {} : { options }),
105
+ })
106
+ }
107
+
108
+ /** One namespace command. Every input is validated before `context.control()` is read. */
109
+ const runZeropsNamespaceCommand = async (
110
+ subcommand: string,
111
+ argv: readonly string[],
112
+ context: NamespaceCommandContext,
113
+ ): Promise<NamespaceCommandResult> => {
114
+ const args = parseZeropsCliArgs(['namespace', subcommand, ...argv])
115
+ if (subcommand === 'plan') {
116
+ return { plan: await namespacePlan(args, context.source) }
117
+ }
118
+ if (subcommand === 'reconcile') {
119
+ const id = required(args.namespaceId, '--id')
120
+ return { namespace: await context.control().reconcile(id) }
121
+ }
122
+ if (subcommand !== 'create' && subcommand !== 'adopt') {
123
+ throw new Error(`Unknown namespace command: ${subcommand}`)
124
+ }
125
+
126
+ const planned = await namespacePlan(args, context.source)
127
+ const control = context.control()
128
+ if (subcommand === 'create') {
129
+ return { namespace: await control.create(planned.namespace) }
130
+ }
131
+
132
+ const projectId = required(args.projectId, '--project-id')
133
+ const decoded = zeropsNamespaceTargetCodec.decode(planned.namespace.target.payload)
134
+ const namespace: ProviderDeploymentNamespace = {
135
+ ...planned.namespace,
136
+ target: {
137
+ provider: 'zerops',
138
+ version: zeropsNamespaceTargetCodec.version,
139
+ payload: zeropsNamespaceTargetCodec.encode({
140
+ ...decoded,
141
+ // An adopted project keeps its own name, so the plan's default for a created one does not apply.
142
+ projectName: args.projectName ?? planned.namespace.id,
143
+ managed: false,
144
+ projectId,
145
+ ready: false,
146
+ }),
147
+ },
148
+ }
149
+ return { namespace: await control.adopt(namespace) }
150
+ }
151
+
152
+ export const zeropsNamespaceCli: NamespaceCli = { usage: NAMESPACE_USAGE, run: runZeropsNamespaceCommand }
package/src/plan.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { type FabrikaManifest, manifestServiceHostnames } from './manifest'
2
- import type { ZeropsRuntimeTarget } from './types'
3
2
 
4
3
  export type ZeropsStepKind =
5
4
  | 'apply-import'
@@ -11,7 +10,6 @@ export type ZeropsStepKind =
11
10
  | 'upload-source'
12
11
  | 'trigger-build'
13
12
  | 'await-version'
14
- | 'reconcile-schema'
15
13
 
16
14
  export interface ZeropsStep {
17
15
  readonly id: string
@@ -30,7 +28,7 @@ export const resolveDeployHostname = (manifest: FabrikaManifest): string => {
30
28
  }
31
29
 
32
30
  /** Build the provider-owned deploy order without touching the platform. */
33
- export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntimeTarget): readonly ZeropsStep[] => {
31
+ export const buildZeropsPlan = (manifest: FabrikaManifest): readonly ZeropsStep[] => {
34
32
  const serviceHostnames = manifestServiceHostnames(manifest)
35
33
  const steps: ZeropsStep[] = []
36
34
 
@@ -48,7 +46,7 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
48
46
  steps.push({
49
47
  id: 'raise-start-memory',
50
48
  kind: 'raise-start-memory',
51
- description: "Raise the deploy service's minimum RAM to what its init commands need",
49
+ description: "Raise the deploy service's minimum RAM to what its init commands need, and pin a dev environment to one container",
52
50
  })
53
51
  steps.push({
54
52
  id: 'size-database',
@@ -59,9 +57,6 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
59
57
  steps.push({ id: 'upload-source', kind: 'upload-source', description: 'Upload the resolved repository snapshot' })
60
58
  steps.push({ id: 'trigger-build', kind: 'trigger-build', description: 'Trigger the Zerops build+deploy pipeline' })
61
59
  steps.push({ id: 'await-version', kind: 'await-version', description: 'Await the Zerops pipeline and relay its build log' })
62
- if (manifest.app.schema !== undefined && target.propustkaUrl !== undefined) {
63
- steps.push({ id: 'reconcile-schema', kind: 'reconcile-schema', description: 'Reconcile authz schema into IAM' })
64
- }
65
60
 
66
61
  return steps
67
62
  }
package/src/provider.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  // The Zerops provider owns its plan and every effect needed to execute it.
2
2
  //
3
3
  // A Zerops deploy is HTTP and nothing else (ADR-0003) — no container, no shell, no filesystem — so this
4
- // driver's whole side-effect surface is one REST client plus the portable schema reconciler
5
- // (`./collaborators.ts`). It never spawns a process and has no `runCommand` to be handed.
4
+ // driver's whole side-effect surface is one REST client (`./collaborators.ts`). It never spawns a process and has
5
+ // no `runCommand` to be handed.
6
6
  //
7
7
  // The interesting step is `await-version`: fabrika does not RUN the build, it WATCHES one the platform
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, FABRIKA_SOURCE_MAP_UPLOAD_KEY } from '@fabrika/auth-core'
11
+ import { FABRIKA_SOURCE_MAP_UPLOAD_KEY, managedValue, staleManagedVariables } from '@fabrika/auth-core'
12
12
  import { type OperationsSourceMapUploadState, readSourceMapUploadReport } from '@fabrika/operations-contract/releases'
13
13
  import {
14
14
  type DeployStepResult,
@@ -50,6 +50,7 @@ import {
50
50
  import {
51
51
  PLATFORM_TYPESCRIPT_VERSION,
52
52
  renderLockedExecutionDescriptor,
53
+ type ZeropsDevExecution,
53
54
  zeropsJobProcesses,
54
55
  type ZeropsLockedExecution,
55
56
  zeropsStartMinRam,
@@ -65,20 +66,9 @@ import {
65
66
  type StorageProvenanceMarker,
66
67
  unprovenBucketReason,
67
68
  } from './exposure'
68
- import {
69
- assertAuthoredManagedDocument,
70
- assertAuthoredManagedInputs,
71
- isFrameworkFamilyVariable,
72
- isSensitiveFamilyVariable,
73
- type ManagedValues,
74
- overlayRelease,
75
- parseManagedEnvironment,
76
- SENSITIVE_MANAGED_KEYS,
77
- SERVICE_MANAGED_KEYS,
78
- } from './managed-env'
69
+ import { assertAuthoredManagedDocument, assertAuthoredManagedInputs, overlayRelease } from './managed-env'
79
70
  import {
80
71
  applicationDatabaseOf,
81
- approvedSchemaOf,
82
72
  type FabrikaImportDocument,
83
73
  type FabrikaManifest,
84
74
  lockedExecutionFor,
@@ -96,8 +86,6 @@ export const CANCELLED = 'deploy cancelled'
96
86
  export interface ZeropsDeployRun {
97
87
  readonly appId: string
98
88
  readonly env: string
99
- /** Every origin IAM may hand this app a session at; app-wide, because IAM's registry is keyed by app id. */
100
- readonly returnOrigins?: readonly string[]
101
89
  readonly secrets: Readonly<Record<string, string>>
102
90
  readonly vars: Readonly<Record<string, string>>
103
91
  /** Platform-owned runtime configuration, separate from operator-managed application vars. */
@@ -108,6 +96,8 @@ export interface ZeropsDeployRun {
108
96
  readonly access?: ProviderApprovedAccess
109
97
  /** The run-scoped source-map destination Control minted; the build uploads to it. */
110
98
  readonly artifactUpload?: ProviderDeployInput['artifactUpload']
99
+ /** Present for an environment of kind `dev`: the launcher starts instead of the serving entry, on one container. */
100
+ readonly dev?: ZeropsDevExecution
111
101
  readonly dryRun: boolean
112
102
  readonly signal: AbortSignal
113
103
  readonly events: ProviderRunEvents
@@ -332,9 +322,8 @@ interface StepEnv {
332
322
  signal: AbortSignal
333
323
  dryRun: boolean
334
324
  attempt: Attempt
335
- managedValues: ManagedValues
336
- /** The key of each job and webhook of the approved contract, by its variable (ADR-0022 "Principal kinds"). */
337
- familyVariables: Readonly<Record<string, string>>
325
+ /** What Control handed (`parseManagedEnvironment`). */
326
+ managed: ProviderManagedEnvironment
338
327
  /** `await-version` gives up at this instant. */
339
328
  deadlineMs: number
340
329
  }
@@ -577,9 +566,9 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
577
566
  }
578
567
  }
579
568
  if (findings.length > 0) {
580
- const dsn = env.managedValues.FABRIKA_OPERATIONS_DSN
569
+ const dsn = managedValue(env.managed, 'FABRIKA_OPERATIONS_DSN')
581
570
  if (dsn !== null) {
582
- const alert = { appId: run.appId, env: run.env, release: env.managedValues.FABRIKA_RELEASE, findings }
571
+ const alert = { appId: run.appId, env: run.env, release: managedValue(env.managed, 'FABRIKA_RELEASE'), findings }
583
572
  await zerops.alertExposure({ dsn, alert, signal }).then(
584
573
  () => log(' raised an Operations alert'),
585
574
  () => log(' [warn] the Operations alert could not be sent'),
@@ -595,86 +584,101 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
595
584
  )
596
585
  }
597
586
 
598
- /** Write the eleven service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
587
+ /** Write the managed variables Control handed; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
599
588
  const writeServiceEnv = async (env: StepEnv): Promise<void> => {
600
- const { zerops, log, signal, managedValues, familyVariables } = env
589
+ const { zerops, log, signal, managed } = env
601
590
  const serviceId = env.run.target.serviceId
591
+ const written = Object.entries(managed).flatMap(([key, { value, credential }]) =>
592
+ key === 'FABRIKA_RELEASE' || value === null ? [] : [{ key, value, credential }]
593
+ )
594
+ const writtenKeys = new Set(written.map(({ key }) => key))
602
595
  if (env.dryRun) {
603
596
  const upload = env.run.artifactUpload !== undefined
604
- const written = [
605
- ...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null),
606
- ...Object.keys(familyVariables),
607
- ...(upload ? [FABRIKA_SOURCE_MAP_UPLOAD_KEY] : []),
608
- ]
609
- const removed = [...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null), ...(upload ? [] : [FABRIKA_SOURCE_MAP_UPLOAD_KEY])]
610
- log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
611
- log(` [dry-run] would remove service variables on ${serviceId} if present: ${removed.join(', ') || '(none)'}`)
612
- if (Object.keys(familyVariables).length > 0) {
613
- log(` [dry-run] would remove every other job, webhook, connector and mail variable on ${serviceId}`)
614
- }
597
+ const removed = Object.keys(managed).filter((key) => key !== 'FABRIKA_RELEASE' && !writtenKeys.has(key))
598
+ log(
599
+ ` [dry-run] would write service variables on ${serviceId}: ${
600
+ [...writtenKeys, ...(upload ? [FABRIKA_SOURCE_MAP_UPLOAD_KEY] : [])].join(', ') || '(none)'
601
+ }`,
602
+ )
603
+ log(
604
+ ` [dry-run] would remove service variables on ${serviceId} if present: ${
605
+ [...removed, ...(upload ? [] : [FABRIKA_SOURCE_MAP_UPLOAD_KEY])].join(', ') || '(none)'
606
+ }`,
607
+ )
608
+ log(` [dry-run] would remove every other job, webhook, connector, mail and platform variable on ${serviceId}`)
615
609
  return
616
610
  }
617
611
  assertRunning(signal)
618
612
  // One read: every write below waits for its own user-data sync, so the build request never meets `userDataSyncRunning`.
619
613
  const records = await zerops.api.listServiceEnv({ serviceId, signal })
620
- for (const key of SERVICE_MANAGED_KEYS) {
621
- const value = managedValues[key]
622
- const existing = records.find((record) => record.key === key)
623
- const sensitive = SENSITIVE_MANAGED_KEYS.has(key)
624
- assertRunning(signal)
625
- if (value !== null && (sensitive || readableContent(existing) !== value)) {
626
- await zerops.api.putServiceEnv({ serviceId, key, value, sensitive, signal })
627
- log(` ${key}: updated`)
628
- } else if (value === null && existing !== undefined) {
629
- await zerops.api.deleteServiceEnv({ envId: existing.id, signal })
630
- log(` ${key}: removed`)
631
- } else {
632
- log(` ${key}: unchanged`)
633
- }
614
+ const changes: ServiceEnvChange[] = []
615
+ const put = (key: string, value: string, sensitive: boolean): void => {
616
+ changes.push({ line: `${key}: updated`, apply: () => zerops.api.putServiceEnv({ serviceId, key, value, sensitive, signal }) })
634
617
  }
635
- const provenance = env.attempt.storageProvenance
636
- const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
637
- if (typeof provenance === 'string' && readableContent(marker) !== provenance) {
638
- assertRunning(signal)
639
- // Not a credential, and the exposure read-back above reads it: it must stay readable.
640
- await zerops.api.putServiceEnv({ serviceId, key: STORAGE_PROVENANCE_KEY, value: provenance, sensitive: false, signal })
641
- log(` ${STORAGE_PROVENANCE_KEY}: updated`)
642
- } else if (provenance === null && marker !== undefined) {
643
- assertRunning(signal)
644
- await zerops.api.deleteServiceEnv({ envId: marker.id, signal })
645
- log(` ${STORAGE_PROVENANCE_KEY}: removed`)
618
+ const remove = (key: string, envId: string): void => {
619
+ changes.push({ line: `${key}: removed`, apply: () => zerops.api.deleteServiceEnv({ envId, signal }) })
646
620
  }
647
- // A workload key is written like a managed key; one whose workload is gone is removed, so no stale key stays readable.
648
- for (const [key, value] of Object.entries(familyVariables)) {
621
+ // A credential is written sensitive and never read back, so it is written every time.
622
+ for (const { key, value, credential } of written) {
649
623
  const existing = records.find((record) => record.key === key)
650
- const sensitive = isSensitiveFamilyVariable(key)
651
- assertRunning(signal)
652
- if (sensitive || readableContent(existing) !== value) {
653
- await zerops.api.putServiceEnv({ serviceId, key, value, sensitive, signal })
654
- log(` ${key}: updated`)
655
- } else {
656
- log(` ${key}: unchanged`)
657
- }
624
+ if (credential || readableContent(existing) !== value) put(key, value, credential)
625
+ else changes.push({ line: `${key}: unchanged`, apply: null })
658
626
  }
627
+ // A variable Control no longer hands is removed, so no stale key stays readable. `/env` also reports the descriptor's
628
+ // `FABRIKA_RELEASE`, which Zerops refuses to delete.
629
+ const stale = new Set(staleManagedVariables(records.map((record) => record.key), new Set([...writtenKeys, 'FABRIKA_RELEASE'])))
659
630
  for (const record of records) {
660
- if (isFrameworkFamilyVariable(record.key) && !Object.hasOwn(familyVariables, record.key)) {
661
- assertRunning(signal)
662
- await zerops.api.deleteServiceEnv({ envId: record.id, signal })
663
- log(` ${record.key}: removed`)
664
- }
631
+ if (stale.has(record.key)) remove(record.key, record.id)
665
632
  }
633
+ const provenance = env.attempt.storageProvenance
634
+ const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
635
+ // Not a credential, and the exposure read-back above reads it: it must stay readable.
636
+ if (typeof provenance === 'string' && readableContent(marker) !== provenance) put(STORAGE_PROVENANCE_KEY, provenance, false)
637
+ else if (provenance === null && marker !== undefined) remove(STORAGE_PROVENANCE_KEY, marker.id)
666
638
  // The build lifts the upload's bearer from here (`${RUNTIME_…}`), so the descriptor never holds it.
667
639
  const bearer = env.run.artifactUpload?.bearer ?? null
668
640
  const uploadKey = records.find((record) => record.key === FABRIKA_SOURCE_MAP_UPLOAD_KEY)
669
- if (bearer !== null) {
670
- assertRunning(signal)
671
- await zerops.api.putServiceEnv({ serviceId, key: FABRIKA_SOURCE_MAP_UPLOAD_KEY, value: bearer, sensitive: true, signal })
672
- log(` ${FABRIKA_SOURCE_MAP_UPLOAD_KEY}: updated`)
673
- } else if (uploadKey !== undefined) {
674
- assertRunning(signal)
675
- await zerops.api.deleteServiceEnv({ envId: uploadKey.id, signal })
676
- log(` ${FABRIKA_SOURCE_MAP_UPLOAD_KEY}: removed`)
641
+ if (bearer !== null) put(FABRIKA_SOURCE_MAP_UPLOAD_KEY, bearer, true)
642
+ else if (uploadKey !== undefined) remove(FABRIKA_SOURCE_MAP_UPLOAD_KEY, uploadKey.id)
643
+ await applyServiceEnvChanges(changes, log, signal)
644
+ }
645
+
646
+ /** One service variable write or removal `write-service-env` decided on, or a key it leaves as it is (`apply: null`). */
647
+ interface ServiceEnvChange {
648
+ readonly line: string
649
+ readonly apply: (() => Promise<void>) | null
650
+ }
651
+
652
+ /** Each write waits ~3 s for its own user-data sync; concurrent writes to one service do not conflict (zerops-platform.md). */
653
+ const SERVICE_ENV_WRITE_CONCURRENCY = 6
654
+
655
+ /**
656
+ * Applies the changes `SERVICE_ENV_WRITE_CONCURRENCY` at a time and waits for every started one, so no sync still runs
657
+ * when the step ends. A failure or a cancel starts no further write; lines follow the decision order.
658
+ */
659
+ const applyServiceEnvChanges = async (changes: readonly ServiceEnvChange[], log: (line: string) => void, signal: AbortSignal): Promise<void> => {
660
+ const applied = new Set<number>()
661
+ const failures: unknown[] = []
662
+ let next = 0
663
+ const worker = async (): Promise<void> => {
664
+ while (next < changes.length && failures.length === 0 && !signal.aborted) {
665
+ const index = next++
666
+ const apply = changes[index]?.apply ?? null
667
+ if (apply === null) continue
668
+ try {
669
+ await apply()
670
+ applied.add(index)
671
+ } catch (error) {
672
+ failures.push(error)
673
+ }
674
+ }
677
675
  }
676
+ await Promise.all(Array.from({ length: SERVICE_ENV_WRITE_CONCURRENCY }, worker))
677
+ for (const [index, change] of changes.entries()) {
678
+ if (change.apply === null || applied.has(index)) log(` ${change.line}`)
679
+ }
680
+ if (failures.length > 0) throw failures[0]
681
+ assertRunning(signal)
678
682
  }
679
683
 
680
684
  /** The custom autoscaling with its minimum RAM raised to `minRam`; every other field is sent back as it was read. */
@@ -690,6 +694,7 @@ const withMinRam = (custom: ZeropsServiceAutoscaling, minRam: number): ZeropsSer
690
694
  */
691
695
  const raiseStartMemory = async (env: StepEnv): Promise<void> => {
692
696
  const { run, zerops, log, signal, dryRun } = env
697
+ if (run.dev !== undefined) await pinOneContainer(env)
693
698
  const locked = lockedExecutionFor(run.artifact, run, dryRun)
694
699
  const minRam = locked === null ? null : zeropsStartMinRam(locked)
695
700
  const hostname = run.artifact.target.deployService
@@ -712,6 +717,42 @@ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
712
717
  log(` minimum RAM of ${hostname}: ${before ?? 'the platform default'} → ${after} GB`)
713
718
  }
714
719
 
720
+ /**
721
+ * A dev environment runs on exactly one container: the launcher writes the files it syncs into that container, and a
722
+ * second one would serve the bootstrap head. Sends the custom autoscaling whole with only the container counts changed,
723
+ * and decides by reading it back.
724
+ */
725
+ const pinOneContainer = async (env: StepEnv): Promise<void> => {
726
+ const { run, zerops, log, signal, dryRun } = env
727
+ const hostname = run.artifact.target.deployService
728
+ if (dryRun) {
729
+ log(` [dry-run] would pin ${hostname} to one container`)
730
+ return
731
+ }
732
+ assertRunning(signal)
733
+ const service = await zerops.api.getService({ serviceId: run.target.serviceId, signal })
734
+ const custom = service.customAutoscaling
735
+ const horizontal = service.currentAutoscaling?.horizontal
736
+ if (horizontal?.minContainerCount === 1 && horizontal.maxContainerCount === 1) {
737
+ log(` ${hostname} runs on one container, unchanged`)
738
+ return
739
+ }
740
+ if (custom === undefined) throw new Error(`zerops: ${hostname} did not report its autoscaling whole, so it cannot be pinned to one container`)
741
+ const process = await zerops.api.configureCustomAutoscaling({
742
+ serviceId: service.id,
743
+ autoscaling: { ...custom, horizontal: { minContainerCount: 1, maxContainerCount: 1 } },
744
+ signal,
745
+ })
746
+ if (process !== null) {
747
+ await waitForProcess({ api: zerops.api, processId: process.id, sleep: zerops.sleep, signal, label: `the autoscaling change of ${hostname}` })
748
+ }
749
+ const after = (await zerops.api.getService({ serviceId: service.id, signal })).currentAutoscaling?.horizontal
750
+ if (after?.minContainerCount !== 1 || after.maxContainerCount !== 1) {
751
+ throw new Error(`zerops: ${hostname} reads back ${after?.maxContainerCount ?? 'no'} container(s) after it was pinned to one`)
752
+ }
753
+ log(` ${hostname} pinned to one container`)
754
+ }
755
+
715
756
  /** What raising a minimum RAM calls; the deploy's collaborators, and the live platform-facts probe of the same path. */
716
757
  export interface MinRamRaise {
717
758
  zerops: Pick<ZeropsCollaborators, 'api' | 'sleep'>
@@ -777,7 +818,7 @@ const sizeDatabase = async (env: StepEnv): Promise<void> => {
777
818
  const deploy = await zerops.api.getService({ serviceId: run.target.serviceId, signal })
778
819
  const demand: ZeropsDatabaseDemand = {
779
820
  maxContainers: deploy.currentAutoscaling?.horizontal.maxContainerCount ?? ZEROPS_MAX_CONTAINERS,
780
- jobs: zeropsJobProcesses(locked.contract),
821
+ jobs: zeropsJobProcesses(locked),
781
822
  }
782
823
  const service = await zerops.api.findService({ projectId: run.target.projectId, hostname: database.hostname, signal })
783
824
  if (service === null) throw new Error(`zerops: the database ${database.hostname} does not exist, so its connections cannot be sized`)
@@ -939,7 +980,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
939
980
  const appVersionId = requireVersion(env, 'trigger-build')
940
981
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
941
982
  const { contents } = artifact.target.sourceDescriptor
942
- const release = env.managedValues.FABRIKA_RELEASE
983
+ const release = managedValue(env.managed, 'FABRIKA_RELEASE')
943
984
  const descriptor = locked === null
944
985
  ? overlayRelease(contents, artifact.target.zeropsSetup, release)
945
986
  : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, sizedExecution(locked, attempt))
@@ -989,35 +1030,6 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
989
1030
  await awaitVersion(env, appVersionId, attempt.processId, locked !== null && locked.databaseUrl !== null)
990
1031
  return
991
1032
  }
992
-
993
- case 'reconcile-schema': {
994
- const schema = approvedSchemaOf(run)
995
- const propustkaUrl = target.propustkaUrl
996
- if (schema === undefined || propustkaUrl === undefined) {
997
- return
998
- }
999
- // Return origins ride this step because `apps.setReturnOrigins` 404s for an app IAM has never
1000
- // heard of, and the schema reconcile is what registers it. Separate call, same moment.
1001
- const returnOrigins = run.returnOrigins !== undefined && run.returnOrigins.length > 0 ? run.returnOrigins : undefined
1002
- const iamApp = appEnvironmentId(artifact.app.id, artifact.app.env)
1003
- if (dryRun) {
1004
- log(` [dry-run] would reconcile schema for \`${iamApp}\` against ${propustkaUrl}`)
1005
- if (returnOrigins !== undefined) {
1006
- log(` [dry-run] would register return origins for \`${iamApp}\`: ${returnOrigins.join(', ')}`)
1007
- }
1008
- return
1009
- }
1010
- assertRunning(signal)
1011
- await zerops.reconcileSchema({
1012
- url: propustkaUrl,
1013
- app: iamApp,
1014
- schema,
1015
- ...(returnOrigins === undefined ? {} : { returnOrigins }),
1016
- adminKey: target.adminKey,
1017
- signal,
1018
- })
1019
- return
1020
- }
1021
1033
  }
1022
1034
  }
1023
1035
 
@@ -1039,15 +1051,6 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
1039
1051
  throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
1040
1052
  }
1041
1053
  assertAuthoredManagedInputs({ variableNames: Object.keys(run.vars), secretNames: Object.keys(run.secrets) })
1042
- // The proxy serves an endpoint table exactly when Control approved a contract, whatever the artifact says.
1043
- const contractProxy = run.access !== undefined
1044
- const { values: handed, familyVariables } = parseManagedEnvironment(run.managedEnvironment)
1045
- // Only a contract application checks the secret; any other would merely hold a credential its own code can print.
1046
- const managedValues: ManagedValues = contractProxy ? handed : { ...handed, FABRIKA_PROXY_SECRET: null }
1047
- if (!run.dryRun && contractProxy && managedValues.FABRIKA_PROXY_SECRET === null) {
1048
- // The application would refuse every request its proxy sends (ADR-0022 rule 5).
1049
- throw new Error('zerops: a contract application needs its proxy secret, and Control supplied none')
1050
- }
1051
1054
  const hostnames = manifestServiceHostnames(run.artifact)
1052
1055
  const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
1053
1056
  const compiled = { yaml: renderYaml(document), hostnames }
@@ -1061,11 +1064,10 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
1061
1064
  signal: run.signal,
1062
1065
  dryRun: run.dryRun,
1063
1066
  attempt: { triggered: false },
1064
- managedValues,
1065
- familyVariables,
1067
+ managed: run.managedEnvironment,
1066
1068
  deadlineMs: Date.now() + ZEROPS_VERSION_DEADLINE_MS,
1067
1069
  }
1068
- const steps = buildZeropsPlan(run.artifact, run.target)
1070
+ const steps = buildZeropsPlan(run.artifact)
1069
1071
  const options = { appId: run.appId, env: run.env, dryRun: run.dryRun, signal: run.signal, log: run.events.log }
1070
1072
  const result = await runSteps(steps, options, (step) => runStep(step, env))
1071
1073
  const { appVersionId, triggered } = env.attempt
@@ -30,3 +30,21 @@ export const zeropsProxyManifestCarrier = (descriptorContents: string, setup = '
30
30
  }
31
31
  return 'archive'
32
32
  }
33
+
34
+ /** The build variable that marks a proxy source whose manifest parser knows `brand` (ADR-0066). */
35
+ export const FABRIKA_PROXY_MANIFEST_BRAND = 'FABRIKA_PROXY_MANIFEST_BRAND'
36
+
37
+ /**
38
+ * Whether a pinned descriptor's proxy reads a manifest that carries the installation's client logo. An older parser
39
+ * refuses the unknown key and fails the build, so a namespace kept on such a pin gets no logo and keeps the fabrika
40
+ * mark until it is re-pinned (`--proxy-source=current`). Decided by the build section Zerops applies, as the carrier is.
41
+ */
42
+ export const zeropsProxyAcceptsBrand = (descriptorContents: string, setup = 'proxy'): boolean => {
43
+ const parsed = parseZeropsDescriptor(descriptorContents)
44
+ for (const entry of appliedZeropsSetups(parsed, selectZeropsDescriptorSetup(parsed, setup))) {
45
+ const read = buildSectionSchema.safeParse(entry)
46
+ if (!read.success) continue
47
+ return read.data.build.envVariables?.[FABRIKA_PROXY_MANIFEST_BRAND] === '1'
48
+ }
49
+ return false
50
+ }
package/src/types.ts CHANGED
@@ -150,6 +150,4 @@ export interface ZeropsRuntimeTarget {
150
150
  /** Absent only for dry runs, which perform no source or Zerops mutation. */
151
151
  source?: ZeropsRuntimeSource
152
152
  apiBaseUrl?: string
153
- propustkaUrl?: string
154
- adminKey?: string
155
153
  }
@@ -0,0 +1,5 @@
1
+ import type { ZeropsApi, ZeropsApiOptions } from './types';
2
+ /** Base URL of the Zerops public REST API. */
3
+ export declare const ZEROPS_API_BASE = "https://api.app-prg1.zerops.io/api/rest/public";
4
+ /** Build the real client. Every request carries the bearer and the run's signal. */
5
+ export declare const createZeropsApi: (options: ZeropsApiOptions) => ZeropsApi;
@@ -0,0 +1,24 @@
1
+ export declare const isAppVersionNotFoundResponse: (value: unknown) => boolean;
2
+ /**
3
+ * A non-2xx answer from the Zerops API. `code` is the platform's own error identifier, so a caller can
4
+ * branch on `userDataDuplicateKey` rather than matching a prose message.
5
+ */
6
+ export declare class ZeropsApiError extends Error {
7
+ readonly status: number;
8
+ readonly code: string;
9
+ constructor(message: string, status: number, code: string);
10
+ }
11
+ /**
12
+ * The platform's code for "this service publishes no HTTP port", the answer `enableSubdomainAccess` gives
13
+ * before a service's first deploy. Exported so a caller can say what is missing instead of relaying prose.
14
+ */
15
+ export declare const ZEROPS_SERVICE_NOT_HTTP = "serviceStackIsNotHttp";
16
+ /**
17
+ * Turn a non-2xx response into a `ZeropsApiError`. Zerops answers with `{ error: { code, message, meta } }`
18
+ * (the envelope observed live; the OpenAPI `Error` schema shows the inner `{ code, message }`).
19
+ *
20
+ * `redactDetail` drops the server's MESSAGE, which a validation error can fill with the value it
21
+ * rejected. The CODE is a fixed identifier and is kept either way — without it nothing downstream can
22
+ * tell a duplicate key from a missing service.
23
+ */
24
+ export declare const apiError: (label: string, status: number, body: unknown, redactDetail: boolean) => ZeropsApiError;