@fabrika/provider-zerops 0.0.8 → 0.0.10
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 +72 -4
- package/src/control.ts +101 -30
- package/src/index.ts +44 -0
- package/src/provider.ts +32 -2
- package/src/source-connection.ts +182 -9
- package/src/source.ts +412 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
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.10",
|
|
36
|
+
"@fabrika/auth-core": "^0.0.10",
|
|
37
|
+
"@fabrika/provider-contract": "^0.0.10",
|
|
38
|
+
"@fabrika/proxy-contract": "^0.0.10"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/api.ts
CHANGED
|
@@ -445,6 +445,8 @@ export interface ZeropsApi {
|
|
|
445
445
|
* NOT `GET /service-stack/{id}/user-data`, which answers `400 serviceStackNotFound` on every
|
|
446
446
|
* service, deployed or not — see `docs/reference/zerops-platform.md`. The `/env` reading omits
|
|
447
447
|
* variables the service's own `zerops.yaml` declares (`type: ENV`); fabrika never writes those.
|
|
448
|
+
* The OpenAPI publishes no lookup/pagination terms and live probes found query terms ignored, so the
|
|
449
|
+
* implementation reads one full response under a byte bound rather than imposing an entry-count cap.
|
|
448
450
|
*/
|
|
449
451
|
listServiceEnv(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv[]>
|
|
450
452
|
|
|
@@ -785,6 +787,58 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
785
787
|
const doFetch = options.fetchImpl ?? fetch
|
|
786
788
|
const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
|
|
787
789
|
const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
|
|
790
|
+
const SERVICE_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024
|
|
791
|
+
class ResponseByteBoundError extends Error {}
|
|
792
|
+
|
|
793
|
+
const boundedJson = async (response: Response, label: string, maxBytes: number, signal: AbortSignal): Promise<unknown> => {
|
|
794
|
+
if (signal.aborted) {
|
|
795
|
+
void response.body?.cancel().catch(() => {})
|
|
796
|
+
throw abortError()
|
|
797
|
+
}
|
|
798
|
+
const contentLength = response.headers.get('content-length')
|
|
799
|
+
if (contentLength !== null) {
|
|
800
|
+
const declaredBytes = Number(contentLength)
|
|
801
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
802
|
+
void response.body?.cancel().catch(() => {})
|
|
803
|
+
throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (response.body === null) return null
|
|
807
|
+
const reader = response.body.getReader()
|
|
808
|
+
let abortRead = (): void => {}
|
|
809
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
810
|
+
abortRead = (): void => {
|
|
811
|
+
void reader.cancel().catch(() => {})
|
|
812
|
+
reject(abortError())
|
|
813
|
+
}
|
|
814
|
+
signal.addEventListener('abort', abortRead, { once: true })
|
|
815
|
+
if (signal.aborted) abortRead()
|
|
816
|
+
})
|
|
817
|
+
const chunks: Uint8Array[] = []
|
|
818
|
+
let receivedBytes = 0
|
|
819
|
+
try {
|
|
820
|
+
while (true) {
|
|
821
|
+
const next = await Promise.race([reader.read(), aborted])
|
|
822
|
+
if (signal.aborted) throw abortError()
|
|
823
|
+
if (next.done) break
|
|
824
|
+
receivedBytes += next.value.byteLength
|
|
825
|
+
if (receivedBytes > maxBytes) {
|
|
826
|
+
void reader.cancel().catch(() => {})
|
|
827
|
+
throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
|
|
828
|
+
}
|
|
829
|
+
chunks.push(next.value)
|
|
830
|
+
}
|
|
831
|
+
} finally {
|
|
832
|
+
signal.removeEventListener('abort', abortRead)
|
|
833
|
+
}
|
|
834
|
+
const bytes = new Uint8Array(receivedBytes)
|
|
835
|
+
let offset = 0
|
|
836
|
+
for (const chunk of chunks) {
|
|
837
|
+
bytes.set(chunk, offset)
|
|
838
|
+
offset += chunk.byteLength
|
|
839
|
+
}
|
|
840
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
841
|
+
}
|
|
788
842
|
|
|
789
843
|
const request = async (
|
|
790
844
|
label: string,
|
|
@@ -797,7 +851,13 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
797
851
|
* `omitErrorResponseDetails` also drops the code for upload flows whose response may carry a
|
|
798
852
|
* credential; nothing from that response body may reach an error.
|
|
799
853
|
*/
|
|
800
|
-
init: {
|
|
854
|
+
init: {
|
|
855
|
+
body?: unknown
|
|
856
|
+
signal: AbortSignal
|
|
857
|
+
redactDetail?: boolean
|
|
858
|
+
omitErrorResponseDetails?: boolean
|
|
859
|
+
responseMaxBytes?: number
|
|
860
|
+
},
|
|
801
861
|
): Promise<unknown> => {
|
|
802
862
|
if (init.signal.aborted) {
|
|
803
863
|
throw abortError()
|
|
@@ -820,9 +880,12 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
820
880
|
}
|
|
821
881
|
let payload: unknown
|
|
822
882
|
try {
|
|
823
|
-
payload =
|
|
883
|
+
payload = init.responseMaxBytes === undefined
|
|
884
|
+
? await response.json()
|
|
885
|
+
: await boundedJson(response, label, init.responseMaxBytes, init.signal)
|
|
824
886
|
} catch (error) {
|
|
825
887
|
if (init.signal.aborted || isAbortError(error)) throw abortError()
|
|
888
|
+
if (error instanceof ResponseByteBoundError) throw error
|
|
826
889
|
payload = null
|
|
827
890
|
}
|
|
828
891
|
if (!response.ok) {
|
|
@@ -887,8 +950,13 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
887
950
|
* that hands it over on every service is `/env` (`/user-data` answers 400 unconditionally).
|
|
888
951
|
*/
|
|
889
952
|
const listServiceEnv: ZeropsApi['listServiceEnv'] = async ({ serviceId, signal }) => {
|
|
890
|
-
const payload = await request('list service env', 'GET', `/service-stack/${serviceId}/env`, {
|
|
891
|
-
|
|
953
|
+
const payload = await request('list service env', 'GET', `/service-stack/${serviceId}/env`, {
|
|
954
|
+
signal,
|
|
955
|
+
responseMaxBytes: SERVICE_ENV_RESPONSE_MAX_BYTES,
|
|
956
|
+
})
|
|
957
|
+
const items = prop(payload, 'items')
|
|
958
|
+
if (!Array.isArray(items)) throw invalidResponse('list service env')
|
|
959
|
+
return items.map(readServiceEnv)
|
|
892
960
|
}
|
|
893
961
|
|
|
894
962
|
return {
|
package/src/control.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
ProviderReconcileOutcome,
|
|
11
11
|
ProviderRegistration,
|
|
12
12
|
ProviderRegistrationInput,
|
|
13
|
+
ProviderSourceResolution,
|
|
14
|
+
ProviderSourceResolutionInput,
|
|
13
15
|
ProviderTerminalOutcome,
|
|
14
16
|
RuntimeProvider,
|
|
15
17
|
RuntimeProviderRun,
|
|
@@ -35,8 +37,8 @@ import {
|
|
|
35
37
|
zeropsArtifactCodec,
|
|
36
38
|
} from './manifest'
|
|
37
39
|
import { createZeropsNamespaceCapabilities, type ZeropsNamespaceTarget, zeropsNamespaceTargetCodec } from './namespace'
|
|
38
|
-
import { createZeropsProvider } from './provider'
|
|
39
|
-
import { normalizeZeropsSourceRepository, type ZeropsSourceClient } from './source'
|
|
40
|
+
import { createZeropsProvider, type ZeropsSourceTransportBinding } from './provider'
|
|
41
|
+
import { normalizeZeropsSourceRepository, type ZeropsSourceClient, type ZeropsSourceClientV2, type ZeropsSourceResolveResult } from './source'
|
|
40
42
|
import type { ZeropsRunState, ZeropsRuntimeTarget } from './types'
|
|
41
43
|
|
|
42
44
|
/** Provider coordinates that are safe to persist. Credentials are composed only for a live run. */
|
|
@@ -109,7 +111,7 @@ export interface ZeropsControlProviderOptions {
|
|
|
109
111
|
readonly propustkaUrl?: string
|
|
110
112
|
readonly adminKey?: string
|
|
111
113
|
readonly api?: ZeropsApi
|
|
112
|
-
readonly source: ZeropsSourceClient
|
|
114
|
+
readonly source: ZeropsSourceClient & Partial<ZeropsSourceClientV2>
|
|
113
115
|
readonly reconcileSchema?: SchemaReconciler
|
|
114
116
|
readonly sleep?: Sleeper
|
|
115
117
|
readonly sourceCancelSleep?: Sleeper
|
|
@@ -123,6 +125,19 @@ export interface ZeropsControlProviderOptions {
|
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
export interface ZeropsBoundSourceResolutionInput extends ProviderSourceResolutionInput {
|
|
129
|
+
readonly sourceBinding: ZeropsSourceTransportBinding
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ZeropsBoundDeployInput extends ProviderDeployInput {
|
|
133
|
+
readonly sourceBinding: ZeropsSourceTransportBinding
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ZeropsControlProvider extends ControlProvider {
|
|
137
|
+
resolveSourceWithBinding(input: ZeropsBoundSourceResolutionInput): Promise<ProviderSourceResolution>
|
|
138
|
+
deployWithBinding(input: ZeropsBoundDeployInput): Promise<ProviderTerminalOutcome>
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
interface DecodedZeropsEnvironment {
|
|
127
142
|
readonly target: ZeropsStoredTarget
|
|
128
143
|
readonly namespace: ProviderDeploymentNamespace
|
|
@@ -184,6 +199,9 @@ const normalizeRegistration = (input: ProviderRegistrationInput): ProviderRegist
|
|
|
184
199
|
if (input.app.id !== input.environment.appId) {
|
|
185
200
|
throw new Error(`Zerops environment belongs to app \`${input.environment.appId}\`, expected \`${input.app.id}\``)
|
|
186
201
|
}
|
|
202
|
+
if ((input.app.source.githubConnectionId === undefined) !== (input.app.source.githubInstallationId === undefined)) {
|
|
203
|
+
throw new Error('Zerops private source requires both connection and installation coordinates')
|
|
204
|
+
}
|
|
187
205
|
const decoded = decodeEnvironment(input.environment)
|
|
188
206
|
const artifact = parseFabrikaManifest(
|
|
189
207
|
decodeEnvelope('artifact', input.environment.artifact, zeropsArtifactCodec),
|
|
@@ -252,11 +270,12 @@ const buildTriggeredState = (
|
|
|
252
270
|
})
|
|
253
271
|
|
|
254
272
|
/** Build the complete Zerops lifecycle bundle without importing control-plane persistence. */
|
|
255
|
-
export const createZeropsControlProvider = (options: ZeropsControlProviderOptions):
|
|
273
|
+
export const createZeropsControlProvider = (options: ZeropsControlProviderOptions): ZeropsControlProvider => {
|
|
256
274
|
if (options.accessToken === '') {
|
|
257
275
|
throw new Error('Zerops access token must be a non-empty string')
|
|
258
276
|
}
|
|
259
277
|
const api = options.api ?? createZeropsApi({ token: options.accessToken, baseUrl: options.apiBaseUrl })
|
|
278
|
+
const sourceBindings = new Map<string, ZeropsSourceTransportBinding>()
|
|
260
279
|
const runtimeProvider = createZeropsProvider((target) => {
|
|
261
280
|
const defaults = defaultZeropsCollaborators(target)
|
|
262
281
|
return {
|
|
@@ -266,6 +285,13 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
266
285
|
reconcileSchema: options.reconcileSchema ?? defaults.reconcileSchema,
|
|
267
286
|
sleep: options.sleep ?? defaultSleep,
|
|
268
287
|
}
|
|
288
|
+
}, {
|
|
289
|
+
bindingForRun: (runId) => sourceBindings.get(runId),
|
|
290
|
+
uploadV2: async (input) => {
|
|
291
|
+
const uploadV2 = options.source.uploadV2
|
|
292
|
+
if (uploadV2 === undefined) throw new Error('Zerops keyed source transport is unavailable')
|
|
293
|
+
return uploadV2.call(options.source, input)
|
|
294
|
+
},
|
|
269
295
|
})
|
|
270
296
|
const namespaceCapabilities = options.namespaces === undefined
|
|
271
297
|
? undefined
|
|
@@ -349,35 +375,68 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
349
375
|
return { state: 'running' }
|
|
350
376
|
}
|
|
351
377
|
|
|
352
|
-
|
|
378
|
+
const assertSourceBinding = (
|
|
379
|
+
input: Pick<ProviderDeployInput, 'app'>,
|
|
380
|
+
binding: ZeropsSourceTransportBinding | undefined,
|
|
381
|
+
): void => {
|
|
382
|
+
const connectionId = input.app.source.githubConnectionId
|
|
383
|
+
const installationId = input.app.source.githubInstallationId
|
|
384
|
+
if (binding === undefined) {
|
|
385
|
+
if (connectionId !== undefined || installationId !== undefined) {
|
|
386
|
+
throw new Error('Zerops private source requires an explicit transport binding')
|
|
387
|
+
}
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
if (connectionId !== binding.connectionId || installationId !== binding.installationId) {
|
|
391
|
+
throw new Error('Zerops source transport binding has different application coordinates')
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const resolveSource = async (
|
|
396
|
+
input: ProviderSourceResolutionInput,
|
|
397
|
+
binding?: ZeropsSourceTransportBinding,
|
|
398
|
+
): Promise<ProviderSourceResolution> => {
|
|
399
|
+
assertSourceBinding(input, binding)
|
|
400
|
+
const registration = normalizeRegistration({ app: input.app, environment: input.environment })
|
|
401
|
+
const artifact = decodeEnvelope('artifact', registration.environment.artifact, zeropsArtifactCodec)
|
|
402
|
+
await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
|
|
403
|
+
const base = {
|
|
404
|
+
runId: input.runId,
|
|
405
|
+
repository: normalizeZeropsSourceRepository(registration.app.source.repoUrl),
|
|
406
|
+
requestedRef: registration.app.source.ref,
|
|
407
|
+
...(input.expectedCommitSha === undefined ? {} : { expectedCommitSha: input.expectedCommitSha }),
|
|
408
|
+
descriptorSha256: artifact.target.sourceDescriptor.sha256,
|
|
409
|
+
signal: input.signal,
|
|
410
|
+
}
|
|
411
|
+
let result: ZeropsSourceResolveResult
|
|
412
|
+
if (binding?.transportKind === 'keyed-v2') {
|
|
413
|
+
const resolveV2 = options.source.resolveV2
|
|
414
|
+
if (resolveV2 === undefined) throw new Error('Zerops keyed source transport is unavailable')
|
|
415
|
+
result = await resolveV2.call(options.source, {
|
|
416
|
+
...base,
|
|
417
|
+
privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId },
|
|
418
|
+
})
|
|
419
|
+
} else {
|
|
420
|
+
result = await options.source.resolve({
|
|
421
|
+
...base,
|
|
422
|
+
...(binding === undefined ? {} : { githubInstallationId: binding.installationId }),
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
if (
|
|
426
|
+
result.runId !== input.runId
|
|
427
|
+
|| result.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
|
|
428
|
+
|| (input.expectedCommitSha !== undefined && result.commitSha !== input.expectedCommitSha)
|
|
429
|
+
) throw new Error('Zerops source resolution returned different coordinates')
|
|
430
|
+
return { commitSha: result.commitSha }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const controlProvider: ZeropsControlProvider = {
|
|
353
434
|
id: 'zerops',
|
|
354
435
|
normalizeRegistration,
|
|
355
|
-
resolveSource:
|
|
356
|
-
|
|
357
|
-
const artifact = decodeEnvelope('artifact', registration.environment.artifact, zeropsArtifactCodec)
|
|
358
|
-
await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
|
|
359
|
-
const repository = normalizeZeropsSourceRepository(registration.app.source.repoUrl)
|
|
360
|
-
const result = await options.source.resolve({
|
|
361
|
-
runId: input.runId,
|
|
362
|
-
repository,
|
|
363
|
-
requestedRef: registration.app.source.ref,
|
|
364
|
-
...(input.expectedCommitSha === undefined ? {} : { expectedCommitSha: input.expectedCommitSha }),
|
|
365
|
-
...(registration.app.source.githubInstallationId === undefined
|
|
366
|
-
? {}
|
|
367
|
-
: { githubInstallationId: registration.app.source.githubInstallationId }),
|
|
368
|
-
descriptorSha256: artifact.target.sourceDescriptor.sha256,
|
|
369
|
-
signal: input.signal,
|
|
370
|
-
})
|
|
371
|
-
if (
|
|
372
|
-
result.runId !== input.runId
|
|
373
|
-
|| result.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
|
|
374
|
-
|| (input.expectedCommitSha !== undefined && result.commitSha !== input.expectedCommitSha)
|
|
375
|
-
) {
|
|
376
|
-
throw new Error('Zerops source resolution returned different coordinates')
|
|
377
|
-
}
|
|
378
|
-
return { commitSha: result.commitSha }
|
|
379
|
-
},
|
|
436
|
+
resolveSource: (input) => resolveSource(input),
|
|
437
|
+
resolveSourceWithBinding: (input) => resolveSource(input, input.sourceBinding),
|
|
380
438
|
deploy: async (input: ProviderDeployInput): Promise<ProviderTerminalOutcome> => {
|
|
439
|
+
assertSourceBinding(input, sourceBindings.get(input.runId))
|
|
381
440
|
const registration = normalizeRegistration({ app: input.app, environment: input.environment })
|
|
382
441
|
const placement = resolvedEnvironment(registration.environment, { requireReady: true })
|
|
383
442
|
const proxyServiceId = placement.proxyServiceId
|
|
@@ -469,6 +528,17 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
469
528
|
artifact: registration.environment.artifact,
|
|
470
529
|
})
|
|
471
530
|
},
|
|
531
|
+
deployWithBinding: async (input) => {
|
|
532
|
+
const { sourceBinding, ...deployInput } = input
|
|
533
|
+
assertSourceBinding(deployInput, sourceBinding)
|
|
534
|
+
if (sourceBindings.has(input.runId)) throw new Error('Zerops source transport binding is already active for this run')
|
|
535
|
+
sourceBindings.set(input.runId, sourceBinding)
|
|
536
|
+
try {
|
|
537
|
+
return await controlProvider.deploy(deployInput)
|
|
538
|
+
} finally {
|
|
539
|
+
if (sourceBindings.get(input.runId) === sourceBinding) sourceBindings.delete(input.runId)
|
|
540
|
+
}
|
|
541
|
+
},
|
|
472
542
|
cancel: async (input) => {
|
|
473
543
|
resolvedEnvironment(input.environment, { requireReady: false })
|
|
474
544
|
const state = parseRunState(input.providerState, input.externalId)
|
|
@@ -646,4 +716,5 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
646
716
|
},
|
|
647
717
|
}),
|
|
648
718
|
}
|
|
719
|
+
return controlProvider
|
|
649
720
|
}
|
package/src/index.ts
CHANGED
|
@@ -98,54 +98,82 @@ export {
|
|
|
98
98
|
buildZeropsSourceCancelRequest,
|
|
99
99
|
buildZeropsSourceCancelResponse,
|
|
100
100
|
buildZeropsSourceCredentialActivateRequest,
|
|
101
|
+
buildZeropsSourceCredentialActivateRequestV2,
|
|
101
102
|
buildZeropsSourceCredentialActivateResponse,
|
|
103
|
+
buildZeropsSourceCredentialActivateResponseV2,
|
|
102
104
|
buildZeropsSourceCredentialBundle,
|
|
105
|
+
buildZeropsSourceCredentialBundleV2,
|
|
103
106
|
buildZeropsSourceCredentialStatusRequest,
|
|
107
|
+
buildZeropsSourceCredentialStatusRequestV2,
|
|
104
108
|
buildZeropsSourceCredentialStatusResponse,
|
|
109
|
+
buildZeropsSourceCredentialStatusResponseV2,
|
|
105
110
|
buildZeropsSourceErrorEnvelope,
|
|
106
111
|
buildZeropsSourceInstallationsVerifyRequest,
|
|
107
112
|
buildZeropsSourceInstallationsVerifyResponse,
|
|
108
113
|
buildZeropsSourceResolveInstallationRequest,
|
|
109
114
|
buildZeropsSourceResolveInstallationResponse,
|
|
110
115
|
buildZeropsSourceResolveRequest,
|
|
116
|
+
buildZeropsSourceResolveRequestV2,
|
|
111
117
|
buildZeropsSourceResolveResponse,
|
|
118
|
+
buildZeropsSourceResolveResponseV2,
|
|
112
119
|
buildZeropsSourceUploadRequest,
|
|
120
|
+
buildZeropsSourceUploadRequestV2,
|
|
113
121
|
buildZeropsSourceUploadResponse,
|
|
122
|
+
buildZeropsSourceUploadResponseV2,
|
|
114
123
|
buildZeropsSourceWebhookConfigureRequest,
|
|
115
124
|
buildZeropsSourceWebhookConfigureResponse,
|
|
116
125
|
decodeZeropsSourceCancelRequest,
|
|
117
126
|
decodeZeropsSourceCancelResponse,
|
|
118
127
|
decodeZeropsSourceCredentialActivateRequest,
|
|
128
|
+
decodeZeropsSourceCredentialActivateRequestV2,
|
|
119
129
|
decodeZeropsSourceCredentialActivateResponse,
|
|
130
|
+
decodeZeropsSourceCredentialActivateResponseV2,
|
|
120
131
|
decodeZeropsSourceCredentialBundle,
|
|
132
|
+
decodeZeropsSourceCredentialBundleV2,
|
|
121
133
|
decodeZeropsSourceCredentialStatusRequest,
|
|
134
|
+
decodeZeropsSourceCredentialStatusRequestV2,
|
|
122
135
|
decodeZeropsSourceCredentialStatusResponse,
|
|
136
|
+
decodeZeropsSourceCredentialStatusResponseV2,
|
|
123
137
|
decodeZeropsSourceErrorEnvelope,
|
|
124
138
|
decodeZeropsSourceInstallationsVerifyRequest,
|
|
125
139
|
decodeZeropsSourceInstallationsVerifyResponse,
|
|
126
140
|
decodeZeropsSourceResolveInstallationRequest,
|
|
127
141
|
decodeZeropsSourceResolveInstallationResponse,
|
|
128
142
|
decodeZeropsSourceResolveRequest,
|
|
143
|
+
decodeZeropsSourceResolveRequestV2,
|
|
129
144
|
decodeZeropsSourceResolveResponse,
|
|
145
|
+
decodeZeropsSourceResolveResponseV2,
|
|
130
146
|
decodeZeropsSourceUploadRequest,
|
|
147
|
+
decodeZeropsSourceUploadRequestV2,
|
|
131
148
|
decodeZeropsSourceUploadResponse,
|
|
149
|
+
decodeZeropsSourceUploadResponseV2,
|
|
132
150
|
decodeZeropsSourceWebhookConfigureRequest,
|
|
133
151
|
decodeZeropsSourceWebhookConfigureResponse,
|
|
134
152
|
normalizeZeropsSourceRepository,
|
|
135
153
|
serializeZeropsSourceCredentialBundle,
|
|
154
|
+
serializeZeropsSourceCredentialBundleV2,
|
|
136
155
|
sha256ZeropsSourceCredentialBundle,
|
|
156
|
+
sha256ZeropsSourceCredentialBundleV2,
|
|
137
157
|
ZEROPS_SOURCE_CANCEL_PATH,
|
|
138
158
|
ZEROPS_SOURCE_CREDENTIAL_ACTIVATE_PATH,
|
|
159
|
+
ZEROPS_SOURCE_CREDENTIAL_ACTIVATE_PATH_V2,
|
|
139
160
|
ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION,
|
|
161
|
+
ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2,
|
|
162
|
+
ZEROPS_SOURCE_CREDENTIAL_ENV_V2_PREFIX,
|
|
140
163
|
ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH,
|
|
164
|
+
ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH_V2,
|
|
141
165
|
ZEROPS_SOURCE_ERROR_CODES,
|
|
142
166
|
ZEROPS_SOURCE_ERROR_STAGES,
|
|
143
167
|
ZEROPS_SOURCE_INSTALLATIONS_VERIFY_PATH,
|
|
144
168
|
ZEROPS_SOURCE_PROTOCOL_VERSION,
|
|
169
|
+
ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
145
170
|
ZEROPS_SOURCE_RESOLVE_INSTALLATION_PATH,
|
|
146
171
|
ZEROPS_SOURCE_RESOLVE_PATH,
|
|
172
|
+
ZEROPS_SOURCE_RESOLVE_PATH_V2,
|
|
147
173
|
ZEROPS_SOURCE_UPLOAD_PATH,
|
|
174
|
+
ZEROPS_SOURCE_UPLOAD_PATH_V2,
|
|
148
175
|
ZEROPS_SOURCE_WEBHOOK_CONFIGURE_PATH,
|
|
176
|
+
zeropsSourceCredentialEnvV2,
|
|
149
177
|
} from './source'
|
|
150
178
|
export type {
|
|
151
179
|
ZeropsSourceCancelInput,
|
|
@@ -153,16 +181,25 @@ export type {
|
|
|
153
181
|
ZeropsSourceCancelResponseV1,
|
|
154
182
|
ZeropsSourceCancelResult,
|
|
155
183
|
ZeropsSourceClient,
|
|
184
|
+
ZeropsSourceClientV2,
|
|
156
185
|
ZeropsSourceCredentialActivateInput,
|
|
157
186
|
ZeropsSourceCredentialActivateRequestV1,
|
|
187
|
+
ZeropsSourceCredentialActivateRequestV2,
|
|
158
188
|
ZeropsSourceCredentialActivateResponseV1,
|
|
189
|
+
ZeropsSourceCredentialActivateResponseV2,
|
|
159
190
|
ZeropsSourceCredentialActiveStatusResponseV1,
|
|
191
|
+
ZeropsSourceCredentialActiveStatusResponseV2,
|
|
160
192
|
ZeropsSourceCredentialAnonymousStatusResponseV1,
|
|
193
|
+
ZeropsSourceCredentialAnonymousStatusResponseV2,
|
|
161
194
|
ZeropsSourceCredentialBundleV1,
|
|
195
|
+
ZeropsSourceCredentialBundleV2,
|
|
162
196
|
ZeropsSourceCredentialManager,
|
|
197
|
+
ZeropsSourceCredentialManagerV2,
|
|
163
198
|
ZeropsSourceCredentialStatusInput,
|
|
164
199
|
ZeropsSourceCredentialStatusRequestV1,
|
|
200
|
+
ZeropsSourceCredentialStatusRequestV2,
|
|
165
201
|
ZeropsSourceCredentialStatusResponseV1,
|
|
202
|
+
ZeropsSourceCredentialStatusResponseV2,
|
|
166
203
|
ZeropsSourceDescriptor,
|
|
167
204
|
ZeropsSourceErrorCode,
|
|
168
205
|
ZeropsSourceErrorEnvelope,
|
|
@@ -173,16 +210,23 @@ export type {
|
|
|
173
210
|
ZeropsSourceInstallationsVerifyRequestV1,
|
|
174
211
|
ZeropsSourceInstallationsVerifyResponseV1,
|
|
175
212
|
ZeropsSourceInstallationVerificationV1,
|
|
213
|
+
ZeropsSourcePrivateBindingV2,
|
|
176
214
|
ZeropsSourceRepository,
|
|
177
215
|
ZeropsSourceResolveInput,
|
|
216
|
+
ZeropsSourceResolveInputV2,
|
|
178
217
|
ZeropsSourceResolveInstallationRequestV1,
|
|
179
218
|
ZeropsSourceResolveInstallationResponseV1,
|
|
180
219
|
ZeropsSourceResolveRequestV1,
|
|
220
|
+
ZeropsSourceResolveRequestV2,
|
|
181
221
|
ZeropsSourceResolveResponseV1,
|
|
222
|
+
ZeropsSourceResolveResponseV2,
|
|
182
223
|
ZeropsSourceResolveResult,
|
|
183
224
|
ZeropsSourceUploadInput,
|
|
225
|
+
ZeropsSourceUploadInputV2,
|
|
184
226
|
ZeropsSourceUploadRequestV1,
|
|
227
|
+
ZeropsSourceUploadRequestV2,
|
|
185
228
|
ZeropsSourceUploadResponseV1,
|
|
229
|
+
ZeropsSourceUploadResponseV2,
|
|
186
230
|
ZeropsSourceUploadResult,
|
|
187
231
|
ZeropsSourceWebhookConfigureInput,
|
|
188
232
|
ZeropsSourceWebhookConfigureRequestV1,
|
package/src/provider.ts
CHANGED
|
@@ -30,12 +30,24 @@ import {
|
|
|
30
30
|
zeropsArtifactCodec,
|
|
31
31
|
} from './manifest'
|
|
32
32
|
import { buildZeropsPlan, resolveDeployHostname, type ZeropsJobSpec, type ZeropsPlan } from './plan'
|
|
33
|
+
import type { ZeropsSourceUploadInputV2, ZeropsSourceUploadResult } from './source'
|
|
33
34
|
import type { ZeropsRunState, ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
|
|
34
35
|
|
|
35
36
|
export const CANCELLED = 'deploy cancelled'
|
|
36
37
|
|
|
37
38
|
type ZeropsRun = TypedProviderRun<ZeropsRuntimeTarget, FabrikaManifest>
|
|
38
39
|
|
|
40
|
+
export interface ZeropsSourceTransportBinding {
|
|
41
|
+
readonly connectionId: string
|
|
42
|
+
readonly installationId: number
|
|
43
|
+
readonly transportKind: 'legacy-v1' | 'keyed-v2'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ZeropsSourceTransportRouting {
|
|
47
|
+
bindingForRun(runId: string): ZeropsSourceTransportBinding | undefined
|
|
48
|
+
uploadV2(input: ZeropsSourceUploadInputV2): Promise<ZeropsSourceUploadResult>
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
/** How often `await-deploy` asks Zerops for the version's status. */
|
|
40
52
|
const POLL_INTERVAL_MS = 3000
|
|
41
53
|
|
|
@@ -94,6 +106,7 @@ interface StepEnv {
|
|
|
94
106
|
log: (line: string) => void
|
|
95
107
|
signal: AbortSignal
|
|
96
108
|
dryRun: boolean
|
|
109
|
+
sourceTransport?: ZeropsSourceTransportRouting
|
|
97
110
|
/** Set by `trigger-deploy`, read by `await-deploy` — the only state that crosses a step boundary. */
|
|
98
111
|
state: { appVersionId?: string; processId?: string }
|
|
99
112
|
}
|
|
@@ -276,7 +289,7 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
|
|
|
276
289
|
let buildTriggerRequested = false
|
|
277
290
|
try {
|
|
278
291
|
await run.events.externalId(version.id, runState(version.id, 'version_created'))
|
|
279
|
-
const
|
|
292
|
+
const uploadInput = {
|
|
280
293
|
runId: source.runtime.runId,
|
|
281
294
|
appVersionId: version.id,
|
|
282
295
|
repository: source.runtime.repository,
|
|
@@ -290,7 +303,22 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
|
|
|
290
303
|
sha256: artifact.target.sourceDescriptor.sha256,
|
|
291
304
|
},
|
|
292
305
|
signal,
|
|
293
|
-
}
|
|
306
|
+
}
|
|
307
|
+
const sourceTransport = env.sourceTransport
|
|
308
|
+
const binding = sourceTransport?.bindingForRun(source.runtime.runId)
|
|
309
|
+
if (binding !== undefined && source.runtime.githubInstallationId !== binding.installationId) {
|
|
310
|
+
throw new Error('zerops: source transport binding has different installation coordinates')
|
|
311
|
+
}
|
|
312
|
+
let uploaded: ZeropsSourceUploadResult
|
|
313
|
+
if (binding?.transportKind === 'keyed-v2') {
|
|
314
|
+
if (sourceTransport === undefined) throw new Error('zerops: keyed source transport is unavailable')
|
|
315
|
+
uploaded = await sourceTransport.uploadV2({
|
|
316
|
+
...uploadInput,
|
|
317
|
+
privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId },
|
|
318
|
+
})
|
|
319
|
+
} else {
|
|
320
|
+
uploaded = await source.client.upload(uploadInput)
|
|
321
|
+
}
|
|
294
322
|
if (
|
|
295
323
|
uploaded.runId !== source.runtime.runId
|
|
296
324
|
|| uploaded.appVersionId !== version.id
|
|
@@ -404,6 +432,7 @@ export type ZeropsProvider = ProviderModule<'zerops', ZeropsRuntimeTarget, Fabri
|
|
|
404
432
|
/** Construct an independently testable Zerops provider against one collaborator factory. */
|
|
405
433
|
export const createZeropsProvider = (
|
|
406
434
|
collaborators: ZeropsCollaboratorFactory = defaultZeropsCollaborators,
|
|
435
|
+
sourceTransport?: ZeropsSourceTransportRouting,
|
|
407
436
|
): ZeropsProvider =>
|
|
408
437
|
createProvider({
|
|
409
438
|
id: 'zerops',
|
|
@@ -431,6 +460,7 @@ export const createZeropsProvider = (
|
|
|
431
460
|
log: run.events.log,
|
|
432
461
|
signal: run.signal,
|
|
433
462
|
dryRun: run.dryRun,
|
|
463
|
+
...(sourceTransport === undefined ? {} : { sourceTransport }),
|
|
434
464
|
state: {},
|
|
435
465
|
}
|
|
436
466
|
const byId = new Map(plan.steps.map((step): [string, ZeropsJobSpec] => [step.id, step]))
|
package/src/source-connection.ts
CHANGED
|
@@ -2,11 +2,19 @@ import type { ZeropsApi, ZeropsService, ZeropsServiceEnv } from './api'
|
|
|
2
2
|
import {
|
|
3
3
|
buildZeropsSourceCredentialBundle,
|
|
4
4
|
decodeZeropsSourceCredentialBundle,
|
|
5
|
+
decodeZeropsSourceCredentialBundleV2,
|
|
5
6
|
serializeZeropsSourceCredentialBundle,
|
|
6
7
|
sha256ZeropsSourceCredentialBundle,
|
|
8
|
+
sha256ZeropsSourceCredentialBundleV2,
|
|
9
|
+
ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2,
|
|
10
|
+
ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
7
11
|
type ZeropsSourceCredentialActivateResponseV1,
|
|
12
|
+
type ZeropsSourceCredentialActivateResponseV2,
|
|
13
|
+
zeropsSourceCredentialEnvV2,
|
|
8
14
|
type ZeropsSourceCredentialManager,
|
|
15
|
+
type ZeropsSourceCredentialManagerV2,
|
|
9
16
|
type ZeropsSourceCredentialStatusResponseV1,
|
|
17
|
+
type ZeropsSourceCredentialStatusResponseV2,
|
|
10
18
|
type ZeropsSourceGitHubAppIdentityV1,
|
|
11
19
|
type ZeropsSourceInstallationsVerifyInput,
|
|
12
20
|
type ZeropsSourceInstallationsVerifyResponseV1,
|
|
@@ -19,7 +27,6 @@ export const ZEROPS_SOURCE_LEGACY_APP_ID_ENV = 'GITHUB_APP_ID'
|
|
|
19
27
|
export const ZEROPS_SOURCE_LEGACY_PRIVATE_KEY_ENV = 'GITHUB_APP_PRIVATE_KEY'
|
|
20
28
|
|
|
21
29
|
const SOURCE_HOSTNAME = 'source'
|
|
22
|
-
const MAX_ENV_ENTRIES = 256
|
|
23
30
|
const PROJECT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
|
|
24
31
|
const DEFAULT_REREAD_ATTEMPTS = 5
|
|
25
32
|
const DEFAULT_REREAD_DELAY_MS = 250
|
|
@@ -62,16 +69,23 @@ export interface SourceConnectionAdmin {
|
|
|
62
69
|
inspect(signal: AbortSignal): Promise<SourceConnectionInspection>
|
|
63
70
|
adoptExisting(input: SourceConnectionAdoptExistingInput): Promise<ZeropsSourceCredentialActivateResponseV1>
|
|
64
71
|
activate(input: SourceConnectionActivateInput): Promise<ZeropsSourceCredentialActivateResponseV1>
|
|
72
|
+
activateV2?(input: SourceConnectionActivateInput): Promise<ZeropsSourceCredentialActivateResponseV2>
|
|
65
73
|
status(input: SourceConnectionStatusInput): Promise<SourceConnectionStatus>
|
|
74
|
+
statusV2?(input: SourceConnectionStatusInput): Promise<SourceConnectionStatus>
|
|
66
75
|
configureWebhook(input: ZeropsSourceWebhookConfigureInput): Promise<ZeropsSourceWebhookConfigureResponseV1>
|
|
67
76
|
verifyInstallations(input: ZeropsSourceInstallationsVerifyInput): Promise<ZeropsSourceInstallationsVerifyResponseV1>
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
interface CompleteSourceConnectionAdmin extends SourceConnectionAdmin {
|
|
80
|
+
activateV2(input: SourceConnectionActivateInput): Promise<ZeropsSourceCredentialActivateResponseV2>
|
|
81
|
+
statusV2(input: SourceConnectionStatusInput): Promise<SourceConnectionStatus>
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
export type SourceConnectionZeropsApi = Pick<ZeropsApi, 'findService' | 'listServiceEnv' | 'createServiceEnv'>
|
|
71
85
|
|
|
72
86
|
export interface ZeropsSourceConnectionAdminOptions {
|
|
73
87
|
readonly api: SourceConnectionZeropsApi
|
|
74
|
-
readonly source: ZeropsSourceCredentialManager
|
|
88
|
+
readonly source: ZeropsSourceCredentialManager & Partial<ZeropsSourceCredentialManagerV2>
|
|
75
89
|
readonly projectId: string
|
|
76
90
|
readonly reread?: {
|
|
77
91
|
readonly attempts?: number
|
|
@@ -102,7 +116,16 @@ interface DurableInspection {
|
|
|
102
116
|
readonly bundle?: string
|
|
103
117
|
}
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
interface DurableInspectionV2 {
|
|
120
|
+
readonly public:
|
|
121
|
+
| { readonly state: 'anonymous' }
|
|
122
|
+
| { readonly state: 'durable'; readonly credentialSha256: string }
|
|
123
|
+
readonly service: ZeropsService
|
|
124
|
+
readonly environmentKey: string
|
|
125
|
+
readonly bundle?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectionAdminOptions): CompleteSourceConnectionAdmin {
|
|
106
129
|
if (!PROJECT_ID_PATTERN.test(options.projectId)) throw new SourceConnectionAdminError('invalid_configuration')
|
|
107
130
|
const attempts = options.reread?.attempts ?? DEFAULT_REREAD_ATTEMPTS
|
|
108
131
|
const delayMs = options.reread?.delayMs ?? DEFAULT_REREAD_DELAY_MS
|
|
@@ -111,7 +134,7 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
111
134
|
}
|
|
112
135
|
const sleep = options.reread?.sleep ?? defaultSleep
|
|
113
136
|
|
|
114
|
-
const
|
|
137
|
+
const readEnvironment = async (signal: AbortSignal): Promise<{ service: ZeropsService; environment: ZeropsServiceEnv[] }> => {
|
|
115
138
|
throwIfAborted(signal)
|
|
116
139
|
const service = await exactSourceService(options.api, options.projectId, signal)
|
|
117
140
|
let environment: ZeropsServiceEnv[]
|
|
@@ -122,10 +145,26 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
122
145
|
throw new SourceConnectionAdminError('credential_persistence')
|
|
123
146
|
}
|
|
124
147
|
throwIfAborted(signal)
|
|
125
|
-
return
|
|
148
|
+
return { service, environment }
|
|
126
149
|
}
|
|
127
150
|
|
|
128
|
-
const
|
|
151
|
+
const inspectInternal = async (signal: AbortSignal): Promise<DurableInspection> => {
|
|
152
|
+
const current = await readEnvironment(signal)
|
|
153
|
+
return await withDigest(classify(current.service, current.environment))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const inspectV2Internal = async (connectionId: string, signal: AbortSignal): Promise<DurableInspectionV2> => {
|
|
157
|
+
let environmentKey: string
|
|
158
|
+
try {
|
|
159
|
+
environmentKey = await zeropsSourceCredentialEnvV2(connectionId)
|
|
160
|
+
} catch {
|
|
161
|
+
throw new SourceConnectionAdminError('credential_conflict')
|
|
162
|
+
}
|
|
163
|
+
const current = await readEnvironment(signal)
|
|
164
|
+
return await classifyV2(current.service, current.environment, environmentKey, connectionId)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const admin: CompleteSourceConnectionAdmin = {
|
|
129
168
|
inspect: async (signal) => (await inspectInternal(signal)).public,
|
|
130
169
|
|
|
131
170
|
adoptExisting: async (input) => {
|
|
@@ -212,6 +251,68 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
212
251
|
return activated
|
|
213
252
|
},
|
|
214
253
|
|
|
254
|
+
activateV2: async (input) => {
|
|
255
|
+
if (options.source.activateV2 === undefined || options.source.statusV2 === undefined) {
|
|
256
|
+
throw new SourceConnectionAdminError('credential_activation')
|
|
257
|
+
}
|
|
258
|
+
const activateV2 = options.source.activateV2.bind(options.source)
|
|
259
|
+
const statusV2 = options.source.statusV2.bind(options.source)
|
|
260
|
+
let digest: string
|
|
261
|
+
try {
|
|
262
|
+
const bundle = decodeZeropsSourceCredentialBundleV2(input.credentialBundle)
|
|
263
|
+
if (bundle.connectionId !== input.connectionId) throw new Error('connection mismatch')
|
|
264
|
+
digest = await sha256ZeropsSourceCredentialBundleV2(input.credentialBundle)
|
|
265
|
+
} catch {
|
|
266
|
+
throw new SourceConnectionAdminError('credential_conflict')
|
|
267
|
+
}
|
|
268
|
+
if (digest !== input.credentialSha256) throw new SourceConnectionAdminError('credential_conflict')
|
|
269
|
+
const initial = await inspectV2Internal(input.connectionId, input.signal)
|
|
270
|
+
if (initial.public.state === 'durable') {
|
|
271
|
+
if (initial.bundle !== input.credentialBundle || initial.public.credentialSha256 !== digest) {
|
|
272
|
+
throw new SourceConnectionAdminError('credential_conflict')
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
try {
|
|
276
|
+
await options.api.createServiceEnv({
|
|
277
|
+
serviceId: initial.service.id,
|
|
278
|
+
key: initial.environmentKey,
|
|
279
|
+
value: input.credentialBundle,
|
|
280
|
+
signal: input.signal,
|
|
281
|
+
})
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (isAbort(error, input.signal)) throw error
|
|
284
|
+
}
|
|
285
|
+
await proveDurableV2(input, digest, attempts, delayMs, sleep, inspectV2Internal)
|
|
286
|
+
}
|
|
287
|
+
let activated: ZeropsSourceCredentialActivateResponseV2
|
|
288
|
+
try {
|
|
289
|
+
activated = await activateV2(input)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (isAbort(error, input.signal)) throw error
|
|
292
|
+
let status: ZeropsSourceCredentialStatusResponseV2
|
|
293
|
+
try {
|
|
294
|
+
status = await statusV2({ connectionId: input.connectionId, signal: input.signal })
|
|
295
|
+
} catch (statusError) {
|
|
296
|
+
if (isAbort(statusError, input.signal)) throw statusError
|
|
297
|
+
throw new SourceConnectionAdminError('credential_activation')
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
status.state !== 'active' || status.connectionId !== input.connectionId || status.credentialSha256 !== digest
|
|
301
|
+
) throw new SourceConnectionAdminError('credential_activation')
|
|
302
|
+
activated = {
|
|
303
|
+
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
304
|
+
connectionId: input.connectionId,
|
|
305
|
+
credentialVersion: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2,
|
|
306
|
+
credentialSha256: status.credentialSha256,
|
|
307
|
+
githubApp: status.githubApp,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (activated.connectionId !== input.connectionId || activated.credentialSha256 !== digest) {
|
|
311
|
+
throw new SourceConnectionAdminError('credential_activation')
|
|
312
|
+
}
|
|
313
|
+
return activated
|
|
314
|
+
},
|
|
315
|
+
|
|
215
316
|
status: async (input) => {
|
|
216
317
|
const durable = await inspectInternal(input.signal)
|
|
217
318
|
if (durable.public.state !== 'durable') return durable.public
|
|
@@ -231,8 +332,30 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
231
332
|
return { state: 'active', credentialSha256: runtime.credentialSha256, githubApp: runtime.githubApp }
|
|
232
333
|
},
|
|
233
334
|
|
|
335
|
+
statusV2: async (input) => {
|
|
336
|
+
const durable = await inspectV2Internal(input.connectionId, input.signal)
|
|
337
|
+
if (durable.public.state !== 'durable') return durable.public
|
|
338
|
+
const statusV2 = options.source.statusV2
|
|
339
|
+
if (statusV2 === undefined) throw new SourceConnectionAdminError('credential_activation')
|
|
340
|
+
const readStatusV2 = statusV2.bind(options.source)
|
|
341
|
+
let runtime: ZeropsSourceCredentialStatusResponseV2
|
|
342
|
+
try {
|
|
343
|
+
runtime = await readStatusV2(input)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (isAbort(error, input.signal)) throw error
|
|
346
|
+
throw new SourceConnectionAdminError('credential_activation')
|
|
347
|
+
}
|
|
348
|
+
if (runtime.connectionId !== input.connectionId || runtime.state === 'anonymous') {
|
|
349
|
+
return { state: 'activation-required', credentialSha256: durable.public.credentialSha256 }
|
|
350
|
+
}
|
|
351
|
+
if (runtime.credentialSha256 !== durable.public.credentialSha256) {
|
|
352
|
+
throw new SourceConnectionAdminError('credential_conflict')
|
|
353
|
+
}
|
|
354
|
+
return { state: 'active', credentialSha256: runtime.credentialSha256, githubApp: runtime.githubApp }
|
|
355
|
+
},
|
|
356
|
+
|
|
234
357
|
configureWebhook: async (input) => {
|
|
235
|
-
await requireDurableDigest(input.credentialSha256, input.signal, inspectInternal)
|
|
358
|
+
await requireDurableDigest(input.connectionId, input.credentialSha256, input.signal, inspectInternal, inspectV2Internal)
|
|
236
359
|
try {
|
|
237
360
|
const response = await options.source.configureWebhook(input)
|
|
238
361
|
if (
|
|
@@ -248,7 +371,7 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
248
371
|
},
|
|
249
372
|
|
|
250
373
|
verifyInstallations: async (input) => {
|
|
251
|
-
await requireDurableDigest(input.credentialSha256, input.signal, inspectInternal)
|
|
374
|
+
await requireDurableDigest(input.connectionId, input.credentialSha256, input.signal, inspectInternal, inspectV2Internal)
|
|
252
375
|
try {
|
|
253
376
|
const response = await options.source.verifyInstallations(input)
|
|
254
377
|
if (response.connectionId !== input.connectionId || response.credentialSha256 !== input.credentialSha256) {
|
|
@@ -272,10 +395,17 @@ async function adoptionConnectionId(projectId: string, credentialSha256: string)
|
|
|
272
395
|
}
|
|
273
396
|
|
|
274
397
|
async function requireDurableDigest(
|
|
398
|
+
connectionId: string,
|
|
275
399
|
digest: string,
|
|
276
400
|
signal: AbortSignal,
|
|
277
401
|
inspect: (signal: AbortSignal) => Promise<DurableInspection>,
|
|
402
|
+
inspectV2: (connectionId: string, signal: AbortSignal) => Promise<DurableInspectionV2>,
|
|
278
403
|
): Promise<void> {
|
|
404
|
+
const keyed = await inspectV2(connectionId, signal)
|
|
405
|
+
if (keyed.public.state === 'durable') {
|
|
406
|
+
if (keyed.public.credentialSha256 === digest) return
|
|
407
|
+
throw new SourceConnectionAdminError('credential_conflict')
|
|
408
|
+
}
|
|
279
409
|
const durable = await inspect(signal)
|
|
280
410
|
if (durable.public.state !== 'durable' || durable.public.credentialSha256 !== digest) {
|
|
281
411
|
throw new SourceConnectionAdminError('credential_conflict')
|
|
@@ -298,7 +428,6 @@ async function exactSourceService(api: SourceConnectionZeropsApi, projectId: str
|
|
|
298
428
|
}
|
|
299
429
|
|
|
300
430
|
function classify(service: ZeropsService, environment: readonly ZeropsServiceEnv[]): DurableInspection {
|
|
301
|
-
if (environment.length > MAX_ENV_ENTRIES) throw new SourceConnectionAdminError('source_mismatch')
|
|
302
431
|
const bundleEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_CREDENTIAL_ENV)
|
|
303
432
|
const legacyIdEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_LEGACY_APP_ID_ENV)
|
|
304
433
|
const legacyKeyEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_LEGACY_PRIVATE_KEY_ENV)
|
|
@@ -337,6 +466,31 @@ function classify(service: ZeropsService, environment: readonly ZeropsServiceEnv
|
|
|
337
466
|
return { public: { state: 'legacy-partial' }, service }
|
|
338
467
|
}
|
|
339
468
|
|
|
469
|
+
async function classifyV2(
|
|
470
|
+
service: ZeropsService,
|
|
471
|
+
environment: readonly ZeropsServiceEnv[],
|
|
472
|
+
environmentKey: string,
|
|
473
|
+
connectionId: string,
|
|
474
|
+
): Promise<DurableInspectionV2> {
|
|
475
|
+
const entries = environment.filter((entry) => entry.key === environmentKey)
|
|
476
|
+
if (entries.length > 1) throw new SourceConnectionAdminError('credential_conflict')
|
|
477
|
+
if (entries.length === 0) return { public: { state: 'anonymous' }, service, environmentKey }
|
|
478
|
+
const bundle = entries[0]?.content
|
|
479
|
+
if (bundle === undefined) throw new SourceConnectionAdminError('credential_conflict')
|
|
480
|
+
try {
|
|
481
|
+
const decoded = decodeZeropsSourceCredentialBundleV2(bundle)
|
|
482
|
+
if (decoded.connectionId !== connectionId) throw new Error('connection mismatch')
|
|
483
|
+
return {
|
|
484
|
+
public: { state: 'durable', credentialSha256: await sha256ZeropsSourceCredentialBundleV2(bundle) },
|
|
485
|
+
service,
|
|
486
|
+
environmentKey,
|
|
487
|
+
bundle,
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
throw new SourceConnectionAdminError('credential_conflict')
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
340
494
|
async function withDigest(inspection: DurableInspection): Promise<DurableInspection> {
|
|
341
495
|
if (inspection.public.state !== 'durable' || inspection.bundle === undefined) return inspection
|
|
342
496
|
return {
|
|
@@ -366,6 +520,25 @@ async function proveDurable(
|
|
|
366
520
|
throw new SourceConnectionAdminError('credential_persistence')
|
|
367
521
|
}
|
|
368
522
|
|
|
523
|
+
async function proveDurableV2(
|
|
524
|
+
input: SourceConnectionActivateInput,
|
|
525
|
+
digest: string,
|
|
526
|
+
attempts: number,
|
|
527
|
+
delayMs: number,
|
|
528
|
+
sleep: (delayMs: number, signal: AbortSignal) => Promise<void>,
|
|
529
|
+
inspect: (connectionId: string, signal: AbortSignal) => Promise<DurableInspectionV2>,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
532
|
+
const durable = await inspect(input.connectionId, input.signal)
|
|
533
|
+
if (durable.public.state === 'durable') {
|
|
534
|
+
if (durable.bundle === input.credentialBundle && durable.public.credentialSha256 === digest) return
|
|
535
|
+
throw new SourceConnectionAdminError('credential_conflict')
|
|
536
|
+
}
|
|
537
|
+
if (attempt + 1 < attempts) await sleep(delayMs, input.signal)
|
|
538
|
+
}
|
|
539
|
+
throw new SourceConnectionAdminError('credential_persistence')
|
|
540
|
+
}
|
|
541
|
+
|
|
369
542
|
const defaultSleep = (delayMs: number, signal: AbortSignal): Promise<void> => {
|
|
370
543
|
throwIfAborted(signal)
|
|
371
544
|
return new Promise((resolve, reject) => {
|
package/src/source.ts
CHANGED
|
@@ -8,6 +8,13 @@ export const ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH = '/v1/source/credentials/stat
|
|
|
8
8
|
export const ZEROPS_SOURCE_WEBHOOK_CONFIGURE_PATH = '/v1/source/github/webhook/configure'
|
|
9
9
|
export const ZEROPS_SOURCE_INSTALLATIONS_VERIFY_PATH = '/v1/source/github/installations/verify'
|
|
10
10
|
export const ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION = 1
|
|
11
|
+
export const ZEROPS_SOURCE_PROTOCOL_VERSION_V2 = 2
|
|
12
|
+
export const ZEROPS_SOURCE_RESOLVE_PATH_V2 = '/v2/source/resolve'
|
|
13
|
+
export const ZEROPS_SOURCE_UPLOAD_PATH_V2 = '/v2/source/upload'
|
|
14
|
+
export const ZEROPS_SOURCE_CREDENTIAL_ACTIVATE_PATH_V2 = '/v2/source/credentials/activate'
|
|
15
|
+
export const ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH_V2 = '/v2/source/credentials/status'
|
|
16
|
+
export const ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2 = 2
|
|
17
|
+
export const ZEROPS_SOURCE_CREDENTIAL_ENV_V2_PREFIX = 'GITHUB_APP_CREDENTIALS_V2_'
|
|
11
18
|
|
|
12
19
|
const MAX_ID_LENGTH = 128
|
|
13
20
|
const MAX_REF_LENGTH = 255
|
|
@@ -32,6 +39,13 @@ export interface ZeropsSourceCredentialBundleV1 {
|
|
|
32
39
|
readonly privateKeyPem: string
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
export interface ZeropsSourceCredentialBundleV2 {
|
|
43
|
+
readonly version: typeof ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2
|
|
44
|
+
readonly connectionId: string
|
|
45
|
+
readonly githubAppId: string
|
|
46
|
+
readonly privateKeyPem: string
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
export interface ZeropsSourceGitHubAppIdentityV1 {
|
|
36
50
|
readonly id: number
|
|
37
51
|
readonly slug: string
|
|
@@ -82,6 +96,41 @@ export type ZeropsSourceCredentialStatusResponseV1 =
|
|
|
82
96
|
| ZeropsSourceCredentialAnonymousStatusResponseV1
|
|
83
97
|
| ZeropsSourceCredentialActiveStatusResponseV1
|
|
84
98
|
|
|
99
|
+
export interface ZeropsSourceCredentialActivateRequestV2 {
|
|
100
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
101
|
+
readonly connectionId: string
|
|
102
|
+
/** Canonical JSON that is byte-identical to the durable source environment value. */
|
|
103
|
+
readonly credentialBundle: string
|
|
104
|
+
readonly credentialSha256: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ZeropsSourceCredentialActivateResponseV2 {
|
|
108
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
109
|
+
readonly connectionId: string
|
|
110
|
+
readonly credentialVersion: typeof ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2
|
|
111
|
+
readonly credentialSha256: string
|
|
112
|
+
readonly githubApp: ZeropsSourceGitHubAppIdentityV1
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ZeropsSourceCredentialStatusRequestV2 {
|
|
116
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
117
|
+
readonly connectionId: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ZeropsSourceCredentialAnonymousStatusResponseV2 {
|
|
121
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
122
|
+
readonly connectionId: string
|
|
123
|
+
readonly state: 'anonymous'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ZeropsSourceCredentialActiveStatusResponseV2 extends ZeropsSourceCredentialActivateResponseV2 {
|
|
127
|
+
readonly state: 'active'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type ZeropsSourceCredentialStatusResponseV2 =
|
|
131
|
+
| ZeropsSourceCredentialAnonymousStatusResponseV2
|
|
132
|
+
| ZeropsSourceCredentialActiveStatusResponseV2
|
|
133
|
+
|
|
85
134
|
export interface ZeropsSourceWebhookConfigureInput {
|
|
86
135
|
readonly connectionId: string
|
|
87
136
|
readonly credentialSha256: string
|
|
@@ -195,6 +244,47 @@ export interface ZeropsSourceUploadResponseV1 {
|
|
|
195
244
|
descriptorSha256: string
|
|
196
245
|
}
|
|
197
246
|
|
|
247
|
+
export interface ZeropsSourcePrivateBindingV2 {
|
|
248
|
+
readonly connectionId: string
|
|
249
|
+
readonly installationId: number
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface ZeropsSourceResolveRequestV2 {
|
|
253
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
254
|
+
readonly runId: string
|
|
255
|
+
readonly repository: ZeropsSourceRepository
|
|
256
|
+
readonly requestedRef: string
|
|
257
|
+
readonly expectedCommitSha?: string
|
|
258
|
+
readonly privateBinding?: ZeropsSourcePrivateBindingV2
|
|
259
|
+
readonly descriptorSha256: string
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface ZeropsSourceResolveResponseV2 {
|
|
263
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
264
|
+
readonly runId: string
|
|
265
|
+
readonly commitSha: string
|
|
266
|
+
readonly descriptorSha256: string
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface ZeropsSourceUploadRequestV2 {
|
|
270
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
271
|
+
readonly runId: string
|
|
272
|
+
readonly appVersionId: string
|
|
273
|
+
readonly repository: ZeropsSourceRepository
|
|
274
|
+
readonly commitSha: string
|
|
275
|
+
readonly privateBinding?: ZeropsSourcePrivateBindingV2
|
|
276
|
+
readonly uploadUrl: string
|
|
277
|
+
readonly descriptor: ZeropsSourceDescriptor
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface ZeropsSourceUploadResponseV2 {
|
|
281
|
+
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
282
|
+
readonly runId: string
|
|
283
|
+
readonly appVersionId: string
|
|
284
|
+
readonly commitSha: string
|
|
285
|
+
readonly descriptorSha256: string
|
|
286
|
+
}
|
|
287
|
+
|
|
198
288
|
export interface ZeropsSourceCancelRequestV1 {
|
|
199
289
|
protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
200
290
|
runId: string
|
|
@@ -303,6 +393,27 @@ export interface ZeropsSourceUploadInput {
|
|
|
303
393
|
signal: AbortSignal
|
|
304
394
|
}
|
|
305
395
|
|
|
396
|
+
export interface ZeropsSourceResolveInputV2 {
|
|
397
|
+
readonly runId: string
|
|
398
|
+
readonly repository: ZeropsSourceRepository
|
|
399
|
+
readonly requestedRef: string
|
|
400
|
+
readonly expectedCommitSha?: string
|
|
401
|
+
readonly privateBinding?: ZeropsSourcePrivateBindingV2
|
|
402
|
+
readonly descriptorSha256: string
|
|
403
|
+
readonly signal: AbortSignal
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface ZeropsSourceUploadInputV2 {
|
|
407
|
+
readonly runId: string
|
|
408
|
+
readonly appVersionId: string
|
|
409
|
+
readonly repository: ZeropsSourceRepository
|
|
410
|
+
readonly commitSha: string
|
|
411
|
+
readonly privateBinding?: ZeropsSourcePrivateBindingV2
|
|
412
|
+
readonly uploadUrl: string
|
|
413
|
+
readonly descriptor: ZeropsSourceDescriptor
|
|
414
|
+
readonly signal: AbortSignal
|
|
415
|
+
}
|
|
416
|
+
|
|
306
417
|
export interface ZeropsSourceUploadResult {
|
|
307
418
|
runId: string
|
|
308
419
|
appVersionId: string
|
|
@@ -323,6 +434,12 @@ export interface ZeropsSourceClient {
|
|
|
323
434
|
cancel(input: ZeropsSourceCancelInput): Promise<void>
|
|
324
435
|
}
|
|
325
436
|
|
|
437
|
+
/** Keyed source operations are additive so legacy client implementations remain valid. */
|
|
438
|
+
export interface ZeropsSourceClientV2 {
|
|
439
|
+
resolveV2(input: ZeropsSourceResolveInputV2): Promise<ZeropsSourceResolveResult>
|
|
440
|
+
uploadV2(input: ZeropsSourceUploadInputV2): Promise<ZeropsSourceUploadResult>
|
|
441
|
+
}
|
|
442
|
+
|
|
326
443
|
export interface ZeropsSourceCredentialActivateInput {
|
|
327
444
|
readonly connectionId: string
|
|
328
445
|
readonly credentialBundle: string
|
|
@@ -343,6 +460,12 @@ export interface ZeropsSourceCredentialManager {
|
|
|
343
460
|
verifyInstallations(input: ZeropsSourceInstallationsVerifyInput): Promise<ZeropsSourceInstallationsVerifyResponseV1>
|
|
344
461
|
}
|
|
345
462
|
|
|
463
|
+
/** Keyed credential operations are additive so legacy credential managers remain valid. */
|
|
464
|
+
export interface ZeropsSourceCredentialManagerV2 {
|
|
465
|
+
activateV2(input: ZeropsSourceCredentialActivateInput): Promise<ZeropsSourceCredentialActivateResponseV2>
|
|
466
|
+
statusV2(input: ZeropsSourceCredentialStatusInput): Promise<ZeropsSourceCredentialStatusResponseV2>
|
|
467
|
+
}
|
|
468
|
+
|
|
346
469
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
347
470
|
|
|
348
471
|
const record = (value: unknown, label: string): Record<string, unknown> => {
|
|
@@ -363,6 +486,11 @@ const protocolVersion = (value: Record<string, unknown>, label: string): typeof
|
|
|
363
486
|
return ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
364
487
|
}
|
|
365
488
|
|
|
489
|
+
const protocolVersionV2 = (value: Record<string, unknown>, label: string): typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2 => {
|
|
490
|
+
if (value['protocolVersion'] !== ZEROPS_SOURCE_PROTOCOL_VERSION_V2) throw new Error(`${label} has an unsupported protocolVersion`)
|
|
491
|
+
return ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
492
|
+
}
|
|
493
|
+
|
|
366
494
|
const boundedString = (value: unknown, label: string, maximum: number): string => {
|
|
367
495
|
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
368
496
|
throw new Error(`${label} must be a non-empty string of at most ${maximum} characters`)
|
|
@@ -428,6 +556,24 @@ const sourceCredentialBundleValue = (value: unknown): ZeropsSourceCredentialBund
|
|
|
428
556
|
return { version: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION, githubAppId, privateKeyPem }
|
|
429
557
|
}
|
|
430
558
|
|
|
559
|
+
const sourceCredentialBundleValueV2 = (value: unknown): ZeropsSourceCredentialBundleV2 => {
|
|
560
|
+
const parsed = record(value, 'source credential bundle v2')
|
|
561
|
+
exactKeys(parsed, ['version', 'connectionId', 'githubAppId', 'privateKeyPem'], 'source credential bundle v2')
|
|
562
|
+
if (parsed['version'] !== ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2) {
|
|
563
|
+
throw new Error('source credential bundle v2 has an unsupported version')
|
|
564
|
+
}
|
|
565
|
+
const connectionId = identifier(parsed['connectionId'], 'source credential bundle v2.connectionId')
|
|
566
|
+
const githubAppId = boundedString(parsed['githubAppId'], 'source credential bundle v2.githubAppId', 32)
|
|
567
|
+
if (!GITHUB_APP_ID_PATTERN.test(githubAppId)) throw new Error('source credential bundle v2.githubAppId is invalid')
|
|
568
|
+
const privateKeyPem = boundedString(
|
|
569
|
+
parsed['privateKeyPem'],
|
|
570
|
+
'source credential bundle v2.privateKeyPem',
|
|
571
|
+
MAX_GITHUB_APP_PRIVATE_KEY_LENGTH,
|
|
572
|
+
)
|
|
573
|
+
if (!isCanonicalPrivateKeyPem(privateKeyPem)) throw new Error('source credential bundle v2.privateKeyPem is invalid')
|
|
574
|
+
return { version: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2, connectionId, githubAppId, privateKeyPem }
|
|
575
|
+
}
|
|
576
|
+
|
|
431
577
|
const isCanonicalPrivateKeyPem = (value: string): boolean => {
|
|
432
578
|
const match = /^-----BEGIN (RSA PRIVATE KEY|PRIVATE KEY)-----\n([A-Za-z0-9+/=\n]+)\n-----END (RSA PRIVATE KEY|PRIVATE KEY)-----\n?$/.exec(value)
|
|
433
579
|
if (match === null || match[1] !== match[3]) return false
|
|
@@ -478,6 +624,51 @@ export async function sha256ZeropsSourceCredentialBundle(value: string): Promise
|
|
|
478
624
|
return [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
479
625
|
}
|
|
480
626
|
|
|
627
|
+
export function buildZeropsSourceCredentialBundleV2(
|
|
628
|
+
input: Omit<ZeropsSourceCredentialBundleV2, 'version'>,
|
|
629
|
+
): ZeropsSourceCredentialBundleV2 {
|
|
630
|
+
return sourceCredentialBundleValueV2({ version: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2, ...input })
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function serializeZeropsSourceCredentialBundleV2(bundle: ZeropsSourceCredentialBundleV2): string {
|
|
634
|
+
const parsed = sourceCredentialBundleValueV2(bundle)
|
|
635
|
+
return JSON.stringify({
|
|
636
|
+
version: parsed.version,
|
|
637
|
+
connectionId: parsed.connectionId,
|
|
638
|
+
githubAppId: parsed.githubAppId,
|
|
639
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function decodeZeropsSourceCredentialBundleV2(value: unknown): ZeropsSourceCredentialBundleV2 {
|
|
644
|
+
if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > MAX_SOURCE_CREDENTIAL_BUNDLE_BYTES) {
|
|
645
|
+
throw new Error('source credential bundle v2 must be bounded canonical JSON')
|
|
646
|
+
}
|
|
647
|
+
let decoded: unknown
|
|
648
|
+
try {
|
|
649
|
+
decoded = JSON.parse(value)
|
|
650
|
+
} catch {
|
|
651
|
+
throw new Error('source credential bundle v2 must be bounded canonical JSON')
|
|
652
|
+
}
|
|
653
|
+
const bundle = sourceCredentialBundleValueV2(decoded)
|
|
654
|
+
if (serializeZeropsSourceCredentialBundleV2(bundle) !== value) {
|
|
655
|
+
throw new Error('source credential bundle v2 must be bounded canonical JSON')
|
|
656
|
+
}
|
|
657
|
+
return bundle
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export async function sha256ZeropsSourceCredentialBundleV2(value: string): Promise<string> {
|
|
661
|
+
decodeZeropsSourceCredentialBundleV2(value)
|
|
662
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
|
|
663
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export async function zeropsSourceCredentialEnvV2(connectionId: string): Promise<string> {
|
|
667
|
+
const canonicalConnectionId = identifier(connectionId, 'source credential connectionId')
|
|
668
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalConnectionId)))
|
|
669
|
+
return `${ZEROPS_SOURCE_CREDENTIAL_ENV_V2_PREFIX}${[...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
670
|
+
}
|
|
671
|
+
|
|
481
672
|
const installationId = (value: unknown, label: string): number => {
|
|
482
673
|
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive safe integer`)
|
|
483
674
|
return value
|
|
@@ -487,6 +678,18 @@ const optionalCommitSha = (value: unknown, label: string): string | undefined =>
|
|
|
487
678
|
|
|
488
679
|
const optionalInstallationId = (value: unknown, label: string): number | undefined => value === undefined ? undefined : installationId(value, label)
|
|
489
680
|
|
|
681
|
+
const privateBindingV2 = (value: unknown, label: string): ZeropsSourcePrivateBindingV2 => {
|
|
682
|
+
const parsed = record(value, label)
|
|
683
|
+
exactKeys(parsed, ['connectionId', 'installationId'], label)
|
|
684
|
+
return {
|
|
685
|
+
connectionId: identifier(parsed['connectionId'], `${label}.connectionId`),
|
|
686
|
+
installationId: installationId(parsed['installationId'], `${label}.installationId`),
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const optionalPrivateBindingV2 = (value: unknown, label: string): ZeropsSourcePrivateBindingV2 | undefined =>
|
|
691
|
+
value === undefined ? undefined : privateBindingV2(value, label)
|
|
692
|
+
|
|
490
693
|
const repository = (value: unknown, label: string): ZeropsSourceRepository => {
|
|
491
694
|
const parsed = record(value, label)
|
|
492
695
|
exactKeys(parsed, ['owner', 'name'], label)
|
|
@@ -716,6 +919,117 @@ export function buildZeropsSourceCredentialStatusResponse(
|
|
|
716
919
|
return decodeZeropsSourceCredentialStatusResponse({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION, ...input })
|
|
717
920
|
}
|
|
718
921
|
|
|
922
|
+
export function decodeZeropsSourceCredentialActivateRequestV2(value: unknown): ZeropsSourceCredentialActivateRequestV2 {
|
|
923
|
+
const parsed = record(value, 'source credential activate request v2')
|
|
924
|
+
exactKeys(
|
|
925
|
+
parsed,
|
|
926
|
+
['protocolVersion', 'connectionId', 'credentialBundle', 'credentialSha256'],
|
|
927
|
+
'source credential activate request v2',
|
|
928
|
+
)
|
|
929
|
+
const connectionId = identifier(parsed['connectionId'], 'source credential activate request v2.connectionId')
|
|
930
|
+
const credentialBundle = boundedString(
|
|
931
|
+
parsed['credentialBundle'],
|
|
932
|
+
'source credential activate request v2.credentialBundle',
|
|
933
|
+
MAX_SOURCE_CREDENTIAL_BUNDLE_BYTES,
|
|
934
|
+
)
|
|
935
|
+
const bundle = decodeZeropsSourceCredentialBundleV2(credentialBundle)
|
|
936
|
+
if (bundle.connectionId !== connectionId) throw new Error('source credential activate request v2 connectionId does not match its bundle')
|
|
937
|
+
return {
|
|
938
|
+
protocolVersion: protocolVersionV2(parsed, 'source credential activate request v2'),
|
|
939
|
+
connectionId,
|
|
940
|
+
credentialBundle,
|
|
941
|
+
credentialSha256: sha256(parsed['credentialSha256'], 'source credential activate request v2.credentialSha256'),
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function buildZeropsSourceCredentialActivateRequestV2(
|
|
946
|
+
input: ZeropsSourceCredentialActivateInput,
|
|
947
|
+
): ZeropsSourceCredentialActivateRequestV2 {
|
|
948
|
+
return decodeZeropsSourceCredentialActivateRequestV2({
|
|
949
|
+
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
950
|
+
connectionId: input.connectionId,
|
|
951
|
+
credentialBundle: input.credentialBundle,
|
|
952
|
+
credentialSha256: input.credentialSha256,
|
|
953
|
+
})
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export function decodeZeropsSourceCredentialActivateResponseV2(value: unknown): ZeropsSourceCredentialActivateResponseV2 {
|
|
957
|
+
const parsed = record(value, 'source credential activate response v2')
|
|
958
|
+
exactKeys(
|
|
959
|
+
parsed,
|
|
960
|
+
['protocolVersion', 'connectionId', 'credentialVersion', 'credentialSha256', 'githubApp'],
|
|
961
|
+
'source credential activate response v2',
|
|
962
|
+
)
|
|
963
|
+
if (parsed['credentialVersion'] !== ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2) {
|
|
964
|
+
throw new Error('source credential activate response v2 has an unsupported credentialVersion')
|
|
965
|
+
}
|
|
966
|
+
return {
|
|
967
|
+
protocolVersion: protocolVersionV2(parsed, 'source credential activate response v2'),
|
|
968
|
+
connectionId: identifier(parsed['connectionId'], 'source credential activate response v2.connectionId'),
|
|
969
|
+
credentialVersion: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2,
|
|
970
|
+
credentialSha256: sha256(parsed['credentialSha256'], 'source credential activate response v2.credentialSha256'),
|
|
971
|
+
githubApp: githubAppIdentity(parsed['githubApp'], 'source credential activate response v2.githubApp'),
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function buildZeropsSourceCredentialActivateResponseV2(
|
|
976
|
+
input: Omit<ZeropsSourceCredentialActivateResponseV2, 'protocolVersion'>,
|
|
977
|
+
): ZeropsSourceCredentialActivateResponseV2 {
|
|
978
|
+
return decodeZeropsSourceCredentialActivateResponseV2({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2, ...input })
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function decodeZeropsSourceCredentialStatusRequestV2(value: unknown): ZeropsSourceCredentialStatusRequestV2 {
|
|
982
|
+
const parsed = record(value, 'source credential status request v2')
|
|
983
|
+
exactKeys(parsed, ['protocolVersion', 'connectionId'], 'source credential status request v2')
|
|
984
|
+
return {
|
|
985
|
+
protocolVersion: protocolVersionV2(parsed, 'source credential status request v2'),
|
|
986
|
+
connectionId: identifier(parsed['connectionId'], 'source credential status request v2.connectionId'),
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function buildZeropsSourceCredentialStatusRequestV2(
|
|
991
|
+
input: ZeropsSourceCredentialStatusInput,
|
|
992
|
+
): ZeropsSourceCredentialStatusRequestV2 {
|
|
993
|
+
return decodeZeropsSourceCredentialStatusRequestV2({
|
|
994
|
+
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
995
|
+
connectionId: input.connectionId,
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export function decodeZeropsSourceCredentialStatusResponseV2(value: unknown): ZeropsSourceCredentialStatusResponseV2 {
|
|
1000
|
+
const parsed = record(value, 'source credential status response v2')
|
|
1001
|
+
if (parsed['state'] === 'anonymous') {
|
|
1002
|
+
exactKeys(parsed, ['protocolVersion', 'connectionId', 'state'], 'source credential status response v2')
|
|
1003
|
+
return {
|
|
1004
|
+
protocolVersion: protocolVersionV2(parsed, 'source credential status response v2'),
|
|
1005
|
+
connectionId: identifier(parsed['connectionId'], 'source credential status response v2.connectionId'),
|
|
1006
|
+
state: 'anonymous',
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (parsed['state'] !== 'active') throw new Error('source credential status response v2.state is invalid')
|
|
1010
|
+
exactKeys(
|
|
1011
|
+
parsed,
|
|
1012
|
+
['protocolVersion', 'connectionId', 'state', 'credentialVersion', 'credentialSha256', 'githubApp'],
|
|
1013
|
+
'source credential status response v2',
|
|
1014
|
+
)
|
|
1015
|
+
const active = decodeZeropsSourceCredentialActivateResponseV2({
|
|
1016
|
+
protocolVersion: parsed['protocolVersion'],
|
|
1017
|
+
connectionId: parsed['connectionId'],
|
|
1018
|
+
credentialVersion: parsed['credentialVersion'],
|
|
1019
|
+
credentialSha256: parsed['credentialSha256'],
|
|
1020
|
+
githubApp: parsed['githubApp'],
|
|
1021
|
+
})
|
|
1022
|
+
return { ...active, state: 'active' }
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export function buildZeropsSourceCredentialStatusResponseV2(
|
|
1026
|
+
input:
|
|
1027
|
+
| { readonly connectionId: string; readonly state: 'anonymous' }
|
|
1028
|
+
| Omit<ZeropsSourceCredentialActiveStatusResponseV2, 'protocolVersion'>,
|
|
1029
|
+
): ZeropsSourceCredentialStatusResponseV2 {
|
|
1030
|
+
return decodeZeropsSourceCredentialStatusResponseV2({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2, ...input })
|
|
1031
|
+
}
|
|
1032
|
+
|
|
719
1033
|
const webhookUrl = (value: unknown, label: string): string => {
|
|
720
1034
|
const candidate = boundedString(value, label, MAX_WEBHOOK_URL_LENGTH)
|
|
721
1035
|
let parsed: URL
|
|
@@ -988,6 +1302,104 @@ export function buildZeropsSourceUploadResponse(result: ZeropsSourceUploadResult
|
|
|
988
1302
|
return decodeZeropsSourceUploadResponse({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION, ...result })
|
|
989
1303
|
}
|
|
990
1304
|
|
|
1305
|
+
export function decodeZeropsSourceResolveRequestV2(value: unknown): ZeropsSourceResolveRequestV2 {
|
|
1306
|
+
const parsed = record(value, 'source resolve request v2')
|
|
1307
|
+
optionalFields(
|
|
1308
|
+
parsed,
|
|
1309
|
+
['protocolVersion', 'runId', 'repository', 'requestedRef', 'descriptorSha256'],
|
|
1310
|
+
['expectedCommitSha', 'privateBinding'],
|
|
1311
|
+
'source resolve request v2',
|
|
1312
|
+
)
|
|
1313
|
+
const expected = optionalCommitSha(parsed['expectedCommitSha'], 'source resolve request v2.expectedCommitSha')
|
|
1314
|
+
const binding = optionalPrivateBindingV2(parsed['privateBinding'], 'source resolve request v2.privateBinding')
|
|
1315
|
+
return {
|
|
1316
|
+
protocolVersion: protocolVersionV2(parsed, 'source resolve request v2'),
|
|
1317
|
+
runId: identifier(parsed['runId'], 'source resolve request v2.runId'),
|
|
1318
|
+
repository: repository(parsed['repository'], 'source resolve request v2.repository'),
|
|
1319
|
+
requestedRef: ref(parsed['requestedRef'], 'source resolve request v2.requestedRef'),
|
|
1320
|
+
...(expected !== undefined ? { expectedCommitSha: expected } : {}),
|
|
1321
|
+
...(binding !== undefined ? { privateBinding: binding } : {}),
|
|
1322
|
+
descriptorSha256: sha256(parsed['descriptorSha256'], 'source resolve request v2.descriptorSha256'),
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
export function buildZeropsSourceResolveRequestV2(input: ZeropsSourceResolveInputV2): ZeropsSourceResolveRequestV2 {
|
|
1327
|
+
return decodeZeropsSourceResolveRequestV2({
|
|
1328
|
+
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
1329
|
+
runId: input.runId,
|
|
1330
|
+
repository: input.repository,
|
|
1331
|
+
requestedRef: input.requestedRef,
|
|
1332
|
+
...(input.expectedCommitSha !== undefined ? { expectedCommitSha: input.expectedCommitSha } : {}),
|
|
1333
|
+
...(input.privateBinding !== undefined ? { privateBinding: input.privateBinding } : {}),
|
|
1334
|
+
descriptorSha256: input.descriptorSha256,
|
|
1335
|
+
})
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
export function decodeZeropsSourceResolveResponseV2(value: unknown): ZeropsSourceResolveResponseV2 {
|
|
1339
|
+
const parsed = record(value, 'source resolve response v2')
|
|
1340
|
+
exactKeys(parsed, ['protocolVersion', 'runId', 'commitSha', 'descriptorSha256'], 'source resolve response v2')
|
|
1341
|
+
return {
|
|
1342
|
+
protocolVersion: protocolVersionV2(parsed, 'source resolve response v2'),
|
|
1343
|
+
runId: identifier(parsed['runId'], 'source resolve response v2.runId'),
|
|
1344
|
+
commitSha: commitSha(parsed['commitSha'], 'source resolve response v2.commitSha'),
|
|
1345
|
+
descriptorSha256: sha256(parsed['descriptorSha256'], 'source resolve response v2.descriptorSha256'),
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
export function buildZeropsSourceResolveResponseV2(result: ZeropsSourceResolveResult): ZeropsSourceResolveResponseV2 {
|
|
1350
|
+
return decodeZeropsSourceResolveResponseV2({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2, ...result })
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
export function decodeZeropsSourceUploadRequestV2(value: unknown): ZeropsSourceUploadRequestV2 {
|
|
1354
|
+
const parsed = record(value, 'source upload request v2')
|
|
1355
|
+
optionalFields(
|
|
1356
|
+
parsed,
|
|
1357
|
+
['protocolVersion', 'runId', 'appVersionId', 'repository', 'commitSha', 'uploadUrl', 'descriptor'],
|
|
1358
|
+
['privateBinding'],
|
|
1359
|
+
'source upload request v2',
|
|
1360
|
+
)
|
|
1361
|
+
const binding = optionalPrivateBindingV2(parsed['privateBinding'], 'source upload request v2.privateBinding')
|
|
1362
|
+
return {
|
|
1363
|
+
protocolVersion: protocolVersionV2(parsed, 'source upload request v2'),
|
|
1364
|
+
runId: identifier(parsed['runId'], 'source upload request v2.runId'),
|
|
1365
|
+
appVersionId: identifier(parsed['appVersionId'], 'source upload request v2.appVersionId'),
|
|
1366
|
+
repository: repository(parsed['repository'], 'source upload request v2.repository'),
|
|
1367
|
+
commitSha: commitSha(parsed['commitSha'], 'source upload request v2.commitSha'),
|
|
1368
|
+
...(binding !== undefined ? { privateBinding: binding } : {}),
|
|
1369
|
+
uploadUrl: uploadUrl(parsed['uploadUrl'], 'source upload request v2.uploadUrl'),
|
|
1370
|
+
descriptor: descriptor(parsed['descriptor'], 'source upload request v2.descriptor'),
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
export function buildZeropsSourceUploadRequestV2(input: ZeropsSourceUploadInputV2): ZeropsSourceUploadRequestV2 {
|
|
1375
|
+
return decodeZeropsSourceUploadRequestV2({
|
|
1376
|
+
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
1377
|
+
runId: input.runId,
|
|
1378
|
+
appVersionId: input.appVersionId,
|
|
1379
|
+
repository: input.repository,
|
|
1380
|
+
commitSha: input.commitSha,
|
|
1381
|
+
...(input.privateBinding !== undefined ? { privateBinding: input.privateBinding } : {}),
|
|
1382
|
+
uploadUrl: input.uploadUrl,
|
|
1383
|
+
descriptor: input.descriptor,
|
|
1384
|
+
})
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
export function decodeZeropsSourceUploadResponseV2(value: unknown): ZeropsSourceUploadResponseV2 {
|
|
1388
|
+
const parsed = record(value, 'source upload response v2')
|
|
1389
|
+
exactKeys(parsed, ['protocolVersion', 'runId', 'appVersionId', 'commitSha', 'descriptorSha256'], 'source upload response v2')
|
|
1390
|
+
return {
|
|
1391
|
+
protocolVersion: protocolVersionV2(parsed, 'source upload response v2'),
|
|
1392
|
+
runId: identifier(parsed['runId'], 'source upload response v2.runId'),
|
|
1393
|
+
appVersionId: identifier(parsed['appVersionId'], 'source upload response v2.appVersionId'),
|
|
1394
|
+
commitSha: commitSha(parsed['commitSha'], 'source upload response v2.commitSha'),
|
|
1395
|
+
descriptorSha256: sha256(parsed['descriptorSha256'], 'source upload response v2.descriptorSha256'),
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
export function buildZeropsSourceUploadResponseV2(result: ZeropsSourceUploadResult): ZeropsSourceUploadResponseV2 {
|
|
1400
|
+
return decodeZeropsSourceUploadResponseV2({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION_V2, ...result })
|
|
1401
|
+
}
|
|
1402
|
+
|
|
991
1403
|
export function decodeZeropsSourceCancelRequest(value: unknown): ZeropsSourceCancelRequestV1 {
|
|
992
1404
|
const parsed = record(value, 'source cancel request')
|
|
993
1405
|
exactKeys(parsed, ['protocolVersion', 'runId', 'appVersionId'], 'source cancel request')
|