@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.7
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 +63 -17
- package/manifest.schema.json +87 -9
- package/package.json +1 -1
- package/src/build/build.ts +7 -7
- 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
- package/src/util/tar.ts +13 -0
|
@@ -7,6 +7,7 @@ export const API_VERSION = "2.0";
|
|
|
7
7
|
const stepName = z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/).describe("Step name: lower-case kebab, 1-63 chars. The step's identity.");
|
|
8
8
|
const modelName = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/).describe("Model name (key of `models`).");
|
|
9
9
|
const secretName = z.string().regex(/^[A-Z_][A-Z0-9_]*$/).describe("Secret name; also the env var name in the step container.");
|
|
10
|
+
const configName = z.string().regex(/^[a-z][a-zA-Z0-9]*$/).describe("Config entry name; a key of the pipeline's config document.");
|
|
10
11
|
const hash = z.string().regex(/^sha256:[0-9a-f]{64}$/);
|
|
11
12
|
const Json = z.json().meta({ id: "Json", description: "Any JSON value." });
|
|
12
13
|
const jsonSchema = z.record(z.string(), z.unknown()).describe("A JSON Schema (draft 2020-12) object, canonical (sorted keys, no $schema).");
|
|
@@ -50,7 +51,7 @@ export const StepDescriptor = z
|
|
|
50
51
|
.strictObject({
|
|
51
52
|
name: stepName,
|
|
52
53
|
description: z.string().describe("What the step does, shown in the UI (may be empty)."),
|
|
53
|
-
kind: z.
|
|
54
|
+
kind: z.string().min(1).describe("'custom' = handlers/<name>.ts; others are SDK library steps. A label: the service accepts any kind."),
|
|
54
55
|
image: z.string().min(1).describe("Container image the step runs in."),
|
|
55
56
|
inputTypes: z.record(z.string(), InputType).describe("Declared input entries, keyed like input.json."),
|
|
56
57
|
input: z.record(z.string(), Mapping).describe("How the service assembles input.json: one mapping per input key (same keys as inputTypes)."),
|
|
@@ -59,11 +60,25 @@ export const StepDescriptor = z
|
|
|
59
60
|
handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for library steps kind + config + harness."),
|
|
60
61
|
timeoutSeconds: z.number().int().positive().optional(),
|
|
61
62
|
retries: z.number().int().nonnegative().optional(),
|
|
62
|
-
config: z
|
|
63
|
+
config: z
|
|
64
|
+
.record(z.string(), Json)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("Library-step configuration (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries."),
|
|
67
|
+
configEntries: z
|
|
68
|
+
.array(configName)
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Config entries the step receives (sorted; omitted when none). The service delivers them in input.json under '$config'."),
|
|
71
|
+
clusterAccess: z
|
|
72
|
+
.literal(true)
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token."),
|
|
63
75
|
})
|
|
64
76
|
.meta({ id: "Step" });
|
|
65
77
|
|
|
66
|
-
export type Stage =
|
|
78
|
+
export type Stage =
|
|
79
|
+
| { steps: z.infer<typeof StepDescriptor>[] }
|
|
80
|
+
| { when: Record<string, (string | number | boolean)[]>; stages: Stage[] }
|
|
81
|
+
| { select: string; discriminator: string; cases: Record<string, Stage[]> };
|
|
67
82
|
export const Stage: z.ZodType<Stage> = z
|
|
68
83
|
.union([
|
|
69
84
|
z.strictObject({ steps: z.array(StepDescriptor).min(1).describe("Steps run in parallel; the stage is a barrier.") }),
|
|
@@ -73,6 +88,13 @@ export const Stage: z.ZodType<Stage> = z
|
|
|
73
88
|
),
|
|
74
89
|
stages: z.array(z.lazy(() => Stage)),
|
|
75
90
|
}),
|
|
91
|
+
z.strictObject({
|
|
92
|
+
select: z.string().min(1).describe("Config reference (`<entry>.<field>…`) to a discriminated union; the run's config snapshot picks the case."),
|
|
93
|
+
discriminator: z.string().min(1).describe("The union's literal field whose value names the case."),
|
|
94
|
+
cases: z
|
|
95
|
+
.record(z.string(), z.array(z.lazy(() => Stage)))
|
|
96
|
+
.describe("Discriminator value → stages. Exactly one case runs; a step name may repeat across cases, nowhere else."),
|
|
97
|
+
}),
|
|
76
98
|
])
|
|
77
99
|
.meta({ id: "Stage" });
|
|
78
100
|
|
|
@@ -123,6 +145,17 @@ export const Manifest = z
|
|
|
123
145
|
.describe("Harness identity. Already folded into every handlerHash."),
|
|
124
146
|
models: z.record(modelName, ModelSchemas).describe("Every model referenced by the pipeline (input, step inputs/outputs)."),
|
|
125
147
|
secrets: z.array(z.strictObject({ name: secretName, description: z.string() })).describe("All declared secrets (steps + poll triggers), sorted by name."),
|
|
148
|
+
config: z
|
|
149
|
+
.array(
|
|
150
|
+
z.strictObject({
|
|
151
|
+
name: configName,
|
|
152
|
+
description: z.string(),
|
|
153
|
+
required: z.boolean().describe("False when the schema accepts a missing value (optional, or a default)."),
|
|
154
|
+
schema: jsonSchema.describe("z.input view: validates the config document's value for this entry (defaults listed)."),
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("Config entries used by steps, sorted by name (omitted when none). Fields of kind secretRef ({secret: NAME}) name a secret."),
|
|
126
159
|
triggers: z.array(Trigger),
|
|
127
160
|
stages: z.array(Stage),
|
|
128
161
|
failureHandlers: z.array(
|
package/src/dev/cli.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// olve-dev — run a pipeline from source through the same harness path as the compiled binary.
|
|
3
3
|
// olve-dev describe <dir> [--sentinels]
|
|
4
|
-
// olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
|
|
4
|
+
// olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
5
5
|
// olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
|
|
6
6
|
// $OLVE_DIR defaults to <dir>/.olve; outputs land in <dir>/.olve/work/.
|
|
7
|
-
import { copyFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
import { runHarness, type HarnessConfig } from "../harness/main.ts";
|
|
@@ -13,7 +13,7 @@ import { handlerHashes } from "../build/hashes.ts";
|
|
|
13
13
|
import { harnessInfo, loadProject, ProjectError } from "../build/project.ts";
|
|
14
14
|
|
|
15
15
|
const USAGE = `usage: olve-dev describe <dir> [--sentinels]
|
|
16
|
-
olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
|
|
16
|
+
olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
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> {
|
|
@@ -38,6 +38,7 @@ export async function devMain(argv: string[]): Promise<number> {
|
|
|
38
38
|
allowPositionals: true,
|
|
39
39
|
options: {
|
|
40
40
|
"input-file": { type: "string" },
|
|
41
|
+
"config-file": { type: "string" },
|
|
41
42
|
"event-file": { type: "string" },
|
|
42
43
|
"olve-dir": { type: "string" },
|
|
43
44
|
secret: { type: "string", multiple: true },
|
|
@@ -68,6 +69,12 @@ export async function devMain(argv: string[]): Promise<number> {
|
|
|
68
69
|
}
|
|
69
70
|
const file = mode === "run" ? values["input-file"] : values["event-file"];
|
|
70
71
|
if (file) copyFileSync(resolve(file), join(olveDir, "input.json"));
|
|
72
|
+
if (mode === "run" && values["config-file"]) {
|
|
73
|
+
const inputFile = join(olveDir, "input.json");
|
|
74
|
+
const input = (existsSync(inputFile) ? JSON.parse(readFileSync(inputFile, "utf8")) : {}) as Record<string, unknown>;
|
|
75
|
+
input.$config = JSON.parse(readFileSync(resolve(values["config-file"]), "utf8"));
|
|
76
|
+
writeFileSync(inputFile, JSON.stringify(input));
|
|
77
|
+
}
|
|
71
78
|
else if (mode === "map-trigger") return console.error("--event-file is required"), EXIT.usage;
|
|
72
79
|
return await runHarness(await devConfig(dir, false), [mode, name]);
|
|
73
80
|
} catch (e) {
|
package/src/harness/execute.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
|
8
8
|
import { objectPath } from "../ctx/files.ts";
|
|
9
9
|
import { sha256File } from "../util/hash.ts";
|
|
10
10
|
import { EXIT } from "./exit.ts";
|
|
11
|
-
import { validateInput, validateOutput } from "./validate.ts";
|
|
11
|
+
import { validateConfig, validateInput, validateOutput } from "./validate.ts";
|
|
12
12
|
|
|
13
13
|
export type FailureKind = "handler" | "input" | "output";
|
|
14
14
|
export type Envelope =
|
|
@@ -58,13 +58,16 @@ export async function executeStep(args: {
|
|
|
58
58
|
workFiles: string;
|
|
59
59
|
log: (line: string) => void;
|
|
60
60
|
}): Promise<Execution> {
|
|
61
|
-
const { step,
|
|
61
|
+
const { step, log } = args;
|
|
62
62
|
const input = validateInput(step, args.rawInput);
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const config = validateConfig(step, args.rawInput);
|
|
64
|
+
if (!input.ok || !config.ok) {
|
|
65
|
+
const issues = [...(input.ok ? [] : input.issues), ...(config.ok ? [] : config.issues)];
|
|
66
|
+
const message = `step '${step.name}': input does not match the step's inputs:\n ${issues.join("\n ")}`;
|
|
65
67
|
log(message);
|
|
66
68
|
return { envelope: { status: "failed", error: { kind: "input", message } }, exitCode: EXIT.inputInvalid };
|
|
67
69
|
}
|
|
70
|
+
const ctx: Ctx = { ...args.ctx, config: Object.freeze(config.value) as Ctx["config"] };
|
|
68
71
|
|
|
69
72
|
let value: unknown;
|
|
70
73
|
try {
|
package/src/harness/main.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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";
|
|
9
10
|
import type { WebhookEvent } from "../core/webhooks.ts";
|
|
10
11
|
import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
|
|
@@ -46,14 +47,43 @@ const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-t
|
|
|
46
47
|
|
|
47
48
|
export function allSteps(data: PipelineData): AnyStep[] {
|
|
48
49
|
const out: AnyStep[] = [];
|
|
49
|
-
const
|
|
50
|
-
|
|
50
|
+
for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
|
|
55
|
+
export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
|
|
56
|
+
const out: { step: AnyStep; cases: string[] }[] = [];
|
|
57
|
+
const walk = (nodes: readonly StageNode[], cases: string[]) => {
|
|
58
|
+
for (const n of nodes) {
|
|
59
|
+
if (n.kind === "stage") out.push(...n.steps.map((s) => ({ step: s.step, cases })));
|
|
60
|
+
else if (n.kind === "when") walk(n.nodes, cases);
|
|
61
|
+
else {
|
|
62
|
+
const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
|
|
63
|
+
for (const [value, inner] of Object.entries(n.cases))
|
|
64
|
+
walk(inner, union ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`] : cases);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
51
67
|
};
|
|
52
|
-
walk(data.nodes);
|
|
53
|
-
for (const fh of data.failureHandlers)
|
|
68
|
+
walk(data.nodes, []);
|
|
69
|
+
for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
|
|
54
70
|
return out;
|
|
55
71
|
}
|
|
56
72
|
|
|
73
|
+
/** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
|
|
74
|
+
export function findStep(data: PipelineData, name: string, selected: readonly string[]): AnyStep | string {
|
|
75
|
+
const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
|
|
76
|
+
const distinct = candidates.filter((c, i) => candidates.findIndex((d) => d.step === c.step) === i);
|
|
77
|
+
if (distinct.length === 0) return `unknown step '${name}'`;
|
|
78
|
+
if (distinct.length === 1) return distinct[0]!.step;
|
|
79
|
+
const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
|
|
80
|
+
if (chosen.length === 1) return chosen[0]!.step;
|
|
81
|
+
return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
|
|
85
|
+
export const ENV_CASES = "OLVE_CASES";
|
|
86
|
+
|
|
57
87
|
/** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
|
|
58
88
|
export function cancellation(): { signal: AbortSignal; dispose(): void } {
|
|
59
89
|
const ac = new AbortController();
|
|
@@ -136,8 +166,8 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
|
|
|
136
166
|
async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
|
|
137
167
|
if (!name) return io.stderr(USAGE), EXIT.usage;
|
|
138
168
|
const data = pipelineData(cfg.pipeline);
|
|
139
|
-
const step =
|
|
140
|
-
if (
|
|
169
|
+
const step = findStep(data, name, selectedCases());
|
|
170
|
+
if (typeof step === "string") return io.stderr(step), EXIT.usage;
|
|
141
171
|
const fn = await resolveStepFunction(step, cfg.handlers);
|
|
142
172
|
if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
|
|
143
173
|
|
|
@@ -230,3 +260,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
|
|
|
230
260
|
}
|
|
231
261
|
|
|
232
262
|
export { formatIssues, formatLog };
|
|
263
|
+
|
|
264
|
+
function selectedCases(): string[] {
|
|
265
|
+
try {
|
|
266
|
+
const v = JSON.parse(process.env[ENV_CASES] ?? "[]") as unknown;
|
|
267
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
}
|
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
|
@@ -3,10 +3,11 @@
|
|
|
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 { config, secretRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
|
|
6
7
|
export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
|
|
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";
|
|
8
|
+
export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
|
|
8
9
|
export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
|
|
9
10
|
export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
|
|
10
11
|
export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
|
|
11
12
|
export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.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";
|
|
13
|
+
export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type HelmDeployOverSshDef, helmTarget, deployTarget, type ComposeTargetValue, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue, registryTag, registryDigest, type RegistryAuthConfig, type RegistryAccess, npmDistTag, npmHasVersion, httpHealthCheck, type HttpHealthCheckOptions, sshSession, shq, type SshSession, serviceUrl, checkSpa } from "./library/index.ts";
|
|
@@ -0,0 +1,93 @@
|
|
|
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 { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
|
|
7
|
+
import type { Secret } from "../core/secrets.ts";
|
|
8
|
+
import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
9
|
+
import { httpHealthCheck } from "./http-health.ts";
|
|
10
|
+
import { HelmRelease, PushedImage } from "./models.ts";
|
|
11
|
+
|
|
12
|
+
/** The step's own image: the work is one HTTP call from the harness. */
|
|
13
|
+
export const COMPOSE_DEPLOY_IMAGE = "alpine:3.22";
|
|
14
|
+
|
|
15
|
+
/** A resolved `kind: compose` target. */
|
|
16
|
+
export interface ComposeTargetValue {
|
|
17
|
+
kind: "compose";
|
|
18
|
+
deployerUrl: string;
|
|
19
|
+
service: string;
|
|
20
|
+
token: { secret: string };
|
|
21
|
+
healthUrl?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the deployer answers to `POST /v1/deploy`. */
|
|
25
|
+
export interface ComposeDeployResult {
|
|
26
|
+
status: "deployed" | "rolled-back" | "failed";
|
|
27
|
+
service: string;
|
|
28
|
+
image: string;
|
|
29
|
+
previousImage?: string;
|
|
30
|
+
message?: string;
|
|
31
|
+
log?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ComposeDeployDef<N extends string, K extends string> {
|
|
35
|
+
name: LiteralName<N>;
|
|
36
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
37
|
+
description: string;
|
|
38
|
+
/** A compose target, usually a `select` case's reference. */
|
|
39
|
+
target: Configurable<ComposeTargetValue>;
|
|
40
|
+
/** The secret the target's `token` names (declared by the step). */
|
|
41
|
+
token: Secret<K>;
|
|
42
|
+
timeoutSeconds?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const input = { image: PushedImage };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deploys a registry image (by digest) to a compose service: `POST <deployerUrl>/v1/deploy
|
|
49
|
+
* { service, image: <repository>@<digest> }` with the target's bearer token. The deployer pulls the
|
|
50
|
+
* image, recreates the service, waits for its health and rolls back on failure; the step reports its
|
|
51
|
+
* answer, then (with the target's `healthUrl`) checks health itself. Output: a `HelmRelease` with the
|
|
52
|
+
* service as `release` and `compose` as `namespace`, so it can share a step name with the helm deploys.
|
|
53
|
+
*/
|
|
54
|
+
export function composeDeploy<const N extends string, const K extends string>(
|
|
55
|
+
def: ComposeDeployDef<N, K>,
|
|
56
|
+
): Step<N, typeof input, typeof HelmRelease, K> & LibraryStep {
|
|
57
|
+
const configurable = { target: def.target };
|
|
58
|
+
return makeStep(
|
|
59
|
+
"composeDeploy",
|
|
60
|
+
{ name: def.name, description: def.description, image: COMPOSE_DEPLOY_IMAGE, input, output: HelmRelease, secrets: [def.token], timeoutSeconds: def.timeoutSeconds },
|
|
61
|
+
{
|
|
62
|
+
manifestConfig: () => ({ target: recordConfigRefs(def.target), token: def.token.name }),
|
|
63
|
+
async run({ image }: { image: { repository: string; digest: string } }, ctx: Ctx) {
|
|
64
|
+
const { target } = resolveConfigRefs(configurable, ctx.config ?? {}) as { target: ComposeTargetValue };
|
|
65
|
+
if (target?.kind !== "compose") throw new Error(`composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`);
|
|
66
|
+
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}`);
|
|
67
|
+
const token = (ctx.secrets as Record<string, string>)[def.token.name];
|
|
68
|
+
const ref = `${image.repository}@${image.digest}`;
|
|
69
|
+
const url = `${target.deployerUrl.replace(/\/+$/, "")}/v1/deploy`;
|
|
70
|
+
ctx.log("deploying", { service: target.service, image: ref, deployer: target.deployerUrl });
|
|
71
|
+
const res = await ctx.http.fetch(url, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ service: target.service, image: ref }),
|
|
75
|
+
});
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
let result: ComposeDeployResult | undefined;
|
|
78
|
+
try {
|
|
79
|
+
result = JSON.parse(text) as ComposeDeployResult;
|
|
80
|
+
} catch {
|
|
81
|
+
// reported below
|
|
82
|
+
}
|
|
83
|
+
for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
|
|
84
|
+
if (!res.ok || result?.status !== "deployed")
|
|
85
|
+
throw new Error(`the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ""}` : text.slice(0, 300)}`);
|
|
86
|
+
if (target.healthUrl) await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
|
|
87
|
+
ctx.log("deployed", { service: target.service, image: ref, previous: result.previousImage ?? "-" });
|
|
88
|
+
return { release: target.service, namespace: "compose", image: ref };
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
configurable,
|
|
92
|
+
) as never;
|
|
93
|
+
}
|
|
@@ -7,6 +7,8 @@ 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 type { Configurable } from "../core/config.ts";
|
|
11
|
+
import { recordConfigRefs, resolveConfigRefs } from "../core/config-refs.ts";
|
|
10
12
|
import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
|
|
11
13
|
import { extractTar } from "../util/tar.ts";
|
|
12
14
|
import { Source } from "./models.ts";
|
|
@@ -23,11 +25,25 @@ export interface GitHubSourceDef<N extends string, T extends string> {
|
|
|
23
25
|
description: string;
|
|
24
26
|
/** Token sent to the GitHub API (needed for private repositories; raises the rate limit). */
|
|
25
27
|
token?: Secret<T>;
|
|
28
|
+
/** GitHub API base (default `https://api.github.com`; GitHub Enterprise: `https://<host>/api/v3`). */
|
|
29
|
+
apiUrl?: Configurable<string | undefined>;
|
|
26
30
|
timeoutSeconds?: number;
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
export const GITHUB_API_URL = "https://api.github.com";
|
|
34
|
+
|
|
35
|
+
/** A declared secret's value, or "" when it is not set. */
|
|
36
|
+
function optionalSecret(ctx: Pick<Ctx, "secrets">, name: string): string {
|
|
37
|
+
try {
|
|
38
|
+
return ((ctx.secrets as Record<string, string>)[name] ?? "").trim();
|
|
39
|
+
} catch {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
/** Tarball URL of `repo` at `sha` (the API redirects to codeload.github.com). */
|
|
30
|
-
export const gitHubTarballUrl = (repo: string, sha: string): string =>
|
|
45
|
+
export const gitHubTarballUrl = (repo: string, sha: string, apiUrl = GITHUB_API_URL): string =>
|
|
46
|
+
`${apiUrl.replace(/\/+$/, "")}/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
|
|
31
47
|
|
|
32
48
|
/**
|
|
33
49
|
* Downloads `repo` at `sha` from GitHub as a tarball and outputs it as a `Source` whose `tree` is the
|
|
@@ -41,11 +57,13 @@ export function gitHubSource<const N extends string, const T extends string = ne
|
|
|
41
57
|
"gitHubSource",
|
|
42
58
|
{ name: def.name, description: def.description, image: GITHUB_SOURCE_IMAGE, input, output: Source, secrets: def.token ? [def.token] : [], timeoutSeconds: def.timeoutSeconds },
|
|
43
59
|
{
|
|
44
|
-
manifestConfig: () => ({ ...(def.token ? { token: def.token.name } : {}) }),
|
|
60
|
+
manifestConfig: () => ({ ...(def.token ? { token: def.token.name } : {}), ...(def.apiUrl !== undefined ? { apiUrl: recordConfigRefs(def.apiUrl) } : {}) }),
|
|
45
61
|
async run({ repo, sha }: { repo: string; sha: string }, ctx: Ctx) {
|
|
46
62
|
const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
const token = def.token ? optionalSecret(ctx, def.token.name) : "";
|
|
64
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
65
|
+
else if (def.token) ctx.log(`${def.token.name} is not set: fetching anonymously (public repositories only)`);
|
|
66
|
+
const url = gitHubTarballUrl(repo, sha, resolveConfigRefs(def.apiUrl, ctx.config ?? {}) ?? GITHUB_API_URL);
|
|
49
67
|
mkdirSync(ctx.tmpDir, { recursive: true });
|
|
50
68
|
const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
|
|
51
69
|
const archive = join(dir, "repo.tar.gz");
|
|
@@ -73,5 +91,6 @@ export function gitHubSource<const N extends string, const T extends string = ne
|
|
|
73
91
|
return { repo, sha, tree };
|
|
74
92
|
},
|
|
75
93
|
},
|
|
94
|
+
{ apiUrl: def.apiUrl },
|
|
76
95
|
) as never;
|
|
77
96
|
}
|