@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.
- package/package.json +18 -5
- package/src/api/client.ts +522 -0
- package/src/api/errors.ts +50 -0
- package/src/api/polling.ts +59 -0
- package/src/api/readers.ts +185 -0
- package/src/api/service-env-response.ts +34 -0
- package/src/api/types.ts +602 -0
- package/src/api.ts +29 -1378
- package/src/authoring.ts +7 -10
- package/src/cli-args.ts +0 -5
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +75 -20
- package/src/control.ts +217 -447
- package/src/descriptor.ts +135 -0
- package/src/index.ts +24 -128
- package/src/installation/admin-options.ts +128 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +155 -0
- package/src/installation/deploy.ts +668 -0
- package/src/installation/generated/platform-console-schema.ts +41 -0
- package/src/installation/generated/platform-proxy-manifest.ts +72 -0
- package/src/installation/hosts.ts +117 -0
- package/src/installation/index.ts +366 -0
- package/src/installation/init.ts +521 -0
- package/src/installation/install-collaborators.ts +28 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +193 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +286 -0
- package/src/installation/installation-source.ts +42 -0
- package/src/installation/log.ts +77 -0
- package/src/installation/machine-key.ts +65 -0
- package/src/installation/manifest.ts +95 -0
- package/src/installation/placement-options.ts +33 -0
- package/src/installation/platform-source.ts +106 -0
- package/src/installation/platform-upload.ts +34 -0
- package/src/installation/proxy-manifest.ts +113 -0
- package/src/installation/secrets.ts +79 -0
- package/src/installation/sidecar.ts +122 -0
- package/src/installation/source-options.ts +12 -0
- package/src/installation/templates/README.md +116 -0
- package/src/installation/templates/fabrika.ref +1 -0
- package/src/installation/templates/gitignore +3 -0
- package/src/installation/templates/platform.yml +144 -0
- package/src/installation/topology/artifacts.ts +141 -0
- package/src/installation/topology/index.ts +13 -0
- package/src/installation/topology/installation-schemas.ts +225 -0
- package/src/installation/topology/invariants.ts +86 -0
- package/src/installation/topology/setups.ts +442 -0
- package/src/installation/topology/topology.ts +501 -0
- package/src/installation/topology/validate.ts +26 -0
- package/src/installation/upgrade-options.ts +108 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +105 -0
- package/src/manifest.ts +208 -315
- package/src/namespace/claims.ts +77 -0
- package/src/namespace/codec.ts +179 -0
- package/src/namespace/constants.ts +30 -0
- package/src/namespace/errors.ts +60 -0
- package/src/namespace/manifest.ts +28 -0
- package/src/namespace/operator.ts +151 -0
- package/src/namespace/provisioning.ts +482 -0
- package/src/namespace/proxy-attempt.ts +220 -0
- package/src/namespace/proxy-source.ts +22 -0
- package/src/namespace/topology.ts +115 -0
- package/src/namespace/types.ts +114 -0
- package/src/namespace.ts +34 -1260
- package/src/plan.ts +21 -23
- package/src/provider.ts +399 -254
- package/src/service-references.ts +28 -0
- package/src/source/constants.ts +11 -0
- package/src/source/port.ts +49 -0
- package/src/source/types.ts +4 -0
- package/src/source/validation.ts +72 -0
- package/src/source.ts +2 -1220
- package/src/types.ts +8 -7
- package/src/version-attempt.ts +18 -0
- package/src/codec.ts +0 -108
- package/src/namespace-command.ts +0 -209
- package/src/source-connection.ts +0 -344
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
"bun": "./src/cli.ts",
|
|
25
25
|
"types": "./src/cli.ts",
|
|
26
26
|
"default": "./src/cli.ts"
|
|
27
|
+
},
|
|
28
|
+
"./installation": {
|
|
29
|
+
"bun": "./src/installation/index.ts",
|
|
30
|
+
"types": "./src/installation/index.ts",
|
|
31
|
+
"default": "./src/installation/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"./installation/topology": {
|
|
34
|
+
"bun": "./src/installation/topology/index.ts",
|
|
35
|
+
"types": "./src/installation/topology/index.ts",
|
|
36
|
+
"default": "./src/installation/topology/index.ts"
|
|
27
37
|
}
|
|
28
38
|
},
|
|
29
39
|
"scripts": {
|
|
@@ -32,9 +42,12 @@
|
|
|
32
42
|
"gen:schema": "bun scripts/generate-zerops-types.ts"
|
|
33
43
|
},
|
|
34
44
|
"dependencies": {
|
|
35
|
-
"
|
|
36
|
-
"@fabrika/auth
|
|
37
|
-
"@fabrika/
|
|
38
|
-
"@fabrika/
|
|
45
|
+
"zod": "^3.25.76",
|
|
46
|
+
"@fabrika/auth": "^0.3.1",
|
|
47
|
+
"@fabrika/auth-core": "^0.3.1",
|
|
48
|
+
"@fabrika/installation-init": "^0.3.1",
|
|
49
|
+
"@fabrika/provider-contract": "^0.3.1",
|
|
50
|
+
"@fabrika/proxy-core": "^0.3.1",
|
|
51
|
+
"yaml": "^2.9.1"
|
|
39
52
|
}
|
|
40
53
|
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Sleeper } from '../collaborators'
|
|
3
|
+
import { apiError, isAppVersionNotFoundResponse, ZeropsApiError } from './errors'
|
|
4
|
+
import { waitForProcess } from './polling'
|
|
5
|
+
import {
|
|
6
|
+
appVersionListSchema,
|
|
7
|
+
appVersionSchema,
|
|
8
|
+
importResultSchema,
|
|
9
|
+
integrationTokenSchema,
|
|
10
|
+
logAccessSchema,
|
|
11
|
+
processSchema,
|
|
12
|
+
projectMatchesSchema,
|
|
13
|
+
projectSchema,
|
|
14
|
+
readResponse,
|
|
15
|
+
serviceEnvSchema,
|
|
16
|
+
serviceSchema,
|
|
17
|
+
} from './readers'
|
|
18
|
+
import { parseServiceEnvResponse } from './service-env-response'
|
|
19
|
+
import type { ZeropsApi, ZeropsApiOptions, ZeropsAppVersion, ZeropsLogLine } from './types'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `sensitive` on every user-data WRITE, create and update alike. Required by the platform — omitting it answers
|
|
23
|
+
* `400 invalidUserInput` with `{"sensitive":["field is required"]}` (measured live, 2026-08-18) — and
|
|
24
|
+
* `true` because most of what fabrika writes is a credential. It costs nothing to read: a sensitive
|
|
25
|
+
* variable's `content` still comes back verbatim from `GET /service-stack/{id}/env`, also measured, so the
|
|
26
|
+
* compare-before-write both `platform deploy` and the create-only credential slots depend on still works.
|
|
27
|
+
*/
|
|
28
|
+
const SENSITIVE_USER_DATA = true
|
|
29
|
+
|
|
30
|
+
/** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
|
|
31
|
+
const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
|
32
|
+
|
|
33
|
+
const appVersionReservationSchema = z.object({
|
|
34
|
+
id: z.string().refine((value) => value.trim() !== ''),
|
|
35
|
+
uploadUrl: z.string().refine((value) => value.trim() !== ''),
|
|
36
|
+
serviceStackId: z.string().refine((value) => value.trim() !== ''),
|
|
37
|
+
})
|
|
38
|
+
const triggerPipelineSchema = z.object({ process: processSchema.nullish() })
|
|
39
|
+
const deleteAppVersionSchema = z.object({ success: z.literal(true) })
|
|
40
|
+
|
|
41
|
+
// ── default (real) implementation ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Base URL of the Zerops public REST API. */
|
|
44
|
+
export const ZEROPS_API_BASE = 'https://api.app-prg1.zerops.io/api/rest/public'
|
|
45
|
+
|
|
46
|
+
/** The platform's code for "this service stack already has a variable under that key". */
|
|
47
|
+
const USER_DATA_DUPLICATE_KEY = 'userDataDuplicateKey'
|
|
48
|
+
|
|
49
|
+
/** The platform's code for a service stack it cannot find — sent with a 400, not a 404. */
|
|
50
|
+
const ZEROPS_SERVICE_STACK_NOT_FOUND = 'serviceStackNotFound'
|
|
51
|
+
|
|
52
|
+
const timerSleep: Sleeper = (ms, signal) =>
|
|
53
|
+
new Promise<void>((resolve) => {
|
|
54
|
+
if (signal.aborted) {
|
|
55
|
+
resolve()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
signal.removeEventListener('abort', onAbort)
|
|
60
|
+
resolve()
|
|
61
|
+
}, ms)
|
|
62
|
+
const onAbort = (): void => {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
resolve()
|
|
65
|
+
}
|
|
66
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** Build the real client. Every request carries the bearer and the run's signal. */
|
|
70
|
+
export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
71
|
+
const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
|
|
72
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
73
|
+
// Its own, rather than `collaborators`' — that module imports this one, so taking the value back would close a cycle.
|
|
74
|
+
const sleep = options.sleep ?? timerSleep
|
|
75
|
+
const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
|
|
76
|
+
const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
|
|
77
|
+
const SERVICE_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024
|
|
78
|
+
class ResponseByteBoundError extends Error {}
|
|
79
|
+
|
|
80
|
+
const boundedJson = async (response: Response, label: string, maxBytes: number, signal: AbortSignal): Promise<unknown> => {
|
|
81
|
+
if (signal.aborted) {
|
|
82
|
+
void response.body?.cancel().catch(() => {})
|
|
83
|
+
throw abortError()
|
|
84
|
+
}
|
|
85
|
+
const contentLength = response.headers.get('content-length')
|
|
86
|
+
if (contentLength !== null) {
|
|
87
|
+
const declaredBytes = Number(contentLength)
|
|
88
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
89
|
+
void response.body?.cancel().catch(() => {})
|
|
90
|
+
throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (response.body === null) return null
|
|
94
|
+
const reader = response.body.getReader()
|
|
95
|
+
let abortRead = (): void => {}
|
|
96
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
97
|
+
abortRead = (): void => {
|
|
98
|
+
void reader.cancel().catch(() => {})
|
|
99
|
+
reject(abortError())
|
|
100
|
+
}
|
|
101
|
+
signal.addEventListener('abort', abortRead, { once: true })
|
|
102
|
+
if (signal.aborted) abortRead()
|
|
103
|
+
})
|
|
104
|
+
const chunks: Uint8Array[] = []
|
|
105
|
+
let receivedBytes = 0
|
|
106
|
+
try {
|
|
107
|
+
while (true) {
|
|
108
|
+
const next = await Promise.race([reader.read(), aborted])
|
|
109
|
+
if (signal.aborted) throw abortError()
|
|
110
|
+
if (next.done) break
|
|
111
|
+
receivedBytes += next.value.byteLength
|
|
112
|
+
if (receivedBytes > maxBytes) {
|
|
113
|
+
void reader.cancel().catch(() => {})
|
|
114
|
+
throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
|
|
115
|
+
}
|
|
116
|
+
chunks.push(next.value)
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
signal.removeEventListener('abort', abortRead)
|
|
120
|
+
}
|
|
121
|
+
const bytes = new Uint8Array(receivedBytes)
|
|
122
|
+
let offset = 0
|
|
123
|
+
for (const chunk of chunks) {
|
|
124
|
+
bytes.set(chunk, offset)
|
|
125
|
+
offset += chunk.byteLength
|
|
126
|
+
}
|
|
127
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const request = async (
|
|
131
|
+
label: string,
|
|
132
|
+
method: string,
|
|
133
|
+
path: string,
|
|
134
|
+
/**
|
|
135
|
+
* `redactDetail` drops the server's message from the thrown error, keeping only its code. Set it on
|
|
136
|
+
* any call whose BODY carries a secret value: a validation error can quote what it rejected, and an
|
|
137
|
+
* error that echoes a secret into a deploy log is a leak whichever end wrote it.
|
|
138
|
+
* `omitErrorResponseDetails` also drops the code for upload flows whose response may carry a
|
|
139
|
+
* credential; nothing from that response body may reach an error.
|
|
140
|
+
*/
|
|
141
|
+
init: {
|
|
142
|
+
body?: unknown
|
|
143
|
+
signal: AbortSignal
|
|
144
|
+
redactDetail?: boolean
|
|
145
|
+
omitErrorResponseDetails?: boolean
|
|
146
|
+
/** Pretrigger reservation cleanup needs verified absence, never arbitrary response text or codes. */
|
|
147
|
+
preserveAppVersionNotFound?: boolean
|
|
148
|
+
responseMaxBytes?: number
|
|
149
|
+
},
|
|
150
|
+
): Promise<unknown> => {
|
|
151
|
+
if (init.signal.aborted) {
|
|
152
|
+
throw abortError()
|
|
153
|
+
}
|
|
154
|
+
const headers: Record<string, string> = { authorization: `Bearer ${options.token}`, accept: 'application/json' }
|
|
155
|
+
if (init.body !== undefined) {
|
|
156
|
+
headers['content-type'] = 'application/json'
|
|
157
|
+
}
|
|
158
|
+
let response: Response
|
|
159
|
+
try {
|
|
160
|
+
response = await doFetch(`${base}${path}`, {
|
|
161
|
+
method,
|
|
162
|
+
headers,
|
|
163
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
164
|
+
signal: init.signal,
|
|
165
|
+
})
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (init.signal.aborted || isAbortError(error)) throw abortError()
|
|
168
|
+
throw error
|
|
169
|
+
}
|
|
170
|
+
let payload: unknown
|
|
171
|
+
try {
|
|
172
|
+
payload = init.responseMaxBytes === undefined
|
|
173
|
+
? await response.json()
|
|
174
|
+
: await boundedJson(response, label, init.responseMaxBytes, init.signal)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (init.signal.aborted || isAbortError(error)) throw abortError()
|
|
177
|
+
if (error instanceof ResponseByteBoundError) throw error
|
|
178
|
+
payload = null
|
|
179
|
+
}
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
if (init.omitErrorResponseDetails === true) {
|
|
182
|
+
if (init.preserveAppVersionNotFound === true && response.status === 400 && isAppVersionNotFoundResponse(payload)) {
|
|
183
|
+
throw new ZeropsApiError(`zerops: ${label} failed (400) — appVersionNotFound`, 400, 'appVersionNotFound')
|
|
184
|
+
}
|
|
185
|
+
throw new ZeropsApiError(`zerops: ${label} failed (${response.status})`, response.status, '')
|
|
186
|
+
}
|
|
187
|
+
throw apiError(label, response.status, payload, init.redactDetail === true)
|
|
188
|
+
}
|
|
189
|
+
return payload
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const invalidResponse = (label: string): Error => new Error(`zerops: ${label} returned an invalid response`)
|
|
193
|
+
|
|
194
|
+
const PAGE_SIZE = 100
|
|
195
|
+
const readAllPages = async <Item>(
|
|
196
|
+
label: string,
|
|
197
|
+
path: string,
|
|
198
|
+
signal: AbortSignal,
|
|
199
|
+
itemSchema: z.ZodType<Item, z.ZodTypeDef, unknown>,
|
|
200
|
+
): Promise<Item[]> => {
|
|
201
|
+
const pageSchema = z.object({ list: z.array(itemSchema), total: z.number().int().nonnegative() })
|
|
202
|
+
const items: Item[] = []
|
|
203
|
+
let offset = 0
|
|
204
|
+
while (true) {
|
|
205
|
+
const separator = path.includes('?') ? '&' : '?'
|
|
206
|
+
const payload = await request(label, 'GET', `${path}${separator}limit=${PAGE_SIZE}&offset=${offset}`, { signal })
|
|
207
|
+
const { list: page, total } = readResponse(label, pageSchema, payload)
|
|
208
|
+
items.push(...page)
|
|
209
|
+
if (items.length >= total) {
|
|
210
|
+
return items
|
|
211
|
+
}
|
|
212
|
+
if (page.length === 0) {
|
|
213
|
+
throw new Error(`zerops: ${label} ended before total`)
|
|
214
|
+
}
|
|
215
|
+
offset += page.length
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Named so `putServiceEnv` can reach it: the conflict path needs the record id, and the ONE endpoint
|
|
221
|
+
* that hands it over on every service is `/env` (`/user-data` answers 400 unconditionally).
|
|
222
|
+
*/
|
|
223
|
+
const listServiceEnv: ZeropsApi['listServiceEnv'] = async ({ serviceId, signal }) => {
|
|
224
|
+
const payload = await request('list service env', 'GET', `/service-stack/${serviceId}/env`, {
|
|
225
|
+
signal,
|
|
226
|
+
responseMaxBytes: SERVICE_ENV_RESPONSE_MAX_BYTES,
|
|
227
|
+
})
|
|
228
|
+
try {
|
|
229
|
+
return parseServiceEnvResponse(payload, serviceId)
|
|
230
|
+
} catch {
|
|
231
|
+
throw invalidResponse('list service env')
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const getProcess: ZeropsApi['getProcess'] = async ({ processId, signal }) =>
|
|
236
|
+
readResponse('get process', processSchema, await request('get process', 'GET', `/process/${processId}`, { signal }))
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Wait out the `stack.updateUserData` process every user-data write answers with.
|
|
240
|
+
*
|
|
241
|
+
* While one runs the platform refuses the NEXT operation on that service with `400
|
|
242
|
+
* userDataSyncRunning` — which is how a deploy that wrote an app's environment and then asked for a
|
|
243
|
+
* build failed, one second before the sync it had started finished. Measured live: PENDING → RUNNING →
|
|
244
|
+
* FINISHED in about three seconds, on a deployed service and on one that has never had code alike.
|
|
245
|
+
*
|
|
246
|
+
* The response is recognised by `actionName`, not by having an `id`: a user-data RECORD carries an id
|
|
247
|
+
* too, and polling `/process/{recordId}` would hang instead of failing. An unrecognised response is
|
|
248
|
+
* left alone — nothing then says the write is still settling.
|
|
249
|
+
*/
|
|
250
|
+
const awaitUserDataSync = async (payload: unknown, label: string, signal: AbortSignal): Promise<void> => {
|
|
251
|
+
const started = processSchema.safeParse(payload)
|
|
252
|
+
if (!started.success || started.data.actionName === undefined || started.data.actionName.trim() === '') {
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
await waitForProcess({ api: { getProcess }, processId: started.data.id, sleep, signal, label })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
importServices: async ({ projectId, yaml, signal }) =>
|
|
260
|
+
readResponse(
|
|
261
|
+
'service-stack import',
|
|
262
|
+
importResultSchema,
|
|
263
|
+
await request('service-stack import', 'POST', `/project/${projectId}/service-stack/import`, { body: { yaml }, signal }),
|
|
264
|
+
),
|
|
265
|
+
|
|
266
|
+
importProject: async ({ clientId, yaml, signal }) =>
|
|
267
|
+
readResponse(
|
|
268
|
+
'project import',
|
|
269
|
+
importResultSchema,
|
|
270
|
+
await request('project import', 'POST', `/client/${clientId}/project/import`, { body: { yaml }, signal }),
|
|
271
|
+
),
|
|
272
|
+
|
|
273
|
+
triggerPipeline: async ({ serviceId, buildFromGit, zeropsYaml, zeropsSetup, signal }) => {
|
|
274
|
+
const body: Record<string, unknown> = {}
|
|
275
|
+
if (buildFromGit !== undefined) {
|
|
276
|
+
body['buildFromGit'] = buildFromGit
|
|
277
|
+
}
|
|
278
|
+
if (zeropsYaml !== undefined) {
|
|
279
|
+
body['zeropsYaml'] = zeropsYaml
|
|
280
|
+
}
|
|
281
|
+
if (zeropsSetup !== undefined) {
|
|
282
|
+
body['zeropsSetup'] = zeropsSetup
|
|
283
|
+
}
|
|
284
|
+
const payload = await request('trigger-pipeline', 'PUT', `/service-stack/${serviceId}/trigger-pipeline`, { body, signal })
|
|
285
|
+
return readResponse('trigger-pipeline', triggerPipelineSchema, payload).process ?? null
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
createAppVersion: async ({ serviceId, name, signal }) => {
|
|
289
|
+
const body = name === undefined ? {} : { name }
|
|
290
|
+
const payload = await request('create app-version', 'POST', `/service-stack/${serviceId}/app-version`, {
|
|
291
|
+
body,
|
|
292
|
+
signal,
|
|
293
|
+
omitErrorResponseDetails: true,
|
|
294
|
+
})
|
|
295
|
+
const result = appVersionReservationSchema.safeParse(payload)
|
|
296
|
+
if (!result.success || result.data.serviceStackId !== serviceId) {
|
|
297
|
+
throw invalidResponse('create app-version')
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
id: result.data.id,
|
|
301
|
+
uploadUrl: result.data.uploadUrl,
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
buildAndDeployAppVersion: async ({ appVersionId, zeropsYaml, zeropsYamlSetup, signal }) => {
|
|
306
|
+
const body = zeropsYamlSetup === undefined ? { zeropsYaml } : { zeropsYaml, zeropsYamlSetup }
|
|
307
|
+
// `redactDetail`, not `omitErrorResponseDetails`: this request body is the app's COMMITTED
|
|
308
|
+
// descriptor and this response carries no upload URL, so there is nothing here to protect by
|
|
309
|
+
// dropping the code as well — and a bare `(400)` is what made `userDataSyncRunning` cost a day.
|
|
310
|
+
const payload = await request('build and deploy app-version', 'PUT', `/app-version/${appVersionId}/build-and-deploy`, {
|
|
311
|
+
body,
|
|
312
|
+
signal,
|
|
313
|
+
redactDetail: true,
|
|
314
|
+
})
|
|
315
|
+
const process = readResponse('build and deploy app-version', processSchema, payload)
|
|
316
|
+
if (process.appVersionId !== undefined && process.appVersionId !== appVersionId) {
|
|
317
|
+
throw invalidResponse('build and deploy app-version')
|
|
318
|
+
}
|
|
319
|
+
return { ...process, appVersionId }
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
deleteAppVersion: async ({ appVersionId, signal }) => {
|
|
323
|
+
const payload = await request('delete app-version', 'DELETE', `/app-version/${appVersionId}`, {
|
|
324
|
+
signal,
|
|
325
|
+
omitErrorResponseDetails: true,
|
|
326
|
+
preserveAppVersionNotFound: true,
|
|
327
|
+
})
|
|
328
|
+
readResponse('delete app-version', deleteAppVersionSchema, payload)
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
getAppVersion: async ({ appVersionId, signal }) =>
|
|
332
|
+
readResponse('get app-version', appVersionSchema, await request('get app-version', 'GET', `/app-version/${appVersionId}`, { signal })),
|
|
333
|
+
|
|
334
|
+
latestAppVersion: async ({ serviceId, signal }) => {
|
|
335
|
+
const payload = await request('list app-versions', 'GET', `/service-stack/${serviceId}/app-version?limit=25`, { signal })
|
|
336
|
+
const versions = readResponse('list app-versions', appVersionListSchema, payload).list
|
|
337
|
+
// Highest `sequence` wins — the list's order is not documented, the sequence's meaning is.
|
|
338
|
+
return versions.reduce<ZeropsAppVersion | null>(
|
|
339
|
+
(best, version) => (best === null || (version.sequence ?? -1) > (best.sequence ?? -1) ? version : best),
|
|
340
|
+
null,
|
|
341
|
+
)
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
cancelBuild: async ({ appVersionId, signal }) => {
|
|
345
|
+
await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
getProcess,
|
|
349
|
+
|
|
350
|
+
enableSubdomainAccess: async ({ serviceId, signal }) => {
|
|
351
|
+
// No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
|
|
352
|
+
await request('enable subdomain access', 'PUT', `/service-stack/${serviceId}/enable-subdomain-access`, { signal })
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
getService: async ({ serviceId, signal }) =>
|
|
356
|
+
readResponse('get service', serviceSchema, await request('get service', 'GET', `/service-stack/${serviceId}`, { signal })),
|
|
357
|
+
|
|
358
|
+
findService: async ({ projectId, hostname, signal }) => {
|
|
359
|
+
try {
|
|
360
|
+
return readResponse(
|
|
361
|
+
'find service',
|
|
362
|
+
serviceSchema,
|
|
363
|
+
await request('find service', 'GET', `/service-stack-by-name/${projectId}/${encodeURIComponent(hostname)}`, { signal }),
|
|
364
|
+
)
|
|
365
|
+
} catch (error) {
|
|
366
|
+
// A missing service is an answer, not a failure; anything else propagates. The live platform
|
|
367
|
+
// says it as 400 `serviceStackNotFound` (docs/reference/zerops-platform.md), not as a 404.
|
|
368
|
+
if (error instanceof ZeropsApiError && (error.status === 404 || error.code === ZEROPS_SERVICE_STACK_NOT_FOUND)) {
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
throw error
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
getProject: async ({ projectId, signal }) => {
|
|
376
|
+
const payload = await request('get project', 'GET', `/project/${projectId}`, { signal })
|
|
377
|
+
return readResponse('get project', projectSchema, payload)
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
listProjects: async ({ clientId, signal }) => readAllPages('list projects', `/client/${clientId}/project`, signal, projectSchema),
|
|
381
|
+
|
|
382
|
+
findProjects: async ({ clientId, name, signal }) => {
|
|
383
|
+
const payload = await request(
|
|
384
|
+
'find projects',
|
|
385
|
+
'GET',
|
|
386
|
+
`/client/${clientId}/projects-by-name/${encodeURIComponent(name)}`,
|
|
387
|
+
{ signal },
|
|
388
|
+
)
|
|
389
|
+
return readResponse('find projects', projectMatchesSchema, payload).projects
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
listProjectServices: async ({ projectId, signal }) =>
|
|
393
|
+
readAllPages('list project services', `/project/${projectId}/service-stack`, signal, serviceSchema),
|
|
394
|
+
|
|
395
|
+
listServiceEnv,
|
|
396
|
+
|
|
397
|
+
createServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
398
|
+
let started: unknown
|
|
399
|
+
try {
|
|
400
|
+
started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
401
|
+
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
402
|
+
signal,
|
|
403
|
+
omitErrorResponseDetails: true,
|
|
404
|
+
})
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (error instanceof Error && error.name === 'AbortError') throw error
|
|
407
|
+
throw new Error('zerops: create-only service env failed')
|
|
408
|
+
}
|
|
409
|
+
await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
putServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
413
|
+
const label = `the user-data sync on service ${serviceId}`
|
|
414
|
+
// `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
|
|
415
|
+
try {
|
|
416
|
+
const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
417
|
+
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
418
|
+
signal,
|
|
419
|
+
redactDetail: true,
|
|
420
|
+
})
|
|
421
|
+
await awaitUserDataSync(created, label, signal)
|
|
422
|
+
return
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_DUPLICATE_KEY) {
|
|
425
|
+
throw error
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Only the conflict reads, and only then: the create must work on a service that has never been
|
|
429
|
+
// deployed, which is the whole of ADR-0004's bring-up order.
|
|
430
|
+
const existing = (await listServiceEnv({ serviceId, signal })).find((entry) => entry.key === key)
|
|
431
|
+
if (existing === undefined || existing.id === '') {
|
|
432
|
+
// The service declares the key in its own `zerops.yaml` (`type: ENV`), which conflicts on create
|
|
433
|
+
// yet never appears in the env list. Replacing it here would be undone by the next deploy.
|
|
434
|
+
throw new Error(
|
|
435
|
+
`zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
|
|
439
|
+
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
440
|
+
signal,
|
|
441
|
+
redactDetail: true,
|
|
442
|
+
})
|
|
443
|
+
await awaitUserDataSync(updated, label, signal)
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
deleteServiceEnv: async ({ envId, signal }) => {
|
|
447
|
+
const deleted = await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
|
|
448
|
+
await awaitUserDataSync(deleted, `the user-data sync for record ${envId}`, signal)
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
getProjectEnv: async ({ projectEnvId, signal }) =>
|
|
452
|
+
readResponse('get project env', serviceEnvSchema, await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
|
|
453
|
+
|
|
454
|
+
createIntegrationToken: async ({ clientId, name, projects, roleCode, canCreateProjects, signal }) =>
|
|
455
|
+
// `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
|
|
456
|
+
// this is the one error envelope that could quote a minted token back at us.
|
|
457
|
+
readResponse(
|
|
458
|
+
'create integration token',
|
|
459
|
+
integrationTokenSchema,
|
|
460
|
+
await request('create integration token', 'POST', `/client/${clientId}/integration-token`, {
|
|
461
|
+
body: {
|
|
462
|
+
name,
|
|
463
|
+
roleCode: roleCode ?? 'NO_ACCESS',
|
|
464
|
+
// Always sent, like `roleCode`: a security-relevant field that never reaches the wire is a
|
|
465
|
+
// field nobody can review.
|
|
466
|
+
canCreateProjects: canCreateProjects ?? false,
|
|
467
|
+
projects: projects.map((grant) => ({ projectId: grant.projectId, roleCode: grant.roleCode })),
|
|
468
|
+
},
|
|
469
|
+
signal,
|
|
470
|
+
redactDetail: true,
|
|
471
|
+
}),
|
|
472
|
+
),
|
|
473
|
+
|
|
474
|
+
getLogAccess: async ({ projectId, signal }) => {
|
|
475
|
+
const payload = await request('get log access', 'GET', `/project/${projectId}/log`, { signal })
|
|
476
|
+
return readResponse('get log access', logAccessSchema, payload)
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
readBuildLog: async ({ access, serviceId, appVersionId, since, limit, signal }) => {
|
|
480
|
+
if (access.urlPlain === '') {
|
|
481
|
+
return []
|
|
482
|
+
}
|
|
483
|
+
const separator = access.urlPlain.includes('?') ? '&' : '?'
|
|
484
|
+
const readWindow = async (terms: string): Promise<{ lines: ZeropsLogLine[]; error: unknown }> => {
|
|
485
|
+
try {
|
|
486
|
+
const url = `${access.urlPlain}${separator}${terms}&limit=${limit ?? 200}`
|
|
487
|
+
const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
throw new Error(`zerops: read build log failed (${response.status})`)
|
|
490
|
+
}
|
|
491
|
+
const text = await response.text()
|
|
492
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
|
|
493
|
+
const head = line.slice(0, Math.max(line.indexOf(' '), 0))
|
|
494
|
+
return LOG_LINE_TIMESTAMP.test(head) ? { timestamp: head, message: line } : { message: line }
|
|
495
|
+
})
|
|
496
|
+
return { lines, error: undefined }
|
|
497
|
+
} catch (error) {
|
|
498
|
+
return { lines: [], error }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// Selected by tag alone: the build runs on a builder stack, so pairing the tag with the runtime
|
|
502
|
+
// service id matches nothing (see the interface).
|
|
503
|
+
const build = appVersionId === undefined ? undefined : await readWindow(`tags=${encodeURIComponent(`zbuilder@${appVersionId}`)}`)
|
|
504
|
+
const runtime = since === undefined ? undefined : await readWindow(`serviceStackId=${encodeURIComponent(serviceId)}`)
|
|
505
|
+
const attempted = [build, runtime].filter((result) => result !== undefined)
|
|
506
|
+
if (attempted.length === 0) {
|
|
507
|
+
return []
|
|
508
|
+
}
|
|
509
|
+
// Independent windows: only a failure of every one attempted is worth raising.
|
|
510
|
+
const failure = attempted.find((result) => result.error !== undefined)
|
|
511
|
+
if (failure !== undefined && attempted.every((result) => result.error !== undefined)) {
|
|
512
|
+
throw failure.error
|
|
513
|
+
}
|
|
514
|
+
const boundary = since === undefined ? Number.NaN : Date.parse(since)
|
|
515
|
+
// The runtime window carries every version that ever ran, so a line the driver cannot date is dropped.
|
|
516
|
+
const runtimeLines = runtime === undefined || Number.isNaN(boundary)
|
|
517
|
+
? runtime?.lines ?? []
|
|
518
|
+
: runtime.lines.filter((line) => line.timestamp !== undefined && Date.parse(line.timestamp) >= boundary)
|
|
519
|
+
return [...build?.lines ?? [], ...runtimeLines]
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const errorDetailShape = {
|
|
4
|
+
code: z.string().optional().catch(undefined),
|
|
5
|
+
message: z.string().optional().catch(undefined),
|
|
6
|
+
// An element that is not an object still counts: `isAppVersionNotFoundResponse` requires exactly one.
|
|
7
|
+
meta: z.array(z.object({ code: z.string().optional().catch(undefined) }).catch({})).catch([]),
|
|
8
|
+
}
|
|
9
|
+
const errorDetailSchema = z.object(errorDetailShape).catch({ meta: [] })
|
|
10
|
+
const errorEnvelopeSchema = z.object({ ...errorDetailShape, error: errorDetailSchema }).catch({ meta: [], error: { meta: [] } })
|
|
11
|
+
|
|
12
|
+
export const isAppVersionNotFoundResponse = (value: unknown): boolean => {
|
|
13
|
+
const { error: inner, ...outer } = errorEnvelopeSchema.parse(value)
|
|
14
|
+
const metadata = [...outer.meta, ...inner.meta]
|
|
15
|
+
return inner.code === 'appVersionNotFound' || outer.code === 'appVersionNotFound'
|
|
16
|
+
|| (metadata.length === 1 && metadata[0]?.code === 'appVersionNotFound')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A non-2xx answer from the Zerops API. `code` is the platform's own error identifier, so a caller can
|
|
21
|
+
* branch on `userDataDuplicateKey` rather than matching a prose message.
|
|
22
|
+
*/
|
|
23
|
+
export class ZeropsApiError extends Error {
|
|
24
|
+
constructor(message: string, readonly status: number, readonly code: string) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'ZeropsApiError'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The platform's code for "this service publishes no HTTP port", the answer `enableSubdomainAccess` gives
|
|
32
|
+
* before a service's first deploy. Exported so a caller can say what is missing instead of relaying prose.
|
|
33
|
+
*/
|
|
34
|
+
export const ZEROPS_SERVICE_NOT_HTTP = 'serviceStackIsNotHttp'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Turn a non-2xx response into a `ZeropsApiError`. Zerops answers with `{ error: { code, message, meta } }`
|
|
38
|
+
* (the envelope observed live; the OpenAPI `Error` schema shows the inner `{ code, message }`).
|
|
39
|
+
*
|
|
40
|
+
* `redactDetail` drops the server's MESSAGE, which a validation error can fill with the value it
|
|
41
|
+
* rejected. The CODE is a fixed identifier and is kept either way — without it nothing downstream can
|
|
42
|
+
* tell a duplicate key from a missing service.
|
|
43
|
+
*/
|
|
44
|
+
export const apiError = (label: string, status: number, body: unknown, redactDetail: boolean): ZeropsApiError => {
|
|
45
|
+
const { error: inner, ...outer } = errorEnvelopeSchema.parse(body)
|
|
46
|
+
const code = inner.code ?? outer.code ?? ''
|
|
47
|
+
const message = redactDetail ? '' : (inner.message ?? outer.message ?? '')
|
|
48
|
+
const detail = [code, message].filter((part) => part !== '').join(': ')
|
|
49
|
+
return new ZeropsApiError(`zerops: ${label} failed (${status})${detail === '' ? '' : ` — ${detail.slice(0, 300)}`}`, status, code)
|
|
50
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Sleeper } from '../collaborators'
|
|
2
|
+
import { ZEROPS_PROCESS_FINISHED, ZEROPS_PROCESS_TERMINAL, type ZeropsApi, type ZeropsProcess } from './types'
|
|
3
|
+
|
|
4
|
+
// ── waiting on a process ────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How often `waitForProcess` polls. The two process durations anyone has measured are 0.7 s
|
|
8
|
+
* (`stack.enableSubdomainAccess`) and 2.6 s (`stack.updateUserData`), so 5 s — the platform DEPLOY's
|
|
9
|
+
* interval — would spend most of its time asleep on a call that already finished.
|
|
10
|
+
*/
|
|
11
|
+
const PROCESS_POLL_INTERVAL_MS = 2_000
|
|
12
|
+
|
|
13
|
+
/** ~5 minutes. The slow case is an import that creates managed services, not a build; a build is polled through `/app-version`. */
|
|
14
|
+
const PROCESS_POLL_ATTEMPTS = 150
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Poll ONE process to completion.
|
|
18
|
+
*
|
|
19
|
+
* The same loop `deployPlatformService` (`installation/deploy.ts`) runs over `/app-version`, run over `/process`
|
|
20
|
+
* instead: read, decide ONLY on a status this build knows to be terminal, sleep, and give up on a deadline
|
|
21
|
+
* of our own rather than on a coincidence. Returns the finished process so a caller can read
|
|
22
|
+
* `actionName`/`appVersionId` off it.
|
|
23
|
+
*
|
|
24
|
+
* Anything outside `ZEROPS_PROCESS_TERMINAL` means "keep waiting", including a status this build has never
|
|
25
|
+
* seen — which is why an unpublished vocabulary (see that constant) can cost a timeout and never a false
|
|
26
|
+
* success. A non-`FINISHED` terminal status throws, naming the status and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
export const waitForProcess = async (input: {
|
|
29
|
+
api: Pick<ZeropsApi, 'getProcess'>
|
|
30
|
+
processId: string
|
|
31
|
+
sleep: Sleeper
|
|
32
|
+
signal: AbortSignal
|
|
33
|
+
/** What to call this process in a failure or timeout message. Defaults to its id. */
|
|
34
|
+
label?: string
|
|
35
|
+
intervalMs?: number
|
|
36
|
+
attempts?: number
|
|
37
|
+
}): Promise<ZeropsProcess> => {
|
|
38
|
+
const label = input.label ?? input.processId
|
|
39
|
+
const intervalMs = input.intervalMs ?? PROCESS_POLL_INTERVAL_MS
|
|
40
|
+
const attempts = input.attempts ?? PROCESS_POLL_ATTEMPTS
|
|
41
|
+
for (let attempt = 0;; attempt += 1) {
|
|
42
|
+
// A `Sleeper` resolves early on abort, so without this the loop would spin out its whole budget
|
|
43
|
+
// against a cancelled run.
|
|
44
|
+
if (input.signal.aborted) {
|
|
45
|
+
throw new Error(`zerops: waiting for ${label} was cancelled`)
|
|
46
|
+
}
|
|
47
|
+
const process = await input.api.getProcess({ processId: input.processId, signal: input.signal })
|
|
48
|
+
if (process.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status)) {
|
|
49
|
+
if (process.status !== ZEROPS_PROCESS_FINISHED) {
|
|
50
|
+
throw new Error(`zerops: ${label} finished as ${process.status}`)
|
|
51
|
+
}
|
|
52
|
+
return process
|
|
53
|
+
}
|
|
54
|
+
if (attempt >= attempts) {
|
|
55
|
+
throw new Error(`zerops: ${label} did not finish in time`)
|
|
56
|
+
}
|
|
57
|
+
await input.sleep(intervalMs, input.signal)
|
|
58
|
+
}
|
|
59
|
+
}
|