@olivervea/pipelines 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +467 -2
- package/manifest.schema.json +674 -0
- package/package.json +60 -4
- package/src/assets.d.ts +10 -0
- package/src/build/build.ts +127 -0
- package/src/build/cli.ts +52 -0
- package/src/build/elf.ts +35 -0
- package/src/build/hashes.ts +96 -0
- package/src/build/project.ts +107 -0
- package/src/builder/pipeline.ts +373 -0
- package/src/builder/refs.ts +26 -0
- package/src/core/brands.ts +15 -0
- package/src/core/config-refs.ts +77 -0
- package/src/core/config.ts +147 -0
- package/src/core/handlers.ts +50 -0
- package/src/core/models.ts +69 -0
- package/src/core/origin.ts +20 -0
- package/src/core/secrets.ts +13 -0
- package/src/core/steps.ts +138 -0
- package/src/core/types.ts +6 -0
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/files.ts +161 -0
- package/src/ctx/index.ts +122 -0
- package/src/ctx/sh.ts +197 -0
- package/src/ctx/types.ts +116 -0
- package/src/describe/describe.ts +565 -0
- package/src/describe/manifest-schema.ts +179 -0
- package/src/describe/mapper-source.ts +238 -0
- package/src/describe/record.ts +107 -0
- package/src/dev/cli.ts +87 -0
- package/src/harness/env.ts +58 -0
- package/src/harness/execute.ts +97 -0
- package/src/harness/exit.ts +19 -0
- package/src/harness/index.ts +9 -0
- package/src/harness/main.ts +270 -0
- package/src/harness/result.ts +75 -0
- package/src/harness/validate.ts +92 -0
- package/src/index.ts +15 -0
- package/src/library/checks.ts +23 -0
- package/src/library/compose-deploy.ts +96 -0
- package/src/library/github-source.ts +100 -0
- package/src/library/helm-deploy-ssh.ts +358 -0
- package/src/library/helm-deploy.ts +271 -0
- package/src/library/http-health.ts +34 -0
- package/src/library/image-build.ts +418 -0
- package/src/library/index.ts +18 -0
- package/src/library/models.ts +79 -0
- package/src/library/ssh.ts +50 -0
- package/src/testing/index.ts +82 -0
- package/src/util/canonical.ts +19 -0
- package/src/util/handler-hash.ts +22 -0
- package/src/util/hash.ts +22 -0
- package/src/util/tar.ts +167 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The environment contract between the service and the harness. Documented in README.md.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const ENV = {
|
|
5
|
+
/** Root of the harness IO directory (default /olve). */
|
|
6
|
+
dir: "OLVE_DIR",
|
|
7
|
+
pipeline: "OLVE_PIPELINE",
|
|
8
|
+
runId: "OLVE_RUN_ID",
|
|
9
|
+
run: "OLVE_RUN",
|
|
10
|
+
attempt: "OLVE_ATTEMPT",
|
|
11
|
+
versionStamp: "OLVE_VERSION_STAMP",
|
|
12
|
+
/** Pre-signed PUT for result.tar.gz (run / map-trigger). Optional. */
|
|
13
|
+
resultUrl: "OLVE_RESULT_URL",
|
|
14
|
+
/** JSON object: file key (`<run>/<step>/<attempt>/<path>`) → pre-signed GET URL. Optional. */
|
|
15
|
+
fileUrls: "OLVE_FILE_URLS",
|
|
16
|
+
/** Base URL for file downloads: GET `<base>/<run>/<step>/<attempt>/<object path>`. Optional. */
|
|
17
|
+
filesBaseUrl: "OLVE_FILES_BASE_URL",
|
|
18
|
+
/** Bearer token sent with OLVE_FILES_BASE_URL requests. Optional. */
|
|
19
|
+
filesToken: "OLVE_FILES_TOKEN",
|
|
20
|
+
/** Local directory laid out as `<run>/<step>/<attempt>/files/<object path>` (dev). Optional. */
|
|
21
|
+
filesDir: "OLVE_FILES_DIR",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/** Env vars never passed to child processes (they carry credentials for the harness only). */
|
|
25
|
+
export const PRIVATE_ENV: readonly string[] = [ENV.resultUrl, ENV.fileUrls, ENV.filesToken];
|
|
26
|
+
|
|
27
|
+
export const olveDir = () => process.env[ENV.dir] || "/olve";
|
|
28
|
+
export const paths = () => {
|
|
29
|
+
const root = olveDir();
|
|
30
|
+
return {
|
|
31
|
+
root,
|
|
32
|
+
input: join(root, "input.json"),
|
|
33
|
+
work: join(root, "work"),
|
|
34
|
+
output: join(root, "work", "output.json"),
|
|
35
|
+
files: join(root, "work", "files"),
|
|
36
|
+
tmp: join(root, "tmp"),
|
|
37
|
+
fileUrls: join(root, "files.json"),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function int(name: string, fallback: number): number {
|
|
42
|
+
const v = process.env[name];
|
|
43
|
+
if (v === undefined || v === "") return fallback;
|
|
44
|
+
const n = Number(v);
|
|
45
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`${name} must be a non-negative integer, got '${v}'`);
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runInfoFromEnv(step: string) {
|
|
50
|
+
return {
|
|
51
|
+
pipeline: process.env[ENV.pipeline] ?? "local",
|
|
52
|
+
id: process.env[ENV.runId] ?? "local",
|
|
53
|
+
number: int(ENV.run, 0),
|
|
54
|
+
attempt: Math.max(1, int(ENV.attempt, 1)),
|
|
55
|
+
step,
|
|
56
|
+
version: process.env[ENV.versionStamp] ?? "0.0.0-local",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The shared execution path: validate input → handler → validate output → envelope.
|
|
2
|
+
// Used by the compiled harness (`run`), the dev runner and testHandler.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isSkipped } from "../core/handlers.ts";
|
|
6
|
+
import type { AnyStep } from "../core/steps.ts";
|
|
7
|
+
import type { Ctx, RunInfo } from "../ctx/types.ts";
|
|
8
|
+
import { objectPath } from "../ctx/files.ts";
|
|
9
|
+
import { sha256File } from "../util/hash.ts";
|
|
10
|
+
import { EXIT } from "./exit.ts";
|
|
11
|
+
import { validateConfig, validateInput, validateOutput } from "./validate.ts";
|
|
12
|
+
|
|
13
|
+
export type FailureKind = "handler" | "input" | "output";
|
|
14
|
+
export type Envelope =
|
|
15
|
+
| { status: "succeeded"; output: unknown }
|
|
16
|
+
| { status: "skipped"; reason: string }
|
|
17
|
+
| { status: "failed"; error: { kind: FailureKind; message: string } };
|
|
18
|
+
|
|
19
|
+
export interface Execution {
|
|
20
|
+
envelope: Envelope;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type StepFunction = (input: Record<string, unknown>, ctx: Ctx) => unknown;
|
|
25
|
+
|
|
26
|
+
/** Signal → exit code, when the handler failed after a cancellation. */
|
|
27
|
+
export function cancelExitCode(signal: AbortSignal): number | undefined {
|
|
28
|
+
if (!signal.aborted) return undefined;
|
|
29
|
+
return signal.reason === "SIGINT" ? EXIT.sigint : EXIT.sigterm;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const errorMessage = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
33
|
+
|
|
34
|
+
/** File refs in `value` that claim to be this attempt's own outputs must exist with the right hash. */
|
|
35
|
+
function checkOwnFileRefs(value: unknown, run: RunInfo, workFiles: string): string[] {
|
|
36
|
+
const problems: string[] = [];
|
|
37
|
+
const walk = (v: unknown, at: string) => {
|
|
38
|
+
if (Array.isArray(v)) return v.forEach((x, i) => walk(x, `${at}[${i}]`));
|
|
39
|
+
if (typeof v !== "object" || v === null) return;
|
|
40
|
+
const o = v as Record<string, unknown>;
|
|
41
|
+
if (typeof o.path === "string" && typeof o.sha256 === "string" && o.run === run.number && o.step === run.step && o.attempt === run.attempt) {
|
|
42
|
+
const file = join(workFiles, objectPath(o.path));
|
|
43
|
+
if (!existsSync(file)) problems.push(`${at}: file reference '${o.path}' points to this attempt but no such output file was produced`);
|
|
44
|
+
else if (sha256File(file) !== o.sha256) problems.push(`${at}: file reference '${o.path}' has the wrong sha256 (the file changed after it was referenced)`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const [k, x] of Object.entries(o)) walk(x, `${at}.${k}`);
|
|
48
|
+
};
|
|
49
|
+
walk(value, "output");
|
|
50
|
+
return problems;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function executeStep(args: {
|
|
54
|
+
step: AnyStep;
|
|
55
|
+
fn: StepFunction;
|
|
56
|
+
rawInput: unknown;
|
|
57
|
+
ctx: Ctx;
|
|
58
|
+
workFiles: string;
|
|
59
|
+
log: (line: string) => void;
|
|
60
|
+
}): Promise<Execution> {
|
|
61
|
+
const { step, log } = args;
|
|
62
|
+
const input = validateInput(step, args.rawInput);
|
|
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 ")}`;
|
|
67
|
+
log(message);
|
|
68
|
+
return { envelope: { status: "failed", error: { kind: "input", message } }, exitCode: EXIT.inputInvalid };
|
|
69
|
+
}
|
|
70
|
+
const ctx: Ctx = { ...args.ctx, config: Object.freeze(config.value) as Ctx["config"] };
|
|
71
|
+
|
|
72
|
+
let value: unknown;
|
|
73
|
+
try {
|
|
74
|
+
value = await args.fn(input.value, ctx);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
const cancelled = cancelExitCode(ctx.signal);
|
|
77
|
+
const message = cancelled
|
|
78
|
+
? `step '${step.name}' was cancelled (${String(ctx.signal.reason)}): ${errorMessage(e)}`
|
|
79
|
+
: `step '${step.name}' failed: ${errorMessage(e)}`;
|
|
80
|
+
log(message);
|
|
81
|
+
if (e instanceof Error && e.stack && !cancelled) log(e.stack);
|
|
82
|
+
return { envelope: { status: "failed", error: { kind: "handler", message } }, exitCode: cancelled ?? EXIT.handlerFailed };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isSkipped(value)) {
|
|
86
|
+
log(`step '${step.name}' skipped: ${value.reason}`);
|
|
87
|
+
return { envelope: { status: "skipped", reason: value.reason }, exitCode: EXIT.ok };
|
|
88
|
+
}
|
|
89
|
+
const out = validateOutput(step, value);
|
|
90
|
+
const problems = out.ok ? checkOwnFileRefs(out.value, ctx.run, args.workFiles) : out.issues;
|
|
91
|
+
if (!out.ok || problems.length) {
|
|
92
|
+
const message = `step '${step.name}': returned value does not match ${step.output?.name ?? "its (absent) output model"}:\n ${problems.join("\n ")}`;
|
|
93
|
+
log(message);
|
|
94
|
+
return { envelope: { status: "failed", error: { kind: "output", message } }, exitCode: EXIT.outputInvalid };
|
|
95
|
+
}
|
|
96
|
+
return { envelope: { status: "succeeded", output: out.value }, exitCode: EXIT.ok };
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Harness exit codes (sysexits-style). Part of the service contract. */
|
|
2
|
+
export const EXIT = {
|
|
3
|
+
ok: 0,
|
|
4
|
+
/** The handler (or trigger mapper) threw. */
|
|
5
|
+
handlerFailed: 1,
|
|
6
|
+
/** Bad command line: unknown mode, step or trigger. */
|
|
7
|
+
usage: 64,
|
|
8
|
+
/** input.json missing, not JSON, or not valid for the step's inputs (or the trigger event). */
|
|
9
|
+
inputInvalid: 65,
|
|
10
|
+
/** The handler's return value (or the mapped trigger input) failed strict validation. */
|
|
11
|
+
outputInvalid: 66,
|
|
12
|
+
/** Uploading result.tar.gz to OLVE_RESULT_URL failed (after retries). The envelope was still printed. */
|
|
13
|
+
uploadFailed: 74,
|
|
14
|
+
/** The pipeline is invalid: describe problems, missing/invalid handler module. */
|
|
15
|
+
configInvalid: 78,
|
|
16
|
+
/** Cancelled by SIGINT / SIGTERM (128 + signal). */
|
|
17
|
+
sigint: 130,
|
|
18
|
+
sigterm: 143,
|
|
19
|
+
} as const;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @olivervea/pipelines/harness — the runtime the build tool compiles into the step binary.
|
|
2
|
+
export { runHarness, defaultIO, allSteps, resolveStepFunction, type HarnessConfig, type HandlerRegistry, type HarnessIO } from "./main.ts";
|
|
3
|
+
export { EXIT } from "./exit.ts";
|
|
4
|
+
export { ENV, paths, runInfoFromEnv } from "./env.ts";
|
|
5
|
+
export { executeStep, type Envelope, type Execution, type FailureKind } from "./execute.ts";
|
|
6
|
+
export { RESULT_BEGIN, RESULT_END, MANIFEST_BEGIN, MANIFEST_END } from "./result.ts";
|
|
7
|
+
export { describePipeline, DescribeError, manifestText } from "../describe/describe.ts";
|
|
8
|
+
export type { ManifestT as Manifest } from "../describe/manifest-schema.ts";
|
|
9
|
+
export type { HarnessInfo } from "../util/handler-hash.ts";
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The harness: one executable, three modes.
|
|
2
|
+
// describe [--sentinels] → manifest (canonical JSON) on stdout
|
|
3
|
+
// run <step> → $OLVE_DIR/input.json → handler → $OLVE_DIR/work/output.json (+ result upload)
|
|
4
|
+
// map-trigger <trigger> → $OLVE_DIR/input.json (event) → pipeline input → $OLVE_DIR/work/output.json
|
|
5
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { isImplementation } from "../core/handlers.ts";
|
|
8
|
+
import { configRefPath, isConfigRef, unionAt } from "../core/config.ts";
|
|
9
|
+
import type { AnyStep } from "../core/steps.ts";
|
|
10
|
+
import type { WebhookEvent } from "../core/webhooks.ts";
|
|
11
|
+
import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
|
|
12
|
+
import { createCtx, formatLog } from "../ctx/index.ts";
|
|
13
|
+
import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
|
|
14
|
+
import type { HarnessInfo } from "../util/handler-hash.ts";
|
|
15
|
+
import { ENV, paths, runInfoFromEnv } from "./env.ts";
|
|
16
|
+
import { EXIT } from "./exit.ts";
|
|
17
|
+
import { executeStep, type Envelope, type Execution, type StepFunction } from "./execute.ts";
|
|
18
|
+
import { MANIFEST_BEGIN, MANIFEST_END, packResult, removeStale, sentinelBlock, uploadFile, writeEnvelope } from "./result.ts";
|
|
19
|
+
import { formatIssues, parseStrict } from "./validate.ts";
|
|
20
|
+
|
|
21
|
+
/** step name → lazy import of `handlers/<name>.ts`. Literal import() paths so Bun bundles them. */
|
|
22
|
+
export type HandlerRegistry = Readonly<Record<string, () => Promise<{ default: unknown }>>>;
|
|
23
|
+
|
|
24
|
+
export interface HarnessConfig {
|
|
25
|
+
/** Default export of pipeline.ts. */
|
|
26
|
+
pipeline: unknown;
|
|
27
|
+
handlers: HandlerRegistry;
|
|
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>>;
|
|
31
|
+
harness: HarnessInfo;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HarnessIO {
|
|
35
|
+
stdout(text: string): Promise<void>;
|
|
36
|
+
stderr(line: string): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const defaultIO: HarnessIO = {
|
|
40
|
+
stdout: async (text) => {
|
|
41
|
+
await Bun.write(Bun.stdout, text);
|
|
42
|
+
},
|
|
43
|
+
stderr: (line) => {
|
|
44
|
+
process.stderr.write(line.endsWith("\n") ? line : line + "\n");
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>";
|
|
49
|
+
|
|
50
|
+
export function allSteps(data: PipelineData): AnyStep[] {
|
|
51
|
+
const out: AnyStep[] = [];
|
|
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
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(data.nodes, []);
|
|
71
|
+
for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
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
|
+
|
|
89
|
+
/** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
|
|
90
|
+
export function cancellation(): { signal: AbortSignal; dispose(): void } {
|
|
91
|
+
const ac = new AbortController();
|
|
92
|
+
const onTerm = () => ac.abort("SIGTERM");
|
|
93
|
+
const onInt = () => (ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort("SIGINT"));
|
|
94
|
+
process.on("SIGTERM", onTerm);
|
|
95
|
+
process.on("SIGINT", onInt);
|
|
96
|
+
return {
|
|
97
|
+
signal: ac.signal,
|
|
98
|
+
dispose() {
|
|
99
|
+
process.off("SIGTERM", onTerm);
|
|
100
|
+
process.off("SIGINT", onInt);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolves the function that implements `step` (carried by the step, or a handler module). */
|
|
106
|
+
export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
|
|
107
|
+
if (isImplementation(step)) return step.handler as unknown as StepFunction;
|
|
108
|
+
const load = handlers[step.name];
|
|
109
|
+
if (!load) return `step '${step.name}' has no handler`;
|
|
110
|
+
let mod: { default: unknown };
|
|
111
|
+
try {
|
|
112
|
+
mod = await load();
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
|
|
115
|
+
}
|
|
116
|
+
const impl = mod.default;
|
|
117
|
+
if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
|
|
118
|
+
if (impl.step !== step)
|
|
119
|
+
return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
|
|
120
|
+
return impl.handler as unknown as StepFunction;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
|
|
124
|
+
export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
|
|
125
|
+
const p = paths();
|
|
126
|
+
writeEnvelope(p.work, exec.envelope);
|
|
127
|
+
await io.stdout(sentinelBlock(exec.envelope));
|
|
128
|
+
const url = process.env[ENV.resultUrl];
|
|
129
|
+
if (!url) return exec.exitCode;
|
|
130
|
+
try {
|
|
131
|
+
mkdirSync(p.tmp, { recursive: true });
|
|
132
|
+
const archive = join(p.tmp, "result.tar.gz");
|
|
133
|
+
await packResult(p.work, archive);
|
|
134
|
+
await uploadFile(url, archive, io.stderr);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
io.stderr(`result upload failed: ${(e as Error).message}`);
|
|
137
|
+
return exec.exitCode === EXIT.ok ? EXIT.uploadFailed : exec.exitCode;
|
|
138
|
+
}
|
|
139
|
+
return exec.exitCode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readJson(path: string): { ok: true; value: unknown } | { ok: false; message: string } {
|
|
143
|
+
try {
|
|
144
|
+
return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
|
|
151
|
+
const sentinels = args.includes("--sentinels");
|
|
152
|
+
try {
|
|
153
|
+
const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, kindHashes: cfg.kindHashes, harness: cfg.harness }));
|
|
154
|
+
await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
|
|
155
|
+
return EXIT.ok;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (e instanceof DescribeError) {
|
|
158
|
+
io.stderr(e.message);
|
|
159
|
+
return EXIT.configInvalid;
|
|
160
|
+
}
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
|
|
166
|
+
if (!name) return io.stderr(USAGE), EXIT.usage;
|
|
167
|
+
const data = pipelineData(cfg.pipeline);
|
|
168
|
+
const step = findStep(data, name, selectedCases());
|
|
169
|
+
if (typeof step === "string") return io.stderr(step), EXIT.usage;
|
|
170
|
+
const fn = await resolveStepFunction(step, cfg.handlers);
|
|
171
|
+
if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
|
|
172
|
+
|
|
173
|
+
const p = paths();
|
|
174
|
+
removeStale(p.work);
|
|
175
|
+
mkdirSync(p.files, { recursive: true });
|
|
176
|
+
mkdirSync(p.tmp, { recursive: true });
|
|
177
|
+
const raw = readJson(p.input);
|
|
178
|
+
const cancel = cancellation();
|
|
179
|
+
try {
|
|
180
|
+
let exec: Execution;
|
|
181
|
+
if (!raw.ok) {
|
|
182
|
+
io.stderr(raw.message);
|
|
183
|
+
exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
|
|
184
|
+
} else {
|
|
185
|
+
const run = runInfoFromEnv(step.name);
|
|
186
|
+
const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr });
|
|
187
|
+
exec = await executeStep({ step, fn, rawInput: raw.value, ctx, workFiles: p.files, log: io.stderr });
|
|
188
|
+
}
|
|
189
|
+
return await finish(exec, io);
|
|
190
|
+
} finally {
|
|
191
|
+
cancel.dispose();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
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
|
+
|
|
201
|
+
async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
|
|
202
|
+
if (!name) return io.stderr(USAGE), EXIT.usage;
|
|
203
|
+
const data = pipelineData(cfg.pipeline);
|
|
204
|
+
const trigger = data.triggers.find((t) => t.name === name);
|
|
205
|
+
if (!trigger) return io.stderr(`unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(", ") || "none"})`), EXIT.usage;
|
|
206
|
+
const p = paths();
|
|
207
|
+
removeStale(p.work);
|
|
208
|
+
mkdirSync(p.work, { recursive: true });
|
|
209
|
+
const raw = readJson(p.input);
|
|
210
|
+
const fail = (kind: "input" | "output" | "handler", message: string, exitCode: number): Execution => {
|
|
211
|
+
io.stderr(message);
|
|
212
|
+
return { envelope: { status: "failed", error: { kind, message } }, exitCode };
|
|
213
|
+
};
|
|
214
|
+
let exec: Execution;
|
|
215
|
+
if (!raw.ok) exec = fail("input", raw.message, EXIT.inputInvalid);
|
|
216
|
+
else if (trigger.kind === "poll" && (typeof raw.value !== "object" || raw.value === null || typeof (raw.value as { value?: unknown }).value !== "string"))
|
|
217
|
+
exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
|
|
218
|
+
else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
|
|
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);
|
|
222
|
+
else {
|
|
223
|
+
let mapped: unknown;
|
|
224
|
+
let threw: unknown = undefined;
|
|
225
|
+
try {
|
|
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);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
threw = e ?? new Error("undefined");
|
|
232
|
+
}
|
|
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 };
|
|
235
|
+
else {
|
|
236
|
+
const r = parseStrict(data.input.schema, mapped, "input");
|
|
237
|
+
exec = r.ok
|
|
238
|
+
? { envelope: { status: "succeeded", output: r.value } satisfies Envelope, exitCode: EXIT.ok }
|
|
239
|
+
: fail("output", `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join("\n ")}`, EXIT.outputInvalid);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return finish(exec, io);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Entry point of the compiled binary (and the dev runner). Returns the exit code. */
|
|
246
|
+
export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io: HarnessIO = defaultIO): Promise<number> {
|
|
247
|
+
const [mode, ...rest] = argv;
|
|
248
|
+
switch (mode) {
|
|
249
|
+
case "describe":
|
|
250
|
+
return describeMode(cfg, rest, io);
|
|
251
|
+
case "run":
|
|
252
|
+
return runMode(cfg, rest[0], io);
|
|
253
|
+
case "map-trigger":
|
|
254
|
+
return mapTriggerMode(cfg, rest[0], io);
|
|
255
|
+
default:
|
|
256
|
+
io.stderr(USAGE);
|
|
257
|
+
return EXIT.usage;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Writing, printing and uploading the result of `run` / `map-trigger`.
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJson } from "../util/canonical.ts";
|
|
5
|
+
import { dirEntries, writeTarGz, type TarEntry } from "../util/tar.ts";
|
|
6
|
+
import { redactUrl } from "../ctx/files.ts";
|
|
7
|
+
import type { Envelope } from "./execute.ts";
|
|
8
|
+
|
|
9
|
+
export const RESULT_BEGIN = "---OLVE-RESULT-BEGIN---";
|
|
10
|
+
export const RESULT_END = "---OLVE-RESULT-END---";
|
|
11
|
+
export const MANIFEST_BEGIN = "---OLVE-MANIFEST-BEGIN---";
|
|
12
|
+
export const MANIFEST_END = "---OLVE-MANIFEST-END---";
|
|
13
|
+
|
|
14
|
+
/** Atomically writes the envelope to `<work>/output.json`. */
|
|
15
|
+
export function writeEnvelope(work: string, envelope: Envelope): string {
|
|
16
|
+
mkdirSync(work, { recursive: true });
|
|
17
|
+
const path = join(work, "output.json");
|
|
18
|
+
writeFileSync(path + ".partial", canonicalJson(envelope));
|
|
19
|
+
renameSync(path + ".partial", path);
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The envelope as one compact line between sentinels (fallback channel via pod logs). */
|
|
24
|
+
export function sentinelBlock(envelope: Envelope): string {
|
|
25
|
+
return `${RESULT_BEGIN}\n${JSON.stringify(JSON.parse(canonicalJson(envelope)))}\n${RESULT_END}\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Packs output.json + files/ into result.tar.gz (deterministic). */
|
|
29
|
+
export async function packResult(work: string, out: string): Promise<void> {
|
|
30
|
+
const entries: TarEntry[] = [{ path: "output.json", type: "file", mode: 0o644, source: join(work, "output.json") }];
|
|
31
|
+
const files = join(work, "files");
|
|
32
|
+
if (existsSync(files)) entries.push({ path: "files", type: "dir", mode: 0o755 }, ...dirEntries(files, "files"));
|
|
33
|
+
await writeTarGz(out, entries);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TRANSIENT = (status: number) => status >= 500 || status === 408 || status === 429;
|
|
37
|
+
|
|
38
|
+
/** PUTs `file` to a pre-signed URL; retries network errors / 5xx / 408 / 429 with backoff, fails fast on other 4xx. */
|
|
39
|
+
export async function uploadFile(
|
|
40
|
+
url: string,
|
|
41
|
+
file: string,
|
|
42
|
+
log: (line: string) => void,
|
|
43
|
+
opts: { attempts?: number; baseDelayMs?: number } = {},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const attempts = opts.attempts ?? 5;
|
|
46
|
+
const base = opts.baseDelayMs ?? 1000;
|
|
47
|
+
for (let i = 1; ; i++) {
|
|
48
|
+
let status = 0;
|
|
49
|
+
let detail: string;
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(url, { method: "PUT", body: Bun.file(file) });
|
|
52
|
+
status = res.status;
|
|
53
|
+
if (res.ok) return;
|
|
54
|
+
detail = `HTTP ${res.status} ${res.statusText}`;
|
|
55
|
+
await res.body?.cancel();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
detail = (e as Error).message;
|
|
58
|
+
}
|
|
59
|
+
const transient = status === 0 || TRANSIENT(status);
|
|
60
|
+
if (!transient || i >= attempts) throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
|
|
61
|
+
log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
|
|
62
|
+
await Bun.sleep(base * 2 ** (i - 1));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Clears results of a previous run in the same $OLVE_DIR (dev loops reuse it): output.json and the
|
|
68
|
+
* *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
|
|
69
|
+
*/
|
|
70
|
+
export function removeStale(work: string): void {
|
|
71
|
+
rmSync(join(work, "output.json"), { force: true });
|
|
72
|
+
rmSync(join(work, "output.json.partial"), { force: true });
|
|
73
|
+
const files = join(work, "files");
|
|
74
|
+
if (existsSync(files)) for (const e of readdirSync(files)) rmSync(join(files, e), { recursive: true, force: true });
|
|
75
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isModel, isOptional } from "../core/models.ts";
|
|
3
|
+
import type { AnyStep } from "../core/steps.ts";
|
|
4
|
+
|
|
5
|
+
export type Validation<T> = { ok: true; value: T } | { ok: false; issues: string[] };
|
|
6
|
+
|
|
7
|
+
export function formatIssues(err: z.ZodError, prefix: string): string[] {
|
|
8
|
+
return err.issues.map((i) => `${prefix}${i.path.length ? "." + i.path.map(String).join(".") : ""}: ${i.message}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
12
|
+
typeof v === "object" && v !== null && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* zod strips unknown keys silently (at every depth). Compares the raw value with the parsed one:
|
|
16
|
+
* any key present in a raw object but absent from the parsed object at the same place was stripped.
|
|
17
|
+
*/
|
|
18
|
+
export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[] {
|
|
19
|
+
if (Array.isArray(raw) && Array.isArray(parsed)) return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
|
|
20
|
+
if (isPlainObject(raw) && isPlainObject(parsed)) {
|
|
21
|
+
const out: string[] = [];
|
|
22
|
+
for (const k of Object.keys(raw)) {
|
|
23
|
+
const p = path ? `${path}.${k}` : k;
|
|
24
|
+
if (!Object.hasOwn(parsed, k)) out.push(p);
|
|
25
|
+
else out.push(...strippedKeys(raw[k], parsed[k], p));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Strict parse: zod validation plus rejection of unknown keys at any depth. */
|
|
33
|
+
export function parseStrict(schema: z.ZodType, value: unknown, prefix: string): Validation<unknown> {
|
|
34
|
+
const r = schema.safeParse(value);
|
|
35
|
+
if (!r.success) return { ok: false, issues: formatIssues(r.error, prefix) };
|
|
36
|
+
const extra = strippedKeys(value, r.data);
|
|
37
|
+
if (extra.length) return { ok: false, issues: extra.map((k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`) };
|
|
38
|
+
return { ok: true, value: r.data };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function entrySchema(e: unknown): z.ZodType {
|
|
42
|
+
if (isOptional(e)) return e.model.schema;
|
|
43
|
+
if (isModel(e)) return e.schema;
|
|
44
|
+
return e as z.ZodType;
|
|
45
|
+
}
|
|
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
|
+
|
|
65
|
+
/** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
|
|
66
|
+
export function validateInput(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
|
|
67
|
+
if (!isPlainObject(raw)) return { ok: false, issues: ["input.json must be a JSON object keyed by the step's input names"] };
|
|
68
|
+
const issues: string[] = [];
|
|
69
|
+
const out: Record<string, unknown> = {};
|
|
70
|
+
const declared = step.input as Record<string, unknown>;
|
|
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}'`);
|
|
72
|
+
for (const [k, e] of Object.entries(declared)) {
|
|
73
|
+
if (!Object.hasOwn(raw, k)) {
|
|
74
|
+
if (isOptional(e)) continue;
|
|
75
|
+
issues.push(`input.${k}: missing`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const r = entrySchema(e).safeParse(raw[k]);
|
|
79
|
+
if (r.success) out[k] = r.data;
|
|
80
|
+
else issues.push(...formatIssues(r.error, `input.${k}`));
|
|
81
|
+
}
|
|
82
|
+
return issues.length ? { ok: false, issues } : { ok: true, value: out };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Validates a handler's return value strictly against the step's output model. */
|
|
86
|
+
export function validateOutput(step: AnyStep, value: unknown): Validation<unknown> {
|
|
87
|
+
if (!step.output) {
|
|
88
|
+
if (value === undefined || value === null) return { ok: true, value: null };
|
|
89
|
+
return { ok: false, issues: [`step '${step.name}' declares no output model but the handler returned a value`] };
|
|
90
|
+
}
|
|
91
|
+
return parseStrict(step.output.schema, value, "output");
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference path="./assets.d.ts" />
|
|
2
|
+
// @olivervea/pipelines — authoring API (contracts, handlers, pipeline builder).
|
|
3
|
+
export { z } from "zod";
|
|
4
|
+
export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
|
|
5
|
+
export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
|
|
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";
|
|
11
|
+
export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
|
|
12
|
+
export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
|
|
13
|
+
export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
|
|
14
|
+
export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
|
|
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
|
+
}
|