@fabrika/provider-contract 0.2.0 → 0.3.2
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 +7 -2
- package/src/control.ts +124 -12
- package/src/index.ts +1 -2
- package/src/installation.ts +66 -0
- package/src/json.ts +10 -0
- package/src/steps.ts +72 -0
- package/src/deploy.ts +0 -88
- package/src/provider.ts +0 -78
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-contract",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"bun": "./src/index.ts",
|
|
20
20
|
"types": "./src/index.ts",
|
|
21
21
|
"default": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./installation": {
|
|
24
|
+
"bun": "./src/installation.ts",
|
|
25
|
+
"types": "./src/installation.ts",
|
|
26
|
+
"default": "./src/installation.ts"
|
|
22
27
|
}
|
|
23
28
|
},
|
|
24
29
|
"scripts": {
|
|
@@ -26,6 +31,6 @@
|
|
|
26
31
|
"test": "bun test"
|
|
27
32
|
},
|
|
28
33
|
"dependencies": {
|
|
29
|
-
"@fabrika/auth-core": "^0.2
|
|
34
|
+
"@fabrika/auth-core": "^0.3.2"
|
|
30
35
|
}
|
|
31
36
|
}
|
package/src/control.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import type { ProviderManagedEnvironment, ProviderRunEvents } from './deploy'
|
|
2
1
|
import type { JsonValue, ProviderEnvelope } from './json'
|
|
3
2
|
|
|
3
|
+
/** Events whose persistence and presentation belong to the control plane. */
|
|
4
|
+
export interface ProviderRunEvents {
|
|
5
|
+
log(line: string): void
|
|
6
|
+
/** Atomically persist the external operation id and, when supplied, its credential-free checkpoint. */
|
|
7
|
+
externalId(id: string, state?: JsonValue): Promise<void>
|
|
8
|
+
/** Replace the credential-free checkpoint while this run remains active. */
|
|
9
|
+
checkpoint(state: JsonValue): Promise<void>
|
|
10
|
+
/**
|
|
11
|
+
* A step boundary: Control checks its lease and reads the run's cancellation request. Rejects with a
|
|
12
|
+
* `ProviderInvocationAbort` after aborting the run's signal. A long step calls it at its own boundaries.
|
|
13
|
+
*/
|
|
14
|
+
boundary(): Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Complete platform-owned runtime state. A null value removes the key from persistent targets. */
|
|
18
|
+
export type ProviderManagedEnvironment = Readonly<Record<string, string | null>>
|
|
19
|
+
|
|
4
20
|
/** Provider-neutral coordinates for obtaining one app revision. */
|
|
5
21
|
export interface ProviderSource {
|
|
6
22
|
readonly repoUrl: string
|
|
@@ -8,8 +24,6 @@ export interface ProviderSource {
|
|
|
8
24
|
readonly workerDir?: string
|
|
9
25
|
readonly buildCommand?: string
|
|
10
26
|
readonly configPath?: string
|
|
11
|
-
readonly githubConnectionId?: string
|
|
12
|
-
readonly githubInstallationId?: number
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
/** App data needed by a control provider. */
|
|
@@ -54,6 +68,7 @@ export interface ProviderSourceResolutionInput {
|
|
|
54
68
|
readonly environment: ProviderEnvironment
|
|
55
69
|
/** An immutable commit already recorded by the trigger, which resolution must verify exactly. */
|
|
56
70
|
readonly expectedCommitSha?: string
|
|
71
|
+
readonly sourceBinding?: ProviderSourceBinding
|
|
57
72
|
readonly signal: AbortSignal
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -62,6 +77,20 @@ export interface ProviderSourceResolution {
|
|
|
62
77
|
readonly commitSha: string
|
|
63
78
|
}
|
|
64
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Credential-free selector of the GitHub connection and installation that read a private source
|
|
82
|
+
* (ADR-0029). Absent means a public source; an installation id alone never selects a credential.
|
|
83
|
+
*/
|
|
84
|
+
export interface ProviderSourceBinding {
|
|
85
|
+
readonly connectionId: string
|
|
86
|
+
readonly installationId: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How private GitHub sources reach a provider (ADR-0029): one keyed connection's App and installation. */
|
|
90
|
+
export interface ProviderSourceCapabilities {
|
|
91
|
+
readonly binding: 'connection'
|
|
92
|
+
}
|
|
93
|
+
|
|
65
94
|
/** One control-plane deploy invocation. */
|
|
66
95
|
export interface ProviderDeployInput {
|
|
67
96
|
readonly runId: string
|
|
@@ -72,12 +101,13 @@ export interface ProviderDeployInput {
|
|
|
72
101
|
/** Platform-owned runtime configuration, separate from operator-managed application vars. */
|
|
73
102
|
readonly managedEnvironment: ProviderManagedEnvironment
|
|
74
103
|
/**
|
|
75
|
-
* Every origin IAM may hand this app a session at (ADR-
|
|
104
|
+
* Every origin IAM may hand this app a session at (ADR-0022). App-wide, not per-environment:
|
|
76
105
|
* IAM's registry is keyed by app id, so the set is assembled from every environment of the app.
|
|
77
106
|
* Absent when the app has no public origin at all, which leaves IAM's registry untouched.
|
|
78
107
|
*/
|
|
79
108
|
readonly returnOrigins?: readonly string[]
|
|
80
109
|
readonly dryRun: boolean
|
|
110
|
+
readonly sourceBinding?: ProviderSourceBinding
|
|
81
111
|
/** Optional short-lived, run-scoped Operations artifact destination. */
|
|
82
112
|
readonly artifactUpload?: {
|
|
83
113
|
readonly url: string
|
|
@@ -100,28 +130,81 @@ export interface ProviderTerminalOutcome {
|
|
|
100
130
|
readonly artifactState?: 'complete' | 'incomplete' | 'not_applicable'
|
|
101
131
|
}
|
|
102
132
|
|
|
133
|
+
/** Running means external settlement is unresolved, including an ambiguous submission response. */
|
|
134
|
+
export type ProviderDeployOutcome = ProviderTerminalOutcome | { readonly state: 'running' }
|
|
135
|
+
|
|
136
|
+
/** Settled does not imply rollback: when activation won before cancellation, `outcome` is `succeeded`. */
|
|
137
|
+
export type ProviderCancelOutcome = { readonly state: 'pending' } | { readonly state: 'settled'; readonly outcome?: 'succeeded' | 'failed' }
|
|
138
|
+
|
|
139
|
+
/** Losing invocation ownership must never be interpreted as permission to cancel external work. */
|
|
140
|
+
export class ProviderInvocationAbort extends Error {
|
|
141
|
+
constructor(readonly kind: 'lease_lost' | 'cancel_requested') {
|
|
142
|
+
super(kind === 'lease_lost' ? 'provider invocation lost its lease' : 'provider cancellation requested')
|
|
143
|
+
this.name = 'ProviderInvocationAbort'
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const isLeaseLoss = (reason: unknown): boolean => reason instanceof ProviderInvocationAbort && reason.kind === 'lease_lost'
|
|
148
|
+
|
|
149
|
+
/** A signal that aborts with `signal`'s reason only when the lease is lost, never on a cancel. */
|
|
150
|
+
export const followLeaseLoss = (signal: AbortSignal): AbortSignal => {
|
|
151
|
+
const controller = new AbortController()
|
|
152
|
+
const follow = (): void => {
|
|
153
|
+
if (isLeaseLoss(signal.reason)) controller.abort(signal.reason)
|
|
154
|
+
}
|
|
155
|
+
if (signal.aborted) follow()
|
|
156
|
+
else signal.addEventListener('abort', follow, { once: true })
|
|
157
|
+
return controller.signal
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolve after `ms`, or reject with the signal's reason once it aborts. */
|
|
161
|
+
export const abortableWait = (ms: number, signal: AbortSignal): Promise<void> =>
|
|
162
|
+
new Promise((resolve, reject) => {
|
|
163
|
+
signal.throwIfAborted()
|
|
164
|
+
const abort = (): void => {
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
reject(signal.reason)
|
|
167
|
+
}
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
signal.removeEventListener('abort', abort)
|
|
170
|
+
resolve()
|
|
171
|
+
}, ms)
|
|
172
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
173
|
+
})
|
|
174
|
+
|
|
103
175
|
/** A platform-owned run that can be cancelled or reconciled. */
|
|
104
176
|
export interface ProviderRunReference {
|
|
105
177
|
readonly runId: string
|
|
106
178
|
readonly externalId: string
|
|
107
179
|
readonly environment: ProviderEnvironment
|
|
108
|
-
/** Credential-free provider-owned
|
|
180
|
+
/** Credential-free provider-owned checkpoint recorded by the run's last owner. */
|
|
109
181
|
readonly providerState?: JsonValue
|
|
182
|
+
/** When the run started (`started_at`, else `created_at`), in epoch milliseconds; bounds how long a provider polls. */
|
|
183
|
+
readonly startedAtMs?: number
|
|
110
184
|
}
|
|
111
185
|
|
|
112
|
-
export
|
|
186
|
+
export interface ProviderCancelInput extends ProviderRunReference {
|
|
187
|
+
readonly signal: AbortSignal
|
|
188
|
+
/** See `ProviderDeployInput.returnOrigins`; a cancel that settles an activated deploy projects the same set. */
|
|
189
|
+
readonly returnOrigins?: readonly string[]
|
|
190
|
+
}
|
|
113
191
|
|
|
114
192
|
export interface ProviderReconcileInput extends ProviderRunReference {
|
|
115
|
-
|
|
193
|
+
readonly signal: AbortSignal
|
|
194
|
+
/** See `ProviderDeployInput.returnOrigins`; a provider that settles a deploy here projects the same set. */
|
|
116
195
|
readonly returnOrigins?: readonly string[]
|
|
117
|
-
/**
|
|
118
|
-
|
|
196
|
+
/** Append a line to the run's log. */
|
|
197
|
+
log?(line: string): Promise<void>
|
|
119
198
|
}
|
|
120
199
|
|
|
121
200
|
/** The latest state reported while reconciling a platform-owned run. */
|
|
122
201
|
export interface ProviderReconcileOutcome {
|
|
123
202
|
readonly state: 'running' | 'succeeded' | 'failed'
|
|
124
203
|
readonly exitCode?: number
|
|
204
|
+
/** See `ProviderTerminalOutcome.artifactState`. */
|
|
205
|
+
readonly artifactState?: 'complete' | 'incomplete' | 'not_applicable'
|
|
206
|
+
/** The settled run was a preview: core projects it as a dry run. */
|
|
207
|
+
readonly dryRun?: boolean
|
|
125
208
|
}
|
|
126
209
|
|
|
127
210
|
/** Write one secret value to provider-managed storage. */
|
|
@@ -179,15 +262,39 @@ export class ProviderSecretError extends Error {
|
|
|
179
262
|
/** Persist a canonical namespace target immediately after one external mutation succeeds. */
|
|
180
263
|
export interface ProviderNamespaceEvents {
|
|
181
264
|
checkpoint(namespace: ProviderDeploymentNamespace): Promise<void>
|
|
265
|
+
/** A step boundary: Control checks the namespace lease and, for a deploy, the run's cancellation request. */
|
|
266
|
+
boundary(): Promise<void>
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** One app environment assigned to a namespace, as its proxy routes it. */
|
|
270
|
+
export interface ProviderNamespaceMember {
|
|
271
|
+
readonly appId: string
|
|
272
|
+
readonly env: string
|
|
273
|
+
readonly domain?: string
|
|
274
|
+
readonly artifact: ProviderEnvelope
|
|
182
275
|
}
|
|
183
276
|
|
|
184
277
|
/** One idempotent namespace lifecycle invocation. */
|
|
185
278
|
export interface ProviderNamespaceMutationInput {
|
|
186
279
|
readonly namespace: ProviderDeploymentNamespace
|
|
280
|
+
/** Every app environment assigned to the namespace, read under its lease. */
|
|
281
|
+
readonly members: readonly ProviderNamespaceMember[]
|
|
187
282
|
readonly signal: AbortSignal
|
|
188
283
|
readonly events: ProviderNamespaceEvents
|
|
189
284
|
}
|
|
190
285
|
|
|
286
|
+
/** `settled` is false while the rebuild is still in flight; core then hands it to a `reconcile` job. */
|
|
287
|
+
export interface ProviderNamespaceRepublishResult {
|
|
288
|
+
readonly namespace: ProviderDeploymentNamespace
|
|
289
|
+
readonly settled: boolean
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Rebuild a ready namespace's entry point from its current members. */
|
|
293
|
+
export interface ProviderNamespaceRepublishInput extends ProviderNamespaceMutationInput {
|
|
294
|
+
/** `started`: return once the rebuild is submitted. `active`: return once it is live. */
|
|
295
|
+
readonly wait: 'started' | 'active'
|
|
296
|
+
}
|
|
297
|
+
|
|
191
298
|
/** One registration whose provider coordinates do not exist until namespace services are prepared. */
|
|
192
299
|
export interface ProviderRegistrationPreparationInput {
|
|
193
300
|
readonly registration: ProviderRegistration
|
|
@@ -273,6 +380,8 @@ export interface ProviderNamespaceCapabilities {
|
|
|
273
380
|
prepareRegistration?(input: ProviderRegistrationPreparationInput): Promise<ProviderRegistration>
|
|
274
381
|
provision(input: ProviderNamespaceMutationInput): Promise<ProviderDeploymentNamespace>
|
|
275
382
|
reconcile(input: ProviderNamespaceMutationInput): Promise<ProviderDeploymentNamespace>
|
|
383
|
+
/** Absent means the namespace has no entry point that follows its members. */
|
|
384
|
+
republish?(input: ProviderNamespaceRepublishInput): Promise<ProviderNamespaceRepublishResult>
|
|
276
385
|
readonly operator?: ProviderNamespaceOperator
|
|
277
386
|
}
|
|
278
387
|
|
|
@@ -284,13 +393,16 @@ export interface ProviderNamespaceCapabilities {
|
|
|
284
393
|
*/
|
|
285
394
|
export interface ControlProvider {
|
|
286
395
|
readonly id: string
|
|
287
|
-
|
|
396
|
+
/** Absent means core stores the registration as submitted. */
|
|
397
|
+
normalizeRegistration?(input: ProviderRegistrationInput): ProviderRegistration
|
|
288
398
|
resolveSource?(input: ProviderSourceResolutionInput): Promise<ProviderSourceResolution>
|
|
289
|
-
deploy(input: ProviderDeployInput): Promise<
|
|
290
|
-
cancel?(input: ProviderCancelInput): Promise<
|
|
399
|
+
deploy(input: ProviderDeployInput): Promise<ProviderDeployOutcome>
|
|
400
|
+
cancel?(input: ProviderCancelInput): Promise<ProviderCancelOutcome>
|
|
291
401
|
reconcile?(input: ProviderReconcileInput): Promise<ProviderReconcileOutcome>
|
|
292
402
|
readonly secrets?: ProviderManagedSecrets
|
|
293
403
|
readonly namespaces?: ProviderNamespaceCapabilities
|
|
404
|
+
/** Absent means the provider deploys public sources only. */
|
|
405
|
+
readonly source?: ProviderSourceCapabilities
|
|
294
406
|
/**
|
|
295
407
|
* The variable names this artifact declares, so core can refuse one it would silently ignore
|
|
296
408
|
* ([ADR-0035](../../../docs/decisions/0035-the-platform-owns-the-application-iam-issuer.md)).
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform commands a provider MAY offer. Every one of them is optional — a provider declares the
|
|
3
|
+
* subset it implements and the CLI prints its usage for anything else.
|
|
4
|
+
*
|
|
5
|
+
* `install` is the newest and the narrowest in support: it BRINGS UP an installation that does not
|
|
6
|
+
* exist yet, generating and placing every credential it needs. Only Zerops declares it, because only
|
|
7
|
+
* there is a from-scratch bring-up a sequence of API calls; on Cloudflare the same ground is covered by
|
|
8
|
+
* `init` plus the workflow it scaffolds.
|
|
9
|
+
*
|
|
10
|
+
* `admin` admits the FIRST HUMAN to an installation that already runs. It is a verb of its own rather
|
|
11
|
+
* than a step of `install`, because it is the one command that is useful long after the bring-up: an
|
|
12
|
+
* installation whose only administrator left needs it, and a bring-up that got as far as the console
|
|
13
|
+
* does not need to be repeated to use it.
|
|
14
|
+
*
|
|
15
|
+
* `upgrade` rolls a RUNNING installation onto a newer published fabrika release. It is separate from
|
|
16
|
+
* `deploy` because the two run in different places: `deploy` runs inside the operator's own pipeline
|
|
17
|
+
* and brings an installation to the checkout it was handed, while `upgrade` runs on the operator's
|
|
18
|
+
* machine and moves the pin that pipeline reads. A provider whose operator edits no pin does not
|
|
19
|
+
* declare it.
|
|
20
|
+
*/
|
|
21
|
+
export type InstallationCommand = 'init' | 'install' | 'plan' | 'deploy' | 'admin' | 'upgrade'
|
|
22
|
+
|
|
23
|
+
export const isInstallationCommand = (value: unknown): value is InstallationCommand =>
|
|
24
|
+
value === 'init' || value === 'install' || value === 'plan' || value === 'deploy' || value === 'admin' || value === 'upgrade'
|
|
25
|
+
|
|
26
|
+
export interface InstallationCli {
|
|
27
|
+
readonly provider: string
|
|
28
|
+
readonly commands: readonly InstallationCommand[]
|
|
29
|
+
readonly usage: string
|
|
30
|
+
run(command: InstallationCommand, argv: readonly string[]): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InstallationCliModule {
|
|
34
|
+
readonly installationCli: InstallationCli
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const isInstallationCli = (value: unknown): value is InstallationCli =>
|
|
38
|
+
typeof value === 'object'
|
|
39
|
+
&& value !== null
|
|
40
|
+
&& 'provider' in value
|
|
41
|
+
&& typeof value.provider === 'string'
|
|
42
|
+
&& value.provider.trim() !== ''
|
|
43
|
+
&& 'commands' in value
|
|
44
|
+
&& Array.isArray(value.commands)
|
|
45
|
+
&& value.commands.length > 0
|
|
46
|
+
&& value.commands.every(isInstallationCommand)
|
|
47
|
+
&& new Set(value.commands).size === value.commands.length
|
|
48
|
+
&& 'usage' in value
|
|
49
|
+
&& typeof value.usage === 'string'
|
|
50
|
+
&& 'run' in value
|
|
51
|
+
&& typeof value.run === 'function'
|
|
52
|
+
|
|
53
|
+
export const supportsInstallationCommand = (installation: InstallationCli, command: InstallationCommand): boolean =>
|
|
54
|
+
installation.commands.some((supported) => supported === command)
|
|
55
|
+
|
|
56
|
+
export const installationCliFromModule = (value: unknown, expectedProvider?: string): InstallationCli => {
|
|
57
|
+
if (typeof value !== 'object' || value === null || !('installationCli' in value) || !isInstallationCli(value.installationCli)) {
|
|
58
|
+
throw new Error('Installation package must export a valid `installationCli`')
|
|
59
|
+
}
|
|
60
|
+
if (expectedProvider !== undefined && value.installationCli.provider !== expectedProvider) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Installation package provides "${value.installationCli.provider}", expected "${expectedProvider}"`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return value.installationCli
|
|
66
|
+
}
|
package/src/json.ts
CHANGED
|
@@ -14,3 +14,13 @@ export interface ProviderCodec<T> {
|
|
|
14
14
|
encode(value: T): JsonValue
|
|
15
15
|
decode(payload: JsonValue): T
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
export const decodeEnvelope = <T>(kind: string, provider: string, envelope: ProviderEnvelope, codec: ProviderCodec<T>): T => {
|
|
19
|
+
if (envelope.provider !== provider) {
|
|
20
|
+
throw new Error(`${kind} belongs to provider "${envelope.provider}", expected "${provider}"`)
|
|
21
|
+
}
|
|
22
|
+
if (envelope.version !== codec.version) {
|
|
23
|
+
throw new Error(`${kind} schema version ${envelope.version} is not supported by provider "${provider}"`)
|
|
24
|
+
}
|
|
25
|
+
return codec.decode(envelope.payload)
|
|
26
|
+
}
|
package/src/steps.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** What happened to one provider deploy step. */
|
|
2
|
+
export interface DeployStepResult {
|
|
3
|
+
readonly id: string
|
|
4
|
+
readonly status: 'succeeded' | 'failed' | 'skipped'
|
|
5
|
+
readonly error?: string
|
|
6
|
+
readonly startedAt?: number
|
|
7
|
+
readonly finishedAt?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RunStepsOptions {
|
|
11
|
+
readonly appId: string
|
|
12
|
+
readonly env: string
|
|
13
|
+
readonly dryRun: boolean
|
|
14
|
+
readonly signal: AbortSignal
|
|
15
|
+
readonly log: (line: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CANCELLED = 'deploy cancelled'
|
|
19
|
+
|
|
20
|
+
/** Stop waiting as soon as the run is cancelled; the step receives the same signal and stops its own work. */
|
|
21
|
+
const untilCancelled = <T>(promise: Promise<T>, signal: AbortSignal): Promise<T> =>
|
|
22
|
+
new Promise<T>((resolve, reject) => {
|
|
23
|
+
const onAbort = (): void => reject(new Error(CANCELLED))
|
|
24
|
+
if (signal.aborted) onAbort()
|
|
25
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
26
|
+
// Always observed, so a step that fails after it was abandoned is not an unhandled rejection.
|
|
27
|
+
void promise.then(resolve, reject).finally(() => {
|
|
28
|
+
signal.removeEventListener('abort', onAbort)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** Run a provider's steps in order. The first failure or a cancellation skips every later step. */
|
|
33
|
+
export const runSteps = async <TStep extends { readonly id: string; readonly description: string }>(
|
|
34
|
+
steps: readonly TStep[],
|
|
35
|
+
options: RunStepsOptions,
|
|
36
|
+
runOne: (step: TStep) => Promise<void>,
|
|
37
|
+
): Promise<{ readonly status: 'succeeded' | 'failed'; readonly steps: readonly DeployStepResult[] }> => {
|
|
38
|
+
const { log, signal } = options
|
|
39
|
+
log(`Deploy ${options.appId} → ${options.env}${options.dryRun ? ' (dry-run)' : ''} — ${steps.length} step(s):`)
|
|
40
|
+
for (const step of steps) {
|
|
41
|
+
log(` • ${step.id} — ${step.description}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const results: DeployStepResult[] = []
|
|
45
|
+
let stopped = false
|
|
46
|
+
for (const step of steps) {
|
|
47
|
+
if (stopped) {
|
|
48
|
+
results.push({ id: step.id, status: 'skipped' })
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if (signal.aborted) {
|
|
52
|
+
stopped = true
|
|
53
|
+
results.push({ id: step.id, status: 'skipped' })
|
|
54
|
+
log(`∅ ${step.id}: ${CANCELLED}`)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const startedAt = Date.now()
|
|
59
|
+
log(`→ ${step.id}`)
|
|
60
|
+
try {
|
|
61
|
+
await untilCancelled(runOne(step), signal)
|
|
62
|
+
results.push({ id: step.id, status: 'succeeded', startedAt, finishedAt: Date.now() })
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
65
|
+
stopped = true
|
|
66
|
+
log(`✗ ${step.id}: ${message}`)
|
|
67
|
+
results.push({ id: step.id, status: 'failed', error: message, startedAt, finishedAt: Date.now() })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { status: stopped ? 'failed' : 'succeeded', steps: results }
|
|
72
|
+
}
|
package/src/deploy.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import type { JsonValue, ProviderEnvelope } from './json'
|
|
2
|
-
|
|
3
|
-
/** One provider-owned unit of deploy work. Core treats `kind` as an open string. */
|
|
4
|
-
export interface ProviderJobSpec {
|
|
5
|
-
readonly id: string
|
|
6
|
-
readonly kind: string
|
|
7
|
-
readonly description: string
|
|
8
|
-
readonly dependsOn?: readonly string[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Shared deploy state used for whole runs and individual steps. */
|
|
12
|
-
export type ProviderRunStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped'
|
|
13
|
-
|
|
14
|
-
/** A provider job plus its observed runtime state. */
|
|
15
|
-
export interface ProviderDeployStep {
|
|
16
|
-
readonly spec: ProviderJobSpec
|
|
17
|
-
readonly status: ProviderRunStatus
|
|
18
|
-
readonly error?: string
|
|
19
|
-
readonly startedAt?: number
|
|
20
|
-
readonly finishedAt?: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** The ordered work derived by a provider before it starts mutation. */
|
|
24
|
-
export interface ProviderDeployPlan {
|
|
25
|
-
readonly appId: string
|
|
26
|
-
readonly env: string
|
|
27
|
-
readonly steps: readonly ProviderJobSpec[]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A provider-owned deploy session opened from one target and artifact. */
|
|
31
|
-
export interface ProviderDeploySession {
|
|
32
|
-
readonly plan: ProviderDeployPlan
|
|
33
|
-
execute(stepId: string): Promise<void>
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The final provider-neutral representation of a deploy execution. */
|
|
37
|
-
export interface ProviderDeployResult {
|
|
38
|
-
readonly appId: string
|
|
39
|
-
readonly env: string
|
|
40
|
-
readonly status: ProviderRunStatus
|
|
41
|
-
readonly plan: ProviderDeployPlan
|
|
42
|
-
readonly steps: readonly ProviderDeployStep[]
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Events whose persistence and presentation belong to the control plane. */
|
|
46
|
-
export interface ProviderRunEvents {
|
|
47
|
-
log(line: string): void
|
|
48
|
-
/** Atomically persist the external operation id and, when supplied, its credential-free resume state. */
|
|
49
|
-
externalId(id: string, state?: JsonValue): Promise<void>
|
|
50
|
-
/** Replace the credential-free resume state while this run remains active. */
|
|
51
|
-
checkpoint(state: JsonValue): Promise<void>
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Complete platform-owned runtime state. A null value removes the key from persistent targets. */
|
|
55
|
-
export type ProviderManagedEnvironment = Readonly<Record<string, string | null>>
|
|
56
|
-
|
|
57
|
-
/** Universal inputs shared by every provider deploy. */
|
|
58
|
-
export interface ProviderRunBase {
|
|
59
|
-
readonly appId: string
|
|
60
|
-
readonly env: string
|
|
61
|
-
readonly domain?: string
|
|
62
|
-
/**
|
|
63
|
-
* Every origin IAM may hand this app a session at, as known by the control plane. App-wide rather
|
|
64
|
-
* than per-environment, because IAM's registry is keyed by app id and a deploy of one environment
|
|
65
|
-
* must not un-register the others.
|
|
66
|
-
*/
|
|
67
|
-
readonly returnOrigins?: readonly string[]
|
|
68
|
-
readonly cwd: string
|
|
69
|
-
readonly secrets: Readonly<Record<string, string>>
|
|
70
|
-
readonly vars: Readonly<Record<string, string>>
|
|
71
|
-
/** Platform-owned runtime configuration, separate from operator-managed application vars. */
|
|
72
|
-
readonly managedEnvironment: ProviderManagedEnvironment
|
|
73
|
-
readonly dryRun: boolean
|
|
74
|
-
readonly signal: AbortSignal
|
|
75
|
-
readonly events: ProviderRunEvents
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The run shape seen by a provider after its envelopes have been decoded. */
|
|
79
|
-
export interface TypedProviderRun<TTarget, TArtifact> extends ProviderRunBase {
|
|
80
|
-
readonly target: TTarget
|
|
81
|
-
readonly artifact: TArtifact
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** The provider-neutral run shape stored and passed by the control plane. */
|
|
85
|
-
export interface RuntimeProviderRun extends ProviderRunBase {
|
|
86
|
-
readonly target: ProviderEnvelope
|
|
87
|
-
readonly artifact: ProviderEnvelope
|
|
88
|
-
}
|
package/src/provider.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type { ProviderDeploySession, RuntimeProviderRun, TypedProviderRun } from './deploy'
|
|
2
|
-
import type { ProviderCodec, ProviderEnvelope } from './json'
|
|
3
|
-
|
|
4
|
-
/** A provider implementation with its target and artifact types preserved. */
|
|
5
|
-
export interface ProviderDefinition<TId extends string, TTarget, TArtifact> {
|
|
6
|
-
readonly id: TId
|
|
7
|
-
readonly target: ProviderCodec<TTarget>
|
|
8
|
-
readonly artifact: ProviderCodec<TArtifact>
|
|
9
|
-
open(run: TypedProviderRun<TTarget, TArtifact>): Promise<ProviderDeploySession>
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** The opaque interface consumed by a provider-specific composition root. */
|
|
13
|
-
export interface RuntimeProvider {
|
|
14
|
-
readonly id: string
|
|
15
|
-
open(run: RuntimeProviderRun): Promise<ProviderDeploySession>
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* A provider module exposes typed encoders to its authoring side and an opaque runtime adapter to
|
|
20
|
-
* its control-plane side.
|
|
21
|
-
*/
|
|
22
|
-
export interface ProviderModule<TId extends string, TTarget, TArtifact> {
|
|
23
|
-
readonly id: TId
|
|
24
|
-
encodeTarget(value: TTarget): ProviderEnvelope
|
|
25
|
-
encodeArtifact(value: TArtifact): ProviderEnvelope
|
|
26
|
-
readonly runtime: RuntimeProvider
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const decodeEnvelope = <T>(kind: string, provider: string, envelope: ProviderEnvelope, codec: ProviderCodec<T>): T => {
|
|
30
|
-
if (envelope.provider !== provider) {
|
|
31
|
-
throw new Error(`${kind} belongs to provider "${envelope.provider}", expected "${provider}"`)
|
|
32
|
-
}
|
|
33
|
-
if (envelope.version !== codec.version) {
|
|
34
|
-
throw new Error(`${kind} schema version ${envelope.version} is not supported by provider "${provider}"`)
|
|
35
|
-
}
|
|
36
|
-
return codec.decode(envelope.payload)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Closes over a provider's concrete types and exposes only JSON envelopes at runtime.
|
|
41
|
-
*
|
|
42
|
-
* A new provider calls this factory in its own package. No central platform map or discriminated
|
|
43
|
-
* union changes when a provider id is added.
|
|
44
|
-
*/
|
|
45
|
-
export const createProvider = <TId extends string, TTarget, TArtifact>(
|
|
46
|
-
definition: ProviderDefinition<TId, TTarget, TArtifact>,
|
|
47
|
-
): ProviderModule<TId, TTarget, TArtifact> => {
|
|
48
|
-
const envelope = <T>(codec: ProviderCodec<T>, value: T): ProviderEnvelope => ({
|
|
49
|
-
provider: definition.id,
|
|
50
|
-
version: codec.version,
|
|
51
|
-
payload: codec.encode(value),
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
id: definition.id,
|
|
56
|
-
encodeTarget: (value) => envelope(definition.target, value),
|
|
57
|
-
encodeArtifact: (value) => envelope(definition.artifact, value),
|
|
58
|
-
runtime: {
|
|
59
|
-
id: definition.id,
|
|
60
|
-
open: async (run) =>
|
|
61
|
-
definition.open({
|
|
62
|
-
appId: run.appId,
|
|
63
|
-
env: run.env,
|
|
64
|
-
domain: run.domain,
|
|
65
|
-
returnOrigins: run.returnOrigins,
|
|
66
|
-
cwd: run.cwd,
|
|
67
|
-
secrets: run.secrets,
|
|
68
|
-
vars: run.vars,
|
|
69
|
-
managedEnvironment: run.managedEnvironment,
|
|
70
|
-
dryRun: run.dryRun,
|
|
71
|
-
signal: run.signal,
|
|
72
|
-
events: run.events,
|
|
73
|
-
target: decodeEnvelope('target', definition.id, run.target, definition.target),
|
|
74
|
-
artifact: decodeEnvelope('artifact', definition.id, run.artifact, definition.artifact),
|
|
75
|
-
}),
|
|
76
|
-
},
|
|
77
|
-
}
|
|
78
|
-
}
|