@tahminator/pipeline 1.0.90 → 1.0.91-beta.gabf5f4ea
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 +43 -0
- package/dist/flux/client.d.ts +33 -0
- package/dist/flux/client.js +215 -0
- package/dist/flux/discover.d.ts +6 -0
- package/dist/flux/discover.js +51 -0
- package/dist/flux/format.d.ts +18 -0
- package/dist/flux/format.js +50 -0
- package/dist/flux/index.d.ts +2 -0
- package/dist/flux/index.js +1 -0
- package/dist/flux/render.d.ts +52 -0
- package/dist/flux/render.js +203 -0
- package/dist/flux/types.d.ts +117 -0
- package/dist/flux/types.js +50 -0
- package/dist/gh/artifact/index.d.ts +28 -0
- package/dist/gh/artifact/index.js +29 -0
- package/dist/gh/client.d.ts +11 -0
- package/dist/gh/client.js +11 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -172,6 +172,7 @@ See the [action documentation](./actions/commands/README.md) for GitHub App perm
|
|
|
172
172
|
```ts
|
|
173
173
|
import {
|
|
174
174
|
DockerClient,
|
|
175
|
+
FluxClient,
|
|
175
176
|
EnvClient,
|
|
176
177
|
GitHubClient,
|
|
177
178
|
NPMClient,
|
|
@@ -194,6 +195,7 @@ Jump to client documentation
|
|
|
194
195
|
|
|
195
196
|
- [`GitHubClient`](#githubclient)
|
|
196
197
|
- [`DockerClient`](#dockerclient)
|
|
198
|
+
- [`FluxClient`](#fluxclient)
|
|
197
199
|
- [`ProtobufCompilerClient`](#protobufcompilerclient)
|
|
198
200
|
- [Setup action versions](#setup-action-versions)
|
|
199
201
|
- [`NPMClient`](#npmclient)
|
|
@@ -257,6 +259,14 @@ await client.sendPrMessage({
|
|
|
257
259
|
message: "Deployed and healthy.",
|
|
258
260
|
});
|
|
259
261
|
|
|
262
|
+
// uploads files as an artifact of the current workflow run; returns { id, url }
|
|
263
|
+
// requires the Actions runtime token, which the setup action exposes
|
|
264
|
+
await client.uploadArtifact({
|
|
265
|
+
name: "report",
|
|
266
|
+
files: ["/tmp/report/report.md"],
|
|
267
|
+
rootDirectory: "/tmp/report",
|
|
268
|
+
});
|
|
269
|
+
|
|
260
270
|
// use VersioningClient to compute your next semver tag.
|
|
261
271
|
// look for `VersioningClient` section for how to configure / use
|
|
262
272
|
const versioning = new VersioningClient(client, VersionUpdatingStrategy.JSTS);
|
|
@@ -286,6 +296,39 @@ await client.outputToGithubOutput({
|
|
|
286
296
|
});
|
|
287
297
|
```
|
|
288
298
|
|
|
299
|
+
### `FluxClient`
|
|
300
|
+
|
|
301
|
+
Work with a repository of [Flux](https://fluxcd.io) manifests. Currently renders every Flux `Kustomization` the way kustomize-controller does and diffs the output between two commits, so a PR shows what would actually change in the cluster.
|
|
302
|
+
|
|
303
|
+
Requires `git`, `diff` and `kustomize` on `PATH` (`INSTALL_KUSTOMIZE: true` in the setup action).
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const gh = await GitHubClient.createWithGithubAppToken({ ... });
|
|
307
|
+
|
|
308
|
+
const flux = new FluxClient(gh, {
|
|
309
|
+
// optional; globs scanned for Flux Kustomizations. by default, it will check all yaml files
|
|
310
|
+
include: ["clusters/**/*.yaml"],
|
|
311
|
+
// optional; which sourceRefs are this repository. default: every GitRepository
|
|
312
|
+
isLocalSource: (ref) => ref.kind === "GitRepository" && ref.name === "flux-system",
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// renders every Flux Kustomization at baseRef and in the working tree, then posts the
|
|
316
|
+
// changed ones as a new PR comment. If comment would exceed GitHub's comment size limit, the full
|
|
317
|
+
// comment is uploaded as a workflow run artifact and the PR comment links to it instead.
|
|
318
|
+
await flux.diff({
|
|
319
|
+
baseRef: process.env.BASE_SHA!,
|
|
320
|
+
owner: "tahminator",
|
|
321
|
+
repository: "infra",
|
|
322
|
+
prId: 123,
|
|
323
|
+
title: "Rendered manifest diff", // optional
|
|
324
|
+
description: "Secrets are shown SOPS-encrypted.", // optional
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Each Kustomization is rendered as `kustomize build` of `spec.path` with `targetNamespace`, `namePrefix`, `nameSuffix`, `components`, `images`, `patches` and `commonMetadata` applied on top, followed by `postBuild.substitute`. Paths without a `kustomization.yaml` get one generated, as Flux does. Values from `postBuild.substituteFrom` live in the cluster, so those variables are left as-is and listed in the diff's notes. A build failure is shown in the comment instead of being skipped. `HelmRelease` objects are diffed as manifests; charts are not templated.
|
|
329
|
+
|
|
330
|
+
The diff is split per Kubernetes object (matched by API group, kind, namespace and name), and each object is labelled with the file that defines it, taken from kustomize's `originAnnotations`. Generated ConfigMaps and Secrets point at the `kustomization.yaml` whose generator creates them, and are matched without their content-hash suffix, so editing a generator's data shows as a line diff of the same object rather than a removal and an addition. Files that only patch an object are not recorded, so a change made by a patch is attributed to the object's original file. Any other renamed object shows as one removal and one addition.
|
|
331
|
+
|
|
289
332
|
### `DockerClient`
|
|
290
333
|
|
|
291
334
|
Interface with Docker to build & deploy images
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { GitHubClient } from "../gh";
|
|
2
|
+
import { type FormatFluxDiffOpts } from "./format";
|
|
3
|
+
import { type FluxClientOpts, type FluxKustomization } from "./types";
|
|
4
|
+
/**
|
|
5
|
+
* Works with a repository of Flux manifests: finds its Flux `Kustomization`s, renders them the
|
|
6
|
+
* way kustomize-controller would, and reports how a change affects the rendered output.
|
|
7
|
+
*
|
|
8
|
+
* Requires `git`, `diff` and `kustomize` on `PATH`.
|
|
9
|
+
*/
|
|
10
|
+
export declare class FluxClient {
|
|
11
|
+
private readonly ghClient;
|
|
12
|
+
private readonly include;
|
|
13
|
+
private readonly isLocalSource;
|
|
14
|
+
constructor(ghClient: GitHubClient, opts?: FluxClientOpts);
|
|
15
|
+
/** Returns every Flux `Kustomization` declared under `root`. */
|
|
16
|
+
findKustomizations(root?: string): Promise<FluxKustomization[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Renders every Flux `Kustomization` at `baseRef` and in the working tree at `root`,
|
|
19
|
+
* and posts the ones whose output differs as a new PR comment.
|
|
20
|
+
*
|
|
21
|
+
* `baseRef` is fetched from `origin` if it is not available locally (e.g. shallow CI clones).
|
|
22
|
+
* If the comment would exceed GitHub's size limit, the full comment is uploaded as an
|
|
23
|
+
* artifact of the current workflow run and the PR comment links to it instead.
|
|
24
|
+
*/
|
|
25
|
+
diff({ baseRef, root, owner, repository, prId, ...formatOpts }: {
|
|
26
|
+
baseRef: string;
|
|
27
|
+
root?: string;
|
|
28
|
+
owner: string;
|
|
29
|
+
repository: string;
|
|
30
|
+
prId: number;
|
|
31
|
+
} & Omit<FormatFluxDiffOpts, "artifactUrl">): Promise<void>;
|
|
32
|
+
private collectDiff;
|
|
33
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { findFluxKustomizations } from "./discover";
|
|
6
|
+
import { formatFluxDiff, label } from "./format";
|
|
7
|
+
import { objectKey, renderKustomization } from "./render";
|
|
8
|
+
import {} from "./types";
|
|
9
|
+
/** GitHub rejects comment bodies above 65536 characters. */
|
|
10
|
+
const MAX_COMMENT_LENGTH = 65_536;
|
|
11
|
+
const DEFAULT_INCLUDE = ["**/*.yaml", "**/*.yml"];
|
|
12
|
+
/**
|
|
13
|
+
* Works with a repository of Flux manifests: finds its Flux `Kustomization`s, renders them the
|
|
14
|
+
* way kustomize-controller would, and reports how a change affects the rendered output.
|
|
15
|
+
*
|
|
16
|
+
* Requires `git`, `diff` and `kustomize` on `PATH`.
|
|
17
|
+
*/
|
|
18
|
+
export class FluxClient {
|
|
19
|
+
ghClient;
|
|
20
|
+
include;
|
|
21
|
+
isLocalSource;
|
|
22
|
+
constructor(ghClient, opts = {}) {
|
|
23
|
+
this.ghClient = ghClient;
|
|
24
|
+
this.include = opts.include ?? DEFAULT_INCLUDE;
|
|
25
|
+
this.isLocalSource =
|
|
26
|
+
opts.isLocalSource ?? ((sourceRef) => sourceRef.kind === "GitRepository");
|
|
27
|
+
}
|
|
28
|
+
/** Returns every Flux `Kustomization` declared under `root`. */
|
|
29
|
+
findKustomizations(root = ".") {
|
|
30
|
+
return findFluxKustomizations(root, this.include);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Renders every Flux `Kustomization` at `baseRef` and in the working tree at `root`,
|
|
34
|
+
* and posts the ones whose output differs as a new PR comment.
|
|
35
|
+
*
|
|
36
|
+
* `baseRef` is fetched from `origin` if it is not available locally (e.g. shallow CI clones).
|
|
37
|
+
* If the comment would exceed GitHub's size limit, the full comment is uploaded as an
|
|
38
|
+
* artifact of the current workflow run and the PR comment links to it instead.
|
|
39
|
+
*/
|
|
40
|
+
async diff({ baseRef, root = ".", owner, repository, prId, ...formatOpts }) {
|
|
41
|
+
const result = await this.collectDiff(baseRef, root);
|
|
42
|
+
const full = formatFluxDiff(result, formatOpts);
|
|
43
|
+
if (full.length <= MAX_COMMENT_LENGTH) {
|
|
44
|
+
await this.ghClient.sendPrMessage({
|
|
45
|
+
owner,
|
|
46
|
+
repository,
|
|
47
|
+
prId,
|
|
48
|
+
message: full,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const dir = await mkdtemp(path.join(tmpdir(), "flux-diff-artifact-"));
|
|
53
|
+
try {
|
|
54
|
+
const file = path.join(dir, "flux-diff.md");
|
|
55
|
+
await Bun.write(file, full);
|
|
56
|
+
const attempt = process.env.GITHUB_RUN_ATTEMPT ?? "1";
|
|
57
|
+
const { url } = await this.ghClient.uploadArtifact({
|
|
58
|
+
name: `flux-diff-pr-${prId}-attempt-${attempt}`,
|
|
59
|
+
files: [file],
|
|
60
|
+
rootDirectory: dir,
|
|
61
|
+
});
|
|
62
|
+
const message = formatFluxDiff(result, {
|
|
63
|
+
...formatOpts,
|
|
64
|
+
artifactUrl: url,
|
|
65
|
+
});
|
|
66
|
+
await this.ghClient.sendPrMessage({ owner, repository, prId, message });
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await rm(dir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async collectDiff(baseRef, root) {
|
|
73
|
+
const workdir = await mkdtemp(path.join(tmpdir(), "flux-diff-"));
|
|
74
|
+
const baseRoot = path.join(workdir, "base");
|
|
75
|
+
await $ `git -C ${root} fetch --depth=1 origin ${baseRef}`.quiet().nothrow();
|
|
76
|
+
await $ `git -C ${root} worktree add --detach ${baseRoot} ${baseRef}`.quiet();
|
|
77
|
+
try {
|
|
78
|
+
const [baseKustomizations, headKustomizations] = await Promise.all([
|
|
79
|
+
this.findKustomizations(baseRoot),
|
|
80
|
+
this.findKustomizations(root),
|
|
81
|
+
]);
|
|
82
|
+
const pairs = new Map();
|
|
83
|
+
for (const kustomization of baseKustomizations) {
|
|
84
|
+
pairs.set(label(kustomization), { base: kustomization });
|
|
85
|
+
}
|
|
86
|
+
for (const kustomization of headKustomizations) {
|
|
87
|
+
const key = label(kustomization);
|
|
88
|
+
pairs.set(key, { ...pairs.get(key), head: kustomization });
|
|
89
|
+
}
|
|
90
|
+
const changed = [];
|
|
91
|
+
const skipped = [];
|
|
92
|
+
let nextDiffId = 0;
|
|
93
|
+
const nextDiffDir = () => path.join(workdir, "diffs", String(nextDiffId++));
|
|
94
|
+
await Promise.all([...pairs.values()].map(async ({ base, head }) => {
|
|
95
|
+
const current = (head ?? base);
|
|
96
|
+
const { name, namespace } = current;
|
|
97
|
+
const unrenderable = [base, head].find((kustomization) => kustomization &&
|
|
98
|
+
!this.isLocalSource(kustomization.spec.sourceRef));
|
|
99
|
+
if (unrenderable) {
|
|
100
|
+
const { kind, name: sourceName } = unrenderable.spec.sourceRef;
|
|
101
|
+
skipped.push({
|
|
102
|
+
name,
|
|
103
|
+
namespace,
|
|
104
|
+
reason: `source \`${kind}/${sourceName}\` is not this repository`,
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const [baseRender, headRender] = await Promise.all([
|
|
109
|
+
base ? renderKustomization(baseRoot, base) : undefined,
|
|
110
|
+
head ? renderKustomization(root, head) : undefined,
|
|
111
|
+
]);
|
|
112
|
+
const buildError = headRender?.error;
|
|
113
|
+
const objects = buildError ?
|
|
114
|
+
[]
|
|
115
|
+
: await diffObjects(baseRender?.objects ?? [], headRender?.objects ?? [], nextDiffDir);
|
|
116
|
+
const unchanged = buildError ?
|
|
117
|
+
buildError === baseRender?.error
|
|
118
|
+
: objects.length === 0;
|
|
119
|
+
if (unchanged) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const notes = new Set([
|
|
123
|
+
...(baseRender?.notes ?? []),
|
|
124
|
+
...(headRender?.notes ?? []),
|
|
125
|
+
]);
|
|
126
|
+
if (baseRender?.error && !buildError) {
|
|
127
|
+
notes.add("`kustomize build` fails at the base commit, so every object is shown as added.");
|
|
128
|
+
}
|
|
129
|
+
changed.push({
|
|
130
|
+
name,
|
|
131
|
+
namespace,
|
|
132
|
+
path: current.path,
|
|
133
|
+
status: !base ? "added"
|
|
134
|
+
: !head ? "removed"
|
|
135
|
+
: "changed",
|
|
136
|
+
objects,
|
|
137
|
+
...(buildError ? { buildError } : {}),
|
|
138
|
+
additions: objects.reduce((sum, o) => sum + o.additions, 0),
|
|
139
|
+
deletions: objects.reduce((sum, o) => sum + o.deletions, 0),
|
|
140
|
+
notes: [...notes],
|
|
141
|
+
});
|
|
142
|
+
}));
|
|
143
|
+
const byLabel = (a, b) => label(a).localeCompare(label(b));
|
|
144
|
+
return {
|
|
145
|
+
baseRef,
|
|
146
|
+
changed: changed.sort(byLabel),
|
|
147
|
+
skipped: skipped.sort(byLabel),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await $ `git -C ${root} worktree remove --force ${baseRoot}`
|
|
152
|
+
.quiet()
|
|
153
|
+
.nothrow();
|
|
154
|
+
await rm(workdir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Pairs base and head objects by {@link objectKey} and diffs the ones whose rendered YAML differs. */
|
|
159
|
+
async function diffObjects(baseObjects, headObjects, nextDir) {
|
|
160
|
+
const pairs = new Map();
|
|
161
|
+
for (const object of baseObjects) {
|
|
162
|
+
pairs.set(objectKey(object), { base: object });
|
|
163
|
+
}
|
|
164
|
+
for (const object of headObjects) {
|
|
165
|
+
const k = objectKey(object);
|
|
166
|
+
pairs.set(k, { ...pairs.get(k), head: object });
|
|
167
|
+
}
|
|
168
|
+
const diffs = await Promise.all([...pairs.values()].map(async ({ base, head }) => {
|
|
169
|
+
if (base?.yaml === head?.yaml) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const current = (head ?? base);
|
|
173
|
+
const { kind, namespace, name, file } = current;
|
|
174
|
+
const diff = await unifiedDiff(nextDir(), [kind, namespace, name].filter(Boolean).join("/"), base?.yaml ?? "", head?.yaml ?? "");
|
|
175
|
+
const lines = diff.split("\n");
|
|
176
|
+
const objectDiff = {
|
|
177
|
+
kind,
|
|
178
|
+
namespace,
|
|
179
|
+
name,
|
|
180
|
+
...(file ? { file } : {}),
|
|
181
|
+
status: !base ? "added"
|
|
182
|
+
: !head ? "removed"
|
|
183
|
+
: "changed",
|
|
184
|
+
diff,
|
|
185
|
+
additions: lines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
|
|
186
|
+
deletions: lines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
|
|
187
|
+
};
|
|
188
|
+
return objectDiff;
|
|
189
|
+
}));
|
|
190
|
+
return diffs
|
|
191
|
+
.filter((diff) => diff !== null)
|
|
192
|
+
.sort((a, b) =>
|
|
193
|
+
// objects without a recorded file sort last
|
|
194
|
+
(a.file ?? "\uffff").localeCompare(b.file ?? "\uffff") ||
|
|
195
|
+
a.kind.localeCompare(b.kind) ||
|
|
196
|
+
a.namespace.localeCompare(b.namespace) ||
|
|
197
|
+
a.name.localeCompare(b.name));
|
|
198
|
+
}
|
|
199
|
+
async function unifiedDiff(dir, targetPath, baseOutput, headOutput) {
|
|
200
|
+
await mkdir(dir, { recursive: true });
|
|
201
|
+
const baseFile = path.join(dir, "base.yaml");
|
|
202
|
+
const headFile = path.join(dir, "head.yaml");
|
|
203
|
+
await Promise.all([
|
|
204
|
+
Bun.write(baseFile, baseOutput),
|
|
205
|
+
Bun.write(headFile, headOutput),
|
|
206
|
+
]);
|
|
207
|
+
const result = await $ `diff -u --label ${`a/${targetPath}`} --label ${`b/${targetPath}`} ${baseFile} ${headFile}`
|
|
208
|
+
.quiet()
|
|
209
|
+
.nothrow();
|
|
210
|
+
// diff exits 1 when the files differ and 2 on error
|
|
211
|
+
if (result.exitCode > 1) {
|
|
212
|
+
throw new Error(`diff failed for ${targetPath}: ${result.stderr}`);
|
|
213
|
+
}
|
|
214
|
+
return result.stdout.toString();
|
|
215
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type FluxKustomization } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Returns every Flux `Kustomization` declared in files under `root` matching `include`,
|
|
4
|
+
* sorted by namespace then name. Hidden directories and `node_modules` are never scanned.
|
|
5
|
+
*/
|
|
6
|
+
export declare function findFluxKustomizations(root: string, include: string[]): Promise<FluxKustomization[]>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Glob } from "bun";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseAllDocuments } from "yaml";
|
|
4
|
+
import { fluxKustomizationSchema } from "./types";
|
|
5
|
+
const FLUX_API_GROUP = "kustomize.toolkit.fluxcd.io/";
|
|
6
|
+
/**
|
|
7
|
+
* Returns every Flux `Kustomization` declared in files under `root` matching `include`,
|
|
8
|
+
* sorted by namespace then name. Hidden directories and `node_modules` are never scanned.
|
|
9
|
+
*/
|
|
10
|
+
export async function findFluxKustomizations(root, include) {
|
|
11
|
+
const files = new Set();
|
|
12
|
+
for (const pattern of include) {
|
|
13
|
+
for await (const file of new Glob(pattern).scan({ cwd: root })) {
|
|
14
|
+
if (!file.split(path.sep).includes("node_modules")) {
|
|
15
|
+
files.add(file);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const found = await Promise.all([...files].map(async (file) => {
|
|
20
|
+
const content = await Bun.file(path.join(root, file)).text();
|
|
21
|
+
// cheap pre-filter; most YAML in a manifest repo is not a Flux Kustomization
|
|
22
|
+
if (!content.includes(FLUX_API_GROUP)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return parseFile(file, content);
|
|
26
|
+
}));
|
|
27
|
+
return found
|
|
28
|
+
.flat()
|
|
29
|
+
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.name.localeCompare(b.name));
|
|
30
|
+
}
|
|
31
|
+
function parseFile(file, content) {
|
|
32
|
+
const kustomizations = [];
|
|
33
|
+
for (const doc of parseAllDocuments(content)) {
|
|
34
|
+
if (doc.errors.length > 0) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const parsed = fluxKustomizationSchema.safeParse(doc.toJS());
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const { metadata, spec } = parsed.data;
|
|
42
|
+
kustomizations.push({
|
|
43
|
+
file,
|
|
44
|
+
name: metadata.name,
|
|
45
|
+
namespace: metadata.namespace ?? "",
|
|
46
|
+
path: path.normalize(spec.path ?? "."),
|
|
47
|
+
spec,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return kustomizations;
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FluxDiffResult } from "./types";
|
|
2
|
+
export type FormatFluxDiffOpts = {
|
|
3
|
+
/** @default "Flux diff" */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** extra markdown shown under the title, e.g. how secrets are displayed */
|
|
6
|
+
description?: string;
|
|
7
|
+
/**
|
|
8
|
+
* when set, diffs are replaced by a summary and a link here
|
|
9
|
+
* (used when the full comment would exceed GitHub's size limit)
|
|
10
|
+
*/
|
|
11
|
+
artifactUrl?: string;
|
|
12
|
+
};
|
|
13
|
+
/** Formats a {@link FluxDiffResult} as a markdown PR comment. */
|
|
14
|
+
export declare function formatFluxDiff(result: FluxDiffResult, { title, description, artifactUrl }?: FormatFluxDiffOpts): string;
|
|
15
|
+
export declare function label({ name, namespace, }: {
|
|
16
|
+
name: string;
|
|
17
|
+
namespace: string;
|
|
18
|
+
}): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Formats a {@link FluxDiffResult} as a markdown PR comment. */
|
|
2
|
+
export function formatFluxDiff(result, { title = "Flux diff", description, artifactUrl } = {}) {
|
|
3
|
+
const base = `\`${result.baseRef.slice(0, 7)}\``;
|
|
4
|
+
const lines = [`### ${title}`, ""];
|
|
5
|
+
if (description) {
|
|
6
|
+
lines.push(description, "");
|
|
7
|
+
}
|
|
8
|
+
lines.push(result.changed.length === 0 ?
|
|
9
|
+
`No Flux Kustomization renders differently against ${base}.`
|
|
10
|
+
: `${result.changed.length} Flux Kustomization(s) render differently against ${base}.`, "");
|
|
11
|
+
if (artifactUrl) {
|
|
12
|
+
lines.push(`> [!WARNING]`, `> The rendered diff is too long for a GitHub comment. [Download the full diff from the workflow run](${artifactUrl}).`, "");
|
|
13
|
+
for (const change of result.changed) {
|
|
14
|
+
lines.push(`- ${summary(change)}`);
|
|
15
|
+
if (change.buildError) {
|
|
16
|
+
lines.push(" - `kustomize build` failed");
|
|
17
|
+
}
|
|
18
|
+
lines.push(...change.objects.map((object) => ` - ${objectSummary(object)}`));
|
|
19
|
+
}
|
|
20
|
+
lines.push("");
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
for (const change of result.changed) {
|
|
24
|
+
lines.push(`<details><summary>${summary(change)}</summary>`, "", ...change.notes.map((note) => `> ${note}\n`));
|
|
25
|
+
if (change.buildError) {
|
|
26
|
+
lines.push("`kustomize build` failed:", "", "````text", change.buildError, "````", "");
|
|
27
|
+
}
|
|
28
|
+
for (const object of change.objects) {
|
|
29
|
+
lines.push(objectSummary(object), "", "````diff", object.diff.trimEnd(), "````", "");
|
|
30
|
+
}
|
|
31
|
+
lines.push("</details>", "");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (result.skipped.length > 0) {
|
|
35
|
+
lines.push(`<details><summary>${result.skipped.length} Flux Kustomization(s) not rendered</summary>`, "", ...result.skipped.map((skipped) => `- <code>${label(skipped)}</code>: ${skipped.reason}`), "", "</details>", "");
|
|
36
|
+
}
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
39
|
+
export function label({ name, namespace, }) {
|
|
40
|
+
return namespace ? `${namespace}/${name}` : name;
|
|
41
|
+
}
|
|
42
|
+
function summary(change) {
|
|
43
|
+
const counts = change.buildError ? "build failed" : (`${change.objects.length} object(s), +${change.additions} −${change.deletions}`);
|
|
44
|
+
return `<code>${label(change)}</code> — <code>${change.path}</code> (${change.status}, ${counts})`;
|
|
45
|
+
}
|
|
46
|
+
function objectSummary(object) {
|
|
47
|
+
const name = [object.namespace, object.name].filter(Boolean).join("/");
|
|
48
|
+
const file = object.file ? ` — \`${object.file}\`` : "";
|
|
49
|
+
return `**\`${object.kind}\` \`${name}\`**${file} (${object.status}, +${object.additions} −${object.deletions})`;
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./client";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FluxKustomization } from "./types";
|
|
2
|
+
export type RenderedObject = {
|
|
3
|
+
apiVersion: string;
|
|
4
|
+
kind: string;
|
|
5
|
+
namespace: string;
|
|
6
|
+
name: string;
|
|
7
|
+
/**
|
|
8
|
+
* repository-relative file that defines the object (for generated objects, the
|
|
9
|
+
* kustomization.yaml whose generator creates it); `undefined` if kustomize did not record one
|
|
10
|
+
*/
|
|
11
|
+
file?: string;
|
|
12
|
+
/** created by a ConfigMap/Secret generator, so `name` may end in a content hash */
|
|
13
|
+
generated: boolean;
|
|
14
|
+
/** the object's rendered YAML, without the origin annotation */
|
|
15
|
+
yaml: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Identifies the same object across two renders: API group, kind, namespace and name.
|
|
19
|
+
* An apiVersion bump within a group keeps the key; generated objects drop the content hash
|
|
20
|
+
* from their name so an edited ConfigMap/Secret pairs with its previous version.
|
|
21
|
+
*/
|
|
22
|
+
export declare function objectKey(object: RenderedObject): string;
|
|
23
|
+
type RenderedKustomization = {
|
|
24
|
+
/** rendered objects, in kustomize's output order; empty when `error` is set */
|
|
25
|
+
objects: RenderedObject[];
|
|
26
|
+
/** `kustomize build` error output, when the build failed */
|
|
27
|
+
error?: string;
|
|
28
|
+
notes: string[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Renders a Flux `Kustomization` the way kustomize-controller does: `kustomize build` of
|
|
32
|
+
* `spec.path` with the overlay fields from the spec (`targetNamespace`, `namePrefix`,
|
|
33
|
+
* `nameSuffix`, `components`, `images`, `patches`, `commonMetadata`) applied on top,
|
|
34
|
+
* followed by `postBuild` variable substitution.
|
|
35
|
+
*
|
|
36
|
+
* Each rendered object records the file that defines it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function renderKustomization(root: string, kustomization: FluxKustomization): Promise<RenderedKustomization>;
|
|
39
|
+
/**
|
|
40
|
+
* Applies Flux `postBuild` substitution to rendered multi-document YAML. Documents annotated
|
|
41
|
+
* with `kustomize.toolkit.fluxcd.io/substitute: disabled` are left untouched.
|
|
42
|
+
*
|
|
43
|
+
* Variables without a value and without a default become empty strings, as in Flux, unless
|
|
44
|
+
* `keepUnresolved` is set (used when some values come from `substituteFrom` and are unknown).
|
|
45
|
+
*/
|
|
46
|
+
export declare function substituteVariables(input: string, vars: Record<string, string>, { keepUnresolved }: {
|
|
47
|
+
keepUnresolved: boolean;
|
|
48
|
+
}): {
|
|
49
|
+
output: string;
|
|
50
|
+
unresolved: string[];
|
|
51
|
+
};
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import yaml from "yaml";
|
|
5
|
+
const KUSTOMIZATION_FILES = [
|
|
6
|
+
"kustomization.yaml",
|
|
7
|
+
"kustomization.yml",
|
|
8
|
+
"Kustomization",
|
|
9
|
+
];
|
|
10
|
+
const SUBSTITUTE_DISABLED = /kustomize\.toolkit\.fluxcd\.io\/substitute:\s*["']?disabled["']?/;
|
|
11
|
+
/** `${VAR}`, `${VAR:-default}`, `${VAR-default}`, `${VAR:=default}`, `${VAR=default}`; `$${VAR}` escapes. */
|
|
12
|
+
const VARIABLE = /\$(\$)?\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-=])([^}]*))?\}/g;
|
|
13
|
+
/** kustomize's `originAnnotations` build metadata; records the file that defines each object. */
|
|
14
|
+
const ORIGIN_ANNOTATION = "config.kubernetes.io/origin";
|
|
15
|
+
const GENERATORS = ["ConfigMapGenerator", "SecretGenerator"];
|
|
16
|
+
/** kustomize's name hash: 10 characters from its vowel-free alphabet, appended last. */
|
|
17
|
+
const NAME_HASH = /-[2456789bcdfghkmt]{10}$/;
|
|
18
|
+
/**
|
|
19
|
+
* Identifies the same object across two renders: API group, kind, namespace and name.
|
|
20
|
+
* An apiVersion bump within a group keeps the key; generated objects drop the content hash
|
|
21
|
+
* from their name so an edited ConfigMap/Secret pairs with its previous version.
|
|
22
|
+
*/
|
|
23
|
+
export function objectKey(object) {
|
|
24
|
+
const group = object.apiVersion.includes("/") ? object.apiVersion.split("/")[0] : "";
|
|
25
|
+
const name = object.generated ? object.name.replace(NAME_HASH, "") : object.name;
|
|
26
|
+
return [group, object.kind, object.namespace, name].join("\0");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Renders a Flux `Kustomization` the way kustomize-controller does: `kustomize build` of
|
|
30
|
+
* `spec.path` with the overlay fields from the spec (`targetNamespace`, `namePrefix`,
|
|
31
|
+
* `nameSuffix`, `components`, `images`, `patches`, `commonMetadata`) applied on top,
|
|
32
|
+
* followed by `postBuild` variable substitution.
|
|
33
|
+
*
|
|
34
|
+
* Each rendered object records the file that defines it.
|
|
35
|
+
*/
|
|
36
|
+
export async function renderKustomization(root, kustomization) {
|
|
37
|
+
const notes = [];
|
|
38
|
+
const { spec } = kustomization;
|
|
39
|
+
const targetDir = path.join(root, kustomization.path);
|
|
40
|
+
// kustomize rejects absolute resource paths, so the wrapper must live inside `root`
|
|
41
|
+
const wrapper = await mkdtemp(path.join(root, ".flux-render-"));
|
|
42
|
+
try {
|
|
43
|
+
const relative = (to) => path.relative(wrapper, to);
|
|
44
|
+
let resources = [relative(targetDir)];
|
|
45
|
+
if (!(await hasKustomizationFile(targetDir))) {
|
|
46
|
+
resources = (await generateResources(targetDir)).map(relative);
|
|
47
|
+
notes.push(`\`${kustomization.path}\` has no kustomization.yaml; resources were generated the way Flux does.`);
|
|
48
|
+
}
|
|
49
|
+
const wrapperKustomization = {
|
|
50
|
+
apiVersion: "kustomize.config.k8s.io/v1beta1",
|
|
51
|
+
kind: "Kustomization",
|
|
52
|
+
namespace: spec.targetNamespace,
|
|
53
|
+
namePrefix: spec.namePrefix,
|
|
54
|
+
nameSuffix: spec.nameSuffix,
|
|
55
|
+
resources,
|
|
56
|
+
components: spec.components?.map((component) => relative(path.join(targetDir, component))),
|
|
57
|
+
images: spec.images,
|
|
58
|
+
patches: spec.patches,
|
|
59
|
+
labels: spec.commonMetadata?.labels ?
|
|
60
|
+
[{ pairs: spec.commonMetadata.labels, includeSelectors: false }]
|
|
61
|
+
: undefined,
|
|
62
|
+
commonAnnotations: spec.commonMetadata?.annotations,
|
|
63
|
+
buildMetadata: ["originAnnotations"],
|
|
64
|
+
};
|
|
65
|
+
await Bun.write(path.join(wrapper, "kustomization.yaml"), yaml.stringify(wrapperKustomization));
|
|
66
|
+
const result = await $ `kustomize build --load-restrictor LoadRestrictionsNone ${wrapper}`
|
|
67
|
+
.quiet()
|
|
68
|
+
.nothrow();
|
|
69
|
+
if (result.exitCode !== 0) {
|
|
70
|
+
return { objects: [], error: result.stderr.toString().trim(), notes };
|
|
71
|
+
}
|
|
72
|
+
let output = result.stdout.toString();
|
|
73
|
+
if (spec.postBuild) {
|
|
74
|
+
const hasExternalValues = (spec.postBuild.substituteFrom ?? []).length > 0;
|
|
75
|
+
const substituted = substituteVariables(output, spec.postBuild.substitute ?? {}, { keepUnresolved: hasExternalValues });
|
|
76
|
+
output = substituted.output;
|
|
77
|
+
if (substituted.unresolved.length > 0) {
|
|
78
|
+
notes.push(`\`postBuild.substituteFrom\` values live in the cluster and were left unresolved: ${substituted.unresolved
|
|
79
|
+
.map((name) => `\`\${${name}}\``)
|
|
80
|
+
.join(", ")}.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const docs = yaml.parseAllDocuments(output);
|
|
84
|
+
const invalid = docs.find((doc) => doc.errors.length > 0);
|
|
85
|
+
if (invalid) {
|
|
86
|
+
return {
|
|
87
|
+
objects: [],
|
|
88
|
+
error: `rendered output is not valid YAML: ${invalid.errors[0]?.message}`,
|
|
89
|
+
notes,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const objects = docs
|
|
93
|
+
.filter((doc) => doc.contents !== null)
|
|
94
|
+
.map((doc) => toRenderedObject(doc, root, wrapper));
|
|
95
|
+
return { objects, notes };
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await rm(wrapper, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function toRenderedObject(doc, root, wrapper) {
|
|
102
|
+
const raw = doc.getIn(["metadata", "annotations", ORIGIN_ANNOTATION]);
|
|
103
|
+
doc.deleteIn(["metadata", "annotations", ORIGIN_ANNOTATION]);
|
|
104
|
+
const annotations = doc.getIn(["metadata", "annotations"]);
|
|
105
|
+
if (yaml.isMap(annotations) && annotations.items.length === 0) {
|
|
106
|
+
doc.deleteIn(["metadata", "annotations"]);
|
|
107
|
+
}
|
|
108
|
+
const origin = typeof raw === "string" ? yaml.parse(raw) : null;
|
|
109
|
+
const value = (doc.toJS() ?? {});
|
|
110
|
+
return {
|
|
111
|
+
apiVersion: String(value.apiVersion ?? ""),
|
|
112
|
+
kind: String(value.kind ?? ""),
|
|
113
|
+
namespace: String(value.metadata?.namespace ?? ""),
|
|
114
|
+
name: String(value.metadata?.name ?? ""),
|
|
115
|
+
file: origin ? originFile(origin, root, wrapper) : undefined,
|
|
116
|
+
generated: GENERATORS.includes(origin?.configuredBy?.kind ?? ""),
|
|
117
|
+
yaml: doc.toString({ lineWidth: 0 }).replace(/^---\n/, ""),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Converts an origin annotation (`path`, or `configuredIn` for generated objects, relative to
|
|
122
|
+
* the wrapper kustomization) to a repository-relative path. Remote origins are returned as
|
|
123
|
+
* `<repo>//<path>?ref=<ref>`.
|
|
124
|
+
*/
|
|
125
|
+
function originFile(origin, root, wrapper) {
|
|
126
|
+
const file = origin.path ?? origin.configuredIn;
|
|
127
|
+
if (origin.repo) {
|
|
128
|
+
return `${origin.repo}//${file ?? ""}${origin.ref ? `?ref=${origin.ref}` : ""}`;
|
|
129
|
+
}
|
|
130
|
+
return file ? path.relative(root, path.resolve(wrapper, file)) : undefined;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Applies Flux `postBuild` substitution to rendered multi-document YAML. Documents annotated
|
|
134
|
+
* with `kustomize.toolkit.fluxcd.io/substitute: disabled` are left untouched.
|
|
135
|
+
*
|
|
136
|
+
* Variables without a value and without a default become empty strings, as in Flux, unless
|
|
137
|
+
* `keepUnresolved` is set (used when some values come from `substituteFrom` and are unknown).
|
|
138
|
+
*/
|
|
139
|
+
export function substituteVariables(input, vars, { keepUnresolved }) {
|
|
140
|
+
const unresolved = new Set();
|
|
141
|
+
const output = input
|
|
142
|
+
.split(/^(?=---$)/m)
|
|
143
|
+
.map((doc) => {
|
|
144
|
+
if (SUBSTITUTE_DISABLED.test(doc)) {
|
|
145
|
+
return doc;
|
|
146
|
+
}
|
|
147
|
+
return doc.replace(VARIABLE, (match, escape, name, operator, fallback) => {
|
|
148
|
+
if (escape) {
|
|
149
|
+
return match.slice(1);
|
|
150
|
+
}
|
|
151
|
+
const value = vars[name];
|
|
152
|
+
if (operator !== undefined) {
|
|
153
|
+
const useValue = value !== undefined &&
|
|
154
|
+
(!operator.startsWith(":") || value !== "");
|
|
155
|
+
return useValue ? value : (fallback ?? "");
|
|
156
|
+
}
|
|
157
|
+
if (value !== undefined) {
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
if (keepUnresolved) {
|
|
161
|
+
unresolved.add(name);
|
|
162
|
+
return match;
|
|
163
|
+
}
|
|
164
|
+
return "";
|
|
165
|
+
});
|
|
166
|
+
})
|
|
167
|
+
.join("");
|
|
168
|
+
return { output, unresolved: [...unresolved].sort() };
|
|
169
|
+
}
|
|
170
|
+
async function hasKustomizationFile(dir) {
|
|
171
|
+
const entries = await readdir(dir).catch(() => []);
|
|
172
|
+
return entries.some((entry) => KUSTOMIZATION_FILES.includes(entry));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Mirrors Flux's generated kustomization for a path without one: every Kubernetes manifest
|
|
176
|
+
* under `dir`, with subdirectories that have their own kustomization included as a whole.
|
|
177
|
+
*/
|
|
178
|
+
async function generateResources(dir) {
|
|
179
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
180
|
+
const resources = [];
|
|
181
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
182
|
+
const full = path.join(dir, entry.name);
|
|
183
|
+
if (entry.isDirectory()) {
|
|
184
|
+
if (await hasKustomizationFile(full)) {
|
|
185
|
+
resources.push(full);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
resources.push(...(await generateResources(full)));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (/\.ya?ml$/.test(entry.name) && (await isManifest(full))) {
|
|
192
|
+
resources.push(full);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return resources;
|
|
196
|
+
}
|
|
197
|
+
async function isManifest(file) {
|
|
198
|
+
const docs = yaml.parseAllDocuments(await Bun.file(file).text());
|
|
199
|
+
return docs.some((doc) => {
|
|
200
|
+
const value = doc.errors.length === 0 ? doc.toJS() : undefined;
|
|
201
|
+
return (typeof value?.apiVersion === "string" && typeof value?.kind === "string");
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The subset of a Flux `Kustomization` (`kustomize.toolkit.fluxcd.io`) that affects rendered output.
|
|
4
|
+
*/
|
|
5
|
+
export declare const fluxKustomizationSchema: z.ZodObject<{
|
|
6
|
+
apiVersion: z.ZodString;
|
|
7
|
+
kind: z.ZodLiteral<"Kustomization">;
|
|
8
|
+
metadata: z.ZodObject<{
|
|
9
|
+
name: z.ZodString;
|
|
10
|
+
namespace: z.ZodOptional<z.ZodString>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
spec: z.ZodObject<{
|
|
13
|
+
path: z.ZodOptional<z.ZodString>;
|
|
14
|
+
sourceRef: z.ZodObject<{
|
|
15
|
+
kind: z.ZodString;
|
|
16
|
+
name: z.ZodString;
|
|
17
|
+
namespace: z.ZodOptional<z.ZodString>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
targetNamespace: z.ZodOptional<z.ZodString>;
|
|
20
|
+
namePrefix: z.ZodOptional<z.ZodString>;
|
|
21
|
+
nameSuffix: z.ZodOptional<z.ZodString>;
|
|
22
|
+
components: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
23
|
+
images: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
24
|
+
name: z.ZodString;
|
|
25
|
+
newName: z.ZodOptional<z.ZodString>;
|
|
26
|
+
newTag: z.ZodOptional<z.ZodString>;
|
|
27
|
+
digest: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, z.core.$strip>>>;
|
|
29
|
+
patches: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
30
|
+
patch: z.ZodString;
|
|
31
|
+
target: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
32
|
+
}, z.core.$strip>>>;
|
|
33
|
+
commonMetadata: z.ZodOptional<z.ZodObject<{
|
|
34
|
+
labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
35
|
+
annotations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
36
|
+
}, z.core.$strip>>;
|
|
37
|
+
postBuild: z.ZodOptional<z.ZodObject<{
|
|
38
|
+
substitute: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
39
|
+
substituteFrom: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
40
|
+
}, z.core.$strip>>;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
type FluxKustomizationSpec = z.infer<typeof fluxKustomizationSchema>["spec"];
|
|
44
|
+
/** A Flux `Kustomization` found in the repository. */
|
|
45
|
+
export type FluxKustomization = {
|
|
46
|
+
/** repository-relative file the Kustomization is declared in */
|
|
47
|
+
file: string;
|
|
48
|
+
name: string;
|
|
49
|
+
/** `metadata.namespace`, or `""` when the manifest leaves it to an enclosing kustomization */
|
|
50
|
+
namespace: string;
|
|
51
|
+
/** normalized, repository-relative `spec.path` */
|
|
52
|
+
path: string;
|
|
53
|
+
spec: FluxKustomizationSpec;
|
|
54
|
+
};
|
|
55
|
+
export type FluxSourceRef = FluxKustomizationSpec["sourceRef"];
|
|
56
|
+
type FluxDiffStatus = "added" | "changed" | "removed";
|
|
57
|
+
/** A rendered Kubernetes object whose output differs between base and head. */
|
|
58
|
+
export type FluxObjectDiff = {
|
|
59
|
+
kind: string;
|
|
60
|
+
/** `""` for cluster-scoped objects */
|
|
61
|
+
namespace: string;
|
|
62
|
+
name: string;
|
|
63
|
+
/**
|
|
64
|
+
* repository-relative file that defines the object, from kustomize's origin annotations.
|
|
65
|
+
* Generated objects (ConfigMap/Secret generators) point at the kustomization.yaml that
|
|
66
|
+
* generates them. Files that only patch the object are not recorded.
|
|
67
|
+
*/
|
|
68
|
+
file?: string;
|
|
69
|
+
status: FluxDiffStatus;
|
|
70
|
+
/** unified diff of the object's rendered YAML */
|
|
71
|
+
diff: string;
|
|
72
|
+
additions: number;
|
|
73
|
+
deletions: number;
|
|
74
|
+
};
|
|
75
|
+
export type FluxKustomizationDiff = {
|
|
76
|
+
name: string;
|
|
77
|
+
namespace: string;
|
|
78
|
+
path: string;
|
|
79
|
+
status: FluxDiffStatus;
|
|
80
|
+
/** changed objects, sorted by file then kind, namespace and name */
|
|
81
|
+
objects: FluxObjectDiff[];
|
|
82
|
+
/** `kustomize build` error at head; `objects` is empty when set */
|
|
83
|
+
buildError?: string;
|
|
84
|
+
/** totals across `objects` */
|
|
85
|
+
additions: number;
|
|
86
|
+
deletions: number;
|
|
87
|
+
/** caveats about how faithful the render is, e.g. unresolvable `substituteFrom` variables */
|
|
88
|
+
notes: string[];
|
|
89
|
+
};
|
|
90
|
+
export type FluxSkippedKustomization = {
|
|
91
|
+
name: string;
|
|
92
|
+
namespace: string;
|
|
93
|
+
reason: string;
|
|
94
|
+
};
|
|
95
|
+
export type FluxDiffResult = {
|
|
96
|
+
baseRef: string;
|
|
97
|
+
/** Kustomizations whose rendered output differs, sorted by name */
|
|
98
|
+
changed: FluxKustomizationDiff[];
|
|
99
|
+
/** Kustomizations that could not be rendered locally */
|
|
100
|
+
skipped: FluxSkippedKustomization[];
|
|
101
|
+
};
|
|
102
|
+
export type FluxClientOpts = {
|
|
103
|
+
/**
|
|
104
|
+
* Globs (relative to the repository root) scanned for Flux Kustomizations.
|
|
105
|
+
*
|
|
106
|
+
* @default ["**\/*.yaml", "**\/*.yml"]
|
|
107
|
+
*/
|
|
108
|
+
include?: string[];
|
|
109
|
+
/**
|
|
110
|
+
* Decides whether a Kustomization's `sourceRef` points at the repository being diffed.
|
|
111
|
+
* Kustomizations with any other source are reported as skipped.
|
|
112
|
+
*
|
|
113
|
+
* @default every `GitRepository` source
|
|
114
|
+
*/
|
|
115
|
+
isLocalSource?: (sourceRef: FluxSourceRef) => boolean;
|
|
116
|
+
};
|
|
117
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The subset of a Flux `Kustomization` (`kustomize.toolkit.fluxcd.io`) that affects rendered output.
|
|
4
|
+
*/
|
|
5
|
+
export const fluxKustomizationSchema = z.object({
|
|
6
|
+
apiVersion: z.string().startsWith("kustomize.toolkit.fluxcd.io/"),
|
|
7
|
+
kind: z.literal("Kustomization"),
|
|
8
|
+
metadata: z.object({
|
|
9
|
+
name: z.string(),
|
|
10
|
+
namespace: z.string().optional(),
|
|
11
|
+
}),
|
|
12
|
+
spec: z.object({
|
|
13
|
+
path: z.string().optional(),
|
|
14
|
+
sourceRef: z.object({
|
|
15
|
+
kind: z.string(),
|
|
16
|
+
name: z.string(),
|
|
17
|
+
namespace: z.string().optional(),
|
|
18
|
+
}),
|
|
19
|
+
targetNamespace: z.string().optional(),
|
|
20
|
+
namePrefix: z.string().optional(),
|
|
21
|
+
nameSuffix: z.string().optional(),
|
|
22
|
+
components: z.array(z.string()).optional(),
|
|
23
|
+
images: z
|
|
24
|
+
.array(z.object({
|
|
25
|
+
name: z.string(),
|
|
26
|
+
newName: z.string().optional(),
|
|
27
|
+
newTag: z.string().optional(),
|
|
28
|
+
digest: z.string().optional(),
|
|
29
|
+
}))
|
|
30
|
+
.optional(),
|
|
31
|
+
patches: z
|
|
32
|
+
.array(z.object({
|
|
33
|
+
patch: z.string(),
|
|
34
|
+
target: z.record(z.string(), z.unknown()).optional(),
|
|
35
|
+
}))
|
|
36
|
+
.optional(),
|
|
37
|
+
commonMetadata: z
|
|
38
|
+
.object({
|
|
39
|
+
labels: z.record(z.string(), z.string()).optional(),
|
|
40
|
+
annotations: z.record(z.string(), z.string()).optional(),
|
|
41
|
+
})
|
|
42
|
+
.optional(),
|
|
43
|
+
postBuild: z
|
|
44
|
+
.object({
|
|
45
|
+
substitute: z.record(z.string(), z.string()).optional(),
|
|
46
|
+
substituteFrom: z.array(z.unknown()).optional(),
|
|
47
|
+
})
|
|
48
|
+
.optional(),
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare class GitHubArtifactManager {
|
|
2
|
+
private readonly client;
|
|
3
|
+
constructor();
|
|
4
|
+
/**
|
|
5
|
+
* Uploads `files` as an artifact of the current workflow run and returns a link to it.
|
|
6
|
+
*
|
|
7
|
+
* @note must run inside a GitHub Actions job with the runtime token exposed
|
|
8
|
+
* (the `setup` action automatically exposes it).
|
|
9
|
+
*/
|
|
10
|
+
uploadArtifact({ name, files, rootDirectory, retentionDays, }: {
|
|
11
|
+
/**
|
|
12
|
+
* must be unique within the workflow run
|
|
13
|
+
*/
|
|
14
|
+
name: string;
|
|
15
|
+
/**
|
|
16
|
+
* absolute paths of the files to upload
|
|
17
|
+
*/
|
|
18
|
+
files: string[];
|
|
19
|
+
/**
|
|
20
|
+
* paths inside the artifact are relative to this directory
|
|
21
|
+
*/
|
|
22
|
+
rootDirectory: string;
|
|
23
|
+
retentionDays?: number;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
id: number;
|
|
26
|
+
url: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DefaultArtifactClient } from "@actions/artifact";
|
|
2
|
+
export class GitHubArtifactManager {
|
|
3
|
+
client;
|
|
4
|
+
constructor() {
|
|
5
|
+
this.client = new DefaultArtifactClient();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Uploads `files` as an artifact of the current workflow run and returns a link to it.
|
|
9
|
+
*
|
|
10
|
+
* @note must run inside a GitHub Actions job with the runtime token exposed
|
|
11
|
+
* (the `setup` action automatically exposes it).
|
|
12
|
+
*/
|
|
13
|
+
async uploadArtifact({ name, files, rootDirectory, retentionDays, }) {
|
|
14
|
+
const serverUrl = process.env.GITHUB_SERVER_URL;
|
|
15
|
+
const repository = process.env.GITHUB_REPOSITORY;
|
|
16
|
+
const runId = process.env.GITHUB_RUN_ID;
|
|
17
|
+
if (!serverUrl || !repository || !runId) {
|
|
18
|
+
throw new Error("uploadArtifact must run inside a GitHub Actions job (missing GITHUB_SERVER_URL, GITHUB_REPOSITORY or GITHUB_RUN_ID)");
|
|
19
|
+
}
|
|
20
|
+
const { id } = await this.client.uploadArtifact(name, files, rootDirectory, { retentionDays });
|
|
21
|
+
if (id === undefined) {
|
|
22
|
+
throw new Error(`Artifact ${name} was uploaded but GitHub returned no id`);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
url: `${serverUrl}/${repository}/actions/runs/${runId}/artifacts/${id}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/gh/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GitHubArtifactManager } from "./artifact";
|
|
1
2
|
import { GitHubOutputManager } from "./output";
|
|
2
3
|
import { GitHubPRManager } from "./pr";
|
|
3
4
|
import { GitHubTagManager } from "./tag";
|
|
@@ -6,6 +7,7 @@ export declare class GitHubClient {
|
|
|
6
7
|
private readonly client;
|
|
7
8
|
private readonly isExplicitToken;
|
|
8
9
|
private readonly tagManager;
|
|
10
|
+
private readonly artifactManager;
|
|
9
11
|
private readonly outputManager;
|
|
10
12
|
private readonly prManager;
|
|
11
13
|
private readonly teamManager;
|
|
@@ -82,6 +84,15 @@ export declare class GitHubClient {
|
|
|
82
84
|
*/
|
|
83
85
|
updateK8sTagWithPR(...args: Parameters<GitHubPRManager["updateK8sTagWithPR"]>): Promise<void>;
|
|
84
86
|
sendPrMessage(...args: Parameters<GitHubPRManager["sendPrMessage"]>): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Uploads files as an artifact of the current workflow run and returns its download URL.
|
|
89
|
+
*
|
|
90
|
+
* @note requires the Actions runtime token, which the `setup` action exposes.
|
|
91
|
+
*/
|
|
92
|
+
uploadArtifact(...args: Parameters<GitHubArtifactManager["uploadArtifact"]>): Promise<{
|
|
93
|
+
id: number;
|
|
94
|
+
url: string;
|
|
95
|
+
}>;
|
|
85
96
|
/**
|
|
86
97
|
* @note if the PR doesn't exist yet, just use `updateK8sTagWithPR` instead, since it
|
|
87
98
|
* creates the PR with the necessary changes and merges it for you in one call.
|
package/dist/gh/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAppAuth } from "@octokit/auth-app";
|
|
2
2
|
import { Octokit } from "@octokit/rest";
|
|
3
|
+
import { GitHubArtifactManager } from "./artifact";
|
|
3
4
|
import { GitHubOutputManager } from "./output";
|
|
4
5
|
import { GitHubPRManager } from "./pr";
|
|
5
6
|
import { GitHubTagManager } from "./tag";
|
|
@@ -8,6 +9,7 @@ export class GitHubClient {
|
|
|
8
9
|
client;
|
|
9
10
|
isExplicitToken;
|
|
10
11
|
tagManager;
|
|
12
|
+
artifactManager;
|
|
11
13
|
outputManager;
|
|
12
14
|
prManager;
|
|
13
15
|
teamManager;
|
|
@@ -15,6 +17,7 @@ export class GitHubClient {
|
|
|
15
17
|
this.client = client;
|
|
16
18
|
this.isExplicitToken = isExplicitToken;
|
|
17
19
|
this.tagManager = new GitHubTagManager(this.client, this.isExplicitToken);
|
|
20
|
+
this.artifactManager = new GitHubArtifactManager();
|
|
18
21
|
this.outputManager = new GitHubOutputManager();
|
|
19
22
|
this.prManager = new GitHubPRManager(this.client);
|
|
20
23
|
this.teamManager = new GitHubTeamManager(this.client);
|
|
@@ -112,6 +115,14 @@ export class GitHubClient {
|
|
|
112
115
|
sendPrMessage(...args) {
|
|
113
116
|
return this.prManager.sendPrMessage(...args);
|
|
114
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Uploads files as an artifact of the current workflow run and returns its download URL.
|
|
120
|
+
*
|
|
121
|
+
* @note requires the Actions runtime token, which the `setup` action exposes.
|
|
122
|
+
*/
|
|
123
|
+
uploadArtifact(...args) {
|
|
124
|
+
return this.artifactManager.uploadArtifact(...args);
|
|
125
|
+
}
|
|
115
126
|
/**
|
|
116
127
|
* @note if the PR doesn't exist yet, just use `updateK8sTagWithPR` instead, since it
|
|
117
128
|
* creates the PR with the necessary changes and merges it for you in one call.
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"type": "module",
|
|
4
4
|
"author": "Tahmid Ahmed",
|
|
5
5
|
"description": "A collection of Bun shell scripts that can be re-used in various CICD pipelines.",
|
|
6
|
-
"version": "1.0.
|
|
6
|
+
"version": "1.0.91-beta.gabf5f4ea",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "git+https://github.com/tahminator/pipeline.git"
|
|
9
9
|
},
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"typescript": "^5"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
+
"@actions/artifact": "^6.2.1",
|
|
50
51
|
"@octokit/auth-app": "^8.2.0",
|
|
51
52
|
"@octokit/rest": "^22.0.1",
|
|
52
53
|
"@pulumi/pulumi": "^3.258.0",
|