@fabrika/provider-zerops 0.0.12 → 0.0.13
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 +5 -5
- package/src/api.ts +66 -6
- package/src/manifest.ts +1 -1
- package/src/namespace.ts +13 -10
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"gen:schema": "bun scripts/generate-zerops-types.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@fabrika/auth": "^0.0.
|
|
36
|
-
"@fabrika/auth-core": "^0.0.
|
|
37
|
-
"@fabrika/provider-contract": "^0.0.
|
|
38
|
-
"@fabrika/proxy-contract": "^0.0.
|
|
35
|
+
"@fabrika/auth": "^0.0.13",
|
|
36
|
+
"@fabrika/auth-core": "^0.0.13",
|
|
37
|
+
"@fabrika/provider-contract": "^0.0.13",
|
|
38
|
+
"@fabrika/proxy-contract": "^0.0.13"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/api.ts
CHANGED
|
@@ -446,6 +446,12 @@ export interface ZeropsApi {
|
|
|
446
446
|
listProjectServices(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsService[]>
|
|
447
447
|
|
|
448
448
|
// ── user-data: SERVICE-level environment variables (ADR-0004) ─────────────────
|
|
449
|
+
//
|
|
450
|
+
// EVERY WRITE HERE IS ASYNCHRONOUS. Create, replace and delete each answer with a PENDING
|
|
451
|
+
// `stack.updateUserData` process rather than the record, and while one is running the platform
|
|
452
|
+
// refuses the next operation on that service with `400 userDataSyncRunning`. So all three wait for
|
|
453
|
+
// their own process before returning: a caller that writes a variable and then builds — which is
|
|
454
|
+
// what every deploy does — must not have to know that.
|
|
449
455
|
|
|
450
456
|
/**
|
|
451
457
|
* Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
|
|
@@ -576,6 +582,8 @@ export interface ZeropsApiOptions {
|
|
|
576
582
|
/** Override for a different region's API host. */
|
|
577
583
|
baseUrl?: string
|
|
578
584
|
fetchImpl?: FetchLike
|
|
585
|
+
/** How the user-data writes wait for their platform process. Tests pass a sleeper that does not sleep. */
|
|
586
|
+
sleep?: Sleeper
|
|
579
587
|
}
|
|
580
588
|
|
|
581
589
|
/** Read a property off an unknown value without asserting anything about the value's shape. */
|
|
@@ -797,10 +805,29 @@ const apiError = (label: string, status: number, body: unknown, redactDetail: bo
|
|
|
797
805
|
return new ZeropsApiError(`zerops: ${label} failed (${status})${detail === '' ? '' : ` — ${detail.slice(0, 300)}`}`, status, code)
|
|
798
806
|
}
|
|
799
807
|
|
|
808
|
+
const timerSleep: Sleeper = (ms, signal) =>
|
|
809
|
+
new Promise<void>((resolve) => {
|
|
810
|
+
if (signal.aborted) {
|
|
811
|
+
resolve()
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
const timer = setTimeout(() => {
|
|
815
|
+
signal.removeEventListener('abort', onAbort)
|
|
816
|
+
resolve()
|
|
817
|
+
}, ms)
|
|
818
|
+
const onAbort = (): void => {
|
|
819
|
+
clearTimeout(timer)
|
|
820
|
+
resolve()
|
|
821
|
+
}
|
|
822
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
823
|
+
})
|
|
824
|
+
|
|
800
825
|
/** Build the real client. Every request carries the bearer and the run's signal. */
|
|
801
826
|
export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
802
827
|
const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
|
|
803
828
|
const doFetch = options.fetchImpl ?? fetch
|
|
829
|
+
// Its own, rather than `collaborators`' — that module imports this one, so taking the value back would close a cycle.
|
|
830
|
+
const sleep = options.sleep ?? timerSleep
|
|
804
831
|
const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
|
|
805
832
|
const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
|
|
806
833
|
const SERVICE_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024
|
|
@@ -975,6 +1002,30 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
975
1002
|
return items.map(readServiceEnv)
|
|
976
1003
|
}
|
|
977
1004
|
|
|
1005
|
+
const getProcess: ZeropsApi['getProcess'] = async ({ processId, signal }) =>
|
|
1006
|
+
readProcess(await request('get process', 'GET', `/process/${processId}`, { signal }))
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Wait out the `stack.updateUserData` process every user-data write answers with.
|
|
1010
|
+
*
|
|
1011
|
+
* While one runs the platform refuses the NEXT operation on that service with `400
|
|
1012
|
+
* userDataSyncRunning` — which is how a deploy that wrote an app's environment and then asked for a
|
|
1013
|
+
* build failed, one second before the sync it had started finished. Measured live: PENDING → RUNNING →
|
|
1014
|
+
* FINISHED in about three seconds, on a deployed service and on one that has never had code alike.
|
|
1015
|
+
*
|
|
1016
|
+
* The response is recognised by `actionName`, not by having an `id`: a user-data RECORD carries an id
|
|
1017
|
+
* too, and polling `/process/{recordId}` would hang instead of failing. An unrecognised response is
|
|
1018
|
+
* left alone — nothing then says the write is still settling.
|
|
1019
|
+
*/
|
|
1020
|
+
const awaitUserDataSync = async (payload: unknown, label: string, signal: AbortSignal): Promise<void> => {
|
|
1021
|
+
const processId = str(payload, 'id')
|
|
1022
|
+
const action = str(payload, 'actionName')
|
|
1023
|
+
if (processId === undefined || processId.trim() === '' || action === undefined || action.trim() === '') {
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
await waitForProcess({ api: { getProcess }, processId, sleep, signal, label })
|
|
1027
|
+
}
|
|
1028
|
+
|
|
978
1029
|
return {
|
|
979
1030
|
importServices: async ({ projectId, yaml, signal }) =>
|
|
980
1031
|
readImportResult(await request('service-stack import', 'POST', `/project/${projectId}/service-stack/import`, { body: { yaml }, signal })),
|
|
@@ -1017,10 +1068,13 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1017
1068
|
|
|
1018
1069
|
buildAndDeployAppVersion: async ({ appVersionId, zeropsYaml, zeropsYamlSetup, signal }) => {
|
|
1019
1070
|
const body = zeropsYamlSetup === undefined ? { zeropsYaml } : { zeropsYaml, zeropsYamlSetup }
|
|
1071
|
+
// `redactDetail`, not `omitErrorResponseDetails`: this request body is the app's COMMITTED
|
|
1072
|
+
// descriptor and this response carries no upload URL, so there is nothing here to protect by
|
|
1073
|
+
// dropping the code as well — and a bare `(400)` is what made `userDataSyncRunning` cost a day.
|
|
1020
1074
|
const payload = await request('build and deploy app-version', 'PUT', `/app-version/${appVersionId}/build-and-deploy`, {
|
|
1021
1075
|
body,
|
|
1022
1076
|
signal,
|
|
1023
|
-
|
|
1077
|
+
redactDetail: true,
|
|
1024
1078
|
})
|
|
1025
1079
|
const processId = requiredString(payload, 'id', 'build and deploy app-version')
|
|
1026
1080
|
const responseAppVersionId = str(prop(payload, 'appVersion'), 'id')
|
|
@@ -1057,7 +1111,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1057
1111
|
await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
|
|
1058
1112
|
},
|
|
1059
1113
|
|
|
1060
|
-
getProcess
|
|
1114
|
+
getProcess,
|
|
1061
1115
|
|
|
1062
1116
|
enableSubdomainAccess: async ({ serviceId, signal }) => {
|
|
1063
1117
|
// No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
|
|
@@ -1101,8 +1155,9 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1101
1155
|
listServiceEnv,
|
|
1102
1156
|
|
|
1103
1157
|
createServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
1158
|
+
let started: unknown
|
|
1104
1159
|
try {
|
|
1105
|
-
await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1160
|
+
started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1106
1161
|
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
1107
1162
|
signal,
|
|
1108
1163
|
omitErrorResponseDetails: true,
|
|
@@ -1111,16 +1166,19 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1111
1166
|
if (error instanceof Error && error.name === 'AbortError') throw error
|
|
1112
1167
|
throw new Error('zerops: create-only service env failed')
|
|
1113
1168
|
}
|
|
1169
|
+
await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
|
|
1114
1170
|
},
|
|
1115
1171
|
|
|
1116
1172
|
putServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
1173
|
+
const label = `the user-data sync on service ${serviceId}`
|
|
1117
1174
|
// `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
|
|
1118
1175
|
try {
|
|
1119
|
-
await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1176
|
+
const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1120
1177
|
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
1121
1178
|
signal,
|
|
1122
1179
|
redactDetail: true,
|
|
1123
1180
|
})
|
|
1181
|
+
await awaitUserDataSync(created, label, signal)
|
|
1124
1182
|
return
|
|
1125
1183
|
} catch (error) {
|
|
1126
1184
|
if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_DUPLICATE_KEY) {
|
|
@@ -1137,15 +1195,17 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1137
1195
|
`zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
|
|
1138
1196
|
)
|
|
1139
1197
|
}
|
|
1140
|
-
await request('update service env', 'PUT', `/user-data/${existing.id}`, {
|
|
1198
|
+
const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
|
|
1141
1199
|
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
1142
1200
|
signal,
|
|
1143
1201
|
redactDetail: true,
|
|
1144
1202
|
})
|
|
1203
|
+
await awaitUserDataSync(updated, label, signal)
|
|
1145
1204
|
},
|
|
1146
1205
|
|
|
1147
1206
|
deleteServiceEnv: async ({ envId, signal }) => {
|
|
1148
|
-
await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
|
|
1207
|
+
const deleted = await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
|
|
1208
|
+
await awaitUserDataSync(deleted, `the user-data sync for record ${envId}`, signal)
|
|
1149
1209
|
},
|
|
1150
1210
|
|
|
1151
1211
|
getProjectEnv: async ({ projectEnvId, signal }) =>
|
package/src/manifest.ts
CHANGED
|
@@ -17,7 +17,7 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/
|
|
|
17
17
|
* asserts the two never diverge.
|
|
18
18
|
*/
|
|
19
19
|
const SHARED_POSTGRES_CONNECTION_STRING: ZeropsSharedPostgresBinding['connectionString'] =
|
|
20
|
-
'${
|
|
20
|
+
'${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
|
|
21
21
|
|
|
22
22
|
export interface FabrikaImportDocument {
|
|
23
23
|
readonly project?: { readonly [key: string]: JsonValue }
|
package/src/namespace.ts
CHANGED
|
@@ -34,21 +34,24 @@ export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
|
|
|
34
34
|
export const ZEROPS_NAMESPACE_IAM_URL_VARIABLE = 'FABRIKA_IAM_URL'
|
|
35
35
|
export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
|
|
36
36
|
/**
|
|
37
|
-
* How an app reaches the namespace-owned PostgreSQL service — database and TLS mode NAMED, not
|
|
37
|
+
* How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
|
|
38
|
+
* defaulted. Every part settled against a live account, none of it safe to infer:
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* • `${x_connectionString}` is `postgresql://${user}:${password}@${hostname}:${port}` — no database
|
|
42
|
-
* path. With none, the libpq/postgres.js convention falls back to the USER name.
|
|
40
|
+
* • `${x_connectionTlsString}` is `postgresql://${user}:${password}@${hostname}:${portTls}` — no
|
|
41
|
+
* database path. With none, the libpq/postgres.js convention falls back to the USER name.
|
|
43
42
|
* • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname
|
|
44
43
|
* (checked on a service named `wu2db`). So the fallback lands right today — by construction rather
|
|
45
44
|
* than by luck, but still by a rule nobody wrote down. `/${x_dbName}` says it out loud.
|
|
46
|
-
* •
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* • THE TLS PORT, NOT THE DIRECT ONE, and this is the one that changed. `postgresql:single@18`
|
|
46
|
+
* speaks TLS on both; `postgresql:ha@18` speaks it ONLY on `portTls` and answers `sslmode=require`
|
|
47
|
+
* on the direct port with "Server does not support SSL". A namespace picks `ha` for `prod` and
|
|
48
|
+
* `single` everywhere else, so a DSN naming the direct port is one preset away from failing.
|
|
49
|
+
* • `portTls` preserves SESSION state on both — same backend pid across statements, `SET SESSION`
|
|
50
|
+
* kept, advisory lock held and visible — so the migration runner's session-level lock is safe
|
|
51
|
+
* there. It is a TLS endpoint, not the transaction pool the port number suggests.
|
|
52
|
+
* • `verify-full` fails (`self signed certificate`), so `require` is the strongest mode available.
|
|
50
53
|
*/
|
|
51
|
-
export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${
|
|
54
|
+
export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
|
|
52
55
|
|
|
53
56
|
const PROXY_TYPE = 'alpine@3.21'
|
|
54
57
|
const EMPTY_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
|
package/src/types.ts
CHANGED
|
@@ -95,7 +95,7 @@ export interface ZeropsProxySpec {
|
|
|
95
95
|
export interface ZeropsSharedPostgresBinding {
|
|
96
96
|
readonly resourceKey: 'service:postgres'
|
|
97
97
|
readonly hostname: 'postgres'
|
|
98
|
-
readonly connectionString: '${
|
|
98
|
+
readonly connectionString: '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
export type ZeropsNamespaceResourceRequirement = ZeropsSharedPostgresBinding
|