@olivervea/pipelines 2.0.0-beta.24 → 2.0.0-beta.26
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 +1 -1
- package/package.json +1 -1
- package/src/build/build.ts +4 -9
- package/src/build/elf.ts +8 -0
- package/src/ctx/files.ts +8 -2
- package/src/ctx/index.ts +17 -11
- package/src/describe/describe.ts +3 -1
- package/src/describe/mapper-source.ts +4 -2
- package/src/harness/index.ts +0 -1
- package/src/harness/main.ts +0 -6
- package/src/harness/result.ts +7 -2
- package/src/library/helm-deploy-ssh.ts +18 -9
- package/src/library/helm-deploy.ts +92 -26
- package/src/util/tar.ts +1 -6
package/README.md
CHANGED
|
@@ -141,7 +141,7 @@ Library steps carry their own implementation and image; they take typed settings
|
|
|
141
141
|
| `steps.gitHubSource` | `alpine:3.22` (work happens in the harness) | `{ repo: "owner/name", sha }` (plain schemas: needs a mapper) → `Source` | `token?: Secret` (sent as `Authorization: Bearer` when set; unset = anonymous; becomes the step's secret), `apiUrl?` (default `https://api.github.com`) |
|
|
142
142
|
| `steps.imageBuild` | `gcr.io/kaniko-project/executor:v1.24.0-debug`, pinned by digest | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest, cached }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree with the `Dockerfile`; default the root), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<hash>` of the context and the build recipe; skip when the registry has it, with `cached: true`), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default none), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2), `registryAuth?` (`{ username, password: Secret }`: log in to the `repository` registry; or a config reference to `{ username, password: { secret: NAME } }`, with `NAME` in `secrets`), `insecureRegistry?` (that registry is plain HTTP), `secrets?` (declared secrets a config `registryAuth` may name), `enabled?` (`false` skips the step) |
|
|
143
143
|
| `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, <key>: BuiltImage }` per `images` (`PushedImage` with `fromRegistry`) → `HelmRelease { release, namespace, image? }` (`image`: the first image; absent without images) | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `images?` (input key → values prefix; default `{ image: "image" }`; `{}` = the chart alone), `fromRegistry?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`, none without images), `rolloutTimeoutSeconds?` (default 120), `dependencies?` (pull `Chart.yaml`'s dependencies on the host), `helmWait?` / `atomic?` (`helm --wait` / `--atomic`), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
|
|
144
|
-
| `steps.helmDeployInCluster` (and the `target` form of `helmDeployOverSsh`) | `alpine/helm:3.16.2` (ssh: `alpine:3.22`) | `{ source: Source, <key>: PushedImage }` per `images`, `<key>: optional(PushedImage)` per `imageRefs` → `HelmRelease` (`image`: `repository:tag@digest` of the first image) | `target` (a `select` case's reference to a `helmTarget()` variant: `{ kind: "inCluster", namespace, release, values, set, url? }` for helmDeployInCluster, the same plus `host` with `kind: "ssh"` for helmDeployOverSsh; `url` is where step pods reach the release, default its in-cluster Service URL, and is passed on in `HelmRelease.url`), `sshKey: Secret` (ssh only), `chart`, `inlineValues?` (objects written as values files after the target's), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
|
|
144
|
+
| `steps.helmDeployInCluster` (and the `target` form of `helmDeployOverSsh`) | `alpine/helm:3.16.2` (ssh: `alpine:3.22`) | `{ source: Source, <key>: PushedImage }` per `images`, `<key>: optional(PushedImage)` per `imageRefs` → `HelmRelease` (`image`: `repository:tag@digest` of the first image) | `target` (a `select` case's reference to a `helmTarget()` variant: `{ kind: "inCluster", namespace, release, values, set, url? }` for helmDeployInCluster, the same plus `host` with `kind: "ssh"` for helmDeployOverSsh; `url` is where step pods reach the release, default its in-cluster Service URL, and is passed on in `HelmRelease.url`), `sshKey: Secret` (ssh only), `chart`, `inlineValues?` (objects written as values files after the target's; a resolved `secretRef`, `{ secret: NAME }`, becomes that secret's value, and the file holding it is deleted after the upgrade), `secrets?` (the secrets those `secretRef`s name; the step declares them), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
|
|
145
145
|
| `steps.composeDeploy` | `alpine:3.22` (work happens in the harness) | `{ image: PushedImage }` → `HelmRelease { release: <service>, namespace: "compose", image: <repository>@<digest> }` | `target` (a `select` case's reference to the compose variant of `deployTarget()`: `{ kind: "compose", deployerUrl, service, token: { secret }, healthUrl? }`), `token: Secret` (must be the secret the target names; the step's only secret), `timeoutSeconds?` |
|
|
146
146
|
|
|
147
147
|
- `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
|
package/package.json
CHANGED
package/src/build/build.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { sha256File } from '../util/hash.ts';
|
|
|
6
6
|
import type { HarnessInfo } from '../util/code-hash.ts';
|
|
7
7
|
import { writeTarZst } from '../util/tar.ts';
|
|
8
8
|
import { MANIFEST_BEGIN, MANIFEST_END, uploadFile } from '../harness/result.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { loaderProblem } from './elf.ts';
|
|
10
10
|
import { codeHash } from './hashes.ts';
|
|
11
11
|
import { harnessInfo, loadProject, relPosix, type Project } from './project.ts';
|
|
12
12
|
|
|
@@ -112,14 +112,9 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
|
|
|
112
112
|
`olve-build: compiled ${target} in ${((performance.now() - t1) / 1000).toFixed(2)}s`,
|
|
113
113
|
);
|
|
114
114
|
|
|
115
|
-
// 2. loader check:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const interp = readInterpreter(binary);
|
|
119
|
-
if (interp !== expected)
|
|
120
|
-
throw new BuildError(
|
|
121
|
-
`compiled binary has PT_INTERP ${interp ?? '(none)'}, expected ${expected} — compile on a glibc Bun image (oven/bun:<version>-slim), not alpine`,
|
|
122
|
-
);
|
|
115
|
+
// 2. loader check: on alpine, bun-linux-x64 silently yields a musl binary, which would not start.
|
|
116
|
+
const problem = loaderProblem(binary, target);
|
|
117
|
+
if (problem) throw new BuildError(problem);
|
|
123
118
|
|
|
124
119
|
// 3. describe (runs the real binary: the manifest is exactly what the step pods will see)
|
|
125
120
|
const d = Bun.spawnSync([binary, 'describe'], {
|
package/src/build/elf.ts
CHANGED
|
@@ -34,3 +34,11 @@ export function readInterpreter(path: string): string | null {
|
|
|
34
34
|
closeSync(fd);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/** Why `binary` won't start through `target`'s loader (the harness runs it via /olve/lib/<loader>), or undefined. */
|
|
39
|
+
export function loaderProblem(binary: string, target: string): string | undefined {
|
|
40
|
+
const expected = target === 'bun-linux-x64' ? GLIBC_INTERP : MUSL_INTERP;
|
|
41
|
+
const interp = readInterpreter(binary);
|
|
42
|
+
if (interp === expected) return undefined;
|
|
43
|
+
return `compiled binary has PT_INTERP ${interp ?? '(none)'}, expected ${expected} — compile on a glibc Bun image (oven/bun:<version>-slim), not alpine`;
|
|
44
|
+
}
|
package/src/ctx/files.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface FilesDeps {
|
|
|
37
37
|
root: string;
|
|
38
38
|
signal: AbortSignal;
|
|
39
39
|
log: (message: string, fields?: Record<string, unknown>) => void;
|
|
40
|
+
/** Backoff between download retries (default: real sleep). */
|
|
41
|
+
sleep?: (ms: number) => Promise<void>;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
function checkName(name: string, what: string): void {
|
|
@@ -122,6 +124,7 @@ async function download(
|
|
|
122
124
|
headers: Record<string, string>,
|
|
123
125
|
signal: AbortSignal,
|
|
124
126
|
log: FilesDeps['log'],
|
|
127
|
+
sleep: (ms: number) => Promise<void>,
|
|
125
128
|
): Promise<void> {
|
|
126
129
|
const attempts = 4;
|
|
127
130
|
for (let i = 1; ; i++) {
|
|
@@ -143,13 +146,14 @@ async function download(
|
|
|
143
146
|
attempt: i,
|
|
144
147
|
error: (e as Error).message,
|
|
145
148
|
});
|
|
146
|
-
await
|
|
149
|
+
await sleep(1000 * 2 ** (i - 1));
|
|
147
150
|
}
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
export function createFetch(deps: FilesDeps) {
|
|
152
155
|
const cache = new Map<string, string>();
|
|
156
|
+
const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
153
157
|
return async (input: FileRef): Promise<string> => {
|
|
154
158
|
const parsed = fileRefSchema.safeParse(input);
|
|
155
159
|
if (!parsed.success)
|
|
@@ -181,7 +185,8 @@ export function createFetch(deps: FilesDeps) {
|
|
|
181
185
|
const baseUrl = process.env[ENV.filesBaseUrl];
|
|
182
186
|
if (own && existsSync(join(deps.workFiles, obj)))
|
|
183
187
|
copyFileSync(join(deps.workFiles, obj), objFile);
|
|
184
|
-
else if (urls[key])
|
|
188
|
+
else if (urls[key])
|
|
189
|
+
await download(urls[key], objFile, {}, deps.signal, deps.log, sleep);
|
|
185
190
|
else if (
|
|
186
191
|
localDir &&
|
|
187
192
|
existsSync(
|
|
@@ -201,6 +206,7 @@ export function createFetch(deps: FilesDeps) {
|
|
|
201
206
|
token ? { authorization: `Bearer ${token}` } : {},
|
|
202
207
|
deps.signal,
|
|
203
208
|
deps.log,
|
|
209
|
+
sleep,
|
|
204
210
|
);
|
|
205
211
|
} else {
|
|
206
212
|
throw new Error(
|
package/src/ctx/index.ts
CHANGED
|
@@ -89,9 +89,25 @@ export function createHttp(signal: AbortSignal): Http {
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** Waits `ms`, or rejects at once when the step is cancelled. */
|
|
93
|
+
function cancellableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
94
|
+
return new Promise<void>((res, rej) => {
|
|
95
|
+
const t = setTimeout(() => {
|
|
96
|
+
signal.removeEventListener('abort', onAbort);
|
|
97
|
+
res();
|
|
98
|
+
}, ms);
|
|
99
|
+
const onAbort = () => {
|
|
100
|
+
clearTimeout(t);
|
|
101
|
+
rej(new Error('retry cancelled: the step was cancelled'));
|
|
102
|
+
};
|
|
103
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
export function createRetry(
|
|
93
108
|
signal: AbortSignal,
|
|
94
109
|
log: (m: string, f?: Record<string, unknown>) => void,
|
|
110
|
+
sleep: (ms: number, signal: AbortSignal) => Promise<void> = cancellableSleep,
|
|
95
111
|
) {
|
|
96
112
|
return async <T>(
|
|
97
113
|
opts: RetryOptions,
|
|
@@ -110,17 +126,7 @@ export function createRetry(
|
|
|
110
126
|
of: opts.attempts,
|
|
111
127
|
error: (e as Error)?.message ?? String(e),
|
|
112
128
|
});
|
|
113
|
-
await
|
|
114
|
-
const t = setTimeout(() => {
|
|
115
|
-
signal.removeEventListener('abort', onAbort);
|
|
116
|
-
res();
|
|
117
|
-
}, delay);
|
|
118
|
-
const onAbort = () => {
|
|
119
|
-
clearTimeout(t);
|
|
120
|
-
rej(new Error('retry cancelled: the step was cancelled'));
|
|
121
|
-
};
|
|
122
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
123
|
-
});
|
|
129
|
+
await sleep(delay, signal);
|
|
124
130
|
delay *= opts.backoff ?? 1;
|
|
125
131
|
}
|
|
126
132
|
}
|
package/src/describe/describe.ts
CHANGED
|
@@ -806,7 +806,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
|
|
|
806
806
|
export const manifestText = (m: ManifestT): string => canonicalJson(m);
|
|
807
807
|
|
|
808
808
|
/**
|
|
809
|
-
* The schemas at `path` inside an entry's schema: every union branch that has the field, except where
|
|
809
|
+
* The schemas at `path` inside an entry's schema (through properties and records): every union branch that has the field, except where
|
|
810
810
|
* `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
|
|
811
811
|
*/
|
|
812
812
|
export function subSchemas(
|
|
@@ -848,6 +848,8 @@ export function subSchemas(
|
|
|
848
848
|
const props = o.properties as Record<string, JsonSchema> | undefined;
|
|
849
849
|
if (props && Object.hasOwn(props, rest[0]!))
|
|
850
850
|
return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
|
|
851
|
+
if (o.additionalProperties && typeof o.additionalProperties === 'object')
|
|
852
|
+
return walk(o.additionalProperties, rest.slice(1), `${here}.${rest[0]}`);
|
|
851
853
|
return [];
|
|
852
854
|
};
|
|
853
855
|
return walk(schema, path, entry);
|
|
@@ -131,8 +131,8 @@ function tokenize(src: string): Tok[] {
|
|
|
131
131
|
i = e + 2;
|
|
132
132
|
continue;
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
if (c === '"' || c === "'") {
|
|
134
|
+
// Bun prints a string constant holding a newline or both quote kinds as a template literal.
|
|
135
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
136
136
|
let j = i + 1;
|
|
137
137
|
let v = '';
|
|
138
138
|
for (;;) {
|
|
@@ -140,6 +140,8 @@ function tokenize(src: string): Tok[] {
|
|
|
140
140
|
throw new MapperError('mapper source has an unterminated string');
|
|
141
141
|
const d = src[j]!;
|
|
142
142
|
if (d === c) break;
|
|
143
|
+
if (c === '`' && d === '$' && src[j + 1] === '{')
|
|
144
|
+
throw new MapperError('mapper uses a template string');
|
|
143
145
|
if (d === '\\') {
|
|
144
146
|
v += src.slice(j, j + 2);
|
|
145
147
|
j += 2;
|
package/src/harness/index.ts
CHANGED
package/src/harness/main.ts
CHANGED
|
@@ -56,12 +56,6 @@ export const defaultIO: HarnessIO = {
|
|
|
56
56
|
const USAGE =
|
|
57
57
|
'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
|
|
58
58
|
|
|
59
|
-
export function allSteps(data: PipelineData): AnyStep[] {
|
|
60
|
-
const out: AnyStep[] = [];
|
|
61
|
-
for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
|
|
62
|
-
return out;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
59
|
/** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
|
|
66
60
|
export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
|
|
67
61
|
const out: { step: AnyStep; cases: string[] }[] = [];
|
package/src/harness/result.ts
CHANGED
|
@@ -53,10 +53,15 @@ export async function uploadFile(
|
|
|
53
53
|
url: string,
|
|
54
54
|
file: string,
|
|
55
55
|
log: (line: string) => void,
|
|
56
|
-
opts: {
|
|
56
|
+
opts: {
|
|
57
|
+
attempts?: number;
|
|
58
|
+
baseDelayMs?: number;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
} = {},
|
|
57
61
|
): Promise<void> {
|
|
58
62
|
const attempts = opts.attempts ?? 5;
|
|
59
63
|
const base = opts.baseDelayMs ?? 1000;
|
|
64
|
+
const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
60
65
|
for (let i = 1; ; i++) {
|
|
61
66
|
let status = 0;
|
|
62
67
|
let detail: string;
|
|
@@ -73,7 +78,7 @@ export async function uploadFile(
|
|
|
73
78
|
if (!transient || i >= attempts)
|
|
74
79
|
throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
|
|
75
80
|
log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
|
|
76
|
-
await
|
|
81
|
+
await sleep(base * 2 ** (i - 1));
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -275,6 +275,8 @@ export async function deployOverSsh(
|
|
|
275
275
|
rolloutTimeoutSeconds: number;
|
|
276
276
|
dependencies?: readonly ChartDependency[];
|
|
277
277
|
wait?: HelmWait;
|
|
278
|
+
/** Files in the chart holding secret values: deleted from the host after the upgrade, also when it fails (a successful upgrade removes the whole chart). */
|
|
279
|
+
secretFiles?: readonly string[];
|
|
278
280
|
},
|
|
279
281
|
): Promise<void> {
|
|
280
282
|
const ssh = await sshSession(ctx, { host: o.host, key: o.key });
|
|
@@ -306,15 +308,22 @@ export async function deployOverSsh(
|
|
|
306
308
|
});
|
|
307
309
|
await ssh.run(helmPullCommand(dep, remoteChart));
|
|
308
310
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
311
|
+
try {
|
|
312
|
+
await ssh.run(
|
|
313
|
+
helmUpgradeCommand({
|
|
314
|
+
...o.flags,
|
|
315
|
+
remoteChart,
|
|
316
|
+
release: o.release,
|
|
317
|
+
namespace: o.namespace,
|
|
318
|
+
...(o.wait ? { wait: o.wait } : {}),
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
} finally {
|
|
322
|
+
if (o.secretFiles?.length)
|
|
323
|
+
await ssh.run(
|
|
324
|
+
`rm -f ${o.secretFiles.map((f) => shq(`${remoteChart}/${f}`)).join(' ')}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
318
327
|
for (const d of o.deployments) {
|
|
319
328
|
const target = d.includes('/') ? d : `deploy/${d}`;
|
|
320
329
|
ctx.log('waiting for rollout', { deployment: target });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Helm deploys to a target from config (`helmTarget()`): steps.helmDeployInCluster runs helm in the step
|
|
2
2
|
// pod against its own cluster; steps.helmDeployOverSsh({ target, … }) runs it on a k3s host. Pick one
|
|
3
3
|
// per target kind with `select`. Images come from a registry, pinned by digest.
|
|
4
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import type { Ctx } from '../ctx/types.ts';
|
|
@@ -191,6 +191,13 @@ export interface HelmTargetDeployDef<
|
|
|
191
191
|
* overrides only when set.
|
|
192
192
|
*/
|
|
193
193
|
inlineValues?: readonly Readonly<Record<string, unknown>>[];
|
|
194
|
+
/**
|
|
195
|
+
* Secrets the `secretRef`s in `inlineValues` may name; the step declares them. Each `{ secret: NAME }`
|
|
196
|
+
* in the resolved values becomes the secret's value, so a chart can render its own Secret from a
|
|
197
|
+
* pipeline secret. The values files holding one are deleted after the upgrade (helm's release record
|
|
198
|
+
* keeps the values, as for any Helm value).
|
|
199
|
+
*/
|
|
200
|
+
secrets?: readonly Secret[];
|
|
194
201
|
/**
|
|
195
202
|
* The images to deploy: step input key → the chart's values prefix. Each input is a `PushedImage`;
|
|
196
203
|
* the step sets `<prefix>.repository`, `.tag`, `.digest` and `.pullPolicy=IfNotPresent`. `{}` deploys
|
|
@@ -274,6 +281,7 @@ interface AnyTargetDef {
|
|
|
274
281
|
chart: string;
|
|
275
282
|
target: unknown;
|
|
276
283
|
inlineValues?: readonly Readonly<Record<string, unknown>>[];
|
|
284
|
+
secrets?: readonly Secret[];
|
|
277
285
|
images?: Readonly<Record<string, string>>;
|
|
278
286
|
imageRefs?: Readonly<Record<string, string>>;
|
|
279
287
|
rolloutTimeoutSeconds?: number;
|
|
@@ -308,6 +316,11 @@ function targetDeploy(
|
|
|
308
316
|
...Object.fromEntries(refEntries.map(([key]) => [key, optional(PushedImage)])),
|
|
309
317
|
};
|
|
310
318
|
const configurable = { target: def.target, inlineValues: def.inlineValues ?? [] };
|
|
319
|
+
const declaredSecrets = [
|
|
320
|
+
...(sshKey ? [sshKey] : []),
|
|
321
|
+
...(def.secrets ?? []).filter((s) => s.name !== sshKey?.name),
|
|
322
|
+
];
|
|
323
|
+
const valueSecrets = new Set((def.secrets ?? []).map((s) => s.name));
|
|
311
324
|
|
|
312
325
|
const contract = step({
|
|
313
326
|
name: def.name,
|
|
@@ -316,7 +329,7 @@ function targetDeploy(
|
|
|
316
329
|
image: inCluster ? HELM_IMAGE : HELM_DEPLOY_IMAGE,
|
|
317
330
|
input,
|
|
318
331
|
output: HelmRelease,
|
|
319
|
-
secrets:
|
|
332
|
+
secrets: declaredSecrets,
|
|
320
333
|
timeoutSeconds: def.timeoutSeconds,
|
|
321
334
|
...(inCluster ? { clusterAccess: true } : {}),
|
|
322
335
|
settings: {
|
|
@@ -349,11 +362,27 @@ function targetDeploy(
|
|
|
349
362
|
);
|
|
350
363
|
const chartDir = chartDirectory(await ctx.files.fetch(input.source.tree), def.chart);
|
|
351
364
|
|
|
365
|
+
const secretFiles: string[] = [];
|
|
352
366
|
const generated = inlineValues.map((values, i) => {
|
|
353
367
|
const file = `olve-values-${i}.json`;
|
|
354
|
-
|
|
368
|
+
const resolved = resolveSecretRefs(values, (name) => {
|
|
369
|
+
if (!valueSecrets.has(name))
|
|
370
|
+
throw new Error(
|
|
371
|
+
`${kind} '${def.name}': inlineValues names secret ${name}, which the step does not declare (add it to secrets)`,
|
|
372
|
+
);
|
|
373
|
+
const value = (ctx.secrets as Record<string, string | undefined>)[name];
|
|
374
|
+
if (!value) throw new Error(`secret ${name} (inlineValues) is not set`);
|
|
375
|
+
return value;
|
|
376
|
+
});
|
|
377
|
+
if (resolved.secret) secretFiles.push(file);
|
|
378
|
+
writeFileSync(join(chartDir, file), JSON.stringify(resolved.value), {
|
|
379
|
+
mode: resolved.secret ? 0o600 : 0o644,
|
|
380
|
+
});
|
|
355
381
|
return file;
|
|
356
382
|
});
|
|
383
|
+
const removeSecretFiles = () => {
|
|
384
|
+
for (const file of secretFiles) rmSync(join(chartDir, file), { force: true });
|
|
385
|
+
};
|
|
357
386
|
const flags: HelmImageFlags[] = imageEntries.map(([key, prefix]) => {
|
|
358
387
|
const image = input[key]!;
|
|
359
388
|
return {
|
|
@@ -395,7 +424,8 @@ function targetDeploy(
|
|
|
395
424
|
flags: helm,
|
|
396
425
|
deployments: deployments ?? (imageEntries.length ? [release] : []),
|
|
397
426
|
rolloutTimeoutSeconds: rolloutTimeout,
|
|
398
|
-
|
|
427
|
+
secretFiles,
|
|
428
|
+
}).finally(removeSecretFiles);
|
|
399
429
|
} else {
|
|
400
430
|
if (!existsSync(SERVICE_ACCOUNT_TOKEN))
|
|
401
431
|
throw new Error(
|
|
@@ -407,29 +437,33 @@ function targetDeploy(
|
|
|
407
437
|
namespace,
|
|
408
438
|
images: flags.map((f) => `${f.repository}@${f.digest}`).join(' '),
|
|
409
439
|
});
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
440
|
+
try {
|
|
441
|
+
await ctx.exec(
|
|
442
|
+
[
|
|
443
|
+
'helm',
|
|
444
|
+
'upgrade',
|
|
445
|
+
'--install',
|
|
446
|
+
release,
|
|
447
|
+
'.',
|
|
448
|
+
'-n',
|
|
449
|
+
namespace,
|
|
450
|
+
...helmFlags(helm),
|
|
451
|
+
'--wait',
|
|
452
|
+
`--timeout=${rolloutTimeout}s`,
|
|
453
|
+
],
|
|
454
|
+
{
|
|
455
|
+
cwd: chartDir,
|
|
456
|
+
env: {
|
|
457
|
+
HOME: home,
|
|
458
|
+
HELM_CACHE_HOME: join(home, 'cache'),
|
|
459
|
+
HELM_CONFIG_HOME: join(home, 'config'),
|
|
460
|
+
HELM_DATA_HOME: join(home, 'data'),
|
|
461
|
+
},
|
|
430
462
|
},
|
|
431
|
-
|
|
432
|
-
|
|
463
|
+
);
|
|
464
|
+
} finally {
|
|
465
|
+
removeSecretFiles();
|
|
466
|
+
}
|
|
433
467
|
}
|
|
434
468
|
|
|
435
469
|
const url = (target.url ?? serviceUrl({ release, namespace })).replace(/\/+$/, '');
|
|
@@ -447,3 +481,35 @@ function targetDeploy(
|
|
|
447
481
|
} as never;
|
|
448
482
|
}) as never;
|
|
449
483
|
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* `v` with every `{ secret: NAME }` (a resolved `secretRef`) replaced by `lookup(NAME)`; `secret` says
|
|
487
|
+
* whether there was one.
|
|
488
|
+
*/
|
|
489
|
+
export function resolveSecretRefs(
|
|
490
|
+
v: unknown,
|
|
491
|
+
lookup: (name: string) => string,
|
|
492
|
+
): { value: unknown; secret: boolean } {
|
|
493
|
+
let secret = false;
|
|
494
|
+
const walk = (x: unknown): unknown => {
|
|
495
|
+
if (Array.isArray(x)) return x.map(walk);
|
|
496
|
+
if (
|
|
497
|
+
x !== null &&
|
|
498
|
+
typeof x === 'object' &&
|
|
499
|
+
Object.getPrototypeOf(x) === Object.prototype
|
|
500
|
+
) {
|
|
501
|
+
const entries = Object.entries(x as Record<string, unknown>);
|
|
502
|
+
if (
|
|
503
|
+
entries.length === 1 &&
|
|
504
|
+
entries[0]![0] === 'secret' &&
|
|
505
|
+
typeof entries[0]![1] === 'string'
|
|
506
|
+
) {
|
|
507
|
+
secret = true;
|
|
508
|
+
return lookup(entries[0]![1]);
|
|
509
|
+
}
|
|
510
|
+
return Object.fromEntries(entries.map(([k, y]) => [k, walk(y)]));
|
|
511
|
+
}
|
|
512
|
+
return x;
|
|
513
|
+
};
|
|
514
|
+
return { value: walk(v), secret };
|
|
515
|
+
}
|
package/src/util/tar.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { createGzip } from 'node:zlib';
|
|
|
20
20
|
|
|
21
21
|
export type TarEntry =
|
|
22
22
|
| { path: string; type: 'file'; mode: number; source: string }
|
|
23
|
-
| { path: string; type: 'bytes'; mode: number; data: Uint8Array }
|
|
24
23
|
| { path: string; type: 'dir'; mode: number }
|
|
25
24
|
| { path: string; type: 'symlink'; target: string };
|
|
26
25
|
|
|
@@ -88,7 +87,6 @@ export function writeTar(out: string, entries: readonly TarEntry[]): void {
|
|
|
88
87
|
const link = e.type === 'symlink' ? e.target : '';
|
|
89
88
|
let size = 0;
|
|
90
89
|
if (e.type === 'file') size = lstatSync(e.source).size;
|
|
91
|
-
else if (e.type === 'bytes') size = e.data.length;
|
|
92
90
|
const pax: string[] = [];
|
|
93
91
|
if (enc.encode(name).length > 100) pax.push(paxRecord('path', name));
|
|
94
92
|
if (enc.encode(link).length > 100) pax.push(paxRecord('linkpath', link));
|
|
@@ -104,10 +102,7 @@ export function writeTar(out: string, entries: readonly TarEntry[]): void {
|
|
|
104
102
|
write(
|
|
105
103
|
header(shortName, size, mode, flag, link.length > 100 ? link.slice(0, 99) : link),
|
|
106
104
|
);
|
|
107
|
-
if (e.type === '
|
|
108
|
-
write(e.data);
|
|
109
|
-
pad(size);
|
|
110
|
-
} else if (e.type === 'file') {
|
|
105
|
+
if (e.type === 'file') {
|
|
111
106
|
const src = openSync(e.source, 'r');
|
|
112
107
|
try {
|
|
113
108
|
const buf = new Uint8Array(1 << 20);
|