@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -13
- package/manifest.schema.json +87 -9
- package/package.json +1 -1
- package/src/builder/pipeline.ts +55 -2
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +3 -3
- package/src/core/steps.ts +29 -6
- package/src/ctx/index.ts +3 -0
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +182 -8
- package/src/describe/manifest-schema.ts +36 -3
- package/src/dev/cli.ts +10 -3
- package/src/harness/execute.ts +7 -4
- package/src/harness/main.ts +45 -6
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +3 -2
- package/src/library/compose-deploy.ts +93 -0
- package/src/library/github-source.ts +23 -4
- package/src/library/helm-deploy-ssh.ts +166 -58
- package/src/library/helm-deploy.ts +268 -0
- package/src/library/image-build.ts +223 -39
- package/src/library/index.ts +7 -2
- package/src/library/models.ts +7 -1
- package/src/library/npm.ts +33 -0
- package/src/testing/index.ts +5 -3
|
@@ -8,11 +8,14 @@ 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
|
+
import type { Configurable } from "../core/config.ts";
|
|
12
|
+
import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
|
|
11
13
|
import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
12
14
|
import type { Simplify } from "../core/types.ts";
|
|
13
15
|
import { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
|
|
14
16
|
import { BuiltImage, HelmRelease, PushedImage, Source } from "./models.ts";
|
|
15
17
|
import { shq, sshSession } from "./ssh.ts";
|
|
18
|
+
import { helmDeployOverSshTarget, type HelmDeploySshTargetDef, type HelmTargetDeployInput } from "./helm-deploy.ts";
|
|
16
19
|
|
|
17
20
|
export { shq } from "./ssh.ts";
|
|
18
21
|
|
|
@@ -31,30 +34,40 @@ export const DEFAULT_ROLLOUT_TIMEOUT_SECONDS = 120;
|
|
|
31
34
|
|
|
32
35
|
type DefaultImages = { readonly image: "image" };
|
|
33
36
|
type ImageModel<R extends boolean> = R extends true ? typeof PushedImage : typeof BuiltImage;
|
|
34
|
-
export type HelmDeployOverSshInput<
|
|
35
|
-
|
|
37
|
+
export type HelmDeployOverSshInput<
|
|
38
|
+
Im extends Record<string, string> = DefaultImages,
|
|
39
|
+
R extends boolean = false,
|
|
40
|
+
Rf extends Record<string, string> = {},
|
|
41
|
+
> = Simplify<
|
|
42
|
+
{ readonly source: typeof Source } & { readonly [K in keyof Im]: ImageModel<R> } & { readonly [K in keyof Rf]: typeof PushedImage }
|
|
36
43
|
>;
|
|
37
44
|
|
|
38
|
-
export interface HelmDeployOverSshDef<
|
|
45
|
+
export interface HelmDeployOverSshDef<
|
|
46
|
+
N extends string,
|
|
47
|
+
K extends string,
|
|
48
|
+
Im extends Record<string, string> = DefaultImages,
|
|
49
|
+
R extends boolean = false,
|
|
50
|
+
Rf extends Record<string, string> = {},
|
|
51
|
+
> {
|
|
39
52
|
name: LiteralName<N>;
|
|
40
53
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
41
54
|
description: string;
|
|
42
55
|
/** SSH target, `user@host`. The user needs passwordless sudo for nerdctl, and helm/kubectl with a kubeconfig. */
|
|
43
|
-
host: string
|
|
56
|
+
host: Configurable<string>;
|
|
44
57
|
/** Private key for `host`. */
|
|
45
58
|
sshKey: Secret<K>;
|
|
46
59
|
/** Helm release name. */
|
|
47
|
-
release: string
|
|
48
|
-
namespace: string
|
|
60
|
+
release: Configurable<string>;
|
|
61
|
+
namespace: Configurable<string>;
|
|
49
62
|
/** Chart directory inside the source tree, e.g. `helm`. */
|
|
50
63
|
chart: string;
|
|
51
64
|
/** Values files, relative to the chart directory (`-f`, in order). */
|
|
52
|
-
values?: readonly string[]
|
|
65
|
+
values?: Configurable<readonly string[]>;
|
|
53
66
|
/**
|
|
54
67
|
* Chart-specific `--set` flags (e.g. `{ "ingress.enabled": "false" }`). The image flags are added by
|
|
55
68
|
* the step and always win: `<prefix>.repository`, `<prefix>.tag`, `<prefix>.pullPolicy`.
|
|
56
69
|
*/
|
|
57
|
-
set?: Readonly<Record<string, string
|
|
70
|
+
set?: Configurable<Readonly<Record<string, string>>>;
|
|
58
71
|
/**
|
|
59
72
|
* The images to deploy: step input key → the chart's values prefix for that image. Each input is a
|
|
60
73
|
* `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
|
|
@@ -67,12 +80,19 @@ export interface HelmDeployOverSshDef<N extends string, K extends string, Im ext
|
|
|
67
80
|
* `pullPolicy=Never`.
|
|
68
81
|
*/
|
|
69
82
|
fromRegistry?: R & (boolean extends R ? "❌ fromRegistry must be a literal true or false (use `as const` on a shared config object)" : unknown);
|
|
83
|
+
/**
|
|
84
|
+
* Registry images the chart only references (not run by the release's own pods, e.g. an image the
|
|
85
|
+
* app starts Jobs with): step input key → values prefix. Each input is a `PushedImage`; the step sets
|
|
86
|
+
* `<prefix>.repository`, `<prefix>.tag` and `<prefix>.digest` and does not pull it on the host. Works
|
|
87
|
+
* next to tarball `images`.
|
|
88
|
+
*/
|
|
89
|
+
imageRefs?: Rf & { readonly source?: never };
|
|
70
90
|
/** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name. */
|
|
71
91
|
deployments?: readonly string[];
|
|
72
92
|
/** Per rollout (`kubectl rollout status --timeout`). Default 120. */
|
|
73
93
|
rolloutTimeoutSeconds?: number;
|
|
74
94
|
/** 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">);
|
|
95
|
+
healthUrl?: Configurable<string> | ({ url: Configurable<string> } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
|
|
76
96
|
timeoutSeconds?: number;
|
|
77
97
|
}
|
|
78
98
|
|
|
@@ -81,19 +101,30 @@ export interface HelmImageFlags {
|
|
|
81
101
|
repository: string;
|
|
82
102
|
tag: string;
|
|
83
103
|
pullPolicy: "Never" | "IfNotPresent";
|
|
104
|
+
/** Pins the image: `<prefix>.digest` (the chart renders `repository:tag@digest`). */
|
|
105
|
+
digest?: string;
|
|
84
106
|
}
|
|
85
107
|
|
|
86
|
-
/**
|
|
87
|
-
export
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
/** A registry image the chart only references (`imageRefs`). */
|
|
109
|
+
export interface HelmImageRefFlags {
|
|
110
|
+
prefix: string;
|
|
111
|
+
repository: string;
|
|
112
|
+
tag: string;
|
|
113
|
+
digest: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface HelmFlagOptions {
|
|
91
117
|
values: readonly string[];
|
|
92
118
|
set: Readonly<Record<string, string>>;
|
|
93
119
|
images: readonly HelmImageFlags[];
|
|
94
|
-
|
|
120
|
+
imageRefs?: readonly HelmImageRefFlags[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `helm upgrade` flags: values files, `--set`, then the image flags (which win). */
|
|
124
|
+
export function helmFlags(o: HelmFlagOptions): string[] {
|
|
95
125
|
// Chart-relative values paths (`-f values-beta.yaml`) resolve because helm runs from inside the
|
|
96
126
|
// copied chart dir. Extra flags come before the image flags, so the image overrides win.
|
|
127
|
+
// --set-string: a tag like `2026.10` or `1e5` would otherwise be parsed as a number.
|
|
97
128
|
const flags = [
|
|
98
129
|
...o.values.flatMap((v) => ["-f", v]),
|
|
99
130
|
...Object.entries(o.set).flatMap(([k, v]) => ["--set", `${k}=${v}`]),
|
|
@@ -101,8 +132,20 @@ export function helmUpgradeCommand(o: {
|
|
|
101
132
|
"--set", `${i.prefix}.repository=${i.repository}`,
|
|
102
133
|
"--set", `${i.prefix}.tag=${i.tag}`,
|
|
103
134
|
"--set", `${i.prefix}.pullPolicy=${i.pullPolicy}`,
|
|
135
|
+
...(i.digest ? ["--set-string", `${i.prefix}.digest=${i.digest}`] : []),
|
|
136
|
+
]),
|
|
137
|
+
...(o.imageRefs ?? []).flatMap((i) => [
|
|
138
|
+
"--set-string", `${i.prefix}.repository=${i.repository}`,
|
|
139
|
+
"--set-string", `${i.prefix}.tag=${i.tag}`,
|
|
140
|
+
"--set-string", `${i.prefix}.digest=${i.digest}`,
|
|
104
141
|
]),
|
|
105
142
|
];
|
|
143
|
+
return flags;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Remote `helm upgrade --install`, run from inside the copied chart directory. */
|
|
147
|
+
export function helmUpgradeCommand(o: HelmFlagOptions & { remoteChart: string; release: string; namespace: string }): string {
|
|
148
|
+
const flags = helmFlags(o);
|
|
106
149
|
return [
|
|
107
150
|
`cd ${shq(o.remoteChart)}`,
|
|
108
151
|
["helm", "upgrade", "--install", o.release, ".", "-n", o.namespace, ...flags].map(shq).join(" "),
|
|
@@ -110,87 +153,152 @@ export function helmUpgradeCommand(o: {
|
|
|
110
153
|
].join(" && ");
|
|
111
154
|
}
|
|
112
155
|
|
|
156
|
+
/** One image to get onto the host before the upgrade. */
|
|
157
|
+
export interface HostImage {
|
|
158
|
+
repository: string;
|
|
159
|
+
tag: string;
|
|
160
|
+
digest?: string;
|
|
161
|
+
/** An image tarball to stream in; without one the image is pulled from its registry. */
|
|
162
|
+
tar?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Over SSH: get each image into the host's k3s containerd, copy the chart (with any generated values
|
|
167
|
+
* files in it), `helm upgrade --install`, then wait for each rollout.
|
|
168
|
+
*/
|
|
169
|
+
export async function deployOverSsh(
|
|
170
|
+
ctx: Ctx,
|
|
171
|
+
o: {
|
|
172
|
+
host: string;
|
|
173
|
+
key: string;
|
|
174
|
+
chartDir: string;
|
|
175
|
+
release: string;
|
|
176
|
+
namespace: string;
|
|
177
|
+
hostImages: readonly HostImage[];
|
|
178
|
+
flags: HelmFlagOptions;
|
|
179
|
+
deployments: readonly string[];
|
|
180
|
+
rolloutTimeoutSeconds: number;
|
|
181
|
+
},
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const ssh = await sshSession(ctx, { host: o.host, key: o.key });
|
|
184
|
+
for (const image of o.hostImages) {
|
|
185
|
+
if (image.tar) {
|
|
186
|
+
ctx.log("importing image", { image: `${image.repository}:${image.tag}`, host: o.host });
|
|
187
|
+
// Streamed over ssh's stdin (ctx.exec has none, hence the shell redirect).
|
|
188
|
+
await ctx.sh(`${ssh.shell(K3S_IMAGE_IMPORT)} < ${shq(image.tar)}`);
|
|
189
|
+
} else {
|
|
190
|
+
const ref = image.digest ? `${image.repository}@${image.digest}` : `${image.repository}:${image.tag}`;
|
|
191
|
+
ctx.log("pulling image", { image: ref, host: o.host });
|
|
192
|
+
await ssh.run(`${K3S_IMAGE_PULL} ${shq(ref)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const remoteChart = `/tmp/${o.release}-helm-${o.namespace}`;
|
|
196
|
+
ctx.log("helm upgrade", { release: o.release, namespace: o.namespace, host: o.host });
|
|
197
|
+
await ssh.run(`rm -rf ${shq(remoteChart)}`);
|
|
198
|
+
await ssh.copy(o.chartDir, remoteChart);
|
|
199
|
+
await ssh.run(helmUpgradeCommand({ ...o.flags, remoteChart, release: o.release, namespace: o.namespace }));
|
|
200
|
+
for (const d of o.deployments) {
|
|
201
|
+
const target = d.includes("/") ? d : `deploy/${d}`;
|
|
202
|
+
ctx.log("waiting for rollout", { deployment: target });
|
|
203
|
+
await ssh.run(`kubectl -n ${shq(o.namespace)} rollout status ${shq(target)} --timeout=${o.rolloutTimeoutSeconds}s`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The chart directory inside a fetched source tree, or an error naming it. */
|
|
208
|
+
export function chartDirectory(tree: string, chart: string): string {
|
|
209
|
+
const dir = join(tree, chart);
|
|
210
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`chart directory '${chart}' not found in the source tree`);
|
|
211
|
+
return dir;
|
|
212
|
+
}
|
|
213
|
+
|
|
113
214
|
/**
|
|
114
215
|
* Deploys images with the Helm chart at `<source>/<chart>` on a k3s host over SSH: get each image
|
|
115
216
|
* into the host's k3s containerd (stream a tarball in, or pull from the registry), copy the chart,
|
|
116
217
|
* `helm upgrade --install`, wait for each rollout, then (with `healthUrl`) poll the health endpoint.
|
|
117
218
|
*/
|
|
219
|
+
export function helmDeployOverSsh<
|
|
220
|
+
const N extends string,
|
|
221
|
+
const K extends string,
|
|
222
|
+
const Im extends Record<string, string> = DefaultImages,
|
|
223
|
+
const Rf extends Record<string, string> = {},
|
|
224
|
+
>(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K> & LibraryStep;
|
|
118
225
|
export function helmDeployOverSsh<
|
|
119
226
|
const N extends string,
|
|
120
227
|
const K extends string,
|
|
121
228
|
const Im extends Record<string, string> = DefaultImages,
|
|
122
229
|
const R extends boolean = false,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
230
|
+
const Rf extends Record<string, string> = {},
|
|
231
|
+
>(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K> & LibraryStep;
|
|
232
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
233
|
+
export function helmDeployOverSsh(def: any): Step & LibraryStep {
|
|
234
|
+
return "target" in def ? helmDeployOverSshTarget(def) : helmDeployOverSshLiteral(def);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
238
|
+
function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>): Step & LibraryStep {
|
|
126
239
|
const images: Record<string, string> = def.images ?? { image: "image" };
|
|
127
240
|
const imageEntries = Object.entries(images);
|
|
128
241
|
const fromRegistry = def.fromRegistry === true;
|
|
129
|
-
const deployments = def.deployments ?? [def.release];
|
|
130
242
|
const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
|
|
131
|
-
const health = typeof def.healthUrl === "string" ? { url: def.healthUrl } : def.healthUrl;
|
|
132
243
|
if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
|
|
133
244
|
if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
|
|
134
|
-
if (deployments
|
|
245
|
+
if (def.deployments?.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
|
|
246
|
+
const imageRefs: Record<string, string> = def.imageRefs ?? {};
|
|
247
|
+
const refEntries = Object.entries(imageRefs);
|
|
248
|
+
if ("source" in imageRefs) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
|
|
249
|
+
for (const [key] of refEntries) if (key in images) throw new Error(`helmDeployOverSsh '${def.name}': '${key}' is both in images and imageRefs`);
|
|
135
250
|
const imageModel = fromRegistry ? PushedImage : BuiltImage;
|
|
136
|
-
const input = {
|
|
251
|
+
const input = {
|
|
252
|
+
source: Source,
|
|
253
|
+
...Object.fromEntries(imageEntries.map(([key]) => [key, imageModel])),
|
|
254
|
+
...Object.fromEntries(refEntries.map(([key]) => [key, PushedImage])),
|
|
255
|
+
};
|
|
137
256
|
const isDefaultImages = imageEntries.length === 1 && images.image === "image";
|
|
257
|
+
const configurable = { host: def.host, release: def.release, namespace: def.namespace, values: def.values ?? [], set: def.set ?? {}, healthUrl: def.healthUrl };
|
|
138
258
|
|
|
139
259
|
return makeStep(
|
|
140
260
|
"helmDeployOverSsh",
|
|
141
261
|
{ name: def.name, description: def.description, image: HELM_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.sshKey], timeoutSeconds: def.timeoutSeconds },
|
|
142
262
|
{
|
|
143
263
|
manifestConfig: () => ({
|
|
144
|
-
host: def.host,
|
|
264
|
+
host: recordConfigRefs(def.host),
|
|
145
265
|
sshKey: def.sshKey.name,
|
|
146
|
-
release: def.release,
|
|
147
|
-
namespace: def.namespace,
|
|
266
|
+
release: recordConfigRefs(def.release),
|
|
267
|
+
namespace: recordConfigRefs(def.namespace),
|
|
148
268
|
chart: def.chart,
|
|
149
|
-
values:
|
|
150
|
-
set:
|
|
269
|
+
values: recordConfigRefs(configurable.values),
|
|
270
|
+
set: recordConfigRefs(configurable.set),
|
|
151
271
|
...(isDefaultImages ? {} : { images: { ...images } }),
|
|
152
272
|
...(fromRegistry ? { fromRegistry: true } : {}),
|
|
273
|
+
...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
|
|
153
274
|
...(def.deployments ? { deployments: [...def.deployments] } : {}),
|
|
154
275
|
...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
|
|
155
|
-
...(def.healthUrl !== undefined ? { healthUrl:
|
|
276
|
+
...(def.healthUrl !== undefined ? { healthUrl: recordConfigRefs(def.healthUrl) } : {}),
|
|
156
277
|
}),
|
|
157
|
-
async run(input: Record<string, { repository: string; tag: string; tar?: FileRef }> & { source: { tree: FileRef } }, ctx: Ctx) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
278
|
+
async run(input: Record<string, { repository: string; tag: string; digest: string; tar?: FileRef }> & { source: { tree: FileRef } }, ctx: Ctx) {
|
|
279
|
+
const d = resolveConfigRefs(configurable, ctx.config ?? {}) as {
|
|
280
|
+
host: string; release: string; namespace: string; values: string[]; set: Record<string, string>;
|
|
281
|
+
healthUrl?: string | ({ url: string } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
|
|
282
|
+
};
|
|
283
|
+
const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
|
|
284
|
+
const hostImages: HostImage[] = [];
|
|
163
285
|
const flags: HelmImageFlags[] = [];
|
|
164
286
|
for (const [key, prefix] of imageEntries) {
|
|
165
287
|
const image = input[key]!;
|
|
166
|
-
|
|
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
|
-
}
|
|
288
|
+
hostImages.push({ repository: image.repository, tag: image.tag, ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }) });
|
|
176
289
|
flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
|
|
177
290
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
}
|
|
190
|
-
|
|
291
|
+
const refs: HelmImageRefFlags[] = refEntries.map(([key, prefix]) => ({ prefix, repository: input[key]!.repository, tag: input[key]!.tag, digest: input[key]!.digest }));
|
|
292
|
+
await deployOverSsh(ctx, {
|
|
293
|
+
host: d.host, key: ctx.secrets[def.sshKey.name]!, chartDir, release: d.release, namespace: d.namespace, hostImages,
|
|
294
|
+
flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
|
|
295
|
+
deployments: def.deployments ?? [d.release], rolloutTimeoutSeconds: rolloutTimeout,
|
|
296
|
+
});
|
|
297
|
+
const health = typeof d.healthUrl === "string" ? { url: d.healthUrl } : d.healthUrl;
|
|
191
298
|
if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
|
|
192
|
-
return { release:
|
|
299
|
+
return { release: d.release, namespace: d.namespace, image: `${flags[0]!.repository}:${flags[0]!.tag}` };
|
|
193
300
|
},
|
|
194
301
|
},
|
|
302
|
+
configurable,
|
|
195
303
|
) as never;
|
|
196
304
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Helm deploys to a target from config (`helmTarget()`): steps.helmDeployInCluster runs helm in the step
|
|
2
|
+
// pod against its own cluster; steps.helmDeployOverSsh({ target, … }) runs it on a k3s host. Pick one
|
|
3
|
+
// per target kind with `select`. Images come from a registry, pinned by digest.
|
|
4
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
8
|
+
import type { Configurable } from "../core/config.ts";
|
|
9
|
+
import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
|
|
10
|
+
import type { FileRef } from "../core/models.ts";
|
|
11
|
+
import { optional } from "../core/models.ts";
|
|
12
|
+
import type { Secret } from "../core/secrets.ts";
|
|
13
|
+
import { secretRef } from "../core/config.ts";
|
|
14
|
+
import { makeStep, type LibraryStep, type LiteralName, type Step, type StepKind } from "../core/steps.ts";
|
|
15
|
+
import type { Simplify } from "../core/types.ts";
|
|
16
|
+
import { serviceUrl } from "./checks.ts";
|
|
17
|
+
import { chartDirectory, deployOverSsh, DEFAULT_ROLLOUT_TIMEOUT_SECONDS, HELM_DEPLOY_IMAGE, helmFlags, type HelmImageFlags, type HelmImageRefFlags } from "./helm-deploy-ssh.ts";
|
|
18
|
+
import { httpHealthCheck } from "./http-health.ts";
|
|
19
|
+
import { HelmRelease, PushedImage, Source } from "./models.ts";
|
|
20
|
+
|
|
21
|
+
/** helm 3 on Alpine, for in-cluster deploys (the health check runs in the harness). */
|
|
22
|
+
export const HELM_IMAGE = "alpine/helm:3.16.2";
|
|
23
|
+
|
|
24
|
+
/** Where the step pod finds its ServiceAccount token (mounted only with the instance's step ServiceAccount). */
|
|
25
|
+
export const SERVICE_ACCOUNT_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
|
26
|
+
|
|
27
|
+
function targetVariants<const E extends z.ZodRawShape, const K extends string>(o: Omit<DeployTargetOptions<E, K>, "composeToken"> & { composeToken?: Secret<K> }) {
|
|
28
|
+
const name = (d: string | undefined) => (d === undefined ? z.string() : z.string().default(d));
|
|
29
|
+
const common = {
|
|
30
|
+
namespace: name(o.namespace).describe("Namespace the release is installed into."),
|
|
31
|
+
release: name(o.release).describe("Helm release name."),
|
|
32
|
+
values: z.array(z.string()).default([]).describe("Values files, relative to the chart directory (-f, in order)."),
|
|
33
|
+
set: z.record(z.string(), z.string()).default({}).describe("Extra --set flags."),
|
|
34
|
+
url: z.string().optional().describe("Base URL step pods reach the release at (health checks, smoke tests); default http://<release>.<namespace>.svc.cluster.local."),
|
|
35
|
+
...(o.extend ?? ({} as E)),
|
|
36
|
+
};
|
|
37
|
+
const inCluster = z.object({ kind: z.literal("inCluster").describe("helm runs in the step pod against its own cluster (needs the instance's step ServiceAccount)."), ...common });
|
|
38
|
+
const ssh = z.object({ kind: z.literal("ssh").describe("helm runs on a k3s host over SSH."), host: z.string().describe("user@host"), ...common });
|
|
39
|
+
const compose = z.object({
|
|
40
|
+
kind: z.literal("compose").describe("A Docker Compose deployer recreates a service with the image."),
|
|
41
|
+
deployerUrl: z.string().describe("The deployer's base URL (POST <deployerUrl>/v1/deploy)."),
|
|
42
|
+
service: z.string().describe("The compose service to recreate."),
|
|
43
|
+
token: secretRef(o.composeToken ?? ({ kind: "secret", name: "DEPLOYER_TOKEN", description: "" } as Secret<K>)).describe("The deployer's bearer token."),
|
|
44
|
+
healthUrl: z.string().optional().describe("Checked by the step after the deployer's own health check, when the step can reach it."),
|
|
45
|
+
...(o.extend ?? ({} as E)),
|
|
46
|
+
});
|
|
47
|
+
const none = z.object({ kind: z.literal("none").describe("Not deployed: the target's steps are left out.") });
|
|
48
|
+
return { inCluster, ssh, compose, none };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DeployTargetOptions<E extends z.ZodRawShape, K extends string> {
|
|
52
|
+
/** Default namespace (helm targets). */
|
|
53
|
+
namespace?: string;
|
|
54
|
+
/** Default release name (helm targets). */
|
|
55
|
+
release?: string;
|
|
56
|
+
/** Fields added to every kind but `none` (e.g. per-target auth overrides). */
|
|
57
|
+
extend?: E;
|
|
58
|
+
/** The secret holding the compose deployer's token (`kind: compose`'s `token: { secret: … }`). */
|
|
59
|
+
composeToken: Secret<K>;
|
|
60
|
+
/** Adds `kind: none`, a target that isn't deployed (e.g. an optional beta). */
|
|
61
|
+
optional?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The config schema of a deploy target, a union on `kind` to `select` on: `inCluster` and `ssh` (helm),
|
|
66
|
+
* `compose` (a Docker Compose deployer), and with `optional: true` also `none` (not deployed).
|
|
67
|
+
* `config("target", "…", z.object({ beta: deployTarget({ optional: true, composeToken: T }), prod: deployTarget({ composeToken: T }) }))`
|
|
68
|
+
*/
|
|
69
|
+
export function deployTarget<const K extends string, const E extends z.ZodRawShape = {}>(o: DeployTargetOptions<E, K> & { optional: true }): z.ZodDiscriminatedUnion<[
|
|
70
|
+
ReturnType<typeof targetVariants<E, K>>["inCluster"], ReturnType<typeof targetVariants<E, K>>["ssh"], ReturnType<typeof targetVariants<E, K>>["compose"], ReturnType<typeof targetVariants<E, K>>["none"],
|
|
71
|
+
]>;
|
|
72
|
+
export function deployTarget<const K extends string, const E extends z.ZodRawShape = {}>(o: DeployTargetOptions<E, K>): z.ZodDiscriminatedUnion<[
|
|
73
|
+
ReturnType<typeof targetVariants<E, K>>["inCluster"], ReturnType<typeof targetVariants<E, K>>["ssh"], ReturnType<typeof targetVariants<E, K>>["compose"],
|
|
74
|
+
]>;
|
|
75
|
+
export function deployTarget(o: DeployTargetOptions<z.ZodRawShape, string>): unknown {
|
|
76
|
+
const v = targetVariants(o);
|
|
77
|
+
return z.discriminatedUnion("kind", o.optional ? [v.inCluster, v.ssh, v.compose, v.none] : [v.inCluster, v.ssh, v.compose]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A helm-only deploy target (`inCluster | ssh`). */
|
|
81
|
+
export function helmTarget<const E extends z.ZodRawShape = {}>(defaults: { namespace?: string; release?: string } = {}, extend?: E) {
|
|
82
|
+
const v = targetVariants({ ...defaults, extend });
|
|
83
|
+
return z.discriminatedUnion("kind", [v.inCluster, v.ssh]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface TargetCommon {
|
|
87
|
+
namespace: string;
|
|
88
|
+
release: string;
|
|
89
|
+
values: readonly string[];
|
|
90
|
+
set: Readonly<Record<string, string>>;
|
|
91
|
+
url?: string | undefined;
|
|
92
|
+
}
|
|
93
|
+
/** A resolved `kind: inCluster` target. */
|
|
94
|
+
export type InClusterTargetValue = TargetCommon & { kind: "inCluster" };
|
|
95
|
+
/** A resolved `kind: ssh` target. */
|
|
96
|
+
export type SshTargetValue = TargetCommon & { kind: "ssh"; host: string };
|
|
97
|
+
/** A resolved deploy target (the output of `helmTarget()`). */
|
|
98
|
+
export type HelmTargetValue = InClusterTargetValue | SshTargetValue;
|
|
99
|
+
|
|
100
|
+
type DefaultImages = { readonly image: "image" };
|
|
101
|
+
export type HelmTargetDeployInput<Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}> = Simplify<
|
|
102
|
+
{ readonly source: typeof Source } & { readonly [K in keyof Im]: typeof PushedImage } & { readonly [K in keyof Rf]: ReturnType<typeof optional<typeof PushedImage>> }
|
|
103
|
+
>;
|
|
104
|
+
|
|
105
|
+
/** What both target deploys take (besides `target`). */
|
|
106
|
+
export interface HelmTargetDeployDef<N extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}> {
|
|
107
|
+
name: LiteralName<N>;
|
|
108
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
109
|
+
description: string;
|
|
110
|
+
/** Chart directory inside the source tree, e.g. `helm`. */
|
|
111
|
+
chart: string;
|
|
112
|
+
/**
|
|
113
|
+
* Values written as files and passed after the target's values files (`-f`, in order). Config
|
|
114
|
+
* references are resolved first; undefined members are dropped, so `{ auth: target.auth }`
|
|
115
|
+
* overrides only when set.
|
|
116
|
+
*/
|
|
117
|
+
inlineValues?: readonly Readonly<Record<string, unknown>>[];
|
|
118
|
+
/**
|
|
119
|
+
* 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
|
+
*/
|
|
122
|
+
images?: Im & { readonly source?: never };
|
|
123
|
+
/**
|
|
124
|
+
* Registry images the chart only references: input key → values prefix. Each input is an
|
|
125
|
+
* `optional(PushedImage)`; when its producer was skipped the values are left alone.
|
|
126
|
+
*/
|
|
127
|
+
imageRefs?: Rf & { readonly source?: never };
|
|
128
|
+
/** Rollout wait: per rollout over SSH, `helm --wait --timeout` in-cluster. Default 120. */
|
|
129
|
+
rolloutTimeoutSeconds?: number;
|
|
130
|
+
/** After the rollout, GET `http://<release>.<namespace>.svc.cluster.local<healthPath>` until it returns 2xx. */
|
|
131
|
+
healthPath?: string;
|
|
132
|
+
timeoutSeconds?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface HelmDeployInClusterDef<N extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}>
|
|
136
|
+
extends HelmTargetDeployDef<N, Im, Rf> {
|
|
137
|
+
/** An in-cluster target, usually a `select` case's reference (`(p, prod) => … target: prod`). */
|
|
138
|
+
target: Configurable<InClusterTargetValue>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface HelmDeploySshTargetDef<N extends string, K extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}>
|
|
142
|
+
extends HelmTargetDeployDef<N, Im, Rf> {
|
|
143
|
+
/** An ssh target, usually a `select` case's reference. */
|
|
144
|
+
target: Configurable<SshTargetValue>;
|
|
145
|
+
/** Private key for the target's host. */
|
|
146
|
+
sshKey: Secret<K>;
|
|
147
|
+
/** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name. */
|
|
148
|
+
deployments?: readonly string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Deploys registry images, pinned by digest, with the Helm chart at `<source>/<chart>` into the
|
|
153
|
+
* cluster the step runs in: `helm upgrade --install --wait` in the step pod, with the instance's step
|
|
154
|
+
* ServiceAccount token (`clusterAccess`). Then (with `healthPath`) polls the release's Service.
|
|
155
|
+
*/
|
|
156
|
+
export function helmDeployInCluster<
|
|
157
|
+
const N extends string,
|
|
158
|
+
const Im extends Record<string, string> = DefaultImages,
|
|
159
|
+
const Rf extends Record<string, string> = {},
|
|
160
|
+
>(def: HelmDeployInClusterDef<N, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never> & LibraryStep {
|
|
161
|
+
return targetDeploy("helmDeployInCluster", def as unknown as AnyTargetDef, undefined) as never;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 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>>): Step & LibraryStep {
|
|
166
|
+
return targetDeploy("helmDeployOverSsh", def as unknown as AnyTargetDef, def.sshKey, def.deployments) as never;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface AnyTargetDef {
|
|
170
|
+
name: string;
|
|
171
|
+
description: string;
|
|
172
|
+
chart: string;
|
|
173
|
+
target: unknown;
|
|
174
|
+
inlineValues?: readonly Readonly<Record<string, unknown>>[];
|
|
175
|
+
images?: Readonly<Record<string, string>>;
|
|
176
|
+
imageRefs?: Readonly<Record<string, string>>;
|
|
177
|
+
rolloutTimeoutSeconds?: number;
|
|
178
|
+
healthPath?: string;
|
|
179
|
+
timeoutSeconds?: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function targetDeploy(kind: StepKind, def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Step & LibraryStep {
|
|
183
|
+
const images: Record<string, string> = { ...(def.images ?? { image: "image" }) };
|
|
184
|
+
const imageEntries = Object.entries(images);
|
|
185
|
+
const imageRefs: Record<string, string> = { ...(def.imageRefs ?? {}) };
|
|
186
|
+
const refEntries = Object.entries(imageRefs);
|
|
187
|
+
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
|
+
if ("source" in images || "source" in imageRefs) throw new Error(`${kind} '${def.name}': 'source' can't be an image input`);
|
|
190
|
+
for (const [key] of refEntries) if (key in images) throw new Error(`${kind} '${def.name}': '${key}' is both in images and imageRefs`);
|
|
191
|
+
if (deployments?.length === 0) throw new Error(`${kind} '${def.name}': deployments must not be empty`);
|
|
192
|
+
if (def.healthPath !== undefined && !def.healthPath.startsWith("/")) throw new Error(`${kind} '${def.name}': healthPath must start with '/'`);
|
|
193
|
+
const inCluster = kind === "helmDeployInCluster";
|
|
194
|
+
const input = {
|
|
195
|
+
source: Source,
|
|
196
|
+
...Object.fromEntries(imageEntries.map(([key]) => [key, PushedImage])),
|
|
197
|
+
...Object.fromEntries(refEntries.map(([key]) => [key, optional(PushedImage)])),
|
|
198
|
+
};
|
|
199
|
+
const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
|
|
200
|
+
|
|
201
|
+
return makeStep(
|
|
202
|
+
kind,
|
|
203
|
+
{
|
|
204
|
+
name: def.name, description: def.description, image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE, input, output: HelmRelease,
|
|
205
|
+
secrets: sshKey ? [sshKey] : [], timeoutSeconds: def.timeoutSeconds, ...(inCluster ? { clusterAccess: true } : {}),
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
manifestConfig: () => ({
|
|
209
|
+
target: recordConfigRefs(def.target),
|
|
210
|
+
...(sshKey ? { sshKey: sshKey.name } : {}),
|
|
211
|
+
chart: def.chart,
|
|
212
|
+
...(def.inlineValues?.length ? { inlineValues: recordConfigRefs(def.inlineValues) } : {}),
|
|
213
|
+
images: { ...images },
|
|
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);
|
|
224
|
+
|
|
225
|
+
const generated = inlineValues.map((values, i) => {
|
|
226
|
+
const file = `olve-values-${i}.json`;
|
|
227
|
+
writeFileSync(join(chartDir, file), JSON.stringify(values));
|
|
228
|
+
return file;
|
|
229
|
+
});
|
|
230
|
+
const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
|
|
231
|
+
const image = input[key]!;
|
|
232
|
+
return { prefix, repository: image.repository, tag: image.tag, digest: image.digest, pullPolicy: "IfNotPresent" };
|
|
233
|
+
});
|
|
234
|
+
const refs: HelmImageRefFlags[] = refEntries.flatMap(([key, prefix]) => {
|
|
235
|
+
const image = input[key];
|
|
236
|
+
return image ? [{ prefix, repository: image.repository, tag: image.tag, digest: image.digest }] : [];
|
|
237
|
+
});
|
|
238
|
+
const helm = { values: [...target.values, ...generated], set: target.set, images: flags, imageRefs: refs };
|
|
239
|
+
const { release, namespace } = target;
|
|
240
|
+
|
|
241
|
+
if (target.kind === "ssh") {
|
|
242
|
+
const key = ctx.secrets[sshKey!.name];
|
|
243
|
+
if (!key) throw new Error(`secret ${sshKey!.name} (sshKey) is not set`);
|
|
244
|
+
await deployOverSsh(ctx, {
|
|
245
|
+
host: target.host, key, chartDir, release, namespace,
|
|
246
|
+
hostImages: flags.map((f) => ({ repository: f.repository, tag: f.tag, digest: f.digest })),
|
|
247
|
+
flags: helm, deployments: deployments ?? [release], rolloutTimeoutSeconds: rolloutTimeout,
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
if (!existsSync(SERVICE_ACCOUNT_TOKEN))
|
|
251
|
+
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`);
|
|
252
|
+
const home = join(ctx.tmpDir, "helm-home");
|
|
253
|
+
ctx.log("helm upgrade", { release, namespace, images: flags.map((f) => `${f.repository}@${f.digest}`).join(" ") });
|
|
254
|
+
await ctx.exec(["helm", "upgrade", "--install", release, ".", "-n", namespace, ...helmFlags(helm), "--wait", `--timeout=${rolloutTimeout}s`], {
|
|
255
|
+
cwd: chartDir,
|
|
256
|
+
env: { HOME: home, HELM_CACHE_HOME: join(home, "cache"), HELM_CONFIG_HOME: join(home, "config"), HELM_DATA_HOME: join(home, "data") },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, "");
|
|
261
|
+
if (def.healthPath !== undefined) await httpHealthCheck(ctx, `${url}${def.healthPath}`, { attempts: 12, delaySeconds: 5 });
|
|
262
|
+
const main = flags[0]!;
|
|
263
|
+
return { release, namespace, image: `${main.repository}:${main.tag}@${main.digest}`, ...(target.url ? { url } : {}) };
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
configurable,
|
|
267
|
+
) as never;
|
|
268
|
+
}
|