@olivervea/pipelines 2.0.0-beta.25 → 2.0.0-beta.27
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 +3 -0
- package/package.json +1 -1
- package/src/build/build.ts +4 -9
- package/src/build/elf.ts +8 -0
- package/src/ctx/files.ts +8 -2
- package/src/ctx/index.ts +19 -11
- package/src/ctx/links.ts +30 -0
- package/src/ctx/types.ts +5 -0
- package/src/describe/describe.ts +3 -1
- package/src/describe/mapper-source.ts +4 -2
- package/src/harness/execute.ts +22 -7
- package/src/harness/index.ts +0 -1
- package/src/harness/main.ts +0 -6
- package/src/harness/result.ts +7 -2
- package/src/index.ts +1 -0
- package/src/library/compose-deploy.ts +2 -0
- package/src/library/helm-deploy-ssh.ts +6 -0
- package/src/library/helm-deploy.ts +14 -0
- package/src/library/models.ts +2 -2
- package/src/testing/index.ts +3 -0
- package/src/util/tar.ts +1 -6
package/README.md
CHANGED
|
@@ -201,6 +201,7 @@ import deployScript from "./scripts/deploy.sh" with { type: "file" };
|
|
|
201
201
|
export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), async ({ artifact }, ctx) => {
|
|
202
202
|
await ctx.sh(deployScript, { env: { IMAGE_TAR: await ctx.files.fetch(artifact.image), CHART_DIR: await ctx.files.fetch(artifact.chart) } });
|
|
203
203
|
await ctx.retry({ attempts: 5, delaySeconds: 5 }, () => ctx.http.expectOk("https://pipelines-beta.ovea.pro/api/health"));
|
|
204
|
+
ctx.link("https://pipelines-beta.ovea.pro", "beta"); // shown on the step card and in pl run get
|
|
204
205
|
return { release: "olve-pipelines", version: artifact.version, url: "https://pipelines-beta.ovea.pro" }; // or ctx.skip("reason")
|
|
205
206
|
});
|
|
206
207
|
```
|
|
@@ -324,6 +325,8 @@ It retries network errors, 5xx, 408 and 429. It always verifies `sha256`.
|
|
|
324
325
|
{ "status": "failed", "error": { "kind": "handler" | "input" | "output", "message": "…" } }
|
|
325
326
|
```
|
|
326
327
|
|
|
328
|
+
Any of the three may carry `"links": [{ "url": "https://…", "label": "…" }]`, what the handler passed to `ctx.link` (absolute http(s) URLs of at most 2048 characters, labels of at most 100, at most 10). The key is left out when there are none. The server refuses an envelope whose links break these limits.
|
|
329
|
+
|
|
327
330
|
For `map-trigger`, `output` is the pipeline input after it has been parsed by the input model, so defaults are applied. Validate it again server-side.
|
|
328
331
|
|
|
329
332
|
### Exit codes
|
package/package.json
CHANGED
package/src/build/build.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { sha256File } from '../util/hash.ts';
|
|
|
6
6
|
import type { HarnessInfo } from '../util/code-hash.ts';
|
|
7
7
|
import { writeTarZst } from '../util/tar.ts';
|
|
8
8
|
import { MANIFEST_BEGIN, MANIFEST_END, uploadFile } from '../harness/result.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { loaderProblem } from './elf.ts';
|
|
10
10
|
import { codeHash } from './hashes.ts';
|
|
11
11
|
import { harnessInfo, loadProject, relPosix, type Project } from './project.ts';
|
|
12
12
|
|
|
@@ -112,14 +112,9 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
|
|
|
112
112
|
`olve-build: compiled ${target} in ${((performance.now() - t1) / 1000).toFixed(2)}s`,
|
|
113
113
|
);
|
|
114
114
|
|
|
115
|
-
// 2. loader check:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const interp = readInterpreter(binary);
|
|
119
|
-
if (interp !== expected)
|
|
120
|
-
throw new BuildError(
|
|
121
|
-
`compiled binary has PT_INTERP ${interp ?? '(none)'}, expected ${expected} — compile on a glibc Bun image (oven/bun:<version>-slim), not alpine`,
|
|
122
|
-
);
|
|
115
|
+
// 2. loader check: on alpine, bun-linux-x64 silently yields a musl binary, which would not start.
|
|
116
|
+
const problem = loaderProblem(binary, target);
|
|
117
|
+
if (problem) throw new BuildError(problem);
|
|
123
118
|
|
|
124
119
|
// 3. describe (runs the real binary: the manifest is exactly what the step pods will see)
|
|
125
120
|
const d = Bun.spawnSync([binary, 'describe'], {
|
package/src/build/elf.ts
CHANGED
|
@@ -34,3 +34,11 @@ export function readInterpreter(path: string): string | null {
|
|
|
34
34
|
closeSync(fd);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/** Why `binary` won't start through `target`'s loader (the harness runs it via /olve/lib/<loader>), or undefined. */
|
|
39
|
+
export function loaderProblem(binary: string, target: string): string | undefined {
|
|
40
|
+
const expected = target === 'bun-linux-x64' ? GLIBC_INTERP : MUSL_INTERP;
|
|
41
|
+
const interp = readInterpreter(binary);
|
|
42
|
+
if (interp === expected) return undefined;
|
|
43
|
+
return `compiled binary has PT_INTERP ${interp ?? '(none)'}, expected ${expected} — compile on a glibc Bun image (oven/bun:<version>-slim), not alpine`;
|
|
44
|
+
}
|
package/src/ctx/files.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface FilesDeps {
|
|
|
37
37
|
root: string;
|
|
38
38
|
signal: AbortSignal;
|
|
39
39
|
log: (message: string, fields?: Record<string, unknown>) => void;
|
|
40
|
+
/** Backoff between download retries (default: real sleep). */
|
|
41
|
+
sleep?: (ms: number) => Promise<void>;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
function checkName(name: string, what: string): void {
|
|
@@ -122,6 +124,7 @@ async function download(
|
|
|
122
124
|
headers: Record<string, string>,
|
|
123
125
|
signal: AbortSignal,
|
|
124
126
|
log: FilesDeps['log'],
|
|
127
|
+
sleep: (ms: number) => Promise<void>,
|
|
125
128
|
): Promise<void> {
|
|
126
129
|
const attempts = 4;
|
|
127
130
|
for (let i = 1; ; i++) {
|
|
@@ -143,13 +146,14 @@ async function download(
|
|
|
143
146
|
attempt: i,
|
|
144
147
|
error: (e as Error).message,
|
|
145
148
|
});
|
|
146
|
-
await
|
|
149
|
+
await sleep(1000 * 2 ** (i - 1));
|
|
147
150
|
}
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
export function createFetch(deps: FilesDeps) {
|
|
152
155
|
const cache = new Map<string, string>();
|
|
156
|
+
const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
153
157
|
return async (input: FileRef): Promise<string> => {
|
|
154
158
|
const parsed = fileRefSchema.safeParse(input);
|
|
155
159
|
if (!parsed.success)
|
|
@@ -181,7 +185,8 @@ export function createFetch(deps: FilesDeps) {
|
|
|
181
185
|
const baseUrl = process.env[ENV.filesBaseUrl];
|
|
182
186
|
if (own && existsSync(join(deps.workFiles, obj)))
|
|
183
187
|
copyFileSync(join(deps.workFiles, obj), objFile);
|
|
184
|
-
else if (urls[key])
|
|
188
|
+
else if (urls[key])
|
|
189
|
+
await download(urls[key], objFile, {}, deps.signal, deps.log, sleep);
|
|
185
190
|
else if (
|
|
186
191
|
localDir &&
|
|
187
192
|
existsSync(
|
|
@@ -201,6 +206,7 @@ export function createFetch(deps: FilesDeps) {
|
|
|
201
206
|
token ? { authorization: `Bearer ${token}` } : {},
|
|
202
207
|
deps.signal,
|
|
203
208
|
deps.log,
|
|
209
|
+
sleep,
|
|
204
210
|
);
|
|
205
211
|
} else {
|
|
206
212
|
throw new Error(
|
package/src/ctx/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { skipped } from '../core/handlers.ts';
|
|
4
|
+
import { createLinks } from './links.ts';
|
|
4
5
|
import type { Secret } from '../core/secrets.ts';
|
|
5
6
|
import { createFetch, createOutput, redactUrl } from './files.ts';
|
|
6
7
|
import { createSh } from './sh.ts';
|
|
@@ -89,9 +90,25 @@ export function createHttp(signal: AbortSignal): Http {
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/** Waits `ms`, or rejects at once when the step is cancelled. */
|
|
94
|
+
function cancellableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
95
|
+
return new Promise<void>((res, rej) => {
|
|
96
|
+
const t = setTimeout(() => {
|
|
97
|
+
signal.removeEventListener('abort', onAbort);
|
|
98
|
+
res();
|
|
99
|
+
}, ms);
|
|
100
|
+
const onAbort = () => {
|
|
101
|
+
clearTimeout(t);
|
|
102
|
+
rej(new Error('retry cancelled: the step was cancelled'));
|
|
103
|
+
};
|
|
104
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
export function createRetry(
|
|
93
109
|
signal: AbortSignal,
|
|
94
110
|
log: (m: string, f?: Record<string, unknown>) => void,
|
|
111
|
+
sleep: (ms: number, signal: AbortSignal) => Promise<void> = cancellableSleep,
|
|
95
112
|
) {
|
|
96
113
|
return async <T>(
|
|
97
114
|
opts: RetryOptions,
|
|
@@ -110,17 +127,7 @@ export function createRetry(
|
|
|
110
127
|
of: opts.attempts,
|
|
111
128
|
error: (e as Error)?.message ?? String(e),
|
|
112
129
|
});
|
|
113
|
-
await
|
|
114
|
-
const t = setTimeout(() => {
|
|
115
|
-
signal.removeEventListener('abort', onAbort);
|
|
116
|
-
res();
|
|
117
|
-
}, delay);
|
|
118
|
-
const onAbort = () => {
|
|
119
|
-
clearTimeout(t);
|
|
120
|
-
rej(new Error('retry cancelled: the step was cancelled'));
|
|
121
|
-
};
|
|
122
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
123
|
-
});
|
|
130
|
+
await sleep(delay, signal);
|
|
124
131
|
delay *= opts.backoff ?? 1;
|
|
125
132
|
}
|
|
126
133
|
}
|
|
@@ -159,6 +166,7 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
159
166
|
run: Object.freeze({ ...o.run }),
|
|
160
167
|
tmpDir: o.paths.tmp,
|
|
161
168
|
skip: skipped,
|
|
169
|
+
link: createLinks().link,
|
|
162
170
|
inputUrl: (step, input) => {
|
|
163
171
|
const name = typeof step === 'string' ? step : step.name;
|
|
164
172
|
const url = o.callbackUrls?.[`${name}/${input}`];
|
package/src/ctx/links.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface StepLink {
|
|
2
|
+
url: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const MAX_LINKS = 10;
|
|
7
|
+
export const MAX_LINK_URL = 2048;
|
|
8
|
+
export const MAX_LINK_LABEL = 100;
|
|
9
|
+
|
|
10
|
+
/** `ctx.link`, collecting into `links`. */
|
|
11
|
+
export function createLinks(): {
|
|
12
|
+
links: StepLink[];
|
|
13
|
+
link: (url: string, label?: string) => void;
|
|
14
|
+
} {
|
|
15
|
+
const links: StepLink[] = [];
|
|
16
|
+
const link = (url: string, label?: string) => {
|
|
17
|
+
if (typeof url !== 'string' || !/^https?:\/\/[^/\s]/i.test(url) || !URL.canParse(url))
|
|
18
|
+
throw new Error(`ctx.link: '${String(url)}' is not an absolute http or https URL`);
|
|
19
|
+
if (url.length > MAX_LINK_URL)
|
|
20
|
+
throw new Error(`ctx.link: the URL is longer than ${MAX_LINK_URL} characters`);
|
|
21
|
+
if (label !== undefined && typeof label !== 'string')
|
|
22
|
+
throw new Error('ctx.link: the label must be a string');
|
|
23
|
+
if (label !== undefined && label.length > MAX_LINK_LABEL)
|
|
24
|
+
throw new Error(`ctx.link: the label is longer than ${MAX_LINK_LABEL} characters`);
|
|
25
|
+
if (links.length >= MAX_LINKS)
|
|
26
|
+
throw new Error(`ctx.link: a step can add at most ${MAX_LINKS} links`);
|
|
27
|
+
links.push(label ? { url, label } : { url });
|
|
28
|
+
};
|
|
29
|
+
return { links, link };
|
|
30
|
+
}
|
package/src/ctx/types.ts
CHANGED
|
@@ -111,6 +111,11 @@ export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = n
|
|
|
111
111
|
readonly run: RunInfo;
|
|
112
112
|
/** A writable scratch directory (`$OLVE_DIR/tmp`; images like kaniko have no /tmp). */
|
|
113
113
|
readonly tmpDir: string;
|
|
114
|
+
/**
|
|
115
|
+
* Link to what the step made or deployed (a site, a release, an image), shown with the attempt in the
|
|
116
|
+
* UI and `pl run get`. Absolute http(s) URL; at most 10 per attempt.
|
|
117
|
+
*/
|
|
118
|
+
link(url: string, label?: string): void;
|
|
114
119
|
/** Runtime escape hatch: produce no output. Prefer `when` blocks. */
|
|
115
120
|
skip(reason: string): Skipped;
|
|
116
121
|
/**
|
package/src/describe/describe.ts
CHANGED
|
@@ -806,7 +806,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
806
806
|
export const manifestText = (m: ManifestT): string => canonicalJson(m);
|
|
807
807
|
|
|
808
808
|
/**
|
|
809
|
-
* The schemas at `path` inside an entry's schema: every union branch that has the field, except where
|
|
809
|
+
* The schemas at `path` inside an entry's schema (through properties and records): every union branch that has the field, except where
|
|
810
810
|
* `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
|
|
811
811
|
*/
|
|
812
812
|
export function subSchemas(
|
|
@@ -848,6 +848,8 @@ export function subSchemas(
|
|
|
848
848
|
const props = o.properties as Record<string, JsonSchema> | undefined;
|
|
849
849
|
if (props && Object.hasOwn(props, rest[0]!))
|
|
850
850
|
return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
|
|
851
|
+
if (o.additionalProperties && typeof o.additionalProperties === 'object')
|
|
852
|
+
return walk(o.additionalProperties, rest.slice(1), `${here}.${rest[0]}`);
|
|
851
853
|
return [];
|
|
852
854
|
};
|
|
853
855
|
return walk(schema, path, entry);
|
|
@@ -131,8 +131,8 @@ function tokenize(src: string): Tok[] {
|
|
|
131
131
|
i = e + 2;
|
|
132
132
|
continue;
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
if (c === '"' || c === "'") {
|
|
134
|
+
// Bun prints a string constant holding a newline or both quote kinds as a template literal.
|
|
135
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
136
136
|
let j = i + 1;
|
|
137
137
|
let v = '';
|
|
138
138
|
for (;;) {
|
|
@@ -140,6 +140,8 @@ function tokenize(src: string): Tok[] {
|
|
|
140
140
|
throw new MapperError('mapper source has an unterminated string');
|
|
141
141
|
const d = src[j]!;
|
|
142
142
|
if (d === c) break;
|
|
143
|
+
if (c === '`' && d === '$' && src[j + 1] === '{')
|
|
144
|
+
throw new MapperError('mapper uses a template string');
|
|
143
145
|
if (d === '\\') {
|
|
144
146
|
v += src.slice(j, j + 2);
|
|
145
147
|
j += 2;
|
package/src/harness/execute.ts
CHANGED
|
@@ -6,15 +6,17 @@ import { isSkipped } from '../core/handlers.ts';
|
|
|
6
6
|
import type { AnyStep } from '../core/steps.ts';
|
|
7
7
|
import type { Ctx, RunInfo } from '../ctx/types.ts';
|
|
8
8
|
import { objectPath } from '../ctx/files.ts';
|
|
9
|
+
import { createLinks, type StepLink } from '../ctx/links.ts';
|
|
9
10
|
import { sha256File } from '../util/hash.ts';
|
|
10
11
|
import { EXIT } from './exit.ts';
|
|
11
12
|
import { validateConfig, validateInput, validateOutput } from './validate.ts';
|
|
12
13
|
|
|
13
14
|
export type FailureKind = 'handler' | 'input' | 'output';
|
|
14
|
-
export type Envelope =
|
|
15
|
+
export type Envelope = (
|
|
15
16
|
| { status: 'succeeded'; output: unknown }
|
|
16
17
|
| { status: 'skipped'; reason: string }
|
|
17
|
-
| { status: 'failed'; error: { kind: FailureKind; message: string } }
|
|
18
|
+
| { status: 'failed'; error: { kind: FailureKind; message: string } }
|
|
19
|
+
) & { links?: StepLink[] };
|
|
18
20
|
|
|
19
21
|
export interface Execution {
|
|
20
22
|
envelope: Envelope;
|
|
@@ -85,7 +87,14 @@ export async function executeStep(args: {
|
|
|
85
87
|
exitCode: EXIT.inputInvalid,
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
|
-
const
|
|
90
|
+
const collected = createLinks();
|
|
91
|
+
const ctx: Ctx = {
|
|
92
|
+
...args.ctx,
|
|
93
|
+
config: Object.freeze(config.value) as Ctx['config'],
|
|
94
|
+
link: collected.link,
|
|
95
|
+
};
|
|
96
|
+
const withLinks = (e: Envelope): Envelope =>
|
|
97
|
+
collected.links.length ? { ...e, links: [...collected.links] } : e;
|
|
89
98
|
|
|
90
99
|
let value: unknown;
|
|
91
100
|
try {
|
|
@@ -98,14 +107,17 @@ export async function executeStep(args: {
|
|
|
98
107
|
log(message);
|
|
99
108
|
if (e instanceof Error && e.stack && !cancelled) log(e.stack);
|
|
100
109
|
return {
|
|
101
|
-
envelope: { status: 'failed', error: { kind: 'handler', message } },
|
|
110
|
+
envelope: withLinks({ status: 'failed', error: { kind: 'handler', message } }),
|
|
102
111
|
exitCode: cancelled ?? EXIT.handlerFailed,
|
|
103
112
|
};
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
if (isSkipped(value)) {
|
|
107
116
|
log(`step '${step.name}' skipped: ${value.reason}`);
|
|
108
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
envelope: withLinks({ status: 'skipped', reason: value.reason }),
|
|
119
|
+
exitCode: EXIT.ok,
|
|
120
|
+
};
|
|
109
121
|
}
|
|
110
122
|
const out = validateOutput(step, value);
|
|
111
123
|
const problems = out.ok
|
|
@@ -115,9 +127,12 @@ export async function executeStep(args: {
|
|
|
115
127
|
const message = `step '${step.name}': returned value does not match ${step.output?.name ?? 'its (absent) output model'}:\n ${problems.join('\n ')}`;
|
|
116
128
|
log(message);
|
|
117
129
|
return {
|
|
118
|
-
envelope: { status: 'failed', error: { kind: 'output', message } },
|
|
130
|
+
envelope: withLinks({ status: 'failed', error: { kind: 'output', message } }),
|
|
119
131
|
exitCode: EXIT.outputInvalid,
|
|
120
132
|
};
|
|
121
133
|
}
|
|
122
|
-
return {
|
|
134
|
+
return {
|
|
135
|
+
envelope: withLinks({ status: 'succeeded', output: out.value }),
|
|
136
|
+
exitCode: EXIT.ok,
|
|
137
|
+
};
|
|
123
138
|
}
|
package/src/harness/index.ts
CHANGED
package/src/harness/main.ts
CHANGED
|
@@ -56,12 +56,6 @@ export const defaultIO: HarnessIO = {
|
|
|
56
56
|
const USAGE =
|
|
57
57
|
'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
|
|
58
58
|
|
|
59
|
-
export function allSteps(data: PipelineData): AnyStep[] {
|
|
60
|
-
const out: AnyStep[] = [];
|
|
61
|
-
for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
|
|
62
|
-
return out;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
59
|
/** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
|
|
66
60
|
export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
|
|
67
61
|
const out: { step: AnyStep; cases: string[] }[] = [];
|
package/src/harness/result.ts
CHANGED
|
@@ -53,10 +53,15 @@ export async function uploadFile(
|
|
|
53
53
|
url: string,
|
|
54
54
|
file: string,
|
|
55
55
|
log: (line: string) => void,
|
|
56
|
-
opts: {
|
|
56
|
+
opts: {
|
|
57
|
+
attempts?: number;
|
|
58
|
+
baseDelayMs?: number;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
} = {},
|
|
57
61
|
): Promise<void> {
|
|
58
62
|
const attempts = opts.attempts ?? 5;
|
|
59
63
|
const base = opts.baseDelayMs ?? 1000;
|
|
64
|
+
const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
60
65
|
for (let i = 1; ; i++) {
|
|
61
66
|
let status = 0;
|
|
62
67
|
let detail: string;
|
|
@@ -73,7 +78,7 @@ export async function uploadFile(
|
|
|
73
78
|
if (!transient || i >= attempts)
|
|
74
79
|
throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
|
|
75
80
|
log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
|
|
76
|
-
await
|
|
81
|
+
await sleep(base * 2 ** (i - 1));
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface ComposeTargetValue {
|
|
|
20
20
|
service: string;
|
|
21
21
|
token: { secret: string };
|
|
22
22
|
healthUrl?: string | undefined;
|
|
23
|
+
link?: string | undefined;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/** What the deployer answers to `POST /v1/deploy`. */
|
|
@@ -103,6 +104,7 @@ export function composeDeploy<const N extends string, const K extends string>(
|
|
|
103
104
|
);
|
|
104
105
|
if (target.healthUrl)
|
|
105
106
|
await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
|
|
107
|
+
if (target.link) ctx.link(target.link);
|
|
106
108
|
ctx.log('deployed', {
|
|
107
109
|
service: target.service,
|
|
108
110
|
image: ref,
|
|
@@ -113,6 +113,8 @@ export interface HelmDeployOverSshDef<
|
|
|
113
113
|
helmWait?: boolean;
|
|
114
114
|
/** `helm upgrade --atomic`: wait like `helmWait`, and roll back when the upgrade fails. Default false. */
|
|
115
115
|
atomic?: boolean;
|
|
116
|
+
/** Public URL of what the release serves; the step links to it after a successful deploy. */
|
|
117
|
+
link?: Configurable<string>;
|
|
116
118
|
/** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
|
|
117
119
|
healthUrl?:
|
|
118
120
|
| Configurable<string>
|
|
@@ -407,6 +409,7 @@ function helmDeployOverSshLiteral(
|
|
|
407
409
|
values: def.values ?? [],
|
|
408
410
|
set: def.set ?? {},
|
|
409
411
|
healthUrl: def.healthUrl,
|
|
412
|
+
link: def.link,
|
|
410
413
|
};
|
|
411
414
|
|
|
412
415
|
const contract = step({
|
|
@@ -436,6 +439,7 @@ function helmDeployOverSshLiteral(
|
|
|
436
439
|
...(def.dependencies ? { dependencies: true } : {}),
|
|
437
440
|
...(def.atomic ? { atomic: true } : def.helmWait ? { helmWait: true } : {}),
|
|
438
441
|
...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
|
|
442
|
+
...(def.link !== undefined ? { link: def.link } : {}),
|
|
439
443
|
},
|
|
440
444
|
});
|
|
441
445
|
return implement(contract, async (stepInput, ctx: Ctx) => {
|
|
@@ -449,6 +453,7 @@ function helmDeployOverSshLiteral(
|
|
|
449
453
|
namespace: string;
|
|
450
454
|
values: string[];
|
|
451
455
|
set: Record<string, string>;
|
|
456
|
+
link?: string;
|
|
452
457
|
healthUrl?:
|
|
453
458
|
| string
|
|
454
459
|
| ({ url: string } & Pick<HttpHealthCheckOptions, 'attempts' | 'delaySeconds'>);
|
|
@@ -495,6 +500,7 @@ function helmDeployOverSshLiteral(
|
|
|
495
500
|
attempts: health.attempts,
|
|
496
501
|
delaySeconds: health.delaySeconds,
|
|
497
502
|
});
|
|
503
|
+
if (d.link) ctx.link(d.link);
|
|
498
504
|
return {
|
|
499
505
|
release: d.release,
|
|
500
506
|
namespace: d.namespace,
|
|
@@ -53,6 +53,12 @@ function targetVariants<const E extends z.ZodRawShape, const K extends string>(
|
|
|
53
53
|
.describe(
|
|
54
54
|
'Base URL step pods reach the release at (health checks, smoke tests); default http://<release>.<namespace>.svc.cluster.local.',
|
|
55
55
|
),
|
|
56
|
+
link: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
'Public URL of what the target serves (a site, an API); the deploy step links to it.',
|
|
61
|
+
),
|
|
56
62
|
...(o.extend ?? ({} as E)),
|
|
57
63
|
};
|
|
58
64
|
const inCluster = z.object({
|
|
@@ -86,6 +92,12 @@ function targetVariants<const E extends z.ZodRawShape, const K extends string>(
|
|
|
86
92
|
.describe(
|
|
87
93
|
"Checked by the step after the deployer's own health check, when the step can reach it.",
|
|
88
94
|
),
|
|
95
|
+
link: z
|
|
96
|
+
.string()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe(
|
|
99
|
+
'Public URL of what the target serves (a site, an API); the deploy step links to it.',
|
|
100
|
+
),
|
|
89
101
|
...(o.extend ?? ({} as E)),
|
|
90
102
|
});
|
|
91
103
|
const none = z.object({
|
|
@@ -156,6 +168,7 @@ interface TargetCommon {
|
|
|
156
168
|
values: readonly string[];
|
|
157
169
|
set: Readonly<Record<string, string>>;
|
|
158
170
|
url?: string | undefined;
|
|
171
|
+
link?: string | undefined;
|
|
159
172
|
}
|
|
160
173
|
/** A resolved `kind: inCluster` target. */
|
|
161
174
|
export type InClusterTargetValue = TargetCommon & { kind: 'inCluster' };
|
|
@@ -472,6 +485,7 @@ function targetDeploy(
|
|
|
472
485
|
attempts: 12,
|
|
473
486
|
delaySeconds: 5,
|
|
474
487
|
});
|
|
488
|
+
if (target.link) ctx.link(target.link);
|
|
475
489
|
const main = flags[0];
|
|
476
490
|
return {
|
|
477
491
|
release,
|
package/src/library/models.ts
CHANGED
|
@@ -54,7 +54,7 @@ export const HelmRelease = model(
|
|
|
54
54
|
export const FailedStep = z.object({
|
|
55
55
|
step: z.string(),
|
|
56
56
|
attempt: z.number().int(),
|
|
57
|
-
/** `<pipeline id>#<run>/<step>@<attempt
|
|
57
|
+
/** `<pipeline id>#<run>/<step>@<attempt>`. */
|
|
58
58
|
address: z.string(),
|
|
59
59
|
/** The attempt's failure reason (handler error, exit code, timeout, …). */
|
|
60
60
|
reason: z.string(),
|
|
@@ -74,7 +74,7 @@ export const FailureContext = model(
|
|
|
74
74
|
run: z.object({
|
|
75
75
|
id: z.string(),
|
|
76
76
|
number: z.number().int(),
|
|
77
|
-
/** `<pipeline id>#<run
|
|
77
|
+
/** `<pipeline id>#<run>`. */
|
|
78
78
|
address: z.string(),
|
|
79
79
|
/** The run's derived version, `yyyy.MM.dd.<run>`. */
|
|
80
80
|
versionStamp: z.string(),
|
package/src/testing/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from '../core/steps.ts';
|
|
15
15
|
import { createCtx } from '../ctx/index.ts';
|
|
16
16
|
import type { Ctx, RunInfo } from '../ctx/types.ts';
|
|
17
|
+
import type { StepLink } from '../ctx/links.ts';
|
|
17
18
|
import { executeStep, type FailureKind, type StepFunction } from '../harness/execute.ts';
|
|
18
19
|
import { sha256Hex } from '../util/hash.ts';
|
|
19
20
|
|
|
@@ -39,6 +40,8 @@ export type TestResult<S extends AnyStep> = {
|
|
|
39
40
|
filesDir: string;
|
|
40
41
|
logs: string[];
|
|
41
42
|
exitCode: number;
|
|
43
|
+
/** What the handler passed to `ctx.link`; absent when nothing. */
|
|
44
|
+
links?: StepLink[];
|
|
42
45
|
} & (
|
|
43
46
|
| { status: 'succeeded'; output: OutputValue<S> }
|
|
44
47
|
| { status: 'skipped'; reason: string }
|
package/src/util/tar.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { createGzip } from 'node:zlib';
|
|
|
20
20
|
|
|
21
21
|
export type TarEntry =
|
|
22
22
|
| { path: string; type: 'file'; mode: number; source: string }
|
|
23
|
-
| { path: string; type: 'bytes'; mode: number; data: Uint8Array }
|
|
24
23
|
| { path: string; type: 'dir'; mode: number }
|
|
25
24
|
| { path: string; type: 'symlink'; target: string };
|
|
26
25
|
|
|
@@ -88,7 +87,6 @@ export function writeTar(out: string, entries: readonly TarEntry[]): void {
|
|
|
88
87
|
const link = e.type === 'symlink' ? e.target : '';
|
|
89
88
|
let size = 0;
|
|
90
89
|
if (e.type === 'file') size = lstatSync(e.source).size;
|
|
91
|
-
else if (e.type === 'bytes') size = e.data.length;
|
|
92
90
|
const pax: string[] = [];
|
|
93
91
|
if (enc.encode(name).length > 100) pax.push(paxRecord('path', name));
|
|
94
92
|
if (enc.encode(link).length > 100) pax.push(paxRecord('linkpath', link));
|
|
@@ -104,10 +102,7 @@ export function writeTar(out: string, entries: readonly TarEntry[]): void {
|
|
|
104
102
|
write(
|
|
105
103
|
header(shortName, size, mode, flag, link.length > 100 ? link.slice(0, 99) : link),
|
|
106
104
|
);
|
|
107
|
-
if (e.type === '
|
|
108
|
-
write(e.data);
|
|
109
|
-
pad(size);
|
|
110
|
-
} else if (e.type === 'file') {
|
|
105
|
+
if (e.type === 'file') {
|
|
111
106
|
const src = openSync(e.source, 'r');
|
|
112
107
|
try {
|
|
113
108
|
const buf = new Uint8Array(1 << 20);
|