@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/core/steps.ts
CHANGED
|
@@ -1,29 +1,46 @@
|
|
|
1
|
-
import type { z } from
|
|
2
|
-
import { STEP, type StepBrand } from
|
|
3
|
-
import
|
|
4
|
-
import type {
|
|
5
|
-
import type {
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import { ORIGIN, STEP, type StepBrand } from './brands.ts';
|
|
3
|
+
import { callerFile } from './origin.ts';
|
|
4
|
+
import type { AnyModel, Infer, Optional } from './models.ts';
|
|
5
|
+
import type { External } from './external.ts';
|
|
6
|
+
import type { AnyConfigEntry } from './config.ts';
|
|
7
|
+
import { configRefsIn } from './config-refs.ts';
|
|
8
|
+
import type { Secret } from './secrets.ts';
|
|
9
|
+
import type { Simplify } from './types.ts';
|
|
6
10
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
11
|
+
/**
|
|
12
|
+
* A step input entry: a model (matchable by model), `optional(model)`, a plain zod schema (mapper required),
|
|
13
|
+
* or `external(model, …)`, answered from outside the run.
|
|
14
|
+
*/
|
|
15
|
+
export type InputEntry = AnyModel | Optional | External | z.ZodType;
|
|
9
16
|
export type InputSpec = { readonly [key: string]: InputEntry };
|
|
10
17
|
|
|
11
18
|
export type InferEntry<E> =
|
|
12
|
-
E extends Optional<infer M>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
E extends Optional<infer M>
|
|
20
|
+
? Infer<M> | undefined
|
|
21
|
+
: E extends External<infer M>
|
|
22
|
+
? Infer<M>
|
|
23
|
+
: E extends AnyModel
|
|
24
|
+
? Infer<E>
|
|
25
|
+
: E extends z.ZodType
|
|
26
|
+
? z.output<E>
|
|
27
|
+
: never;
|
|
16
28
|
/** What a handler receives: the output view of every input entry. */
|
|
17
29
|
export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
|
|
18
30
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A label for what a step is, shown in the UI. `custom` (the default) is a step whose handler is in
|
|
33
|
+
* `handlers/`; a step that carries its implementation (`implement(step, …)` staged directly) names its
|
|
34
|
+
* kind, e.g. `gitHubSource`. Packages prefix theirs (`acme/deploy`).
|
|
35
|
+
*/
|
|
36
|
+
export type StepKind = string;
|
|
21
37
|
|
|
22
38
|
export interface Step<
|
|
23
39
|
N extends string = string,
|
|
24
40
|
I extends InputSpec = InputSpec,
|
|
25
41
|
O extends AnyModel | null = AnyModel | null,
|
|
26
42
|
Sec extends string = string,
|
|
43
|
+
Cfg extends AnyConfigEntry = never,
|
|
27
44
|
> {
|
|
28
45
|
readonly [StepBrand]: true;
|
|
29
46
|
readonly kind: StepKind;
|
|
@@ -33,35 +50,41 @@ export interface Step<
|
|
|
33
50
|
readonly input: I;
|
|
34
51
|
readonly output: O;
|
|
35
52
|
readonly secrets: readonly Secret<Sec>[];
|
|
53
|
+
/** Config entries the step receives as `ctx.config`. */
|
|
54
|
+
readonly config: readonly Cfg[];
|
|
36
55
|
readonly timeoutSeconds?: number;
|
|
37
56
|
readonly retries?: number;
|
|
38
|
-
/**
|
|
39
|
-
readonly
|
|
57
|
+
/** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
|
|
58
|
+
readonly clusterAccess?: boolean;
|
|
59
|
+
/** How the step is set up, as written in the definition (may hold config references). */
|
|
60
|
+
readonly settings?: Readonly<Record<string, unknown>>;
|
|
40
61
|
}
|
|
41
|
-
/** Runtime part of a library step (not part of the public contract). */
|
|
42
|
-
export interface LibraryImplementation {
|
|
43
|
-
/** Config as emitted in the manifest (JSON only; must not contain paths). */
|
|
44
|
-
readonly manifestConfig: () => Record<string, unknown>;
|
|
45
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
-
readonly run: (input: any, ctx: any) => Promise<unknown>;
|
|
47
|
-
}
|
|
48
|
-
export type LibraryStep = { readonly builtin: LibraryImplementation };
|
|
49
62
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
-
export type AnyStep = Step<string, any, AnyModel | null, any>;
|
|
63
|
+
export type AnyStep = Step<string, any, AnyModel | null, any, any>;
|
|
51
64
|
|
|
52
65
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
-
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec> ? Sec : never;
|
|
54
|
-
|
|
66
|
+
export type SecretsOf<S> = S extends Step<any, any, any, infer Sec, any> ? Sec : never;
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
export type ConfigOf<S> = S extends Step<any, any, any, any, infer C> ? C : never;
|
|
69
|
+
export type OutputValue<S extends AnyStep> = S['output'] extends AnyModel
|
|
70
|
+
? Infer<S['output']>
|
|
71
|
+
: void;
|
|
55
72
|
|
|
56
73
|
/** Step names must be literals (identity, duplicate detection and mapper keys depend on it). */
|
|
57
|
-
export type LiteralName<N extends string> = N &
|
|
74
|
+
export type LiteralName<N extends string> = N &
|
|
75
|
+
(string extends N ? '❌ step name must be a string literal' : unknown);
|
|
58
76
|
|
|
59
|
-
|
|
60
|
-
export const STEP_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
77
|
+
export { STEP_NAME } from './models.ts';
|
|
61
78
|
/** Names that cannot be steps: `input` is the pipeline input's root in mappings. */
|
|
62
|
-
export const RESERVED_STEP_NAMES: readonly string[] = [
|
|
79
|
+
export const RESERVED_STEP_NAMES: readonly string[] = ['input'];
|
|
63
80
|
|
|
64
|
-
export interface StepDef<
|
|
81
|
+
export interface StepDef<
|
|
82
|
+
N extends string,
|
|
83
|
+
I extends InputSpec,
|
|
84
|
+
O extends AnyModel | null,
|
|
85
|
+
Sec extends readonly Secret[],
|
|
86
|
+
Cfg extends readonly AnyConfigEntry[] = [],
|
|
87
|
+
> {
|
|
65
88
|
name: LiteralName<N>;
|
|
66
89
|
/** What the step does, shown in the UI. Required; may be empty. */
|
|
67
90
|
description: string;
|
|
@@ -70,38 +93,66 @@ export interface StepDef<N extends string, I extends InputSpec, O extends AnyMod
|
|
|
70
93
|
input?: I;
|
|
71
94
|
output?: O;
|
|
72
95
|
secrets?: Sec;
|
|
96
|
+
/** Config entries delivered as `ctx.config` (typed to exactly these). */
|
|
97
|
+
config?: Cfg;
|
|
73
98
|
timeoutSeconds?: number;
|
|
74
99
|
/** Automatic retries (an idempotency claim). Default 0. */
|
|
75
100
|
retries?: number;
|
|
101
|
+
/**
|
|
102
|
+
* Ask for a Kubernetes API token in the step pod: the instance's step ServiceAccount
|
|
103
|
+
* (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
|
|
104
|
+
*/
|
|
105
|
+
clusterAccess?: boolean;
|
|
106
|
+
/** A label for the step's kind (default `custom`). */
|
|
107
|
+
kind?: StepKind;
|
|
108
|
+
/**
|
|
109
|
+
* How the step is set up, shown in the manifest and part of the step's identity: the parameters of
|
|
110
|
+
* a reusable step. JSON values and config references (`target.prod.host`); every config entry it
|
|
111
|
+
* references is delivered to the step, and `resolveConfigRefs(value, ctx.config)` resolves them.
|
|
112
|
+
*/
|
|
113
|
+
settings?: Readonly<Record<string, unknown>>;
|
|
76
114
|
}
|
|
77
115
|
|
|
78
|
-
|
|
116
|
+
function makeStep(def: Record<string, unknown>, origin: string | undefined): AnyStep {
|
|
117
|
+
const config = new Map<string, AnyConfigEntry>();
|
|
118
|
+
for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? [])
|
|
119
|
+
if (!config.has(e.name)) config.set(e.name, e);
|
|
120
|
+
configRefsIn(def.settings, config);
|
|
79
121
|
const s = {
|
|
80
122
|
[STEP]: true,
|
|
81
|
-
kind,
|
|
123
|
+
kind: def.kind ?? 'custom',
|
|
82
124
|
name: def.name,
|
|
83
125
|
description: def.description,
|
|
84
126
|
image: def.image,
|
|
85
127
|
input: Object.freeze({ ...((def.input as object | undefined) ?? {}) }),
|
|
86
128
|
output: def.output ?? null,
|
|
87
129
|
secrets: Object.freeze([...((def.secrets as readonly Secret[] | undefined) ?? [])]),
|
|
130
|
+
config: Object.freeze([...config.values()]),
|
|
88
131
|
...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
|
|
89
132
|
...(def.retries !== undefined ? { retries: def.retries } : {}),
|
|
90
|
-
...(
|
|
133
|
+
...(def.clusterAccess === true ? { clusterAccess: true } : {}),
|
|
134
|
+
...(def.settings !== undefined ? { settings: def.settings } : {}),
|
|
135
|
+
[ORIGIN]: origin === undefined ? [] : [origin],
|
|
91
136
|
};
|
|
92
137
|
return Object.freeze(s) as unknown as AnyStep;
|
|
93
138
|
}
|
|
94
139
|
|
|
95
140
|
export function isStep(v: unknown): v is AnyStep {
|
|
96
|
-
return
|
|
141
|
+
return (
|
|
142
|
+
typeof v === 'object' && v !== null && (v as Record<symbol, unknown>)[STEP] === true
|
|
143
|
+
);
|
|
97
144
|
}
|
|
98
145
|
|
|
99
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Define a step: a contract (name, image, inputs, output, secrets). Its handler is a `handlers/*.ts`
|
|
148
|
+
* default-exporting `implement(step, …)`, or, for a reusable step, `implement(step, …)` staged directly.
|
|
149
|
+
*/
|
|
100
150
|
export function step<
|
|
101
151
|
const N extends string,
|
|
102
152
|
const I extends InputSpec = {},
|
|
103
153
|
O extends AnyModel | null = null,
|
|
104
154
|
const Sec extends readonly Secret[] = [],
|
|
105
|
-
|
|
106
|
-
|
|
155
|
+
const Cfg extends readonly AnyConfigEntry[] = [],
|
|
156
|
+
>(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]['name'], Cfg[number]> {
|
|
157
|
+
return makeStep(def as never, callerFile()) as never;
|
|
107
158
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export type Primitive = string | number | boolean | bigint | null | undefined;
|
|
2
2
|
export type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
3
3
|
export type Awaitable<T> = T | Promise<T>;
|
|
4
|
-
export type IsUnion<T, U = T> = T extends unknown
|
|
4
|
+
export type IsUnion<T, U = T> = T extends unknown
|
|
5
|
+
? [U] extends [T]
|
|
6
|
+
? false
|
|
7
|
+
: true
|
|
8
|
+
: never;
|
|
5
9
|
/** `Ok` when there is no error, else the error (a readable string-literal type). */
|
|
6
10
|
export type OrError<Err, Ok> = [Err] extends [never] ? Ok : Err;
|
package/src/core/webhooks.ts
CHANGED
|
@@ -1,23 +1,37 @@
|
|
|
1
|
-
import type { Secret } from
|
|
1
|
+
import type { Secret } from './secrets.ts';
|
|
2
2
|
|
|
3
3
|
/** How a webhook trigger authenticates deliveries. Build one with `hmacSha256`, `githubSignature` or `bearer`. */
|
|
4
4
|
export type WebhookVerify =
|
|
5
|
-
| {
|
|
6
|
-
|
|
5
|
+
| {
|
|
6
|
+
readonly scheme: 'hmac-sha256';
|
|
7
|
+
readonly secret: Secret;
|
|
8
|
+
readonly header: string;
|
|
9
|
+
readonly prefix: string;
|
|
10
|
+
}
|
|
11
|
+
| { readonly scheme: 'bearer'; readonly secret: Secret };
|
|
7
12
|
|
|
8
13
|
/** HMAC-SHA256 of the raw body keyed by `secret`, hex, in `header` after `prefix`. */
|
|
9
|
-
export function hmacSha256(o: {
|
|
10
|
-
|
|
14
|
+
export function hmacSha256(o: {
|
|
15
|
+
secret: Secret;
|
|
16
|
+
header: string;
|
|
17
|
+
prefix?: string;
|
|
18
|
+
}): WebhookVerify {
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
scheme: 'hmac-sha256',
|
|
21
|
+
secret: o.secret,
|
|
22
|
+
header: o.header,
|
|
23
|
+
prefix: o.prefix ?? '',
|
|
24
|
+
});
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
/** GitHub's delivery signature: `X-Hub-Signature-256: sha256=<hex>`. */
|
|
14
28
|
export function githubSignature(secret: Secret): WebhookVerify {
|
|
15
|
-
return hmacSha256({ secret, header:
|
|
29
|
+
return hmacSha256({ secret, header: 'X-Hub-Signature-256', prefix: 'sha256=' });
|
|
16
30
|
}
|
|
17
31
|
|
|
18
32
|
/** `Authorization: Bearer <secret>`. */
|
|
19
33
|
export function bearer(secret: Secret): WebhookVerify {
|
|
20
|
-
return Object.freeze({ scheme:
|
|
34
|
+
return Object.freeze({ scheme: 'bearer', secret });
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
/**
|
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;
|
|
@@ -28,42 +40,54 @@ export interface FilesDeps {
|
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
function checkName(name: string, what: string): void {
|
|
31
|
-
if (!FILE_PATH.test(name) || name.endsWith(
|
|
43
|
+
if (!FILE_PATH.test(name) || name.endsWith('/'))
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${what}: '${name}' is not a valid relative file name (no leading '/', no '..', no trailing '/')`,
|
|
46
|
+
);
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
export function createOutput(deps: FilesDeps) {
|
|
35
50
|
const used = new Set<string>();
|
|
36
51
|
const claim = (obj: string) => {
|
|
37
|
-
if (used.has(obj) || existsSync(join(deps.workFiles, obj)))
|
|
52
|
+
if (used.has(obj) || existsSync(join(deps.workFiles, obj)))
|
|
53
|
+
throw new Error(`output file '${obj}' already exists`);
|
|
38
54
|
used.add(obj);
|
|
39
55
|
const dest = join(deps.workFiles, obj);
|
|
40
56
|
mkdirSync(dirname(dest), { recursive: true });
|
|
41
57
|
return dest;
|
|
42
58
|
};
|
|
43
59
|
const ref = (path: string, dest: string): FileRef =>
|
|
44
|
-
({
|
|
60
|
+
({
|
|
61
|
+
run: deps.run.number,
|
|
62
|
+
step: deps.run.step,
|
|
63
|
+
attempt: deps.run.attempt,
|
|
64
|
+
path,
|
|
65
|
+
sha256: sha256File(dest),
|
|
66
|
+
}) as FileRef;
|
|
45
67
|
|
|
46
68
|
return {
|
|
47
69
|
async file(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
|
|
48
70
|
const src = resolve(path);
|
|
49
|
-
if (!statSync(src).isFile())
|
|
71
|
+
if (!statSync(src).isFile())
|
|
72
|
+
throw new Error(`ctx.output.file: '${path}' is not a file`);
|
|
50
73
|
const name = opts.name ?? basename(src);
|
|
51
|
-
checkName(name,
|
|
74
|
+
checkName(name, 'ctx.output.file');
|
|
52
75
|
const dest = claim(name);
|
|
53
76
|
copyFileSync(src, dest);
|
|
54
77
|
return ref(name, dest);
|
|
55
78
|
},
|
|
56
79
|
async dir(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
|
|
57
80
|
const src = resolve(path);
|
|
58
|
-
if (!statSync(src).isDirectory())
|
|
81
|
+
if (!statSync(src).isDirectory())
|
|
82
|
+
throw new Error(`ctx.output.dir: '${path}' is not a directory`);
|
|
59
83
|
const name = opts.name ?? basename(src);
|
|
60
|
-
checkName(name,
|
|
61
|
-
const dest = claim(objectPath(name +
|
|
84
|
+
checkName(name, 'ctx.output.dir');
|
|
85
|
+
const dest = claim(objectPath(name + '/'));
|
|
62
86
|
writeTar(dest, dirEntries(src));
|
|
63
|
-
return ref(name +
|
|
87
|
+
return ref(name + '/', dest);
|
|
64
88
|
},
|
|
65
89
|
async write(name: string, content: string | Uint8Array): Promise<FileRef> {
|
|
66
|
-
checkName(name,
|
|
90
|
+
checkName(name, 'ctx.output.write');
|
|
67
91
|
const dest = claim(name);
|
|
68
92
|
writeFileSync(dest, content);
|
|
69
93
|
return ref(name, dest);
|
|
@@ -72,20 +96,33 @@ export function createOutput(deps: FilesDeps) {
|
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
function urlMap(root: string): Record<string, string> {
|
|
75
|
-
const raw =
|
|
99
|
+
const raw =
|
|
100
|
+
process.env[ENV.fileUrls] ??
|
|
101
|
+
(existsSync(join(root, 'files.json'))
|
|
102
|
+
? readFileSync(join(root, 'files.json'), 'utf8')
|
|
103
|
+
: undefined);
|
|
76
104
|
if (!raw) return {};
|
|
77
105
|
try {
|
|
78
106
|
const m = JSON.parse(raw) as unknown;
|
|
79
|
-
if (typeof m !==
|
|
107
|
+
if (typeof m !== 'object' || m === null || Array.isArray(m))
|
|
108
|
+
throw new Error('not an object');
|
|
80
109
|
return m as Record<string, string>;
|
|
81
110
|
} catch (e) {
|
|
82
|
-
throw new Error(
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${ENV.fileUrls} / files.json is not a JSON object of file key → URL (${(e as Error).message})`,
|
|
113
|
+
);
|
|
83
114
|
}
|
|
84
115
|
}
|
|
85
116
|
|
|
86
117
|
const TRANSIENT = (status: number) => status >= 500 || status === 408 || status === 429;
|
|
87
118
|
|
|
88
|
-
async function download(
|
|
119
|
+
async function download(
|
|
120
|
+
url: string,
|
|
121
|
+
dest: string,
|
|
122
|
+
headers: Record<string, string>,
|
|
123
|
+
signal: AbortSignal,
|
|
124
|
+
log: FilesDeps['log'],
|
|
125
|
+
): Promise<void> {
|
|
89
126
|
const attempts = 4;
|
|
90
127
|
for (let i = 1; ; i++) {
|
|
91
128
|
let status = 0;
|
|
@@ -93,14 +130,19 @@ async function download(url: string, dest: string, headers: Record<string, strin
|
|
|
93
130
|
const res = await fetch(url, { headers, signal });
|
|
94
131
|
status = res.status;
|
|
95
132
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
96
|
-
await Bun.write(dest +
|
|
97
|
-
renameSync(dest +
|
|
133
|
+
await Bun.write(dest + '.partial', res);
|
|
134
|
+
renameSync(dest + '.partial', dest);
|
|
98
135
|
return;
|
|
99
136
|
} catch (e) {
|
|
100
|
-
rmSync(dest +
|
|
137
|
+
rmSync(dest + '.partial', { force: true });
|
|
101
138
|
const transient = status === 0 || TRANSIENT(status);
|
|
102
|
-
if (signal.aborted || !transient || i >= attempts)
|
|
103
|
-
|
|
139
|
+
if (signal.aborted || !transient || i >= attempts)
|
|
140
|
+
throw new Error(`download of ${redactUrl(url)} failed: ${(e as Error).message}`);
|
|
141
|
+
log('download failed, retrying', {
|
|
142
|
+
url: redactUrl(url),
|
|
143
|
+
attempt: i,
|
|
144
|
+
error: (e as Error).message,
|
|
145
|
+
});
|
|
104
146
|
await Bun.sleep(1000 * 2 ** (i - 1));
|
|
105
147
|
}
|
|
106
148
|
}
|
|
@@ -110,29 +152,56 @@ export function createFetch(deps: FilesDeps) {
|
|
|
110
152
|
const cache = new Map<string, string>();
|
|
111
153
|
return async (input: FileRef): Promise<string> => {
|
|
112
154
|
const parsed = fileRefSchema.safeParse(input);
|
|
113
|
-
if (!parsed.success)
|
|
155
|
+
if (!parsed.success)
|
|
156
|
+
throw new Error(
|
|
157
|
+
`ctx.files.fetch: not a valid file reference: ${JSON.stringify(input)}`,
|
|
158
|
+
);
|
|
114
159
|
const ref = parsed.data;
|
|
115
160
|
const key = fileKey(ref);
|
|
116
161
|
const hit = cache.get(key);
|
|
117
162
|
if (hit) return hit;
|
|
118
163
|
|
|
119
164
|
const obj = objectPath(ref.path);
|
|
120
|
-
const base = join(
|
|
121
|
-
|
|
165
|
+
const base = join(
|
|
166
|
+
deps.tmpDir,
|
|
167
|
+
'files',
|
|
168
|
+
String(ref.run),
|
|
169
|
+
ref.step,
|
|
170
|
+
String(ref.attempt),
|
|
171
|
+
);
|
|
172
|
+
const objFile = join(base, '.objects', obj);
|
|
122
173
|
mkdirSync(dirname(objFile), { recursive: true });
|
|
123
174
|
|
|
124
|
-
const own =
|
|
175
|
+
const own =
|
|
176
|
+
ref.run === deps.run.number &&
|
|
177
|
+
ref.step === deps.run.step &&
|
|
178
|
+
ref.attempt === deps.run.attempt;
|
|
125
179
|
const urls = urlMap(deps.root);
|
|
126
180
|
const localDir = process.env[ENV.filesDir];
|
|
127
181
|
const baseUrl = process.env[ENV.filesBaseUrl];
|
|
128
|
-
if (own && existsSync(join(deps.workFiles, obj)))
|
|
182
|
+
if (own && existsSync(join(deps.workFiles, obj)))
|
|
183
|
+
copyFileSync(join(deps.workFiles, obj), objFile);
|
|
129
184
|
else if (urls[key]) await download(urls[key], objFile, {}, deps.signal, deps.log);
|
|
130
|
-
else if (
|
|
131
|
-
|
|
185
|
+
else if (
|
|
186
|
+
localDir &&
|
|
187
|
+
existsSync(
|
|
188
|
+
join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
copyFileSync(
|
|
192
|
+
join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
|
|
193
|
+
objFile,
|
|
194
|
+
);
|
|
132
195
|
else if (baseUrl) {
|
|
133
196
|
const token = process.env[ENV.filesToken];
|
|
134
|
-
const url = `${baseUrl.replace(/\/+$/,
|
|
135
|
-
await download(
|
|
197
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/${ref.run}/${encodeURIComponent(ref.step)}/${ref.attempt}/${obj.split('/').map(encodeURIComponent).join('/')}`;
|
|
198
|
+
await download(
|
|
199
|
+
url,
|
|
200
|
+
objFile,
|
|
201
|
+
token ? { authorization: `Bearer ${token}` } : {},
|
|
202
|
+
deps.signal,
|
|
203
|
+
deps.log,
|
|
204
|
+
);
|
|
136
205
|
} else {
|
|
137
206
|
throw new Error(
|
|
138
207
|
`ctx.files.fetch: no source for ${key} (set ${ENV.fileUrls} / $OLVE_DIR/files.json, ${ENV.filesBaseUrl} or ${ENV.filesDir})`,
|
|
@@ -142,10 +211,12 @@ export function createFetch(deps: FilesDeps) {
|
|
|
142
211
|
const actual = sha256File(objFile);
|
|
143
212
|
if (actual !== ref.sha256) {
|
|
144
213
|
rmSync(objFile, { force: true });
|
|
145
|
-
throw new Error(
|
|
214
|
+
throw new Error(
|
|
215
|
+
`ctx.files.fetch: ${key} has sha256 ${actual}, expected ${ref.sha256}`,
|
|
216
|
+
);
|
|
146
217
|
}
|
|
147
218
|
let result = objFile;
|
|
148
|
-
if (ref.path.endsWith(
|
|
219
|
+
if (ref.path.endsWith('/')) {
|
|
149
220
|
result = join(base, ref.path.slice(0, -1));
|
|
150
221
|
rmSync(result, { recursive: true, force: true });
|
|
151
222
|
mkdirSync(result, { recursive: true });
|