@olivervea/pipelines 2.0.0-beta.7 → 2.0.0-beta.9
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 +36 -31
- package/manifest.schema.json +3 -3
- package/package.json +1 -1
- package/src/build/build.ts +9 -7
- package/src/build/hashes.ts +31 -2
- package/src/build/project.ts +7 -0
- package/src/core/brands.ts +2 -0
- package/src/core/config.ts +1 -1
- package/src/core/handlers.ts +14 -8
- package/src/core/origin.ts +20 -0
- package/src/core/steps.ts +28 -20
- package/src/describe/describe.ts +37 -32
- package/src/describe/manifest-schema.ts +3 -3
- package/src/dev/cli.ts +5 -5
- package/src/harness/main.ts +5 -6
- package/src/index.ts +5 -3
- package/src/library/compose-deploy.ts +42 -39
- package/src/library/github-source.ts +45 -41
- package/src/library/helm-deploy-ssh.ts +114 -60
- package/src/library/helm-deploy.ts +73 -70
- package/src/library/image-build.ts +113 -169
- package/src/library/index.ts +5 -8
- package/src/library/models.ts +2 -1
- package/src/testing/index.ts +6 -12
- package/src/util/handler-hash.ts +3 -3
- package/src/library/npm.ts +0 -33
- package/src/library/script.ts +0 -85
|
@@ -7,8 +7,9 @@ import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
|
7
7
|
import type { FileRef } from "../core/models.ts";
|
|
8
8
|
import type { Secret } from "../core/secrets.ts";
|
|
9
9
|
import { isConfigRef, type ConfigRef, type Configurable } from "../core/config.ts";
|
|
10
|
-
import { isLiteral,
|
|
11
|
-
import {
|
|
10
|
+
import { isLiteral, resolveConfigRefs } from "../core/config-refs.ts";
|
|
11
|
+
import { implement, type Implementation } from "../core/handlers.ts";
|
|
12
|
+
import { step, type LiteralName, type Step } from "../core/steps.ts";
|
|
12
13
|
import { sha256File } from "../util/hash.ts";
|
|
13
14
|
import { dirEntries, writeTar } from "../util/tar.ts";
|
|
14
15
|
import { BuiltImage, PushedImage, Source } from "./models.ts";
|
|
@@ -47,16 +48,8 @@ export interface ImageBuildDef<N extends string, K extends string = never> {
|
|
|
47
48
|
* (`registry.ovea.pro/app/api`). The tag is the run's version (see `imageTag`) unless `contentTag`.
|
|
48
49
|
*/
|
|
49
50
|
repository: Configurable<string>;
|
|
50
|
-
/** Build context: a directory of the source tree (default: the root). */
|
|
51
|
+
/** Build context: a directory of the source tree with a `Dockerfile` (default: the root). */
|
|
51
52
|
context?: string;
|
|
52
|
-
/** Dockerfile path, relative to the context (default `Dockerfile`). */
|
|
53
|
-
dockerfile?: string;
|
|
54
|
-
/**
|
|
55
|
-
* Registry repository for kaniko's layer cache (`--cache=true --cache-repo=…`), so unchanged
|
|
56
|
-
* layers (restore, apt, npm, …) are reused. kaniko pushes cache layers even with `--no-push`.
|
|
57
|
-
* A registry without GC grows with every changed layer — prune it when needed.
|
|
58
|
-
*/
|
|
59
|
-
cacheRepo?: Configurable<string | undefined>;
|
|
60
53
|
/**
|
|
61
54
|
* kaniko `--registry-map` (registry → mirror prefix); kaniko falls back to the original registry if
|
|
62
55
|
* the mirror lacks the image. Defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`);
|
|
@@ -71,15 +64,6 @@ export interface ImageBuildDef<N extends string, K extends string = never> {
|
|
|
71
64
|
* the longest silent build phase (an AOT "Generating native code" runs ~2 min with no output).
|
|
72
65
|
*/
|
|
73
66
|
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;
|
|
83
67
|
/**
|
|
84
68
|
* Automatic retries of a failed attempt (a fresh pod: kaniko unpacks the base image over the
|
|
85
69
|
* container root and does not clean up when killed, so retrying inside the same pod is unsafe).
|
|
@@ -147,32 +131,22 @@ export async function registryUp(http: Ctx["http"], host: string, signal?: Abort
|
|
|
147
131
|
}
|
|
148
132
|
}
|
|
149
133
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
152
|
-
* unreachable mirror is left out of the registry map, an unreachable `cacheRepo` means no layer cache.
|
|
153
|
-
*/
|
|
154
|
-
export async function reachableCaches(
|
|
134
|
+
/** The registry map without the mirrors that don't answer, so a mirror outage (GC, maintenance) never blocks a build. */
|
|
135
|
+
export async function reachableMirrors(
|
|
155
136
|
ctx: Pick<Ctx, "http" | "log" | "signal">,
|
|
156
|
-
|
|
137
|
+
registryMap: Record<string, string>,
|
|
157
138
|
insecureHosts: readonly string[] = [],
|
|
158
|
-
): Promise<
|
|
159
|
-
const hosts = new Set(
|
|
139
|
+
): Promise<Record<string, string>> {
|
|
140
|
+
const hosts = new Set(Object.values(registryMap).map(registryHost));
|
|
160
141
|
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)));
|
|
161
|
-
const
|
|
162
|
-
for (const [from, to] of Object.entries(
|
|
163
|
-
if (up.get(registryHost(to)))
|
|
142
|
+
const reachable: Record<string, string> = {};
|
|
143
|
+
for (const [from, to] of Object.entries(registryMap)) {
|
|
144
|
+
if (up.get(registryHost(to))) reachable[from] = to;
|
|
164
145
|
else ctx.log(`${registryHost(to)} unreachable; pulling from ${from} directly`);
|
|
165
146
|
}
|
|
166
|
-
|
|
167
|
-
ctx.log(`${registryHost(caches.cacheRepo)} unreachable; building without the layer cache`);
|
|
168
|
-
return { registryMap };
|
|
169
|
-
}
|
|
170
|
-
return { registryMap, ...(caches.cacheRepo ? { cacheRepo: caches.cacheRepo } : {}) };
|
|
147
|
+
return reachable;
|
|
171
148
|
}
|
|
172
149
|
|
|
173
|
-
/** Default kaniko `--snapshot-mode`. */
|
|
174
|
-
export const DEFAULT_SNAPSHOT_MODE = "redo";
|
|
175
|
-
|
|
176
150
|
/** Default `retries`: a stalled or flaky build gets two fresh attempts before the run fails. */
|
|
177
151
|
export const DEFAULT_RETRIES = 2;
|
|
178
152
|
|
|
@@ -314,124 +288,115 @@ export function writeDockerConfig(dir: string, host: string, username: string, p
|
|
|
314
288
|
}
|
|
315
289
|
|
|
316
290
|
/**
|
|
317
|
-
* Builds an image with kaniko from `<context
|
|
318
|
-
* at the root). Default: into an image tarball, output `BuiltImage { repository, tag, digest, tar }`.
|
|
291
|
+
* 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 }`.
|
|
319
292
|
* With `push: true`: pushed to `repository`, output `PushedImage { repository, tag, digest }`.
|
|
320
293
|
*/
|
|
321
|
-
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K>): Step<N, ImageBuildInput, typeof BuiltImage, K
|
|
322
|
-
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildPushDef<N, K>): Step<N, ImageBuildInput, typeof PushedImage, K
|
|
294
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K>): Implementation<Step<N, ImageBuildInput, typeof BuiltImage, K>>;
|
|
295
|
+
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildPushDef<N, K>): Implementation<Step<N, ImageBuildInput, typeof PushedImage, K>>;
|
|
323
296
|
export function imageBuild<const N extends string, const K extends string = never>(def: ImageBuildTarballDef<N, K> | ImageBuildPushDef<N, K>): AnyImageBuild {
|
|
324
297
|
const push = def.push === true;
|
|
325
298
|
const contentTag = push && def.contentTag === true;
|
|
326
299
|
const context = checkRelative("context", def.context ?? "");
|
|
327
|
-
const dockerfile = checkRelative("dockerfile", def.dockerfile ?? "Dockerfile");
|
|
328
300
|
const authSecret = def.registryAuth && !isConfigRef(def.registryAuth) ? def.registryAuth : undefined;
|
|
329
301
|
if (isLiteral([def.repository, def.registryAuth, def.insecureRegistry])) checkRegistryHost(def.name, def.repository as string, push, def.registryAuth !== undefined, def.insecureRegistry === true);
|
|
330
302
|
const secrets = [...(authSecret ? [authSecret.password] : []), ...(def.secrets ?? [])].filter((s, i, all) => all.findIndex((x) => x.name === s.name) === i);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
};
|
|
354
|
-
},
|
|
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
|
-
|
|
368
|
-
const tree = await ctx.files.fetch(source.tree);
|
|
369
|
-
const contextDir = context ? join(tree, context) : tree;
|
|
370
|
-
if (!existsSync(contextDir) || !statSync(contextDir).isDirectory()) throw new Error(`build context '${context}' not found in the source tree`);
|
|
371
|
-
if (!existsSync(join(contextDir, dockerfile))) throw new Error(`${dockerfile} not found in the build context${context ? ` '${context}'` : ""}`);
|
|
372
|
-
|
|
373
|
-
const access: RegistryAccess = { insecure, ...(auth ?? {}) };
|
|
374
|
-
if (auth) writeDockerConfig(process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG, host, auth.username, auth.password);
|
|
375
|
-
|
|
376
|
-
const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
|
|
377
|
-
if (contentTag) {
|
|
378
|
-
const digest = await registryDigest(ctx.http, repository, tag, access);
|
|
379
|
-
if (digest) {
|
|
380
|
-
ctx.log("image already in the registry", { image: `${repository}:${tag}`, digest });
|
|
381
|
-
return { repository, tag, digest };
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const caches = await reachableCaches(ctx, { registryMap, cacheRepo: d.cacheRepo as string | undefined }, insecure ? [host] : []);
|
|
386
|
-
await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
|
|
387
|
-
await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
|
|
388
|
-
const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
|
|
389
|
-
|
|
390
|
-
// Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
|
|
391
|
-
// kaniko images have no /tmp), then into the step's files.
|
|
392
|
-
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
393
|
-
const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
|
|
394
|
-
const tarPath = join(out, "image.tar");
|
|
395
|
-
const digestPath = join(out, "digest");
|
|
396
|
-
const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
|
|
397
|
-
// No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
|
|
398
|
-
// the build context intact. --skip-push-permission-check: with --no-push kaniko otherwise checks
|
|
399
|
-
// the cache repo at startup and exits if it is down; later cache lookup and push errors are
|
|
400
|
-
// only warnings.
|
|
401
|
-
await ctx.exec([
|
|
402
|
-
"/kaniko/executor",
|
|
403
|
-
`--context=${kanikoContext}`,
|
|
404
|
-
`--dockerfile=${kanikoContext}/${dockerfile}`,
|
|
405
|
-
...output,
|
|
406
|
-
`--digest-file=${digestPath}`,
|
|
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}`,
|
|
412
|
-
...(caches.cacheRepo ? ["--cache=true", `--cache-repo=${caches.cacheRepo}`, "--skip-push-permission-check"] : []),
|
|
413
|
-
...Object.entries(caches.registryMap).map(([from, to]) => `--registry-map=${from}=${to}`),
|
|
414
|
-
], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
|
|
415
|
-
|
|
416
|
-
let digest: string;
|
|
417
|
-
try {
|
|
418
|
-
digest = readFileSync(digestPath, "utf8").trim();
|
|
419
|
-
} catch {
|
|
420
|
-
throw new Error(`kaniko did not write the image digest to ${digestPath}`);
|
|
421
|
-
}
|
|
422
|
-
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
|
|
423
|
-
if (push) {
|
|
424
|
-
ctx.log("image pushed", { image: `${repository}:${tag}`, digest });
|
|
425
|
-
return { repository, tag, digest };
|
|
426
|
-
}
|
|
427
|
-
const tar = await ctx.output.file(tarPath, { name: "image.tar" });
|
|
428
|
-
rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
|
|
429
|
-
ctx.log("image built", { image: `${repository}:${tag}`, digest });
|
|
430
|
-
return { repository: qualifyRepository(repository), tag, digest, tar };
|
|
431
|
-
},
|
|
303
|
+
const registryMap = def.registryMap ?? DEFAULT_REGISTRY_MAP;
|
|
304
|
+
const mapped = isConfigRef(registryMap) || Object.keys(registryMap).length > 0;
|
|
305
|
+
const contract = step({
|
|
306
|
+
name: def.name,
|
|
307
|
+
description: def.description,
|
|
308
|
+
kind: "imageBuild",
|
|
309
|
+
image: KANIKO_IMAGE,
|
|
310
|
+
input,
|
|
311
|
+
output: push ? PushedImage : BuiltImage,
|
|
312
|
+
secrets,
|
|
313
|
+
timeoutSeconds: def.timeoutSeconds,
|
|
314
|
+
retries: def.retries ?? DEFAULT_RETRIES,
|
|
315
|
+
settings: {
|
|
316
|
+
repository: def.repository,
|
|
317
|
+
...(context ? { context } : {}),
|
|
318
|
+
...(push ? { push: true } : {}),
|
|
319
|
+
...(contentTag ? { contentTag: true } : {}),
|
|
320
|
+
...(mapped ? { registryMap } : {}),
|
|
321
|
+
...(authSecret ? { registryAuth: { username: authSecret.username, password: authSecret.password.name } } : {}),
|
|
322
|
+
...(def.registryAuth && isConfigRef(def.registryAuth) ? { registryAuth: def.registryAuth } : {}),
|
|
323
|
+
...(def.insecureRegistry !== undefined && def.insecureRegistry !== false ? { insecureRegistry: def.insecureRegistry } : {}),
|
|
324
|
+
...(def.enabled !== undefined ? { enabled: def.enabled } : {}),
|
|
432
325
|
},
|
|
433
|
-
|
|
434
|
-
)
|
|
326
|
+
});
|
|
327
|
+
return implement(contract, async ({ source }, ctx: Ctx) => {
|
|
328
|
+
const d = resolveConfigRefs({
|
|
329
|
+
repository: def.repository, registryMap: def.registryMap, insecureRegistry: def.insecureRegistry,
|
|
330
|
+
registryAuth: def.registryAuth, enabled: def.enabled,
|
|
331
|
+
}, ctx.config ?? {});
|
|
332
|
+
const repository = d.repository as string;
|
|
333
|
+
if (d.enabled === false) return ctx.skip(`publishing ${repository} is turned off`);
|
|
334
|
+
const insecure = d.insecureRegistry === true;
|
|
335
|
+
const auth = registryLogin(d.registryAuth as RegistryAuth<string> | RegistryAuthConfig | undefined, ctx, secrets.map((s) => s.name));
|
|
336
|
+
checkRegistryHost(def.name, repository, push, auth !== undefined, insecure);
|
|
337
|
+
const host = registryHost(repository);
|
|
338
|
+
|
|
339
|
+
const tree = await ctx.files.fetch(source.tree);
|
|
340
|
+
const contextDir = context ? join(tree, context) : tree;
|
|
341
|
+
if (!existsSync(contextDir) || !statSync(contextDir).isDirectory()) throw new Error(`build context '${context}' not found in the source tree`);
|
|
342
|
+
if (!existsSync(join(contextDir, "Dockerfile"))) throw new Error(`Dockerfile not found in the build context${context ? ` '${context}'` : ""}`);
|
|
343
|
+
|
|
344
|
+
const access: RegistryAccess = { insecure, ...(auth ?? {}) };
|
|
345
|
+
if (auth) writeDockerConfig(process.env.DOCKER_CONFIG || KANIKO_DOCKER_CONFIG, host, auth.username, auth.password);
|
|
346
|
+
|
|
347
|
+
const tag = contentTag ? contentTagOf(contextDir, ctx.tmpDir) : imageTag(ctx.run);
|
|
348
|
+
if (contentTag) {
|
|
349
|
+
const digest = await registryDigest(ctx.http, repository, tag, access);
|
|
350
|
+
if (digest) {
|
|
351
|
+
ctx.log("image already in the registry", { image: `${repository}:${tag}`, digest });
|
|
352
|
+
return { repository, tag, digest };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const mirrors = await reachableMirrors(ctx, (d.registryMap as Record<string, string> | undefined) ?? DEFAULT_REGISTRY_MAP, insecure ? [host] : []);
|
|
357
|
+
await ctx.exec(["mkdir", "-p", KANIKO_CONTEXT]);
|
|
358
|
+
await ctx.exec(["cp", "-r", `${tree}/.`, KANIKO_CONTEXT]);
|
|
359
|
+
const kanikoContext = context ? `${KANIKO_CONTEXT}/${context}` : KANIKO_CONTEXT;
|
|
360
|
+
|
|
361
|
+
// Outputs go to the harness's scratch dir (/olve/tmp, an emptyDir mount kaniko leaves alone;
|
|
362
|
+
// kaniko images have no /tmp), then into the step's files.
|
|
363
|
+
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
364
|
+
const out = mkdtempSync(join(ctx.tmpDir, "image-build-"));
|
|
365
|
+
const tarPath = join(out, "image.tar");
|
|
366
|
+
const digestPath = join(out, "digest");
|
|
367
|
+
const output = push ? [] : ["--no-push", `--tar-path=${tarPath}`];
|
|
368
|
+
// No --single-snapshot: it is incompatible with multi-stage Dockerfiles whose later stages need
|
|
369
|
+
// the build context intact.
|
|
370
|
+
await ctx.exec([
|
|
371
|
+
"/kaniko/executor",
|
|
372
|
+
`--context=${kanikoContext}`,
|
|
373
|
+
`--dockerfile=${kanikoContext}/Dockerfile`,
|
|
374
|
+
...output,
|
|
375
|
+
`--digest-file=${digestPath}`,
|
|
376
|
+
`--destination=${repository}:${tag}`,
|
|
377
|
+
...(insecure ? ["--insecure", `--insecure-registry=${host}`] : []),
|
|
378
|
+
"--snapshot-mode=redo",
|
|
379
|
+
"--use-new-run",
|
|
380
|
+
"--compressed-caching=false",
|
|
381
|
+
...Object.entries(mirrors).map(([from, to]) => `--registry-map=${from}=${to}`),
|
|
382
|
+
], { idleTimeoutSeconds: def.stallTimeoutSeconds ?? DEFAULT_STALL_TIMEOUT_SECONDS });
|
|
383
|
+
|
|
384
|
+
let digest: string;
|
|
385
|
+
try {
|
|
386
|
+
digest = readFileSync(digestPath, "utf8").trim();
|
|
387
|
+
} catch {
|
|
388
|
+
throw new Error(`kaniko did not write the image digest to ${digestPath}`);
|
|
389
|
+
}
|
|
390
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`kaniko wrote an unexpected digest: '${digest}'`);
|
|
391
|
+
if (push) {
|
|
392
|
+
ctx.log("image pushed", { image: `${repository}:${tag}`, digest });
|
|
393
|
+
return { repository, tag, digest };
|
|
394
|
+
}
|
|
395
|
+
const tar = await ctx.output.file(tarPath, { name: "image.tar" });
|
|
396
|
+
rmSync(tarPath); // the copy in the step's files is the one that counts; don't hold the image twice in /olve
|
|
397
|
+
ctx.log("image built", { image: `${repository}:${tag}`, digest });
|
|
398
|
+
return { repository: qualifyRepository(repository), tag, digest, tar };
|
|
399
|
+
}) as never;
|
|
435
400
|
}
|
|
436
401
|
|
|
437
402
|
function checkRegistryHost(name: string, repository: string, push: boolean, auth: boolean, insecure: boolean): void {
|
|
@@ -450,25 +415,4 @@ function registryLogin(auth: RegistryAuth<string> | RegistryAuthConfig | undefin
|
|
|
450
415
|
return { username: auth.username, password };
|
|
451
416
|
}
|
|
452
417
|
|
|
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;
|
|
418
|
+
type AnyImageBuild = Implementation<Step<string, ImageBuildInput, typeof BuiltImage | typeof PushedImage, any>>;
|
package/src/library/index.ts
CHANGED
|
@@ -3,19 +3,16 @@ import { composeDeploy } from "./compose-deploy.ts";
|
|
|
3
3
|
import { helmDeployInCluster } from "./helm-deploy.ts";
|
|
4
4
|
import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
|
|
5
5
|
import { imageBuild } from "./image-build.ts";
|
|
6
|
-
import { script } from "./script.ts";
|
|
7
6
|
|
|
8
7
|
export { Source, BuiltImage, PushedImage, HelmRelease, FailureContext } from "./models.ts";
|
|
9
|
-
export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
|
|
10
8
|
export type { GitHubSourceDef } from "./github-source.ts";
|
|
11
|
-
export {
|
|
9
|
+
export type { ImageBuildDef, ImageBuildTarballDef, ImageBuildPushDef, RegistryAuth, RegistryAuthConfig } from "./image-build.ts";
|
|
12
10
|
export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
|
|
13
11
|
export { type ComposeDeployDef, type ComposeTargetValue, type ComposeDeployResult } from "./compose-deploy.ts";
|
|
14
12
|
export { helmTarget, deployTarget, type DeployTargetOptions, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetDeployDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue } from "./helm-deploy.ts";
|
|
15
|
-
export { sshSession,
|
|
13
|
+
export { sshSession, type SshSession } from "./ssh.ts";
|
|
16
14
|
export { serviceUrl, checkSpa } from "./checks.ts";
|
|
17
|
-
export { httpHealthCheck,
|
|
18
|
-
export { npmDistTag, npmHasVersion, NPM_REGISTRY } from "./npm.ts";
|
|
15
|
+
export { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
|
|
19
16
|
|
|
20
|
-
/** SDK-provided steps (step
|
|
21
|
-
export const steps = {
|
|
17
|
+
/** SDK-provided steps: each is `implement(step(…), …)` with typed settings, staged directly. */
|
|
18
|
+
export const steps = { gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy };
|
package/src/library/models.ts
CHANGED
|
@@ -29,7 +29,8 @@ export const HelmRelease = model(
|
|
|
29
29
|
z.object({
|
|
30
30
|
release: z.string(),
|
|
31
31
|
namespace: z.string(),
|
|
32
|
-
image
|
|
32
|
+
/** The release's main image; absent for a chart deployed without images. */
|
|
33
|
+
image: z.string().optional(),
|
|
33
34
|
/** Where step pods reach the release (the target's `url`); absent = its in-cluster Service URL (`serviceUrl`). */
|
|
34
35
|
url: z.string().optional(),
|
|
35
36
|
}),
|
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 {
|
|
8
|
+
import type { AnyStep, ConfigOf, OutputValue, SecretsOf, 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";
|
|
@@ -38,24 +38,18 @@ export type TestResult<S extends AnyStep> = {
|
|
|
38
38
|
);
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Runs a handler
|
|
41
|
+
* Runs an implementation (a handler module's default export, or a step that carries its own) with `input`.
|
|
42
42
|
* Input is validated like `input.json`, the result like a real run: an invalid output gives
|
|
43
43
|
* `{ status: "failed", error: { kind: "output" } }`, a throwing handler `kind: "handler"`.
|
|
44
44
|
*/
|
|
45
45
|
export async function testHandler<S extends AnyStep>(
|
|
46
|
-
target: Implementation<S
|
|
46
|
+
target: Implementation<S>,
|
|
47
47
|
input: StepInput<S["input"]>,
|
|
48
48
|
opts: TestHandlerOptions<S> = {},
|
|
49
49
|
): Promise<TestResult<S>> {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
step = target.step;
|
|
54
|
-
fn = target.handler as unknown as StepFunction;
|
|
55
|
-
} else if (isStep(target) && target.builtin) {
|
|
56
|
-
step = target;
|
|
57
|
-
fn = target.builtin.run as StepFunction;
|
|
58
|
-
} else throw new TypeError("testHandler expects implement(...) (a handler module's default export) or a library step");
|
|
50
|
+
if (!isImplementation(target)) throw new TypeError("testHandler expects implement(…): a handler module's default export or a step that carries its implementation");
|
|
51
|
+
const step: AnyStep = target.step;
|
|
52
|
+
const fn = target.handler as unknown as StepFunction;
|
|
59
53
|
|
|
60
54
|
const dir = opts.dir ?? mkdtempSync(join(tmpdir(), `olve-test-${step.name}-`));
|
|
61
55
|
const filesDir = join(dir, "work", "files");
|
package/src/util/handler-hash.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function composeHandlerHash(bundleHash: string, harness: HarnessInfo): st
|
|
|
16
16
|
return "sha256:" + sha256Hex(canonicalJson({ bundle: bundleHash, harness }));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
export function libraryHandlerHash(kind: string, config: unknown, harness: HarnessInfo): string {
|
|
21
|
-
return "sha256:" + sha256Hex(canonicalJson({ library: kind, config, harness }));
|
|
19
|
+
/** A step that carries its implementation: kind + canonical settings + harness, and the hash of the code that implements the kind. */
|
|
20
|
+
export function libraryHandlerHash(kind: string, config: unknown, harness: HarnessInfo, code?: string): string {
|
|
21
|
+
return "sha256:" + sha256Hex(canonicalJson({ library: kind, config, harness, ...(code ? { code } : {}) }));
|
|
22
22
|
}
|
package/src/library/npm.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
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/library/script.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
4
|
-
import type { AnyModel } from "../core/models.ts";
|
|
5
|
-
import type { Secret } from "../core/secrets.ts";
|
|
6
|
-
import { makeStep, type InputSpec, type LibraryStep, type Step, type StepDef, type StepInput } from "../core/steps.ts";
|
|
7
|
-
import { sha256Hex } from "../util/hash.ts";
|
|
8
|
-
|
|
9
|
-
/** A config value that may depend on the run: a constant or a function of the step's inputs and run info. */
|
|
10
|
-
export type RunValue<I, T = string> = T | ((input: StepInput<I>, run: RunInfo) => T);
|
|
11
|
-
|
|
12
|
-
export interface ScriptDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]>
|
|
13
|
-
extends StepDef<N, I, O, Sec> {
|
|
14
|
-
/** An embedded script: `import s from "./scripts/x.sh" with { type: "file" }`. */
|
|
15
|
-
script: string;
|
|
16
|
-
/** Extra environment; values may be functions of the inputs (evaluated in the step, not at describe). */
|
|
17
|
-
env?: Record<string, RunValue<I>>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** `deployTo` → `INPUT_DEPLOY_TO`. */
|
|
21
|
-
export function inputEnvName(key: string): string {
|
|
22
|
-
return "INPUT_" + key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Runs a shell script (the zero-TypeScript path). Contract for the script:
|
|
27
|
-
* - `$OLVE_INPUT`: path of the validated input as JSON; each input key is also `INPUT_<KEY>`
|
|
28
|
-
* (strings raw, everything else JSON).
|
|
29
|
-
* - `$OLVE_OUTPUT`: if the step has an output model, write the output JSON there. Objects of the
|
|
30
|
-
* form `{"$file": "<path>"}` / `{"$dir": "<path>"}` are turned into file references.
|
|
31
|
-
* - Declared secrets are in the environment under their names.
|
|
32
|
-
*/
|
|
33
|
-
export function script<
|
|
34
|
-
const N extends string,
|
|
35
|
-
const I extends InputSpec = {},
|
|
36
|
-
O extends AnyModel | null = null,
|
|
37
|
-
const Sec extends readonly Secret[] = [],
|
|
38
|
-
>(def: ScriptDef<N, I, O, Sec>): Step<N, I, O, Sec[number]["name"]> & LibraryStep {
|
|
39
|
-
const env = def.env ?? {};
|
|
40
|
-
return makeStep("script", def as never, {
|
|
41
|
-
manifestConfig: () => ({
|
|
42
|
-
scriptSha256: "sha256:" + sha256Hex(readFileSync(def.script)),
|
|
43
|
-
env: Object.fromEntries(Object.entries(env).filter(([, v]) => typeof v === "string")),
|
|
44
|
-
computedEnv: Object.keys(env).filter((k) => typeof env[k] === "function").sort(),
|
|
45
|
-
}),
|
|
46
|
-
async run(input: Record<string, unknown>, ctx: Ctx) {
|
|
47
|
-
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
48
|
-
const dir = mkdtempSync(join(ctx.tmpDir, "script-"));
|
|
49
|
-
const inputPath = join(dir, "input.json");
|
|
50
|
-
const outputPath = join(dir, "output.json");
|
|
51
|
-
writeFileSync(inputPath, JSON.stringify(input));
|
|
52
|
-
const vars: Record<string, string> = { OLVE_INPUT: inputPath };
|
|
53
|
-
for (const [k, v] of Object.entries(input)) {
|
|
54
|
-
if (v !== undefined) vars[inputEnvName(k)] = typeof v === "string" ? v : JSON.stringify(v);
|
|
55
|
-
}
|
|
56
|
-
for (const [k, v] of Object.entries(env)) {
|
|
57
|
-
vars[k] = typeof v === "function" ? (v as (i: unknown, r: RunInfo) => string)(input, ctx.run) : v;
|
|
58
|
-
}
|
|
59
|
-
if (def.output) vars.OLVE_OUTPUT = outputPath;
|
|
60
|
-
await ctx.sh({ script: def.script }, { env: vars });
|
|
61
|
-
if (!def.output) return undefined;
|
|
62
|
-
let raw: unknown;
|
|
63
|
-
try {
|
|
64
|
-
raw = JSON.parse(readFileSync(outputPath, "utf8"));
|
|
65
|
-
} catch (e) {
|
|
66
|
-
throw new Error(`script did not write valid JSON to $OLVE_OUTPUT: ${(e as Error).message}`);
|
|
67
|
-
}
|
|
68
|
-
return resolveFilePlaceholders(raw, ctx);
|
|
69
|
-
},
|
|
70
|
-
}) as never;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function resolveFilePlaceholders(v: unknown, ctx: Ctx): Promise<unknown> {
|
|
74
|
-
if (Array.isArray(v)) return Promise.all(v.map((x) => resolveFilePlaceholders(x, ctx)));
|
|
75
|
-
if (typeof v === "object" && v !== null) {
|
|
76
|
-
const o = v as Record<string, unknown>;
|
|
77
|
-
const keys = Object.keys(o);
|
|
78
|
-
if (keys.length === 1 && typeof o.$file === "string") return ctx.output.file(o.$file);
|
|
79
|
-
if (keys.length === 1 && typeof o.$dir === "string") return ctx.output.dir(o.$dir);
|
|
80
|
-
const out: Record<string, unknown> = {};
|
|
81
|
-
for (const k of keys) out[k] = await resolveFilePlaceholders(o[k], ctx);
|
|
82
|
-
return out;
|
|
83
|
-
}
|
|
84
|
-
return v;
|
|
85
|
-
}
|