@fabrika/provider-zerops 0.4.2 → 0.4.3
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 +8 -7
- package/src/api/client.ts +11 -14
- package/src/api/polling.ts +42 -0
- package/src/api/readers.ts +9 -2
- package/src/api/service-env-limit.ts +29 -0
- package/src/api/service-env-response.ts +34 -2
- package/src/api/types.ts +39 -9
- package/src/api.ts +10 -1
- package/src/control.ts +23 -8
- package/src/execution.ts +41 -3
- package/src/index.ts +5 -0
- package/src/installation/admin.ts +4 -2
- package/src/installation/checker-release.ts +44 -0
- package/src/installation/deploy.ts +66 -22
- package/src/installation/generated/platform-console-schema.ts +18 -2
- package/src/installation/generated/platform-proxy-manifest.ts +20 -0
- package/src/installation/guide.md +164 -52
- package/src/installation/index.ts +22 -15
- package/src/installation/init-options.ts +2 -2
- package/src/installation/init.ts +8 -2
- package/src/installation/install-collaborators.ts +4 -0
- package/src/installation/install-environment.ts +52 -30
- package/src/installation/install-options.ts +20 -19
- package/src/installation/install-project.ts +23 -15
- package/src/installation/install-provisioning.ts +12 -12
- package/src/installation/install-secrets.ts +89 -0
- package/src/installation/install-token.ts +37 -0
- package/src/installation/install.ts +34 -21
- package/src/installation/machine-key.ts +3 -2
- package/src/installation/manifest.ts +29 -12
- package/src/installation/platform-archive.ts +87 -0
- package/src/installation/platform-upload.ts +2 -2
- package/src/installation/secrets.ts +3 -4
- package/src/installation/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- package/src/managed-env.ts +11 -0
- package/src/manifest.ts +25 -3
- package/src/namespace/codec.ts +6 -1
- package/src/namespace/errors.ts +3 -1
- package/src/namespace/provisioning.ts +18 -5
- package/src/namespace/proxy-attempt.ts +56 -14
- package/src/namespace/update.ts +50 -8
- package/src/provider.ts +57 -13
- package/src/proxy-manifest-carrier.ts +32 -0
- package/src/source/port.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,12 +43,13 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"zod": "^3.25.76",
|
|
46
|
-
"@fabrika/auth": "^0.4.
|
|
47
|
-
"@fabrika/auth-core": "^0.4.
|
|
48
|
-
"@fabrika/iam-contract": "^0.4.
|
|
49
|
-
"@fabrika/installation-init": "^0.4.
|
|
50
|
-
"@fabrika/
|
|
51
|
-
"@fabrika/
|
|
46
|
+
"@fabrika/auth": "^0.4.3",
|
|
47
|
+
"@fabrika/auth-core": "^0.4.3",
|
|
48
|
+
"@fabrika/iam-contract": "^0.4.3",
|
|
49
|
+
"@fabrika/installation-init": "^0.4.3",
|
|
50
|
+
"@fabrika/operations-contract": "^0.4.3",
|
|
51
|
+
"@fabrika/provider-contract": "^0.4.3",
|
|
52
|
+
"@fabrika/proxy-core": "^0.4.3",
|
|
52
53
|
"yaml": "^2.9.1"
|
|
53
54
|
}
|
|
54
55
|
}
|
package/src/api/client.ts
CHANGED
|
@@ -14,19 +14,12 @@ import {
|
|
|
14
14
|
readResponse,
|
|
15
15
|
serviceEnvSchema,
|
|
16
16
|
serviceSchema,
|
|
17
|
+
userInfoSchema,
|
|
17
18
|
} from './readers'
|
|
19
|
+
import { assertServiceEnvValueFits } from './service-env-limit'
|
|
18
20
|
import { parseServiceEnvResponse } from './service-env-response'
|
|
19
21
|
import type { ZeropsApi, ZeropsApiOptions, ZeropsAppVersion, ZeropsLogLine } from './types'
|
|
20
22
|
|
|
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
23
|
/** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
|
|
31
24
|
const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
|
32
25
|
|
|
@@ -403,11 +396,12 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
403
396
|
|
|
404
397
|
listServiceEnv,
|
|
405
398
|
|
|
406
|
-
createServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
399
|
+
createServiceEnv: async ({ serviceId, key, value, sensitive, signal }) => {
|
|
400
|
+
assertServiceEnvValueFits(key, value)
|
|
407
401
|
let started: unknown
|
|
408
402
|
try {
|
|
409
403
|
started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
410
|
-
body: { key, content: value, sensitive
|
|
404
|
+
body: { key, content: value, sensitive },
|
|
411
405
|
signal,
|
|
412
406
|
omitErrorResponseDetails: true,
|
|
413
407
|
})
|
|
@@ -418,12 +412,13 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
418
412
|
await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
|
|
419
413
|
},
|
|
420
414
|
|
|
421
|
-
putServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
415
|
+
putServiceEnv: async ({ serviceId, key, value, sensitive, signal }) => {
|
|
416
|
+
assertServiceEnvValueFits(key, value)
|
|
422
417
|
const label = `the user-data sync on service ${serviceId}`
|
|
423
418
|
// `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
|
|
424
419
|
try {
|
|
425
420
|
const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
426
|
-
body: { key, content: value, sensitive
|
|
421
|
+
body: { key, content: value, sensitive },
|
|
427
422
|
signal,
|
|
428
423
|
redactDetail: true,
|
|
429
424
|
})
|
|
@@ -445,7 +440,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
445
440
|
)
|
|
446
441
|
}
|
|
447
442
|
const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
|
|
448
|
-
body: { key, content: value, sensitive
|
|
443
|
+
body: { key, content: value, sensitive },
|
|
449
444
|
signal,
|
|
450
445
|
redactDetail: true,
|
|
451
446
|
})
|
|
@@ -460,6 +455,8 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
460
455
|
getProjectEnv: async ({ projectEnvId, signal }) =>
|
|
461
456
|
readResponse('get project env', serviceEnvSchema, await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
|
|
462
457
|
|
|
458
|
+
getUserInfo: async ({ signal }) => readResponse('get user info', userInfoSchema, await request('get user info', 'GET', '/user/info', { signal })),
|
|
459
|
+
|
|
463
460
|
createIntegrationToken: async ({ clientId, name, projects, roleCode, canCreateProjects, signal }) =>
|
|
464
461
|
// `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
|
|
465
462
|
// this is the one error envelope that could quote a minted token back at us.
|
package/src/api/polling.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Sleeper } from '../collaborators'
|
|
2
|
+
import { ZEROPS_SERVICE_NOT_HTTP, ZeropsApiError } from './errors'
|
|
2
3
|
import { ZEROPS_PROCESS_FINISHED, ZEROPS_PROCESS_TERMINAL, type ZeropsApi, type ZeropsProcess } from './types'
|
|
3
4
|
|
|
4
5
|
// ── waiting on a process ────────────────────────────────────────────────────────
|
|
@@ -57,3 +58,44 @@ export const waitForProcess = async (input: {
|
|
|
57
58
|
await input.sleep(intervalMs, input.signal)
|
|
58
59
|
}
|
|
59
60
|
}
|
|
61
|
+
|
|
62
|
+
// ── waiting for a service's HTTP ports ──────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** How often `enableSubdomainAccessWhenHttp` asks again while the platform still answers `serviceStackIsNotHttp`. */
|
|
65
|
+
const HTTP_PORT_POLL_INTERVAL_MS = 5_000
|
|
66
|
+
|
|
67
|
+
/** ~3 minutes. The live lag measured between ACTIVE and the HTTP ports was under a minute. */
|
|
68
|
+
const HTTP_PORT_POLL_ATTEMPTS = 36
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `enableSubdomainAccess`, repeated while the service does not yet publish an HTTP port.
|
|
72
|
+
*
|
|
73
|
+
* Live-verified: right after a service's first app version reads ACTIVE the platform still answers
|
|
74
|
+
* `serviceStackIsNotHttp`, and a minute later the same call is accepted. The ports lag ACTIVE, so that
|
|
75
|
+
* answer means "not yet" for a bounded window. Past it the last refusal is rethrown unchanged, so a caller
|
|
76
|
+
* still names the missing port. Any other failure is rethrown at once. The 2xx still proves nothing: the
|
|
77
|
+
* caller decides by reading `subdomainAccess` back.
|
|
78
|
+
*/
|
|
79
|
+
export const enableSubdomainAccessWhenHttp = async (input: {
|
|
80
|
+
api: Pick<ZeropsApi, 'enableSubdomainAccess'>
|
|
81
|
+
serviceId: string
|
|
82
|
+
sleep: Sleeper
|
|
83
|
+
signal: AbortSignal
|
|
84
|
+
intervalMs?: number
|
|
85
|
+
attempts?: number
|
|
86
|
+
}): Promise<void> => {
|
|
87
|
+
const intervalMs = input.intervalMs ?? HTTP_PORT_POLL_INTERVAL_MS
|
|
88
|
+
const attempts = input.attempts ?? HTTP_PORT_POLL_ATTEMPTS
|
|
89
|
+
for (let attempt = 0;; attempt += 1) {
|
|
90
|
+
input.signal.throwIfAborted()
|
|
91
|
+
try {
|
|
92
|
+
await input.api.enableSubdomainAccess({ serviceId: input.serviceId, signal: input.signal })
|
|
93
|
+
return
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!(error instanceof ZeropsApiError) || error.code !== ZEROPS_SERVICE_NOT_HTTP || attempt >= attempts) {
|
|
96
|
+
throw error
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await input.sleep(intervalMs, input.signal)
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/api/readers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { withoutSensitiveContent } from './service-env-response'
|
|
2
3
|
import type {
|
|
3
4
|
ZeropsAppVersion,
|
|
4
5
|
ZeropsAppVersionStatus,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
ZeropsService,
|
|
14
15
|
ZeropsServiceEnv,
|
|
15
16
|
ZeropsServiceStatus,
|
|
17
|
+
ZeropsUserInfo,
|
|
16
18
|
} from './types'
|
|
17
19
|
|
|
18
20
|
/** Zerops sends `null` for an absent optional field; nothing reads one of these as required, so a malformed one reads as absent. */
|
|
@@ -148,14 +150,19 @@ export const integrationTokenSchema = z.object({
|
|
|
148
150
|
roleCode: optional(roleCodeSchema),
|
|
149
151
|
}) satisfies z.ZodType<ZeropsIntegrationToken, z.ZodTypeDef, unknown>
|
|
150
152
|
|
|
153
|
+
export const userInfoSchema = z.object({
|
|
154
|
+
id: idSchema,
|
|
155
|
+
email: z.string(),
|
|
156
|
+
}) satisfies z.ZodType<ZeropsUserInfo, z.ZodTypeDef, unknown>
|
|
157
|
+
|
|
151
158
|
export const serviceEnvSchema = z.object({
|
|
152
159
|
id: idSchema,
|
|
153
160
|
key: nonBlankSchema,
|
|
154
161
|
content: z.string(),
|
|
155
162
|
serviceStackId: optional(idSchema),
|
|
156
163
|
type: optional(z.string()),
|
|
157
|
-
sensitive:
|
|
158
|
-
}) satisfies z.ZodType<ZeropsServiceEnv, z.ZodTypeDef, unknown>
|
|
164
|
+
sensitive: z.boolean(),
|
|
165
|
+
}).transform(withoutSensitiveContent) satisfies z.ZodType<ZeropsServiceEnv, z.ZodTypeDef, unknown>
|
|
159
166
|
|
|
160
167
|
export const importResultSchema = z.object({
|
|
161
168
|
projectId: idSchema,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The largest service variable value fabrika writes, in UTF-8 bytes. Measured live on 2026-10-15 (issue #3): a value
|
|
3
|
+
* of 32,000 bytes is created, one of 32,768 bytes or more answers a bare `500` with no error code. The bytes between
|
|
4
|
+
* were not probed, so the bound is the largest size that was seen to work.
|
|
5
|
+
*/
|
|
6
|
+
export const ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES = 32_000
|
|
7
|
+
|
|
8
|
+
/** The provider's code for a value over the bound; the platform has none of its own, only the 500. */
|
|
9
|
+
export const ZEROPS_SERVICE_ENV_VALUE_TOO_LARGE = 'serviceEnvValueTooLarge'
|
|
10
|
+
|
|
11
|
+
/** A write refused before it was sent. The message names the variable and the sizes, never the value. */
|
|
12
|
+
export class ZeropsServiceEnvValueTooLargeError extends Error {
|
|
13
|
+
readonly code = ZEROPS_SERVICE_ENV_VALUE_TOO_LARGE
|
|
14
|
+
|
|
15
|
+
constructor(readonly key: string, readonly bytes: number) {
|
|
16
|
+
super(
|
|
17
|
+
`zerops: service variable \`${key}\` is ${bytes} bytes (UTF-8), over the ${ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES}-byte limit of a Zerops service variable`,
|
|
18
|
+
)
|
|
19
|
+
this.name = 'ZeropsServiceEnvValueTooLargeError'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Refuse a value Zerops would answer with a bare 500, which reads as an outage and is retried for ever. */
|
|
24
|
+
export const assertServiceEnvValueFits = (key: string, value: string): void => {
|
|
25
|
+
const bytes = new TextEncoder().encode(value).byteLength
|
|
26
|
+
if (bytes > ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES) {
|
|
27
|
+
throw new ZeropsServiceEnvValueTooLargeError(key, bytes)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -5,13 +5,45 @@ const invalid = (): never => {
|
|
|
5
5
|
throw new Error('zerops: invalid service environment response')
|
|
6
6
|
}
|
|
7
7
|
const nonemptySchema = z.string().refine((value) => value.trim() !== '')
|
|
8
|
+
|
|
9
|
+
/** One user-data record as the platform sends it, before `withoutSensitiveContent`. */
|
|
10
|
+
interface RawServiceEnv {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly key: string
|
|
13
|
+
readonly content: string
|
|
14
|
+
readonly serviceStackId?: string | undefined
|
|
15
|
+
readonly type?: string | undefined
|
|
16
|
+
readonly sensitive: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Drop a sensitive record's `content`, whatever it says.
|
|
21
|
+
*
|
|
22
|
+
* Zerops answers `REDACTED` outside sudo mode and the real value inside it (measured 2026-10-15), so the
|
|
23
|
+
* same call reads differently depending on the token. Dropping it here makes every caller behave the
|
|
24
|
+
* same for both, and no caller can compare, merge or leak a secret it read back.
|
|
25
|
+
*/
|
|
26
|
+
export const withoutSensitiveContent = (raw: RawServiceEnv): ZeropsServiceEnv => {
|
|
27
|
+
const record = {
|
|
28
|
+
id: raw.id,
|
|
29
|
+
key: raw.key,
|
|
30
|
+
...(raw.serviceStackId === undefined ? {} : { serviceStackId: raw.serviceStackId }),
|
|
31
|
+
...(raw.type === undefined ? {} : { type: raw.type }),
|
|
32
|
+
}
|
|
33
|
+
return raw.sensitive ? { ...record, sensitive: true } : { ...record, sensitive: false, content: raw.content }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The value of a record fabrika can read: absent when there is no record or it is sensitive. */
|
|
37
|
+
export const readableContent = (record: ZeropsServiceEnv | undefined): string | undefined =>
|
|
38
|
+
record === undefined || record.sensitive ? undefined : record.content
|
|
39
|
+
|
|
8
40
|
const serviceEnvEntrySchema = z.object({
|
|
9
41
|
id: nonemptySchema,
|
|
10
42
|
key: nonemptySchema,
|
|
11
43
|
content: z.string(),
|
|
12
44
|
serviceStackId: nonemptySchema.optional(),
|
|
13
45
|
type: nonemptySchema.optional(),
|
|
14
|
-
sensitive: z.boolean()
|
|
46
|
+
sensitive: z.boolean(),
|
|
15
47
|
}).superRefine((entry, ctx) => {
|
|
16
48
|
if (Object.values(entry).some((value) => value === undefined)) ctx.addIssue({ code: 'custom', message: 'present fields must have values' })
|
|
17
49
|
})
|
|
@@ -29,6 +61,6 @@ export function parseServiceEnvResponse(value: unknown, serviceId: string): Zero
|
|
|
29
61
|
ids.add(id)
|
|
30
62
|
keys.add(key)
|
|
31
63
|
if (serviceStackId !== undefined && serviceStackId !== serviceId) return invalid()
|
|
32
|
-
return entry
|
|
64
|
+
return withoutSensitiveContent(entry)
|
|
33
65
|
})
|
|
34
66
|
}
|
package/src/api/types.ts
CHANGED
|
@@ -231,22 +231,46 @@ export interface ZeropsIntegrationToken {
|
|
|
231
231
|
roleCode?: ZeropsRoleCode
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The identity a token authenticates as, narrowed to what fabrika reads. VERIFIED: `GET /user/info`,
|
|
236
|
+
* `ResponseUserAuthorize` — `id` and `email` are in its `required` list.
|
|
237
|
+
*
|
|
238
|
+
* Read with an integration token, `id` is the token's own id (measured live, 2026-08-21) and `email` is
|
|
239
|
+
* `token-<id>@zerops.io` (reported by an operator, not measured here).
|
|
240
|
+
*/
|
|
241
|
+
export interface ZeropsUserInfo {
|
|
242
|
+
id: string
|
|
243
|
+
email: string
|
|
244
|
+
}
|
|
245
|
+
|
|
234
246
|
/**
|
|
235
247
|
* ONE service-level environment variable. VERIFIED: `OutDtoUserData`.
|
|
236
248
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
249
|
+
* A SENSITIVE record carries no `content`. Measured 2026-10-15: Zerops answers `REDACTED` to a token
|
|
250
|
+
* outside sudo mode and the value itself to one inside it, so nothing may depend on reading a secret
|
|
251
|
+
* back, and the reader drops it. Never log `content`.
|
|
240
252
|
*/
|
|
241
|
-
export
|
|
253
|
+
export type ZeropsServiceEnv = ZeropsServiceEnvRecord & ({ sensitive: false; content: string } | { sensitive: true })
|
|
254
|
+
|
|
255
|
+
export interface ZeropsServiceEnvRecord {
|
|
242
256
|
id: string
|
|
243
257
|
key: string
|
|
244
|
-
content: string
|
|
245
258
|
serviceStackId?: string
|
|
246
259
|
/** Record classification, not a sensitivity or ownership guarantee; sensitive records can be USER. */
|
|
247
260
|
type?: string
|
|
248
|
-
|
|
249
|
-
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One service-level variable to write. */
|
|
264
|
+
export interface ZeropsServiceEnvWrite {
|
|
265
|
+
serviceId: string
|
|
266
|
+
key: string
|
|
267
|
+
value: string
|
|
268
|
+
/**
|
|
269
|
+
* `true` for a credential, `false` for everything else. A sensitive value can never be read back
|
|
270
|
+
* (see `ZeropsServiceEnv`), so a value fabrika compares or merges later must be written `false`.
|
|
271
|
+
*/
|
|
272
|
+
sensitive: boolean
|
|
273
|
+
signal: AbortSignal
|
|
250
274
|
}
|
|
251
275
|
|
|
252
276
|
/**
|
|
@@ -481,7 +505,7 @@ export interface ZeropsApi {
|
|
|
481
505
|
* details are intentionally unavailable because both the request and an upstream response can contain
|
|
482
506
|
* the secret value.
|
|
483
507
|
*/
|
|
484
|
-
createServiceEnv(input:
|
|
508
|
+
createServiceEnv(input: ZeropsServiceEnvWrite): Promise<void>
|
|
485
509
|
|
|
486
510
|
/**
|
|
487
511
|
* Create-or-update ONE service-level variable — the only way fabrika ever writes a secret, and it is
|
|
@@ -491,8 +515,9 @@ export interface ZeropsApi {
|
|
|
491
515
|
*
|
|
492
516
|
* WRITE FIRST, READ ONLY ON CONFLICT. A read before the write cannot be part of this: the create
|
|
493
517
|
* must work on a service that has never been deployed, which is exactly ADR-0004's bring-up order.
|
|
518
|
+
* An update also sets `sensitive`, so a record written sensitive by an older release becomes readable.
|
|
494
519
|
*/
|
|
495
|
-
putServiceEnv(input:
|
|
520
|
+
putServiceEnv(input: ZeropsServiceEnvWrite): Promise<void>
|
|
496
521
|
|
|
497
522
|
/** Delete one service-level variable by its record id. VERIFIED: `DELETE /user-data/{id}`. */
|
|
498
523
|
deleteServiceEnv(input: { envId: string; signal: AbortSignal }): Promise<void>
|
|
@@ -503,6 +528,11 @@ export interface ZeropsApi {
|
|
|
503
528
|
*/
|
|
504
529
|
getProjectEnv(input: { projectEnvId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv>
|
|
505
530
|
|
|
531
|
+
// ── user: who the token is ────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
/** The identity this client's token authenticates as. VERIFIED: `GET /user/info` (`GetUserInfo`). */
|
|
534
|
+
getUserInfo(input: { signal: AbortSignal }): Promise<ZeropsUserInfo>
|
|
535
|
+
|
|
506
536
|
// ── client: minting a credential ──────────────────────────────────────────────
|
|
507
537
|
|
|
508
538
|
/**
|
package/src/api.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export { createZeropsApi, ZEROPS_API_BASE } from './api/client'
|
|
2
2
|
export { ZEROPS_SERVICE_NOT_HTTP, ZeropsApiError } from './api/errors'
|
|
3
|
-
export { waitForProcess } from './api/polling'
|
|
3
|
+
export { enableSubdomainAccessWhenHttp, waitForProcess } from './api/polling'
|
|
4
|
+
export {
|
|
5
|
+
assertServiceEnvValueFits,
|
|
6
|
+
ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES,
|
|
7
|
+
ZEROPS_SERVICE_ENV_VALUE_TOO_LARGE,
|
|
8
|
+
ZeropsServiceEnvValueTooLargeError,
|
|
9
|
+
} from './api/service-env-limit'
|
|
10
|
+
export { readableContent } from './api/service-env-response'
|
|
4
11
|
export {
|
|
5
12
|
type FetchLike,
|
|
6
13
|
ZEROPS_ACTIVE,
|
|
@@ -24,6 +31,8 @@ export {
|
|
|
24
31
|
type ZeropsRoleCode,
|
|
25
32
|
type ZeropsService,
|
|
26
33
|
type ZeropsServiceEnv,
|
|
34
|
+
type ZeropsServiceEnvWrite,
|
|
27
35
|
type ZeropsServiceStatus,
|
|
28
36
|
type ZeropsTokenProjectGrant,
|
|
37
|
+
type ZeropsUserInfo,
|
|
29
38
|
} from './api/types'
|
package/src/control.ts
CHANGED
|
@@ -20,7 +20,15 @@ import type {
|
|
|
20
20
|
} from '@fabrika/provider-contract'
|
|
21
21
|
import { decodeEnvelope, ProviderSecretError } from '@fabrika/provider-contract'
|
|
22
22
|
import { z } from 'zod'
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createZeropsApi,
|
|
25
|
+
waitForProcess,
|
|
26
|
+
ZEROPS_ACTIVE,
|
|
27
|
+
ZEROPS_TERMINAL,
|
|
28
|
+
type ZeropsApi,
|
|
29
|
+
type ZeropsAppVersion,
|
|
30
|
+
ZeropsServiceEnvValueTooLargeError,
|
|
31
|
+
} from './api'
|
|
24
32
|
import { defaultSleep, defaultZeropsCollaborators, type Sleeper } from './collaborators'
|
|
25
33
|
import { createdServices, type ExposureAlerter, isObjectStorageType } from './exposure'
|
|
26
34
|
import { assertAuthoredManagedInputs, assertAuthoredManagedName, assertNoFrameworkReference, parseManagedEnvironment } from './managed-env'
|
|
@@ -371,12 +379,13 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
371
379
|
target: runtimeTarget,
|
|
372
380
|
artifact,
|
|
373
381
|
...(input.environment.access === undefined ? {} : { access: input.environment.access }),
|
|
382
|
+
...(input.artifactUpload === undefined ? {} : { artifactUpload: input.artifactUpload }),
|
|
374
383
|
dryRun: input.dryRun,
|
|
375
384
|
signal: input.signal,
|
|
376
385
|
events: input.events,
|
|
377
386
|
})
|
|
378
387
|
input.signal.throwIfAborted()
|
|
379
|
-
return { state: result.status }
|
|
388
|
+
return { state: result.status, ...(result.artifactState === undefined ? {} : { artifactState: result.artifactState }) }
|
|
380
389
|
},
|
|
381
390
|
cancel: async (input): Promise<ProviderCancelOutcome> => {
|
|
382
391
|
const signal = input.signal
|
|
@@ -459,12 +468,18 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
459
468
|
assertNoFrameworkReference(input.value)
|
|
460
469
|
assertOwnReferencesOnly(input.environment, input.name, input.value)
|
|
461
470
|
const target = decodeEnvironment(input.environment).target
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
471
|
+
try {
|
|
472
|
+
await api.putServiceEnv({
|
|
473
|
+
serviceId: target.serviceId,
|
|
474
|
+
key: input.name,
|
|
475
|
+
value: input.value,
|
|
476
|
+
sensitive: true,
|
|
477
|
+
signal: abortSignal(),
|
|
478
|
+
})
|
|
479
|
+
} catch (cause) {
|
|
480
|
+
if (cause instanceof ZeropsServiceEnvValueTooLargeError) throw new ProviderSecretError(cause.message, cause.code)
|
|
481
|
+
throw cause
|
|
482
|
+
}
|
|
468
483
|
return { valueRef: `zerops:${target.serviceId}/${encodeURIComponent(input.name)}` }
|
|
469
484
|
},
|
|
470
485
|
delete: async (input) => {
|
package/src/execution.ts
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
// runs. Of the authored setup only `build.base`, `build.os` and the SPA's `build.buildCommands` survive; the rest of
|
|
3
3
|
// the build, the deploy gate and the whole `run` section are generated here. PURE — no network, no filesystem.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type ContractArtifact,
|
|
7
|
+
contractDigest,
|
|
8
|
+
FABRIKA_CONTRACT_DIGEST,
|
|
9
|
+
FABRIKA_SOURCE_MAP_UPLOAD,
|
|
10
|
+
FABRIKA_SOURCE_MAP_UPLOAD_KEY,
|
|
11
|
+
type FrameworkVariableName,
|
|
12
|
+
} from '@fabrika/auth-core'
|
|
13
|
+
import type { OperationsAssetRoot, OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
|
|
6
14
|
import type { JsonValue } from '@fabrika/provider-contract'
|
|
7
15
|
import packageManifest from '../package.json'
|
|
8
16
|
import { renderYaml } from './compile'
|
|
@@ -16,8 +24,9 @@ export const ZEROPS_APPLICATION_PORT = 3000
|
|
|
16
24
|
export const ZEROPS_APPLICATION_RUNTIME = 'alpine/bun@1.3'
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
|
-
* The checker every build runs, pinned to this package's own version
|
|
20
|
-
*
|
|
27
|
+
* The checker every build runs, pinned to this package's own version. Control runs the git checkout of its release
|
|
28
|
+
* tag, so this reads the source version, which `release:validate` holds equal to the tag; the public packages are
|
|
29
|
+
* released together, so it is the checker of the installation that renders the build.
|
|
21
30
|
*/
|
|
22
31
|
export const FABRIKA_CHECK_VERSION: string = packageManifest.version
|
|
23
32
|
|
|
@@ -73,6 +82,8 @@ export interface ZeropsLockedExecution {
|
|
|
73
82
|
/** The hostname of the application's one object-storage service, or null when it has none. */
|
|
74
83
|
readonly storageHostname: string | null
|
|
75
84
|
readonly runtimePackages: readonly ZeropsRuntimePackage[]
|
|
85
|
+
/** Where the build uploads its source maps, without the bearer; null when Control minted no upload for the run. */
|
|
86
|
+
readonly sourceMaps: OperationsSourceMapBuildUpload | null
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
/** The application ids the generated scripts may name: the contract's own id rule, so a script needs no quoting. */
|
|
@@ -148,6 +159,16 @@ const refuse = (setup: string, key: string, reason: string): never => {
|
|
|
148
159
|
throw new Error(`invalid fabrika manifest: zerops.yaml setup \`${setup}\` sets \`${key}\`; ${reason} (allowed form: ${LOCKED_BUILD_FORM})`)
|
|
149
160
|
}
|
|
150
161
|
|
|
162
|
+
/** The directories a contract serves its pages from: each SPA mount's and each share link's SPA, by URL prefix. */
|
|
163
|
+
export function zeropsAssetRoots(artifact: ContractArtifact): OperationsAssetRoot[] {
|
|
164
|
+
const roots: OperationsAssetRoot[] = []
|
|
165
|
+
for (const mount of Object.values(artifact.mounts)) {
|
|
166
|
+
if (mount.kind === 'spa') roots.push({ prefix: mount.prefix, dir: mount.dir })
|
|
167
|
+
if (mount.kind === 'link') roots.push({ prefix: mount.prefix, dir: mount.spa })
|
|
168
|
+
}
|
|
169
|
+
return roots.sort((left, right) => left.prefix < right.prefix ? -1 : left.prefix > right.prefix ? 1 : 0)
|
|
170
|
+
}
|
|
171
|
+
|
|
151
172
|
interface AuthoredBuild {
|
|
152
173
|
readonly base: string | string[] | null
|
|
153
174
|
readonly os: string | null
|
|
@@ -253,6 +274,21 @@ const startScript = (appId: string, database: boolean): readonly string[] => [
|
|
|
253
274
|
|
|
254
275
|
const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
|
|
255
276
|
|
|
277
|
+
// After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
|
|
278
|
+
// provider reads the state from the report line.
|
|
279
|
+
const SOURCE_MAP_UPLOAD = `cd /tmp && bun x --bun -p @fabrika/check@${FABRIKA_CHECK_VERSION} fabrika-source-maps ${SOURCE_ROOT} || true`
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The upload's build variables: the destination and asset roots as a literal, and the bearer lifted from the service
|
|
283
|
+
* variable the deploy wrote sensitive, so the descriptor never holds it.
|
|
284
|
+
*/
|
|
285
|
+
const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record<string, string> => {
|
|
286
|
+
const value = JSON.stringify(upload)
|
|
287
|
+
// Zerops resolves `${…}` in a build variable, so an SPA directory carrying one would read another variable.
|
|
288
|
+
if (value.includes('${')) throw new Error('zerops: an SPA directory or prefix must not contain `${`')
|
|
289
|
+
return { [FABRIKA_SOURCE_MAP_UPLOAD]: value, [FABRIKA_SOURCE_MAP_UPLOAD_KEY]: `\${RUNTIME_${FABRIKA_SOURCE_MAP_UPLOAD_KEY}}` }
|
|
290
|
+
}
|
|
291
|
+
|
|
256
292
|
/**
|
|
257
293
|
* The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
|
|
258
294
|
* run first: they are authored code, so everything after them is reinstalled, regenerated or checked again.
|
|
@@ -270,6 +306,7 @@ export function generatedBuildCommands(execution: ZeropsLockedExecution): readon
|
|
|
270
306
|
`cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
|
|
271
307
|
...(database ? [writeFileCommand(MIGRATE_SCRIPT, migrateScript(execution.appId))] : []),
|
|
272
308
|
writeFileCommand(START_SCRIPT, startScript(execution.appId, database)),
|
|
309
|
+
...(execution.sourceMaps === null ? [] : [SOURCE_MAP_UPLOAD]),
|
|
273
310
|
]
|
|
274
311
|
}
|
|
275
312
|
|
|
@@ -330,6 +367,7 @@ export function renderLockedSetup(
|
|
|
330
367
|
base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
|
|
331
368
|
...(authored.os === null ? {} : { os: authored.os }),
|
|
332
369
|
buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
|
|
370
|
+
...(execution.sourceMaps === null ? {} : { envVariables: sourceMapBuildVariables(execution.sourceMaps) }),
|
|
333
371
|
deployFiles: ['./'],
|
|
334
372
|
cache: ['node_modules'],
|
|
335
373
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export {
|
|
2
2
|
createZeropsApi,
|
|
3
|
+
enableSubdomainAccessWhenHttp,
|
|
4
|
+
readableContent,
|
|
3
5
|
waitForProcess,
|
|
4
6
|
ZEROPS_ACTIVE,
|
|
5
7
|
ZEROPS_API_BASE,
|
|
@@ -28,8 +30,10 @@ export type {
|
|
|
28
30
|
ZeropsRoleCode,
|
|
29
31
|
ZeropsService,
|
|
30
32
|
ZeropsServiceEnv,
|
|
33
|
+
ZeropsServiceEnvWrite,
|
|
31
34
|
ZeropsServiceStatus,
|
|
32
35
|
ZeropsTokenProjectGrant,
|
|
36
|
+
ZeropsUserInfo,
|
|
33
37
|
} from './api'
|
|
34
38
|
export { defineApp, isZeropsAppConfig, useSharedPostgres, zeropsSharedServiceHostname, zeropsSharedServicePrefix } from './authoring'
|
|
35
39
|
export type { ZeropsAuthoredAppConfig } from './authoring'
|
|
@@ -100,6 +104,7 @@ export { buildZeropsPlan, resolveDeployHostname } from './plan'
|
|
|
100
104
|
export type { ZeropsStep, ZeropsStepKind } from './plan'
|
|
101
105
|
export { CANCELLED, createZeropsDeployer, interpolateManifest } from './provider'
|
|
102
106
|
export type { ZeropsDeployResult, ZeropsDeployRun } from './provider'
|
|
107
|
+
export { ZEROPS_PROXY_MANIFEST_PATH, type ZeropsProxyManifestCarrier, zeropsProxyManifestCarrier } from './proxy-manifest-carrier'
|
|
103
108
|
export type * from './schema.generated'
|
|
104
109
|
export { isZeropsUploadDestination, normalizeZeropsSourceRepository } from './source'
|
|
105
110
|
export type { ZeropsSourceRepository } from './source'
|
|
@@ -84,7 +84,7 @@ export const runPlatformAdmin = async (input: PlatformAdminInput, collaborators:
|
|
|
84
84
|
const report = (result: FirstAdministratorResult, log: AdminLog): void => {
|
|
85
85
|
log.ok(
|
|
86
86
|
result.principal === 'invited'
|
|
87
|
-
? `
|
|
87
|
+
? `created principal ${result.principalId} for ${result.email} — no e-mail was sent`
|
|
88
88
|
: `${result.email} already exists as principal ${result.principalId}`,
|
|
89
89
|
)
|
|
90
90
|
// `app: null` is the whole point: an `admin` grant scoped to the console's own app id leaves
|
|
@@ -113,7 +113,9 @@ const announceEnrollment = (result: FirstAdministratorResult, log: AdminLog, pri
|
|
|
113
113
|
return
|
|
114
114
|
}
|
|
115
115
|
log.ok(`enrollment issued, valid until ${expiry(result.enrollment.expiresAt)}`)
|
|
116
|
-
|
|
116
|
+
// IAM answers with the URL only when it has no mail service, so nobody else will deliver it.
|
|
117
|
+
log.info('Nothing was e-mailed: this installation has no mail service. Give the URL below to the administrator yourself.')
|
|
118
|
+
log.info('It sets the first password. It is a credential: it is printed once, stored nowhere, and it expires.')
|
|
117
119
|
print(result.enrollment.url)
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { FABRIKA_CHECK_VERSION } from '../execution'
|
|
2
|
+
|
|
3
|
+
/** The package every contract application build fetches by this checkout's version. */
|
|
4
|
+
export const FABRIKA_CHECK_PACKAGE = '@fabrika/check'
|
|
5
|
+
|
|
6
|
+
/** Whether npm has `@fabrika/check` at `version`. Throws when it cannot tell. */
|
|
7
|
+
export type CheckerPublication = (version: string, signal: AbortSignal) => Promise<boolean>
|
|
8
|
+
|
|
9
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
10
|
+
|
|
11
|
+
export type RegistryFetch = (url: string, init: RequestInit) => Promise<Response>
|
|
12
|
+
|
|
13
|
+
export const npmCheckerPublication = (fetchImpl: RegistryFetch = (url, init) => fetch(url, init)): CheckerPublication => async (version, signal) => {
|
|
14
|
+
const url = `${NPM_REGISTRY}/${FABRIKA_CHECK_PACKAGE.replace('/', '%2F')}/${encodeURIComponent(version)}`
|
|
15
|
+
let response: Response
|
|
16
|
+
try {
|
|
17
|
+
response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal })
|
|
18
|
+
} catch {
|
|
19
|
+
signal.throwIfAborted()
|
|
20
|
+
throw new Error(`could not reach npm to check that ${FABRIKA_CHECK_PACKAGE}@${version} is published; retry when npm answers`)
|
|
21
|
+
}
|
|
22
|
+
await response.body?.cancel()
|
|
23
|
+
if (response.ok) return true
|
|
24
|
+
if (response.status === 404) return false
|
|
25
|
+
throw new Error(`npm answered ${response.status} for ${FABRIKA_CHECK_PACKAGE}@${version}; retry when npm answers`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Refuse a checkout whose checker npm does not have. Every contract application build of the installation fetches
|
|
30
|
+
* `@fabrika/check` at this checkout's version, so without it no application could deploy. Between a release's bump
|
|
31
|
+
* commit and its publish, `main` is such a checkout.
|
|
32
|
+
*/
|
|
33
|
+
export const assertCheckerPublished = async (
|
|
34
|
+
published: CheckerPublication,
|
|
35
|
+
signal: AbortSignal,
|
|
36
|
+
version: string = FABRIKA_CHECK_VERSION,
|
|
37
|
+
): Promise<void> => {
|
|
38
|
+
if (await published(version, signal)) return
|
|
39
|
+
throw new Error(
|
|
40
|
+
`this checkout is version ${version}, which is not published yet: npm has no ${FABRIKA_CHECK_PACKAGE}@${version}, and every `
|
|
41
|
+
+ 'application build of this installation fetches it. Deploy a released tag of contember/fabrika-platform, '
|
|
42
|
+
+ `or wait until v${version} is published.`,
|
|
43
|
+
)
|
|
44
|
+
}
|