@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.6
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 +59 -13
- package/manifest.schema.json +87 -9
- package/package.json +1 -1
- package/src/builder/pipeline.ts +55 -2
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +3 -3
- package/src/core/steps.ts +29 -6
- package/src/ctx/index.ts +3 -0
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +182 -8
- package/src/describe/manifest-schema.ts +36 -3
- package/src/dev/cli.ts +10 -3
- package/src/harness/execute.ts +7 -4
- package/src/harness/main.ts +45 -6
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +3 -2
- package/src/library/compose-deploy.ts +93 -0
- package/src/library/github-source.ts +23 -4
- package/src/library/helm-deploy-ssh.ts +166 -58
- package/src/library/helm-deploy.ts +268 -0
- package/src/library/image-build.ts +223 -39
- package/src/library/index.ts +7 -2
- package/src/library/models.ts +7 -1
- package/src/library/npm.ts +33 -0
- package/src/testing/index.ts +5 -3
|
@@ -1,10 +1,13 @@
|
|
|
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 { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
7
7
|
import type { FileRef } from "../core/models.ts";
|
|
8
|
+
import type { Secret } from "../core/secrets.ts";
|
|
9
|
+
import { isConfigRef, type ConfigRef, type Configurable } from "../core/config.ts";
|
|
10
|
+
import { isLiteral, recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
|
|
8
11
|
import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
9
12
|
import { sha256File } from "../util/hash.ts";
|
|
10
13
|
import { dirEntries, writeTar } from "../util/tar.ts";
|
|
@@ -23,7 +26,19 @@ export const KANIKO_CONTEXT = "/kaniko/build-context";
|
|
|
23
26
|
const input = { source: Source };
|
|
24
27
|
export type ImageBuildInput = typeof input;
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
/** Credentials for the registry of `repository`: a user name and a secret holding the password or token. */
|
|
30
|
+
export interface RegistryAuth<K extends string> {
|
|
31
|
+
username: string;
|
|
32
|
+
password: Secret<K>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Registry credentials as config: the password is a `secretRef` (`{ secret: NAME }`) the step must declare in `secrets`. */
|
|
36
|
+
export interface RegistryAuthConfig {
|
|
37
|
+
username: string;
|
|
38
|
+
password: { secret: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ImageBuildDef<N extends string, K extends string = never> {
|
|
27
42
|
name: LiteralName<N>;
|
|
28
43
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
29
44
|
description: string;
|
|
@@ -31,7 +46,7 @@ export interface ImageBuildDef<N extends string> {
|
|
|
31
46
|
* Image name, e.g. `olve-pipelines-v2`; with `push`, the registry repository to push to
|
|
32
47
|
* (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
|
|
33
48
|
*/
|
|
34
|
-
repository: string
|
|
49
|
+
repository: Configurable<string>;
|
|
35
50
|
/** Build context: a directory of the source tree (default: the root). */
|
|
36
51
|
context?: string;
|
|
37
52
|
/** Dockerfile path, relative to the context (default `Dockerfile`). */
|
|
@@ -41,13 +56,13 @@ export interface ImageBuildDef<N extends string> {
|
|
|
41
56
|
* layers (restore, apt, npm, …) are reused. kaniko pushes cache layers even with `--no-push`.
|
|
42
57
|
* A registry without GC grows with every changed layer — prune it when needed.
|
|
43
58
|
*/
|
|
44
|
-
cacheRepo?: string
|
|
59
|
+
cacheRepo?: Configurable<string | undefined>;
|
|
45
60
|
/**
|
|
46
61
|
* kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
|
|
47
62
|
* the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
|
|
48
63
|
* a map replaces the default entirely, `{}` turns mapping off.
|
|
49
64
|
*/
|
|
50
|
-
registryMap?: Record<string, string>;
|
|
65
|
+
registryMap?: Configurable<Record<string, string> | undefined>;
|
|
51
66
|
timeoutSeconds?: number;
|
|
52
67
|
/**
|
|
53
68
|
* Kill kaniko when it writes no output for this long (default 300 s; 0 disables), failing the
|
|
@@ -56,16 +71,36 @@ export interface ImageBuildDef<N extends string> {
|
|
|
56
71
|
* the longest silent build phase (an AOT "Generating native code" runs ~2 min with no output).
|
|
57
72
|
*/
|
|
58
73
|
stallTimeoutSeconds?: number;
|
|
74
|
+
/**
|
|
75
|
+
* kaniko `--snapshot-mode`: `redo` (default) compares file metadata instead of hashing every file's
|
|
76
|
+
* content after each instruction; `full` hashes content (slower, and what kaniko defaults to).
|
|
77
|
+
*/
|
|
78
|
+
snapshotMode?: "redo" | "full" | "time";
|
|
79
|
+
/** kaniko `--use-new-run`: snapshot only what a RUN changed (faster for big images). Default true. */
|
|
80
|
+
useNewRun?: boolean;
|
|
81
|
+
/** kaniko `--compressed-caching`: compress layers while caching (saves memory, costs time). Default false. */
|
|
82
|
+
compressedCaching?: boolean;
|
|
59
83
|
/**
|
|
60
84
|
* Automatic retries of a failed attempt (a fresh pod: kaniko unpacks the base image over the
|
|
61
85
|
* container root and does not clean up when killed, so retrying inside the same pod is unsafe).
|
|
62
86
|
* A `--no-push` build is idempotent, so the default is 2 (three tries).
|
|
63
87
|
*/
|
|
64
88
|
retries?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Log in to the registry of `repository` (e.g. `ghcr.io` with a token that may write packages).
|
|
91
|
+
* Used for kaniko's push, base image and cache pulls from that registry, and the `contentTag` lookup.
|
|
92
|
+
*/
|
|
93
|
+
registryAuth?: RegistryAuth<K> | ConfigRef<RegistryAuthConfig | undefined>;
|
|
94
|
+
/** The registry of `repository` speaks plain HTTP (a local `registry:2`). */
|
|
95
|
+
insecureRegistry?: Configurable<boolean | undefined>;
|
|
96
|
+
/** Secrets a config-referenced `registryAuth` may name (the step declares them). */
|
|
97
|
+
secrets?: readonly Secret<K>[];
|
|
98
|
+
/** `false` skips the step (`ctx.skip`), e.g. `enabled: PUBLISH.harness.push`. Default true. */
|
|
99
|
+
enabled?: Configurable<boolean | undefined>;
|
|
65
100
|
}
|
|
66
101
|
|
|
67
102
|
/** Tarball mode (default): output `BuiltImage` with the image tar as a file. */
|
|
68
|
-
export interface ImageBuildTarballDef<N extends string> extends ImageBuildDef<N> {
|
|
103
|
+
export interface ImageBuildTarballDef<N extends string, K extends string = never> extends ImageBuildDef<N, K> {
|
|
69
104
|
push?: false;
|
|
70
105
|
contentTag?: never;
|
|
71
106
|
}
|
|
@@ -74,7 +109,7 @@ export interface ImageBuildTarballDef<N extends string> extends ImageBuildDef<N>
|
|
|
74
109
|
* Registry mode: push to `repository` and output `PushedImage`, for images too big to travel as a
|
|
75
110
|
* tarball (the step's scratch space is ~1 GiB).
|
|
76
111
|
*/
|
|
77
|
-
export interface ImageBuildPushDef<N extends string> extends ImageBuildDef<N> {
|
|
112
|
+
export interface ImageBuildPushDef<N extends string, K extends string = never> extends ImageBuildDef<N, K> {
|
|
78
113
|
push: true;
|
|
79
114
|
/**
|
|
80
115
|
* Tag the image `src-<hash>`, the hash of the build context's content, and skip the build when the
|
|
@@ -101,10 +136,10 @@ export function registryHost(ref: string): string {
|
|
|
101
136
|
}
|
|
102
137
|
|
|
103
138
|
/** Whether a registry answers its API root: 200, or 401 (it wants a token). Never throws. */
|
|
104
|
-
export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS): Promise<boolean> {
|
|
139
|
+
export async function registryUp(http: Ctx["http"], host: string, signal?: AbortSignal, timeoutMs = CACHE_PROBE_TIMEOUT_MS, insecure = false): Promise<boolean> {
|
|
105
140
|
try {
|
|
106
141
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
107
|
-
const res = await http.fetch(
|
|
142
|
+
const res = await http.fetch(`${insecure ? "http" : "https"}://${host}/v2/`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
|
|
108
143
|
await res.body?.cancel().catch(() => {});
|
|
109
144
|
return res.status === 200 || res.status === 401;
|
|
110
145
|
} catch {
|
|
@@ -119,9 +154,10 @@ export async function registryUp(http: Ctx["http"], host: string, signal?: Abort
|
|
|
119
154
|
export async function reachableCaches(
|
|
120
155
|
ctx: Pick<Ctx, "http" | "log" | "signal">,
|
|
121
156
|
caches: { registryMap: Record<string, string>; cacheRepo?: string },
|
|
157
|
+
insecureHosts: readonly string[] = [],
|
|
122
158
|
): Promise<{ registryMap: Record<string, string>; cacheRepo?: string }> {
|
|
123
159
|
const hosts = new Set([...Object.values(caches.registryMap), ...(caches.cacheRepo ? [caches.cacheRepo] : [])].map(registryHost));
|
|
124
|
-
const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal)] as const)));
|
|
160
|
+
const up = new Map(await Promise.all([...hosts].map(async (h) => [h, await registryUp(ctx.http, h, ctx.signal, undefined, insecureHosts.includes(h))] as const)));
|
|
125
161
|
const registryMap: Record<string, string> = {};
|
|
126
162
|
for (const [from, to] of Object.entries(caches.registryMap)) {
|
|
127
163
|
if (up.get(registryHost(to))) registryMap[from] = to;
|
|
@@ -134,6 +170,9 @@ export async function reachableCaches(
|
|
|
134
170
|
return { registryMap, ...(caches.cacheRepo ? { cacheRepo: caches.cacheRepo } : {}) };
|
|
135
171
|
}
|
|
136
172
|
|
|
173
|
+
/** Default kaniko `--snapshot-mode`. */
|
|
174
|
+
export const DEFAULT_SNAPSHOT_MODE = "redo";
|
|
175
|
+
|
|
137
176
|
/** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
|
|
138
177
|
export const DEFAULT_RETRIES = 2;
|
|
139
178
|
|
|
@@ -186,61 +225,164 @@ const MANIFEST_TYPES = [
|
|
|
186
225
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
187
226
|
].join(", ");
|
|
188
227
|
|
|
189
|
-
/**
|
|
190
|
-
export
|
|
228
|
+
/** Options of a registry API call: login (`username`/`password`) and plain HTTP. */
|
|
229
|
+
export interface RegistryAccess {
|
|
230
|
+
username?: string;
|
|
231
|
+
password?: string;
|
|
232
|
+
insecure?: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const basic = (username: string, password: string) => `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
236
|
+
|
|
237
|
+
/** `Bearer realm="…",service="…",scope="…"` → its parameters. */
|
|
238
|
+
export function parseChallenge(header: string): { scheme: string; params: Record<string, string> } {
|
|
239
|
+
const scheme = header.trim().split(/\s+/, 1)[0]!.toLowerCase();
|
|
240
|
+
const params: Record<string, string> = {};
|
|
241
|
+
for (const m of header.slice(scheme.length).matchAll(/([A-Za-z]+)="([^"]*)"/g)) params[m[1]!.toLowerCase()] = m[2]!;
|
|
242
|
+
return { scheme, params };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `HEAD`/`GET` against a registry API path, answering its auth challenge: Basic (a `registry:2` with
|
|
247
|
+
* htpasswd) or a Bearer token from the realm (ghcr.io, Docker Hub; anonymous for public images).
|
|
248
|
+
*/
|
|
249
|
+
async function registryFetch(http: Ctx["http"], host: string, path: string, init: RequestInit, access: RegistryAccess): Promise<Response> {
|
|
250
|
+
const url = `${access.insecure ? "http" : "https"}://${host}${path}`;
|
|
251
|
+
const res = await http.fetch(url, init);
|
|
252
|
+
if (res.status !== 401) return res;
|
|
253
|
+
await res.body?.cancel().catch(() => {});
|
|
254
|
+
const { scheme, params } = parseChallenge(res.headers.get("www-authenticate") ?? "");
|
|
255
|
+
const login = access.username !== undefined && access.password !== undefined ? basic(access.username, access.password) : undefined;
|
|
256
|
+
let authorization: string;
|
|
257
|
+
if (scheme === "basic") {
|
|
258
|
+
if (!login) return new Response(null, { status: 401 });
|
|
259
|
+
authorization = login;
|
|
260
|
+
} else if (scheme === "bearer" && params.realm) {
|
|
261
|
+
const q = new URLSearchParams();
|
|
262
|
+
if (params.service) q.set("service", params.service);
|
|
263
|
+
if (params.scope) q.set("scope", params.scope);
|
|
264
|
+
const tokenRes = await http.fetch(`${params.realm}?${q}`, { headers: login ? { Authorization: login } : {} });
|
|
265
|
+
if (!tokenRes.ok) {
|
|
266
|
+
await tokenRes.body?.cancel().catch(() => {});
|
|
267
|
+
return new Response(null, { status: tokenRes.status === 404 ? 401 : tokenRes.status });
|
|
268
|
+
}
|
|
269
|
+
const body = (await tokenRes.json()) as { token?: string; access_token?: string };
|
|
270
|
+
const token = body.token ?? body.access_token;
|
|
271
|
+
if (!token) throw new Error(`registry token endpoint ${params.realm} returned no token`);
|
|
272
|
+
authorization = `Bearer ${token}`;
|
|
273
|
+
} else {
|
|
274
|
+
return new Response(null, { status: 401 });
|
|
275
|
+
}
|
|
276
|
+
const headers = new Headers(init.headers);
|
|
277
|
+
headers.set("Authorization", authorization);
|
|
278
|
+
return http.fetch(url, { ...init, headers });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The digest of `repository:tag` in its registry (`HEAD …/manifests/<tag>`), or undefined if it has none. */
|
|
282
|
+
export async function registryDigest(http: Ctx["http"], repository: string, tag: string, access: RegistryAccess = {}): Promise<string | undefined> {
|
|
191
283
|
const [host, ...path] = repository.split("/");
|
|
192
|
-
const res = await http
|
|
284
|
+
const res = await registryFetch(http, host!, `/v2/${path.join("/")}/manifests/${tag}`, { method: "HEAD", headers: { Accept: MANIFEST_TYPES } }, access);
|
|
193
285
|
await res.body?.cancel().catch(() => {});
|
|
194
286
|
if (res.status === 404) return undefined;
|
|
287
|
+
// An anonymous lookup of a repository that doesn't exist yet (or is private) is denied, not 404.
|
|
288
|
+
if ((res.status === 401 || res.status === 403) && access.password === undefined) return undefined;
|
|
195
289
|
if (!res.ok) throw new Error(`registry lookup of ${repository}:${tag} answered HTTP ${res.status}`);
|
|
196
290
|
const digest = res.headers.get("docker-content-digest") ?? "";
|
|
197
291
|
return /^sha256:[0-9a-f]{64}$/.test(digest) ? digest : undefined;
|
|
198
292
|
}
|
|
199
293
|
|
|
294
|
+
/** kaniko reads registry logins from `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in its image). */
|
|
295
|
+
export const KANIKO_DOCKER_CONFIG = "/kaniko/.docker";
|
|
296
|
+
|
|
297
|
+
/** A docker `config.json` that logs in to `host`. */
|
|
298
|
+
export function dockerConfigJson(host: string, username: string, password: string): string {
|
|
299
|
+
return JSON.stringify({ auths: { [host]: { auth: Buffer.from(`${username}:${password}`).toString("base64") } } });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Adds the login for `host` to `<dir>/config.json` (owner-only), keeping what is there (kaniko's credHelpers). */
|
|
303
|
+
export function writeDockerConfig(dir: string, host: string, username: string, password: string): void {
|
|
304
|
+
mkdirSync(dir, { recursive: true });
|
|
305
|
+
const file = join(dir, "config.json");
|
|
306
|
+
let config: { auths?: Record<string, unknown> } = {};
|
|
307
|
+
try {
|
|
308
|
+
config = JSON.parse(readFileSync(file, "utf8"));
|
|
309
|
+
} catch {
|
|
310
|
+
// absent or unreadable: start fresh
|
|
311
|
+
}
|
|
312
|
+
const login = JSON.parse(dockerConfigJson(host, username, password)) as { auths: Record<string, unknown> };
|
|
313
|
+
writeFileSync(file, JSON.stringify({ ...config, auths: { ...config.auths, ...login.auths } }), { mode: 0o600 });
|
|
314
|
+
}
|
|
315
|
+
|
|
200
316
|
/**
|
|
201
317
|
* Builds an image with kaniko from `<context>/<dockerfile>` in the source tree (default: `Dockerfile`
|
|
202
318
|
* at the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
|
|
203
319
|
* With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
|
|
204
320
|
*/
|
|
205
|
-
export function imageBuild<const N extends string>(def: ImageBuildTarballDef<N>): Step<N, ImageBuildInput, typeof BuiltImage,
|
|
206
|
-
export function imageBuild<const N extends string>(def: ImageBuildPushDef<N>): Step<N, ImageBuildInput, typeof PushedImage,
|
|
207
|
-
export function imageBuild<const N extends string>(def: ImageBuildTarballDef<N> | ImageBuildPushDef<N>): AnyImageBuild {
|
|
208
|
-
const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
|
|
209
|
-
const mapped = Object.keys(registryMap).length > 0;
|
|
321
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K>): Step<N, ImageBuildInput, typeof BuiltImage, K> & LibraryStep;
|
|
322
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildPushDef<N, K>): Step<N, ImageBuildInput, typeof PushedImage, K> & LibraryStep;
|
|
323
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K> | ImageBuildPushDef<N, K>): AnyImageBuild {
|
|
210
324
|
const push = def.push === true;
|
|
211
325
|
const contentTag = push && def.contentTag === true;
|
|
212
326
|
const context = checkRelative("context", def.context ?? "");
|
|
213
327
|
const dockerfile = checkRelative("dockerfile", def.dockerfile ?? "Dockerfile");
|
|
214
|
-
|
|
328
|
+
const authSecret = def.registryAuth && !isConfigRef(def.registryAuth) ? def.registryAuth : undefined;
|
|
329
|
+
if (isLiteral([def.repository, def.registryAuth, def.insecureRegistry])) checkRegistryHost(def.name, def.repository as string, push, def.registryAuth !== undefined, def.insecureRegistry === true);
|
|
330
|
+
const secrets = [...(authSecret ? [authSecret.password] : []), ...(def.secrets ?? [])].filter((s, i, all) => all.findIndex((x) => x.name === s.name) === i);
|
|
215
331
|
return makeStep(
|
|
216
332
|
"imageBuild",
|
|
217
|
-
{ name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: push ? PushedImage : BuiltImage, secrets
|
|
333
|
+
{ name: def.name, description: def.description, image: KANIKO_IMAGE, input, output: push ? PushedImage : BuiltImage, secrets, timeoutSeconds: def.timeoutSeconds, retries: def.retries ?? DEFAULT_RETRIES },
|
|
218
334
|
{
|
|
219
|
-
manifestConfig: () =>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
335
|
+
manifestConfig: () => {
|
|
336
|
+
const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
|
|
337
|
+
const mapped = isConfigRef(registryMap) || Object.keys(registryMap).length > 0;
|
|
338
|
+
return {
|
|
339
|
+
repository: recordConfigRefs(def.repository),
|
|
340
|
+
...(context ? { context } : {}),
|
|
341
|
+
...(dockerfile !== "Dockerfile" ? { dockerfile } : {}),
|
|
342
|
+
...(push ? { push: true } : {}),
|
|
343
|
+
...(contentTag ? { contentTag: true } : {}),
|
|
344
|
+
...(def.cacheRepo !== undefined ? { cacheRepo: recordConfigRefs(def.cacheRepo) } : {}),
|
|
345
|
+
...(mapped ? { registryMap: recordConfigRefs(registryMap) } : {}),
|
|
346
|
+
...(authSecret ? { registryAuth: { username: authSecret.username, password: authSecret.password.name } } : {}),
|
|
347
|
+
...(def.registryAuth && isConfigRef(def.registryAuth) ? { registryAuth: recordConfigRefs(def.registryAuth) } : {}),
|
|
348
|
+
...(def.insecureRegistry !== undefined && def.insecureRegistry !== false ? { insecureRegistry: recordConfigRefs(def.insecureRegistry) } : {}),
|
|
349
|
+
...(def.enabled !== undefined ? { enabled: recordConfigRefs(def.enabled) } : {}),
|
|
350
|
+
...(def.snapshotMode !== undefined && def.snapshotMode !== DEFAULT_SNAPSHOT_MODE ? { snapshotMode: def.snapshotMode } : {}),
|
|
351
|
+
...(def.useNewRun === false ? { useNewRun: false } : {}),
|
|
352
|
+
...(def.compressedCaching === true ? { compressedCaching: true } : {}),
|
|
353
|
+
};
|
|
354
|
+
},
|
|
228
355
|
async run({ source }: { source: { tree: FileRef } }, ctx: Ctx) {
|
|
356
|
+
const d = resolveConfigRefs({
|
|
357
|
+
repository: def.repository, cacheRepo: def.cacheRepo, registryMap: def.registryMap, insecureRegistry: def.insecureRegistry,
|
|
358
|
+
registryAuth: def.registryAuth, enabled: def.enabled,
|
|
359
|
+
}, ctx.config ?? {});
|
|
360
|
+
const repository = d.repository as string;
|
|
361
|
+
if (d.enabled === false) return ctx.skip(`publishing ${repository} is turned off`);
|
|
362
|
+
const registryMap = (d.registryMap as Record<string, string> | undefined) ?? DEFAULT_REGISTRY_MAP;
|
|
363
|
+
const insecure = d.insecureRegistry === true;
|
|
364
|
+
const auth = registryLogin(d.registryAuth as RegistryAuth<string> | RegistryAuthConfig | undefined, ctx, secrets.map((s) => s.name));
|
|
365
|
+
checkRegistryHost(def.name, repository, push, auth !== undefined, insecure);
|
|
366
|
+
const host = registryHost(repository);
|
|
367
|
+
|
|
229
368
|
const tree = await ctx.files.fetch(source.tree);
|
|
230
369
|
const contextDir = context ? join(tree, context) : tree;
|
|
231
370
|
if (!existsSync(contextDir) || !statSync(contextDir).isDirectory()) throw new Error(`build context '${context}' not found in the source tree`);
|
|
232
371
|
if (!existsSync(join(contextDir, dockerfile))) throw new Error(`${dockerfile} not found in the build context${context ? ` '${context}'` : ""}`);
|
|
233
372
|
|
|
373
|
+
const access: RegistryAccess = { insecure, ...(auth ?? {}) };
|
|
374
|
+
if (auth) writeDockerConfig(process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG, host, auth.username, auth.password);
|
|
375
|
+
|
|
234
376
|
const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
|
|
235
377
|
if (contentTag) {
|
|
236
|
-
const digest = await registryDigest(ctx.http,
|
|
378
|
+
const digest = await registryDigest(ctx.http, repository, tag, access);
|
|
237
379
|
if (digest) {
|
|
238
|
-
ctx.log("image already in the registry", { image: `${
|
|
239
|
-
return { repository
|
|
380
|
+
ctx.log("image already in the registry", { image: `${repository}:${tag}`, digest });
|
|
381
|
+
return { repository, tag, digest };
|
|
240
382
|
}
|
|
241
383
|
}
|
|
242
384
|
|
|
243
|
-
const caches = await reachableCaches(ctx, { registryMap, cacheRepo:
|
|
385
|
+
const caches = await reachableCaches(ctx, { registryMap, cacheRepo: d.cacheRepo as string | undefined }, insecure ? [host] : []);
|
|
244
386
|
await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
|
|
245
387
|
await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
|
|
246
388
|
const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
|
|
@@ -262,7 +404,11 @@ export function imageBuild<const N extends string>(def: ImageBuildTarballDef<N>
|
|
|
262
404
|
`--dockerfile=${kanikoContext}/${dockerfile}`,
|
|
263
405
|
...output,
|
|
264
406
|
`--digest-file=${digestPath}`,
|
|
265
|
-
`--destination=${
|
|
407
|
+
`--destination=${repository}:${tag}`,
|
|
408
|
+
...(insecure ? ["--insecure", `--insecure-registry=${host}`] : []),
|
|
409
|
+
`--snapshot-mode=${def.snapshotMode ?? DEFAULT_SNAPSHOT_MODE}`,
|
|
410
|
+
...((def.useNewRun ?? true) ? ["--use-new-run"] : []),
|
|
411
|
+
`--compressed-caching=${def.compressedCaching ?? false}`,
|
|
266
412
|
...(caches.cacheRepo ? ["--cache=true", `--cache-repo=${caches.cacheRepo}`, "--skip-push-permission-check"] : []),
|
|
267
413
|
...Object.entries(caches.registryMap).map(([from, to]) => `--registry-map=${from}=${to}`),
|
|
268
414
|
], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
|
|
@@ -275,16 +421,54 @@ export function imageBuild<const N extends string>(def: ImageBuildTarballDef<N>
|
|
|
275
421
|
}
|
|
276
422
|
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
|
|
277
423
|
if (push) {
|
|
278
|
-
ctx.log("image pushed", { image: `${
|
|
279
|
-
return { repository
|
|
424
|
+
ctx.log("image pushed", { image: `${repository}:${tag}`, digest });
|
|
425
|
+
return { repository, tag, digest };
|
|
280
426
|
}
|
|
281
427
|
const tar = await ctx.output.file(tarPath, { name: "image.tar" });
|
|
282
428
|
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: `${
|
|
284
|
-
return { repository: qualifyRepository(
|
|
429
|
+
ctx.log("image built", { image: `${repository}:${tag}`, digest });
|
|
430
|
+
return { repository: qualifyRepository(repository), tag, digest, tar };
|
|
285
431
|
},
|
|
286
432
|
},
|
|
433
|
+
def,
|
|
287
434
|
) as never;
|
|
288
435
|
}
|
|
289
436
|
|
|
290
|
-
|
|
437
|
+
function checkRegistryHost(name: string, repository: string, push: boolean, auth: boolean, insecure: boolean): void {
|
|
438
|
+
const host = registryHost(repository);
|
|
439
|
+
if (push && !/[.:]|^localhost$/.test(host)) throw new Error(`imageBuild '${name}': push needs a repository with a registry host, got '${repository}'`);
|
|
440
|
+
if ((auth || insecure) && !/[.:]|^localhost$/.test(host)) throw new Error(`imageBuild '${name}': registryAuth and insecureRegistry need a repository with a registry host, got '${repository}'`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The login for `registryAuth` (a literal secret or a config `secretRef`), or undefined without one. */
|
|
444
|
+
function registryLogin(auth: RegistryAuth<string> | RegistryAuthConfig | undefined, ctx: Pick<Ctx, "secrets">, declared: readonly string[]): { username: string; password: string } | undefined {
|
|
445
|
+
if (!auth) return undefined;
|
|
446
|
+
const name = "name" in auth.password ? auth.password.name : auth.password.secret;
|
|
447
|
+
if (!declared.includes(name)) throw new Error(`registryAuth names secret ${name}, which the step does not declare (add it to secrets)`);
|
|
448
|
+
const password = (ctx.secrets as Record<string, string | undefined>)[name];
|
|
449
|
+
if (!password) throw new Error(`secret ${name} (registryAuth) is not set`);
|
|
450
|
+
return { username: auth.username, password };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Adds `tag` to the image `repository@digest` without rebuilding it: the manifest is fetched by
|
|
455
|
+
* digest and PUT back under the tag byte for byte, so the digest stays the same.
|
|
456
|
+
*/
|
|
457
|
+
export async function registryTag(http: Ctx["http"], repository: string, digest: string, tag: string, access: RegistryAccess = {}): Promise<void> {
|
|
458
|
+
const [host, ...path] = repository.split("/");
|
|
459
|
+
const name = path.join("/");
|
|
460
|
+
const got = await registryFetch(http, host!, `/v2/${name}/manifests/${digest}`, { method: "GET", headers: { Accept: MANIFEST_TYPES } }, access);
|
|
461
|
+
if (!got.ok) {
|
|
462
|
+
await got.body?.cancel().catch(() => {});
|
|
463
|
+
throw new Error(`registry fetch of ${repository}@${digest} answered HTTP ${got.status}`);
|
|
464
|
+
}
|
|
465
|
+
const type = got.headers.get("content-type") ?? "application/vnd.oci.image.manifest.v1+json";
|
|
466
|
+
const body = new Uint8Array(await got.arrayBuffer());
|
|
467
|
+
const put = await registryFetch(http, host!, `/v2/${name}/manifests/${encodeURIComponent(tag)}`, { method: "PUT", headers: { "Content-Type": type }, body }, access);
|
|
468
|
+
await put.body?.cancel().catch(() => {});
|
|
469
|
+
if (!put.ok) throw new Error(`registry tag ${repository}:${tag} → ${digest} answered HTTP ${put.status}`);
|
|
470
|
+
const written = put.headers.get("docker-content-digest");
|
|
471
|
+
if (written && written !== digest) throw new Error(`registry stored ${repository}:${tag} as ${written}, not ${digest}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
type AnyImageBuild = Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, any> & LibraryStep;
|
package/src/library/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { gitHubSource } from "./github-source.ts";
|
|
2
|
+
import { composeDeploy } from "./compose-deploy.ts";
|
|
3
|
+
import { helmDeployInCluster } from "./helm-deploy.ts";
|
|
2
4
|
import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
|
|
3
5
|
import { imageBuild } from "./image-build.ts";
|
|
4
6
|
import { script } from "./script.ts";
|
|
@@ -6,11 +8,14 @@ import { script } from "./script.ts";
|
|
|
6
8
|
export { Source, BuiltImage, PushedImage, HelmRelease, FailureContext } from "./models.ts";
|
|
7
9
|
export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
|
|
8
10
|
export type { GitHubSourceDef } from "./github-source.ts";
|
|
9
|
-
export type
|
|
11
|
+
export { registryTag, registryDigest, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type RegistryAuthConfig, type RegistryAccess } from "./image-build.ts";
|
|
10
12
|
export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
|
|
13
|
+
export { type ComposeDeployDef, type ComposeTargetValue, type ComposeDeployResult } from "./compose-deploy.ts";
|
|
14
|
+
export { helmTarget, deployTarget, type DeployTargetOptions, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetDeployDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue } from "./helm-deploy.ts";
|
|
11
15
|
export { sshSession, shq, type SshSession } from "./ssh.ts";
|
|
12
16
|
export { serviceUrl, checkSpa } from "./checks.ts";
|
|
13
17
|
export { httpHealthCheck, HTTP_HEALTH_DEFAULTS, type HttpHealthCheckOptions } from "./http-health.ts";
|
|
18
|
+
export { npmDistTag, npmHasVersion, NPM_REGISTRY } from "./npm.ts";
|
|
14
19
|
|
|
15
20
|
/** SDK-provided steps (step + handler pairs with typed config). */
|
|
16
|
-
export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh };
|
|
21
|
+
export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy };
|
package/src/library/models.ts
CHANGED
|
@@ -26,7 +26,13 @@ export const PushedImage = model(
|
|
|
26
26
|
/** A Helm release that was installed or upgraded. */
|
|
27
27
|
export const HelmRelease = model(
|
|
28
28
|
"HelmRelease",
|
|
29
|
-
z.object({
|
|
29
|
+
z.object({
|
|
30
|
+
release: z.string(),
|
|
31
|
+
namespace: z.string(),
|
|
32
|
+
image: z.string(),
|
|
33
|
+
/** Where step pods reach the release (the target's `url`); absent = its in-cluster Service URL (`serviceUrl`). */
|
|
34
|
+
url: z.string().optional(),
|
|
35
|
+
}),
|
|
30
36
|
);
|
|
31
37
|
|
|
32
38
|
/** One failed step of a run, as seen by a failure handler. */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// npm registry calls for release steps (no npm CLI needed in the image).
|
|
2
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
+
|
|
4
|
+
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
5
|
+
|
|
6
|
+
const packagePath = (name: string) => name.replace("/", "%2f");
|
|
7
|
+
|
|
8
|
+
/** Whether `name@version` is on the registry. */
|
|
9
|
+
export async function npmHasVersion(ctx: Pick<Ctx, "http">, name: string, version: string, registry = NPM_REGISTRY): Promise<boolean> {
|
|
10
|
+
const res = await ctx.http.fetch(`${registry}/${packagePath(name)}`, { headers: { "cache-control": "no-cache" } });
|
|
11
|
+
if (res.status === 404) {
|
|
12
|
+
await res.body?.cancel().catch(() => {});
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (!res.ok) throw new Error(`npm registry lookup of ${name} failed: HTTP ${res.status}`);
|
|
16
|
+
return Boolean(((await res.json()) as { versions?: Record<string, unknown> }).versions?.[version]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `npm dist-tag add <name>@<version> <tag>`: points the tag at an already published version. */
|
|
20
|
+
export async function npmDistTag(
|
|
21
|
+
ctx: Pick<Ctx, "http" | "log">,
|
|
22
|
+
o: { name: string; version: string; tag: string; token: string; registry?: string },
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const url = `${o.registry ?? NPM_REGISTRY}/-/package/${packagePath(o.name)}/dist-tags/${encodeURIComponent(o.tag)}`;
|
|
25
|
+
const res = await ctx.http.fetch(url, {
|
|
26
|
+
method: "PUT",
|
|
27
|
+
headers: { authorization: `Bearer ${o.token}`, "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify(o.version),
|
|
29
|
+
});
|
|
30
|
+
await res.body?.cancel().catch(() => {});
|
|
31
|
+
if (!res.ok) throw new Error(`npm dist-tag ${o.name}@${o.version} ${o.tag} failed: HTTP ${res.status}`);
|
|
32
|
+
ctx.log("npm dist-tag", { package: `${o.name}@${o.version}`, tag: o.tag });
|
|
33
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { isImplementation, type Implementation } from "../core/handlers.ts";
|
|
7
7
|
import type { FileRef } from "../core/models.ts";
|
|
8
|
-
import { isStep, type AnyStep, type LibraryStep, type OutputValue, type SecretsOf, type StepInput } from "../core/steps.ts";
|
|
8
|
+
import { isStep, type AnyStep, type ConfigOf, type LibraryStep, type OutputValue, type SecretsOf, type StepInput } from "../core/steps.ts";
|
|
9
9
|
import { createCtx } from "../ctx/index.ts";
|
|
10
10
|
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
11
11
|
import { executeStep, type FailureKind, type StepFunction } from "../harness/execute.ts";
|
|
@@ -13,7 +13,9 @@ import { sha256Hex } from "../util/hash.ts";
|
|
|
13
13
|
|
|
14
14
|
export interface TestHandlerOptions<S extends AnyStep> {
|
|
15
15
|
/** Replace parts of ctx (e.g. `sh`, `http`, `files`). The rest is the real implementation. */
|
|
16
|
-
ctx?: Partial<Ctx<SecretsOf<S>>>;
|
|
16
|
+
ctx?: Partial<Ctx<SecretsOf<S>, ConfigOf<S>>>;
|
|
17
|
+
/** The step's config entries (z.input view: defaults are applied), like `$config` in input.json. */
|
|
18
|
+
config?: Record<string, unknown>;
|
|
17
19
|
/** Secret values (instead of reading the environment). */
|
|
18
20
|
secrets?: Partial<Record<SecretsOf<S>, string>>;
|
|
19
21
|
run?: Partial<Omit<RunInfo, "step">>;
|
|
@@ -69,7 +71,7 @@ export async function testHandler<S extends AnyStep>(
|
|
|
69
71
|
});
|
|
70
72
|
const ctx = { ...real, ...opts.ctx } as Ctx;
|
|
71
73
|
// Round-trip through JSON like input.json does (drops undefined, rejects non-JSON values early).
|
|
72
|
-
const raw = JSON.parse(JSON.stringify(input
|
|
74
|
+
const raw = JSON.parse(JSON.stringify({ ...input, ...(opts.config ? { $config: opts.config } : {}) }));
|
|
73
75
|
const exec = await executeStep({ step, fn, rawInput: raw, ctx, workFiles: filesDir, log: (l) => logs.push(l) });
|
|
74
76
|
return { dir, filesDir, logs, exitCode: exec.exitCode, ...exec.envelope } as TestResult<S>;
|
|
75
77
|
}
|