@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.30
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/README.md +163 -78
- package/manifest.schema.json +300 -16
- package/package.json +6 -4
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +98 -64
- package/src/build/cli.ts +33 -23
- package/src/build/elf.ts +16 -7
- package/src/build/hashes.ts +40 -51
- package/src/build/project.ts +55 -52
- package/src/builder/pipeline.ts +348 -89
- package/src/builder/refs.ts +15 -5
- package/src/core/brands.ts +6 -5
- package/src/core/config-refs.ts +103 -0
- package/src/core/config.ts +224 -0
- package/src/core/external.ts +86 -0
- package/src/core/handlers.ts +62 -15
- package/src/core/matrix.ts +150 -0
- package/src/core/models.ts +37 -15
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +113 -38
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +119 -42
- package/src/ctx/index.ts +103 -38
- package/src/ctx/links.ts +30 -0
- package/src/ctx/sh.ts +104 -44
- package/src/ctx/types.ts +24 -4
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +187 -0
- package/src/describe/describe.ts +63 -355
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/manifest-schema.ts +304 -61
- package/src/describe/mapper-source.ts +252 -105
- package/src/describe/record.ts +67 -37
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +166 -0
- package/src/describe/step.ts +544 -0
- package/src/describe/triggers.ts +72 -0
- package/src/dev/cli.ts +62 -35
- package/src/harness/env.ts +47 -25
- package/src/harness/execute.ts +74 -30
- package/src/harness/exit.ts +1 -1
- package/src/harness/index.ts +19 -8
- package/src/harness/main.ts +234 -98
- package/src/harness/result.ts +45 -24
- package/src/harness/validate.ts +82 -15
- package/src/index.ts +139 -10
- package/src/library/checks.ts +11 -6
- package/src/library/compose-deploy.ts +115 -0
- package/src/library/github-source.ts +100 -47
- package/src/library/helm-deploy-ssh.ts +426 -112
- package/src/library/helm-deploy.ts +529 -0
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +510 -162
- package/src/library/index.ts +48 -14
- package/src/library/models.ts +35 -14
- package/src/library/ssh.ts +44 -18
- package/src/testing/index.ts +100 -35
- package/src/util/canonical.ts +7 -4
- package/src/util/code-hash.ts +17 -0
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +83 -38
- package/src/library/script.ts +0 -85
- package/src/util/handler-hash.ts +0 -22
|
@@ -1,29 +1,71 @@
|
|
|
1
1
|
// steps.imageBuild — build a container image from a `Source` with kaniko: to an image tarball that
|
|
2
2
|
// travels as a file ref (a deploy imports it on the host), or pushed to a registry (`push: true`).
|
|
3
3
|
// Ported from v1's olve-lib.sh (olve_kaniko_build + build.sh's layer cache); footguns kept below.
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import type { Ctx, RunInfo } from '../ctx/types.ts';
|
|
15
|
+
import type { FileRef } from '../core/models.ts';
|
|
16
|
+
import type { Secret } from '../core/secrets.ts';
|
|
17
|
+
import { isConfigRef, type ConfigRef, type Configurable } from '../core/config.ts';
|
|
18
|
+
import { isLiteral, resolveConfigRefs } from '../core/config-refs.ts';
|
|
19
|
+
import { implement, type Implementation } from '../core/handlers.ts';
|
|
20
|
+
import { step, type LiteralName, type Step } from '../core/steps.ts';
|
|
21
|
+
import { sha256File, sha256Hex } from '../util/hash.ts';
|
|
22
|
+
import { dirEntries, writeTar } from '../util/tar.ts';
|
|
23
|
+
import { BuiltImage, PushedImage, Source } from './models.ts';
|
|
24
|
+
|
|
25
|
+
/** kaniko's debug image, pinned by digest. The handler needs its busybox for `mkdir` and `cp`. */
|
|
26
|
+
export const KANIKO_IMAGE =
|
|
27
|
+
'gcr.io/kaniko-project/executor:v1.24.0-debug@sha256:2562c4fe551399514277ffff7dcca9a3b1628c4ea38cb017d7286dc6ea52f4cd';
|
|
28
|
+
|
|
29
|
+
/** The executor flags every build uses; part of `BUILD_RECIPE`. */
|
|
30
|
+
export const KANIKO_BUILD_FLAGS: readonly string[] = [
|
|
31
|
+
'--snapshot-mode=redo',
|
|
32
|
+
'--use-new-run',
|
|
33
|
+
'--compressed-caching=false',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What decides the built image besides its context: the executor and its flags. A content tag hashes
|
|
38
|
+
* it with the context, so a change here rebuilds every `contentTag` image once instead of reusing an
|
|
39
|
+
* image another build produced.
|
|
40
|
+
*/
|
|
41
|
+
export const BUILD_RECIPE = JSON.stringify({
|
|
42
|
+
kaniko: KANIKO_IMAGE,
|
|
43
|
+
flags: KANIKO_BUILD_FLAGS,
|
|
44
|
+
});
|
|
15
45
|
|
|
16
46
|
/**
|
|
17
47
|
* Footgun: the build context must live under /kaniko, NOT /workspace (or another plain directory in
|
|
18
48
|
* the container): kaniko wipes the filesystem root between the stages of a multi-stage build, which
|
|
19
49
|
* breaks a later stage's COPY from the context. /kaniko survives stage transitions.
|
|
20
50
|
*/
|
|
21
|
-
export const KANIKO_CONTEXT =
|
|
51
|
+
export const KANIKO_CONTEXT = '/kaniko/build-context';
|
|
22
52
|
|
|
23
53
|
const input = { source: Source };
|
|
24
54
|
export type ImageBuildInput = typeof input;
|
|
25
55
|
|
|
26
|
-
|
|
56
|
+
/** Credentials for the registry of `repository`: a user name and a secret holding the password or token. */
|
|
57
|
+
export interface RegistryAuth<K extends string> {
|
|
58
|
+
username: string;
|
|
59
|
+
password: Secret<K>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Registry credentials as config: the password is a `secretRef` (`{ secret: NAME }`) the step must declare in `secrets`. */
|
|
63
|
+
export interface RegistryAuthConfig {
|
|
64
|
+
username: string;
|
|
65
|
+
password: { secret: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ImageBuildDef<N extends string, K extends string = never> {
|
|
27
69
|
name: LiteralName<N>;
|
|
28
70
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
29
71
|
description: string;
|
|
@@ -31,23 +73,15 @@ export interface ImageBuildDef<N extends string> {
|
|
|
31
73
|
* Image name, e.g. `olve-pipelines-v2`; with `push`, the registry repository to push to
|
|
32
74
|
* (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
|
|
33
75
|
*/
|
|
34
|
-
repository: string
|
|
35
|
-
/** Build context: a directory of the source tree (default: the root). */
|
|
76
|
+
repository: Configurable<string>;
|
|
77
|
+
/** Build context: a directory of the source tree with a `Dockerfile` (default: the root). */
|
|
36
78
|
context?: string;
|
|
37
|
-
/** Dockerfile path, relative to the context (default `Dockerfile`). */
|
|
38
|
-
dockerfile?: string;
|
|
39
|
-
/**
|
|
40
|
-
* Registry repository for kaniko's layer cache (`--cache=true --cache-repo=…`), so unchanged
|
|
41
|
-
* layers (restore, apt, npm, …) are reused. kaniko pushes cache layers even with `--no-push`.
|
|
42
|
-
* A registry without GC grows with every changed layer — prune it when needed.
|
|
43
|
-
*/
|
|
44
|
-
cacheRepo?: string;
|
|
45
79
|
/**
|
|
46
80
|
* kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
|
|
47
81
|
* the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
|
|
48
82
|
* a map replaces the default entirely, `{}` turns mapping off.
|
|
49
83
|
*/
|
|
50
|
-
registryMap?: Record<string, string>;
|
|
84
|
+
registryMap?: Configurable<Record<string, string> | undefined>;
|
|
51
85
|
timeoutSeconds?: number;
|
|
52
86
|
/**
|
|
53
87
|
* Kill kaniko when it writes no output for this long (default 300 s; 0 disables), failing the
|
|
@@ -62,10 +96,30 @@ export interface ImageBuildDef<N extends string> {
|
|
|
62
96
|
* A `--no-push` build is idempotent, so the default is 2 (three tries).
|
|
63
97
|
*/
|
|
64
98
|
retries?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Log in to the registry of `repository` (e.g. `ghcr.io` with a token that may write packages).
|
|
101
|
+
* Used for kaniko's push, base image and cache pulls from that registry, and the `contentTag` lookup.
|
|
102
|
+
*/
|
|
103
|
+
registryAuth?: RegistryAuth<K> | ConfigRef<RegistryAuthConfig | undefined>;
|
|
104
|
+
/**
|
|
105
|
+
* kaniko's layer cache: a repository it reuses unchanged layers from and pushes new ones to. Default
|
|
106
|
+
* `<repository>/cache` when pushing, none for a tarball build; `false` turns it off. Login and plain
|
|
107
|
+
* HTTP apply when it is on `repository`'s registry. An unreachable cache is skipped, never a failure.
|
|
108
|
+
*/
|
|
109
|
+
cacheRepo?: Configurable<string | false | undefined>;
|
|
110
|
+
/** The registry of `repository` speaks plain HTTP (a local `registry:2`). */
|
|
111
|
+
insecureRegistry?: Configurable<boolean | undefined>;
|
|
112
|
+
/** Secrets a config-referenced `registryAuth` may name (the step declares them). */
|
|
113
|
+
secrets?: readonly Secret<K>[];
|
|
114
|
+
/** `false` skips the step (`ctx.skip`), e.g. `enabled: PUBLISH.harness.push`. Default true. */
|
|
115
|
+
enabled?: Configurable<boolean | undefined>;
|
|
65
116
|
}
|
|
66
117
|
|
|
67
118
|
/** Tarball mode (default): output `BuiltImage` with the image tar as a file. */
|
|
68
|
-
export interface ImageBuildTarballDef<
|
|
119
|
+
export interface ImageBuildTarballDef<
|
|
120
|
+
N extends string,
|
|
121
|
+
K extends string = never,
|
|
122
|
+
> extends ImageBuildDef<N, K> {
|
|
69
123
|
push?: false;
|
|
70
124
|
contentTag?: never;
|
|
71
125
|
}
|
|
@@ -74,7 +128,10 @@ export interface ImageBuildTarballDef<N extends string> extends ImageBuildDef<N>
|
|
|
74
128
|
* Registry mode: push to `repository` and output `PushedImage`, for images too big to travel as a
|
|
75
129
|
* tarball (the step's scratch space is ~1 GiB).
|
|
76
130
|
*/
|
|
77
|
-
export interface ImageBuildPushDef<
|
|
131
|
+
export interface ImageBuildPushDef<
|
|
132
|
+
N extends string,
|
|
133
|
+
K extends string = never,
|
|
134
|
+
> extends ImageBuildDef<N, K> {
|
|
78
135
|
push: true;
|
|
79
136
|
/**
|
|
80
137
|
* Tag the image `src-<hash>`, the hash of the build context's content, and skip the build when the
|
|
@@ -83,28 +140,30 @@ export interface ImageBuildPushDef<N extends string> extends ImageBuildDef<N> {
|
|
|
83
140
|
contentTag?: boolean;
|
|
84
141
|
}
|
|
85
142
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
* can stall forever pulling from mcr.microsoft.com directly.
|
|
89
|
-
*/
|
|
90
|
-
export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {
|
|
91
|
-
"mcr.microsoft.com": "mcr-cache.ovea.pro",
|
|
92
|
-
"index.docker.io": "hub-cache.ovea.pro",
|
|
93
|
-
};
|
|
143
|
+
/** Default `registryMap`: none, kaniko pulls from the registries themselves. */
|
|
144
|
+
export const DEFAULT_REGISTRY_MAP: Readonly<Record<string, string>> = {};
|
|
94
145
|
|
|
95
146
|
/** How long a cache registry gets to answer `GET /v2/` before the build goes without it. */
|
|
96
147
|
export const CACHE_PROBE_TIMEOUT_MS = 5000;
|
|
97
148
|
|
|
98
149
|
/** The registry host of an image reference or mirror prefix (`registry.ovea.pro/a/b` → `registry.ovea.pro`). */
|
|
99
150
|
export function registryHost(ref: string): string {
|
|
100
|
-
return ref.split(
|
|
151
|
+
return ref.split('/')[0]!;
|
|
101
152
|
}
|
|
102
153
|
|
|
103
154
|
/** Whether a registry answers its API root: 200, or 401 (it wants a token). Never throws. */
|
|
104
|
-
export async function registryUp(
|
|
155
|
+
export async function registryUp(
|
|
156
|
+
http: Ctx['http'],
|
|
157
|
+
host: string,
|
|
158
|
+
signal?: AbortSignal,
|
|
159
|
+
timeoutMs = CACHE_PROBE_TIMEOUT_MS,
|
|
160
|
+
insecure = false,
|
|
161
|
+
): Promise<boolean> {
|
|
105
162
|
try {
|
|
106
163
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
107
|
-
const res = await http.fetch(
|
|
164
|
+
const res = await http.fetch(`${insecure ? 'http' : 'https'}://${host}/v2/`, {
|
|
165
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
166
|
+
});
|
|
108
167
|
await res.body?.cancel().catch(() => {});
|
|
109
168
|
return res.status === 200 || res.status === 401;
|
|
110
169
|
} catch {
|
|
@@ -112,26 +171,52 @@ export async function registryUp(http: Ctx["http"], host: string, signal?: Abort
|
|
|
112
171
|
}
|
|
113
172
|
}
|
|
114
173
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
174
|
+
/** The registry map without the mirrors that don't answer, so a mirror outage (GC, maintenance) never blocks a build. */
|
|
175
|
+
export async function reachableMirrors(
|
|
176
|
+
ctx: Pick<Ctx, 'http' | 'log' | 'signal'>,
|
|
177
|
+
registryMap: Record<string, string>,
|
|
178
|
+
insecureHosts: readonly string[] = [],
|
|
179
|
+
): Promise<Record<string, string>> {
|
|
180
|
+
const hosts = new Set(Object.values(registryMap).map(registryHost));
|
|
181
|
+
const up = new Map(
|
|
182
|
+
await Promise.all(
|
|
183
|
+
[...hosts].map(
|
|
184
|
+
async (h) =>
|
|
185
|
+
[
|
|
186
|
+
h,
|
|
187
|
+
await registryUp(
|
|
188
|
+
ctx.http,
|
|
189
|
+
h,
|
|
190
|
+
ctx.signal,
|
|
191
|
+
undefined,
|
|
192
|
+
insecureHosts.includes(h),
|
|
193
|
+
),
|
|
194
|
+
] as const,
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
const reachable: Record<string, string> = {};
|
|
199
|
+
for (const [from, to] of Object.entries(registryMap)) {
|
|
200
|
+
if (up.get(registryHost(to))) reachable[from] = to;
|
|
128
201
|
else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
|
|
129
202
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
203
|
+
return reachable;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The layer cache repository when its registry answers, else nothing (logged). */
|
|
207
|
+
export async function reachableCache(
|
|
208
|
+
ctx: Pick<Ctx, 'http' | 'log' | 'signal'>,
|
|
209
|
+
cacheRepo: string | false,
|
|
210
|
+
insecureHosts: readonly string[] = [],
|
|
211
|
+
): Promise<string | undefined> {
|
|
212
|
+
if (!cacheRepo) return undefined;
|
|
213
|
+
const host = registryHost(cacheRepo);
|
|
214
|
+
if (
|
|
215
|
+
await registryUp(ctx.http, host, ctx.signal, undefined, insecureHosts.includes(host))
|
|
216
|
+
)
|
|
217
|
+
return cacheRepo;
|
|
218
|
+
ctx.log(`${host} unreachable; building without the layer cache`);
|
|
219
|
+
return undefined;
|
|
135
220
|
}
|
|
136
221
|
|
|
137
222
|
/** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
|
|
@@ -145,146 +230,409 @@ export const DEFAULT_STALL_TIMEOUT_SECONDS = 300;
|
|
|
145
230
|
* must not reuse the tag: deploys import it with `pullPolicy=Never`, so an unchanged tag would leave
|
|
146
231
|
* the old pods running.
|
|
147
232
|
*/
|
|
148
|
-
export function imageTag(run: Pick<RunInfo,
|
|
233
|
+
export function imageTag(run: Pick<RunInfo, 'version' | 'attempt'>): string {
|
|
149
234
|
const tag = run.attempt > 1 ? `${run.version}-attempt${run.attempt}` : run.version;
|
|
150
|
-
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag))
|
|
235
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag))
|
|
236
|
+
throw new Error(`'${tag}' (from the run's version stamp) is not a valid image tag`);
|
|
151
237
|
return tag;
|
|
152
238
|
}
|
|
153
239
|
|
|
154
240
|
/** The name a container runtime gives an image after loading it (`x` → `docker.io/library/x`). */
|
|
155
241
|
export function qualifyRepository(repository: string): string {
|
|
156
|
-
const first = repository.split(
|
|
157
|
-
if (!repository.includes(
|
|
158
|
-
if (first.includes(
|
|
242
|
+
const first = repository.split('/')[0]!;
|
|
243
|
+
if (!repository.includes('/')) return `docker.io/library/${repository}`;
|
|
244
|
+
if (first.includes('.') || first.includes(':') || first === 'localhost')
|
|
245
|
+
return repository;
|
|
159
246
|
return `docker.io/${repository}`;
|
|
160
247
|
}
|
|
161
248
|
|
|
162
249
|
/** A relative path inside the source tree (no `..`, not absolute). */
|
|
163
250
|
function checkRelative(what: string, path: string): string {
|
|
164
|
-
const clean = path.replace(/^\.\/+/,
|
|
165
|
-
if (clean.startsWith(
|
|
166
|
-
|
|
251
|
+
const clean = path.replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
252
|
+
if (clean.startsWith('/') || clean.split('/').includes('..'))
|
|
253
|
+
throw new Error(
|
|
254
|
+
`imageBuild ${what} '${path}' must be a relative path inside the source tree`,
|
|
255
|
+
);
|
|
256
|
+
return clean === '.' ? '' : clean;
|
|
167
257
|
}
|
|
168
258
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
259
|
+
/**
|
|
260
|
+
* The `src-<12 hex>` tag of a build context: the sha256 of the build recipe and of the directory's
|
|
261
|
+
* deterministic tar (as `ctx.output.dir` writes it).
|
|
262
|
+
*/
|
|
263
|
+
export function contentTagOf(dir: string, tmpDir: string, recipe = BUILD_RECIPE): string {
|
|
171
264
|
mkdirSync(tmpDir, { recursive: true });
|
|
172
|
-
const work = mkdtempSync(join(tmpDir,
|
|
265
|
+
const work = mkdtempSync(join(tmpDir, 'content-tag-'));
|
|
173
266
|
try {
|
|
174
|
-
const tar = join(work,
|
|
267
|
+
const tar = join(work, 'context.tar');
|
|
175
268
|
writeTar(tar, dirEntries(dir));
|
|
176
|
-
return `src-${sha256File(tar).slice(0, 12)}`;
|
|
269
|
+
return `src-${sha256Hex(`${recipe}\n${sha256File(tar)}`).slice(0, 12)}`;
|
|
177
270
|
} finally {
|
|
178
271
|
rmSync(work, { recursive: true, force: true });
|
|
179
272
|
}
|
|
180
273
|
}
|
|
181
274
|
|
|
182
275
|
const MANIFEST_TYPES = [
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
].join(
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
export
|
|
191
|
-
|
|
192
|
-
|
|
276
|
+
'application/vnd.oci.image.index.v1+json',
|
|
277
|
+
'application/vnd.oci.image.manifest.v1+json',
|
|
278
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
279
|
+
'application/vnd.docker.distribution.manifest.v2+json',
|
|
280
|
+
].join(', ');
|
|
281
|
+
|
|
282
|
+
/** Options of a registry API call: login (`username`/`password`) and plain HTTP. */
|
|
283
|
+
export interface RegistryAccess {
|
|
284
|
+
username?: string;
|
|
285
|
+
password?: string;
|
|
286
|
+
insecure?: boolean;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const basic = (username: string, password: string) =>
|
|
290
|
+
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
291
|
+
|
|
292
|
+
/** `Bearer realm="…",service="…",scope="…"` → its parameters. */
|
|
293
|
+
export function parseChallenge(header: string): {
|
|
294
|
+
scheme: string;
|
|
295
|
+
params: Record<string, string>;
|
|
296
|
+
} {
|
|
297
|
+
const scheme = header.trim().split(/\s+/, 1)[0]!.toLowerCase();
|
|
298
|
+
const params: Record<string, string> = {};
|
|
299
|
+
for (const m of header.slice(scheme.length).matchAll(/([A-Za-z]+)="([^"]*)"/g))
|
|
300
|
+
params[m[1]!.toLowerCase()] = m[2]!;
|
|
301
|
+
return { scheme, params };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* `HEAD`/`GET` against a registry API path, answering its auth challenge: Basic (a `registry:2` with
|
|
306
|
+
* htpasswd) or a Bearer token from the realm (ghcr.io, Docker Hub; anonymous for public images).
|
|
307
|
+
*/
|
|
308
|
+
async function registryFetch(
|
|
309
|
+
http: Ctx['http'],
|
|
310
|
+
host: string,
|
|
311
|
+
path: string,
|
|
312
|
+
init: RequestInit,
|
|
313
|
+
access: RegistryAccess,
|
|
314
|
+
): Promise<Response> {
|
|
315
|
+
const url = `${access.insecure ? 'http' : 'https'}://${host}${path}`;
|
|
316
|
+
const res = await http.fetch(url, init);
|
|
317
|
+
if (res.status !== 401) return res;
|
|
318
|
+
await res.body?.cancel().catch(() => {});
|
|
319
|
+
const { scheme, params } = parseChallenge(res.headers.get('www-authenticate') ?? '');
|
|
320
|
+
const login =
|
|
321
|
+
access.username !== undefined && access.password !== undefined
|
|
322
|
+
? basic(access.username, access.password)
|
|
323
|
+
: undefined;
|
|
324
|
+
let authorization: string;
|
|
325
|
+
if (scheme === 'basic') {
|
|
326
|
+
if (!login) return new Response(null, { status: 401 });
|
|
327
|
+
authorization = login;
|
|
328
|
+
} else if (scheme === 'bearer' && params.realm) {
|
|
329
|
+
const q = new URLSearchParams();
|
|
330
|
+
if (params.service) q.set('service', params.service);
|
|
331
|
+
if (params.scope) q.set('scope', params.scope);
|
|
332
|
+
const tokenRes = await http.fetch(`${params.realm}?${q}`, {
|
|
333
|
+
headers: login ? { Authorization: login } : {},
|
|
334
|
+
});
|
|
335
|
+
if (!tokenRes.ok) {
|
|
336
|
+
await tokenRes.body?.cancel().catch(() => {});
|
|
337
|
+
return new Response(null, {
|
|
338
|
+
status: tokenRes.status === 404 ? 401 : tokenRes.status,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const body = (await tokenRes.json()) as { token?: string; access_token?: string };
|
|
342
|
+
const token = body.token ?? body.access_token;
|
|
343
|
+
if (!token)
|
|
344
|
+
throw new Error(`registry token endpoint ${params.realm} returned no token`);
|
|
345
|
+
authorization = `Bearer ${token}`;
|
|
346
|
+
} else {
|
|
347
|
+
return new Response(null, { status: 401 });
|
|
348
|
+
}
|
|
349
|
+
const headers = new Headers(init.headers);
|
|
350
|
+
headers.set('Authorization', authorization);
|
|
351
|
+
return http.fetch(url, { ...init, headers });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The digest of `repository:tag` in its registry (`HEAD …/manifests/<tag>`), or undefined if it has none. */
|
|
355
|
+
export async function registryDigest(
|
|
356
|
+
http: Ctx['http'],
|
|
357
|
+
repository: string,
|
|
358
|
+
tag: string,
|
|
359
|
+
access: RegistryAccess = {},
|
|
360
|
+
): Promise<string | undefined> {
|
|
361
|
+
const [host, ...path] = repository.split('/');
|
|
362
|
+
const res = await registryFetch(
|
|
363
|
+
http,
|
|
364
|
+
host!,
|
|
365
|
+
`/v2/${path.join('/')}/manifests/${tag}`,
|
|
366
|
+
{ method: 'HEAD', headers: { Accept: MANIFEST_TYPES } },
|
|
367
|
+
access,
|
|
368
|
+
);
|
|
193
369
|
await res.body?.cancel().catch(() => {});
|
|
194
370
|
if (res.status === 404) return undefined;
|
|
195
|
-
|
|
196
|
-
|
|
371
|
+
// An anonymous lookup of a repository that doesn't exist yet (or is private) is denied, not 404.
|
|
372
|
+
if ((res.status === 401 || res.status === 403) && access.password === undefined)
|
|
373
|
+
return undefined;
|
|
374
|
+
if (!res.ok)
|
|
375
|
+
throw new Error(
|
|
376
|
+
`registry lookup of ${repository}:${tag} answered HTTP ${res.status}`,
|
|
377
|
+
);
|
|
378
|
+
const digest = res.headers.get('docker-content-digest') ?? '';
|
|
197
379
|
return /^sha256:[0-9a-f]{64}$/.test(digest) ? digest : undefined;
|
|
198
380
|
}
|
|
199
381
|
|
|
382
|
+
/** kaniko reads registry logins from `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in its image). */
|
|
383
|
+
export const KANIKO_DOCKER_CONFIG = '/kaniko/.docker';
|
|
384
|
+
|
|
385
|
+
/** A docker `config.json` that logs in to `host`. */
|
|
386
|
+
export function dockerConfigJson(
|
|
387
|
+
host: string,
|
|
388
|
+
username: string,
|
|
389
|
+
password: string,
|
|
390
|
+
): string {
|
|
391
|
+
return JSON.stringify({
|
|
392
|
+
auths: {
|
|
393
|
+
[host]: { auth: Buffer.from(`${username}:${password}`).toString('base64') },
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Adds the login for `host` to `<dir>/config.json` (owner-only), keeping what is there (kaniko's credHelpers). */
|
|
399
|
+
export function writeDockerConfig(
|
|
400
|
+
dir: string,
|
|
401
|
+
host: string,
|
|
402
|
+
username: string,
|
|
403
|
+
password: string,
|
|
404
|
+
): void {
|
|
405
|
+
mkdirSync(dir, { recursive: true });
|
|
406
|
+
const file = join(dir, 'config.json');
|
|
407
|
+
let config: { auths?: Record<string, unknown> } = {};
|
|
408
|
+
try {
|
|
409
|
+
config = JSON.parse(readFileSync(file, 'utf8'));
|
|
410
|
+
} catch {
|
|
411
|
+
// absent or unreadable: start fresh
|
|
412
|
+
}
|
|
413
|
+
const login = JSON.parse(dockerConfigJson(host, username, password)) as {
|
|
414
|
+
auths: Record<string, unknown>;
|
|
415
|
+
};
|
|
416
|
+
writeFileSync(
|
|
417
|
+
file,
|
|
418
|
+
JSON.stringify({ ...config, auths: { ...config.auths, ...login.auths } }),
|
|
419
|
+
{ mode: 0o600 },
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
200
423
|
/**
|
|
201
|
-
* Builds an image with kaniko from `<context
|
|
202
|
-
* at the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
|
|
424
|
+
* Builds an image with kaniko from `<context>/Dockerfile` in the source tree (default: the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
|
|
203
425
|
* With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
|
|
204
426
|
*/
|
|
205
|
-
export function imageBuild<const N extends string
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
427
|
+
export function imageBuild<const N extends string, const K extends string = never>(
|
|
428
|
+
def: ImageBuildTarballDef<N, K>,
|
|
429
|
+
): Implementation<Step<N, ImageBuildInput, typeof BuiltImage, K>>;
|
|
430
|
+
export function imageBuild<const N extends string, const K extends string = never>(
|
|
431
|
+
def: ImageBuildPushDef<N, K>,
|
|
432
|
+
): Implementation<Step<N, ImageBuildInput, typeof PushedImage, K>>;
|
|
433
|
+
export function imageBuild<const N extends string, const K extends string = never>(
|
|
434
|
+
def: ImageBuildTarballDef<N, K> | ImageBuildPushDef<N, K>,
|
|
435
|
+
): AnyImageBuild {
|
|
210
436
|
const push = def.push === true;
|
|
211
437
|
const contentTag = push && def.contentTag === true;
|
|
212
|
-
const context = checkRelative(
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
438
|
+
const context = checkRelative('context', def.context ?? '');
|
|
439
|
+
const authSecret =
|
|
440
|
+
def.registryAuth && !isConfigRef(def.registryAuth) ? def.registryAuth : undefined;
|
|
441
|
+
if (isLiteral([def.repository, def.registryAuth, def.insecureRegistry]))
|
|
442
|
+
checkRegistryHost(
|
|
443
|
+
def.name,
|
|
444
|
+
def.repository as string,
|
|
445
|
+
push,
|
|
446
|
+
def.registryAuth !== undefined,
|
|
447
|
+
def.insecureRegistry === true,
|
|
448
|
+
);
|
|
449
|
+
const secrets = [
|
|
450
|
+
...(authSecret ? [authSecret.password] : []),
|
|
451
|
+
...(def.secrets ?? []),
|
|
452
|
+
].filter((s, i, all) => all.findIndex((x) => x.name === s.name) === i);
|
|
453
|
+
const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
|
|
454
|
+
const mapped = isConfigRef(registryMap) || Object.keys(registryMap).length > 0;
|
|
455
|
+
const contract = step({
|
|
456
|
+
name: def.name,
|
|
457
|
+
description: def.description,
|
|
458
|
+
kind: 'imageBuild',
|
|
459
|
+
image: KANIKO_IMAGE,
|
|
460
|
+
input,
|
|
461
|
+
output: push ? PushedImage : BuiltImage,
|
|
462
|
+
secrets,
|
|
463
|
+
timeoutSeconds: def.timeoutSeconds,
|
|
464
|
+
retries: def.retries ?? DEFAULT_RETRIES,
|
|
465
|
+
settings: {
|
|
466
|
+
repository: def.repository,
|
|
467
|
+
...(context ? { context } : {}),
|
|
468
|
+
...(push ? { push: true } : {}),
|
|
469
|
+
...(contentTag ? { contentTag: true } : {}),
|
|
470
|
+
...(mapped ? { registryMap } : {}),
|
|
471
|
+
...(def.cacheRepo !== undefined ? { cacheRepo: def.cacheRepo } : {}),
|
|
472
|
+
...(authSecret
|
|
473
|
+
? {
|
|
474
|
+
registryAuth: {
|
|
475
|
+
username: authSecret.username,
|
|
476
|
+
password: authSecret.password.name,
|
|
477
|
+
},
|
|
240
478
|
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// kaniko images have no /tmp), then into the step's files.
|
|
250
|
-
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
251
|
-
const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
|
|
252
|
-
const tarPath = join(out, "image.tar");
|
|
253
|
-
const digestPath = join(out, "digest");
|
|
254
|
-
const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
|
|
255
|
-
// No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
|
|
256
|
-
// the build context intact. --skip-push-permission-check: with --no-push kaniko otherwise checks
|
|
257
|
-
// the cache repo at startup and exits if it is down; later cache lookup and push errors are
|
|
258
|
-
// only warnings.
|
|
259
|
-
await ctx.exec([
|
|
260
|
-
"/kaniko/executor",
|
|
261
|
-
`--context=${kanikoContext}`,
|
|
262
|
-
`--dockerfile=${kanikoContext}/${dockerfile}`,
|
|
263
|
-
...output,
|
|
264
|
-
`--digest-file=${digestPath}`,
|
|
265
|
-
`--destination=${def.repository}:${tag}`,
|
|
266
|
-
...(caches.cacheRepo ? ["--cache=true", `--cache-repo=${caches.cacheRepo}`, "--skip-push-permission-check"] : []),
|
|
267
|
-
...Object.entries(caches.registryMap).map(([from, to]) => `--registry-map=${from}=${to}`),
|
|
268
|
-
], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
|
|
269
|
-
|
|
270
|
-
let digest: string;
|
|
271
|
-
try {
|
|
272
|
-
digest = readFileSync(digestPath, "utf8").trim();
|
|
273
|
-
} catch {
|
|
274
|
-
throw new Error(`kaniko did not write the image digest to ${digestPath}`);
|
|
275
|
-
}
|
|
276
|
-
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
|
|
277
|
-
if (push) {
|
|
278
|
-
ctx.log("image pushed", { image: `${def.repository}:${tag}`, digest });
|
|
279
|
-
return { repository: def.repository, tag, digest };
|
|
280
|
-
}
|
|
281
|
-
const tar = await ctx.output.file(tarPath, { name: "image.tar" });
|
|
282
|
-
rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
|
|
283
|
-
ctx.log("image built", { image: `${def.repository}:${tag}`, digest });
|
|
284
|
-
return { repository: qualifyRepository(def.repository), tag, digest, tar };
|
|
285
|
-
},
|
|
479
|
+
: {}),
|
|
480
|
+
...(def.registryAuth && isConfigRef(def.registryAuth)
|
|
481
|
+
? { registryAuth: def.registryAuth }
|
|
482
|
+
: {}),
|
|
483
|
+
...(def.insecureRegistry !== undefined && def.insecureRegistry !== false
|
|
484
|
+
? { insecureRegistry: def.insecureRegistry }
|
|
485
|
+
: {}),
|
|
486
|
+
...(def.enabled !== undefined ? { enabled: def.enabled } : {}),
|
|
286
487
|
},
|
|
287
|
-
)
|
|
488
|
+
});
|
|
489
|
+
return implement(contract, async ({ source }, ctx: Ctx) => {
|
|
490
|
+
const d = resolveConfigRefs(
|
|
491
|
+
{
|
|
492
|
+
repository: def.repository,
|
|
493
|
+
registryMap: def.registryMap,
|
|
494
|
+
cacheRepo: def.cacheRepo,
|
|
495
|
+
insecureRegistry: def.insecureRegistry,
|
|
496
|
+
registryAuth: def.registryAuth,
|
|
497
|
+
enabled: def.enabled,
|
|
498
|
+
},
|
|
499
|
+
ctx.config ?? {},
|
|
500
|
+
);
|
|
501
|
+
const repository = d.repository as string;
|
|
502
|
+
if (d.enabled === false) return ctx.skip(`publishing ${repository} is turned off`);
|
|
503
|
+
const insecure = d.insecureRegistry === true;
|
|
504
|
+
const auth = registryLogin(
|
|
505
|
+
d.registryAuth as RegistryAuth<string> | RegistryAuthConfig | undefined,
|
|
506
|
+
ctx,
|
|
507
|
+
secrets.map((s) => s.name),
|
|
508
|
+
);
|
|
509
|
+
checkRegistryHost(def.name, repository, push, auth !== undefined, insecure);
|
|
510
|
+
const host = registryHost(repository);
|
|
511
|
+
|
|
512
|
+
const tree = await ctx.files.fetch(source.tree);
|
|
513
|
+
const contextDir = context ? join(tree, context) : tree;
|
|
514
|
+
if (!existsSync(contextDir) || !statSync(contextDir).isDirectory())
|
|
515
|
+
throw new Error(`build context '${context}' not found in the source tree`);
|
|
516
|
+
if (!existsSync(join(contextDir, 'Dockerfile')))
|
|
517
|
+
throw new Error(
|
|
518
|
+
`Dockerfile not found in the build context${context ? ` '${context}'` : ''}`,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const access: RegistryAccess = { insecure, ...(auth ?? {}) };
|
|
522
|
+
if (auth)
|
|
523
|
+
writeDockerConfig(
|
|
524
|
+
process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG,
|
|
525
|
+
host,
|
|
526
|
+
auth.username,
|
|
527
|
+
auth.password,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
|
|
531
|
+
if (contentTag) {
|
|
532
|
+
const digest = await registryDigest(ctx.http, repository, tag, access);
|
|
533
|
+
if (digest) {
|
|
534
|
+
ctx.log('image already in the registry; build skipped', {
|
|
535
|
+
image: `${repository}:${tag}`,
|
|
536
|
+
digest,
|
|
537
|
+
});
|
|
538
|
+
return { repository, tag, digest, cached: true };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const mirrors = await reachableMirrors(
|
|
543
|
+
ctx,
|
|
544
|
+
(d.registryMap as Record<string, string> | undefined) ?? DEFAULT_REGISTRY_MAP,
|
|
545
|
+
insecure ? [host] : [],
|
|
546
|
+
);
|
|
547
|
+
const cache = await reachableCache(
|
|
548
|
+
ctx,
|
|
549
|
+
(d.cacheRepo as string | false | undefined) ??
|
|
550
|
+
(push ? `${repository}/cache` : false),
|
|
551
|
+
insecure ? [host] : [],
|
|
552
|
+
);
|
|
553
|
+
await ctx.exec(['mkdir', '-p', KANIKO_CONTEXT]);
|
|
554
|
+
await ctx.exec(['cp', '-r', `${tree}/.`, KANIKO_CONTEXT]);
|
|
555
|
+
const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
|
|
556
|
+
|
|
557
|
+
// Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
|
|
558
|
+
// kaniko images have no /tmp), then into the step's files.
|
|
559
|
+
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
560
|
+
const out = mkdtempSync(join(ctx.tmpDir, 'image-build-'));
|
|
561
|
+
const tarPath = join(out, 'image.tar');
|
|
562
|
+
const digestPath = join(out, 'digest');
|
|
563
|
+
const output = push ? [] : ['--no-push', `--tar-path=${tarPath}`];
|
|
564
|
+
// No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
|
|
565
|
+
// the build context intact.
|
|
566
|
+
await ctx.exec(
|
|
567
|
+
[
|
|
568
|
+
'/kaniko/executor',
|
|
569
|
+
`--context=${kanikoContext}`,
|
|
570
|
+
`--dockerfile=${kanikoContext}/Dockerfile`,
|
|
571
|
+
...output,
|
|
572
|
+
`--digest-file=${digestPath}`,
|
|
573
|
+
`--destination=${repository}:${tag}`,
|
|
574
|
+
...(insecure ? ['--insecure', `--insecure-registry=${host}`] : []),
|
|
575
|
+
...KANIKO_BUILD_FLAGS,
|
|
576
|
+
...(cache ? ['--cache=true', `--cache-repo=${cache}`] : []),
|
|
577
|
+
...Object.entries(mirrors).map(([from, to]) => `--registry-map=${from}=${to}`),
|
|
578
|
+
],
|
|
579
|
+
{ idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS },
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
let digest: string;
|
|
583
|
+
try {
|
|
584
|
+
digest = readFileSync(digestPath, 'utf8').trim();
|
|
585
|
+
} catch {
|
|
586
|
+
throw new Error(`kaniko did not write the image digest to ${digestPath}`);
|
|
587
|
+
}
|
|
588
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(digest))
|
|
589
|
+
throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
|
|
590
|
+
if (push) {
|
|
591
|
+
ctx.log('image pushed', { image: `${repository}:${tag}`, digest });
|
|
592
|
+
return { repository, tag, digest, cached: false };
|
|
593
|
+
}
|
|
594
|
+
const tar = await ctx.output.file(tarPath, { name: 'image.tar' });
|
|
595
|
+
rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
|
|
596
|
+
ctx.log('image built', { image: `${repository}:${tag}`, digest });
|
|
597
|
+
return { repository: qualifyRepository(repository), tag, digest, tar };
|
|
598
|
+
}) as never;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function checkRegistryHost(
|
|
602
|
+
name: string,
|
|
603
|
+
repository: string,
|
|
604
|
+
push: boolean,
|
|
605
|
+
auth: boolean,
|
|
606
|
+
insecure: boolean,
|
|
607
|
+
): void {
|
|
608
|
+
const host = registryHost(repository);
|
|
609
|
+
if (push && !/[.:]|^localhost$/.test(host))
|
|
610
|
+
throw new Error(
|
|
611
|
+
`imageBuild '${name}': push needs a repository with a registry host, got '${repository}'`,
|
|
612
|
+
);
|
|
613
|
+
if ((auth || insecure) && !/[.:]|^localhost$/.test(host))
|
|
614
|
+
throw new Error(
|
|
615
|
+
`imageBuild '${name}': registryAuth and insecureRegistry need a repository with a registry host, got '${repository}'`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The login for `registryAuth` (a literal secret or a config `secretRef`), or undefined without one. */
|
|
620
|
+
function registryLogin(
|
|
621
|
+
auth: RegistryAuth<string> | RegistryAuthConfig | undefined,
|
|
622
|
+
ctx: Pick<Ctx, 'secrets'>,
|
|
623
|
+
declared: readonly string[],
|
|
624
|
+
): { username: string; password: string } | undefined {
|
|
625
|
+
if (!auth) return undefined;
|
|
626
|
+
const name = 'name' in auth.password ? auth.password.name : auth.password.secret;
|
|
627
|
+
if (!declared.includes(name))
|
|
628
|
+
throw new Error(
|
|
629
|
+
`registryAuth names secret ${name}, which the step does not declare (add it to secrets)`,
|
|
630
|
+
);
|
|
631
|
+
const password = (ctx.secrets as Record<string, string | undefined>)[name];
|
|
632
|
+
if (!password) throw new Error(`secret ${name} (registryAuth) is not set`);
|
|
633
|
+
return { username: auth.username, password };
|
|
288
634
|
}
|
|
289
635
|
|
|
290
|
-
type AnyImageBuild =
|
|
636
|
+
type AnyImageBuild = Implementation<
|
|
637
|
+
Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, any>
|
|
638
|
+
>;
|