@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/package.json +40 -0
- package/src/api.ts +825 -0
- package/src/authoring.ts +33 -0
- package/src/cli-args.ts +101 -0
- package/src/cli.ts +82 -0
- package/src/codec.ts +50 -0
- package/src/collaborators.ts +47 -0
- package/src/compile.ts +382 -0
- package/src/control.ts +390 -0
- package/src/index.ts +98 -0
- package/src/manifest.ts +442 -0
- package/src/namespace-command.ts +209 -0
- package/src/namespace.ts +1028 -0
- package/src/plan.ts +51 -0
- package/src/provider.ts +311 -0
- package/src/schema.generated.ts +607 -0
- package/src/service-names.ts +38 -0
- package/src/types.ts +125 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
// The ZEROPS driver's collaborator: a thin, typed client over the Zerops REST API. This is the whole
|
|
2
|
+
// side-effect surface of a Zerops deploy — ADR-0003 says a deploy there is HTTP calls and nothing else, so
|
|
3
|
+
// substituting this ONE interface is what makes the driver testable and dry-run-able (ADR-0009).
|
|
4
|
+
//
|
|
5
|
+
// PROVENANCE. Every request/response shape below was read off Zerops' own OpenAPI document, served at
|
|
6
|
+
// `https://api.app-prg1.zerops.io/api/rest/public/swagger/openapi.yml` (linked from the REST API
|
|
7
|
+
// reference). Members whose shape could NOT be read off that document are marked `UNVERIFIED:` in their
|
|
8
|
+
// doc comment — there is exactly one such area (the log service behind `GET /project/{id}/log`), plus a
|
|
9
|
+
// handful of BEHAVIOURS (as opposed to shapes) that no document states. Do not quietly promote one to
|
|
10
|
+
// fact; check it against a real account first.
|
|
11
|
+
//
|
|
12
|
+
// SECURITY. The token is a Zerops PERSONAL ACCESS TOKEN and carries account-wide admin rights. It is
|
|
13
|
+
// never logged, never included in an error message, and never returned from a method.
|
|
14
|
+
|
|
15
|
+
/** Every status a Zerops application version can be in. VERIFIED: `OutDtoGetAppVersion.status` enum. */
|
|
16
|
+
export type ZeropsAppVersionStatus =
|
|
17
|
+
| 'UPLOADING'
|
|
18
|
+
| 'WAITING_TO_BUILD'
|
|
19
|
+
| 'BUILDING'
|
|
20
|
+
| 'BUILD_FAILED'
|
|
21
|
+
| 'BUILD_VALIDATION_FAILED'
|
|
22
|
+
| 'WAITING_TO_DEPLOY'
|
|
23
|
+
| 'DEPLOYING'
|
|
24
|
+
| 'DEPLOY_FAILED'
|
|
25
|
+
| 'PREPARING_RUNTIME'
|
|
26
|
+
| 'PREPARING_RUNTIME_FAILED'
|
|
27
|
+
| 'ACTIVE'
|
|
28
|
+
| 'BACKUP'
|
|
29
|
+
| 'CANCELLED'
|
|
30
|
+
|
|
31
|
+
/** The one status that means "this version is live". */
|
|
32
|
+
export const ZEROPS_ACTIVE: ZeropsAppVersionStatus = 'ACTIVE'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Statuses a version never leaves. `BACKUP` is included deliberately: a version we triggered that has
|
|
36
|
+
* become an archived one was superseded by another deploy, which is an outcome, not a step on the way.
|
|
37
|
+
*/
|
|
38
|
+
export const ZEROPS_TERMINAL: ReadonlySet<ZeropsAppVersionStatus> = new Set<ZeropsAppVersionStatus>([
|
|
39
|
+
'ACTIVE',
|
|
40
|
+
'BACKUP',
|
|
41
|
+
'BUILD_FAILED',
|
|
42
|
+
'BUILD_VALIDATION_FAILED',
|
|
43
|
+
'DEPLOY_FAILED',
|
|
44
|
+
'PREPARING_RUNTIME_FAILED',
|
|
45
|
+
'CANCELLED',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
/** One application version, narrowed to what the driver reads. VERIFIED: `GET /app-version/{id}`. */
|
|
49
|
+
export interface ZeropsAppVersion {
|
|
50
|
+
id: string
|
|
51
|
+
/** Absent on a response that omitted it; the poll loop treats that as "not yet known". */
|
|
52
|
+
status?: ZeropsAppVersionStatus
|
|
53
|
+
serviceStackId?: string
|
|
54
|
+
projectId?: string
|
|
55
|
+
/** Monotonic per service — how Zerops orders the 10 versions it retains. */
|
|
56
|
+
sequence?: number
|
|
57
|
+
/** Build-pipeline timestamps (ISO dateTime), when a build ran. */
|
|
58
|
+
build?: { startDate?: string; endDate?: string; pipelineStart?: string; pipelineFinish?: string; pipelineFailed?: string }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An asynchronous platform operation. VERIFIED: `OutDtoProcess`. */
|
|
62
|
+
export interface ZeropsProcess {
|
|
63
|
+
id: string
|
|
64
|
+
/** Free-form on the wire (`OutDtoProcess.status` is an untyped string defaulting to `PENDING`). */
|
|
65
|
+
status?: string
|
|
66
|
+
actionName?: string
|
|
67
|
+
serviceStackId?: string
|
|
68
|
+
appVersionId?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One service created or updated by an import. VERIFIED: `OutDtoProjectImportServiceStack`. */
|
|
72
|
+
export interface ZeropsImportedService {
|
|
73
|
+
id: string
|
|
74
|
+
/** The service's name as Zerops stores it (hostname, possibly suffixed by env). */
|
|
75
|
+
name: string
|
|
76
|
+
processes: ZeropsProcess[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The result of applying an import document. VERIFIED: `ResponseProjectImport`. */
|
|
80
|
+
export interface ZeropsImportResult {
|
|
81
|
+
projectId: string
|
|
82
|
+
projectName?: string
|
|
83
|
+
services: ZeropsImportedService[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every documented project lifecycle state. VERIFIED: `ResponseProject.status`. */
|
|
87
|
+
export type ZeropsProjectStatus = 'NEW' | 'CREATING' | 'ACTIVE' | 'DELETING' | 'FAILED' | 'STOPPING' | 'STOPPED' | 'STARTING'
|
|
88
|
+
|
|
89
|
+
/** Project compute tier. The import schema calls the same choice `corePackage`. VERIFIED: `ResponseProject.mode`. */
|
|
90
|
+
export type ZeropsProjectMode = 'LIGHT' | 'SERIOUS'
|
|
91
|
+
|
|
92
|
+
/** Every documented service-stack lifecycle state. VERIFIED: `ResponseServiceStack.status`. */
|
|
93
|
+
export type ZeropsServiceStatus =
|
|
94
|
+
| 'NEW'
|
|
95
|
+
| 'CREATING'
|
|
96
|
+
| 'ACTIVE'
|
|
97
|
+
| 'STOPPING'
|
|
98
|
+
| 'STOPPED'
|
|
99
|
+
| 'STARTING'
|
|
100
|
+
| 'RESTARTING'
|
|
101
|
+
| 'RELOADING'
|
|
102
|
+
| 'DELETING'
|
|
103
|
+
| 'DELETED'
|
|
104
|
+
| 'FAILED'
|
|
105
|
+
| 'ACTION_FAILED'
|
|
106
|
+
| 'UPGRADING'
|
|
107
|
+
| 'READY_TO_DEPLOY'
|
|
108
|
+
| 'SERVICE_CREATING'
|
|
109
|
+
| 'SERVICE_ACTIVE'
|
|
110
|
+
| 'SERVICE_STOPPING'
|
|
111
|
+
| 'SERVICE_STOPPED'
|
|
112
|
+
| 'SERVICE_STARTING'
|
|
113
|
+
| 'SERVICE_RESTARTING'
|
|
114
|
+
| 'SERVICE_RELOADING'
|
|
115
|
+
| 'SERVICE_DELETING'
|
|
116
|
+
| 'SERVICE_DELETED'
|
|
117
|
+
| 'SERVICE_FAILED'
|
|
118
|
+
| 'SERVICE_ACTION_FAILED'
|
|
119
|
+
| 'SERVICE_REPAIRING'
|
|
120
|
+
| 'SERVICE_CONTAINER_FAILED'
|
|
121
|
+
| 'SERVICE_MOVING_CONTAINER'
|
|
122
|
+
| 'SERVICE_UPGRADING'
|
|
123
|
+
| 'SERVICE_SCALING'
|
|
124
|
+
| 'SERVICE_REPAIR_FAILED'
|
|
125
|
+
| 'REPAIRING'
|
|
126
|
+
| 'CONTAINER_FAILED'
|
|
127
|
+
| 'MOVING_CONTAINER'
|
|
128
|
+
| 'SCALING'
|
|
129
|
+
| 'REPAIR_FAILED'
|
|
130
|
+
|
|
131
|
+
/** One service. VERIFIED: `ResponseServiceStack` (narrowed to the fields fabrika reads). */
|
|
132
|
+
export interface ZeropsService {
|
|
133
|
+
id: string
|
|
134
|
+
name: string
|
|
135
|
+
projectId?: string
|
|
136
|
+
status?: ZeropsServiceStatus
|
|
137
|
+
/** Full service-type identifier, e.g. `alpine/bun@1.3`. VERIFIED: `ResponseServiceStack.base`. */
|
|
138
|
+
base?: string
|
|
139
|
+
activeAppVersionId?: string
|
|
140
|
+
/** Whether Zerops' public subdomain routes this service. VERIFIED: `ResponseServiceStack.subdomainAccess`. */
|
|
141
|
+
subdomainAccess?: boolean
|
|
142
|
+
/** Provider profile identifier, e.g. `oltp-production`. VERIFIED: `ResponseServiceStack.autoscalingProfileId`. */
|
|
143
|
+
autoscalingProfileId?: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One project. VERIFIED: `ResponseProject` (narrowed). */
|
|
147
|
+
export interface ZeropsProject {
|
|
148
|
+
id: string
|
|
149
|
+
name: string
|
|
150
|
+
status?: ZeropsProjectStatus
|
|
151
|
+
/** The wire calls the import schema's `corePackage` choice `mode`. */
|
|
152
|
+
mode?: ZeropsProjectMode
|
|
153
|
+
/** VERIFIED: `ResponseProject.description`. */
|
|
154
|
+
description?: string
|
|
155
|
+
/** VERIFIED: `ResponseProject.tagList`. */
|
|
156
|
+
tagList?: string[]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* ONE service-level environment variable. VERIFIED: `OutDtoUserData`.
|
|
161
|
+
*
|
|
162
|
+
* UNVERIFIED BEHAVIOUR: whether `content` carries the real value for a variable Zerops stores as a
|
|
163
|
+
* SECRET, or a blurred placeholder. `docs/backlog/06-can-zerops-secrets-be-read-back.md` is open on
|
|
164
|
+
* exactly this; the dashboard UX depends on the answer. Never log `content`.
|
|
165
|
+
*/
|
|
166
|
+
export interface ZeropsServiceEnv {
|
|
167
|
+
id: string
|
|
168
|
+
key: string
|
|
169
|
+
content: string
|
|
170
|
+
serviceStackId?: string
|
|
171
|
+
/** `READ_ONLY` | `EDITABLE` | `SECRET` | `INTERNAL` | `ENV` — VERIFIED as an enum, meaning inferred. */
|
|
172
|
+
type?: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A short-lived grant for the project's LOG service. VERIFIED: `ResponseProjectLog` — the field names and
|
|
177
|
+
* types are exactly these. What is NOT verified is what the URLs speak; see `readBuildLog`.
|
|
178
|
+
*/
|
|
179
|
+
export interface ZeropsLogAccess {
|
|
180
|
+
url: string
|
|
181
|
+
urlPlain: string
|
|
182
|
+
urlInfo: string
|
|
183
|
+
urlUi: string
|
|
184
|
+
/** Bearer for the log service (a different credential from the personal access token). Never log it. */
|
|
185
|
+
accessToken: string
|
|
186
|
+
/** ISO dateTime after which the grant is dead and must be re-fetched. */
|
|
187
|
+
expiration: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One relayed build/runtime log line. Shape is fabrika's, not the platform's. */
|
|
191
|
+
export interface ZeropsLogLine {
|
|
192
|
+
/** ISO timestamp when Zerops recorded the line, when it supplied one. */
|
|
193
|
+
timestamp?: string
|
|
194
|
+
message: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The Zerops REST surface fabrika depends on, as ONE injectable interface (ADR-0009's collaborator
|
|
199
|
+
* bundle). Every method takes the run's `AbortSignal`: a driver that polls `/app-version` is precisely the
|
|
200
|
+
* case cancellation was added for, and an abandoned run must not keep an HTTP request alive.
|
|
201
|
+
*
|
|
202
|
+
* WIDER THAN THE DRIVER, on purpose. The driver uses four of these; the rest are the CONTROL PLANE's
|
|
203
|
+
* Zerops surface — `putServiceEnv`/`listServiceEnv` for the edit-time secret writes that ADR-0004 says are
|
|
204
|
+
* not a deploy step, `importProject` for creating a managed project, and `getAppVersion`/`latestAppVersion`
|
|
205
|
+
* for the startup reconciliation of in-flight runs that ADR-0003 makes a requirement. One client, so there
|
|
206
|
+
* is one place the Zerops contract is written down.
|
|
207
|
+
*
|
|
208
|
+
* NOTE WHAT IS ABSENT. There is no method that writes a PROJECT-level environment variable. That is not
|
|
209
|
+
* an oversight and must not be "completed": project-level variables are injected into every service in the
|
|
210
|
+
* project, and one project holds many apps, so a project-level write hands app A's credentials to app B
|
|
211
|
+
* (ADR-0004). The invariant is enforced by the surface not existing — `POST /project/{id}/env` and
|
|
212
|
+
* `PUT /project-env/{id}` are real endpoints that this client deliberately does not expose. `getProjectEnv`
|
|
213
|
+
* reads one back (for drift reporting); nothing here can create or change one.
|
|
214
|
+
*/
|
|
215
|
+
export interface ZeropsApi {
|
|
216
|
+
// ── service-stack: applying the compiled import document ──────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Apply a `zerops-import.yaml` (its `services:` section) to an EXISTING project — fabrika's
|
|
220
|
+
* provisioning step. VERIFIED: `POST /project/{id}/service-stack/import`, body `{ yaml }`.
|
|
221
|
+
*
|
|
222
|
+
* VERIFIED (2026-08-05): re-applying an unchanged document is a complete no-op — same ids, no
|
|
223
|
+
* processes, `lastUpdate` unmoved. That is ADR-0003's idempotency claim, and it is ALL of it: a
|
|
224
|
+
* changed `profile`/`maxContainers`/`objectStorageSize` is silently ignored too, so this reconciles
|
|
225
|
+
* nothing. Change a live field through the API that owns it.
|
|
226
|
+
*/
|
|
227
|
+
importServices(input: { projectId: string; yaml: string; signal: AbortSignal }): Promise<ZeropsImportResult>
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Apply a full `zerops-import.yaml` INCLUDING its `project:` section, creating the project.
|
|
231
|
+
* VERIFIED: `POST /client/{id}/project/import`, body `{ yaml }`.
|
|
232
|
+
*
|
|
233
|
+
* Needed because `envIsolation` is settable at project CREATION only — `RequestPutProject` has no such
|
|
234
|
+
* field, so a project made without it cannot be corrected through the project API afterwards. The
|
|
235
|
+
* driver compensates by also setting `envIsolation` per service, which the schema says overrides the
|
|
236
|
+
* project-level value.
|
|
237
|
+
*/
|
|
238
|
+
importProject(input: { clientId: string; yaml: string; signal: AbortSignal }): Promise<ZeropsImportResult>
|
|
239
|
+
|
|
240
|
+
// ── app-version: build + deploy ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Trigger a build+deploy pipeline for a service. VERIFIED:
|
|
244
|
+
* `PUT /service-stack/{id}/trigger-pipeline`, body `RequestPutStandardServiceStackTriggerPipeline`.
|
|
245
|
+
*
|
|
246
|
+
* With no `buildFromGit` the platform builds from the service's configured Git integration (the
|
|
247
|
+
* private-repo path); with one it does a one-time build from that PUBLIC repository URL.
|
|
248
|
+
*
|
|
249
|
+
* UNVERIFIED BEHAVIOUR: the response is `{ process?: … }` with EVERY field optional, so whether the
|
|
250
|
+
* new app-version id is reliably reachable at `process.appVersion.id` is a guess. The driver therefore
|
|
251
|
+
* does not depend on it — it resolves the version it triggered via `latestAppVersion` instead.
|
|
252
|
+
*/
|
|
253
|
+
triggerPipeline(input: {
|
|
254
|
+
serviceId: string
|
|
255
|
+
/** Public Git repository URL for a one-time build. Omit to use the service's Git integration. */
|
|
256
|
+
buildFromGit?: string
|
|
257
|
+
/** Inline `zerops.yaml`, overriding whatever is in the repo. */
|
|
258
|
+
zeropsYaml?: string
|
|
259
|
+
/** Which `setup:` block of the `zerops.yaml` to use. */
|
|
260
|
+
zeropsSetup?: string
|
|
261
|
+
signal: AbortSignal
|
|
262
|
+
}): Promise<ZeropsProcess | null>
|
|
263
|
+
|
|
264
|
+
/** One application version's current state — the poll target. VERIFIED: `GET /app-version/{id}`. */
|
|
265
|
+
getAppVersion(input: { appVersionId: string; signal: AbortSignal }): Promise<ZeropsAppVersion>
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The newest application version of a service. VERIFIED: `GET /service-stack/{id}/app-version`
|
|
269
|
+
* (`{ list, totalCount }`, `limit`/`offset`/`statuses` query params).
|
|
270
|
+
*
|
|
271
|
+
* UNVERIFIED BEHAVIOUR: the list's ORDER. The driver does not rely on it — it picks the highest
|
|
272
|
+
* `sequence`, which the schema documents as monotonic per service.
|
|
273
|
+
*/
|
|
274
|
+
latestAppVersion(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsAppVersion | null>
|
|
275
|
+
|
|
276
|
+
/** Cancel an in-flight build — what a cancelled run does with the work it started. VERIFIED: `PUT /app-version/{id}/cancel-build`. */
|
|
277
|
+
cancelBuild(input: { appVersionId: string; signal: AbortSignal }): Promise<void>
|
|
278
|
+
|
|
279
|
+
// ── service-stack: public access ──────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Publish a service on Zerops' generated `*.zerops.app` subdomain. VERIFIED:
|
|
283
|
+
* `PUT /service-stack/{id}/enable-subdomain-access`, no body, `ResponseProcess`.
|
|
284
|
+
*
|
|
285
|
+
* THE ONLY THING THAT ESTABLISHES ONE. `enableSubdomainAccess: true` in an import document is accepted
|
|
286
|
+
* and then silently dropped — on CREATE as well as on `override` — so a service imported with it reads
|
|
287
|
+
* back `subdomainAccess: false` and stays that way (`docs/reference/zerops-platform.md`).
|
|
288
|
+
*
|
|
289
|
+
* TWO ORDERING FACTS the caller must respect. It needs a DEPLOYED HTTP port: before the first deploy
|
|
290
|
+
* the platform answers `400 serviceStackIsNotHttp`. And a 2xx here is NOT a success signal — calling it
|
|
291
|
+
* on a service that already has a subdomain returns a process that then FAILS — so decide by reading
|
|
292
|
+
* `getService().subdomainAccess` back, never by this call returning.
|
|
293
|
+
*/
|
|
294
|
+
enableSubdomainAccess(input: { serviceId: string; signal: AbortSignal }): Promise<void>
|
|
295
|
+
|
|
296
|
+
// ── service-stack / project: reading state ────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/** One service by id. VERIFIED: `GET /service-stack/{id}`. */
|
|
299
|
+
getService(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsService>
|
|
300
|
+
|
|
301
|
+
/** One service by hostname within a project, or `null` if absent. VERIFIED: `GET /service-stack-by-name/{projectId}/{name}`. */
|
|
302
|
+
findService(input: { projectId: string; hostname: string; signal: AbortSignal }): Promise<ZeropsService | null>
|
|
303
|
+
|
|
304
|
+
/** One project by id. VERIFIED: `GET /project/{id}`. */
|
|
305
|
+
getProject(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsProject>
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Every project under a client. VERIFIED: `GET /client/{id}/project`.
|
|
309
|
+
* The implementation follows the response's `totalCount` with explicit `limit`/`offset` pages.
|
|
310
|
+
*/
|
|
311
|
+
listProjects(input: { clientId: string; signal: AbortSignal }): Promise<ZeropsProject[]>
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Every exact-name match under a client. VERIFIED: `GET /client/{id}/projects-by-name/{name}`.
|
|
315
|
+
* Zerops returns an array, so duplicate names remain visible to the caller.
|
|
316
|
+
*/
|
|
317
|
+
findProjects(input: { clientId: string; name: string; signal: AbortSignal }): Promise<ZeropsProject[]>
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Every service stack under a project. VERIFIED: `GET /project/{id}/service-stack`.
|
|
321
|
+
* The implementation follows the response's `totalCount` with explicit `limit`/`offset` pages.
|
|
322
|
+
*/
|
|
323
|
+
listProjectServices(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsService[]>
|
|
324
|
+
|
|
325
|
+
// ── user-data: SERVICE-level environment variables (ADR-0004) ─────────────────
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
|
|
329
|
+
* VERIFIED: `GET /service-stack/{id}/env` (`{ items }`).
|
|
330
|
+
*
|
|
331
|
+
* NOT `GET /service-stack/{id}/user-data`, which answers `400 serviceStackNotFound` on every
|
|
332
|
+
* service, deployed or not — see `docs/reference/zerops-platform.md`. The `/env` reading omits
|
|
333
|
+
* variables the service's own `zerops.yaml` declares (`type: ENV`); fabrika never writes those.
|
|
334
|
+
*/
|
|
335
|
+
listServiceEnv(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv[]>
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Create-or-update ONE service-level variable — the only way fabrika ever writes a secret, and it is
|
|
339
|
+
* addressed BY SERVICE so ADR-0004's invariant cannot be violated by calling it.
|
|
340
|
+
* VERIFIED: `POST /service-stack/{id}/user-data` creates and `PUT /user-data/{id}` replaces, both
|
|
341
|
+
* `{ key, content }`; POSTing an existing key answers `400 userDataDuplicateKey` and changes nothing.
|
|
342
|
+
*
|
|
343
|
+
* WRITE FIRST, READ ONLY ON CONFLICT. A read before the write cannot be part of this: the create
|
|
344
|
+
* must work on a service that has never been deployed, which is exactly ADR-0004's bring-up order.
|
|
345
|
+
*/
|
|
346
|
+
putServiceEnv(input: { serviceId: string; key: string; value: string; signal: AbortSignal }): Promise<void>
|
|
347
|
+
|
|
348
|
+
/** Delete one service-level variable by its record id. VERIFIED: `DELETE /user-data/{id}`. */
|
|
349
|
+
deleteServiceEnv(input: { envId: string; signal: AbortSignal }): Promise<void>
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Read ONE project-level variable back, by record id. VERIFIED: `GET /project-env/{id}`.
|
|
353
|
+
* Read-only ON PURPOSE — see the note on this interface. Present so drift can be REPORTED.
|
|
354
|
+
*/
|
|
355
|
+
getProjectEnv(input: { projectEnvId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv>
|
|
356
|
+
|
|
357
|
+
// ── logs ──────────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/** A short-lived grant for the project's log service. VERIFIED: `GET /project/{id}/log`. */
|
|
360
|
+
getLogAccess(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsLogAccess>
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* UNVERIFIED — THE ONE SHAPE NOBODY COULD CHECK. `GET /project/{id}/log` hands back URLs to a
|
|
364
|
+
* SEPARATE log service, and that service's request/response contract appears in no published document
|
|
365
|
+
* (it is not in the OpenAPI file, which stops at the grant). The default implementation's reading is:
|
|
366
|
+
* GET `urlPlain` with the grant as a bearer, one plain-text line per log record, `limit`/`from` as
|
|
367
|
+
* query params. Treat that as a guess.
|
|
368
|
+
*
|
|
369
|
+
* BECAUSE it is a guess, the driver treats a failure here as non-fatal: log relay degrades to "no
|
|
370
|
+
* lines" and the deploy still succeeds or fails on `/app-version` status alone. An unverified endpoint
|
|
371
|
+
* must never be able to fail a deploy.
|
|
372
|
+
*/
|
|
373
|
+
readBuildLog(input: { access: ZeropsLogAccess; serviceId: string; limit?: number; signal: AbortSignal }): Promise<ZeropsLogLine[]>
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── default (real) implementation ───────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
/** Base URL of the Zerops public REST API. */
|
|
379
|
+
export const ZEROPS_API_BASE = 'https://api.app-prg1.zerops.io/api/rest/public'
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Just enough of `fetch` for this client — narrower than `typeof fetch` so a test can supply a plain
|
|
383
|
+
* function without also implementing the runtime's extras (`preconnect`).
|
|
384
|
+
*/
|
|
385
|
+
export type FetchLike = (
|
|
386
|
+
input: string,
|
|
387
|
+
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
|
|
388
|
+
) => Promise<Response>
|
|
389
|
+
|
|
390
|
+
/** How the real client is built. `fetchImpl` exists so a test can drive the REAL client offline. */
|
|
391
|
+
export interface ZeropsApiOptions {
|
|
392
|
+
/** Zerops personal access token. Sent as `Authorization: Bearer`. NEVER logged. */
|
|
393
|
+
token: string
|
|
394
|
+
/** Override for a different region's API host. */
|
|
395
|
+
baseUrl?: string
|
|
396
|
+
fetchImpl?: FetchLike
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Read a property off an unknown value without asserting anything about the value's shape. */
|
|
400
|
+
const prop = (
|
|
401
|
+
value: unknown,
|
|
402
|
+
key: string,
|
|
403
|
+
): unknown => (typeof value === 'object' && value !== null && key in value ? Reflect.get(value, key) : undefined)
|
|
404
|
+
|
|
405
|
+
const str = (value: unknown, key: string): string | undefined => {
|
|
406
|
+
const found = prop(value, key)
|
|
407
|
+
return typeof found === 'string' ? found : undefined
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const num = (value: unknown, key: string): number | undefined => {
|
|
411
|
+
const found = prop(value, key)
|
|
412
|
+
return typeof found === 'number' ? found : undefined
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const bool = (value: unknown, key: string): boolean | undefined => {
|
|
416
|
+
const found = prop(value, key)
|
|
417
|
+
return typeof found === 'boolean' ? found : undefined
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const arr = (value: unknown, key: string): unknown[] => {
|
|
421
|
+
const found = prop(value, key)
|
|
422
|
+
return Array.isArray(found) ? found : []
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const stringList = (value: unknown, key: string): string[] | undefined => {
|
|
426
|
+
const found = prop(value, key)
|
|
427
|
+
if (!Array.isArray(found) || found.some((entry) => typeof entry !== 'string')) {
|
|
428
|
+
return undefined
|
|
429
|
+
}
|
|
430
|
+
return found.filter((entry): entry is string => typeof entry === 'string')
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Every valid `ZeropsAppVersionStatus`, in pipeline order. The array's element type keeps it exhaustive. */
|
|
434
|
+
const STATUSES: readonly ZeropsAppVersionStatus[] = [
|
|
435
|
+
'UPLOADING',
|
|
436
|
+
'WAITING_TO_BUILD',
|
|
437
|
+
'BUILDING',
|
|
438
|
+
'BUILD_FAILED',
|
|
439
|
+
'BUILD_VALIDATION_FAILED',
|
|
440
|
+
'WAITING_TO_DEPLOY',
|
|
441
|
+
'DEPLOYING',
|
|
442
|
+
'DEPLOY_FAILED',
|
|
443
|
+
'PREPARING_RUNTIME',
|
|
444
|
+
'PREPARING_RUNTIME_FAILED',
|
|
445
|
+
'ACTIVE',
|
|
446
|
+
'BACKUP',
|
|
447
|
+
'CANCELLED',
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
const PROJECT_STATUSES: readonly ZeropsProjectStatus[] = [
|
|
451
|
+
'NEW',
|
|
452
|
+
'CREATING',
|
|
453
|
+
'ACTIVE',
|
|
454
|
+
'DELETING',
|
|
455
|
+
'FAILED',
|
|
456
|
+
'STOPPING',
|
|
457
|
+
'STOPPED',
|
|
458
|
+
'STARTING',
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
const PROJECT_MODES: readonly ZeropsProjectMode[] = ['LIGHT', 'SERIOUS']
|
|
462
|
+
|
|
463
|
+
const SERVICE_STATUSES: readonly ZeropsServiceStatus[] = [
|
|
464
|
+
'NEW',
|
|
465
|
+
'CREATING',
|
|
466
|
+
'ACTIVE',
|
|
467
|
+
'STOPPING',
|
|
468
|
+
'STOPPED',
|
|
469
|
+
'STARTING',
|
|
470
|
+
'RESTARTING',
|
|
471
|
+
'RELOADING',
|
|
472
|
+
'DELETING',
|
|
473
|
+
'DELETED',
|
|
474
|
+
'FAILED',
|
|
475
|
+
'ACTION_FAILED',
|
|
476
|
+
'UPGRADING',
|
|
477
|
+
'READY_TO_DEPLOY',
|
|
478
|
+
'SERVICE_CREATING',
|
|
479
|
+
'SERVICE_ACTIVE',
|
|
480
|
+
'SERVICE_STOPPING',
|
|
481
|
+
'SERVICE_STOPPED',
|
|
482
|
+
'SERVICE_STARTING',
|
|
483
|
+
'SERVICE_RESTARTING',
|
|
484
|
+
'SERVICE_RELOADING',
|
|
485
|
+
'SERVICE_DELETING',
|
|
486
|
+
'SERVICE_DELETED',
|
|
487
|
+
'SERVICE_FAILED',
|
|
488
|
+
'SERVICE_ACTION_FAILED',
|
|
489
|
+
'SERVICE_REPAIRING',
|
|
490
|
+
'SERVICE_CONTAINER_FAILED',
|
|
491
|
+
'SERVICE_MOVING_CONTAINER',
|
|
492
|
+
'SERVICE_UPGRADING',
|
|
493
|
+
'SERVICE_SCALING',
|
|
494
|
+
'SERVICE_REPAIR_FAILED',
|
|
495
|
+
'REPAIRING',
|
|
496
|
+
'CONTAINER_FAILED',
|
|
497
|
+
'MOVING_CONTAINER',
|
|
498
|
+
'SCALING',
|
|
499
|
+
'REPAIR_FAILED',
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Narrow a wire string to a known status. `undefined` means Zerops sent something this build does not
|
|
504
|
+
* know — which the poll loop must treat as "keep waiting", never as success or failure.
|
|
505
|
+
*/
|
|
506
|
+
export const asAppVersionStatus = (value: unknown): ZeropsAppVersionStatus | undefined => STATUSES.find((status) => status === value)
|
|
507
|
+
|
|
508
|
+
const asProjectStatus = (value: unknown): ZeropsProjectStatus | undefined => PROJECT_STATUSES.find((status) => status === value)
|
|
509
|
+
|
|
510
|
+
const asProjectMode = (value: unknown): ZeropsProjectMode | undefined => PROJECT_MODES.find((mode) => mode === value)
|
|
511
|
+
|
|
512
|
+
const asServiceStatus = (value: unknown): ZeropsServiceStatus | undefined => SERVICE_STATUSES.find((status) => status === value)
|
|
513
|
+
|
|
514
|
+
const readAppVersion = (value: unknown): ZeropsAppVersion => ({
|
|
515
|
+
id: str(value, 'id') ?? '',
|
|
516
|
+
status: asAppVersionStatus(prop(value, 'status')),
|
|
517
|
+
serviceStackId: str(value, 'serviceStackId'),
|
|
518
|
+
projectId: str(value, 'projectId'),
|
|
519
|
+
sequence: num(value, 'sequence'),
|
|
520
|
+
build: typeof prop(value, 'build') === 'object' && prop(value, 'build') !== null
|
|
521
|
+
? {
|
|
522
|
+
startDate: str(prop(value, 'build'), 'startDate'),
|
|
523
|
+
endDate: str(prop(value, 'build'), 'endDate'),
|
|
524
|
+
pipelineStart: str(prop(value, 'build'), 'pipelineStart'),
|
|
525
|
+
pipelineFinish: str(prop(value, 'build'), 'pipelineFinish'),
|
|
526
|
+
pipelineFailed: str(prop(value, 'build'), 'pipelineFailed'),
|
|
527
|
+
}
|
|
528
|
+
: undefined,
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const readProcess = (value: unknown): ZeropsProcess => ({
|
|
532
|
+
id: str(value, 'id') ?? '',
|
|
533
|
+
status: str(value, 'status'),
|
|
534
|
+
actionName: str(value, 'actionName'),
|
|
535
|
+
serviceStackId: str(value, 'serviceStackId'),
|
|
536
|
+
appVersionId: str(prop(value, 'appVersion'), 'id'),
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
const readService = (value: unknown): ZeropsService => ({
|
|
540
|
+
id: str(value, 'id') ?? '',
|
|
541
|
+
name: str(value, 'name') ?? '',
|
|
542
|
+
projectId: str(value, 'projectId'),
|
|
543
|
+
status: asServiceStatus(prop(value, 'status')),
|
|
544
|
+
base: str(value, 'base'),
|
|
545
|
+
activeAppVersionId: str(prop(value, 'activeAppVersion'), 'id'),
|
|
546
|
+
...(bool(value, 'subdomainAccess') !== undefined ? { subdomainAccess: bool(value, 'subdomainAccess') } : {}),
|
|
547
|
+
...(str(value, 'autoscalingProfileId') !== undefined ? { autoscalingProfileId: str(value, 'autoscalingProfileId') } : {}),
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
const readProject = (value: unknown): ZeropsProject => ({
|
|
551
|
+
id: str(value, 'id') ?? '',
|
|
552
|
+
name: str(value, 'name') ?? '',
|
|
553
|
+
status: asProjectStatus(prop(value, 'status')),
|
|
554
|
+
mode: asProjectMode(prop(value, 'mode')),
|
|
555
|
+
...(str(value, 'description') !== undefined ? { description: str(value, 'description') } : {}),
|
|
556
|
+
...(stringList(value, 'tagList') !== undefined ? { tagList: stringList(value, 'tagList') } : {}),
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
const readServiceEnv = (value: unknown): ZeropsServiceEnv => ({
|
|
560
|
+
id: str(value, 'id') ?? '',
|
|
561
|
+
key: str(value, 'key') ?? '',
|
|
562
|
+
content: str(value, 'content') ?? '',
|
|
563
|
+
serviceStackId: str(value, 'serviceStackId'),
|
|
564
|
+
type: str(value, 'type'),
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* A non-2xx answer from the Zerops API. `code` is the platform's own error identifier, so a caller can
|
|
569
|
+
* branch on `userDataDuplicateKey` rather than matching a prose message.
|
|
570
|
+
*/
|
|
571
|
+
export class ZeropsApiError extends Error {
|
|
572
|
+
constructor(message: string, readonly status: number, readonly code: string) {
|
|
573
|
+
super(message)
|
|
574
|
+
this.name = 'ZeropsApiError'
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The platform's code for "this service stack already has a variable under that key". */
|
|
579
|
+
const USER_DATA_DUPLICATE_KEY = 'userDataDuplicateKey'
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The platform's code for "this service publishes no HTTP port", the answer `enableSubdomainAccess` gives
|
|
583
|
+
* before a service's first deploy. Exported so a caller can say what is missing instead of relaying prose.
|
|
584
|
+
*/
|
|
585
|
+
export const ZEROPS_SERVICE_NOT_HTTP = 'serviceStackIsNotHttp'
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Turn a non-2xx response into a `ZeropsApiError`. Zerops answers with `{ error: { code, message, meta } }`
|
|
589
|
+
* (the envelope observed live; the OpenAPI `Error` schema shows the inner `{ code, message }`).
|
|
590
|
+
*
|
|
591
|
+
* `redactDetail` drops the server's MESSAGE, which a validation error can fill with the value it
|
|
592
|
+
* rejected. The CODE is a fixed identifier and is kept either way — without it nothing downstream can
|
|
593
|
+
* tell a duplicate key from a missing service.
|
|
594
|
+
*/
|
|
595
|
+
const apiError = (label: string, status: number, body: unknown, redactDetail: boolean): ZeropsApiError => {
|
|
596
|
+
const code = str(prop(body, 'error'), 'code') ?? str(body, 'code') ?? ''
|
|
597
|
+
const message = redactDetail ? '' : (str(prop(body, 'error'), 'message') ?? str(body, 'message') ?? '')
|
|
598
|
+
const detail = [code, message].filter((part) => part !== '').join(': ')
|
|
599
|
+
return new ZeropsApiError(`zerops: ${label} failed (${status})${detail === '' ? '' : ` — ${detail.slice(0, 300)}`}`, status, code)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Build the real client. Every request carries the bearer and the run's signal. */
|
|
603
|
+
export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
604
|
+
const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
|
|
605
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
606
|
+
|
|
607
|
+
const request = async (
|
|
608
|
+
label: string,
|
|
609
|
+
method: string,
|
|
610
|
+
path: string,
|
|
611
|
+
/**
|
|
612
|
+
* `redactDetail` drops the server's message from the thrown error, keeping only its code. Set it on
|
|
613
|
+
* any call whose BODY carries a secret value: a validation error can quote what it rejected, and an
|
|
614
|
+
* error that echoes a secret into a deploy log is a leak whichever end wrote it.
|
|
615
|
+
*/
|
|
616
|
+
init: { body?: unknown; signal: AbortSignal; redactDetail?: boolean },
|
|
617
|
+
): Promise<unknown> => {
|
|
618
|
+
const headers: Record<string, string> = { authorization: `Bearer ${options.token}`, accept: 'application/json' }
|
|
619
|
+
if (init.body !== undefined) {
|
|
620
|
+
headers['content-type'] = 'application/json'
|
|
621
|
+
}
|
|
622
|
+
const response = await doFetch(`${base}${path}`, {
|
|
623
|
+
method,
|
|
624
|
+
headers,
|
|
625
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
626
|
+
signal: init.signal,
|
|
627
|
+
})
|
|
628
|
+
const payload: unknown = await response.json().catch(() => null)
|
|
629
|
+
if (!response.ok) {
|
|
630
|
+
throw apiError(label, response.status, payload, init.redactDetail === true)
|
|
631
|
+
}
|
|
632
|
+
return payload
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const readImportResult = (payload: unknown): ZeropsImportResult => ({
|
|
636
|
+
projectId: str(payload, 'projectId') ?? '',
|
|
637
|
+
projectName: str(payload, 'projectName'),
|
|
638
|
+
services: arr(payload, 'serviceStacks').map((entry) => ({
|
|
639
|
+
id: str(entry, 'id') ?? '',
|
|
640
|
+
name: str(entry, 'name') ?? '',
|
|
641
|
+
processes: arr(entry, 'processes').map(readProcess),
|
|
642
|
+
})),
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
const PAGE_SIZE = 100
|
|
646
|
+
const readAllPages = async <Item>(
|
|
647
|
+
label: string,
|
|
648
|
+
path: string,
|
|
649
|
+
signal: AbortSignal,
|
|
650
|
+
readItem: (value: unknown) => Item,
|
|
651
|
+
): Promise<Item[]> => {
|
|
652
|
+
const items: Item[] = []
|
|
653
|
+
let offset = 0
|
|
654
|
+
while (true) {
|
|
655
|
+
const separator = path.includes('?') ? '&' : '?'
|
|
656
|
+
const payload = await request(label, 'GET', `${path}${separator}limit=${PAGE_SIZE}&offset=${offset}`, { signal })
|
|
657
|
+
const page = arr(payload, 'list')
|
|
658
|
+
const totalCount = num(payload, 'totalCount')
|
|
659
|
+
if (totalCount === undefined || !Number.isInteger(totalCount) || totalCount < 0) {
|
|
660
|
+
throw new Error(`zerops: ${label} returned an invalid totalCount`)
|
|
661
|
+
}
|
|
662
|
+
items.push(...page.map(readItem))
|
|
663
|
+
if (items.length >= totalCount) {
|
|
664
|
+
return items
|
|
665
|
+
}
|
|
666
|
+
if (page.length === 0) {
|
|
667
|
+
throw new Error(`zerops: ${label} ended before totalCount`)
|
|
668
|
+
}
|
|
669
|
+
offset += page.length
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Named so `putServiceEnv` can reach it: the conflict path needs the record id, and the ONE endpoint
|
|
675
|
+
* that hands it over on every service is `/env` (`/user-data` answers 400 unconditionally).
|
|
676
|
+
*/
|
|
677
|
+
const listServiceEnv: ZeropsApi['listServiceEnv'] = async ({ serviceId, signal }) => {
|
|
678
|
+
const payload = await request('list service env', 'GET', `/service-stack/${serviceId}/env`, { signal })
|
|
679
|
+
return arr(payload, 'items').map(readServiceEnv)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
importServices: async ({ projectId, yaml, signal }) =>
|
|
684
|
+
readImportResult(await request('service-stack import', 'POST', `/project/${projectId}/service-stack/import`, { body: { yaml }, signal })),
|
|
685
|
+
|
|
686
|
+
importProject: async ({ clientId, yaml, signal }) =>
|
|
687
|
+
readImportResult(await request('project import', 'POST', `/client/${clientId}/project/import`, { body: { yaml }, signal })),
|
|
688
|
+
|
|
689
|
+
triggerPipeline: async ({ serviceId, buildFromGit, zeropsYaml, zeropsSetup, signal }) => {
|
|
690
|
+
const body: Record<string, unknown> = {}
|
|
691
|
+
if (buildFromGit !== undefined) {
|
|
692
|
+
body['buildFromGit'] = buildFromGit
|
|
693
|
+
}
|
|
694
|
+
if (zeropsYaml !== undefined) {
|
|
695
|
+
body['zeropsYaml'] = zeropsYaml
|
|
696
|
+
}
|
|
697
|
+
if (zeropsSetup !== undefined) {
|
|
698
|
+
body['zeropsSetup'] = zeropsSetup
|
|
699
|
+
}
|
|
700
|
+
const payload = await request('trigger-pipeline', 'PUT', `/service-stack/${serviceId}/trigger-pipeline`, { body, signal })
|
|
701
|
+
const process = prop(payload, 'process')
|
|
702
|
+
return process === undefined || process === null ? null : readProcess(process)
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
getAppVersion: async ({ appVersionId, signal }) =>
|
|
706
|
+
readAppVersion(await request('get app-version', 'GET', `/app-version/${appVersionId}`, { signal })),
|
|
707
|
+
|
|
708
|
+
latestAppVersion: async ({ serviceId, signal }) => {
|
|
709
|
+
const payload = await request('list app-versions', 'GET', `/service-stack/${serviceId}/app-version?limit=25`, { signal })
|
|
710
|
+
const versions = arr(payload, 'list').map(readAppVersion)
|
|
711
|
+
// Highest `sequence` wins — the list's order is not documented, the sequence's meaning is.
|
|
712
|
+
return versions.reduce<ZeropsAppVersion | null>(
|
|
713
|
+
(best, version) => (best === null || (version.sequence ?? -1) > (best.sequence ?? -1) ? version : best),
|
|
714
|
+
null,
|
|
715
|
+
)
|
|
716
|
+
},
|
|
717
|
+
|
|
718
|
+
cancelBuild: async ({ appVersionId, signal }) => {
|
|
719
|
+
await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
enableSubdomainAccess: async ({ serviceId, signal }) => {
|
|
723
|
+
// No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
|
|
724
|
+
await request('enable subdomain access', 'PUT', `/service-stack/${serviceId}/enable-subdomain-access`, { signal })
|
|
725
|
+
},
|
|
726
|
+
|
|
727
|
+
getService: async ({ serviceId, signal }) => readService(await request('get service', 'GET', `/service-stack/${serviceId}`, { signal })),
|
|
728
|
+
|
|
729
|
+
findService: async ({ projectId, hostname, signal }) => {
|
|
730
|
+
try {
|
|
731
|
+
return readService(await request('find service', 'GET', `/service-stack-by-name/${projectId}/${encodeURIComponent(hostname)}`, { signal }))
|
|
732
|
+
} catch (error) {
|
|
733
|
+
// A missing service is an answer, not a failure; anything else propagates.
|
|
734
|
+
if (error instanceof Error && error.message.includes('(404)')) {
|
|
735
|
+
return null
|
|
736
|
+
}
|
|
737
|
+
throw error
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
getProject: async ({ projectId, signal }) => {
|
|
742
|
+
const payload = await request('get project', 'GET', `/project/${projectId}`, { signal })
|
|
743
|
+
return readProject(payload)
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
listProjects: async ({ clientId, signal }) => readAllPages('list projects', `/client/${clientId}/project`, signal, readProject),
|
|
747
|
+
|
|
748
|
+
findProjects: async ({ clientId, name, signal }) => {
|
|
749
|
+
const payload = await request(
|
|
750
|
+
'find projects',
|
|
751
|
+
'GET',
|
|
752
|
+
`/client/${clientId}/projects-by-name/${encodeURIComponent(name)}`,
|
|
753
|
+
{ signal },
|
|
754
|
+
)
|
|
755
|
+
return arr(payload, 'projects').map(readProject)
|
|
756
|
+
},
|
|
757
|
+
|
|
758
|
+
listProjectServices: async ({ projectId, signal }) =>
|
|
759
|
+
readAllPages('list project services', `/project/${projectId}/service-stack`, signal, readService),
|
|
760
|
+
|
|
761
|
+
listServiceEnv,
|
|
762
|
+
|
|
763
|
+
putServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
764
|
+
// `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
|
|
765
|
+
try {
|
|
766
|
+
await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
767
|
+
body: { key, content: value },
|
|
768
|
+
signal,
|
|
769
|
+
redactDetail: true,
|
|
770
|
+
})
|
|
771
|
+
return
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_DUPLICATE_KEY) {
|
|
774
|
+
throw error
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Only the conflict reads, and only then: the create must work on a service that has never been
|
|
778
|
+
// deployed, which is the whole of ADR-0004's bring-up order.
|
|
779
|
+
const existing = (await listServiceEnv({ serviceId, signal })).find((entry) => entry.key === key)
|
|
780
|
+
if (existing === undefined || existing.id === '') {
|
|
781
|
+
// The service declares the key in its own `zerops.yaml` (`type: ENV`), which conflicts on create
|
|
782
|
+
// yet never appears in the env list. Replacing it here would be undone by the next deploy.
|
|
783
|
+
throw new Error(
|
|
784
|
+
`zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
await request('update service env', 'PUT', `/user-data/${existing.id}`, { body: { key, content: value }, signal, redactDetail: true })
|
|
788
|
+
},
|
|
789
|
+
|
|
790
|
+
deleteServiceEnv: async ({ envId, signal }) => {
|
|
791
|
+
await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
|
|
792
|
+
},
|
|
793
|
+
|
|
794
|
+
getProjectEnv: async ({ projectEnvId, signal }) =>
|
|
795
|
+
readServiceEnv(await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
|
|
796
|
+
|
|
797
|
+
getLogAccess: async ({ projectId, signal }) => {
|
|
798
|
+
const payload = await request('get log access', 'GET', `/project/${projectId}/log`, { signal })
|
|
799
|
+
return {
|
|
800
|
+
url: str(payload, 'url') ?? '',
|
|
801
|
+
urlPlain: str(payload, 'urlPlain') ?? '',
|
|
802
|
+
urlInfo: str(payload, 'urlInfo') ?? '',
|
|
803
|
+
urlUi: str(payload, 'urlUi') ?? '',
|
|
804
|
+
accessToken: str(payload, 'accessToken') ?? '',
|
|
805
|
+
expiration: str(payload, 'expiration') ?? '',
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
|
|
809
|
+
readBuildLog: async ({ access, serviceId, limit, signal }) => {
|
|
810
|
+
// UNVERIFIED (see the interface): this is a best reading of an undocumented log service, not a
|
|
811
|
+
// contract. It is written so a wrong guess degrades to "no lines" instead of failing a deploy.
|
|
812
|
+
if (access.urlPlain === '') {
|
|
813
|
+
return []
|
|
814
|
+
}
|
|
815
|
+
const separator = access.urlPlain.includes('?') ? '&' : '?'
|
|
816
|
+
const url = `${access.urlPlain}${separator}serviceStackId=${encodeURIComponent(serviceId)}&limit=${limit ?? 200}`
|
|
817
|
+
const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
|
|
818
|
+
if (!response.ok) {
|
|
819
|
+
throw new Error(`zerops: read build log failed (${response.status})`)
|
|
820
|
+
}
|
|
821
|
+
const text = await response.text()
|
|
822
|
+
return text.split('\n').filter((line) => line.trim() !== '').map((line) => ({ message: line }))
|
|
823
|
+
},
|
|
824
|
+
}
|
|
825
|
+
}
|