@fabrika/provider-zerops 0.0.3 → 0.0.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,9 +32,9 @@
32
32
  "gen:schema": "bun scripts/generate-zerops-types.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@fabrika/auth": "^0.0.3",
36
- "@fabrika/auth-core": "^0.0.3",
37
- "@fabrika/provider-contract": "^0.0.3",
38
- "@fabrika/proxy-contract": "^0.0.3"
35
+ "@fabrika/auth": "^0.0.5",
36
+ "@fabrika/auth-core": "^0.0.5",
37
+ "@fabrika/provider-contract": "^0.0.5",
38
+ "@fabrika/proxy-contract": "^0.0.5"
39
39
  }
40
40
  }
package/src/api.ts CHANGED
@@ -62,6 +62,13 @@ export interface ZeropsAppVersion {
62
62
  build?: { startDate?: string; endDate?: string; pipelineStart?: string; pipelineFinish?: string; pipelineFailed?: string }
63
63
  }
64
64
 
65
+ /** The credential-bearing result of reserving one upload-backed application version. */
66
+ export interface ZeropsAppVersionUpload {
67
+ id: string
68
+ /** Presigned object-storage destination. Never log it or include it in an error. */
69
+ uploadUrl: string
70
+ }
71
+
65
72
  /** An asynchronous platform operation. VERIFIED: `OutDtoProcess`. */
