@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.3
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 +41 -32
- package/manifest.schema.json +73 -1
- package/package.json +1 -1
- package/src/build/build.ts +3 -3
- package/src/build/cli.ts +1 -1
- package/src/build/hashes.ts +1 -1
- package/src/build/project.ts +26 -5
- package/src/builder/pipeline.ts +37 -8
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/sh.ts +2 -1
- package/src/describe/describe.ts +13 -6
- package/src/describe/manifest-schema.ts +16 -1
- package/src/dev/cli.ts +1 -1
- package/src/harness/index.ts +1 -1
- package/src/harness/main.ts +18 -5
- package/src/index.ts +3 -2
- package/src/library/checks.ts +23 -0
- package/src/library/helm-deploy-ssh.ts +107 -62
- package/src/library/image-build.ts +176 -20
- package/src/library/index.ts +4 -2
- package/src/library/models.ts +9 -0
- package/src/library/ssh.ts +45 -0
- package/src/testing/index.ts +1 -1
- package/src/util/handler-hash.ts +1 -1
package/src/describe/describe.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class DescribeError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface DescribeOptions {
|
|
23
|
-
/** Step names that have a handler
|
|
23
|
+
/** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
|
|
24
24
|
handlerNames: Iterable<string>;
|
|
25
25
|
/** Step name → handler hash (from the build; includes the harness identity). */
|
|
26
26
|
handlerHashes: Readonly<Record<string, string>>;
|
|
@@ -198,7 +198,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
198
198
|
let handlerHash: string;
|
|
199
199
|
let config: Record<string, unknown> | undefined;
|
|
200
200
|
if (s.kind === "custom") {
|
|
201
|
-
if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (
|
|
201
|
+
if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`);
|
|
202
202
|
handlerHash = opts.handlerHashes[s.name] ?? "";
|
|
203
203
|
if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
|
|
204
204
|
} else if (s.builtin) {
|
|
@@ -329,19 +329,26 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
329
329
|
return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
|
|
330
330
|
}).filter((x) => x !== undefined);
|
|
331
331
|
|
|
332
|
-
// ──
|
|
332
|
+
// ── handlers without a step ──
|
|
333
333
|
for (const h of handlerNames) {
|
|
334
334
|
const s = steps.get(h);
|
|
335
|
-
if (!s) problems.push(`
|
|
336
|
-
else if (s.kind !== "custom") problems.push(`
|
|
335
|
+
if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
|
|
336
|
+
else if (s.kind !== "custom") problems.push(`a handler implements '${h}', a library step (${s.kind}) that carries its own implementation`);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
// ── triggers ──
|
|
340
340
|
const triggerNames = new Set<string>();
|
|
341
341
|
const triggers: TriggerT[] = data.triggers.map((t) => {
|
|
342
|
-
if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll a distinct name)"}`);
|
|
342
|
+
if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll and onWebhook a distinct name)"}`);
|
|
343
343
|
triggerNames.add(t.name);
|
|
344
344
|
if (t.kind === "push") return { kind: "push", name: "push" };
|
|
345
|
+
if (t.kind === "webhook") {
|
|
346
|
+
if (!STEP_NAME.test(t.name)) problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
|
|
347
|
+
const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
|
|
348
|
+
if (t.verify.scheme === "bearer") return { kind: "webhook", name: t.name, verify: { scheme: "bearer", secret } };
|
|
349
|
+
if (!t.verify.header) problems.push(`webhook '${t.name}': the signature header must not be empty`);
|
|
350
|
+
return { kind: "webhook", name: t.name, verify: { scheme: "hmac-sha256", secret, header: t.verify.header, prefix: t.verify.prefix } };
|
|
351
|
+
}
|
|
345
352
|
if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
|
|
346
353
|
if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
|
|
347
354
|
const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
|
|
@@ -90,6 +90,21 @@ export const Trigger = z
|
|
|
90
90
|
intervalSeconds: z.number().int().positive(),
|
|
91
91
|
})
|
|
92
92
|
.describe("Background poller."),
|
|
93
|
+
z
|
|
94
|
+
.strictObject({
|
|
95
|
+
kind: z.literal("webhook"),
|
|
96
|
+
name: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/),
|
|
97
|
+
verify: z.discriminatedUnion("scheme", [
|
|
98
|
+
z.strictObject({
|
|
99
|
+
scheme: z.literal("hmac-sha256"),
|
|
100
|
+
secret: secretName,
|
|
101
|
+
header: z.string().min(1),
|
|
102
|
+
prefix: z.string().describe("Text before the hex digest in the header, e.g. 'sha256='."),
|
|
103
|
+
}),
|
|
104
|
+
z.strictObject({ scheme: z.literal("bearer"), secret: secretName }),
|
|
105
|
+
]),
|
|
106
|
+
})
|
|
107
|
+
.describe("Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it."),
|
|
93
108
|
])
|
|
94
109
|
.meta({ id: "Trigger" });
|
|
95
110
|
|
|
@@ -102,7 +117,7 @@ export const Manifest = z
|
|
|
102
117
|
harness: z
|
|
103
118
|
.strictObject({
|
|
104
119
|
bun: z.string().describe("Bun runtime version compiled into the binary."),
|
|
105
|
-
sdk: z.string().describe("@
|
|
120
|
+
sdk: z.string().describe("@olivervea/pipelines package version."),
|
|
106
121
|
sdkHash: hash.describe("Hash of the SDK runtime sources + zod version."),
|
|
107
122
|
})
|
|
108
123
|
.describe("Harness identity. Already folded into every handlerHash."),
|
package/src/dev/cli.ts
CHANGED
|
@@ -17,7 +17,7 @@ const USAGE = `usage: olve-dev describe <dir> [--sentinels]
|
|
|
17
17
|
olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]`;
|
|
18
18
|
|
|
19
19
|
export async function devConfig(dir: string, withHashes: boolean): Promise<HarnessConfig> {
|
|
20
|
-
const project = loadProject(dir);
|
|
20
|
+
const project = await loadProject(dir);
|
|
21
21
|
const mod = (await import(project.pipelineFile)) as { default?: unknown };
|
|
22
22
|
if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
|
|
23
23
|
const harness = harnessInfo();
|
package/src/harness/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @
|
|
1
|
+
// @olivervea/pipelines/harness — the runtime the build tool compiles into the step binary.
|
|
2
2
|
export { runHarness, defaultIO, allSteps, resolveStepFunction, type HarnessConfig, type HandlerRegistry, type HarnessIO } from "./main.ts";
|
|
3
3
|
export { EXIT } from "./exit.ts";
|
|
4
4
|
export { ENV, paths, runInfoFromEnv } from "./env.ts";
|
package/src/harness/main.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { mkdirSync, readFileSync } from "node:fs";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { isImplementation } from "../core/handlers.ts";
|
|
8
8
|
import type { AnyStep } from "../core/steps.ts";
|
|
9
|
+
import type { WebhookEvent } from "../core/webhooks.ts";
|
|
9
10
|
import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
|
|
10
11
|
import { createCtx, formatLog } from "../ctx/index.ts";
|
|
11
12
|
import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
|
|
@@ -76,17 +77,17 @@ export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegist
|
|
|
76
77
|
return step.builtin.run as StepFunction;
|
|
77
78
|
}
|
|
78
79
|
const load = handlers[step.name];
|
|
79
|
-
if (!load) return `step '${step.name}' has no handler
|
|
80
|
+
if (!load) return `step '${step.name}' has no handler`;
|
|
80
81
|
let mod: { default: unknown };
|
|
81
82
|
try {
|
|
82
83
|
mod = await load();
|
|
83
84
|
} catch (e) {
|
|
84
|
-
return `
|
|
85
|
+
return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
|
|
85
86
|
}
|
|
86
87
|
const impl = mod.default;
|
|
87
|
-
if (!isImplementation(impl)) return `
|
|
88
|
+
if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
|
|
88
89
|
if (impl.step !== step)
|
|
89
|
-
return `
|
|
90
|
+
return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
|
|
90
91
|
return impl.handler as unknown as StepFunction;
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -162,6 +163,12 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
|
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
function isWebhookEvent(v: unknown): boolean {
|
|
167
|
+
if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
|
|
168
|
+
const headers = (v as { headers?: unknown }).headers;
|
|
169
|
+
return typeof headers === "object" && headers !== null && !Array.isArray(headers);
|
|
170
|
+
}
|
|
171
|
+
|
|
165
172
|
async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
|
|
166
173
|
if (!name) return io.stderr(USAGE), EXIT.usage;
|
|
167
174
|
const data = pipelineData(cfg.pipeline);
|
|
@@ -181,15 +188,21 @@ async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io:
|
|
|
181
188
|
exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
|
|
182
189
|
else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
|
|
183
190
|
exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
|
|
191
|
+
else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
|
|
192
|
+
exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
|
|
184
193
|
else {
|
|
185
194
|
let mapped: unknown;
|
|
186
195
|
let threw: unknown = undefined;
|
|
187
196
|
try {
|
|
188
|
-
mapped =
|
|
197
|
+
mapped =
|
|
198
|
+
trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
|
|
199
|
+
: trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
|
|
200
|
+
: await trigger.map(raw.value);
|
|
189
201
|
} catch (e) {
|
|
190
202
|
threw = e ?? new Error("undefined");
|
|
191
203
|
}
|
|
192
204
|
if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
|
|
205
|
+
else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
|
|
193
206
|
else {
|
|
194
207
|
const r = parseStrict(data.input.schema, mapped, "input");
|
|
195
208
|
exec = r.ok
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/// <reference path="./assets.d.ts" />
|
|
2
|
-
// @
|
|
2
|
+
// @olivervea/pipelines — authoring API (contracts, handlers, pipeline builder).
|
|
3
3
|
export { z } from "zod";
|
|
4
4
|
export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
|
|
5
5
|
export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
|
|
6
|
+
export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
|
|
6
7
|
export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
|
|
7
8
|
export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
|
|
8
9
|
export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
|
|
9
10
|
export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
|
|
10
11
|
export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
|
|
11
|
-
export { steps, Source, BuiltImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions } from "./library/index.ts";
|
|
12
|
+
export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions, sshSession, shq, type SshSession, serviceUrl, checkSpa } from "./library/index.ts";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Checks for deployed apps, used from custom test handlers.
|
|
2
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
3
|
+
|
|
4
|
+
/** The in-cluster URL of a release's Service, assuming the chart names it after the release. */
|
|
5
|
+
export function serviceUrl(release: { release: string; namespace: string }): string {
|
|
6
|
+
return `http://${release.release}.${release.namespace}.svc.cluster.local`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Checks `url` serves a single-page app: `GET <url>/` references a `/assets/*.js` bundle (Vite's
|
|
11
|
+
* layout), and the bundle is served as JavaScript. Returns the page, for app-specific checks.
|
|
12
|
+
*/
|
|
13
|
+
export async function checkSpa(ctx: Pick<Ctx, "http" | "log">, url: string): Promise<{ html: string; bundle: string }> {
|
|
14
|
+
const base = url.replace(/\/$/, "");
|
|
15
|
+
const html = await (await ctx.http.expectOk(`${base}/`)).text();
|
|
16
|
+
const bundle = html.match(/src="(\/assets\/[^"]+\.js)"/)?.[1];
|
|
17
|
+
if (bundle === undefined) throw new Error(`${base}/ does not reference an /assets/*.js bundle`);
|
|
18
|
+
const js = await ctx.http.expectOk(`${base}${bundle}`);
|
|
19
|
+
await js.body?.cancel().catch(() => {});
|
|
20
|
+
if (!(js.headers.get("content-type")?.includes("javascript") ?? false)) throw new Error(`${base}${bundle} is not served as JavaScript`);
|
|
21
|
+
ctx.log("SPA served", { url: base, bundle });
|
|
22
|
+
return { html, bundle };
|
|
23
|
+
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
// steps.helmDeployOverSsh —
|
|
2
|
-
//
|
|
1
|
+
// steps.helmDeployOverSsh — get built images onto a k3s host and helm-upgrade a release there, over
|
|
2
|
+
// SSH; optionally verify an HTTP health endpoint afterwards.
|
|
3
3
|
//
|
|
4
4
|
// Ported from v1's olve-lib.sh (olve_ssh_host, olve_image_import, olve_helm_deploy) and
|
|
5
5
|
// deploy-beta.sh (rollout wait + health loop); the footguns are kept as comments below.
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync, statSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import type { Ctx } from "../ctx/types.ts";
|
|
9
9
|
import type { FileRef } from "../core/models.ts";
|
|
10
10
|
import type { Secret } from "../core/secrets.ts";
|
|
11
11
|
import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import type { Simplify } from "../core/types.ts";
|
|
13
|
+
import { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
|
|
14
|
+
import { BuiltImage, HelmRelease, PushedImage, Source } from "./models.ts";
|
|
15
|
+
import { shq, sshSession } from "./ssh.ts";
|
|
16
|
+
|
|
17
|
+
export { shq } from "./ssh.ts";
|
|
14
18
|
|
|
15
19
|
/** Alpine: `apk add openssh-client` at run time gives ssh/scp (the health check runs in the harness). */
|
|
16
20
|
export const HELM_DEPLOY_IMAGE = "alpine:3.22";
|
|
@@ -20,13 +24,18 @@ export const HELM_DEPLOY_IMAGE = "alpine:3.22";
|
|
|
20
24
|
* the k3s instance's `k8s.io` namespace (otherwise pods fail with ErrImageNeverPull).
|
|
21
25
|
*/
|
|
22
26
|
export const K3S_IMAGE_IMPORT = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load";
|
|
27
|
+
/** Registry images are pulled the same way before the rollout, so the rollout doesn't wait on the pull. */
|
|
28
|
+
export const K3S_IMAGE_PULL = "sudo nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io pull -q";
|
|
23
29
|
|
|
24
|
-
const
|
|
30
|
+
export const DEFAULT_ROLLOUT_TIMEOUT_SECONDS = 120;
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
type DefaultImages = { readonly image: "image" };
|
|
33
|
+
type ImageModel<R extends boolean> = R extends true ? typeof PushedImage : typeof BuiltImage;
|
|
34
|
+
export type HelmDeployOverSshInput<Im extends Record<string, string> = DefaultImages, R extends boolean = false> = Simplify<
|
|
35
|
+
{ readonly source: typeof Source } & { readonly [K in keyof Im]: ImageModel<R> }
|
|
36
|
+
>;
|
|
28
37
|
|
|
29
|
-
export interface HelmDeployOverSshDef<N extends string, K extends string> {
|
|
38
|
+
export interface HelmDeployOverSshDef<N extends string, K extends string, Im extends Record<string, string> = DefaultImages, R extends boolean = false> {
|
|
30
39
|
name: LiteralName<N>;
|
|
31
40
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
32
41
|
description: string;
|
|
@@ -34,7 +43,7 @@ export interface HelmDeployOverSshDef<N extends string, K extends string> {
|
|
|
34
43
|
host: string;
|
|
35
44
|
/** Private key for `host`. */
|
|
36
45
|
sshKey: Secret<K>;
|
|
37
|
-
/** Helm release name
|
|
46
|
+
/** Helm release name. */
|
|
38
47
|
release: string;
|
|
39
48
|
namespace: string;
|
|
40
49
|
/** Chart directory inside the source tree, e.g. `helm`. */
|
|
@@ -42,19 +51,36 @@ export interface HelmDeployOverSshDef<N extends string, K extends string> {
|
|
|
42
51
|
/** Values files, relative to the chart directory (`-f`, in order). */
|
|
43
52
|
values?: readonly string[];
|
|
44
53
|
/**
|
|
45
|
-
* Chart-specific `--set` flags (e.g. `{ "
|
|
46
|
-
* the step and always win:
|
|
54
|
+
* Chart-specific `--set` flags (e.g. `{ "ingress.enabled": "false" }`). The image flags are added by
|
|
55
|
+
* the step and always win: `<prefix>.repository`, `<prefix>.tag`, `<prefix>.pullPolicy`.
|
|
47
56
|
*/
|
|
48
57
|
set?: Readonly<Record<string, string>>;
|
|
49
|
-
/**
|
|
50
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The images to deploy: step input key → the chart's values prefix for that image. Each input is a
|
|
60
|
+
* `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
|
|
61
|
+
* need a mapper, e.g. `{ server: "image", web: "web.image" }`.
|
|
62
|
+
*/
|
|
63
|
+
images?: Im & { readonly source?: never };
|
|
64
|
+
/**
|
|
65
|
+
* The images are `PushedImage`s from a registry (`imageBuild` with `push`): pulled on the host
|
|
66
|
+
* first, then `pullPolicy=IfNotPresent`. Default: `BuiltImage` tarballs, imported on the host, then
|
|
67
|
+
* `pullPolicy=Never`.
|
|
68
|
+
*/
|
|
69
|
+
fromRegistry?: R & (boolean extends R ? "❌ fromRegistry must be a literal true or false (use `as const` on a shared config object)" : unknown);
|
|
70
|
+
/** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name. */
|
|
71
|
+
deployments?: readonly string[];
|
|
72
|
+
/** Per rollout (`kubectl rollout status --timeout`). Default 120. */
|
|
73
|
+
rolloutTimeoutSeconds?: number;
|
|
74
|
+
/** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
|
|
75
|
+
healthUrl?: string | ({ url: string } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
|
|
51
76
|
timeoutSeconds?: number;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
export interface HelmImageFlags {
|
|
80
|
+
prefix: string;
|
|
81
|
+
repository: string;
|
|
82
|
+
tag: string;
|
|
83
|
+
pullPolicy: "Never" | "IfNotPresent";
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
/** Remote `helm upgrade --install`, run from inside the copied chart directory. */
|
|
@@ -64,18 +90,18 @@ export function helmUpgradeCommand(o: {
|
|
|
64
90
|
namespace: string;
|
|
65
91
|
values: readonly string[];
|
|
66
92
|
set: Readonly<Record<string, string>>;
|
|
67
|
-
|
|
68
|
-
tag: string;
|
|
93
|
+
images: readonly HelmImageFlags[];
|
|
69
94
|
}): string {
|
|
70
95
|
// Chart-relative values paths (`-f values-beta.yaml`) resolve because helm runs from inside the
|
|
71
|
-
// copied chart dir. Extra flags come before the image flags, so the image overrides win.
|
|
72
|
-
// is local to the node (imported above), never pulled: pullPolicy=Never.
|
|
96
|
+
// copied chart dir. Extra flags come before the image flags, so the image overrides win.
|
|
73
97
|
const flags = [
|
|
74
98
|
...o.values.flatMap((v) => ["-f", v]),
|
|
75
99
|
...Object.entries(o.set).flatMap(([k, v]) => ["--set", `${k}=${v}`]),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
...o.images.flatMap((i) => [
|
|
101
|
+
"--set", `${i.prefix}.repository=${i.repository}`,
|
|
102
|
+
"--set", `${i.prefix}.tag=${i.tag}`,
|
|
103
|
+
"--set", `${i.prefix}.pullPolicy=${i.pullPolicy}`,
|
|
104
|
+
]),
|
|
79
105
|
];
|
|
80
106
|
return [
|
|
81
107
|
`cd ${shq(o.remoteChart)}`,
|
|
@@ -85,15 +111,31 @@ export function helmUpgradeCommand(o: {
|
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
/**
|
|
88
|
-
* Deploys
|
|
89
|
-
*
|
|
90
|
-
* wait for
|
|
114
|
+
* Deploys images with the Helm chart at `<source>/<chart>` on a k3s host over SSH: get each image
|
|
115
|
+
* into the host's k3s containerd (stream a tarball in, or pull from the registry), copy the chart,
|
|
116
|
+
* `helm upgrade --install`, wait for each rollout, then (with `healthUrl`) poll the health endpoint.
|
|
91
117
|
*/
|
|
92
|
-
export function helmDeployOverSsh<
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
export function helmDeployOverSsh<
|
|
119
|
+
const N extends string,
|
|
120
|
+
const K extends string,
|
|
121
|
+
const Im extends Record<string, string> = DefaultImages,
|
|
122
|
+
const R extends boolean = false,
|
|
123
|
+
>(def: HelmDeployOverSshDef<N, K, Im, R>): Step<N, HelmDeployOverSshInput<Im, R>, typeof HelmRelease, K> & LibraryStep {
|
|
95
124
|
const values = def.values ?? [];
|
|
96
125
|
const set = def.set ?? {};
|
|
126
|
+
const images: Record<string, string> = def.images ?? { image: "image" };
|
|
127
|
+
const imageEntries = Object.entries(images);
|
|
128
|
+
const fromRegistry = def.fromRegistry === true;
|
|
129
|
+
const deployments = def.deployments ?? [def.release];
|
|
130
|
+
const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
|
|
131
|
+
const health = typeof def.healthUrl === "string" ? { url: def.healthUrl } : def.healthUrl;
|
|
132
|
+
if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
|
|
133
|
+
if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
|
|
134
|
+
if (deployments.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
|
|
135
|
+
const imageModel = fromRegistry ? PushedImage : BuiltImage;
|
|
136
|
+
const input = { source: Source, ...Object.fromEntries(imageEntries.map(([key]) => [key, imageModel])) };
|
|
137
|
+
const isDefaultImages = imageEntries.length === 1 && images.image === "image";
|
|
138
|
+
|
|
97
139
|
return makeStep(
|
|
98
140
|
"helmDeployOverSsh",
|
|
99
141
|
{ name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
|
|
@@ -106,45 +148,48 @@ export function helmDeployOverSsh<const N extends string, const K extends string
|
|
|
106
148
|
chart: def.chart,
|
|
107
149
|
values: [...values],
|
|
108
150
|
set: { ...set },
|
|
109
|
-
...(
|
|
151
|
+
...(isDefaultImages ? {} : { images: { ...images } }),
|
|
152
|
+
...(fromRegistry ? { fromRegistry: true } : {}),
|
|
153
|
+
...(def.deployments ? { deployments: [...def.deployments] } : {}),
|
|
154
|
+
...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
|
|
155
|
+
...(def.healthUrl !== undefined ? { healthUrl: typeof def.healthUrl === "string" ? def.healthUrl : { ...def.healthUrl } } : {}),
|
|
110
156
|
}),
|
|
111
|
-
async run(
|
|
112
|
-
|
|
113
|
-
ctx: Ctx,
|
|
114
|
-
) {
|
|
115
|
-
const tree = await ctx.files.fetch(source.tree);
|
|
157
|
+
async run(input: Record<string, { repository: string; tag: string; tar?: FileRef }> & { source: { tree: FileRef } }, ctx: Ctx) {
|
|
158
|
+
const tree = await ctx.files.fetch(input.source.tree);
|
|
116
159
|
const chartDir = join(tree, def.chart);
|
|
117
160
|
if (!existsSync(chartDir) || !statSync(chartDir).isDirectory()) throw new Error(`chart directory '${def.chart}' not found in the source tree`);
|
|
118
|
-
const imageTar = await ctx.files.fetch(image.tar);
|
|
119
|
-
|
|
120
|
-
// SSH setup. The key file needs a trailing newline (OpenSSH rejects the key otherwise) and
|
|
121
|
-
// mode 600. The host key is not verified (as in v1): the host is reached over the tailnet.
|
|
122
|
-
await ctx.exec(["apk", "add", "--no-cache", "openssh-client"]);
|
|
123
|
-
const sshDir = join(ctx.tmpDir, "ssh");
|
|
124
|
-
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
125
|
-
const keyFile = join(sshDir, "id");
|
|
126
|
-
const key = ctx.secrets[def.sshKey.name]!;
|
|
127
|
-
writeFileSync(keyFile, key.endsWith("\n") ? key : key + "\n", { mode: 0o600 });
|
|
128
|
-
chmodSync(keyFile, 0o600);
|
|
129
|
-
const opts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
|
|
130
|
-
const ssh = (remote: string) => ctx.exec(["ssh", ...opts, def.host, remote]);
|
|
131
161
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
162
|
+
const ssh = await sshSession(ctx, { host: def.host, key: ctx.secrets[def.sshKey.name]! });
|
|
163
|
+
const flags: HelmImageFlags[] = [];
|
|
164
|
+
for (const [key, prefix] of imageEntries) {
|
|
165
|
+
const image = input[key]!;
|
|
166
|
+
const ref = `${image.repository}:${image.tag}`;
|
|
167
|
+
if (fromRegistry) {
|
|
168
|
+
ctx.log("pulling image", { image: ref, host: def.host });
|
|
169
|
+
await ssh.run(`${K3S_IMAGE_PULL} ${shq(ref)}`);
|
|
170
|
+
} else {
|
|
171
|
+
const tar = await ctx.files.fetch(image.tar!);
|
|
172
|
+
ctx.log("importing image", { image: ref, host: def.host });
|
|
173
|
+
// Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
|
|
174
|
+
await ctx.sh(`${ssh.shell(K3S_IMAGE_IMPORT)} < ${shq(tar)}`);
|
|
175
|
+
}
|
|
176
|
+
flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
|
|
177
|
+
}
|
|
136
178
|
|
|
137
179
|
const remoteChart = `/tmp/${def.release}-helm-${def.namespace}`;
|
|
138
|
-
ctx.log("helm upgrade", { release: def.release, namespace: def.namespace,
|
|
139
|
-
await ssh(`rm -rf ${shq(remoteChart)}`);
|
|
140
|
-
await
|
|
141
|
-
await ssh(helmUpgradeCommand({ remoteChart, release: def.release, namespace: def.namespace, values, set,
|
|
180
|
+
ctx.log("helm upgrade", { release: def.release, namespace: def.namespace, images: flags.map((f) => `${f.repository}:${f.tag}`).join(" ") });
|
|
181
|
+
await ssh.run(`rm -rf ${shq(remoteChart)}`);
|
|
182
|
+
await ssh.copy(chartDir, remoteChart);
|
|
183
|
+
await ssh.run(helmUpgradeCommand({ remoteChart, release: def.release, namespace: def.namespace, values, set, images: flags }));
|
|
142
184
|
|
|
143
|
-
|
|
144
|
-
|
|
185
|
+
for (const d of deployments) {
|
|
186
|
+
const target = d.includes("/") ? d : `deploy/${d}`;
|
|
187
|
+
ctx.log("waiting for rollout", { deployment: target });
|
|
188
|
+
await ssh.run(`kubectl -n ${shq(def.namespace)} rollout status ${shq(target)} --timeout=${rolloutTimeout}s`);
|
|
189
|
+
}
|
|
145
190
|
|
|
146
|
-
if (
|
|
147
|
-
return { release: def.release, namespace: def.namespace, image:
|
|
191
|
+
if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
|
|
192
|
+
return { release: def.release, namespace: def.namespace, image: `${flags[0]!.repository}:${flags[0]!.tag}` };
|
|
148
193
|
},
|
|
149
194
|
},
|
|
150
195
|
) as never;
|