@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.20
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 +125 -62
- package/manifest.schema.json +208 -11
- package/package.json +6 -4
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +121 -51
- package/src/build/cli.ts +29 -22
- package/src/build/elf.ts +8 -7
- package/src/build/hashes.ts +82 -20
- package/src/build/project.ts +95 -28
- package/src/builder/pipeline.ts +344 -88
- package/src/builder/refs.ts +15 -5
- package/src/core/brands.ts +7 -5
- package/src/core/config-refs.ts +103 -0
- package/src/core/config.ts +224 -0
- package/src/core/external.ts +86 -0
- package/src/core/handlers.ts +61 -15
- package/src/core/models.ts +32 -15
- package/src/core/origin.ts +23 -0
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +89 -38
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +111 -40
- package/src/ctx/index.ts +84 -31
- package/src/ctx/sh.ts +105 -44
- package/src/ctx/types.ts +12 -4
- package/src/describe/describe.ts +701 -133
- package/src/describe/manifest-schema.ts +258 -61
- package/src/describe/mapper-source.ts +249 -104
- package/src/describe/record.ts +67 -37
- package/src/dev/cli.ts +72 -35
- package/src/harness/env.ts +45 -25
- package/src/harness/execute.ts +58 -29
- package/src/harness/index.ts +22 -9
- package/src/harness/main.ts +239 -78
- package/src/harness/result.ts +38 -22
- package/src/harness/validate.ts +77 -15
- package/src/index.ts +124 -11
- package/src/library/checks.ts +28 -0
- package/src/library/compose-deploy.ts +113 -0
- package/src/library/github-source.ts +100 -47
- package/src/library/helm-deploy-ssh.ts +452 -108
- package/src/library/helm-deploy.ts +449 -0
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +499 -105
- package/src/library/index.ts +48 -12
- package/src/library/models.ts +33 -9
- package/src/library/ssh.ts +67 -0
- package/src/testing/index.ts +74 -36
- package/src/util/canonical.ts +7 -4
- package/src/util/handler-hash.ts +17 -7
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +86 -36
- package/src/library/script.ts +0 -85
package/src/ctx/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync } from
|
|
2
|
-
import { resolve } from
|
|
3
|
-
import { skipped } from
|
|
4
|
-
import type { Secret } from
|
|
5
|
-
import { createFetch, createOutput, redactUrl } from
|
|
6
|
-
import { createSh } from
|
|
7
|
-
import type { Ctx, Http, RetryOptions, RunInfo } from
|
|
1
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { skipped } from '../core/handlers.ts';
|
|
4
|
+
import type { Secret } from '../core/secrets.ts';
|
|
5
|
+
import { createFetch, createOutput, redactUrl } from './files.ts';
|
|
6
|
+
import { createSh } from './sh.ts';
|
|
7
|
+
import type { Ctx, Http, RetryOptions, RunInfo } from './types.ts';
|
|
8
8
|
|
|
9
9
|
export interface CtxOptions {
|
|
10
10
|
run: RunInfo;
|
|
@@ -12,25 +12,38 @@ export interface CtxOptions {
|
|
|
12
12
|
secrets: readonly Secret[];
|
|
13
13
|
/** Secret values that take precedence over the environment (tests). */
|
|
14
14
|
secretValues?: Readonly<Record<string, string>>;
|
|
15
|
+
/** Parsed config entries of the step (`ctx.config`). */
|
|
16
|
+
config?: Readonly<Record<string, unknown>>;
|
|
15
17
|
paths: { root: string; workFiles: string; tmp: string };
|
|
16
18
|
signal: AbortSignal;
|
|
17
19
|
/** Where log lines go (default stderr). */
|
|
18
20
|
logSink?: (line: string) => void;
|
|
21
|
+
/** `<step>/<input>` → callback URL (`ctx.inputUrl`). */
|
|
22
|
+
callbackUrls?: Readonly<Record<string, string>>;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export function formatLog(message: string, fields?: Record<string, unknown>): string {
|
|
22
|
-
const parts = Object.entries(fields ?? {}).map(
|
|
23
|
-
|
|
26
|
+
const parts = Object.entries(fields ?? {}).map(
|
|
27
|
+
([k, v]) =>
|
|
28
|
+
`${k}=${typeof v === 'string' && !/\s|"/.test(v) ? v : JSON.stringify(v)}`,
|
|
29
|
+
);
|
|
30
|
+
return parts.length ? `${message} ${parts.join(' ')}` : message;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
export function createSecrets(
|
|
33
|
+
export function createSecrets(
|
|
34
|
+
declared: readonly Secret[],
|
|
35
|
+
values?: Readonly<Record<string, string>>,
|
|
36
|
+
): Record<string, string> {
|
|
27
37
|
const obj: Record<string, string> = {};
|
|
28
38
|
for (const s of declared) {
|
|
29
39
|
Object.defineProperty(obj, s.name, {
|
|
30
40
|
enumerable: true,
|
|
31
41
|
get() {
|
|
32
42
|
const v = values?.[s.name] ?? process.env[s.name];
|
|
33
|
-
if (v === undefined)
|
|
43
|
+
if (v === undefined)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`,
|
|
46
|
+
);
|
|
34
47
|
return v;
|
|
35
48
|
},
|
|
36
49
|
});
|
|
@@ -39,52 +52,74 @@ export function createSecrets(declared: readonly Secret[], values?: Readonly<Rec
|
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
async function httpError(res: Response, url: string): Promise<Error> {
|
|
42
|
-
let body =
|
|
55
|
+
let body = '';
|
|
43
56
|
try {
|
|
44
57
|
body = (await res.text()).slice(0, 500);
|
|
45
58
|
} catch {
|
|
46
59
|
/* ignore */
|
|
47
60
|
}
|
|
48
|
-
return new Error(
|
|
61
|
+
return new Error(
|
|
62
|
+
`HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ''}`,
|
|
63
|
+
);
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
export function createHttp(signal: AbortSignal): Http {
|
|
52
|
-
const f = (url: string, init?: RequestInit) =>
|
|
67
|
+
const f = (url: string, init?: RequestInit) =>
|
|
68
|
+
fetch(url, { ...init, signal: init?.signal ?? signal });
|
|
53
69
|
return {
|
|
54
70
|
fetch: f,
|
|
55
|
-
async getJson<T>(
|
|
56
|
-
|
|
71
|
+
async getJson<T>(
|
|
72
|
+
url: string,
|
|
73
|
+
init?: { headers?: Record<string, string> },
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
const res = await f(url, {
|
|
76
|
+
headers: { accept: 'application/json', ...init?.headers },
|
|
77
|
+
});
|
|
57
78
|
if (!res.ok) throw await httpError(res, url);
|
|
58
79
|
return (await res.json()) as T;
|
|
59
80
|
},
|
|
60
|
-
async expectOk(
|
|
61
|
-
|
|
81
|
+
async expectOk(
|
|
82
|
+
url: string,
|
|
83
|
+
init?: { headers?: Record<string, string>; method?: string },
|
|
84
|
+
): Promise<Response> {
|
|
85
|
+
const res = await f(url, { method: init?.method ?? 'GET', headers: init?.headers });
|
|
62
86
|
if (!res.ok) throw await httpError(res, url);
|
|
63
87
|
return res;
|
|
64
88
|
},
|
|
65
89
|
};
|
|
66
90
|
}
|
|
67
91
|
|
|
68
|
-
export function createRetry(
|
|
69
|
-
|
|
70
|
-
|
|
92
|
+
export function createRetry(
|
|
93
|
+
signal: AbortSignal,
|
|
94
|
+
log: (m: string, f?: Record<string, unknown>) => void,
|
|
95
|
+
) {
|
|
96
|
+
return async <T>(
|
|
97
|
+
opts: RetryOptions,
|
|
98
|
+
fn: (attempt: number) => Promise<T>,
|
|
99
|
+
): Promise<T> => {
|
|
100
|
+
if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1))
|
|
101
|
+
throw new Error('retry: attempts must be a positive integer');
|
|
71
102
|
let delay = Math.max(0, opts.delaySeconds) * 1000;
|
|
72
103
|
for (let attempt = 1; ; attempt++) {
|
|
73
104
|
try {
|
|
74
105
|
return await fn(attempt);
|
|
75
106
|
} catch (e) {
|
|
76
107
|
if (attempt >= opts.attempts || signal.aborted) throw e;
|
|
77
|
-
log(
|
|
108
|
+
log('retrying', {
|
|
109
|
+
attempt,
|
|
110
|
+
of: opts.attempts,
|
|
111
|
+
error: (e as Error)?.message ?? String(e),
|
|
112
|
+
});
|
|
78
113
|
await new Promise<void>((res, rej) => {
|
|
79
114
|
const t = setTimeout(() => {
|
|
80
|
-
signal.removeEventListener(
|
|
115
|
+
signal.removeEventListener('abort', onAbort);
|
|
81
116
|
res();
|
|
82
117
|
}, delay);
|
|
83
118
|
const onAbort = () => {
|
|
84
119
|
clearTimeout(t);
|
|
85
|
-
rej(new Error(
|
|
120
|
+
rej(new Error('retry cancelled: the step was cancelled'));
|
|
86
121
|
};
|
|
87
|
-
signal.addEventListener(
|
|
122
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
88
123
|
});
|
|
89
124
|
delay *= opts.backoff ?? 1;
|
|
90
125
|
}
|
|
@@ -96,16 +131,25 @@ export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<str
|
|
|
96
131
|
export function createCtx(o: CtxOptions): Ctx {
|
|
97
132
|
mkdirSync(o.paths.tmp, { recursive: true });
|
|
98
133
|
mkdirSync(o.paths.workFiles, { recursive: true });
|
|
99
|
-
const sink = o.logSink ?? ((line: string) => process.stderr.write(line +
|
|
100
|
-
const log = (message: string, fields?: Record<string, unknown>) =>
|
|
134
|
+
const sink = o.logSink ?? ((line: string) => process.stderr.write(line + '\n'));
|
|
135
|
+
const log = (message: string, fields?: Record<string, unknown>) =>
|
|
136
|
+
sink(formatLog(message, fields));
|
|
101
137
|
const { sh, exec } = createSh({ tmpDir: o.paths.tmp, signal: o.signal });
|
|
102
|
-
const filesDeps = {
|
|
138
|
+
const filesDeps = {
|
|
139
|
+
run: o.run,
|
|
140
|
+
workFiles: o.paths.workFiles,
|
|
141
|
+
tmpDir: o.paths.tmp,
|
|
142
|
+
root: o.paths.root,
|
|
143
|
+
signal: o.signal,
|
|
144
|
+
log,
|
|
145
|
+
};
|
|
103
146
|
return {
|
|
104
|
-
secrets: createSecrets(o.secrets, o.secretValues) as Ctx[
|
|
147
|
+
secrets: createSecrets(o.secrets, o.secretValues) as Ctx['secrets'],
|
|
148
|
+
config: Object.freeze({ ...o.config }) as Ctx['config'],
|
|
105
149
|
sh,
|
|
106
150
|
exec,
|
|
107
|
-
readText: async (p) => readFileSync(resolve(p),
|
|
108
|
-
readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p),
|
|
151
|
+
readText: async (p) => readFileSync(resolve(p), 'utf8'),
|
|
152
|
+
readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), 'utf8')) as T,
|
|
109
153
|
files: { fetch: createFetch(filesDeps) },
|
|
110
154
|
output: createOutput(filesDeps),
|
|
111
155
|
http: createHttp(o.signal),
|
|
@@ -115,5 +159,14 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
115
159
|
run: Object.freeze({ ...o.run }),
|
|
116
160
|
tmpDir: o.paths.tmp,
|
|
117
161
|
skip: skipped,
|
|
162
|
+
inputUrl: (step, input) => {
|
|
163
|
+
const name = typeof step === 'string' ? step : step.name;
|
|
164
|
+
const url = o.callbackUrls?.[`${name}/${input}`];
|
|
165
|
+
if (!url)
|
|
166
|
+
throw new Error(
|
|
167
|
+
`no callback URL for input '${input}' of step '${name}': it must list callback() in from, and the instance needs a public base URL`,
|
|
168
|
+
);
|
|
169
|
+
return url;
|
|
170
|
+
},
|
|
118
171
|
};
|
|
119
172
|
}
|
package/src/ctx/sh.ts
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
10
|
+
import type { ExecOptions, Sh, ShOptions } from './types.ts';
|
|
11
|
+
import { PRIVATE_ENV } from '../harness/env.ts';
|
|
5
12
|
|
|
6
13
|
/** Shells looked for, in order. kaniko only has /busybox/sh; distroless has none. */
|
|
7
|
-
export const SHELL_CANDIDATES = [
|
|
14
|
+
export const SHELL_CANDIDATES = ['/bin/sh', '/busybox/sh', '/bin/bash'] as const;
|
|
8
15
|
|
|
9
16
|
export class ShellNotFoundError extends Error {
|
|
10
|
-
override name =
|
|
17
|
+
override name = 'ShellNotFoundError';
|
|
11
18
|
constructor() {
|
|
12
19
|
super(
|
|
13
|
-
`no shell found in this image (looked for ${SHELL_CANDIDATES.join(
|
|
20
|
+
`no shell found in this image (looked for ${SHELL_CANDIDATES.join(', ')}). ` +
|
|
14
21
|
`ctx.sh needs a shell: use an image that has one, or run binaries directly with ctx.exec([...]).`,
|
|
15
22
|
);
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
export class CommandError extends Error {
|
|
20
|
-
override name =
|
|
21
|
-
constructor(
|
|
22
|
-
|
|
27
|
+
override name = 'CommandError';
|
|
28
|
+
constructor(
|
|
29
|
+
readonly what: string,
|
|
30
|
+
readonly exitCode: number | null,
|
|
31
|
+
readonly signalCode: string | null,
|
|
32
|
+
) {
|
|
33
|
+
super(
|
|
34
|
+
`${what} failed with ${signalCode ? `signal ${signalCode}` : `exit code ${exitCode}`}`,
|
|
35
|
+
);
|
|
23
36
|
}
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
/** The command went quiet for longer than its `idleTimeoutSeconds` and was killed. */
|
|
27
40
|
export class CommandStalledError extends CommandError {
|
|
28
|
-
override name =
|
|
29
|
-
constructor(
|
|
41
|
+
override name = 'CommandStalledError';
|
|
42
|
+
constructor(
|
|
43
|
+
what: string,
|
|
44
|
+
readonly idleTimeoutSeconds: number,
|
|
45
|
+
signalCode: string | null,
|
|
46
|
+
) {
|
|
30
47
|
super(what, null, signalCode);
|
|
31
48
|
this.message = `${what} produced no output for ${idleTimeoutSeconds} s and was killed`;
|
|
32
49
|
}
|
|
@@ -42,7 +59,7 @@ export function detectShell(candidates: readonly string[] = SHELL_CANDIDATES): s
|
|
|
42
59
|
|
|
43
60
|
/** True for the value of `import x from "./x.sh" with { type: "file" }` (compiled or from source). */
|
|
44
61
|
export function isEmbeddedScript(s: string): boolean {
|
|
45
|
-
if (s.startsWith(
|
|
62
|
+
if (s.startsWith('/$bunfs/') || s.startsWith('B:/~BUN/')) return true;
|
|
46
63
|
if (!isAbsolute(s) || !/\.(sh|bash)$/.test(s) || /\s/.test(s)) return false;
|
|
47
64
|
try {
|
|
48
65
|
return statSync(s).isFile();
|
|
@@ -54,7 +71,8 @@ export function isEmbeddedScript(s: string): boolean {
|
|
|
54
71
|
/** Environment for child processes: the harness env minus harness-only credentials, plus extras. */
|
|
55
72
|
export function childEnv(extra?: Record<string, string>): Record<string, string> {
|
|
56
73
|
const env: Record<string, string> = {};
|
|
57
|
-
for (const [k, v] of Object.entries(process.env))
|
|
74
|
+
for (const [k, v] of Object.entries(process.env))
|
|
75
|
+
if (v !== undefined && !PRIVATE_ENV.includes(k)) env[k] = v;
|
|
58
76
|
return { ...env, ...extra };
|
|
59
77
|
}
|
|
60
78
|
|
|
@@ -64,7 +82,10 @@ export interface ShDeps {
|
|
|
64
82
|
shellCandidates?: readonly string[];
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
export function createSh(deps: ShDeps): {
|
|
85
|
+
export function createSh(deps: ShDeps): {
|
|
86
|
+
sh: Sh;
|
|
87
|
+
exec: (argv: readonly string[], opts?: ExecOptions) => Promise<void>;
|
|
88
|
+
} {
|
|
68
89
|
const copied = new Map<string, string>();
|
|
69
90
|
let counter = 0;
|
|
70
91
|
|
|
@@ -72,7 +93,7 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
72
93
|
const materialize = (script: string): string => {
|
|
73
94
|
const hit = copied.get(script);
|
|
74
95
|
if (hit) return hit;
|
|
75
|
-
const dir = join(deps.tmpDir,
|
|
96
|
+
const dir = join(deps.tmpDir, 'scripts');
|
|
76
97
|
mkdirSync(dir, { recursive: true });
|
|
77
98
|
const dest = join(dir, `${++counter}-${basename(script)}`);
|
|
78
99
|
writeFileSync(dest, readFileSync(script));
|
|
@@ -81,20 +102,34 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
81
102
|
return dest;
|
|
82
103
|
};
|
|
83
104
|
|
|
84
|
-
const argvFor = (
|
|
85
|
-
|
|
105
|
+
const argvFor = (
|
|
106
|
+
cmd: string | { script: string },
|
|
107
|
+
args: readonly string[],
|
|
108
|
+
): { argv: string[]; what: string } => {
|
|
109
|
+
const scriptPath =
|
|
110
|
+
typeof cmd === 'object' ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
|
|
86
111
|
if (scriptPath === undefined) {
|
|
87
112
|
const shell = detectShell(deps.shellCandidates);
|
|
88
|
-
|
|
113
|
+
// `sh -c cmd sh a b`: the args are $1, $2… of the command.
|
|
114
|
+
return {
|
|
115
|
+
argv: [shell, '-c', cmd as string, 'sh', ...args],
|
|
116
|
+
what: `command \`${truncate(cmd as string)}\``,
|
|
117
|
+
};
|
|
89
118
|
}
|
|
90
119
|
const file = materialize(scriptPath);
|
|
91
|
-
const first = readFileSync(file,
|
|
120
|
+
const first = readFileSync(file, 'utf8').split('\n', 1)[0] ?? '';
|
|
92
121
|
const shebang = /^#!\s*(\S+)(?:\s+(.*))?$/.exec(first);
|
|
93
122
|
if (shebang && existsSync(shebang[1]!)) {
|
|
94
123
|
const interpArgs = shebang[2] ? [shebang[2].trim()] : [];
|
|
95
|
-
return {
|
|
124
|
+
return {
|
|
125
|
+
argv: [shebang[1]!, ...interpArgs, file, ...args],
|
|
126
|
+
what: `script ${basename(scriptPath)}`,
|
|
127
|
+
};
|
|
96
128
|
}
|
|
97
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
argv: [detectShell(deps.shellCandidates), file, ...args],
|
|
131
|
+
what: `script ${basename(scriptPath)}`,
|
|
132
|
+
};
|
|
98
133
|
};
|
|
99
134
|
|
|
100
135
|
const run = async (
|
|
@@ -103,22 +138,26 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
103
138
|
opts: { env?: Record<string, string>; cwd?: string; idleTimeoutSeconds?: number },
|
|
104
139
|
capture: boolean,
|
|
105
140
|
): Promise<string> => {
|
|
106
|
-
if (deps.signal.aborted)
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
if (deps.signal.aborted)
|
|
142
|
+
throw new Error(`${what} not started: the step was cancelled`);
|
|
143
|
+
const idleSeconds =
|
|
144
|
+
opts.idleTimeoutSeconds !== undefined && opts.idleTimeoutSeconds > 0
|
|
145
|
+
? opts.idleTimeoutSeconds
|
|
146
|
+
: undefined;
|
|
147
|
+
let proc: Bun.Subprocess<'ignore', 'pipe' | 'inherit', 'pipe' | 'inherit'>;
|
|
109
148
|
try {
|
|
110
149
|
proc = Bun.spawn(argv, {
|
|
111
150
|
cwd: opts.cwd,
|
|
112
151
|
env: childEnv(opts.env),
|
|
113
|
-
stdin:
|
|
114
|
-
stdout: capture || idleSeconds ?
|
|
115
|
-
stderr: idleSeconds ?
|
|
152
|
+
stdin: 'ignore',
|
|
153
|
+
stdout: capture || idleSeconds ? 'pipe' : 'inherit',
|
|
154
|
+
stderr: idleSeconds ? 'pipe' : 'inherit',
|
|
116
155
|
});
|
|
117
156
|
} catch (e) {
|
|
118
157
|
throw new Error(`cannot start ${what} (${argv[0]}): ${(e as Error).message}`);
|
|
119
158
|
}
|
|
120
|
-
const onAbort = () => proc.kill(
|
|
121
|
-
deps.signal.addEventListener(
|
|
159
|
+
const onAbort = () => proc.kill('SIGTERM');
|
|
160
|
+
deps.signal.addEventListener('abort', onAbort, { once: true });
|
|
122
161
|
|
|
123
162
|
// Watchdog: every chunk of output re-arms the timer; on expiry TERM, then KILL after a grace.
|
|
124
163
|
let stalled = false;
|
|
@@ -129,13 +168,16 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
129
168
|
clearTimeout(idleTimer);
|
|
130
169
|
idleTimer = setTimeout(() => {
|
|
131
170
|
stalled = true;
|
|
132
|
-
proc.kill(
|
|
133
|
-
killTimer = setTimeout(() => proc.kill(
|
|
171
|
+
proc.kill('SIGTERM');
|
|
172
|
+
killTimer = setTimeout(() => proc.kill('SIGKILL'), STALL_KILL_GRACE_MS);
|
|
134
173
|
}, idleSeconds * 1000);
|
|
135
174
|
};
|
|
136
175
|
const captured: Uint8Array[] = [];
|
|
137
176
|
const readers: { cancel(): Promise<void> }[] = [];
|
|
138
|
-
const pump = async (
|
|
177
|
+
const pump = async (
|
|
178
|
+
stream: ReadableStream<Uint8Array>,
|
|
179
|
+
sink: (chunk: Uint8Array) => void,
|
|
180
|
+
) => {
|
|
139
181
|
const reader = stream.getReader();
|
|
140
182
|
readers.push(reader);
|
|
141
183
|
try {
|
|
@@ -150,9 +192,18 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
150
192
|
}
|
|
151
193
|
};
|
|
152
194
|
const pumps: Promise<void>[] = [];
|
|
153
|
-
if (capture)
|
|
154
|
-
|
|
155
|
-
|
|
195
|
+
if (capture)
|
|
196
|
+
pumps.push(
|
|
197
|
+
pump(proc.stdout as ReadableStream<Uint8Array>, (c) => captured.push(c)),
|
|
198
|
+
);
|
|
199
|
+
else if (idleSeconds)
|
|
200
|
+
pumps.push(
|
|
201
|
+
pump(proc.stdout as ReadableStream<Uint8Array>, (c) => process.stdout.write(c)),
|
|
202
|
+
);
|
|
203
|
+
if (idleSeconds)
|
|
204
|
+
pumps.push(
|
|
205
|
+
pump(proc.stderr as ReadableStream<Uint8Array>, (c) => process.stderr.write(c)),
|
|
206
|
+
);
|
|
156
207
|
arm();
|
|
157
208
|
|
|
158
209
|
try {
|
|
@@ -160,13 +211,23 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
160
211
|
// A killed command's children may still hold the pipes open: don't wait for them after a stall.
|
|
161
212
|
if (stalled) await Promise.all(readers.map((r) => r.cancel().catch(() => {})));
|
|
162
213
|
else await Promise.all(pumps);
|
|
163
|
-
if (stalled)
|
|
164
|
-
|
|
165
|
-
|
|
214
|
+
if (stalled)
|
|
215
|
+
throw new CommandStalledError(
|
|
216
|
+
what,
|
|
217
|
+
idleSeconds!,
|
|
218
|
+
proc.signalCode ? String(proc.signalCode) : null,
|
|
219
|
+
);
|
|
220
|
+
if (code !== 0)
|
|
221
|
+
throw new CommandError(
|
|
222
|
+
what,
|
|
223
|
+
proc.signalCode ? null : code,
|
|
224
|
+
proc.signalCode ? String(proc.signalCode) : null,
|
|
225
|
+
);
|
|
226
|
+
return capture ? Buffer.concat(captured).toString('utf8') : '';
|
|
166
227
|
} finally {
|
|
167
228
|
clearTimeout(idleTimer);
|
|
168
229
|
clearTimeout(killTimer);
|
|
169
|
-
deps.signal.removeEventListener(
|
|
230
|
+
deps.signal.removeEventListener('abort', onAbort);
|
|
170
231
|
}
|
|
171
232
|
};
|
|
172
233
|
|
|
@@ -184,13 +245,13 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
184
245
|
) as Sh;
|
|
185
246
|
|
|
186
247
|
const exec = async (argv: readonly string[], opts: ExecOptions = {}) => {
|
|
187
|
-
if (argv.length === 0) throw new Error(
|
|
188
|
-
await run([...argv], `\`${truncate(argv.join(
|
|
248
|
+
if (argv.length === 0) throw new Error('ctx.exec needs at least the program');
|
|
249
|
+
await run([...argv], `\`${truncate(argv.join(' '))}\``, opts, false);
|
|
189
250
|
};
|
|
190
251
|
return { sh, exec };
|
|
191
252
|
}
|
|
192
253
|
|
|
193
254
|
function truncate(s: string, n = 120): string {
|
|
194
|
-
const one = s.replace(/\s+/g,
|
|
195
|
-
return one.length > n ? one.slice(0, n - 1) +
|
|
255
|
+
const one = s.replace(/\s+/g, ' ').trim();
|
|
256
|
+
return one.length > n ? one.slice(0, n - 1) + '…' : one;
|
|
196
257
|
}
|
package/src/ctx/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { Skipped } from
|
|
2
|
-
import type { FileRef } from
|
|
3
|
-
import type {
|
|
1
|
+
import type { Skipped } from '../core/handlers.ts';
|
|
2
|
+
import type { FileRef } from '../core/models.ts';
|
|
3
|
+
import type { AnyConfigEntry, ConfigValues } from '../core/config.ts';
|
|
4
|
+
import type { DeclaredSecrets } from '../core/secrets.ts';
|
|
4
5
|
|
|
5
6
|
/** Facts about the current execution, from the service (see README "Environment"). */
|
|
6
7
|
export interface RunInfo {
|
|
@@ -79,9 +80,11 @@ export interface OutputFileOptions {
|
|
|
79
80
|
name?: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export interface Ctx<Sec extends string = string> {
|
|
83
|
+
export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = never> {
|
|
83
84
|
/** Exactly the secrets the step declares, read from the environment. */
|
|
84
85
|
readonly secrets: DeclaredSecrets<Sec>;
|
|
86
|
+
/** Exactly the config entries the step declares, from the run's config snapshot (defaults applied). */
|
|
87
|
+
readonly config: ConfigValues<Cfg>;
|
|
85
88
|
readonly sh: Sh;
|
|
86
89
|
/** Run a binary directly, without a shell (works in shell-less images). Rejects on non-zero exit. */
|
|
87
90
|
exec(argv: readonly string[], opts?: ExecOptions): Promise<void>;
|
|
@@ -110,4 +113,9 @@ export interface Ctx<Sec extends string = string> {
|
|
|
110
113
|
readonly tmpDir: string;
|
|
111
114
|
/** Runtime escape hatch: produce no output. Prefer `when` blocks. */
|
|
112
115
|
skip(reason: string): Skipped;
|
|
116
|
+
/**
|
|
117
|
+
* This run's callback URL for an external input with `from: [callback()]` (of any step of the pipeline):
|
|
118
|
+
* hand it to the system that will answer. POST the value as JSON, or `{"$decline": "reason"}`.
|
|
119
|
+
*/
|
|
120
|
+
inputUrl(step: string | { readonly name: string }, input: string): string;
|
|
113
121
|
}
|