@olivervea/pipelines 2.0.0-beta.26 → 2.0.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/ctx/index.ts +2 -0
- package/src/ctx/links.ts +30 -0
- package/src/ctx/types.ts +5 -0
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +182 -0
- package/src/describe/describe.ts +37 -851
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +165 -0
- package/src/describe/step.ts +395 -0
- package/src/describe/triggers.ts +72 -0
- package/src/harness/execute.ts +22 -7
- package/src/index.ts +1 -0
- package/src/library/compose-deploy.ts +2 -0
- package/src/library/helm-deploy-ssh.ts +6 -0
- package/src/library/helm-deploy.ts +14 -0
- package/src/library/models.ts +2 -2
- package/src/testing/index.ts +3 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { STEP_NAME } from '../core/steps.ts';
|
|
2
|
+
import type { TriggerNode } from '../builder/pipeline.ts';
|
|
3
|
+
import type { TriggerT } from './manifest-schema.ts';
|
|
4
|
+
import type { DescribeContext } from './context.ts';
|
|
5
|
+
|
|
6
|
+
export function describeTriggers(
|
|
7
|
+
ctx: DescribeContext,
|
|
8
|
+
triggers: readonly TriggerNode[],
|
|
9
|
+
): TriggerT[] {
|
|
10
|
+
const names = new Set<string>();
|
|
11
|
+
return triggers.map((t) => {
|
|
12
|
+
if (names.has(t.name))
|
|
13
|
+
ctx.problems.push(
|
|
14
|
+
`duplicate trigger '${t.name}'${t.kind === 'push' ? ' (onPush may be used once)' : ' (give each onPoll and onWebhook a distinct name)'}`,
|
|
15
|
+
);
|
|
16
|
+
names.add(t.name);
|
|
17
|
+
if (t.kind === 'push') return { kind: 'push', name: 'push' };
|
|
18
|
+
if (t.kind === 'webhook') return describeWebhook(ctx, t);
|
|
19
|
+
return describePoll(ctx, t);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function describeWebhook(
|
|
24
|
+
ctx: DescribeContext,
|
|
25
|
+
t: Extract<TriggerNode, { kind: 'webhook' }>,
|
|
26
|
+
): TriggerT {
|
|
27
|
+
if (!STEP_NAME.test(t.name))
|
|
28
|
+
ctx.problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
|
|
29
|
+
const secret = ctx.useSecret(t.verify.secret, `webhook '${t.name}'`);
|
|
30
|
+
if (t.verify.scheme === 'bearer')
|
|
31
|
+
return { kind: 'webhook', name: t.name, verify: { scheme: 'bearer', secret } };
|
|
32
|
+
if (!t.verify.header)
|
|
33
|
+
ctx.problems.push(`webhook '${t.name}': the signature header must not be empty`);
|
|
34
|
+
return {
|
|
35
|
+
kind: 'webhook',
|
|
36
|
+
name: t.name,
|
|
37
|
+
verify: {
|
|
38
|
+
scheme: 'hmac-sha256',
|
|
39
|
+
secret,
|
|
40
|
+
header: t.verify.header,
|
|
41
|
+
prefix: t.verify.prefix,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describePoll(
|
|
47
|
+
ctx: DescribeContext,
|
|
48
|
+
t: Extract<TriggerNode, { kind: 'poll' }>,
|
|
49
|
+
): TriggerT {
|
|
50
|
+
const problems = ctx.problems;
|
|
51
|
+
if (!STEP_NAME.test(t.name))
|
|
52
|
+
problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
|
|
53
|
+
if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0))
|
|
54
|
+
problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
|
|
55
|
+
const declared = new Set(t.secrets.map((s) => ctx.useSecret(s, `poll '${t.name}'`)));
|
|
56
|
+
for (const [h, v] of Object.entries(t.headers)) {
|
|
57
|
+
for (const m of v.matchAll(/\$SECRET:([A-Za-z0-9_]+)/g))
|
|
58
|
+
if (!declared.has(m[1]!))
|
|
59
|
+
problems.push(
|
|
60
|
+
`poll '${t.name}': header '${h}' references secret '${m[1]}', which is not in the poll's secrets`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
kind: 'poll',
|
|
65
|
+
name: t.name,
|
|
66
|
+
url: t.url,
|
|
67
|
+
headers: { ...t.headers },
|
|
68
|
+
secrets: [...declared].sort(),
|
|
69
|
+
valuePath: t.valuePath,
|
|
70
|
+
intervalSeconds: t.intervalSeconds,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/harness/execute.ts
CHANGED
|
@@ -6,15 +6,17 @@ import { isSkipped } from '../core/handlers.ts';
|
|
|
6
6
|
import type { AnyStep } from '../core/steps.ts';
|
|
7
7
|
import type { Ctx, RunInfo } from '../ctx/types.ts';
|
|
8
8
|
import { objectPath } from '../ctx/files.ts';
|
|
9
|
+
import { createLinks, type StepLink } from '../ctx/links.ts';
|
|
9
10
|
import { sha256File } from '../util/hash.ts';
|
|
10
11
|
import { EXIT } from './exit.ts';
|
|
11
12
|
import { validateConfig, validateInput, validateOutput } from './validate.ts';
|
|
12
13
|
|
|
13
14
|
export type FailureKind = 'handler' | 'input' | 'output';
|
|
14
|
-
export type Envelope =
|
|
15
|
+
export type Envelope = (
|
|
15
16
|
| { status: 'succeeded'; output: unknown }
|
|
16
17
|
| { status: 'skipped'; reason: string }
|
|
17
|
-
| { status: 'failed'; error: { kind: FailureKind; message: string } }
|
|
18
|
+
| { status: 'failed'; error: { kind: FailureKind; message: string } }
|
|
19
|
+
) & { links?: StepLink[] };
|
|
18
20
|
|
|
19
21
|
export interface Execution {
|
|
20
22
|
envelope: Envelope;
|
|
@@ -85,7 +87,14 @@ export async function executeStep(args: {
|
|
|
85
87
|
exitCode: EXIT.inputInvalid,
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
|
-
const
|
|
90
|
+
const collected = createLinks();
|
|
91
|
+
const ctx: Ctx = {
|
|
92
|
+
...args.ctx,
|
|
93
|
+
config: Object.freeze(config.value) as Ctx['config'],
|
|
94
|
+
link: collected.link,
|
|
95
|
+
};
|
|
96
|
+
const withLinks = (e: Envelope): Envelope =>
|
|
97
|
+
collected.links.length ? { ...e, links: [...collected.links] } : e;
|
|
89
98
|
|
|
90
99
|
let value: unknown;
|
|
91
100
|
try {
|
|
@@ -98,14 +107,17 @@ export async function executeStep(args: {
|
|
|
98
107
|
log(message);
|
|
99
108
|
if (e instanceof Error && e.stack && !cancelled) log(e.stack);
|
|
100
109
|
return {
|
|
101
|
-
envelope: { status: 'failed', error: { kind: 'handler', message } },
|
|
110
|
+
envelope: withLinks({ status: 'failed', error: { kind: 'handler', message } }),
|
|
102
111
|
exitCode: cancelled ?? EXIT.handlerFailed,
|
|
103
112
|
};
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
if (isSkipped(value)) {
|
|
107
116
|
log(`step '${step.name}' skipped: ${value.reason}`);
|
|
108
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
envelope: withLinks({ status: 'skipped', reason: value.reason }),
|
|
119
|
+
exitCode: EXIT.ok,
|
|
120
|
+
};
|
|
109
121
|
}
|
|
110
122
|
const out = validateOutput(step, value);
|
|
111
123
|
const problems = out.ok
|
|
@@ -115,9 +127,12 @@ export async function executeStep(args: {
|
|
|
115
127
|
const message = `step '${step.name}': returned value does not match ${step.output?.name ?? 'its (absent) output model'}:\n ${problems.join('\n ')}`;
|
|
116
128
|
log(message);
|
|
117
129
|
return {
|
|
118
|
-
envelope: { status: 'failed', error: { kind: 'output', message } },
|
|
130
|
+
envelope: withLinks({ status: 'failed', error: { kind: 'output', message } }),
|
|
119
131
|
exitCode: EXIT.outputInvalid,
|
|
120
132
|
};
|
|
121
133
|
}
|
|
122
|
-
return {
|
|
134
|
+
return {
|
|
135
|
+
envelope: withLinks({ status: 'succeeded', output: out.value }),
|
|
136
|
+
exitCode: EXIT.ok,
|
|
137
|
+
};
|
|
123
138
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface ComposeTargetValue {
|
|
|
20
20
|
service: string;
|
|
21
21
|
token: { secret: string };
|
|
22
22
|
healthUrl?: string | undefined;
|
|
23
|
+
link?: string | undefined;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/** What the deployer answers to `POST /v1/deploy`. */
|
|
@@ -103,6 +104,7 @@ export function composeDeploy<const N extends string, const K extends string>(
|
|
|
103
104
|
);
|
|
104
105
|
if (target.healthUrl)
|
|
105
106
|
await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
|
|
107
|
+
if (target.link) ctx.link(target.link);
|
|
106
108
|
ctx.log('deployed', {
|
|
107
109
|
service: target.service,
|
|
108
110
|
image: ref,
|
|
@@ -113,6 +113,8 @@ export interface HelmDeployOverSshDef<
|
|
|
113
113
|
helmWait?: boolean;
|
|
114
114
|
/** `helm upgrade --atomic`: wait like `helmWait`, and roll back when the upgrade fails. Default false. */
|
|
115
115
|
atomic?: boolean;
|
|
116
|
+
/** Public URL of what the release serves; the step links to it after a successful deploy. */
|
|
117
|
+
link?: Configurable<string>;
|
|
116
118
|
/** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
|
|
117
119
|
healthUrl?:
|
|
118
120
|
| Configurable<string>
|
|
@@ -407,6 +409,7 @@ function helmDeployOverSshLiteral(
|
|
|
407
409
|
values: def.values ?? [],
|
|
408
410
|
set: def.set ?? {},
|
|
409
411
|
healthUrl: def.healthUrl,
|
|
412
|
+
link: def.link,
|
|
410
413
|
};
|
|
411
414
|
|
|
412
415
|
const contract = step({
|
|
@@ -436,6 +439,7 @@ function helmDeployOverSshLiteral(
|
|
|
436
439
|
...(def.dependencies ? { dependencies: true } : {}),
|
|
437
440
|
...(def.atomic ? { atomic: true } : def.helmWait ? { helmWait: true } : {}),
|
|
438
441
|
...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
|
|
442
|
+
...(def.link !== undefined ? { link: def.link } : {}),
|
|
439
443
|
},
|
|
440
444
|
});
|
|
441
445
|
return implement(contract, async (stepInput, ctx: Ctx) => {
|
|
@@ -449,6 +453,7 @@ function helmDeployOverSshLiteral(
|
|
|
449
453
|
namespace: string;
|
|
450
454
|
values: string[];
|
|
451
455
|
set: Record<string, string>;
|
|
456
|
+
link?: string;
|
|
452
457
|
healthUrl?:
|
|
453
458
|
| string
|
|
454
459
|
| ({ url: string } & Pick<HttpHealthCheckOptions, 'attempts' | 'delaySeconds'>);
|
|
@@ -495,6 +500,7 @@ function helmDeployOverSshLiteral(
|
|
|
495
500
|
attempts: health.attempts,
|
|
496
501
|
delaySeconds: health.delaySeconds,
|
|
497
502
|
});
|
|
503
|
+
if (d.link) ctx.link(d.link);
|
|
498
504
|
return {
|
|
499
505
|
release: d.release,
|
|
500
506
|
namespace: d.namespace,
|
|
@@ -53,6 +53,12 @@ function targetVariants<const E extends z.ZodRawShape, const K extends string>(
|
|
|
53
53
|
.describe(
|
|
54
54
|
'Base URL step pods reach the release at (health checks, smoke tests); default http://<release>.<namespace>.svc.cluster.local.',
|
|
55
55
|
),
|
|
56
|
+
link: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
'Public URL of what the target serves (a site, an API); the deploy step links to it.',
|
|
61
|
+
),
|
|
56
62
|
...(o.extend ?? ({} as E)),
|
|
57
63
|
};
|
|
58
64
|
const inCluster = z.object({
|
|
@@ -86,6 +92,12 @@ function targetVariants<const E extends z.ZodRawShape, const K extends string>(
|
|
|
86
92
|
.describe(
|
|
87
93
|
"Checked by the step after the deployer's own health check, when the step can reach it.",
|
|
88
94
|
),
|
|
95
|
+
link: z
|
|
96
|
+
.string()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe(
|
|
99
|
+
'Public URL of what the target serves (a site, an API); the deploy step links to it.',
|
|
100
|
+
),
|
|
89
101
|
...(o.extend ?? ({} as E)),
|
|
90
102
|
});
|
|
91
103
|
const none = z.object({
|
|
@@ -156,6 +168,7 @@ interface TargetCommon {
|
|
|
156
168
|
values: readonly string[];
|
|
157
169
|
set: Readonly<Record<string, string>>;
|
|
158
170
|
url?: string | undefined;
|
|
171
|
+
link?: string | undefined;
|
|
159
172
|
}
|
|
160
173
|
/** A resolved `kind: inCluster` target. */
|
|
161
174
|
export type InClusterTargetValue = TargetCommon & { kind: 'inCluster' };
|
|
@@ -472,6 +485,7 @@ function targetDeploy(
|
|
|
472
485
|
attempts: 12,
|
|
473
486
|
delaySeconds: 5,
|
|
474
487
|
});
|
|
488
|
+
if (target.link) ctx.link(target.link);
|
|
475
489
|
const main = flags[0];
|
|
476
490
|
return {
|
|
477
491
|
release,
|
package/src/library/models.ts
CHANGED
|
@@ -54,7 +54,7 @@ export const HelmRelease = model(
|
|
|
54
54
|
export const FailedStep = z.object({
|
|
55
55
|
step: z.string(),
|
|
56
56
|
attempt: z.number().int(),
|
|
57
|
-
/** `<pipeline id>#<run>/<step>@<attempt
|
|
57
|
+
/** `<pipeline id>#<run>/<step>@<attempt>`. */
|
|
58
58
|
address: z.string(),
|
|
59
59
|
/** The attempt's failure reason (handler error, exit code, timeout, …). */
|
|
60
60
|
reason: z.string(),
|
|
@@ -74,7 +74,7 @@ export const FailureContext = model(
|
|
|
74
74
|
run: z.object({
|
|
75
75
|
id: z.string(),
|
|
76
76
|
number: z.number().int(),
|
|
77
|
-
/** `<pipeline id>#<run
|
|
77
|
+
/** `<pipeline id>#<run>`. */
|
|
78
78
|
address: z.string(),
|
|
79
79
|
/** The run's derived version, `yyyy.MM.dd.<run>`. */
|
|
80
80
|
versionStamp: z.string(),
|
package/src/testing/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from '../core/steps.ts';
|
|
15
15
|
import { createCtx } from '../ctx/index.ts';
|
|
16
16
|
import type { Ctx, RunInfo } from '../ctx/types.ts';
|
|
17
|
+
import type { StepLink } from '../ctx/links.ts';
|
|
17
18
|
import { executeStep, type FailureKind, type StepFunction } from '../harness/execute.ts';
|
|
18
19
|
import { sha256Hex } from '../util/hash.ts';
|
|
19
20
|
|
|
@@ -39,6 +40,8 @@ export type TestResult<S extends AnyStep> = {
|
|
|
39
40
|
filesDir: string;
|
|
40
41
|
logs: string[];
|
|
41
42
|
exitCode: number;
|
|
43
|
+
/** What the handler passed to `ctx.link`; absent when nothing. */
|
|
44
|
+
links?: StepLink[];
|
|
42
45
|
} & (
|
|
43
46
|
| { status: 'succeeded'; output: OutputValue<S> }
|
|
44
47
|
| { status: 'skipped'; reason: string }
|