@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.31
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 +165 -78
- package/manifest.schema.json +300 -16
- package/package.json +6 -4
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +98 -64
- package/src/build/cli.ts +33 -23
- package/src/build/elf.ts +16 -7
- package/src/build/hashes.ts +40 -51
- package/src/build/project.ts +55 -52
- package/src/builder/pipeline.ts +348 -89
- package/src/builder/refs.ts +15 -5
- package/src/core/brands.ts +6 -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 +62 -15
- package/src/core/matrix.ts +150 -0
- package/src/core/models.ts +37 -15
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +113 -38
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +119 -42
- package/src/ctx/index.ts +110 -39
- package/src/ctx/links.ts +30 -0
- package/src/ctx/sh.ts +106 -44
- package/src/ctx/types.ts +24 -4
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +187 -0
- package/src/describe/describe.ts +63 -355
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/manifest-schema.ts +304 -61
- package/src/describe/mapper-source.ts +252 -105
- package/src/describe/record.ts +67 -37
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +166 -0
- package/src/describe/step.ts +544 -0
- package/src/describe/triggers.ts +72 -0
- package/src/dev/cli.ts +62 -35
- package/src/harness/env.ts +47 -25
- package/src/harness/execute.ts +74 -30
- package/src/harness/exit.ts +1 -1
- package/src/harness/index.ts +19 -8
- package/src/harness/main.ts +245 -99
- package/src/harness/mask.ts +98 -0
- package/src/harness/result.ts +45 -24
- package/src/harness/validate.ts +82 -15
- package/src/index.ts +139 -10
- package/src/library/checks.ts +11 -6
- package/src/library/compose-deploy.ts +115 -0
- package/src/library/github-source.ts +100 -47
- package/src/library/helm-deploy-ssh.ts +426 -112
- package/src/library/helm-deploy.ts +529 -0
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +510 -162
- package/src/library/index.ts +48 -14
- package/src/library/models.ts +35 -14
- package/src/library/ssh.ts +44 -18
- package/src/testing/index.ts +100 -35
- package/src/util/canonical.ts +7 -4
- package/src/util/code-hash.ts +17 -0
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +83 -38
- package/src/library/script.ts +0 -85
- package/src/util/handler-hash.ts +0 -22
package/src/ctx/files.ts
CHANGED
|
@@ -4,19 +4,31 @@
|
|
|
4
4
|
// `path` minus the trailing "/" plus ".tar" for directories (a deterministic tar). The step writes
|
|
5
5
|
// objects to `$OLVE_DIR/work/files/<object path>`; the service stores them under
|
|
6
6
|
// `runs/<p>/<run>/<step>/<attempt>/files/<object path>`.
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
import {
|
|
8
|
+
copyFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { fileRefSchema, FILE_PATH, type FileRef } from '../core/models.ts';
|
|
19
|
+
import { ENV } from '../harness/env.ts';
|
|
20
|
+
import { sha256File } from '../util/hash.ts';
|
|
21
|
+
import { dirEntries, extractTar, writeTar } from '../util/tar.ts';
|
|
22
|
+
import type { OutputFileOptions, RunInfo } from './types.ts';
|
|
14
23
|
|
|
15
|
-
export const objectPath = (refPath: string): string =>
|
|
16
|
-
|
|
24
|
+
export const objectPath = (refPath: string): string =>
|
|
25
|
+
refPath.endsWith('/') ? refPath.slice(0, -1) + '.tar' : refPath;
|
|
26
|
+
export const fileKey = (
|
|
27
|
+
ref: Pick<FileRef, 'run' | 'step' | 'attempt' | 'path'>,
|
|
28
|
+
): string => `${ref.run}/${ref.step}/${ref.attempt}/${ref.path}`;
|
|
17
29
|
|
|
18
30
|
/** Strips everything from `?` on (pre-signed credentials). */
|
|
19
|
-
export const redactUrl = (url: string): string => url.replace(/[?#].*$/,
|
|
31
|
+
export const redactUrl = (url: string): string => url.replace(/[?#].*$/, '?…');
|
|
20
32
|
|
|
21
33
|
export interface FilesDeps {
|
|
22
34
|
run: RunInfo;
|
|
@@ -25,45 +37,59 @@ export interface FilesDeps {
|
|
|
25
37
|
root: string;
|
|
26
38
|
signal: AbortSignal;
|
|
27
39
|
log: (message: string, fields?: Record<string, unknown>) => void;
|
|
40
|
+
/** Backoff between download retries (default: real sleep). */
|
|
41
|
+
sleep?: (ms: number) => Promise<void>;
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
function checkName(name: string, what: string): void {
|
|
31
|
-
if (!FILE_PATH.test(name) || name.endsWith(
|
|
45
|
+
if (!FILE_PATH.test(name) || name.endsWith('/'))
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${what}: '${name}' is not a valid relative file name (no leading '/', no '..', no trailing '/')`,
|
|
48
|
+
);
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
export function createOutput(deps: FilesDeps) {
|
|
35
52
|
const used = new Set<string>();
|
|
36
53
|
const claim = (obj: string) => {
|
|
37
|
-
if (used.has(obj) || existsSync(join(deps.workFiles, obj)))
|
|
54
|
+
if (used.has(obj) || existsSync(join(deps.workFiles, obj)))
|
|
55
|
+
throw new Error(`output file '${obj}' already exists`);
|
|
38
56
|
used.add(obj);
|
|
39
57
|
const dest = join(deps.workFiles, obj);
|
|
40
58
|
mkdirSync(dirname(dest), { recursive: true });
|
|
41
59
|
return dest;
|
|
42
60
|
};
|
|
43
61
|
const ref = (path: string, dest: string): FileRef =>
|
|
44
|
-
({
|
|
62
|
+
({
|
|
63
|
+
run: deps.run.number,
|
|
64
|
+
step: deps.run.step,
|
|
65
|
+
attempt: deps.run.attempt,
|
|
66
|
+
path,
|
|
67
|
+
sha256: sha256File(dest),
|
|
68
|
+
}) as FileRef;
|
|
45
69
|
|
|
46
70
|
return {
|
|
47
71
|
async file(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
|
|
48
72
|
const src = resolve(path);
|
|
49
|
-
if (!statSync(src).isFile())
|
|
73
|
+
if (!statSync(src).isFile())
|
|
74
|
+
throw new Error(`ctx.output.file: '${path}' is not a file`);
|
|
50
75
|
const name = opts.name ?? basename(src);
|
|
51
|
-
checkName(name,
|
|
76
|
+
checkName(name, 'ctx.output.file');
|
|
52
77
|
const dest = claim(name);
|
|
53
78
|
copyFileSync(src, dest);
|
|
54
79
|
return ref(name, dest);
|
|
55
80
|
},
|
|
56
81
|
async dir(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
|
|
57
82
|
const src = resolve(path);
|
|
58
|
-
if (!statSync(src).isDirectory())
|
|
83
|
+
if (!statSync(src).isDirectory())
|
|
84
|
+
throw new Error(`ctx.output.dir: '${path}' is not a directory`);
|
|
59
85
|
const name = opts.name ?? basename(src);
|
|
60
|
-
checkName(name,
|
|
61
|
-
const dest = claim(objectPath(name +
|
|
86
|
+
checkName(name, 'ctx.output.dir');
|
|
87
|
+
const dest = claim(objectPath(name + '/'));
|
|
62
88
|
writeTar(dest, dirEntries(src));
|
|
63
|
-
return ref(name +
|
|
89
|
+
return ref(name + '/', dest);
|
|
64
90
|
},
|
|
65
91
|
async write(name: string, content: string | Uint8Array): Promise<FileRef> {
|
|
66
|
-
checkName(name,
|
|
92
|
+
checkName(name, 'ctx.output.write');
|
|
67
93
|
const dest = claim(name);
|
|
68
94
|
writeFileSync(dest, content);
|
|
69
95
|
return ref(name, dest);
|
|
@@ -72,20 +98,34 @@ export function createOutput(deps: FilesDeps) {
|
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
function urlMap(root: string): Record<string, string> {
|
|
75
|
-
const raw =
|
|
101
|
+
const raw =
|
|
102
|
+
process.env[ENV.fileUrls] ??
|
|
103
|
+
(existsSync(join(root, 'files.json'))
|
|
104
|
+
? readFileSync(join(root, 'files.json'), 'utf8')
|
|
105
|
+
: undefined);
|
|
76
106
|
if (!raw) return {};
|
|
77
107
|
try {
|
|
78
108
|
const m = JSON.parse(raw) as unknown;
|
|
79
|
-
if (typeof m !==
|
|
109
|
+
if (typeof m !== 'object' || m === null || Array.isArray(m))
|
|
110
|
+
throw new Error('not an object');
|
|
80
111
|
return m as Record<string, string>;
|
|
81
112
|
} catch (e) {
|
|
82
|
-
throw new Error(
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${ENV.fileUrls} / files.json is not a JSON object of file key → URL (${(e as Error).message})`,
|
|
115
|
+
);
|
|
83
116
|
}
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
const TRANSIENT = (status: number) => status >= 500 || status === 408 || status === 429;
|
|
87
120
|
|
|
88
|
-
async function download(
|
|
121
|
+
async function download(
|
|
122
|
+
url: string,
|
|
123
|
+
dest: string,
|
|
124
|
+
headers: Record<string, string>,
|
|
125
|
+
signal: AbortSignal,
|
|
126
|
+
log: FilesDeps['log'],
|
|
127
|
+
sleep: (ms: number) => Promise<void>,
|
|
128
|
+
): Promise<void> {
|
|
89
129
|
const attempts = 4;
|
|
90
130
|
for (let i = 1; ; i++) {
|
|
91
131
|
let status = 0;
|
|
@@ -93,46 +133,81 @@ async function download(url: string, dest: string, headers: Record<string, strin
|
|
|
93
133
|
const res = await fetch(url, { headers, signal });
|
|
94
134
|
status = res.status;
|
|
95
135
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
96
|
-
await Bun.write(dest +
|
|
97
|
-
renameSync(dest +
|
|
136
|
+
await Bun.write(dest + '.partial', res);
|
|
137
|
+
renameSync(dest + '.partial', dest);
|
|
98
138
|
return;
|
|
99
139
|
} catch (e) {
|
|
100
|
-
rmSync(dest +
|
|
140
|
+
rmSync(dest + '.partial', { force: true });
|
|
101
141
|
const transient = status === 0 || TRANSIENT(status);
|
|
102
|
-
if (signal.aborted || !transient || i >= attempts)
|
|
103
|
-
|
|
104
|
-
|
|
142
|
+
if (signal.aborted || !transient || i >= attempts)
|
|
143
|
+
throw new Error(`download of ${redactUrl(url)} failed: ${(e as Error).message}`);
|
|
144
|
+
log('download failed, retrying', {
|
|
145
|
+
url: redactUrl(url),
|
|
146
|
+
attempt: i,
|
|
147
|
+
error: (e as Error).message,
|
|
148
|
+
});
|
|
149
|
+
await sleep(1000 * 2 ** (i - 1));
|
|
105
150
|
}
|
|
106
151
|
}
|
|
107
152
|
}
|
|
108
153
|
|
|
109
154
|
export function createFetch(deps: FilesDeps) {
|
|
110
155
|
const cache = new Map<string, string>();
|
|
156
|
+
const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
111
157
|
return async (input: FileRef): Promise<string> => {
|
|
112
158
|
const parsed = fileRefSchema.safeParse(input);
|
|
113
|
-
if (!parsed.success)
|
|
159
|
+
if (!parsed.success)
|
|
160
|
+
throw new Error(
|
|
161
|
+
`ctx.files.fetch: not a valid file reference: ${JSON.stringify(input)}`,
|
|
162
|
+
);
|
|
114
163
|
const ref = parsed.data;
|
|
115
164
|
const key = fileKey(ref);
|
|
116
165
|
const hit = cache.get(key);
|
|
117
166
|
if (hit) return hit;
|
|
118
167
|
|
|
119
168
|
const obj = objectPath(ref.path);
|
|
120
|
-
const base = join(
|
|
121
|
-
|
|
169
|
+
const base = join(
|
|
170
|
+
deps.tmpDir,
|
|
171
|
+
'files',
|
|
172
|
+
String(ref.run),
|
|
173
|
+
ref.step,
|
|
174
|
+
String(ref.attempt),
|
|
175
|
+
);
|
|
176
|
+
const objFile = join(base, '.objects', obj);
|
|
122
177
|
mkdirSync(dirname(objFile), { recursive: true });
|
|
123
178
|
|
|
124
|
-
const own =
|
|
179
|
+
const own =
|
|
180
|
+
ref.run === deps.run.number &&
|
|
181
|
+
ref.step === deps.run.step &&
|
|
182
|
+
ref.attempt === deps.run.attempt;
|
|
125
183
|
const urls = urlMap(deps.root);
|
|
126
184
|
const localDir = process.env[ENV.filesDir];
|
|
127
185
|
const baseUrl = process.env[ENV.filesBaseUrl];
|
|
128
|
-
if (own && existsSync(join(deps.workFiles, obj)))
|
|
129
|
-
|
|
130
|
-
else if (
|
|
131
|
-
|
|
186
|
+
if (own && existsSync(join(deps.workFiles, obj)))
|
|
187
|
+
copyFileSync(join(deps.workFiles, obj), objFile);
|
|
188
|
+
else if (urls[key])
|
|
189
|
+
await download(urls[key], objFile, {}, deps.signal, deps.log, sleep);
|
|
190
|
+
else if (
|
|
191
|
+
localDir &&
|
|
192
|
+
existsSync(
|
|
193
|
+
join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
copyFileSync(
|
|
197
|
+
join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
|
|
198
|
+
objFile,
|
|
199
|
+
);
|
|
132
200
|
else if (baseUrl) {
|
|
133
201
|
const token = process.env[ENV.filesToken];
|
|
134
|
-
const url = `${baseUrl.replace(/\/+$/,
|
|
135
|
-
await download(
|
|
202
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/${ref.run}/${encodeURIComponent(ref.step)}/${ref.attempt}/${obj.split('/').map(encodeURIComponent).join('/')}`;
|
|
203
|
+
await download(
|
|
204
|
+
url,
|
|
205
|
+
objFile,
|
|
206
|
+
token ? { authorization: `Bearer ${token}` } : {},
|
|
207
|
+
deps.signal,
|
|
208
|
+
deps.log,
|
|
209
|
+
sleep,
|
|
210
|
+
);
|
|
136
211
|
} else {
|
|
137
212
|
throw new Error(
|
|
138
213
|
`ctx.files.fetch: no source for ${key} (set ${ENV.fileUrls} / $OLVE_DIR/files.json, ${ENV.filesBaseUrl} or ${ENV.filesDir})`,
|
|
@@ -142,10 +217,12 @@ export function createFetch(deps: FilesDeps) {
|
|
|
142
217
|
const actual = sha256File(objFile);
|
|
143
218
|
if (actual !== ref.sha256) {
|
|
144
219
|
rmSync(objFile, { force: true });
|
|
145
|
-
throw new Error(
|
|
220
|
+
throw new Error(
|
|
221
|
+
`ctx.files.fetch: ${key} has sha256 ${actual}, expected ${ref.sha256}`,
|
|
222
|
+
);
|
|
146
223
|
}
|
|
147
224
|
let result = objFile;
|
|
148
|
-
if (ref.path.endsWith(
|
|
225
|
+
if (ref.path.endsWith('/')) {
|
|
149
226
|
result = join(base, ref.path.slice(0, -1));
|
|
150
227
|
rmSync(result, { recursive: true, force: true });
|
|
151
228
|
mkdirSync(result, { recursive: true });
|
package/src/ctx/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync } from
|
|
2
|
-
import { resolve } from
|
|
3
|
-
import { skipped } from
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
1
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { skipped } from '../core/handlers.ts';
|
|
4
|
+
import { createLinks } from './links.ts';
|
|
5
|
+
import type { Secret } from '../core/secrets.ts';
|
|
6
|
+
import type { Cell } from '../core/matrix.ts';
|
|
7
|
+
import { createFetch, createOutput, redactUrl } from './files.ts';
|
|
8
|
+
import { createSh } from './sh.ts';
|
|
9
|
+
import type { Ctx, Http, RetryOptions, RunInfo } from './types.ts';
|
|
8
10
|
|
|
9
11
|
export interface CtxOptions {
|
|
10
12
|
run: RunInfo;
|
|
@@ -12,25 +14,42 @@ export interface CtxOptions {
|
|
|
12
14
|
secrets: readonly Secret[];
|
|
13
15
|
/** Secret values that take precedence over the environment (tests). */
|
|
14
16
|
secretValues?: Readonly<Record<string, string>>;
|
|
17
|
+
/** Parsed config entries of the step (`ctx.config`). */
|
|
18
|
+
config?: Readonly<Record<string, unknown>>;
|
|
15
19
|
paths: { root: string; workFiles: string; tmp: string };
|
|
16
20
|
signal: AbortSignal;
|
|
17
21
|
/** Where log lines go (default stderr). */
|
|
18
22
|
logSink?: (line: string) => void;
|
|
23
|
+
/** `<step>/<input>` → callback URL (`ctx.inputUrl`). */
|
|
24
|
+
callbackUrls?: Readonly<Record<string, string>>;
|
|
25
|
+
/** A matrix step's cell (`ctx.cell`). */
|
|
26
|
+
cell?: Cell;
|
|
27
|
+
/** Commands' output goes through this process (the harness masks secrets there). */
|
|
28
|
+
pipeOutput?: boolean;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
export function formatLog(message: string, fields?: Record<string, unknown>): string {
|
|
22
|
-
const parts = Object.entries(fields ?? {}).map(
|
|
23
|
-
|
|
32
|
+
const parts = Object.entries(fields ?? {}).map(
|
|
33
|
+
([k, v]) =>
|
|
34
|
+
`${k}=${typeof v === 'string' && !/\s|"/.test(v) ? v : JSON.stringify(v)}`,
|
|
35
|
+
);
|
|
36
|
+
return parts.length ? `${message} ${parts.join(' ')}` : message;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
export function createSecrets(
|
|
39
|
+
export function createSecrets(
|
|
40
|
+
declared: readonly Secret[],
|
|
41
|
+
values?: Readonly<Record<string, string>>,
|
|
42
|
+
): Record<string, string> {
|
|
27
43
|
const obj: Record<string, string> = {};
|
|
28
44
|
for (const s of declared) {
|
|
29
45
|
Object.defineProperty(obj, s.name, {
|
|
30
46
|
enumerable: true,
|
|
31
47
|
get() {
|
|
32
48
|
const v = values?.[s.name] ?? process.env[s.name];
|
|
33
|
-
if (v === undefined)
|
|
49
|
+
if (v === undefined)
|
|
50
|
+
throw new Error(
|
|
51
|
+
`secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`,
|
|
52
|
+
);
|
|
34
53
|
return v;
|
|
35
54
|
},
|
|
36
55
|
});
|
|
@@ -39,53 +58,81 @@ export function createSecrets(declared: readonly Secret[], values?: Readonly<Rec
|
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
async function httpError(res: Response, url: string): Promise<Error> {
|
|
42
|
-
let body =
|
|
61
|
+
let body = '';
|
|
43
62
|
try {
|
|
44
63
|
body = (await res.text()).slice(0, 500);
|
|
45
64
|
} catch {
|
|
46
65
|
/* ignore */
|
|
47
66
|
}
|
|
48
|
-
return new Error(
|
|
67
|
+
return new Error(
|
|
68
|
+
`HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ''}`,
|
|
69
|
+
);
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
export function createHttp(signal: AbortSignal): Http {
|
|
52
|
-
const f = (url: string, init?: RequestInit) =>
|
|
73
|
+
const f = (url: string, init?: RequestInit) =>
|
|
74
|
+
fetch(url, { ...init, signal: init?.signal ?? signal });
|
|
53
75
|
return {
|
|
54
76
|
fetch: f,
|
|
55
|
-
async getJson<T>(
|
|
56
|
-
|
|
77
|
+
async getJson<T>(
|
|
78
|
+
url: string,
|
|
79
|
+
init?: { headers?: Record<string, string> },
|
|
80
|
+
): Promise<T> {
|
|
81
|
+
const res = await f(url, {
|
|
82
|
+
headers: { accept: 'application/json', ...init?.headers },
|
|
83
|
+
});
|
|
57
84
|
if (!res.ok) throw await httpError(res, url);
|
|
58
85
|
return (await res.json()) as T;
|
|
59
86
|
},
|
|
60
|
-
async expectOk(
|
|
61
|
-
|
|
87
|
+
async expectOk(
|
|
88
|
+
url: string,
|
|
89
|
+
init?: { headers?: Record<string, string>; method?: string },
|
|
90
|
+
): Promise<Response> {
|
|
91
|
+
const res = await f(url, { method: init?.method ?? 'GET', headers: init?.headers });
|
|
62
92
|
if (!res.ok) throw await httpError(res, url);
|
|
63
93
|
return res;
|
|
64
94
|
},
|
|
65
95
|
};
|
|
66
96
|
}
|
|
67
97
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
98
|
+
/** Waits `ms`, or rejects at once when the step is cancelled. */
|
|
99
|
+
function cancellableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
100
|
+
return new Promise<void>((res, rej) => {
|
|
101
|
+
const t = setTimeout(() => {
|
|
102
|
+
signal.removeEventListener('abort', onAbort);
|
|
103
|
+
res();
|
|
104
|
+
}, ms);
|
|
105
|
+
const onAbort = () => {
|
|
106
|
+
clearTimeout(t);
|
|
107
|
+
rej(new Error('retry cancelled: the step was cancelled'));
|
|
108
|
+
};
|
|
109
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createRetry(
|
|
114
|
+
signal: AbortSignal,
|
|
115
|
+
log: (m: string, f?: Record<string, unknown>) => void,
|
|
116
|
+
sleep: (ms: number, signal: AbortSignal) => Promise<void> = cancellableSleep,
|
|
117
|
+
) {
|
|
118
|
+
return async <T>(
|
|
119
|
+
opts: RetryOptions,
|
|
120
|
+
fn: (attempt: number) => Promise<T>,
|
|
121
|
+
): Promise<T> => {
|
|
122
|
+
if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1))
|
|
123
|
+
throw new Error('retry: attempts must be a positive integer');
|
|
71
124
|
let delay = Math.max(0, opts.delaySeconds) * 1000;
|
|
72
125
|
for (let attempt = 1; ; attempt++) {
|
|
73
126
|
try {
|
|
74
127
|
return await fn(attempt);
|
|
75
128
|
} catch (e) {
|
|
76
129
|
if (attempt >= opts.attempts || signal.aborted) throw e;
|
|
77
|
-
log(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
res();
|
|
82
|
-
}, delay);
|
|
83
|
-
const onAbort = () => {
|
|
84
|
-
clearTimeout(t);
|
|
85
|
-
rej(new Error("retry cancelled: the step was cancelled"));
|
|
86
|
-
};
|
|
87
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
log('retrying', {
|
|
131
|
+
attempt,
|
|
132
|
+
of: opts.attempts,
|
|
133
|
+
error: (e as Error)?.message ?? String(e),
|
|
88
134
|
});
|
|
135
|
+
await sleep(delay, signal);
|
|
89
136
|
delay *= opts.backoff ?? 1;
|
|
90
137
|
}
|
|
91
138
|
}
|
|
@@ -96,16 +143,30 @@ export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<str
|
|
|
96
143
|
export function createCtx(o: CtxOptions): Ctx {
|
|
97
144
|
mkdirSync(o.paths.tmp, { recursive: true });
|
|
98
145
|
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>) =>
|
|
101
|
-
|
|
102
|
-
const
|
|
146
|
+
const sink = o.logSink ?? ((line: string) => process.stderr.write(line + '\n'));
|
|
147
|
+
const log = (message: string, fields?: Record<string, unknown>) =>
|
|
148
|
+
sink(formatLog(message, fields));
|
|
149
|
+
const { sh, exec } = createSh({
|
|
150
|
+
tmpDir: o.paths.tmp,
|
|
151
|
+
signal: o.signal,
|
|
152
|
+
pipeOutput: o.pipeOutput,
|
|
153
|
+
});
|
|
154
|
+
const filesDeps = {
|
|
155
|
+
run: o.run,
|
|
156
|
+
workFiles: o.paths.workFiles,
|
|
157
|
+
tmpDir: o.paths.tmp,
|
|
158
|
+
root: o.paths.root,
|
|
159
|
+
signal: o.signal,
|
|
160
|
+
log,
|
|
161
|
+
};
|
|
103
162
|
return {
|
|
104
|
-
|
|
163
|
+
...(o.cell ? { cell: o.cell } : {}),
|
|
164
|
+
secrets: createSecrets(o.secrets, o.secretValues) as Ctx['secrets'],
|
|
165
|
+
config: Object.freeze({ ...o.config }) as Ctx['config'],
|
|
105
166
|
sh,
|
|
106
167
|
exec,
|
|
107
|
-
readText: async (p) => readFileSync(resolve(p),
|
|
108
|
-
readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p),
|
|
168
|
+
readText: async (p) => readFileSync(resolve(p), 'utf8'),
|
|
169
|
+
readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), 'utf8')) as T,
|
|
109
170
|
files: { fetch: createFetch(filesDeps) },
|
|
110
171
|
output: createOutput(filesDeps),
|
|
111
172
|
http: createHttp(o.signal),
|
|
@@ -115,5 +176,15 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
115
176
|
run: Object.freeze({ ...o.run }),
|
|
116
177
|
tmpDir: o.paths.tmp,
|
|
117
178
|
skip: skipped,
|
|
179
|
+
link: createLinks().link,
|
|
180
|
+
inputUrl: (step, input) => {
|
|
181
|
+
const name = typeof step === 'string' ? step : step.name;
|
|
182
|
+
const url = o.callbackUrls?.[`${name}/${input}`];
|
|
183
|
+
if (!url)
|
|
184
|
+
throw new Error(
|
|
185
|
+
`no callback URL for input '${input}' of step '${name}': it must list callback() in from, and the instance needs a public base URL`,
|
|
186
|
+
);
|
|
187
|
+
return url;
|
|
188
|
+
},
|
|
118
189
|
};
|
|
119
190
|
}
|
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
|
+
}
|