@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
package/src/harness/main.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { isImplementation } from "../core/handlers.ts";
|
|
8
|
+
import { configRefPath, isConfigRef, unionAt } from "../core/config.ts";
|
|
8
9
|
import type { AnyStep } from "../core/steps.ts";
|
|
10
|
+
import type { WebhookEvent } from "../core/webhooks.ts";
|
|
9
11
|
import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
|
|
10
12
|
import { createCtx, formatLog } from "../ctx/index.ts";
|
|
11
13
|
import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
|
|
@@ -24,6 +26,8 @@ export interface HarnessConfig {
|
|
|
24
26
|
pipeline: unknown;
|
|
25
27
|
handlers: HandlerRegistry;
|
|
26
28
|
handlerHashes: Readonly<Record<string, string>>;
|
|
29
|
+
/** Kind → hash of the code of the steps of that kind that carry their implementation. */
|
|
30
|
+
kindHashes?: Readonly<Record<string, string>>;
|
|
27
31
|
harness: HarnessInfo;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -45,14 +49,43 @@ const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-t
|
|
|
45
49
|
|
|
46
50
|
export function allSteps(data: PipelineData): AnyStep[] {
|
|
47
51
|
const out: AnyStep[] = [];
|
|
48
|
-
const
|
|
49
|
-
|
|
52
|
+
for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
|
|
57
|
+
export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
|
|
58
|
+
const out: { step: AnyStep; cases: string[] }[] = [];
|
|
59
|
+
const walk = (nodes: readonly StageNode[], cases: string[]) => {
|
|
60
|
+
for (const n of nodes) {
|
|
61
|
+
if (n.kind === "stage") out.push(...n.steps.map((s) => ({ step: s.step, cases })));
|
|
62
|
+
else if (n.kind === "when") walk(n.nodes, cases);
|
|
63
|
+
else {
|
|
64
|
+
const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
|
|
65
|
+
for (const [value, inner] of Object.entries(n.cases))
|
|
66
|
+
walk(inner, union ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`] : cases);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
50
69
|
};
|
|
51
|
-
walk(data.nodes);
|
|
52
|
-
for (const fh of data.failureHandlers)
|
|
70
|
+
walk(data.nodes, []);
|
|
71
|
+
for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
|
|
53
72
|
return out;
|
|
54
73
|
}
|
|
55
74
|
|
|
75
|
+
/** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
|
|
76
|
+
export function findStep(data: PipelineData, name: string, selected: readonly string[]): AnyStep | string {
|
|
77
|
+
const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
|
|
78
|
+
const distinct = candidates.filter((c, i) => candidates.findIndex((d) => d.step === c.step) === i);
|
|
79
|
+
if (distinct.length === 0) return `unknown step '${name}'`;
|
|
80
|
+
if (distinct.length === 1) return distinct[0]!.step;
|
|
81
|
+
const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
|
|
82
|
+
if (chosen.length === 1) return chosen[0]!.step;
|
|
83
|
+
return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
|
|
87
|
+
export const ENV_CASES = "OLVE_CASES";
|
|
88
|
+
|
|
56
89
|
/** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
|
|
57
90
|
export function cancellation(): { signal: AbortSignal; dispose(): void } {
|
|
58
91
|
const ac = new AbortController();
|
|
@@ -69,24 +102,21 @@ export function cancellation(): { signal: AbortSignal; dispose(): void } {
|
|
|
69
102
|
};
|
|
70
103
|
}
|
|
71
104
|
|
|
72
|
-
/** Resolves the function that implements `step` (
|
|
105
|
+
/** Resolves the function that implements `step` (carried by the step, or a handler module). */
|
|
73
106
|
export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
|
|
74
|
-
if (step.
|
|
75
|
-
if (!step.builtin) return `library step '${step.name}' (${step.kind}) has no implementation`;
|
|
76
|
-
return step.builtin.run as StepFunction;
|
|
77
|
-
}
|
|
107
|
+
if (isImplementation(step)) return step.handler as unknown as StepFunction;
|
|
78
108
|
const load = handlers[step.name];
|
|
79
|
-
if (!load) return `step '${step.name}' has no handler
|
|
109
|
+
if (!load) return `step '${step.name}' has no handler`;
|
|
80
110
|
let mod: { default: unknown };
|
|
81
111
|
try {
|
|
82
112
|
mod = await load();
|
|
83
113
|
} catch (e) {
|
|
84
|
-
return `
|
|
114
|
+
return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
|
|
85
115
|
}
|
|
86
116
|
const impl = mod.default;
|
|
87
|
-
if (!isImplementation(impl)) return `
|
|
117
|
+
if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
|
|
88
118
|
if (impl.step !== step)
|
|
89
|
-
return `
|
|
119
|
+
return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
|
|
90
120
|
return impl.handler as unknown as StepFunction;
|
|
91
121
|
}
|
|
92
122
|
|
|
@@ -120,7 +150,7 @@ function readJson(path: string): { ok: true; value: unknown } | { ok: false; mes
|
|
|
120
150
|
async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
|
|
121
151
|
const sentinels = args.includes("--sentinels");
|
|
122
152
|
try {
|
|
123
|
-
const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, harness: cfg.harness }));
|
|
153
|
+
const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, kindHashes: cfg.kindHashes, harness: cfg.harness }));
|
|
124
154
|
await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
|
|
125
155
|
return EXIT.ok;
|
|
126
156
|
} catch (e) {
|
|
@@ -135,8 +165,8 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
|
|
|
135
165
|
async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
|
|
136
166
|
if (!name) return io.stderr(USAGE), EXIT.usage;
|
|
137
167
|
const data = pipelineData(cfg.pipeline);
|
|
138
|
-
const step =
|
|
139
|
-
if (
|
|
168
|
+
const step = findStep(data, name, selectedCases());
|
|
169
|
+
if (typeof step === "string") return io.stderr(step), EXIT.usage;
|
|
140
170
|
const fn = await resolveStepFunction(step, cfg.handlers);
|
|
141
171
|
if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
|
|
142
172
|
|
|
@@ -162,6 +192,12 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
|
|
|
162
192
|
}
|
|
163
193
|
}
|
|
164
194
|
|
|
195
|
+
function isWebhookEvent(v: unknown): boolean {
|
|
196
|
+
if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
|
|
197
|
+
const headers = (v as { headers?: unknown }).headers;
|
|
198
|
+
return typeof headers === "object" && headers !== null && !Array.isArray(headers);
|
|
199
|
+
}
|
|
200
|
+
|
|
165
201
|
async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
|
|
166
202
|
if (!name) return io.stderr(USAGE), EXIT.usage;
|
|
167
203
|
const data = pipelineData(cfg.pipeline);
|
|
@@ -181,15 +217,21 @@ async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io:
|
|
|
181
217
|
exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
|
|
182
218
|
else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
|
|
183
219
|
exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
|
|
220
|
+
else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
|
|
221
|
+
exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
|
|
184
222
|
else {
|
|
185
223
|
let mapped: unknown;
|
|
186
224
|
let threw: unknown = undefined;
|
|
187
225
|
try {
|
|
188
|
-
mapped =
|
|
226
|
+
mapped =
|
|
227
|
+
trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
|
|
228
|
+
: trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
|
|
229
|
+
: await trigger.map(raw.value);
|
|
189
230
|
} catch (e) {
|
|
190
231
|
threw = e ?? new Error("undefined");
|
|
191
232
|
}
|
|
192
233
|
if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
|
|
234
|
+
else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
|
|
193
235
|
else {
|
|
194
236
|
const r = parseStrict(data.input.schema, mapped, "input");
|
|
195
237
|
exec = r.ok
|
|
@@ -217,3 +259,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
|
|
|
217
259
|
}
|
|
218
260
|
|
|
219
261
|
export { formatIssues, formatLog };
|
|
262
|
+
|
|
263
|
+
function selectedCases(): string[] {
|
|
264
|
+
try {
|
|
265
|
+
const v = JSON.parse(process.env[ENV_CASES] ?? "[]") as unknown;
|
|
266
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
267
|
+
} catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/harness/validate.ts
CHANGED
|
@@ -44,13 +44,31 @@ function entrySchema(e: unknown): z.ZodType {
|
|
|
44
44
|
return e as z.ZodType;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Reserved input.json key holding the step's config entries (written by the service). */
|
|
48
|
+
export const CONFIG_KEY = "$config";
|
|
49
|
+
|
|
50
|
+
/** Parses `$config` with each declared entry's schema (defaults applied). Undeclared entries are ignored. */
|
|
51
|
+
export function validateConfig(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
|
|
52
|
+
const given = isPlainObject(raw) ? raw[CONFIG_KEY] : undefined;
|
|
53
|
+
if (given !== undefined && !isPlainObject(given)) return { ok: false, issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`] };
|
|
54
|
+
const issues: string[] = [];
|
|
55
|
+
const out: Record<string, unknown> = {};
|
|
56
|
+
for (const e of step.config ?? []) {
|
|
57
|
+
const value = given?.[e.name];
|
|
58
|
+
const r = e.schema.safeParse(value);
|
|
59
|
+
if (!r.success) issues.push(...formatIssues(r.error, `config.${e.name}`).map((i) => (value === undefined ? `${i} (config entry '${e.name}' is not set)` : i)));
|
|
60
|
+
else if (r.data !== undefined) out[e.name] = r.data;
|
|
61
|
+
}
|
|
62
|
+
return issues.length ? { ok: false, issues } : { ok: true, value: out };
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
/** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
|
|
48
66
|
export function validateInput(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
|
|
49
67
|
if (!isPlainObject(raw)) return { ok: false, issues: ["input.json must be a JSON object keyed by the step's input names"] };
|
|
50
68
|
const issues: string[] = [];
|
|
51
69
|
const out: Record<string, unknown> = {};
|
|
52
70
|
const declared = step.input as Record<string, unknown>;
|
|
53
|
-
for (const k of Object.keys(raw)) if (!Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
|
|
71
|
+
for (const k of Object.keys(raw)) if (k !== CONFIG_KEY && !Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
|
|
54
72
|
for (const [k, e] of Object.entries(declared)) {
|
|
55
73
|
if (!Object.hasOwn(raw, k)) {
|
|
56
74
|
if (isOptional(e)) continue;
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
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 {
|
|
6
|
+
export { config, secretRef, isConfigRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
|
|
7
|
+
export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
|
|
8
|
+
export { resolveConfigRefs, isLiteral, type Resolved } from "./core/config-refs.ts";
|
|
9
|
+
export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LiteralName, type OutputValue } from "./core/steps.ts";
|
|
10
|
+
export type { Simplify } from "./core/types.ts";
|
|
7
11
|
export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
|
|
8
12
|
export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
|
|
9
13
|
export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
|
|
10
14
|
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
|
|
15
|
+
export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type RegistryAuthConfig, type HelmDeployOverSshDef, helmTarget, deployTarget, type DeployTargetOptions, type ComposeDeployDef, type ComposeTargetValue, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetDeployDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue, httpHealthCheck, type HttpHealthCheckOptions, sshSession, 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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// steps.composeDeploy — ask a Docker Compose deployer to recreate a service with an image. The step is
|
|
2
|
+
// thin on purpose: the deployer (pull, recreate, health, rollback) lives beside the compose project
|
|
3
|
+
// and is fixed there, behind a versioned HTTP API, without editing pipelines.
|
|
4
|
+
import type { Ctx } from "../ctx/types.ts";
|
|
5
|
+
import type { Configurable } from "../core/config.ts";
|
|
6
|
+
import { resolveConfigRefs } from "../core/config-refs.ts";
|
|
7
|
+
import { implement, type Implementation } from "../core/handlers.ts";
|
|
8
|
+
import type { Secret } from "../core/secrets.ts";
|
|
9
|
+
import { step, type LiteralName, type Step } from "../core/steps.ts";
|
|
10
|
+
import { httpHealthCheck } from "./http-health.ts";
|
|
11
|
+
import { HelmRelease, PushedImage } from "./models.ts";
|
|
12
|
+
|
|
13
|
+
/** The step's own image: the work is one HTTP call from the harness. */
|
|
14
|
+
export const COMPOSE_DEPLOY_IMAGE = "alpine:3.22";
|
|
15
|
+
|
|
16
|
+
/** A resolved `kind: compose` target. */
|
|
17
|
+
export interface ComposeTargetValue {
|
|
18
|
+
kind: "compose";
|
|
19
|
+
deployerUrl: string;
|
|
20
|
+
service: string;
|
|
21
|
+
token: { secret: string };
|
|
22
|
+
healthUrl?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the deployer answers to `POST /v1/deploy`. */
|
|
26
|
+
export interface ComposeDeployResult {
|
|
27
|
+
status: "deployed" | "rolled-back" | "failed";
|
|
28
|
+
service: string;
|
|
29
|
+
image: string;
|
|
30
|
+
previousImage?: string;
|
|
31
|
+
message?: string;
|
|
32
|
+
log?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ComposeDeployDef<N extends string, K extends string> {
|
|
36
|
+
name: LiteralName<N>;
|
|
37
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
38
|
+
description: string;
|
|
39
|
+
/** A compose target, usually a `select` case's reference. */
|
|
40
|
+
target: Configurable<ComposeTargetValue>;
|
|
41
|
+
/** The secret the target's `token` names (declared by the step). */
|
|
42
|
+
token: Secret<K>;
|
|
43
|
+
timeoutSeconds?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const input = { image: PushedImage };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deploys a registry image (by digest) to a compose service: `POST <deployerUrl>/v1/deploy
|
|
50
|
+
* { service, image: <repository>@<digest> }` with the target's bearer token. The deployer pulls the
|
|
51
|
+
* image, recreates the service, waits for its health and rolls back on failure; the step reports its
|
|
52
|
+
* answer, then (with the target's `healthUrl`) checks health itself. Output: a `HelmRelease` with the
|
|
53
|
+
* service as `release` and `compose` as `namespace`, so it can share a step name with the helm deploys.
|
|
54
|
+
*/
|
|
55
|
+
export function composeDeploy<const N extends string, const K extends string>(
|
|
56
|
+
def: ComposeDeployDef<N, K>,
|
|
57
|
+
): Implementation<Step<N, typeof input, typeof HelmRelease, K>> {
|
|
58
|
+
const contract = step({
|
|
59
|
+
name: def.name,
|
|
60
|
+
description: def.description,
|
|
61
|
+
kind: "composeDeploy",
|
|
62
|
+
image: COMPOSE_DEPLOY_IMAGE,
|
|
63
|
+
input,
|
|
64
|
+
output: HelmRelease,
|
|
65
|
+
secrets: [def.token],
|
|
66
|
+
timeoutSeconds: def.timeoutSeconds,
|
|
67
|
+
settings: { target: def.target, token: def.token.name },
|
|
68
|
+
});
|
|
69
|
+
return implement(contract, async ({ image }, ctx: Ctx) => {
|
|
70
|
+
const target = resolveConfigRefs(def.target, ctx.config ?? {}) as ComposeTargetValue;
|
|
71
|
+
if (target?.kind !== "compose") throw new Error(`composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`);
|
|
72
|
+
if (target.token.secret !== def.token.name) throw new Error(`composeDeploy '${def.name}': the target's token names secret ${target.token.secret}, but the step declares ${def.token.name}`);
|
|
73
|
+
const token = (ctx.secrets as Record<string, string>)[def.token.name];
|
|
74
|
+
const ref = `${image.repository}@${image.digest}`;
|
|
75
|
+
const url = `${target.deployerUrl.replace(/\/+$/, "")}/v1/deploy`;
|
|
76
|
+
ctx.log("deploying", { service: target.service, image: ref, deployer: target.deployerUrl });
|
|
77
|
+
const res = await ctx.http.fetch(url, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
80
|
+
body: JSON.stringify({ service: target.service, image: ref }),
|
|
81
|
+
});
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
let result: ComposeDeployResult | undefined;
|
|
84
|
+
try {
|
|
85
|
+
result = JSON.parse(text) as ComposeDeployResult;
|
|
86
|
+
} catch {
|
|
87
|
+
// reported below
|
|
88
|
+
}
|
|
89
|
+
for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
|
|
90
|
+
if (!res.ok || result?.status !== "deployed")
|
|
91
|
+
throw new Error(`the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ""}` : text.slice(0, 300)}`);
|
|
92
|
+
if (target.healthUrl) await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
|
|
93
|
+
ctx.log("deployed", { service: target.service, image: ref, previous: result.previousImage ?? "-" });
|
|
94
|
+
return { release: target.service, namespace: "compose", image: ref };
|
|
95
|
+
}) as never;
|
|
96
|
+
}
|
|
@@ -7,7 +7,10 @@ import { join } from "node:path";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import type { Ctx } from "../ctx/types.ts";
|
|
9
9
|
import type { Secret } from "../core/secrets.ts";
|
|
10
|
-
import
|
|
10
|
+
import type { Configurable } from "../core/config.ts";
|
|
11
|
+
import { resolveConfigRefs } from "../core/config-refs.ts";
|
|
12
|
+
import { implement, type Implementation } from "../core/handlers.ts";
|
|
13
|
+
import { step, type LiteralName, type Step } from "../core/steps.ts";
|
|
11
14
|
import { extractTar } from "../util/tar.ts";
|
|
12
15
|
import { Source } from "./models.ts";
|
|
13
16
|
|
|
@@ -23,11 +26,25 @@ export interface GitHubSourceDef<N extends string, T extends string> {
|
|
|
23
26
|
description: string;
|
|
24
27
|
/** Token sent to the GitHub API (needed for private repositories; raises the rate limit). */
|
|
25
28
|
token?: Secret<T>;
|
|
29
|
+
/** GitHub API base (default `https://api.github.com`; GitHub Enterprise: `https://<host>/api/v3`). */
|
|
30
|
+
apiUrl?: Configurable<string | undefined>;
|
|
26
31
|
timeoutSeconds?: number;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
export const GITHUB_API_URL = "https://api.github.com";
|
|
35
|
+
|
|
36
|
+
/** A declared secret's value, or "" when it is not set. */
|
|
37
|
+
function optionalSecret(ctx: Pick<Ctx, "secrets">, name: string): string {
|
|
38
|
+
try {
|
|
39
|
+
return ((ctx.secrets as Record<string, string>)[name] ?? "").trim();
|
|
40
|
+
} catch {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
/** Tarball URL of `repo` at `sha` (the API redirects to codeload.github.com). */
|
|
30
|
-
export const gitHubTarballUrl = (repo: string, sha: string): string =>
|
|
46
|
+
export const gitHubTarballUrl = (repo: string, sha: string, apiUrl = GITHUB_API_URL): string =>
|
|
47
|
+
`${apiUrl.replace(/\/+$/, "")}/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
|
|
31
48
|
|
|
32
49
|
/**
|
|
33
50
|
* Downloads `repo` at `sha` from GitHub as a tarball and outputs it as a `Source` whose `tree` is the
|
|
@@ -36,42 +53,48 @@ export const gitHubTarballUrl = (repo: string, sha: string): string => `https://
|
|
|
36
53
|
*/
|
|
37
54
|
export function gitHubSource<const N extends string, const T extends string = never>(
|
|
38
55
|
def: GitHubSourceDef<N, T>,
|
|
39
|
-
): Step<N, GitHubSourceInput, typeof Source, T
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
): Implementation<Step<N, GitHubSourceInput, typeof Source, T>> {
|
|
57
|
+
const contract = step({
|
|
58
|
+
name: def.name,
|
|
59
|
+
description: def.description,
|
|
60
|
+
kind: "gitHubSource",
|
|
61
|
+
image: GITHUB_SOURCE_IMAGE,
|
|
62
|
+
input,
|
|
63
|
+
output: Source,
|
|
64
|
+
secrets: def.token ? [def.token] : [],
|
|
65
|
+
timeoutSeconds: def.timeoutSeconds,
|
|
66
|
+
settings: { ...(def.token ? { token: def.token.name } : {}), ...(def.apiUrl !== undefined ? { apiUrl: def.apiUrl } : {}) },
|
|
67
|
+
});
|
|
68
|
+
return implement(contract, async ({ repo, sha }, ctx: Ctx) => {
|
|
69
|
+
const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
|
|
70
|
+
const token = def.token ? optionalSecret(ctx, def.token.name) : "";
|
|
71
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
72
|
+
else if (def.token) ctx.log(`${def.token.name} is not set: fetching anonymously (public repositories only)`);
|
|
73
|
+
const url = gitHubTarballUrl(repo, sha, resolveConfigRefs(def.apiUrl, ctx.config ?? {}) ?? GITHUB_API_URL);
|
|
74
|
+
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
75
|
+
const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
|
|
76
|
+
const archive = join(dir, "repo.tar.gz");
|
|
52
77
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
ctx.log("fetching source", { repo, sha });
|
|
79
|
+
// Retries network errors and 5xx; a 4xx (bad sha, no access) fails at once.
|
|
80
|
+
const status = await ctx.retry({ attempts: 4, delaySeconds: 2, backoff: 2 }, async () => {
|
|
81
|
+
const res = await ctx.http.fetch(url, { headers });
|
|
82
|
+
if (res.status >= 500) throw new Error(`HTTP ${res.status} from ${url}`);
|
|
83
|
+
if (res.ok) await Bun.write(archive, res);
|
|
84
|
+
return res.status;
|
|
85
|
+
});
|
|
86
|
+
if (status < 200 || status >= 300) {
|
|
87
|
+
throw new Error(`cannot fetch ${repo}@${sha}: HTTP ${status} from ${url}${def.token ? "" : " (no token configured: a private repository needs one)"}`);
|
|
88
|
+
}
|
|
64
89
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
},
|
|
76
|
-
) as never;
|
|
90
|
+
const extracted = join(dir, "x");
|
|
91
|
+
mkdirSync(extracted);
|
|
92
|
+
await extractTar(archive, extracted);
|
|
93
|
+
// GitHub tarballs hold one top-level directory, plus a pax_global_header entry (the commit id)
|
|
94
|
+
// that some extractors materialise as a file.
|
|
95
|
+
const roots = readdirSync(extracted).filter((e) => e !== "pax_global_header" && statSync(join(extracted, e)).isDirectory());
|
|
96
|
+
if (roots.length !== 1) throw new Error(`unexpected tarball layout for ${repo}@${sha}: top-level entries ${JSON.stringify(readdirSync(extracted))}`);
|
|
97
|
+
const tree = await ctx.output.dir(join(extracted, roots[0]!), { name: "tree" });
|
|
98
|
+
return { repo, sha, tree };
|
|
99
|
+
}) as never;
|
|
77
100
|
}
|