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