@fabrika/provider-zerops 0.0.26 → 0.3.1

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 (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +75 -20
  14. package/src/control.ts +217 -447
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +24 -128
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +208 -315
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1260
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +399 -254
  73. package/src/service-references.ts +28 -0
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
package/src/authoring.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ZEROPS_SHARED_POSTGRES_CONNECTION_STRING } from './namespace'
2
2
  export { zeropsSharedServiceHostname, zeropsSharedServicePrefix } from './service-names'
3
3
  import { APP_PROVIDER, isProviderAuthoredApp, type ProviderAuthoredApp } from '@fabrika/provider-contract'
4
+ import { z } from 'zod'
4
5
  import type { ZeropsAppConfig, ZeropsSharedPostgresBinding } from './types'
5
6
 
6
7
  /** Declare consumption of the namespace-owned `postgres` service without resolving its credential. */
@@ -20,14 +21,10 @@ export const defineApp = (config: ZeropsAppConfig): ZeropsAuthoredAppConfig => {
20
21
  return { ...config, [APP_PROVIDER]: 'zerops' }
21
22
  }
22
23
 
23
- const property = (value: unknown, key: string): unknown => typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined
24
+ const zeropsAppConfigSchema = z.object({
25
+ id: z.string().refine((id) => id.trim() !== ''),
26
+ target: z.object({ platform: z.literal('zerops'), services: z.function() }),
27
+ })
24
28
 
25
- export const isZeropsAppConfig = (value: unknown): value is ZeropsAuthoredAppConfig => {
26
- const id = property(value, 'id')
27
- const target = property(value, 'target')
28
- return isProviderAuthoredApp(value, 'zerops')
29
- && typeof id === 'string'
30
- && id.trim() !== ''
31
- && property(target, 'platform') === 'zerops'
32
- && typeof property(target, 'services') === 'function'
33
- }
29
+ export const isZeropsAppConfig = (value: unknown): value is ZeropsAuthoredAppConfig =>
30
+ isProviderAuthoredApp(value, 'zerops') && zeropsAppConfigSchema.safeParse(value).success
package/src/cli-args.ts CHANGED
@@ -13,7 +13,6 @@ export interface ZeropsCliArgs {
13
13
  publicAccess: string | undefined
14
14
  postgresType: string | undefined
15
15
  postgresProfile: string | undefined
16
- proxyBuildFromGit: string | undefined
17
16
  controlUrl: string | undefined
18
17
  help: boolean
19
18
  }
