@fabrika/provider-zerops 0.0.10 → 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 +95 -12
- package/src/control.ts +13 -1
- package/src/manifest.ts +1 -1
- package/src/namespace.ts +13 -10
- package/src/provider.ts +12 -0
- 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
|
@@ -32,6 +32,15 @@ export type ZeropsAppVersionStatus =
|
|
|
32
32
|
| 'BACKUP'
|
|
33
33
|
| 'CANCELLED'
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `sensitive` on every user-data WRITE, create and update alike. Required by the platform — omitting it answers
|
|
37
|
+
* `400 invalidUserInput` with `{"sensitive":["field is required"]}` (measured live, 2026-08-18) — and
|
|
38
|
+
* `true` because most of what fabrika writes is a credential. It costs nothing to read: a sensitive
|
|
39
|
+
* variable's `content` still comes back verbatim from `GET /service-stack/{id}/env`, also measured, so the
|
|
40
|
+
* compare-before-write both `platform deploy` and the create-only credential slots depend on still works.
|
|
41
|
+
*/
|
|
42
|
+
const SENSITIVE_USER_DATA = true
|
|
43
|
+
|
|
35
44
|
/** The one status that means "this version is live". */
|
|
36
45
|
export const ZEROPS_ACTIVE: ZeropsAppVersionStatus = 'ACTIVE'
|
|
37
46
|
|
|
@@ -437,6 +446,12 @@ export interface ZeropsApi {
|
|
|
437
446
|
listProjectServices(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsService[]>
|
|
438
447
|
|
|
439
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.
|
|
440
455
|
|
|
441
456
|
/**
|
|
442
457
|
* Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
|
|
@@ -497,9 +512,14 @@ export interface ZeropsApi {
|
|
|
497
512
|
* `compile.ts` writes `envIsolation` instead of inheriting it: a security-relevant field that never
|
|
498
513
|
* reaches the wire is a field nobody can review.
|
|
499
514
|
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* `
|
|
515
|
+
* `canCreateProjects` IS EXPOSED, because a control plane that provisions app namespaces cannot work
|
|
516
|
+
* without it: every namespace preset creates a project, and `POST /client/{id}/project/import` answers
|
|
517
|
+
* `403 insufficientPermissions` to a token without the flag (measured live, 2026-08-18; ADR-0034).
|
|
518
|
+
* The cost is real and bounded — the token receives `OWNER` on what it creates, and nothing else:
|
|
519
|
+
* `roleCode` stays `NO_ACCESS`, so a project it never created and was never granted stays unreachable.
|
|
520
|
+
*
|
|
521
|
+
* NOT EXPOSED, deliberately: `canViewFinances` / `canEditFinances`. Both default to `false`, and
|
|
522
|
+
* fabrika has no use for either.
|
|
503
523
|
*
|
|
504
524
|
* UNVERIFIED BEHAVIOUR: EVERYTHING AT RUNTIME. No token has ever been minted against a real account —
|
|
505
525
|
* doing so is a mutation, not an inspection — so the shapes below are the published document's and the
|
|
@@ -517,6 +537,8 @@ export interface ZeropsApi {
|
|
|
517
537
|
projects: readonly { projectId: string; roleCode: ZeropsRoleCode }[]
|
|
518
538
|
/** The client-wide role. Defaults to `NO_ACCESS`, which is the entire point of a scoped token. */
|
|
519
539
|
roleCode?: ZeropsRoleCode
|
|
540
|
+
/** Let the token create projects, which it then OWNS. Required to provision app namespaces (ADR-0034). */
|
|
541
|
+
canCreateProjects?: boolean
|
|
520
542
|
signal: AbortSignal
|
|
521
543
|
}): Promise<ZeropsIntegrationToken>
|
|
522
544
|
|
|
@@ -560,6 +582,8 @@ export interface ZeropsApiOptions {
|
|
|
560
582
|
/** Override for a different region's API host. */
|
|
561
583
|
baseUrl?: string
|
|
562
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
|
|
563
587
|
}
|
|
564
588
|
|
|
565
589
|
/** Read a property off an unknown value without asserting anything about the value's shape. */
|
|
@@ -781,10 +805,29 @@ const apiError = (label: string, status: number, body: unknown, redactDetail: bo
|
|
|
781
805
|
return new ZeropsApiError(`zerops: ${label} failed (${status})${detail === '' ? '' : ` — ${detail.slice(0, 300)}`}`, status, code)
|
|
782
806
|
}
|
|
783
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
|
+
|
|
784
825
|
/** Build the real client. Every request carries the bearer and the run's signal. */
|
|
785
826
|
export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
786
827
|
const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
|
|
787
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
|
|
788
831
|
const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
|
|
789
832
|
const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
|
|
790
833
|
const SERVICE_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024
|
|
@@ -959,6 +1002,30 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
959
1002
|
return items.map(readServiceEnv)
|
|
960
1003
|
}
|
|
961
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
|
+
|
|
962
1029
|
return {
|
|
963
1030
|
importServices: async ({ projectId, yaml, signal }) =>
|
|
964
1031
|
readImportResult(await request('service-stack import', 'POST', `/project/${projectId}/service-stack/import`, { body: { yaml }, signal })),
|
|
@@ -1001,10 +1068,13 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1001
1068
|
|
|
1002
1069
|
buildAndDeployAppVersion: async ({ appVersionId, zeropsYaml, zeropsYamlSetup, signal }) => {
|
|
1003
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.
|
|
1004
1074
|
const payload = await request('build and deploy app-version', 'PUT', `/app-version/${appVersionId}/build-and-deploy`, {
|
|
1005
1075
|
body,
|
|
1006
1076
|
signal,
|
|
1007
|
-
|
|
1077
|
+
redactDetail: true,
|
|
1008
1078
|
})
|
|
1009
1079
|
const processId = requiredString(payload, 'id', 'build and deploy app-version')
|
|
1010
1080
|
const responseAppVersionId = str(prop(payload, 'appVersion'), 'id')
|
|
@@ -1041,7 +1111,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1041
1111
|
await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
|
|
1042
1112
|
},
|
|
1043
1113
|
|
|
1044
|
-
getProcess
|
|
1114
|
+
getProcess,
|
|
1045
1115
|
|
|
1046
1116
|
enableSubdomainAccess: async ({ serviceId, signal }) => {
|
|
1047
1117
|
// No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
|
|
@@ -1085,9 +1155,10 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1085
1155
|
listServiceEnv,
|
|
1086
1156
|
|
|
1087
1157
|
createServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
1158
|
+
let started: unknown
|
|
1088
1159
|
try {
|
|
1089
|
-
await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1090
|
-
body: { key, content: value },
|
|
1160
|
+
started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1161
|
+
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
1091
1162
|
signal,
|
|
1092
1163
|
omitErrorResponseDetails: true,
|
|
1093
1164
|
})
|
|
@@ -1095,16 +1166,19 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1095
1166
|
if (error instanceof Error && error.name === 'AbortError') throw error
|
|
1096
1167
|
throw new Error('zerops: create-only service env failed')
|
|
1097
1168
|
}
|
|
1169
|
+
await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
|
|
1098
1170
|
},
|
|
1099
1171
|
|
|
1100
1172
|
putServiceEnv: async ({ serviceId, key, value, signal }) => {
|
|
1173
|
+
const label = `the user-data sync on service ${serviceId}`
|
|
1101
1174
|
// `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
|
|
1102
1175
|
try {
|
|
1103
|
-
await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1104
|
-
body: { key, content: value },
|
|
1176
|
+
const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
|
|
1177
|
+
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
1105
1178
|
signal,
|
|
1106
1179
|
redactDetail: true,
|
|
1107
1180
|
})
|
|
1181
|
+
await awaitUserDataSync(created, label, signal)
|
|
1108
1182
|
return
|
|
1109
1183
|
} catch (error) {
|
|
1110
1184
|
if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_DUPLICATE_KEY) {
|
|
@@ -1121,17 +1195,23 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1121
1195
|
`zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
|
|
1122
1196
|
)
|
|
1123
1197
|
}
|
|
1124
|
-
await request('update service env', 'PUT', `/user-data/${existing.id}`, {
|
|
1198
|
+
const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
|
|
1199
|
+
body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
|
|
1200
|
+
signal,
|
|
1201
|
+
redactDetail: true,
|
|
1202
|
+
})
|
|
1203
|
+
await awaitUserDataSync(updated, label, signal)
|
|
1125
1204
|
},
|
|
1126
1205
|
|
|
1127
1206
|
deleteServiceEnv: async ({ envId, signal }) => {
|
|
1128
|
-
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)
|
|
1129
1209
|
},
|
|
1130
1210
|
|
|
1131
1211
|
getProjectEnv: async ({ projectEnvId, signal }) =>
|
|
1132
1212
|
readServiceEnv(await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
|
|
1133
1213
|
|
|
1134
|
-
createIntegrationToken: async ({ clientId, name, projects, roleCode, signal }) =>
|
|
1214
|
+
createIntegrationToken: async ({ clientId, name, projects, roleCode, canCreateProjects, signal }) =>
|
|
1135
1215
|
// `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
|
|
1136
1216
|
// this is the one error envelope that could quote a minted token back at us.
|
|
1137
1217
|
readIntegrationToken(
|
|
@@ -1139,6 +1219,9 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1139
1219
|
body: {
|
|
1140
1220
|
name,
|
|
1141
1221
|
roleCode: roleCode ?? 'NO_ACCESS',
|
|
1222
|
+
// Always sent, like `roleCode`: a security-relevant field that never reaches the wire is a
|
|
1223
|
+
// field nobody can review.
|
|
1224
|
+
canCreateProjects: canCreateProjects ?? false,
|
|
1142
1225
|
projects: projects.map((grant) => ({ projectId: grant.projectId, roleCode: grant.roleCode })),
|
|
1143
1226
|
},
|
|
1144
1227
|
signal,
|
package/src/control.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface ZeropsBeforeDeployInput {
|
|
|
92
92
|
readonly target: ZeropsStoredTarget & {
|
|
93
93
|
readonly projectId: string
|
|
94
94
|
readonly proxyServiceId: string
|
|
95
|
+
/** The namespace proxy has no Git integration, so its pipeline must be told what to build. */
|
|
96
|
+
readonly proxyBuildFromGit: string
|
|
95
97
|
}
|
|
96
98
|
readonly artifact: FabrikaManifest
|
|
97
99
|
readonly api: ZeropsApi
|
|
@@ -175,7 +177,7 @@ const decodeNamespace = (
|
|
|
175
177
|
const resolvedEnvironment = (
|
|
176
178
|
environment: ProviderEnvironment,
|
|
177
179
|
options: { requireReady: boolean },
|
|
178
|
-
): DecodedZeropsEnvironment & { projectId: string; proxyServiceId?: string } => {
|
|
180
|
+
): DecodedZeropsEnvironment & { projectId: string; proxyServiceId?: string; proxyBuildFromGit?: string } => {
|
|
179
181
|
const decoded = decodeEnvironment(environment)
|
|
180
182
|
const projectId = decoded.namespaceTarget.projectId
|
|
181
183
|
if (projectId === undefined) {
|
|
@@ -188,10 +190,15 @@ const resolvedEnvironment = (
|
|
|
188
190
|
if (options.requireReady && proxyServiceId === undefined) {
|
|
189
191
|
throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no proxy service id`)
|
|
190
192
|
}
|
|
193
|
+
const proxyBuildFromGit = decoded.namespaceTarget.proxyBuildFromGit
|
|
194
|
+
if (options.requireReady && proxyBuildFromGit === undefined) {
|
|
195
|
+
throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no proxy build source`)
|
|
196
|
+
}
|
|
191
197
|
return {
|
|
192
198
|
...decoded,
|
|
193
199
|
projectId,
|
|
194
200
|
...(proxyServiceId === undefined ? {} : { proxyServiceId }),
|
|
201
|
+
...(proxyBuildFromGit === undefined ? {} : { proxyBuildFromGit }),
|
|
195
202
|
}
|
|
196
203
|
}
|
|
197
204
|
|
|
@@ -443,6 +450,10 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
443
450
|
if (proxyServiceId === undefined) {
|
|
444
451
|
throw new Error(`Zerops deployment namespace \`${placement.namespace.id}\` has no proxy service id`)
|
|
445
452
|
}
|
|
453
|
+
const proxyBuildFromGit = placement.proxyBuildFromGit
|
|
454
|
+
if (proxyBuildFromGit === undefined) {
|
|
455
|
+
throw new Error(`Zerops deployment namespace \`${placement.namespace.id}\` has no proxy build source`)
|
|
456
|
+
}
|
|
446
457
|
const artifact = decodeEnvelope('artifact', registration.environment.artifact, zeropsArtifactCodec)
|
|
447
458
|
await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
|
|
448
459
|
let source: ZeropsRuntimeTarget['source']
|
|
@@ -468,6 +479,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
468
479
|
serviceId: placement.target.serviceId,
|
|
469
480
|
projectId: placement.projectId,
|
|
470
481
|
proxyServiceId,
|
|
482
|
+
proxyBuildFromGit,
|
|
471
483
|
},
|
|
472
484
|
artifact,
|
|
473
485
|
api,
|
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/provider.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { createProvider, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
|
|
13
13
|
import {
|
|
14
|
+
waitForProcess,
|
|
14
15
|
ZEROPS_ACTIVE,
|
|
15
16
|
ZEROPS_PROCESS_FINISHED,
|
|
16
17
|
ZEROPS_PROCESS_TERMINAL,
|
|
@@ -273,6 +274,17 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
|
|
|
273
274
|
// `override: true` on every service (written by the compiler) is what makes re-applying safe.
|
|
274
275
|
const result = await zerops.api.importServices({ projectId: target.projectId, yaml: compiled.yaml, signal })
|
|
275
276
|
log(` imported: ${result.services.map((service) => `${service.name} (${service.id})`).join(', ') || '(none reported)'}`)
|
|
277
|
+
// An import ANSWERS before its services exist. Triggering a pipeline on a service Zerops is
|
|
278
|
+
// still creating answers 400 `projectImportInvalidParameter`, which is what the first deploy
|
|
279
|
+
// into a fresh namespace hit; a re-apply onto services that already exist reports none.
|
|
280
|
+
const pending = result.services.flatMap((service) => service.processes.map((process) => ({ service: service.name, id: process.id })))
|
|
281
|
+
for (const process of pending) {
|
|
282
|
+
assertRunning(signal)
|
|
283
|
+
await waitForProcess({ api: zerops.api, processId: process.id, sleep: zerops.sleep, signal, label: `the ${process.service} import` })
|
|
284
|
+
}
|
|
285
|
+
if (pending.length > 0) {
|
|
286
|
+
log(` awaited ${pending.length} import process(es)`)
|
|
287
|
+
}
|
|
276
288
|
return
|
|
277
289
|
}
|
|
278
290
|
|
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
|