@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/harness/result.ts
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
// Writing, printing and uploading the result of `run` / `map-trigger`.
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { canonicalJson } from '../util/canonical.ts';
|
|
12
|
+
import { dirEntries, writeTarGz, type TarEntry } from '../util/tar.ts';
|
|
13
|
+
import { redactUrl } from '../ctx/files.ts';
|
|
14
|
+
import type { Envelope } from './execute.ts';
|
|
8
15
|
|
|
9
|
-
export const RESULT_BEGIN =
|
|
10
|
-
export const RESULT_END =
|
|
11
|
-
export const MANIFEST_BEGIN =
|
|
12
|
-
export const MANIFEST_END =
|
|
16
|
+
export const RESULT_BEGIN = '---OLVE-RESULT-BEGIN---';
|
|
17
|
+
export const RESULT_END = '---OLVE-RESULT-END---';
|
|
18
|
+
export const MANIFEST_BEGIN = '---OLVE-MANIFEST-BEGIN---';
|
|
19
|
+
export const MANIFEST_END = '---OLVE-MANIFEST-END---';
|
|
13
20
|
|
|
14
21
|
/** Atomically writes the envelope to `<work>/output.json`. */
|
|
15
22
|
export function writeEnvelope(work: string, envelope: Envelope): string {
|
|
16
23
|
mkdirSync(work, { recursive: true });
|
|
17
|
-
const path = join(work,
|
|
18
|
-
writeFileSync(path +
|
|
19
|
-
renameSync(path +
|
|
24
|
+
const path = join(work, 'output.json');
|
|
25
|
+
writeFileSync(path + '.partial', canonicalJson(envelope));
|
|
26
|
+
renameSync(path + '.partial', path);
|
|
20
27
|
return path;
|
|
21
28
|
}
|
|
22
29
|
|
|
@@ -27,9 +34,15 @@ export function sentinelBlock(envelope: Envelope): string {
|
|
|
27
34
|
|
|
28
35
|
/** Packs output.json + files/ into result.tar.gz (deterministic). */
|
|
29
36
|
export async function packResult(work: string, out: string): Promise<void> {
|
|
30
|
-
const entries: TarEntry[] = [
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
const entries: TarEntry[] = [
|
|
38
|
+
{ path: 'output.json', type: 'file', mode: 0o644, source: join(work, 'output.json') },
|
|
39
|
+
];
|
|
40
|
+
const files = join(work, 'files');
|
|
41
|
+
if (existsSync(files))
|
|
42
|
+
entries.push(
|
|
43
|
+
{ path: 'files', type: 'dir', mode: 0o755 },
|
|
44
|
+
...dirEntries(files, 'files'),
|
|
45
|
+
);
|
|
33
46
|
await writeTarGz(out, entries);
|
|
34
47
|
}
|
|
35
48
|
|
|
@@ -40,15 +53,20 @@ export async function uploadFile(
|
|
|
40
53
|
url: string,
|
|
41
54
|
file: string,
|
|
42
55
|
log: (line: string) => void,
|
|
43
|
-
opts: {
|
|
56
|
+
opts: {
|
|
57
|
+
attempts?: number;
|
|
58
|
+
baseDelayMs?: number;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
} = {},
|
|
44
61
|
): Promise<void> {
|
|
45
62
|
const attempts = opts.attempts ?? 5;
|
|
46
63
|
const base = opts.baseDelayMs ?? 1000;
|
|
64
|
+
const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
47
65
|
for (let i = 1; ; i++) {
|
|
48
66
|
let status = 0;
|
|
49
67
|
let detail: string;
|
|
50
68
|
try {
|
|
51
|
-
const res = await fetch(url, { method:
|
|
69
|
+
const res = await fetch(url, { method: 'PUT', body: Bun.file(file) });
|
|
52
70
|
status = res.status;
|
|
53
71
|
if (res.ok) return;
|
|
54
72
|
detail = `HTTP ${res.status} ${res.statusText}`;
|
|
@@ -57,9 +75,10 @@ export async function uploadFile(
|
|
|
57
75
|
detail = (e as Error).message;
|
|
58
76
|
}
|
|
59
77
|
const transient = status === 0 || TRANSIENT(status);
|
|
60
|
-
if (!transient || i >= attempts)
|
|
78
|
+
if (!transient || i >= attempts)
|
|
79
|
+
throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
|
|
61
80
|
log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
|
|
62
|
-
await
|
|
81
|
+
await sleep(base * 2 ** (i - 1));
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
84
|
|
|
@@ -68,8 +87,10 @@ export async function uploadFile(
|
|
|
68
87
|
* *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
|
|
69
88
|
*/
|
|
70
89
|
export function removeStale(work: string): void {
|
|
71
|
-
rmSync(join(work,
|
|
72
|
-
rmSync(join(work,
|
|
73
|
-
const files = join(work,
|
|
74
|
-
if (existsSync(files))
|
|
90
|
+
rmSync(join(work, 'output.json'), { force: true });
|
|
91
|
+
rmSync(join(work, 'output.json.partial'), { force: true });
|
|
92
|
+
const files = join(work, 'files');
|
|
93
|
+
if (existsSync(files))
|
|
94
|
+
for (const e of readdirSync(files))
|
|
95
|
+
rmSync(join(files, e), { recursive: true, force: true });
|
|
75
96
|
}
|
package/src/harness/validate.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import { isModel, isOptional } from
|
|
3
|
-
import
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isModel, isOptional } from '../core/models.ts';
|
|
3
|
+
import { isExternal } from '../core/external.ts';
|
|
4
|
+
import { isCells } from '../core/matrix.ts';
|
|
5
|
+
import type { AnyStep } from '../core/steps.ts';
|
|
4
6
|
|
|
5
7
|
export type Validation<T> = { ok: true; value: T } | { ok: false; issues: string[] };
|
|
6
8
|
|
|
7
9
|
export function formatIssues(err: z.ZodError, prefix: string): string[] {
|
|
8
|
-
return err.issues.map(
|
|
10
|
+
return err.issues.map(
|
|
11
|
+
(i) =>
|
|
12
|
+
`${prefix}${i.path.length ? '.' + i.path.map(String).join('.') : ''}: ${i.message}`,
|
|
13
|
+
);
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
12
|
-
typeof v ===
|
|
17
|
+
typeof v === 'object' &&
|
|
18
|
+
v !== null &&
|
|
19
|
+
!Array.isArray(v) &&
|
|
20
|
+
(Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
13
21
|
|
|
14
22
|
/**
|
|
15
23
|
* zod strips unknown keys silently (at every depth). Compares the raw value with the parsed one:
|
|
16
24
|
* any key present in a raw object but absent from the parsed object at the same place was stripped.
|
|
17
25
|
*/
|
|
18
|
-
export function strippedKeys(raw: unknown, parsed: unknown, path =
|
|
19
|
-
if (Array.isArray(raw) && Array.isArray(parsed))
|
|
26
|
+
export function strippedKeys(raw: unknown, parsed: unknown, path = ''): string[] {
|
|
27
|
+
if (Array.isArray(raw) && Array.isArray(parsed))
|
|
28
|
+
return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
|
|
20
29
|
if (isPlainObject(raw) && isPlainObject(parsed)) {
|
|
21
30
|
const out: string[] = [];
|
|
22
31
|
for (const k of Object.keys(raw)) {
|
|
@@ -30,27 +39,80 @@ export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[]
|
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/** Strict parse: zod validation plus rejection of unknown keys at any depth. */
|
|
33
|
-
export function parseStrict(
|
|
42
|
+
export function parseStrict(
|
|
43
|
+
schema: z.ZodType,
|
|
44
|
+
value: unknown,
|
|
45
|
+
prefix: string,
|
|
46
|
+
): Validation<unknown> {
|
|
34
47
|
const r = schema.safeParse(value);
|
|
35
48
|
if (!r.success) return { ok: false, issues: formatIssues(r.error, prefix) };
|
|
36
49
|
const extra = strippedKeys(value, r.data);
|
|
37
|
-
if (extra.length)
|
|
50
|
+
if (extra.length)
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
issues: extra.map(
|
|
54
|
+
(k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`,
|
|
55
|
+
),
|
|
56
|
+
};
|
|
38
57
|
return { ok: true, value: r.data };
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
function entrySchema(e: unknown): z.ZodType {
|
|
42
|
-
if (
|
|
61
|
+
if (isCells(e))
|
|
62
|
+
return z.array(
|
|
63
|
+
z.object({ cell: z.record(z.string(), z.string()), output: e.model.schema }),
|
|
64
|
+
);
|
|
65
|
+
if (isOptional(e) || isExternal(e)) return e.model.schema;
|
|
43
66
|
if (isModel(e)) return e.schema;
|
|
44
67
|
return e as z.ZodType;
|
|
45
68
|
}
|
|
46
69
|
|
|
70
|
+
/** Reserved input.json key holding the step's config entries (written by the service). */
|
|
71
|
+
export const CONFIG_KEY = '$config';
|
|
72
|
+
|
|
73
|
+
/** Parses `$config` with each declared entry's schema (defaults applied). Undeclared entries are ignored. */
|
|
74
|
+
export function validateConfig(
|
|
75
|
+
step: AnyStep,
|
|
76
|
+
raw: unknown,
|
|
77
|
+
): Validation<Record<string, unknown>> {
|
|
78
|
+
const given = isPlainObject(raw) ? raw[CONFIG_KEY] : undefined;
|
|
79
|
+
if (given !== undefined && !isPlainObject(given))
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`],
|
|
83
|
+
};
|
|
84
|
+
const issues: string[] = [];
|
|
85
|
+
const out: Record<string, unknown> = {};
|
|
86
|
+
for (const e of step.config ?? []) {
|
|
87
|
+
const value = given?.[e.name];
|
|
88
|
+
const r = e.schema.safeParse(value);
|
|
89
|
+
if (!r.success)
|
|
90
|
+
issues.push(
|
|
91
|
+
...formatIssues(r.error, `config.${e.name}`).map((i) =>
|
|
92
|
+
value === undefined ? `${i} (config entry '${e.name}' is not set)` : i,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
else if (r.data !== undefined) out[e.name] = r.data;
|
|
96
|
+
}
|
|
97
|
+
return issues.length ? { ok: false, issues } : { ok: true, value: out };
|
|
98
|
+
}
|
|
99
|
+
|
|
47
100
|
/** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
|
|
48
|
-
export function validateInput(
|
|
49
|
-
|
|
101
|
+
export function validateInput(
|
|
102
|
+
step: AnyStep,
|
|
103
|
+
raw: unknown,
|
|
104
|
+
): Validation<Record<string, unknown>> {
|
|
105
|
+
if (!isPlainObject(raw))
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
issues: ["input.json must be a JSON object keyed by the step's input names"],
|
|
109
|
+
};
|
|
50
110
|
const issues: string[] = [];
|
|
51
111
|
const out: Record<string, unknown> = {};
|
|
52
112
|
const declared = step.input as Record<string, unknown>;
|
|
53
|
-
for (const k of Object.keys(raw))
|
|
113
|
+
for (const k of Object.keys(raw))
|
|
114
|
+
if (k !== CONFIG_KEY && !Object.hasOwn(declared, k))
|
|
115
|
+
issues.push(`input.${k}: not an input of step '${step.name}'`);
|
|
54
116
|
for (const [k, e] of Object.entries(declared)) {
|
|
55
117
|
if (!Object.hasOwn(raw, k)) {
|
|
56
118
|
if (isOptional(e)) continue;
|
|
@@ -68,7 +130,12 @@ export function validateInput(step: AnyStep, raw: unknown): Validation<Record<st
|
|
|
68
130
|
export function validateOutput(step: AnyStep, value: unknown): Validation<unknown> {
|
|
69
131
|
if (!step.output) {
|
|
70
132
|
if (value === undefined || value === null) return { ok: true, value: null };
|
|
71
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
issues: [
|
|
136
|
+
`step '${step.name}' declares no output model but the handler returned a value`,
|
|
137
|
+
],
|
|
138
|
+
};
|
|
72
139
|
}
|
|
73
|
-
return parseStrict(step.output.schema, value,
|
|
140
|
+
return parseStrict(step.output.schema, value, 'output');
|
|
74
141
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,141 @@
|
|
|
1
1
|
/// <reference path="./assets.d.ts" />
|
|
2
2
|
// @olivervea/pipelines — authoring API (contracts, handlers, pipeline builder).
|
|
3
|
-
export { z } from
|
|
4
|
-
export {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
export { z } from 'zod';
|
|
4
|
+
export {
|
|
5
|
+
model,
|
|
6
|
+
fileRef,
|
|
7
|
+
optional,
|
|
8
|
+
type Model,
|
|
9
|
+
type AnyModel,
|
|
10
|
+
type Infer,
|
|
11
|
+
type InferInput,
|
|
12
|
+
type Plain,
|
|
13
|
+
type FileRef,
|
|
14
|
+
type Optional,
|
|
15
|
+
} from './core/models.ts';
|
|
16
|
+
export { secret, type Secret, type DeclaredSecrets } from './core/secrets.ts';
|
|
17
|
+
export {
|
|
18
|
+
cells,
|
|
19
|
+
keepRunning,
|
|
20
|
+
cascadeFailure,
|
|
21
|
+
type Axes,
|
|
22
|
+
type Cell,
|
|
23
|
+
type CellOutput,
|
|
24
|
+
type Cells,
|
|
25
|
+
type FailureStrategy,
|
|
26
|
+
type Matrix,
|
|
27
|
+
type MatrixDef,
|
|
28
|
+
} from './core/matrix.ts';
|
|
29
|
+
export {
|
|
30
|
+
config,
|
|
31
|
+
secretRef,
|
|
32
|
+
isConfigRef,
|
|
33
|
+
type ConfigEntry,
|
|
34
|
+
type AnyConfigEntry,
|
|
35
|
+
type ConfigValues,
|
|
36
|
+
type ConfigRef,
|
|
37
|
+
type ConfigNode,
|
|
38
|
+
type Configurable,
|
|
39
|
+
type DiscriminatorOf,
|
|
40
|
+
type VariantOf,
|
|
41
|
+
} from './core/config.ts';
|
|
42
|
+
export {
|
|
43
|
+
hmacSha256,
|
|
44
|
+
githubSignature,
|
|
45
|
+
bearer,
|
|
46
|
+
type WebhookVerify,
|
|
47
|
+
type WebhookEvent,
|
|
48
|
+
} from './core/webhooks.ts';
|
|
49
|
+
export { resolveConfigRefs, isLiteral, type Resolved } from './core/config-refs.ts';
|
|
50
|
+
export {
|
|
51
|
+
callback,
|
|
52
|
+
external,
|
|
53
|
+
person,
|
|
54
|
+
poll,
|
|
55
|
+
type External,
|
|
56
|
+
type ExternalOptions,
|
|
57
|
+
type ExternalSource,
|
|
58
|
+
type PollOptions,
|
|
59
|
+
} from './core/external.ts';
|
|
60
|
+
export {
|
|
61
|
+
step,
|
|
62
|
+
type Step,
|
|
63
|
+
type AnyStep,
|
|
64
|
+
type StepDef,
|
|
65
|
+
type StepInput,
|
|
66
|
+
type StepKind,
|
|
67
|
+
type ConfigOf,
|
|
68
|
+
type CellOf,
|
|
69
|
+
type InputEntry,
|
|
70
|
+
type InputSpec,
|
|
71
|
+
type LiteralName,
|
|
72
|
+
type OutputValue,
|
|
73
|
+
} from './core/steps.ts';
|
|
74
|
+
export type { Simplify } from './core/types.ts';
|
|
75
|
+
export {
|
|
76
|
+
implement,
|
|
77
|
+
type Implementation,
|
|
78
|
+
type CtxOf,
|
|
79
|
+
type Handler,
|
|
80
|
+
type HandlerResult,
|
|
81
|
+
type Skipped,
|
|
82
|
+
} from './core/handlers.ts';
|
|
83
|
+
export type {
|
|
84
|
+
Ctx,
|
|
85
|
+
StepCtx,
|
|
86
|
+
RunInfo,
|
|
87
|
+
Sh,
|
|
88
|
+
ShOptions,
|
|
89
|
+
ExecOptions,
|
|
90
|
+
Http,
|
|
91
|
+
HttpInit,
|
|
92
|
+
RetryOptions,
|
|
93
|
+
OutputFileOptions,
|
|
94
|
+
} from './ctx/types.ts';
|
|
95
|
+
export type { StepLink } from './ctx/links.ts';
|
|
96
|
+
export type { Ref, Mappable, MapperResult } from './builder/refs.ts';
|
|
97
|
+
export {
|
|
98
|
+
pipeline,
|
|
99
|
+
Pipeline,
|
|
100
|
+
type PipelineDef,
|
|
101
|
+
type RetentionPolicy,
|
|
102
|
+
type InRefs,
|
|
103
|
+
type InitialAvail,
|
|
104
|
+
type WhenCond,
|
|
105
|
+
type PushEvent,
|
|
106
|
+
type StageEntry,
|
|
107
|
+
type CheckStage,
|
|
108
|
+
type MapperFn,
|
|
109
|
+
} from './builder/pipeline.ts';
|
|
110
|
+
export {
|
|
111
|
+
steps,
|
|
112
|
+
Source,
|
|
113
|
+
BuiltImage,
|
|
114
|
+
PushedImage,
|
|
115
|
+
HelmRelease,
|
|
116
|
+
FailureContext,
|
|
117
|
+
type GitHubSourceDef,
|
|
118
|
+
type ImageBuildDef,
|
|
119
|
+
type ImageBuildTarballDef,
|
|
120
|
+
type ImageBuildPushDef,
|
|
121
|
+
type RegistryAuth,
|
|
122
|
+
type RegistryAuthConfig,
|
|
123
|
+
type HelmDeployOverSshDef,
|
|
124
|
+
helmTarget,
|
|
125
|
+
deployTarget,
|
|
126
|
+
type DeployTargetOptions,
|
|
127
|
+
type ComposeDeployDef,
|
|
128
|
+
type ComposeTargetValue,
|
|
129
|
+
type HelmDeployInClusterDef,
|
|
130
|
+
type HelmDeploySshTargetDef,
|
|
131
|
+
type HelmTargetDeployDef,
|
|
132
|
+
type HelmTargetValue,
|
|
133
|
+
type InClusterTargetValue,
|
|
134
|
+
type SshTargetValue,
|
|
135
|
+
httpHealthCheck,
|
|
136
|
+
type HttpHealthCheckOptions,
|
|
137
|
+
sshSession,
|
|
138
|
+
type SshSession,
|
|
139
|
+
serviceUrl,
|
|
140
|
+
checkSpa,
|
|
141
|
+
} from './library/index.ts';
|
package/src/library/checks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Checks for deployed apps, used from custom test handlers.
|
|
2
|
-
import type { Ctx } from
|
|
2
|
+
import type { Ctx } from '../ctx/types.ts';
|
|
3
3
|
|
|
4
4
|
/** The in-cluster URL of a release's Service, assuming the chart names it after the release. */
|
|
5
5
|
export function serviceUrl(release: { release: string; namespace: string }): string {
|
|
@@ -10,14 +10,19 @@ export function serviceUrl(release: { release: string; namespace: string }): str
|
|
|
10
10
|
* Checks `url` serves a single-page app: `GET <url>/` references a `/assets/*.js` bundle (Vite's
|
|
11
11
|
* layout), and the bundle is served as JavaScript. Returns the page, for app-specific checks.
|
|
12
12
|
*/
|
|
13
|
-
export async function checkSpa(
|
|
14
|
-
|
|
13
|
+
export async function checkSpa(
|
|
14
|
+
ctx: Pick<Ctx, 'http' | 'log'>,
|
|
15
|
+
url: string,
|
|
16
|
+
): Promise<{ html: string; bundle: string }> {
|
|
17
|
+
const base = url.replace(/\/$/, '');
|
|
15
18
|
const html = await (await ctx.http.expectOk(`${base}/`)).text();
|
|
16
19
|
const bundle = html.match(/src="(\/assets\/[^"]+\.js)"/)?.[1];
|
|
17
|
-
if (bundle === undefined)
|
|
20
|
+
if (bundle === undefined)
|
|
21
|
+
throw new Error(`${base}/ does not reference an /assets/*.js bundle`);
|
|
18
22
|
const js = await ctx.http.expectOk(`${base}${bundle}`);
|
|
19
23
|
await js.body?.cancel().catch(() => {});
|
|
20
|
-
if (!(js.headers.get(
|
|
21
|
-
|
|
24
|
+
if (!(js.headers.get('content-type')?.includes('javascript') ?? false))
|
|
25
|
+
throw new Error(`${base}${bundle} is not served as JavaScript`);
|
|
26
|
+
ctx.log('SPA served', { url: base, bundle });
|
|
22
27
|
return { html, bundle };
|
|
23
28
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// steps.composeDeploy — ask a Docker Compose deployer to recreate a service with an image. The step is
|
|
2
|
+
// thin on purpose: the deployer (pull, recreate, health, rollback) lives beside the compose project
|
|
3
|
+
// and is fixed there, behind a versioned HTTP API, without editing pipelines.
|
|
4
|
+
import type { Ctx } from '../ctx/types.ts';
|
|
5
|
+
import type { Configurable } from '../core/config.ts';
|
|
6
|
+
import { resolveConfigRefs } from '../core/config-refs.ts';
|
|
7
|
+
import { implement, type Implementation } from '../core/handlers.ts';
|
|
8
|
+
import type { Secret } from '../core/secrets.ts';
|
|
9
|
+
import { step, type LiteralName, type Step } from '../core/steps.ts';
|
|
10
|
+
import { httpHealthCheck } from './http-health.ts';
|
|
11
|
+
import { HelmRelease, PushedImage } from './models.ts';
|
|
12
|
+
|
|
13
|
+
/** The step's own image: the work is one HTTP call from the harness. */
|
|
14
|
+
export const COMPOSE_DEPLOY_IMAGE = 'alpine:3.22';
|
|
15
|
+
|
|
16
|
+
/** A resolved `kind: compose` target. */
|
|
17
|
+
export interface ComposeTargetValue {
|
|
18
|
+
kind: 'compose';
|
|
19
|
+
deployerUrl: string;
|
|
20
|
+
service: string;
|
|
21
|
+
token: { secret: string };
|
|
22
|
+
healthUrl?: string | undefined;
|
|
23
|
+
link?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the deployer answers to `POST /v1/deploy`. */
|
|
27
|
+
export interface ComposeDeployResult {
|
|
28
|
+
status: 'deployed' | 'rolled-back' | 'failed';
|
|
29
|
+
service: string;
|
|
30
|
+
image: string;
|
|
31
|
+
previousImage?: string;
|
|
32
|
+
message?: string;
|
|
33
|
+
log?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ComposeDeployDef<N extends string, K extends string> {
|
|
37
|
+
name: LiteralName<N>;
|
|
38
|
+
/** What the step does, shown in the UI. Required; may be empty. */
|
|
39
|
+
description: string;
|
|
40
|
+
/** A compose target, usually a `select` case's reference. */
|
|
41
|
+
target: Configurable<ComposeTargetValue>;
|
|
42
|
+
/** The secret the target's `token` names (declared by the step). */
|
|
43
|
+
token: Secret<K>;
|
|
44
|
+
timeoutSeconds?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const input = { image: PushedImage };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deploys a registry image (by digest) to a compose service: `POST <deployerUrl>/v1/deploy
|
|
51
|
+
* { service, image: <repository>@<digest> }` with the target's bearer token. The deployer pulls the
|
|
52
|
+
* image, recreates the service, waits for its health and rolls back on failure; the step reports its
|
|
53
|
+
* answer, then (with the target's `healthUrl`) checks health itself. Output: a `HelmRelease` with the
|
|
54
|
+
* service as `release` and `compose` as `namespace`, so it can share a step name with the helm deploys.
|
|
55
|
+
*/
|
|
56
|
+
export function composeDeploy<const N extends string, const K extends string>(
|
|
57
|
+
def: ComposeDeployDef<N, K>,
|
|
58
|
+
): Implementation<Step<N, typeof input, typeof HelmRelease, K>> {
|
|
59
|
+
const contract = step({
|
|
60
|
+
name: def.name,
|
|
61
|
+
description: def.description,
|
|
62
|
+
kind: 'composeDeploy',
|
|
63
|
+
image: COMPOSE_DEPLOY_IMAGE,
|
|
64
|
+
input,
|
|
65
|
+
output: HelmRelease,
|
|
66
|
+
secrets: [def.token],
|
|
67
|
+
timeoutSeconds: def.timeoutSeconds,
|
|
68
|
+
settings: { target: def.target, token: def.token.name },
|
|
69
|
+
});
|
|
70
|
+
return implement(contract, async ({ image }, ctx: Ctx) => {
|
|
71
|
+
const target = resolveConfigRefs(def.target, ctx.config ?? {}) as ComposeTargetValue;
|
|
72
|
+
if (target?.kind !== 'compose')
|
|
73
|
+
throw new Error(
|
|
74
|
+
`composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`,
|
|
75
|
+
);
|
|
76
|
+
if (target.token.secret !== def.token.name)
|
|
77
|
+
throw new Error(
|
|
78
|
+
`composeDeploy '${def.name}': the target's token names secret ${target.token.secret}, but the step declares ${def.token.name}`,
|
|
79
|
+
);
|
|
80
|
+
const token = (ctx.secrets as Record<string, string>)[def.token.name];
|
|
81
|
+
const ref = `${image.repository}@${image.digest}`;
|
|
82
|
+
const url = `${target.deployerUrl.replace(/\/+$/, '')}/v1/deploy`;
|
|
83
|
+
ctx.log('deploying', {
|
|
84
|
+
service: target.service,
|
|
85
|
+
image: ref,
|
|
86
|
+
deployer: target.deployerUrl,
|
|
87
|
+
});
|
|
88
|
+
const res = await ctx.http.fetch(url, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ service: target.service, image: ref }),
|
|
92
|
+
});
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
let result: ComposeDeployResult | undefined;
|
|
95
|
+
try {
|
|
96
|
+
result = JSON.parse(text) as ComposeDeployResult;
|
|
97
|
+
} catch {
|
|
98
|
+
// reported below
|
|
99
|
+
}
|
|
100
|
+
for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
|
|
101
|
+
if (!res.ok || result?.status !== 'deployed')
|
|
102
|
+
throw new Error(
|
|
103
|
+
`the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ''}` : text.slice(0, 300)}`,
|
|
104
|
+
);
|
|
105
|
+
if (target.healthUrl)
|
|
106
|
+
await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
|
|
107
|
+
if (target.link) ctx.link(target.link);
|
|
108
|
+
ctx.log('deployed', {
|
|
109
|
+
service: target.service,
|
|
110
|
+
image: ref,
|
|
111
|
+
previous: result.previousImage ?? '-',
|
|
112
|
+
});
|
|
113
|
+
return { release: target.service, namespace: 'compose', image: ref };
|
|
114
|
+
}) as never;
|
|
115
|
+
}
|