66
73
  export interface ZeropsProcess {
67
74
  id: string
@@ -268,10 +275,10 @@ export interface ZeropsLogLine {
268
275
  * case cancellation was added for, and an abandoned run must not keep an HTTP request alive.
269
276
  *
270
277
  * WIDER THAN THE DRIVER, on purpose. The driver uses four of these; the rest are the CONTROL PLANE's
271
- * Zerops surface — `putServiceEnv`/`listServiceEnv` for the edit-time secret writes that ADR-0004 says are
272
- * not a deploy step, `importProject` for creating a managed project, and `getAppVersion`/`latestAppVersion`
273
- * for the startup reconciliation of in-flight runs that ADR-0003 makes a requirement. One client, so there
274
- * is one place the Zerops contract is written down.
278
+ * Zerops surface — the service-env methods for the edit-time secret writes that ADR-0004 says are not a
279
+ * deploy step, `importProject` for creating a managed project, and `getAppVersion`/`latestAppVersion` for
280
+ * the startup reconciliation of in-flight runs that ADR-0003 makes a requirement. One client, so there is
281
+ * one place the Zerops contract is written down.
275
282
  *
276
283
  * NOTE WHAT IS ABSENT. There is no method that writes a PROJECT-level environment variable. That is not
277
284
  * an oversight and must not be "completed": project-level variables are injected into every service in the
@@ -329,6 +336,27 @@ export interface ZeropsApi {
329
336
  signal: AbortSignal
330
337
  }): Promise<ZeropsProcess | null>
331
338
 
339
+ /**
340
+ * Reserve an upload-backed application version. VERIFIED: `POST /service-stack/{id}/app-version`,
341
+ * optional body `{ name }`, response `ResponsePostAppVersion`.
342
+ */
343
+ createAppVersion(input: { serviceId: string; name?: string; signal: AbortSignal }): Promise<ZeropsAppVersionUpload>
344
+
345
+ /**
346
+ * Build and deploy an application version whose source archive has already been uploaded. VERIFIED:
347
+ * `PUT /app-version/{id}/build-and-deploy`, required body `{ zeropsYaml }` and optional
348
+ * `zeropsYamlSetup`.
349
+ */
350
+ buildAndDeployAppVersion(input: {
351
+ appVersionId: string
352
+ zeropsYaml: string
353
+ zeropsYamlSetup?: string
354
+ signal: AbortSignal
355
+ }): Promise<ZeropsProcess>
356
+
357
+ /** Delete an application version, including an untriggered upload reservation. VERIFIED: `DELETE /app-version/{id}`. */
358
+ deleteAppVersion(input: { appVersionId: string; signal: AbortSignal }): Promise<void>
359
+
332
360
  /** One application version's current state — the poll target. VERIFIED: `GET /app-version/{id}`. */
333
361
  getAppVersion(input: { appVersionId: string; signal: AbortSignal }): Promise<ZeropsAppVersion>
334
362
 
@@ -420,6 +448,16 @@ export interface ZeropsApi {
420
448
  */
421
449
  listServiceEnv(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv[]>
422
450
 
451
+ /**
452
+ * Create ONE service-level variable only when the key is absent.
453
+ * VERIFIED: `POST /service-stack/{id}/user-data`, body `{ key, content }`.
454
+ *
455
+ * Unlike `putServiceEnv`, a duplicate is an error and is never followed by a read or update. Error
456
+ * details are intentionally unavailable because both the request and an upstream response can contain
457
+ * the secret value.
458
+ */
459
+ createServiceEnv(input: { serviceId: string; key: string; value: string; signal: AbortSignal }): Promise<void>
460
+
423
461
  /**
424
462
  * Create-or-update ONE service-level variable — the only way fabrika ever writes a secret, and it is
425
463
  * addressed BY SERVICE so ADR-0004's invariant cannot be violated by calling it.
@@ -745,6 +783,8 @@ const apiError = (label: string, status: number, body: unknown, redactDetail: bo
745
783
  export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
746
784
  const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
747
785
  const doFetch = options.fetchImpl ?? fetch
786
+ const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
787
+ const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
748
788
 
749
789
  const request = async (
750
790
  label: string,
@@ -754,26 +794,56 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
754
794
  * `redactDetail` drops the server's message from the thrown error, keeping only its code. Set it on
755
795
  * any call whose BODY carries a secret value: a validation error can quote what it rejected, and an
756
796
  * error that echoes a secret into a deploy log is a leak whichever end wrote it.
797
+ * `omitErrorResponseDetails` also drops the code for upload flows whose response may carry a
798
+ * credential; nothing from that response body may reach an error.
757
799
  */
758
- init: { body?: unknown; signal: AbortSignal; redactDetail?: boolean },
800
+ init: { body?: unknown; signal: AbortSignal; redactDetail?: boolean; omitErrorResponseDetails?: boolean },
759
801
  ): Promise<unknown> => {
802
+ if (init.signal.aborted) {
803
+ throw abortError()
804
+ }
760
805
  const headers: Record<string, string> = { authorization: `Bearer ${options.token}`, accept: 'application/json' }
761
806
  if (init.body !== undefined) {
762
807
  headers['content-type'] = 'application/json'
763
808
  }
764
- const response = await doFetch(`${base}${path}`, {
765
- method,
766
- headers,
767
- body: init.body === undefined ? undefined : JSON.stringify(init.body),
768
- signal: init.signal,
769
- })
770
- const payload: unknown = await response.json().catch(() => null)
809
+ let response: Response
810
+ try {
811
+ response = await doFetch(`${base}${path}`, {
812
+ method,
813
+ headers,
814
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
815
+ signal: init.signal,
816
+ })
817
+ } catch (error) {
818
+ if (init.signal.aborted || isAbortError(error)) throw abortError()
819
+ throw error
820
+ }
821
+ let payload: unknown
822
+ try {
823
+ payload = await response.json()
824
+ } catch (error) {
825
+ if (init.signal.aborted || isAbortError(error)) throw abortError()
826
+ payload = null
827
+ }
771
828
  if (!response.ok) {
829
+ if (init.omitErrorResponseDetails === true) {
830
+ throw new ZeropsApiError(`zerops: ${label} failed (${response.status})`, response.status, '')
831
+ }
772
832
  throw apiError(label, response.status, payload, init.redactDetail === true)
773
833
  }
774
834
  return payload
775
835
  }
776
836
 
837
+ const invalidResponse = (label: string): Error => new Error(`zerops: ${label} returned an invalid response`)
838
+
839
+ const requiredString = (payload: unknown, key: string, label: string): string => {
840
+ const value = str(payload, key)
841
+ if (value === undefined || value.trim() === '') {
842
+ throw invalidResponse(label)
843
+ }
844
+ return value
845
+ }
846
+
777
847
  const readImportResult = (payload: unknown): ZeropsImportResult => ({
778
848
  projectId: str(payload, 'projectId') ?? '',
779
849
  projectName: str(payload, 'projectName'),
@@ -844,6 +914,48 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
844
914
  return process === undefined || process === null ? null : readProcess(process)
845
915
  },
846
916
 
917
+ createAppVersion: async ({ serviceId, name, signal }) => {
918
+ const body = name === undefined ? {} : { name }
919
+ const payload = await request('create app-version', 'POST', `/service-stack/${serviceId}/app-version`, {
920
+ body,
921
+ signal,
922
+ omitErrorResponseDetails: true,
923
+ })
924
+ const responseServiceId = requiredString(payload, 'serviceStackId', 'create app-version')
925
+ if (responseServiceId !== serviceId) {
926
+ throw invalidResponse('create app-version')
927
+ }
928
+ return {
929
+ id: requiredString(payload, 'id', 'create app-version'),
930
+ uploadUrl: requiredString(payload, 'uploadUrl', 'create app-version'),
931
+ }
932
+ },
933
+
934
+ buildAndDeployAppVersion: async ({ appVersionId, zeropsYaml, zeropsYamlSetup, signal }) => {
935
+ const body = zeropsYamlSetup === undefined ? { zeropsYaml } : { zeropsYaml, zeropsYamlSetup }
936
+ const payload = await request('build and deploy app-version', 'PUT', `/app-version/${appVersionId}/build-and-deploy`, {
937
+ body,
938
+ signal,
939
+ omitErrorResponseDetails: true,
940
+ })
941
+ const processId = requiredString(payload, 'id', 'build and deploy app-version')
942
+ const responseAppVersionId = str(prop(payload, 'appVersion'), 'id')
943
+ if (responseAppVersionId !== undefined && (responseAppVersionId.trim() === '' || responseAppVersionId !== appVersionId)) {
944
+ throw invalidResponse('build and deploy app-version')
945
+ }
946
+ return { ...readProcess(payload), id: processId, appVersionId }
947
+ },
948
+
949
+ deleteAppVersion: async ({ appVersionId, signal }) => {
950
+ const payload = await request('delete app-version', 'DELETE', `/app-version/${appVersionId}`, {
951
+ signal,
952
+ omitErrorResponseDetails: true,
953
+ })
954
+ if (prop(payload, 'success') !== true) {
955
+ throw invalidResponse('delete app-version')
956
+ }
957
+ },
958
+
847
959
  getAppVersion: async ({ appVersionId, signal }) =>
848
960
  readAppVersion(await request('get app-version', 'GET', `/app-version/${appVersionId}`, { signal })),
849
961
 
@@ -904,6 +1016,19 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
904
1016
 
905
1017
  listServiceEnv,
906
1018
 
1019
+ createServiceEnv: async ({ serviceId, key, value, signal }) => {
1020
+ try {
1021
+ await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1022
+ body: { key, content: value },
1023
+ signal,
1024
+ omitErrorResponseDetails: true,
1025
+ })
1026
+ } catch (error) {
1027
+ if (error instanceof Error && error.name === 'AbortError') throw error
1028
+ throw new Error('zerops: create-only service env failed')
1029
+ }
1030
+ },
1031
+
907
1032
  putServiceEnv: async ({ serviceId, key, value, signal }) => {
908
1033
  // `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
909
1034
  try {
package/src/cli.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  #!/usr/bin/env bun
2
+ import { constants } from 'node:fs'
3
+ import { open } from 'node:fs/promises'
2
4
  import { resolve } from 'node:path'
3
5
  import { isZeropsAppConfig } from './authoring'
4
6
  import { parseZeropsCliArgs } from './cli-args'
5
- import { compileFabrikaManifest, manifestServiceHostnames } from './manifest'
7
+ import {
8
+ compileFabrikaManifest,
9
+ createZeropsArtifactSourceDescriptor,
10
+ manifestServiceHostnames,
11
+ ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES,
12
+ } from './manifest'
6
13
  import { runZeropsNamespaceCommand } from './namespace-command'
7
14
  import type { ZeropsAppConfig } from './types'
8
15
 
@@ -47,6 +54,38 @@ const loadConfig = async (path: string): Promise<ZeropsAppConfig> => {
47
54
  return config
48
55
  }
49
56
 
57
+ export const readZeropsSourceDescriptor = async (root: string = process.cwd()) => {
58
+ const path = resolve(root, 'zerops.yaml')
59
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch((error: unknown) => {
60
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
61
+ throw new Error(`Missing repository-root Zerops source descriptor: ${path}`)
62
+ }
63
+ if (error instanceof Error && 'code' in error && error.code === 'ELOOP') {
64
+ throw new Error(`Repository-root Zerops source descriptor must not be a symlink: ${path}`)
65
+ }
66
+ throw error
67
+ })
68
+ try {
69
+ const metadata = await file.stat()
70
+ if (!metadata.isFile()) {
71
+ throw new Error(`Repository-root Zerops source descriptor must be a regular file: ${path}`)
72
+ }
73
+ if (metadata.size > ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES) {
74
+ throw new Error(`Zerops source descriptor exceeds ${ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES} bytes: ${path}`)
75
+ }
76
+ const bytes = await file.readFile()
77
+ let contents: string
78
+ try {
79
+ contents = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
80
+ } catch {
81
+ throw new Error(`Zerops source descriptor is not valid UTF-8: ${path}`)
82
+ }
83
+ return await createZeropsArtifactSourceDescriptor(contents)
84
+ } finally {
85
+ await file.close()
86
+ }
87
+ }
88
+
50
89
  export const runZeropsCli = async (argv: readonly string[]): Promise<void> => {
51
90
  const args = parseZeropsCliArgs(argv)
52
91
  if (args.help || args.command === undefined) {
@@ -68,7 +107,8 @@ export const runZeropsCli = async (argv: readonly string[]): Promise<void> => {
68
107
  throw new Error('`build` requires --env=<env>')
69
108
  }
70
109
  const config = await loadConfig(args.config)
71
- const manifest = compileFabrikaManifest(config, args.env)
110
+ const sourceDescriptor = await readZeropsSourceDescriptor()
111
+ const manifest = compileFabrikaManifest(config, args.env, sourceDescriptor)
72
112
  const output = resolve(process.cwd(), args.output)
73
113
  await Bun.write(output, `${JSON.stringify(manifest, null, '\t')}\n`)
74
114
  console.info(`wrote ${output} (${manifestServiceHostnames(manifest).length} service(s))`)
package/src/codec.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
2
- import type { ZeropsRuntimeTarget } from './types'
2
+ import type { ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
3
3
 
4
4
  const field = (payload: JsonValue, key: string): JsonValue | undefined => {
5
5
  if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
@@ -27,24 +27,82 @@ const optionalString = (payload: JsonValue, key: string): string | undefined =>
27
27
  return value
28
28
  }
29
29
 
30
+ const IMMUTABLE_GIT_OBJECT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/
31
+
32
+ const object = (value: JsonValue | undefined, label: string): { readonly [key: string]: JsonValue } => {
33
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
34
+ throw new Error(`${label} must be an object`)
35
+ }
36
+ return value
37
+ }
38
+
39
+ const runtimeSource = (payload: JsonValue): ZeropsRuntimeSource | undefined => {
40
+ const value = field(payload, 'source')
41
+ if (value === undefined) return undefined
42
+ const source = object(value, 'Zerops target source')
43
+ const repository = object(source['repository'], 'Zerops target source repository')
44
+ const allowedSourceKeys = ['runId', 'repository', 'commitSha', 'githubInstallationId']
45
+ const allowedRepositoryKeys = ['owner', 'name']
46
+ if (Object.keys(source).some((key) => !allowedSourceKeys.includes(key))) {
47
+ throw new Error('Zerops target source contains an unknown field')
48
+ }
49
+ if (Object.keys(repository).some((key) => !allowedRepositoryKeys.includes(key))) {
50
+ throw new Error('Zerops target source repository contains an unknown field')
51
+ }
52
+ const installation = source['githubInstallationId']
53
+ if (installation !== undefined && (typeof installation !== 'number' || !Number.isSafeInteger(installation) || installation <= 0)) {
54
+ throw new Error('Zerops target source githubInstallationId must be a positive safe integer')
55
+ }
56
+ const commitSha = requiredString(source, 'commitSha')
57
+ if (!IMMUTABLE_GIT_OBJECT.test(commitSha)) {
58
+ throw new Error('Zerops target source commitSha must be an exact lowercase Git object id')
59
+ }
60
+ return {
61
+ runId: requiredString(source, 'runId'),
62
+ repository: {
63
+ owner: requiredString(repository, 'owner'),
64
+ name: requiredString(repository, 'name'),
65
+ },
66
+ commitSha,
67
+ ...(installation === undefined ? {} : { githubInstallationId: installation }),
68
+ }
69
+ }
70
+
30
71
  export const zeropsTargetCodec: ProviderCodec<ZeropsRuntimeTarget> = {
31
- version: 1,
72
+ version: 2,
32
73
  encode: (target) => ({
33
74
  projectId: target.projectId,
34
75
  serviceId: target.serviceId,
35
76
  accessToken: target.accessToken,
36
- ...(target.buildFromGit !== undefined ? { buildFromGit: target.buildFromGit } : {}),
77
+ ...(target.source === undefined
78
+ ? {}
79
+ : {
80
+ source: {
81
+ runId: target.source.runId,
82
+ repository: { owner: target.source.repository.owner, name: target.source.repository.name },
83
+ commitSha: target.source.commitSha,
84
+ ...(target.source.githubInstallationId === undefined ? {} : { githubInstallationId: target.source.githubInstallationId }),
85
+ },
86
+ }),
37
87
  ...(target.apiBaseUrl !== undefined ? { apiBaseUrl: target.apiBaseUrl } : {}),
38
88
  ...(target.propustkaUrl !== undefined ? { propustkaUrl: target.propustkaUrl } : {}),
39
89
  ...(target.adminKey !== undefined ? { adminKey: target.adminKey } : {}),
40
90
  }),
41
- decode: (payload) => ({
42
- projectId: requiredString(payload, 'projectId'),
43
- serviceId: requiredString(payload, 'serviceId'),
44
- accessToken: requiredString(payload, 'accessToken'),
45
- ...(optionalString(payload, 'buildFromGit') !== undefined ? { buildFromGit: optionalString(payload, 'buildFromGit') } : {}),
46
- ...(optionalString(payload, 'apiBaseUrl') !== undefined ? { apiBaseUrl: optionalString(payload, 'apiBaseUrl') } : {}),
47
- ...(optionalString(payload, 'propustkaUrl') !== undefined ? { propustkaUrl: optionalString(payload, 'propustkaUrl') } : {}),
48
- ...(optionalString(payload, 'adminKey') !== undefined ? { adminKey: optionalString(payload, 'adminKey') } : {}),
49
- }),
91
+ decode: (payload) => {
92
+ const target = object(payload, 'Zerops target')
93
+ const allowedKeys = ['projectId', 'serviceId', 'accessToken', 'source', 'apiBaseUrl', 'propustkaUrl', 'adminKey']
94
+ if (Object.keys(target).some((key) => !allowedKeys.includes(key))) {
95
+ throw new Error('Zerops target contains an unknown field')
96
+ }
97
+ const source = runtimeSource(payload)
98
+ return {
99
+ projectId: requiredString(payload, 'projectId'),
100
+ serviceId: requiredString(payload, 'serviceId'),
101
+ accessToken: requiredString(payload, 'accessToken'),
102
+ ...(source === undefined ? {} : { source }),
103
+ ...(optionalString(payload, 'apiBaseUrl') !== undefined ? { apiBaseUrl: optionalString(payload, 'apiBaseUrl') } : {}),
104
+ ...(optionalString(payload, 'propustkaUrl') !== undefined ? { propustkaUrl: optionalString(payload, 'propustkaUrl') } : {}),
105
+ ...(optionalString(payload, 'adminKey') !== undefined ? { adminKey: optionalString(payload, 'adminKey') } : {}),
106
+ }
107
+ },
50
108
  }
@@ -1,6 +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
5
  import type { ZeropsRuntimeTarget } from './types'
5
6
 
6
7
  export type Sleeper = (ms: number, signal: AbortSignal) => Promise<void>
@@ -24,6 +25,10 @@ export const defaultSleep: Sleeper = (ms, signal) =>
24
25
 
25
26
  export interface ZeropsCollaborators {
26
27
  api: ZeropsApi
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
27
32
  reconcileSchema: SchemaReconciler
28
33
  sleep: Sleeper
29
34
  }