@@ -33,7 +32,6 @@ export const parseZeropsCliArgs = (argv: readonly string[]): ZeropsCliArgs => {
33
32
  let publicAccess: string | undefined
34
33
  let postgresType: string | undefined
35
34
  let postgresProfile: string | undefined
36
- let proxyBuildFromGit: string | undefined
37
35
  let controlUrl: string | undefined
38
36
  let help = false
39
37
 
@@ -64,8 +62,6 @@ export const parseZeropsCliArgs = (argv: readonly string[]): ZeropsCliArgs => {
64
62
  postgresType = arg.slice('--postgres-type='.length)
65
63
  } else if (arg.startsWith('--postgres-profile=')) {
66
64
  postgresProfile = arg.slice('--postgres-profile='.length)
67
- } else if (arg.startsWith('--proxy-build-from-git=')) {
68
- proxyBuildFromGit = arg.slice('--proxy-build-from-git='.length)
69
65
  } else if (arg.startsWith('--control-url=')) {
70
66
  controlUrl = arg.slice('--control-url='.length)
71
67
  } else if (arg.startsWith('-')) {
@@ -94,7 +90,6 @@ export const parseZeropsCliArgs = (argv: readonly string[]): ZeropsCliArgs => {
94
90
  publicAccess,
95
91
  postgresType,
96
92
  postgresProfile,
97
- proxyBuildFromGit,
98
93
  controlUrl,
99
94
  help,
100
95
  }
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { constants } from 'node:fs'
3
3
  import { open } from 'node:fs/promises'
4
4
  import { resolve } from 'node:path'
5
+ import { z } from 'zod'
5
6
  import { isZeropsAppConfig } from './authoring'
6
7
  import { parseZeropsCliArgs } from './cli-args'
7
8
  import {
@@ -10,44 +11,26 @@ import {
10
11
  manifestServiceHostnames,
11
12
  ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES,
12
13
  } from './manifest'
13
- import { runZeropsNamespaceCommand } from './namespace-command'
14
14
  import type { ZeropsAppConfig } from './types'
15
15
 
16
16
  const USAGE = `Zerops app tools
17
17
 
18
18
  Usage:
19
19
  fabrika app build --provider=zerops --env=<env> [--config=<path>] [--output=<path>]
20
- fabrika namespace plan --provider=zerops --id=<id> --env=<env> --preset=<cheap|mid|full> [namespace options]
21
- fabrika namespace create --provider=zerops --id=<id> --env=<env> --preset=<cheap|mid|full> [namespace options] [--control-url=<url>]
22
- fabrika namespace adopt --provider=zerops --id=<id> --env=<env> --preset=<cheap|mid|full> --project-id=<id> [namespace options] [--control-url=<url>]
23
- fabrika namespace reconcile --provider=zerops --id=<id> [--control-url=<url>]
24
20
 
25
21
  Options:
26
22
  --env=<env> Environment.
27
23
  --config=<path> App config (default: ./fabrika.config.ts).
28
24
  --output=<path> Artifact path (default: ./fabrika.manifest.json).
29
- --id=<id> Namespace id.
30
- --preset=<cheap|mid|full> Provider-owned namespace preset.
31
- --exclusive-app=<id> App reserved by a full namespace.
32
- --project-id=<id> Existing Zerops project id for adoption.
33
- --project-name=<name> Zerops project name (default: namespace id).
34
- --core-package=<tier> LIGHT or SERIOUS.
35
- --public-access=<mode> custom-domain or zerops-subdomain.
36
- --postgres-type=<type> Cheap-tier PostgreSQL type.
37
- --postgres-profile=<profile> Cheap-tier PostgreSQL profile.
38
- --proxy-build-from-git=<url> Public proxy source; or FABRIKA_ZEROPS_PROXY_BUILD_FROM_GIT.
39
- --control-url=<url> Control origin; or FABRIKA_CONTROL_URL.
40
25
  -h, --help Show this help.
41
-
42
- Mutating namespace commands read the Bearer credential from FABRIKA_CONTROL_TOKEN.
43
26
  `
44
27
 
45
- const property = (value: unknown, key: string): unknown => typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined
28
+ const configModuleSchema = z.object({ default: z.unknown() })
46
29
 
47
30
  const loadConfig = async (path: string): Promise<ZeropsAppConfig> => {
48
31
  const absolute = resolve(process.cwd(), path)
49
32
  const loaded: unknown = await import(absolute)
50
- const config = property(loaded, 'default')
33
+ const config = configModuleSchema.safeParse(loaded).data?.default
51
34
  if (!isZeropsAppConfig(config)) {
52
35
  throw new Error(`Config at ${absolute} must default-export defineApp({ target: { platform: 'zerops', ... } })`)
53
36
  }
@@ -92,14 +75,6 @@ export const runZeropsCli = async (argv: readonly string[]): Promise<void> => {
92
75
  console.info(USAGE)
93
76
  return
94
77
  }
95
- if (args.command === 'namespace') {
96
- await runZeropsNamespaceCommand(args, {
97
- source: process.env,
98
- fetch: globalThis.fetch,
99
- write: (value) => console.info(value.trimEnd()),
100
- })
101
- return
102
- }
103
78
  if (args.command !== 'build') {
104
79
  throw new Error(`Unknown command: ${args.command}\n${USAGE}`)
105
80
  }
@@ -1,7 +1,7 @@
1
1
  import { reconcileSchema } from '@fabrika/auth'
2
2
  import type { SchemaReconciler } from '@fabrika/provider-contract'
3
3
  import { createZeropsApi, type ZeropsApi } from './api'
4
- import type { ZeropsSourceClient } from './source'
4
+ import type { ZeropsSourcePort } from './source/port'
5
5
  import type { ZeropsRuntimeTarget } from './types'
6
6
 
7
7
  export type Sleeper = (ms: number, signal: AbortSignal) => Promise<void>
@@ -26,9 +26,7 @@ export const defaultSleep: Sleeper = (ms, signal) =>
26
26
  export interface ZeropsCollaborators {
27
27
  api: ZeropsApi
28
28
  /** Injected by the Zerops control composition; dry runs do not require it. */
29
- source?: ZeropsSourceClient
30
- /** Independently bounds best-effort source cancellation without delaying Zerops cleanup. */
31
- sourceCancelSleep?: Sleeper
29
+ source?: ZeropsSourcePort
32
30
  reconcileSchema: SchemaReconciler
33
31
  sleep: Sleeper
34
32
  }
package/src/compile.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  // credentials leak to app B. "Never write to project-level env" alone does not prevent that; the isolation
19
19
  // setting is the other half.
20
20
 
21
+ import type { JsonValue } from '@fabrika/provider-contract'
21
22
  import type { ZeropsImportProject, ZeropsImportService } from './schema.generated'
22
23
  import type { ZeropsProjectSpec, ZeropsResourceContext, ZeropsServiceSpec, ZeropsSourceTarget } from './types'
23
24
 
@@ -37,6 +38,16 @@ export interface ZeropsImportDocument {
37
38
  services: ZeropsImportService[]
38
39
  }
39
40
 
41
+ /**
42
+ * The same document once it has been through JSON — a manifest's stored `importDocument`, or one whose
43
+ * values were substituted. Structurally identical, leaves typed as JSON, so `assertZeropsInvariants`
44
+ * checks it too rather than trusting whoever produced it.
45
+ */
46
+ export interface ZeropsJsonImportDocument {
47
+ readonly project?: { readonly [key: string]: JsonValue }
48
+ readonly services: ReadonlyArray<{ readonly [key: string]: JsonValue }>
49
+ }
50
+
40
51
  /** What the compiler needs beyond the app's declaration. */
41
52
  export interface CompileInput {
42
53
  target: ZeropsSourceTarget
@@ -185,14 +196,14 @@ const compileProject = (spec: ZeropsProjectSpec): ZeropsImportProject => {
185
196
  *
186
197
  * Deliberately checks the emitted document rather than the input: it is the document that reaches Zerops.
187
198
  */
188
- export const assertZeropsInvariants = (document: ZeropsImportDocument): void => {
199
+ export const assertZeropsInvariants = (document: ZeropsImportDocument | ZeropsJsonImportDocument): void => {
189
200
  const project = document.project
190
201
  if (project !== undefined) {
191
- if (project.envVariables !== undefined) {
202
+ if (project['envVariables'] !== undefined) {
192
203
  throw new Error('zerops: refusing to write PROJECT-level env variables — they reach every service in the project (ADR-0004)')
193
204
  }
194
- if (project.envIsolation !== ENV_ISOLATION) {
195
- throw new Error(`zerops: project envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(project.envIsolation)}\` (ADR-0004)`)
205
+ if (project['envIsolation'] !== ENV_ISOLATION) {
206
+ throw new Error(`zerops: project envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(project['envIsolation'])}\` (ADR-0004)`)
196
207
  }
197
208
  }
198
209
  if (document.services.length === 0) {
@@ -200,23 +211,24 @@ export const assertZeropsInvariants = (document: ZeropsImportDocument): void =>
200
211
  }
201
212
  const seen = new Set<string>()
202
213
  for (const service of document.services) {
203
- if (service.envIsolation !== ENV_ISOLATION) {
214
+ const hostname = String(service['hostname'])
215
+ if (service['envIsolation'] !== ENV_ISOLATION) {
204
216
  throw new Error(
205
- `zerops: service \`${service.hostname}\` envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(service.envIsolation)}\` (ADR-0004)`,
217
+ `zerops: service \`${hostname}\` envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(service['envIsolation'])}\` (ADR-0004)`,
206
218
  )
207
219
  }
208
220
  // Every service class, managed included. A document missing it anywhere cannot be re-applied at
209
221
  // all: the platform answers `400 serviceStackNameUnavailable` on the first hostname that exists
210
222
  // and rejects the whole import, so one omission breaks every later apply, not just that service.
211
- if (service.override !== OVERRIDE) {
223
+ if (service['override'] !== OVERRIDE) {
212
224
  throw new Error(
213
- `zerops: service \`${service.hostname}\` must carry \`override: true\`; without it a re-apply is rejected as a name collision`,
225
+ `zerops: service \`${hostname}\` must carry \`override: true\`; without it a re-apply is rejected as a name collision`,
214
226
  )
215
227
  }
216
- if (service.envSecrets !== undefined || service.dotEnvSecrets !== undefined) {
228
+ if (service['envSecrets'] !== undefined || service['dotEnvSecrets'] !== undefined) {
217
229
  // Never assert on the VALUE — only that the field is present.
218
230
  throw new Error(
219
- `zerops: service \`${service.hostname}\` carries secrets in the import document; secrets are written through the env API (ADR-0004)`,
231
+ `zerops: service \`${hostname}\` carries secrets in the import document; secrets are written through the env API (ADR-0004)`,
220
232
  )
221
233
  }
222
234
  // `zeropsSetup` is part of the import's pipeline configuration, and the platform rejects the WHOLE
@@ -230,15 +242,15 @@ export const assertZeropsInvariants = (document: ZeropsImportDocument): void =>
230
242
  // Nothing is lost by dropping the setup name here: it is only redundant restatement of Zerops' own
231
243
  // "setup name defaults to the hostname" rule, and a deploy that needs to say otherwise passes
232
244
  // `zeropsSetup` at TRIGGER time, where no repository is required.
233
- if (service.zeropsSetup !== undefined && service.buildFromGit === undefined) {
245
+ if (service['zeropsSetup'] !== undefined && service['buildFromGit'] === undefined) {
234
246
  throw new Error(
235
- `zerops: service \`${service.hostname}\` names \`zeropsSetup\` without \`buildFromGit\`; the platform rejects the import (pass the setup when triggering the pipeline instead)`,
247
+ `zerops: service \`${hostname}\` names \`zeropsSetup\` without \`buildFromGit\`; the platform rejects the import (pass the setup when triggering the pipeline instead)`,
236
248
  )
237
249
  }
238
- if (seen.has(service.hostname)) {
239
- throw new Error(`zerops: duplicate service hostname \`${service.hostname}\` in the import document`)
250
+ if (seen.has(hostname)) {
251
+ throw new Error(`zerops: duplicate service hostname \`${hostname}\` in the import document`)
240
252
  }
241
- seen.add(service.hostname)
253
+ seen.add(hostname)
242
254
  }
243
255
  }
244
256
 
@@ -257,13 +269,57 @@ export const compileImport = (input: CompileInput): ZeropsImportDocument => {
257
269
  /** Keys/scalars that are safe unquoted in YAML. Everything else gets a double-quoted, JSON-escaped form. */
258
270
  const PLAIN = /^[A-Za-z_][A-Za-z0-9_./@+-]*$/
259
271
 
272
+ /**
273
+ * Code points a YAML 1.1 reader (go-yaml, which parses this on Zerops) ends a line on or refuses outright,
274
+ * while Bun's 1.2 reader takes them for ordinary text — so no test using Bun can see the difference. `\n`
275
+ * and `\r` are excluded: the block form owns one and `blockScalarSafe` refuses the other.
276
+ */
277
+ const isYamlHostileCodePoint = (code: number): boolean =>
278
+ (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)
279
+ || (code >= 0x7f && code <= 0x9f)
280
+ || code === 0x2028
281
+ || code === 0x2029
282
+ || code === 0xfeff
283
+ || code === 0xfffe
284
+ || code === 0xffff
285
+ || (code >= 0xd800 && code <= 0xdfff)
286
+
287
+ /** `JSON.stringify` leaves DEL, the C1 block, U+2028/9 and U+FEFF raw; YAML's own `\uXXXX` carries them. */
288
+ const escapeYamlHostile = (quoted: string): string =>
289
+ [...quoted].map((character) => {
290
+ const code = character.codePointAt(0) ?? 0
291
+ return isYamlHostileCodePoint(code) ? `\\u${code.toString(16).padStart(4, '0')}` : character
292
+ }).join('')
293
+
260
294
  const scalar = (value: string): string => {
261
295
  // Reserved plain scalars that YAML would read as booleans/null, plus anything with structure.
262
- const reserved = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'])
296
+ const reserved = new Set(['true', 'false', 'null', 'yes', 'no', 'y', 'n', 'on', 'off', '~'])
263
297
  if (value !== '' && PLAIN.test(value) && !reserved.has(value.toLowerCase())) {
264
298
  return value
265
299
  }
266
- return JSON.stringify(value)
300
+ return escapeYamlHostile(JSON.stringify(value))
301
+ }
302
+
303
+ /**
304
+ * A `|-` block reproduces the string only when the text owns its own lines. An indented or blank first
305
+ * line moves (or breaks) the block's own indentation, `\r` and a blank line do not survive it, and `-`
306
+ * eats a trailing newline. A code point a YAML 1.1 reader breaks a line on cannot go in at all — the block
307
+ * is the one form with no escapes. Everything else goes out double-quoted, which is exact YAML.
308
+ */
309
+ const blockScalarSafe = (value: string): boolean => {
310
+ if (!value.includes('\n') || value.includes('\r')) {
311
+ return false
312
+ }
313
+ if ([...value].some((character) => isYamlHostileCodePoint(character.codePointAt(0) ?? 0))) {
314
+ return false
315
+ }
316
+ const lines = value.split('\n')
317
+ const first = lines[0] ?? ''
318
+ if (first === '' || first.startsWith(' ') || first.startsWith('\t')) {
319
+ return false
320
+ }
321
+ // A blank line loses its whitespace, and an empty last line is the trailing newline `-` strips.
322
+ return !lines.some((line, index) => line.trim() === '' && (line !== '' || index === lines.length - 1))
267
323
  }
268
324
 
269
325
  const isArray = (value: unknown): value is unknown[] => Array.isArray(value)
@@ -316,9 +372,8 @@ const emit = (value: unknown, indent: number): string[] => {
316
372
  return lines
317
373
  }
318
374
  if (typeof value === 'string') {
319
- if (value.includes('\n')) {
320
- const body = value.replace(/\n$/, '').split('\n').map((line) => `${pad} ${line}`)
321
- return [`${pad}|-`, ...body]
375
+ if (blockScalarSafe(value)) {
376
+ return [`${pad}|-`, ...value.split('\n').map((line) => `${pad} ${line}`)]
322
377
  }
323
378
  return [`${pad}${scalar(value)}`]
324
379
  }