@fabrika/provider-zerops 0.0.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.
package/src/control.ts ADDED
@@ -0,0 +1,390 @@
1
+ import type {
2
+ ControlProvider,
3
+ JsonValue,
4
+ ProviderCodec,
5
+ ProviderDeployInput,
6
+ ProviderDeploymentNamespace,
7
+ ProviderEnvelope,
8
+ ProviderEnvironment,
9
+ ProviderReconcileOutcome,
10
+ ProviderRegistration,
11
+ ProviderRegistrationInput,
12
+ ProviderTerminalOutcome,
13
+ RuntimeProvider,
14
+ RuntimeProviderRun,
15
+ SchemaReconciler,
16
+ } from '@fabrika/provider-contract'
17
+ import { createZeropsApi, ZEROPS_ACTIVE, ZEROPS_TERMINAL, type ZeropsApi } from './api'
18
+ import { defaultSleep, defaultZeropsCollaborators, type Sleeper } from './collaborators'
19
+ import { type FabrikaManifest, parseFabrikaManifest, renderFabrikaProvisioningYaml, zeropsArtifactCodec } from './manifest'
20
+ import { createZeropsNamespaceCapabilities, type ZeropsNamespaceTarget, zeropsNamespaceTargetCodec } from './namespace'
21
+ import { createZeropsProvider } from './provider'
22
+ import type { ZeropsRuntimeTarget } from './types'
23
+
24
+ /** Provider coordinates that are safe to persist. Credentials are composed only for a live run. */
25
+ export interface ZeropsStoredTarget {
26
+ serviceId: string
27
+ }
28
+
29
+ const property = (payload: JsonValue, key: string): JsonValue | undefined => {
30
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
31
+ throw new Error('Zerops stored target must be an object')
32
+ }
33
+ return payload[key]
34
+ }
35
+
36
+ const requiredString = (payload: JsonValue, key: string): string => {
37
+ const value = property(payload, key)
38
+ if (typeof value !== 'string' || value === '') {
39
+ throw new Error(`Zerops stored target ${key} must be a non-empty string`)
40
+ }
41
+ return value
42
+ }
43
+
44
+ export const zeropsStoredTargetCodec: ProviderCodec<ZeropsStoredTarget> = {
45
+ version: 2,
46
+ encode: (target) => ({ serviceId: target.serviceId }),
47
+ decode: (payload) => ({
48
+ serviceId: requiredString(payload, 'serviceId'),
49
+ }),
50
+ }
51
+
52
+ const decodeEnvelope = <T>(kind: string, envelope: ProviderEnvelope, codec: ProviderCodec<T>): T => {
53
+ if (envelope.provider !== 'zerops') {
54
+ throw new Error(`${kind} belongs to provider "${envelope.provider}", expected "zerops"`)
55
+ }
56
+ if (envelope.version !== codec.version) {
57
+ throw new Error(`${kind} schema version ${envelope.version} is not supported by provider "zerops"`)
58
+ }
59
+ return codec.decode(envelope.payload)
60
+ }
61
+
62
+ const envelope = <T>(codec: ProviderCodec<T>, value: T): ProviderEnvelope => ({
63
+ provider: 'zerops',
64
+ version: codec.version,
65
+ payload: codec.encode(value),
66
+ })
67
+
68
+ export interface ZeropsBeforeDeployInput {
69
+ readonly appId: string
70
+ readonly env: string
71
+ readonly namespaceId: string
72
+ readonly target: ZeropsStoredTarget & {
73
+ readonly projectId: string
74
+ readonly proxyServiceId: string
75
+ }
76
+ readonly artifact: FabrikaManifest
77
+ readonly api: ZeropsApi
78
+ readonly signal: AbortSignal
79
+ }
80
+
81
+ export type ZeropsBeforeDeploy = (input: ZeropsBeforeDeployInput) => Promise<void>
82
+
83
+ export type ZeropsProviderExecutor = (
84
+ provider: RuntimeProvider,
85
+ run: RuntimeProviderRun,
86
+ ) => Promise<ProviderTerminalOutcome>
87
+
88
+ export interface ZeropsControlProviderOptions {
89
+ readonly accessToken: string
90
+ readonly apiBaseUrl?: string
91
+ readonly propustkaUrl?: string
92
+ readonly adminKey?: string
93
+ readonly api?: ZeropsApi
94
+ readonly reconcileSchema?: SchemaReconciler
95
+ readonly sleep?: Sleeper
96
+ readonly execute: ZeropsProviderExecutor
97
+ readonly beforeDeploy?: ZeropsBeforeDeploy
98
+ readonly namespaces?: {
99
+ readonly clientId: string
100
+ readonly proxyBuildFromGit: string
101
+ readonly iamUrl: string
102
+ readonly iamKey: string
103
+ }
104
+ }
105
+
106
+ interface DecodedZeropsEnvironment {
107
+ readonly target: ZeropsStoredTarget
108
+ readonly namespace: ProviderDeploymentNamespace
109
+ readonly namespaceTarget: ZeropsNamespaceTarget
110
+ }
111
+
112
+ const decodeEnvironment = (environment: ProviderEnvironment): DecodedZeropsEnvironment => {
113
+ const target = decodeEnvelope('target', environment.target, zeropsStoredTargetCodec)
114
+ const placement = decodeNamespace(environment)
115
+ return {
116
+ target,
117
+ ...placement,
118
+ }
119
+ }
120
+
121
+ const decodeNamespace = (
122
+ environment: ProviderEnvironment,
123
+ ): Pick<DecodedZeropsEnvironment, 'namespace' | 'namespaceTarget'> => {
124
+ const namespace = environment.namespace
125
+ if (namespace === undefined) {
126
+ throw new Error('Zerops environment requires a deployment namespace')
127
+ }
128
+ if (namespace.id === '' || namespace.env !== environment.env) {
129
+ throw new Error('Zerops deployment namespace has different environment coordinates')
130
+ }
131
+ if (namespace.exclusiveAppId !== undefined && namespace.exclusiveAppId !== environment.appId) {
132
+ throw new Error(`Zerops deployment namespace is exclusive to app \`${namespace.exclusiveAppId}\``)
133
+ }
134
+ return {
135
+ namespace,
136
+ namespaceTarget: decodeEnvelope('namespace target', namespace.target, zeropsNamespaceTargetCodec),
137
+ }
138
+ }
139
+
140
+ const resolvedEnvironment = (
141
+ environment: ProviderEnvironment,
142
+ options: { requireReady: boolean },
143
+ ): DecodedZeropsEnvironment & { projectId: string; proxyServiceId?: string } => {
144
+ const decoded = decodeEnvironment(environment)
145
+ const projectId = decoded.namespaceTarget.projectId
146
+ if (projectId === undefined) {
147
+ throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no project id`)
148
+ }
149
+ if (options.requireReady && decoded.namespaceTarget.ready !== true) {
150
+ throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` is not ready`)
151
+ }
152
+ const proxyServiceId = decoded.namespaceTarget.proxyServiceId
153
+ if (options.requireReady && proxyServiceId === undefined) {
154
+ throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no proxy service id`)
155
+ }
156
+ return {
157
+ ...decoded,
158
+ projectId,
159
+ ...(proxyServiceId === undefined ? {} : { proxyServiceId }),
160
+ }
161
+ }
162
+
163
+ const normalizeRegistration = (input: ProviderRegistrationInput): ProviderRegistration => {
164
+ if (input.app.id !== input.environment.appId) {
165
+ throw new Error(`Zerops environment belongs to app \`${input.environment.appId}\`, expected \`${input.app.id}\``)
166
+ }
167
+ const decoded = decodeEnvironment(input.environment)
168
+ const artifact = parseFabrikaManifest(
169
+ decodeEnvelope('artifact', input.environment.artifact, zeropsArtifactCodec),
170
+ { appId: input.app.id, env: input.environment.env },
171
+ )
172
+ return {
173
+ app: input.app,
174
+ environment: {
175
+ ...input.environment,
176
+ namespace: {
177
+ ...decoded.namespace,
178
+ target: envelope(zeropsNamespaceTargetCodec, decoded.namespaceTarget),
179
+ },
180
+ target: envelope(zeropsStoredTargetCodec, decoded.target),
181
+ artifact: envelope(zeropsArtifactCodec, artifact),
182
+ },
183
+ }
184
+ }
185
+
186
+ const abortSignal = (): AbortSignal => new AbortController().signal
187
+
188
+ /** Build the complete Zerops lifecycle bundle without importing control-plane persistence. */
189
+ export const createZeropsControlProvider = (options: ZeropsControlProviderOptions): ControlProvider => {
190
+ if (options.accessToken === '') {
191
+ throw new Error('Zerops access token must be a non-empty string')
192
+ }
193
+ const api = options.api ?? createZeropsApi({ token: options.accessToken, baseUrl: options.apiBaseUrl })
194
+ const runtimeProvider = createZeropsProvider((target) => {
195
+ const defaults = defaultZeropsCollaborators(target)
196
+ return {
197
+ api,
198
+ reconcileSchema: options.reconcileSchema ?? defaults.reconcileSchema,
199
+ sleep: options.sleep ?? defaultSleep,
200
+ }
201
+ })
202
+ const namespaceCapabilities = options.namespaces === undefined
203
+ ? undefined
204
+ : createZeropsNamespaceCapabilities({
205
+ ...options.namespaces,
206
+ api,
207
+ ...(options.sleep !== undefined ? { sleep: options.sleep } : {}),
208
+ })
209
+
210
+ return {
211
+ id: 'zerops',
212
+ normalizeRegistration,
213
+ deploy: async (input: ProviderDeployInput): Promise<ProviderTerminalOutcome> => {
214
+ const registration = normalizeRegistration({ app: input.app, environment: input.environment })
215
+ const placement = resolvedEnvironment(registration.environment, { requireReady: true })
216
+ const proxyServiceId = placement.proxyServiceId
217
+ if (proxyServiceId === undefined) {
218
+ throw new Error(`Zerops deployment namespace \`${placement.namespace.id}\` has no proxy service id`)
219
+ }
220
+ const artifact = decodeEnvelope('artifact', registration.environment.artifact, zeropsArtifactCodec)
221
+ await options.beforeDeploy?.({
222
+ appId: input.app.id,
223
+ env: input.environment.env,
224
+ namespaceId: placement.namespace.id,
225
+ target: {
226
+ serviceId: placement.target.serviceId,
227
+ projectId: placement.projectId,
228
+ proxyServiceId,
229
+ },
230
+ artifact,
231
+ api,
232
+ signal: input.signal,
233
+ })
234
+ const managedEnvironment = input.managedEnvironment
235
+ const managedEntries = Object.entries(managedEnvironment).sort(([left], [right]) => left.localeCompare(right))
236
+ const managedNames = managedEntries.map(([name]) => name)
237
+ if (input.dryRun) {
238
+ if (managedNames.length > 0) {
239
+ input.events.log(
240
+ ` [dry-run] would reconcile managed environment on service ${placement.target.serviceId}: ${managedNames.join(', ')}`,
241
+ )
242
+ }
243
+ } else {
244
+ const removedNames = new Set(managedEntries.filter(([, value]) => value === null).map(([name]) => name))
245
+ const existing = removedNames.size === 0
246
+ ? []
247
+ : await api.listServiceEnv({ serviceId: placement.target.serviceId, signal: input.signal })
248
+ for (const [name, value] of managedEntries) {
249
+ if (value === null) {
250
+ for (const item of existing) {
251
+ if (item.key === name) await api.deleteServiceEnv({ envId: item.id, signal: input.signal })
252
+ }
253
+ } else {
254
+ await api.putServiceEnv({
255
+ serviceId: placement.target.serviceId,
256
+ key: name,
257
+ value,
258
+ signal: input.signal,
259
+ })
260
+ }
261
+ }
262
+ }
263
+ const runtimeTarget: ZeropsRuntimeTarget = {
264
+ projectId: placement.projectId,
265
+ serviceId: placement.target.serviceId,
266
+ accessToken: options.accessToken,
267
+ ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}),
268
+ ...(options.propustkaUrl !== undefined ? { propustkaUrl: options.propustkaUrl } : {}),
269
+ ...(options.adminKey !== undefined ? { adminKey: options.adminKey } : {}),
270
+ }
271
+ return options.execute(runtimeProvider.runtime, {
272
+ appId: input.app.id,
273
+ env: input.environment.env,
274
+ ...(input.environment.domain !== undefined ? { domain: input.environment.domain } : {}),
275
+ ...(input.returnOrigins === undefined ? {} : { returnOrigins: input.returnOrigins }),
276
+ cwd: input.app.source.workerDir ?? '.',
277
+ secrets: input.secrets,
278
+ vars: input.vars,
279
+ managedEnvironment: {},
280
+ dryRun: input.dryRun,
281
+ signal: input.signal,
282
+ events: input.events,
283
+ target: runtimeProvider.encodeTarget(runtimeTarget),
284
+ artifact: registration.environment.artifact,
285
+ })
286
+ },
287
+ cancel: async (input) => {
288
+ resolvedEnvironment(input.environment, { requireReady: false })
289
+ await api.cancelBuild({ appVersionId: input.externalId, signal: abortSignal() })
290
+ },
291
+ reconcile: async (input): Promise<ProviderReconcileOutcome> => {
292
+ const signal = abortSignal()
293
+ const placement = resolvedEnvironment(input.environment, { requireReady: false })
294
+ const version = await api.getAppVersion({ appVersionId: input.externalId, signal })
295
+ if (version.status === ZEROPS_ACTIVE) {
296
+ const artifact = parseFabrikaManifest(
297
+ decodeEnvelope('artifact', input.environment.artifact, zeropsArtifactCodec),
298
+ { appId: input.environment.appId, env: input.environment.env },
299
+ )
300
+ if (artifact.app.schema !== undefined && options.propustkaUrl !== undefined) {
301
+ const runtimeTarget: ZeropsRuntimeTarget = {
302
+ projectId: placement.projectId,
303
+ serviceId: placement.target.serviceId,
304
+ accessToken: options.accessToken,
305
+ ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}),
306
+ propustkaUrl: options.propustkaUrl,
307
+ ...(options.adminKey !== undefined ? { adminKey: options.adminKey } : {}),
308
+ }
309
+ const reconcileSchema = options.reconcileSchema ?? defaultZeropsCollaborators(runtimeTarget).reconcileSchema
310
+ await reconcileSchema({
311
+ url: options.propustkaUrl,
312
+ app: artifact.app.id,
313
+ schema: artifact.app.schema,
314
+ ...(input.returnOrigins === undefined || input.returnOrigins.length === 0 ? {} : { returnOrigins: input.returnOrigins }),
315
+ adminKey: options.adminKey,
316
+ signal,
317
+ })
318
+ }
319
+ return { state: 'succeeded' }
320
+ }
321
+ if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
322
+ return { state: 'failed' }
323
+ }
324
+ return { state: 'running' }
325
+ },
326
+ secrets: {
327
+ put: async (input) => {
328
+ const target = decodeEnvironment(input.environment).target
329
+ await api.putServiceEnv({
330
+ serviceId: target.serviceId,
331
+ key: input.name,
332
+ value: input.value,
333
+ signal: abortSignal(),
334
+ })
335
+ return { valueRef: `zerops:${target.serviceId}/${encodeURIComponent(input.name)}` }
336
+ },
337
+ delete: async (input) => {
338
+ const target = decodeEnvironment(input.environment).target
339
+ const variables = await api.listServiceEnv({ serviceId: target.serviceId, signal: abortSignal() })
340
+ const found = variables.find((variable) => variable.key === input.name)
341
+ if (found !== undefined) {
342
+ await api.deleteServiceEnv({ envId: found.id, signal: abortSignal() })
343
+ }
344
+ },
345
+ },
346
+ ...(namespaceCapabilities === undefined
347
+ ? {}
348
+ : {
349
+ namespaces: {
350
+ ...namespaceCapabilities,
351
+ prepareRegistration: async (input) => {
352
+ const { namespace, namespaceTarget } = decodeNamespace(input.registration.environment)
353
+ if (namespaceTarget.ready !== true || namespaceTarget.projectId === undefined) {
354
+ throw new Error(`Zerops deployment namespace \`${namespace.id}\` is not ready`)
355
+ }
356
+ const manifest = parseFabrikaManifest(
357
+ decodeEnvelope('artifact', input.registration.environment.artifact, zeropsArtifactCodec),
358
+ {
359
+ appId: input.registration.app.id,
360
+ env: input.registration.environment.env,
361
+ },
362
+ )
363
+ const imported = await api.importServices({
364
+ projectId: namespaceTarget.projectId,
365
+ yaml: renderFabrikaProvisioningYaml(manifest),
366
+ signal: input.signal,
367
+ })
368
+ const reported = imported.services.find((service) => service.name === manifest.target.deployService)
369
+ const discovered = reported === undefined
370
+ ? await api.findService({
371
+ projectId: namespaceTarget.projectId,
372
+ hostname: manifest.target.deployService,
373
+ signal: input.signal,
374
+ })
375
+ : reported
376
+ if (discovered === null || discovered === undefined || discovered.id === '') {
377
+ throw new Error(`Zerops import did not create deploy service \`${manifest.target.deployService}\``)
378
+ }
379
+ return normalizeRegistration({
380
+ app: input.registration.app,
381
+ environment: {
382
+ ...input.registration.environment,
383
+ target: envelope(zeropsStoredTargetCodec, { serviceId: discovered.id }),
384
+ },
385
+ })
386
+ },
387
+ },
388
+ }),
389
+ }
390
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ export { asAppVersionStatus, createZeropsApi, ZEROPS_ACTIVE, ZEROPS_API_BASE, ZEROPS_SERVICE_NOT_HTTP, ZEROPS_TERMINAL, ZeropsApiError } from './api'
2
+ export type {
3
+ FetchLike,
4
+ ZeropsApi,
5
+ ZeropsApiOptions,
6
+ ZeropsAppVersion,
7
+ ZeropsAppVersionStatus,
8
+ ZeropsImportedService,
9
+ ZeropsImportResult,
10
+ ZeropsLogAccess,
11
+ ZeropsLogLine,
12
+ ZeropsProcess,
13
+ ZeropsProject,
14
+ ZeropsProjectMode,
15
+ ZeropsProjectStatus,
16
+ ZeropsService,
17
+ ZeropsServiceEnv,
18
+ ZeropsServiceStatus,
19
+ } from './api'
20
+ export { defineApp, isZeropsAppConfig, useSharedPostgres, zeropsSharedServiceHostname, zeropsSharedServicePrefix } from './authoring'
21
+ export type { ZeropsAuthoredAppConfig } from './authoring'
22
+ export { parseZeropsCliArgs } from './cli-args'
23
+ export type { ZeropsCliArgs } from './cli-args'
24
+ export { zeropsTargetCodec } from './codec'
25
+ export { defaultSleep, defaultZeropsCollaborators } from './collaborators'
26
+ export type { Sleeper, ZeropsCollaboratorFactory, ZeropsCollaborators } from './collaborators'
27
+ export {
28
+ assertZeropsInvariants,
29
+ compileImport,
30
+ compileImportYaml,
31
+ compileProvisioningYaml,
32
+ ENV_ISOLATION,
33
+ OVERRIDE,
34
+ renderImportYaml,
35
+ renderYaml,
36
+ } from './compile'
37
+ export type { CompileInput, ZeropsImportDocument } from './compile'
38
+ export { createZeropsControlProvider, zeropsStoredTargetCodec } from './control'
39
+ export type { ZeropsBeforeDeploy, ZeropsBeforeDeployInput, ZeropsControlProviderOptions, ZeropsProviderExecutor, ZeropsStoredTarget } from './control'
40
+ export {
41
+ compileFabrikaManifest,
42
+ FABRIKA_MANIFEST_VERSION,
43
+ manifestServiceHostnames,
44
+ parseFabrikaManifest,
45
+ renderFabrikaImportYaml,
46
+ ZEROPS_SERVICE_HOSTNAME_PATTERN,
47
+ zeropsArtifactCodec,
48
+ } from './manifest'
49
+ export type { FabrikaImportDocument, FabrikaManifest, ManifestExpectation } from './manifest'
50
+ export {
51
+ compileZeropsNamespaceTopology,
52
+ createZeropsNamespaceCapabilities,
53
+ createZeropsNamespaceOperator,
54
+ ZEROPS_NAMESPACE_IAM_KEY_VARIABLE,
55
+ ZEROPS_NAMESPACE_IAM_URL_VARIABLE,
56
+ ZEROPS_NAMESPACE_POSTGRES_HOSTNAME,
57
+ ZEROPS_NAMESPACE_PROXY_HOSTNAME,
58
+ ZEROPS_SHARED_POSTGRES_CONNECTION_STRING,
59
+ zeropsNamespacePreset,
60
+ zeropsNamespaceTargetCodec,
61
+ } from './namespace'
62
+ export type {
63
+ ZeropsNamespaceOperatorOptions,
64
+ ZeropsNamespaceOptions,
65
+ ZeropsNamespacePlanOptions,
66
+ ZeropsNamespacePostgres,
67
+ ZeropsNamespacePresetInput,
68
+ ZeropsNamespacePublicAccess,
69
+ ZeropsNamespaceTarget,
70
+ ZeropsNamespaceTopology,
71
+ } from './namespace'
72
+ export { runZeropsNamespaceCommand } from './namespace-command'
73
+ export type { ZeropsNamespaceCommandDependencies } from './namespace-command'
74
+ export { buildZeropsPlan, resolveDeployHostname } from './plan'
75
+ export type { ZeropsJobSpec, ZeropsPlan, ZeropsStepKind } from './plan'
76
+ export { CANCELLED, createZeropsProvider, interpolateManifest, zeropsProvider } from './provider'
77
+ export type { ZeropsProvider } from './provider'
78
+ export type * from './schema.generated'
79
+ export type {
80
+ ZeropsAppConfig,
81
+ ZeropsCompilerOwnedProjectField,
82
+ ZeropsCompilerOwnedServiceField,
83
+ ZeropsDuration,
84
+ ZeropsHealthCheckSpec,
85
+ ZeropsNamespaceResourceRequirement,
86
+ ZeropsProjectSpec,
87
+ ZeropsProxySpec,
88
+ ZeropsReadinessCheckSpec,
89
+ ZeropsResourceContext,
90
+ ZeropsRuntimeTarget,
91
+ ZeropsServiceSpec,
92
+ ZeropsSharedPostgresBinding,
93
+ ZeropsSourceTarget,
94
+ ZeropsYamlDeploySpec,
95
+ ZeropsYamlRunSpec,
96
+ ZeropsYamlSetupSpec,
97
+ ZeropsYamlSpec,
98
+ } from './types'