@olivervea/pipelines 2.0.0-beta.6 → 2.0.0-beta.8
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 +40 -35
- package/manifest.schema.json +3 -3
- package/package.json +1 -1
- package/src/build/build.ts +16 -14
- 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 +57 -55
- 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/util/tar.ts +13 -0
- package/src/library/npm.ts +0 -33
- package/src/library/script.ts +0 -85
|
@@ -6,12 +6,13 @@ import { join } from "node:path";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import type { Ctx } from "../ctx/types.ts";
|
|
8
8
|
import type { Configurable } from "../core/config.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { resolveConfigRefs } from "../core/config-refs.ts";
|
|
10
|
+
import { implement, type Implementation } from "../core/handlers.ts";
|
|
10
11
|
import type { FileRef } from "../core/models.ts";
|
|
11
12
|
import { optional } from "../core/models.ts";
|
|
12
13
|
import type { Secret } from "../core/secrets.ts";
|
|
13
14
|
import { secretRef } from "../core/config.ts";
|
|
14
|
-
import {
|
|
15
|
+
import { step, type AnyStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
15
16
|
import type { Simplify } from "../core/types.ts";
|
|
16
17
|
import { serviceUrl } from "./checks.ts";
|
|
17
18
|
import { chartDirectory, deployOverSsh, DEFAULT_ROLLOUT_TIMEOUT_SECONDS, HELM_DEPLOY_IMAGE, helmFlags, type HelmImageFlags, type HelmImageRefFlags } from "./helm-deploy-ssh.ts";
|
|
@@ -117,7 +118,8 @@ export interface HelmTargetDeployDef<N extends string, Im extends Record<string,
|
|
|
117
118
|
inlineValues?: readonly Readonly<Record<string, unknown>>[];
|
|
118
119
|
/**
|
|
119
120
|
* The images to deploy: step input key → the chart's values prefix. Each input is a `PushedImage`;
|
|
120
|
-
* the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`.
|
|
121
|
+
* the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`. `{}` deploys
|
|
122
|
+
* the chart alone.
|
|
121
123
|
*/
|
|
122
124
|
images?: Im & { readonly source?: never };
|
|
123
125
|
/**
|
|
@@ -144,7 +146,7 @@ export interface HelmDeploySshTargetDef<N extends string, K extends string, Im e
|
|
|
144
146
|
target: Configurable<SshTargetValue>;
|
|
145
147
|
/** Private key for the target's host. */
|
|
146
148
|
sshKey: Secret<K>;
|
|
147
|
-
/** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name. */
|
|
149
|
+
/** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name, or none without images. */
|
|
148
150
|
deployments?: readonly string[];
|
|
149
151
|
}
|
|
150
152
|
|
|
@@ -157,12 +159,12 @@ export function helmDeployInCluster<
|
|
|
157
159
|
const N extends string,
|
|
158
160
|
const Im extends Record<string, string> = DefaultImages,
|
|
159
161
|
const Rf extends Record<string, string> = {},
|
|
160
|
-
>(def: HelmDeployInClusterDef<N, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never
|
|
162
|
+
>(def: HelmDeployInClusterDef<N, Im, Rf>): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never>> {
|
|
161
163
|
return targetDeploy("helmDeployInCluster", def as unknown as AnyTargetDef, undefined) as never;
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
/** The `target` form of `steps.helmDeployOverSsh`: an ssh target from config, registry images by digest. */
|
|
165
|
-
export function helmDeployOverSshTarget(def: HelmDeploySshTargetDef<string, string, Record<string, string>, Record<string, string>>):
|
|
167
|
+
export function helmDeployOverSshTarget(def: HelmDeploySshTargetDef<string, string, Record<string, string>, Record<string, string>>): Implementation {
|
|
166
168
|
return targetDeploy("helmDeployOverSsh", def as unknown as AnyTargetDef, def.sshKey, def.deployments) as never;
|
|
167
169
|
}
|
|
168
170
|
|
|
@@ -179,13 +181,12 @@ interface AnyTargetDef {
|
|
|
179
181
|
timeoutSeconds?: number;
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
function targetDeploy(kind:
|
|
184
|
+
function targetDeploy(kind: "helmDeployInCluster" | "helmDeployOverSsh", def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Implementation {
|
|
183
185
|
const images: Record<string, string> = { ...(def.images ?? { image: "image" }) };
|
|
184
186
|
const imageEntries = Object.entries(images);
|
|
185
187
|
const imageRefs: Record<string, string> = { ...(def.imageRefs ?? {}) };
|
|
186
188
|
const refEntries = Object.entries(imageRefs);
|
|
187
189
|
const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
|
|
188
|
-
if (imageEntries.length === 0) throw new Error(`${kind} '${def.name}': images must not be empty`);
|
|
189
190
|
if ("source" in images || "source" in imageRefs) throw new Error(`${kind} '${def.name}': 'source' can't be an image input`);
|
|
190
191
|
for (const [key] of refEntries) if (key in images) throw new Error(`${kind} '${def.name}': '${key}' is both in images and imageRefs`);
|
|
191
192
|
if (deployments?.length === 0) throw new Error(`${kind} '${def.name}': deployments must not be empty`);
|
|
@@ -198,71 +199,73 @@ function targetDeploy(kind: StepKind, def: AnyTargetDef, sshKey: Secret | undefi
|
|
|
198
199
|
};
|
|
199
200
|
const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
|
|
200
201
|
|
|
201
|
-
|
|
202
|
+
const contract = step({
|
|
203
|
+
name: def.name,
|
|
204
|
+
description: def.description,
|
|
202
205
|
kind,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE,
|
|
207
|
+
input,
|
|
208
|
+
output: HelmRelease,
|
|
209
|
+
secrets: sshKey ? [sshKey] : [],
|
|
210
|
+
timeoutSeconds: def.timeoutSeconds,
|
|
211
|
+
...(inCluster ? { clusterAccess: true } : {}),
|
|
212
|
+
settings: {
|
|
213
|
+
target: def.target,
|
|
214
|
+
...(sshKey ? { sshKey: sshKey.name } : {}),
|
|
215
|
+
chart: def.chart,
|
|
216
|
+
...(def.inlineValues?.length ? { inlineValues: def.inlineValues } : {}),
|
|
217
|
+
images: { ...images },
|
|
218
|
+
...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
|
|
219
|
+
...(deployments ? { deployments: [...deployments] } : {}),
|
|
220
|
+
...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
|
|
221
|
+
...(def.healthPath !== undefined ? { healthPath: def.healthPath } : {}),
|
|
206
222
|
},
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
|
|
215
|
-
...(deployments ? { deployments: [...deployments] } : {}),
|
|
216
|
-
...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
|
|
217
|
-
...(def.healthPath !== undefined ? { healthPath: def.healthPath } : {}),
|
|
218
|
-
}),
|
|
219
|
-
async run(input: Record<string, { repository: string; tag: string; digest: string } | undefined> & { source: { tree: FileRef } }, ctx: Ctx) {
|
|
220
|
-
const { target, inlineValues } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: HelmTargetValue; inlineValues: Record<string, unknown>[] };
|
|
221
|
-
const want = inCluster ? "inCluster" : "ssh";
|
|
222
|
-
if (target?.kind !== want) throw new Error(`${kind} '${def.name}': the target is kind '${String(target?.kind)}', not '${want}'`);
|
|
223
|
-
const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
|
|
223
|
+
} as never) as AnyStep;
|
|
224
|
+
return implement(contract, async (stepInput, ctx: Ctx) => {
|
|
225
|
+
const input = stepInput as unknown as Record<string, { repository: string; tag: string; digest: string } | undefined> & { source: { tree: FileRef } };
|
|
226
|
+
const { target, inlineValues } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: HelmTargetValue; inlineValues: Record<string, unknown>[] };
|
|
227
|
+
const want = inCluster ? "inCluster" : "ssh";
|
|
228
|
+
if (target?.kind !== want) throw new Error(`${kind} '${def.name}': the target is kind '${String(target?.kind)}', not '${want}'`);
|
|
229
|
+
const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
|
|
224
230
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
231
|
+
const generated = inlineValues.map((values, i) => {
|
|
232
|
+
const file = `olve-values-${i}.json`;
|
|
233
|
+
writeFileSync(join(chartDir, file), JSON.stringify(values));
|
|
234
|
+
return file;
|
|
235
|
+
});
|
|
236
|
+
const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
|
|
237
|
+
const image = input[key]!;
|
|
238
|
+
return { prefix, repository: image.repository, tag: image.tag, digest: image.digest, pullPolicy: "IfNotPresent" };
|
|
239
|
+
});
|
|
240
|
+
const refs: HelmImageRefFlags[] = refEntries.flatMap(([key, prefix]) => {
|
|
241
|
+
const image = input[key];
|
|
242
|
+
return image ? [{ prefix, repository: image.repository, tag: image.tag, digest: image.digest }] : [];
|
|
243
|
+
});
|
|
244
|
+
const helm = { values: [...target.values, ...generated], set: target.set, images: flags, imageRefs: refs };
|
|
245
|
+
const { release, namespace } = target;
|
|
240
246
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
247
|
+
if (target.kind === "ssh") {
|
|
248
|
+
const key = ctx.secrets[sshKey!.name];
|
|
249
|
+
if (!key) throw new Error(`secret ${sshKey!.name} (sshKey) is not set`);
|
|
250
|
+
await deployOverSsh(ctx, {
|
|
251
|
+
host: target.host, key, chartDir, release, namespace,
|
|
252
|
+
hostImages: flags.map((f) => ({ repository: f.repository, tag: f.tag, digest: f.digest })),
|
|
253
|
+
flags: helm, deployments: deployments ?? (imageEntries.length ? [release] : []), rolloutTimeoutSeconds: rolloutTimeout,
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
if (!existsSync(SERVICE_ACCOUNT_TOKEN))
|
|
257
|
+
throw new Error(`no ServiceAccount token in the step pod: the instance grants no step ServiceAccount (Kubernetes:StepServiceAccount), so an inCluster target can't be deployed`);
|
|
258
|
+
const home = join(ctx.tmpDir, "helm-home");
|
|
259
|
+
ctx.log("helm upgrade", { release, namespace, images: flags.map((f) => `${f.repository}@${f.digest}`).join(" ") });
|
|
260
|
+
await ctx.exec(["helm", "upgrade", "--install", release, ".", "-n", namespace, ...helmFlags(helm), "--wait", `--timeout=${rolloutTimeout}s`], {
|
|
261
|
+
cwd: chartDir,
|
|
262
|
+
env: { HOME: home, HELM_CACHE_HOME: join(home, "cache"), HELM_CONFIG_HOME: join(home, "config"), HELM_DATA_HOME: join(home, "data") },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
259
265
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
},
|
|
266
|
-
configurable,
|
|
267
|
-
) as never;
|
|
266
|
+
const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, "");
|
|
267
|
+
if (def.healthPath !== undefined) await httpHealthCheck(ctx, `${url}${def.healthPath}`, { attempts: 12, delaySeconds: 5 });
|
|
268
|
+
const main = flags[0];
|
|
269
|
+
return { release, namespace, ...(main ? { image: `${main.repository}:${main.tag}@${main.digest}` } : {}), ...(target.url ? { url } : {}) } as never;
|
|
270
|
+
}) as never;
|
|
268
271
|
}
|
|
@@ -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
|
}
|