@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.10
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 +121 -61
- package/manifest.schema.json +161 -11
- package/package.json +1 -1
- package/src/build/build.ts +19 -17
- package/src/build/cli.ts +1 -1
- package/src/build/hashes.ts +32 -3
- package/src/build/project.ts +33 -5
- package/src/builder/pipeline.ts +92 -10
- package/src/core/brands.ts +2 -0
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +16 -10
- package/src/core/origin.ts +20 -0
- package/src/core/steps.ts +53 -22
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/index.ts +3 -0
- package/src/ctx/sh.ts +2 -1
- package/src/ctx/types.ts +4 -1
- package/src/describe/describe.ts +213 -27
- package/src/describe/manifest-schema.ts +53 -5
- package/src/dev/cli.ts +16 -9
- package/src/harness/execute.ts +7 -4
- package/src/harness/index.ts +1 -1
- package/src/harness/main.ts +68 -17
- package/src/harness/validate.ts +19 -1
- package/src/index.ts +7 -3
- package/src/library/checks.ts +23 -0
- package/src/library/compose-deploy.ts +96 -0
- package/src/library/github-source.ts +61 -38
- package/src/library/helm-deploy-ssh.ts +306 -99
- package/src/library/helm-deploy.ts +271 -0
- package/src/library/image-build.ts +350 -66
- package/src/library/index.ts +11 -7
- package/src/library/models.ts +17 -1
- package/src/library/ssh.ts +50 -0
- package/src/testing/index.ts +11 -15
- package/src/util/handler-hash.ts +4 -4
- package/src/util/tar.ts +13 -0
- package/src/library/script.ts +0 -85
|
@@ -0,0 +1,271 @@
|
|
|
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 { resolveConfigRefs } from "../core/config-refs.ts";
|
|
10
|
+
import { implement, type Implementation } from "../core/handlers.ts";
|
|
11
|
+
import type { FileRef } from "../core/models.ts";
|
|
12
|
+
import { optional } from "../core/models.ts";
|
|
13
|
+
import type { Secret } from "../core/secrets.ts";
|
|
14
|
+
import { secretRef } from "../core/config.ts";
|
|
15
|
+
import { step, type AnyStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
16
|
+
import type { Simplify } from "../core/types.ts";
|
|
17
|
+
import { serviceUrl } from "./checks.ts";
|
|
18
|
+
import { chartDirectory, deployOverSsh, DEFAULT_ROLLOUT_TIMEOUT_SECONDS, HELM_DEPLOY_IMAGE, helmFlags, type HelmImageFlags, type HelmImageRefFlags } from "./helm-deploy-ssh.ts";
|
|
19
|
+
import { httpHealthCheck } from "./http-health.ts";
|
|
20
|
+
import { HelmRelease, PushedImage, Source } from "./models.ts";
|
|
21
|
+
|
|
22
|
+
/** helm 3 on Alpine, for in-cluster deploys (the health check runs in the harness). */
|
|
23
|
+
export const HELM_IMAGE = "alpine/helm:3.16.2";
|
|
24
|
+
|
|
25
|
+
/** Where the step pod finds its ServiceAccount token (mounted only with the instance's step ServiceAccount). */
|
|
26
|
+
export const SERVICE_ACCOUNT_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
|
27
|
+
|
|
28
|
+
function targetVariants<const E extends z.ZodRawShape, const K extends string>(o: Omit<DeployTargetOptions<E, K>, "composeToken"> & { composeToken?: Secret<K> }) {
|
|
29
|
+
const name = (d: string | undefined) => (d === undefined ? z.string() : z.string().default(d));
|
|
30
|
+
const common = {
|
|
31
|
+
namespace: name(o.namespace).describe("Namespace the release is installed into."),
|
|
32
|
+
release: name(o.release).describe("Helm release name."),
|
|
33
|
+
values: z.array(z.string()).default([]).describe("Values files, relative to the chart directory (-f, in order)."),
|
|
34
|
+
set: z.record(z.string(), z.string()).default({}).describe("Extra --set flags."),
|
|
35
|
+
url: z.string().optional().describe("Base URL step pods reach the release at (health checks, smoke tests); default http://<release>.<namespace>.svc.cluster.local."),
|
|
36
|
+
...(o.extend ?? ({} as E)),
|
|
37
|
+
};
|
|
38
|
+
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 });
|
|
39
|
+
const ssh = z.object({ kind: z.literal("ssh").describe("helm runs on a k3s host over SSH."), host: z.string().describe("user@host"), ...common });
|
|
40
|
+
const compose = z.object({
|
|
41
|
+
kind: z.literal("compose").describe("A Docker Compose deployer recreates a service with the image."),
|
|
42
|
+
deployerUrl: z.string().describe("The deployer's base URL (POST <deployerUrl>/v1/deploy)."),
|
|
43
|
+
service: z.string().describe("The compose service to recreate."),
|
|
44
|
+
token: secretRef(o.composeToken ?? ({ kind: "secret", name: "DEPLOYER_TOKEN", description: "" } as Secret<K>)).describe("The deployer's bearer token."),
|
|
45
|
+
healthUrl: z.string().optional().describe("Checked by the step after the deployer's own health check, when the step can reach it."),
|
|
46
|
+
...(o.extend ?? ({} as E)),
|
|
47
|
+
});
|
|
48
|
+
const none = z.object({ kind: z.literal("none").describe("Not deployed: the target's steps are left out.") });
|
|
49
|
+
return { inCluster, ssh, compose, none };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DeployTargetOptions<E extends z.ZodRawShape, K extends string> {
|
|
53
|
+
/** Default namespace (helm targets). */
|
|
54
|
+
namespace?: string;
|
|
55
|
+
/** Default release name (helm targets). */
|
|
56
|
+
release?: string;
|
|
57
|
+
/** Fields added to every kind but `none` (e.g. per-target auth overrides). */
|
|
58
|
+
extend?: E;
|
|
59
|
+
/** The secret holding the compose deployer's token (`kind: compose`'s `token: { secret: … }`). */
|
|
60
|
+
composeToken: Secret<K>;
|
|
61
|
+
/** Adds `kind: none`, a target that isn't deployed (e.g. an optional beta). */
|
|
62
|
+
optional?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The config schema of a deploy target, a union on `kind` to `select` on: `inCluster` and `ssh` (helm),
|
|
67
|
+
* `compose` (a Docker Compose deployer), and with `optional: true` also `none` (not deployed).
|
|
68
|
+
* `config("target", "…", z.object({ beta: deployTarget({ optional: true, composeToken: T }), prod: deployTarget({ composeToken: T }) }))`
|
|
69
|
+
*/
|
|
70
|
+
export function deployTarget<const K extends string, const E extends z.ZodRawShape = {}>(o: DeployTargetOptions<E, K> & { optional: true }): z.ZodDiscriminatedUnion<[
|
|
71
|
+
ReturnType<typeof targetVariants<E, K>>["inCluster"], ReturnType<typeof targetVariants<E, K>>["ssh"], ReturnType<typeof targetVariants<E, K>>["compose"], ReturnType<typeof targetVariants<E, K>>["none"],
|
|
72
|
+
]>;
|
|
73
|
+
export function deployTarget<const K extends string, const E extends z.ZodRawShape = {}>(o: DeployTargetOptions<E, K>): z.ZodDiscriminatedUnion<[
|
|
74
|
+
ReturnType<typeof targetVariants<E, K>>["inCluster"], ReturnType<typeof targetVariants<E, K>>["ssh"], ReturnType<typeof targetVariants<E, K>>["compose"],
|
|
75
|
+
]>;
|
|
76
|
+
export function deployTarget(o: DeployTargetOptions<z.ZodRawShape, string>): unknown {
|
|
77
|
+
const v = targetVariants(o);
|
|
78
|
+
return z.discriminatedUnion("kind", o.optional ? [v.inCluster, v.ssh, v.compose, v.none] : [v.inCluster, v.ssh, v.compose]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A helm-only deploy target (`inCluster | ssh`). */
|
|
82
|
+
export function helmTarget<const E extends z.ZodRawShape = {}>(defaults: { namespace?: string; release?: string } = {}, extend?: E) {
|
|
83
|
+
const v = targetVariants({ ...defaults, extend });
|
|
84
|
+
return z.discriminatedUnion("kind", [v.inCluster, v.ssh]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface TargetCommon {
|
|
88
|
+
namespace: string;
|
|
89
|
+
release: string;
|
|
90
|
+
values: readonly string[];
|
|
91
|
+
set: Readonly<Record<string, string>>;
|
|
92
|
+
url?: string | undefined;
|
|
93
|
+
}
|
|
94
|
+
/** A resolved `kind: inCluster` target. */
|
|
95
|
+
export type InClusterTargetValue = TargetCommon & { kind: "inCluster" };
|
|
96
|
+
/** A resolved `kind: ssh` target. */
|
|
97
|
+
export type SshTargetValue = TargetCommon & { kind: "ssh"; host: string };
|
|
98
|
+
/** A resolved deploy target (the output of `helmTarget()`). */
|
|
99
|
+
export type HelmTargetValue = InClusterTargetValue | SshTargetValue;
|
|
100
|
+
|
|
101
|
+
type DefaultImages = { readonly image: "image" };
|
|
102
|
+
export type HelmTargetDeployInput<Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}> = Simplify<
|
|
103
|
+
{ readonly source: typeof Source } & { readonly [K in keyof Im]: typeof PushedImage } & { readonly [K in keyof Rf]: ReturnType<typeof optional<typeof PushedImage>> }
|
|
104
|
+
>;
|
|
105
|
+
|
|
106
|
+
/** What both target deploys take (besides `target`). */
|
|
107
|
+
export interface HelmTargetDeployDef<N extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}> {
|
|
108
|
+
name: LiteralName<N>;
|
|
109
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
110
|
+
description: string;
|
|
111
|
+
/** Chart directory inside the source tree, e.g. `helm`. */
|
|
112
|
+
chart: string;
|
|
113
|
+
/**
|
|
114
|
+
* Values written as files and passed after the target's values files (`-f`, in order). Config
|
|
115
|
+
* references are resolved first; undefined members are dropped, so `{ auth: target.auth }`
|
|
116
|
+
* overrides only when set.
|
|
117
|
+
*/
|
|
118
|
+
inlineValues?: readonly Readonly<Record<string, unknown>>[];
|
|
119
|
+
/**
|
|
120
|
+
* The images to deploy: step input key → the chart's values prefix. Each input is a `PushedImage`;
|
|
121
|
+
* the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`. `{}` deploys
|
|
122
|
+
* the chart alone.
|
|
123
|
+
*/
|
|
124
|
+
images?: Im & { readonly source?: never };
|
|
125
|
+
/**
|
|
126
|
+
* Registry images the chart only references: input key → values prefix. Each input is an
|
|
127
|
+
* `optional(PushedImage)`; when its producer was skipped the values are left alone.
|
|
128
|
+
*/
|
|
129
|
+
imageRefs?: Rf & { readonly source?: never };
|
|
130
|
+
/** Rollout wait: per rollout over SSH, `helm --wait --timeout` in-cluster. Default 120. */
|
|
131
|
+
rolloutTimeoutSeconds?: number;
|
|
132
|
+
/** After the rollout, GET `http://<release>.<namespace>.svc.cluster.local<healthPath>` until it returns 2xx. */
|
|
133
|
+
healthPath?: string;
|
|
134
|
+
timeoutSeconds?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface HelmDeployInClusterDef<N extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}>
|
|
138
|
+
extends HelmTargetDeployDef<N, Im, Rf> {
|
|
139
|
+
/** An in-cluster target, usually a `select` case's reference (`(p, prod) => … target: prod`). */
|
|
140
|
+
target: Configurable<InClusterTargetValue>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface HelmDeploySshTargetDef<N extends string, K extends string, Im extends Record<string, string> = DefaultImages, Rf extends Record<string, string> = {}>
|
|
144
|
+
extends HelmTargetDeployDef<N, Im, Rf> {
|
|
145
|
+
/** An ssh target, usually a `select` case's reference. */
|
|
146
|
+
target: Configurable<SshTargetValue>;
|
|
147
|
+
/** Private key for the target's host. */
|
|
148
|
+
sshKey: Secret<K>;
|
|
149
|
+
/** Rollouts to wait for (`deploy/<name>` for a bare name). Default: the release name, or none without images. */
|
|
150
|
+
deployments?: readonly string[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Deploys registry images, pinned by digest, with the Helm chart at `<source>/<chart>` into the
|
|
155
|
+
* cluster the step runs in: `helm upgrade --install --wait` in the step pod, with the instance's step
|
|
156
|
+
* ServiceAccount token (`clusterAccess`). Then (with `healthPath`) polls the release's Service.
|
|
157
|
+
*/
|
|
158
|
+
export function helmDeployInCluster<
|
|
159
|
+
const N extends string,
|
|
160
|
+
const Im extends Record<string, string> = DefaultImages,
|
|
161
|
+
const Rf extends Record<string, string> = {},
|
|
162
|
+
>(def: HelmDeployInClusterDef<N, Im, Rf>): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, never>> {
|
|
163
|
+
return targetDeploy("helmDeployInCluster", def as unknown as AnyTargetDef, undefined) as never;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The `target` form of `steps.helmDeployOverSsh`: an ssh target from config, registry images by digest. */
|
|
167
|
+
export function helmDeployOverSshTarget(def: HelmDeploySshTargetDef<string, string, Record<string, string>, Record<string, string>>): Implementation {
|
|
168
|
+
return targetDeploy("helmDeployOverSsh", def as unknown as AnyTargetDef, def.sshKey, def.deployments) as never;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface AnyTargetDef {
|
|
172
|
+
name: string;
|
|
173
|
+
description: string;
|
|
174
|
+
chart: string;
|
|
175
|
+
target: unknown;
|
|
176
|
+
inlineValues?: readonly Readonly<Record<string, unknown>>[];
|
|
177
|
+
images?: Readonly<Record<string, string>>;
|
|
178
|
+
imageRefs?: Readonly<Record<string, string>>;
|
|
179
|
+
rolloutTimeoutSeconds?: number;
|
|
180
|
+
healthPath?: string;
|
|
181
|
+
timeoutSeconds?: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function targetDeploy(kind: "helmDeployInCluster" | "helmDeployOverSsh", def: AnyTargetDef, sshKey: Secret | undefined, deployments?: readonly string[]): Implementation {
|
|
185
|
+
const images: Record<string, string> = { ...(def.images ?? { image: "image" }) };
|
|
186
|
+
const imageEntries = Object.entries(images);
|
|
187
|
+
const imageRefs: Record<string, string> = { ...(def.imageRefs ?? {}) };
|
|
188
|
+
const refEntries = Object.entries(imageRefs);
|
|
189
|
+
const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
|
|
190
|
+
if ("source" in images || "source" in imageRefs) throw new Error(`${kind} '${def.name}': 'source' can't be an image input`);
|
|
191
|
+
for (const [key] of refEntries) if (key in images) throw new Error(`${kind} '${def.name}': '${key}' is both in images and imageRefs`);
|
|
192
|
+
if (deployments?.length === 0) throw new Error(`${kind} '${def.name}': deployments must not be empty`);
|
|
193
|
+
if (def.healthPath !== undefined && !def.healthPath.startsWith("/")) throw new Error(`${kind} '${def.name}': healthPath must start with '/'`);
|
|
194
|
+
const inCluster = kind === "helmDeployInCluster";
|
|
195
|
+
const input = {
|
|
196
|
+
source: Source,
|
|
197
|
+
...Object.fromEntries(imageEntries.map(([key]) => [key, PushedImage])),
|
|
198
|
+
...Object.fromEntries(refEntries.map(([key]) => [key, optional(PushedImage)])),
|
|
199
|
+
};
|
|
200
|
+
const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
|
|
201
|
+
|
|
202
|
+
const contract = step({
|
|
203
|
+
name: def.name,
|
|
204
|
+
description: def.description,
|
|
205
|
+
kind,
|
|
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 } : {}),
|
|
222
|
+
},
|
|
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);
|
|
230
|
+
|
|
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;
|
|
246
|
+
|
|
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
|
+
}
|
|
265
|
+
|
|
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;
|
|
271
|
+
}
|