@olivervea/pipelines 2.0.0-beta.7 → 2.0.0-beta.8
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 +36 -31
- package/manifest.schema.json +3 -3
- package/package.json +1 -1
- package/src/build/build.ts +9 -7
- package/src/build/hashes.ts +31 -2
- package/src/build/project.ts +7 -0
- package/src/core/brands.ts +2 -0
- package/src/core/config.ts +1 -1
- package/src/core/handlers.ts +14 -8
- package/src/core/origin.ts +20 -0
- package/src/core/steps.ts +28 -20
- package/src/describe/describe.ts +37 -32
- package/src/describe/manifest-schema.ts +3 -3
- package/src/dev/cli.ts +5 -5
- package/src/harness/main.ts +5 -6
- package/src/index.ts +5 -3
- package/src/library/compose-deploy.ts +42 -39
- package/src/library/github-source.ts +45 -41
- package/src/library/helm-deploy-ssh.ts +57 -55
- package/src/library/helm-deploy.ts +73 -70
- package/src/library/image-build.ts +113 -169
- package/src/library/index.ts +5 -8
- package/src/library/models.ts +2 -1
- package/src/testing/index.ts +6 -12
- package/src/util/handler-hash.ts +3 -3
- package/src/library/npm.ts +0 -33
- package/src/library/script.ts +0 -85
package/src/describe/describe.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { configRefPath, configRefTarget, isConfigRef, unionAt } from "../core/co
|
|
|
7
7
|
import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
|
|
8
8
|
import { SECRET_NAME, type Secret } from "../core/secrets.ts";
|
|
9
9
|
import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from "../core/steps.ts";
|
|
10
|
+
import { isImplementation } from "../core/handlers.ts";
|
|
11
|
+
import { recordConfigRefs } from "../core/config-refs.ts";
|
|
10
12
|
import { pipelineData, type MapperFunction, type StageNode, type StageStepNode } from "../builder/pipeline.ts";
|
|
11
13
|
import { FailureContext } from "../library/models.ts";
|
|
12
14
|
import { canonicalize, canonicalJson } from "../util/canonical.ts";
|
|
@@ -27,6 +29,8 @@ export interface DescribeOptions {
|
|
|
27
29
|
handlerNames: Iterable<string>;
|
|
28
30
|
/** Step name → handler hash (from the build; includes the harness identity). */
|
|
29
31
|
handlerHashes: Readonly<Record<string, string>>;
|
|
32
|
+
/** Kind → hash of the code that implements the steps of that kind which carry their implementation (from the build). */
|
|
33
|
+
kindHashes?: Readonly<Record<string, string>>;
|
|
30
34
|
harness: HarnessInfo;
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -163,8 +167,8 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
163
167
|
const prev = placements.get(s.name) ?? [];
|
|
164
168
|
if (prev.some((p) => !exclusive(p.cases, cases))) problems.push(`duplicate step name '${s.name}'`);
|
|
165
169
|
else if (prev.length) {
|
|
166
|
-
if (prev.some((p) => p.step !== s && p.step
|
|
167
|
-
problems.push(`step '${s.name}': two different
|
|
170
|
+
if (prev.some((p) => p.step !== s && !isImplementation(p.step) && !isImplementation(s)))
|
|
171
|
+
problems.push(`step '${s.name}': two different steps with handler files share the name across select cases; handlers pair by name, so use one step or different names`);
|
|
168
172
|
if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
|
|
169
173
|
problems.push(`step '${s.name}': its select cases must give it the same output model`);
|
|
170
174
|
}
|
|
@@ -233,14 +237,20 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
233
237
|
if (key.startsWith("$")) problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
|
|
234
238
|
const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
|
|
235
239
|
const configNames = [...new Set((s.config ?? []).map((c) => useConfig(c, at)).filter((n): n is string => n !== undefined))].sort();
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
let
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
const carried = isImplementation(s);
|
|
241
|
+
let settings: Record<string, unknown> | undefined;
|
|
242
|
+
let settingsProblem: string | undefined;
|
|
243
|
+
if (s.settings !== undefined || carried) {
|
|
244
|
+
try {
|
|
245
|
+
settings = canonicalize(recordConfigRefs(s.settings ?? {})) as Record<string, unknown>;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
settingsProblem = (e as Error).message;
|
|
248
|
+
settings = {};
|
|
249
|
+
}
|
|
243
250
|
}
|
|
251
|
+
const refs = settings ? recordedConfigRefs(settings) : [];
|
|
252
|
+
// A step whose settings reference parts of an entry needs the secrets those parts name; any other
|
|
253
|
+
// step receiving an entry needs every secret the entry names.
|
|
244
254
|
for (const name of configNames) {
|
|
245
255
|
const into = refs.filter((r) => r.ref.split(".")[0] === name);
|
|
246
256
|
const needed = into.length === 0
|
|
@@ -250,29 +260,24 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
250
260
|
if (!secretNames.includes(ref)) problems.push(`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`);
|
|
251
261
|
}
|
|
252
262
|
let handlerHash: string;
|
|
253
|
-
|
|
254
|
-
if (s.kind === "
|
|
263
|
+
if (settingsProblem !== undefined) problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
|
|
264
|
+
if (typeof s.kind !== "string" || s.kind === "") problems.push(`${at}: kind must be a non-empty string`);
|
|
265
|
+
if (carried && s.kind === "custom") problems.push(`${at} carries its implementation, so it needs a kind (step({ kind: "…", … })); a step with kind custom has its handler in handlers/`);
|
|
266
|
+
for (const { ref, case: kase } of refs) {
|
|
267
|
+
const [entry, ...path] = ref.split(".") as [string, ...string[]];
|
|
268
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
269
|
+
problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
|
|
270
|
+
if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
|
|
271
|
+
else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
|
|
272
|
+
}
|
|
273
|
+
if (carried) {
|
|
274
|
+
const code = opts.kindHashes?.[s.kind];
|
|
275
|
+
if (opts.kindHashes && !code) problems.push(`${at}: no hash was computed for the code of kind '${s.kind}'`);
|
|
276
|
+
handlerHash = libraryHandlerHash(s.kind, settings, opts.harness, code);
|
|
277
|
+
} else {
|
|
255
278
|
if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`);
|
|
256
279
|
handlerHash = opts.handlerHashes[s.name] ?? "";
|
|
257
280
|
if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
|
|
258
|
-
} else if (s.builtin) {
|
|
259
|
-
try {
|
|
260
|
-
config = canonicalize(s.builtin.manifestConfig()) as Record<string, unknown>;
|
|
261
|
-
} catch (e) {
|
|
262
|
-
problems.push(`${at}: invalid ${s.kind} configuration (${(e as Error).message})`);
|
|
263
|
-
config = {};
|
|
264
|
-
}
|
|
265
|
-
for (const { ref, case: kase } of recordedConfigRefs(config)) {
|
|
266
|
-
const [entry, ...path] = ref.split(".") as [string, ...string[]];
|
|
267
|
-
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
268
|
-
problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
|
|
269
|
-
if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
|
|
270
|
-
else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
|
|
271
|
-
}
|
|
272
|
-
handlerHash = libraryHandlerHash(s.kind, config, opts.harness);
|
|
273
|
-
} else {
|
|
274
|
-
problems.push(`${at}: library step of kind '${s.kind}' has no implementation`);
|
|
275
|
-
handlerHash = "";
|
|
276
281
|
}
|
|
277
282
|
if (typeof s.description !== "string") problems.push(`${at}: description must be a string (it may be empty)`);
|
|
278
283
|
if (s.timeoutSeconds !== undefined && !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)) problems.push(`${at}: timeoutSeconds must be a positive integer`);
|
|
@@ -290,7 +295,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
290
295
|
handlerHash: handlerHash || "sha256:" + "0".repeat(64),
|
|
291
296
|
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
292
297
|
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
293
|
-
...(
|
|
298
|
+
...(settings !== undefined ? { config: settings as Record<string, z.core.util.JSONType> } : {}),
|
|
294
299
|
...(configNames.length ? { configEntries: configNames } : {}),
|
|
295
300
|
...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
|
|
296
301
|
};
|
|
@@ -432,7 +437,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
432
437
|
for (const h of handlerNames) {
|
|
433
438
|
const s = steps.get(h);
|
|
434
439
|
if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
|
|
435
|
-
else if (s
|
|
440
|
+
else if (isImplementation(s)) problems.push(`a handler implements '${h}', a ${s.kind} step that carries its own implementation`);
|
|
436
441
|
}
|
|
437
442
|
|
|
438
443
|
// ── triggers ──
|
|
@@ -534,7 +539,7 @@ export function secretRefsOf(schema: unknown): string[] {
|
|
|
534
539
|
return [...out].sort();
|
|
535
540
|
}
|
|
536
541
|
|
|
537
|
-
/** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a
|
|
542
|
+
/** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
|
|
538
543
|
export function recordedConfigRefs(v: unknown, out: { ref: string; case?: string }[] = []): { ref: string; case?: string }[] {
|
|
539
544
|
if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
|
|
540
545
|
else if (v !== null && typeof v === "object") {
|
|
@@ -51,19 +51,19 @@ export const StepDescriptor = z
|
|
|
51
51
|
.strictObject({
|
|
52
52
|
name: stepName,
|
|
53
53
|
description: z.string().describe("What the step does, shown in the UI (may be empty)."),
|
|
54
|
-
kind: z.string().min(1).describe("'custom' = handlers
|
|
54
|
+
kind: z.string().min(1).describe("'custom' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). A label: the service accepts any kind."),
|
|
55
55
|
image: z.string().min(1).describe("Container image the step runs in."),
|
|
56
56
|
inputTypes: z.record(z.string(), InputType).describe("Declared input entries, keyed like input.json."),
|
|
57
57
|
input: z.record(z.string(), Mapping).describe("How the service assembles input.json: one mapping per input key (same keys as inputTypes)."),
|
|
58
58
|
output: modelName.nullable().describe("Output model, or null if the step produces no output."),
|
|
59
59
|
secrets: z.array(secretName).describe("Declared secrets (sorted). Mount exactly these, as env vars of the same name."),
|
|
60
|
-
handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for
|
|
60
|
+
handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind."),
|
|
61
61
|
timeoutSeconds: z.number().int().positive().optional(),
|
|
62
62
|
retries: z.number().int().nonnegative().optional(),
|
|
63
63
|
config: z
|
|
64
64
|
.record(z.string(), Json)
|
|
65
65
|
.optional()
|
|
66
|
-
.describe("
|
|
66
|
+
.describe("The step's settings (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries."),
|
|
67
67
|
configEntries: z
|
|
68
68
|
.array(configName)
|
|
69
69
|
.optional()
|
package/src/dev/cli.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { join, resolve } from "node:path";
|
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
import { runHarness, type HarnessConfig } from "../harness/main.ts";
|
|
11
11
|
import { EXIT } from "../harness/exit.ts";
|
|
12
|
-
import { handlerHashes } from "../build/hashes.ts";
|
|
13
|
-
import { harnessInfo, loadProject, ProjectError } from "../build/project.ts";
|
|
12
|
+
import { handlerHashes, kindHashes } from "../build/hashes.ts";
|
|
13
|
+
import { harnessInfo, loadPipeline, loadProject, ProjectError } from "../build/project.ts";
|
|
14
14
|
|
|
15
15
|
const USAGE = `usage: olve-dev describe <dir> [--sentinels]
|
|
16
16
|
olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
|
|
@@ -18,14 +18,14 @@ const USAGE = `usage: olve-dev describe <dir> [--sentinels]
|
|
|
18
18
|
|
|
19
19
|
export async function devConfig(dir: string, withHashes: boolean): Promise<HarnessConfig> {
|
|
20
20
|
const project = await loadProject(dir);
|
|
21
|
-
const
|
|
22
|
-
if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
|
|
21
|
+
const pipeline = await loadPipeline(project);
|
|
23
22
|
const harness = harnessInfo();
|
|
24
23
|
const hashes = withHashes ? await handlerHashes(project, harness) : {};
|
|
25
24
|
return {
|
|
26
|
-
pipeline
|
|
25
|
+
pipeline,
|
|
27
26
|
handlers: Object.fromEntries(project.handlers.map(([name, file]) => [name, () => import(file) as Promise<{ default: unknown }>])),
|
|
28
27
|
handlerHashes: Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])),
|
|
28
|
+
...(withHashes ? { kindHashes: await kindHashes(project, pipeline) } : {}),
|
|
29
29
|
harness,
|
|
30
30
|
};
|
|
31
31
|
}
|
package/src/harness/main.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface HarnessConfig {
|
|
|
26
26
|
pipeline: unknown;
|
|
27
27
|
handlers: HandlerRegistry;
|
|
28
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>>;
|
|
29
31
|
harness: HarnessInfo;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -100,12 +102,9 @@ export function cancellation(): { signal: AbortSignal; dispose(): void } {
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
/** Resolves the function that implements `step` (
|
|
105
|
+
/** Resolves the function that implements `step` (carried by the step, or a handler module). */
|
|
104
106
|
export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
|
|
105
|
-
if (step.
|
|
106
|
-
if (!step.builtin) return `library step '${step.name}' (${step.kind}) has no implementation`;
|
|
107
|
-
return step.builtin.run as StepFunction;
|
|
108
|
-
}
|
|
107
|
+
if (isImplementation(step)) return step.handler as unknown as StepFunction;
|
|
109
108
|
const load = handlers[step.name];
|
|
110
109
|
if (!load) return `step '${step.name}' has no handler`;
|
|
111
110
|
let mod: { default: unknown };
|
|
@@ -151,7 +150,7 @@ function readJson(path: string): { ok: true; value: unknown } | { ok: false; mes
|
|
|
151
150
|
async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
|
|
152
151
|
const sentinels = args.includes("--sentinels");
|
|
153
152
|
try {
|
|
154
|
-
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 }));
|
|
155
154
|
await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
|
|
156
155
|
return EXIT.ok;
|
|
157
156
|
} catch (e) {
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
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
|
+
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
7
|
export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
|
|
8
|
-
export {
|
|
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";
|
|
9
11
|
export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
|
|
10
12
|
export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
|
|
11
13
|
export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
|
|
12
14
|
export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
|
|
13
|
-
export { steps, Source, BuiltImage, PushedImage, 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";
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// and is fixed there, behind a versioned HTTP API, without editing pipelines.
|
|
4
4
|
import type { Ctx } from "../ctx/types.ts";
|
|
5
5
|
import type { Configurable } from "../core/config.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveConfigRefs } from "../core/config-refs.ts";
|
|
7
|
+
import { implement, type Implementation } from "../core/handlers.ts";
|
|
7
8
|
import type { Secret } from "../core/secrets.ts";
|
|
8
|
-
import {
|
|
9
|
+
import { step, type LiteralName, type Step } from "../core/steps.ts";
|
|
9
10
|
import { httpHealthCheck } from "./http-health.ts";
|
|
10
11
|
import { HelmRelease, PushedImage } from "./models.ts";
|
|
11
12
|
|
|
@@ -53,41 +54,43 @@ const input = { image: PushedImage };
|
|
|
53
54
|
*/
|
|
54
55
|
export function composeDeploy<const N extends string, const K extends string>(
|
|
55
56
|
def: ComposeDeployDef<N, K>,
|
|
56
|
-
): Step<N, typeof input, typeof HelmRelease, K
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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;
|
|
93
96
|
}
|
|
@@ -8,8 +8,9 @@ import { z } from "zod";
|
|
|
8
8
|
import type { Ctx } from "../ctx/types.ts";
|
|
9
9
|
import type { Secret } from "../core/secrets.ts";
|
|
10
10
|
import type { Configurable } from "../core/config.ts";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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";
|
|
13
14
|
import { extractTar } from "../util/tar.ts";
|
|
14
15
|
import { Source } from "./models.ts";
|
|
15
16
|
|
|
@@ -52,45 +53,48 @@ export const gitHubTarballUrl = (repo: string, sha: string, apiUrl = GITHUB_API_
|
|
|
52
53
|
*/
|
|
53
54
|
export function gitHubSource<const N extends string, const T extends string = never>(
|
|
54
55
|
def: GitHubSourceDef<N, T>,
|
|
55
|
-
): Step<N, GitHubSourceInput, typeof Source, T
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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");
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
},
|
|
94
|
-
{ apiUrl: def.apiUrl },
|
|
95
|
-
) 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;
|
|
96
100
|
}
|
|
@@ -9,16 +9,15 @@ import type { Ctx } from "../ctx/types.ts";
|
|
|
9
9
|
import type { FileRef } from "../core/models.ts";
|
|
10
10
|
import type { Secret } from "../core/secrets.ts";
|
|
11
11
|
import type { Configurable } from "../core/config.ts";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { resolveConfigRefs } from "../core/config-refs.ts";
|
|
13
|
+
import { implement, type Implementation } from "../core/handlers.ts";
|
|
14
|
+
import { step, type LiteralName, type Step } from "../core/steps.ts";
|
|
14
15
|
import type { Simplify } from "../core/types.ts";
|
|
15
16
|
import { httpHealthCheck, type HttpHealthCheckOptions } from "./http-health.ts";
|
|
16
17
|
import { BuiltImage, HelmRelease, PushedImage, Source } from "./models.ts";
|
|
17
18
|
import { shq, sshSession } from "./ssh.ts";
|
|
18
19
|
import { helmDeployOverSshTarget, type HelmDeploySshTargetDef, type HelmTargetDeployInput } from "./helm-deploy.ts";
|
|
19
20
|
|
|
20
|
-
export { shq } from "./ssh.ts";
|
|
21
|
-
|
|
22
21
|
/** Alpine: `apk add openssh-client` at run time gives ssh/scp (the health check runs in the harness). */
|
|
23
22
|
export const HELM_DEPLOY_IMAGE = "alpine:3.22";
|
|
24
23
|
|
|
@@ -71,7 +70,7 @@ export interface HelmDeployOverSshDef<
|
|
|
71
70
|
/**
|
|
72
71
|
* The images to deploy: step input key → the chart's values prefix for that image. Each input is a
|
|
73
72
|
* `BuiltImage` (or `PushedImage` with `fromRegistry`). Default `{ image: "image" }`; several images
|
|
74
|
-
* need a mapper, e.g. `{ server: "image", web: "web.image" }`.
|
|
73
|
+
* need a mapper, e.g. `{ server: "image", web: "web.image" }`. `{}` deploys the chart alone.
|
|
75
74
|
*/
|
|
76
75
|
images?: Im & { readonly source?: never };
|
|
77
76
|
/**
|
|
@@ -87,7 +86,7 @@ export interface HelmDeployOverSshDef<
|
|
|
87
86
|
* next to tarball `images`.
|
|
88
87
|
*/
|
|
89
88
|
imageRefs?: Rf & { readonly source?: never };
|
|
90
|
-
/** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name. */
|
|
89
|
+
/** Rollouts to wait for, in order: `deploy/<name>` for a bare name. Default: the release name, or none without images. */
|
|
91
90
|
deployments?: readonly string[];
|
|
92
91
|
/** Per rollout (`kubectl rollout status --timeout`). Default 120. */
|
|
93
92
|
rolloutTimeoutSeconds?: number;
|
|
@@ -221,26 +220,25 @@ export function helmDeployOverSsh<
|
|
|
221
220
|
const K extends string,
|
|
222
221
|
const Im extends Record<string, string> = DefaultImages,
|
|
223
222
|
const Rf extends Record<string, string> = {},
|
|
224
|
-
>(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K
|
|
223
|
+
>(def: HelmDeploySshTargetDef<N, K, Im, Rf>): Implementation<Step<N, HelmTargetDeployInput<Im, Rf>, typeof HelmRelease, K>>;
|
|
225
224
|
export function helmDeployOverSsh<
|
|
226
225
|
const N extends string,
|
|
227
226
|
const K extends string,
|
|
228
227
|
const Im extends Record<string, string> = DefaultImages,
|
|
229
228
|
const R extends boolean = false,
|
|
230
229
|
const Rf extends Record<string, string> = {},
|
|
231
|
-
>(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K
|
|
230
|
+
>(def: HelmDeployOverSshDef<N, K, Im, R, Rf>): Implementation<Step<N, HelmDeployOverSshInput<Im, R, Rf>, typeof HelmRelease, K>>;
|
|
232
231
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
233
|
-
export function helmDeployOverSsh(def: any):
|
|
232
|
+
export function helmDeployOverSsh(def: any): any {
|
|
234
233
|
return "target" in def ? helmDeployOverSshTarget(def) : helmDeployOverSshLiteral(def);
|
|
235
234
|
}
|
|
236
235
|
|
|
237
236
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
238
|
-
function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>):
|
|
237
|
+
function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any, boolean, any>): Implementation {
|
|
239
238
|
const images: Record<string, string> = def.images ?? { image: "image" };
|
|
240
239
|
const imageEntries = Object.entries(images);
|
|
241
240
|
const fromRegistry = def.fromRegistry === true;
|
|
242
241
|
const rolloutTimeout = def.rolloutTimeoutSeconds ?? DEFAULT_ROLLOUT_TIMEOUT_SECONDS;
|
|
243
|
-
if (imageEntries.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': images must not be empty`);
|
|
244
242
|
if ("source" in images) throw new Error(`helmDeployOverSsh '${def.name}': 'source' can't be an image input`);
|
|
245
243
|
if (def.deployments?.length === 0) throw new Error(`helmDeployOverSsh '${def.name}': deployments must not be empty`);
|
|
246
244
|
const imageRefs: Record<string, string> = def.imageRefs ?? {};
|
|
@@ -256,49 +254,53 @@ function helmDeployOverSshLiteral(def: HelmDeployOverSshDef<string, string, any,
|
|
|
256
254
|
const isDefaultImages = imageEntries.length === 1 && images.image === "image";
|
|
257
255
|
const configurable = { host: def.host, release: def.release, namespace: def.namespace, values: def.values ?? [], set: def.set ?? {}, healthUrl: def.healthUrl };
|
|
258
256
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}),
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
};
|
|
283
|
-
const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
|
|
284
|
-
const hostImages: HostImage[] = [];
|
|
285
|
-
const flags: HelmImageFlags[] = [];
|
|
286
|
-
for (const [key, prefix] of imageEntries) {
|
|
287
|
-
const image = input[key]!;
|
|
288
|
-
hostImages.push({ repository: image.repository, tag: image.tag, ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }) });
|
|
289
|
-
flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
|
|
290
|
-
}
|
|
291
|
-
const refs: HelmImageRefFlags[] = refEntries.map(([key, prefix]) => ({ prefix, repository: input[key]!.repository, tag: input[key]!.tag, digest: input[key]!.digest }));
|
|
292
|
-
await deployOverSsh(ctx, {
|
|
293
|
-
host: d.host, key: ctx.secrets[def.sshKey.name]!, chartDir, release: d.release, namespace: d.namespace, hostImages,
|
|
294
|
-
flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
|
|
295
|
-
deployments: def.deployments ?? [d.release], rolloutTimeoutSeconds: rolloutTimeout,
|
|
296
|
-
});
|
|
297
|
-
const health = typeof d.healthUrl === "string" ? { url: d.healthUrl } : d.healthUrl;
|
|
298
|
-
if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
|
|
299
|
-
return { release: d.release, namespace: d.namespace, image: `${flags[0]!.repository}:${flags[0]!.tag}` };
|
|
300
|
-
},
|
|
257
|
+
const contract = step({
|
|
258
|
+
name: def.name,
|
|
259
|
+
description: def.description,
|
|
260
|
+
kind: "helmDeployOverSsh",
|
|
261
|
+
image: HELM_DEPLOY_IMAGE,
|
|
262
|
+
input,
|
|
263
|
+
output: HelmRelease,
|
|
264
|
+
secrets: [def.sshKey],
|
|
265
|
+
timeoutSeconds: def.timeoutSeconds,
|
|
266
|
+
settings: {
|
|
267
|
+
host: def.host,
|
|
268
|
+
sshKey: def.sshKey.name,
|
|
269
|
+
release: def.release,
|
|
270
|
+
namespace: def.namespace,
|
|
271
|
+
chart: def.chart,
|
|
272
|
+
values: configurable.values,
|
|
273
|
+
set: configurable.set,
|
|
274
|
+
...(isDefaultImages ? {} : { images: { ...images } }),
|
|
275
|
+
...(fromRegistry ? { fromRegistry: true } : {}),
|
|
276
|
+
...(refEntries.length > 0 ? { imageRefs: { ...imageRefs } } : {}),
|
|
277
|
+
...(def.deployments ? { deployments: [...def.deployments] } : {}),
|
|
278
|
+
...(def.rolloutTimeoutSeconds !== undefined ? { rolloutTimeoutSeconds: def.rolloutTimeoutSeconds } : {}),
|
|
279
|
+
...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
|
|
301
280
|
},
|
|
302
|
-
|
|
303
|
-
)
|
|
281
|
+
});
|
|
282
|
+
return implement(contract, async (stepInput, ctx: Ctx) => {
|
|
283
|
+
const input = stepInput as unknown as Record<string, { repository: string; tag: string; digest: string; tar?: FileRef }> & { source: { tree: FileRef } };
|
|
284
|
+
const d = resolveConfigRefs(configurable, ctx.config ?? {}) as {
|
|
285
|
+
host: string; release: string; namespace: string; values: string[]; set: Record<string, string>;
|
|
286
|
+
healthUrl?: string | ({ url: string } & Pick<HttpHealthCheckOptions, "attempts" | "delaySeconds">);
|
|
287
|
+
};
|
|
288
|
+
const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
|
|
289
|
+
const hostImages: HostImage[] = [];
|
|
290
|
+
const flags: HelmImageFlags[] = [];
|
|
291
|
+
for (const [key, prefix] of imageEntries) {
|
|
292
|
+
const image = input[key]!;
|
|
293
|
+
hostImages.push({ repository: image.repository, tag: image.tag, ...(fromRegistry ? {} : { tar: await ctx.files.fetch(image.tar!) }) });
|
|
294
|
+
flags.push({ prefix, repository: image.repository, tag: image.tag, pullPolicy: fromRegistry ? "IfNotPresent" : "Never" });
|
|
295
|
+
}
|
|
296
|
+
const refs: HelmImageRefFlags[] = refEntries.map(([key, prefix]) => ({ prefix, repository: input[key]!.repository, tag: input[key]!.tag, digest: input[key]!.digest }));
|
|
297
|
+
await deployOverSsh(ctx, {
|
|
298
|
+
host: d.host, key: ctx.secrets[def.sshKey.name]!, chartDir, release: d.release, namespace: d.namespace, hostImages,
|
|
299
|
+
flags: { values: d.values, set: d.set, images: flags, imageRefs: refs },
|
|
300
|
+
deployments: def.deployments ?? (imageEntries.length ? [d.release] : []), rolloutTimeoutSeconds: rolloutTimeout,
|
|
301
|
+
});
|
|
302
|
+
const health = typeof d.healthUrl === "string" ? { url: d.healthUrl } : d.healthUrl;
|
|
303
|
+
if (health) await httpHealthCheck(ctx, health.url, { attempts: health.attempts, delaySeconds: health.delaySeconds });
|
|
304
|
+
return { release: d.release, namespace: d.namespace, ...(flags[0] ? { image: `${flags[0].repository}:${flags[0].tag}` } : {}) };
|
|
305
|
+
}) as never;
|
|
304
306
|
}
|