@cldmv/gh-broker 0.7.0 → 0.8.0
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 +134 -40
- package/config.example.json +21 -8
- package/config.sample.json +45 -0
- package/deploy/README.md +73 -15
- package/deploy/install.mjs +166 -101
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +1638 -202
- package/dist/index.mjs.map +4 -4
- package/docs/TOOLS.md +192 -1
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +13 -11
- package/package.json +7 -7
- package/src/availability.ts +42 -0
- package/src/catalog.ts +18 -1
- package/src/config.ts +29 -2
- package/src/router.ts +43 -2
- package/src/secrets.ts +34 -4
- package/src/types.ts +25 -1
- package/src/verbs/ghcr/archive.ts +314 -0
- package/src/verbs/ghcr/config.ts +142 -0
- package/src/verbs/ghcr/jobs.ts +141 -0
- package/src/verbs/ghcr/push.ts +192 -0
- package/src/verbs/ghcr/registry.ts +275 -0
- package/src/verbs/ghcr/tar.ts +203 -0
- package/src/verbs/ghcr_push.ts +264 -0
- package/src/verbs/npm-registry.ts +168 -0
- package/src/verbs/npm-tarball.ts +76 -0
- package/src/verbs/npm_scaffold.ts +325 -0
- package/src/verbs/release-analysis.ts +3 -1
- package/tests/catalog.test.vitest.mts +55 -1
- package/tests/config-merge.test.vitest.mts +275 -8
- package/tests/config.test.vitest.mts +75 -1
- package/tests/credentials.test.vitest.mts +127 -0
- package/tests/ghcr-archive.test.vitest.mts +439 -0
- package/tests/ghcr-config.test.vitest.mts +96 -0
- package/tests/ghcr-push.test.vitest.mts +526 -0
- package/tests/ghcr-registry.test.vitest.mts +206 -0
- package/tests/helpers/ghcr-fixtures.mts +373 -0
- package/tests/install-args.test.vitest.mts +152 -0
- package/tests/issue-labels.test.vitest.mts +15 -0
- package/tests/npm-scaffold.test.vitest.mts +479 -0
- package/tests/npm-setup.test.vitest.mts +196 -0
- package/tests/paths.test.vitest.mts +5 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +10 -0
- package/tests/router.test.vitest.mts +108 -2
- package/tests/secret-store.test.vitest.mts +64 -3
- package/tests/secrets.test.vitest.mts +53 -0
- package/tests/security-alerts.test.vitest.mts +10 -0
- package/tests/server.test.vitest.mts +187 -7
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/verb-helpers.test.vitest.mts +46 -0
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { mergeConfig } from "../deploy/lib/config-merge.mjs";
|
|
2
|
+
import { mergeConfig, layerConfig, overlayFromFlags, readOperatorConfig, buildConfig, secretsBlock, DEFAULT_GHCR_DROP_DIR } from "../deploy/lib/config-merge.mjs";
|
|
3
3
|
|
|
4
4
|
const example = {
|
|
5
5
|
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
@@ -8,8 +8,8 @@ const example = {
|
|
|
8
8
|
{ name: "get_me", mode: "read" },
|
|
9
9
|
{ name: "list_issues", mode: "read" }
|
|
10
10
|
],
|
|
11
|
-
owners: {
|
|
12
|
-
defaultOwner: "
|
|
11
|
+
owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } },
|
|
12
|
+
defaultOwner: "acme"
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
describe("mergeConfig", () => {
|
|
@@ -25,17 +25,17 @@ describe("mergeConfig", () => {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("preserves current owner data and adds example-only owners on upgrade", () => {
|
|
28
|
-
const current = { ...example, owners: {
|
|
28
|
+
const current = { ...example, owners: { acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 123 } } };
|
|
29
29
|
const example2 = {
|
|
30
30
|
...example,
|
|
31
31
|
owners: {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
acme: { read: "cred:acme-read", write: "cred:acme-write" },
|
|
33
|
+
beta: { read: "cred:beta-read", write: "cred:beta-write" }
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
const merged = mergeConfig(example2, current);
|
|
37
|
-
expect(merged.owners.
|
|
38
|
-
expect(merged.owners.
|
|
37
|
+
expect(merged.owners.acme.installationId).toBe(123); // current wins
|
|
38
|
+
expect(merged.owners.beta).toBeDefined(); // new example owner appears
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it("lets current win for listen/upstream/defaultOwner/app/secrets", () => {
|
|
@@ -45,6 +45,18 @@ describe("mergeConfig", () => {
|
|
|
45
45
|
expect(merged.app).toEqual({ appId: 1, privateKey: "cred:k" });
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("keeps the current npm block on upgrade (the example has none)", () => {
|
|
49
|
+
const npm = { token: "cred:npm-token", scopes: ["@cldmv"] };
|
|
50
|
+
expect(mergeConfig(example, { ...example, npm }).npm).toEqual(npm);
|
|
51
|
+
expect(mergeConfig(example, { ...example }).npm).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps the example's scalars when current omits them, and tolerates configs with no owners", () => {
|
|
55
|
+
const merged = mergeConfig({ tools: [], listen: "unix:/a" }, { tools: [] });
|
|
56
|
+
expect(merged.listen).toBe("unix:/a");
|
|
57
|
+
expect(merged.owners).toEqual({});
|
|
58
|
+
});
|
|
59
|
+
|
|
48
60
|
it("does not mutate its inputs", () => {
|
|
49
61
|
const ex = structuredClone(example);
|
|
50
62
|
mergeConfig(ex, { ...example, listen: "unix:/z" });
|
|
@@ -55,4 +67,259 @@ describe("mergeConfig", () => {
|
|
|
55
67
|
expect(() => mergeConfig([], null)).toThrow(/example config must be an object/);
|
|
56
68
|
expect(() => mergeConfig(example, [])).toThrow(/current config must be an object/);
|
|
57
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("ghcr", () => {
|
|
72
|
+
const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", owners: { cldmv: { username: "u", credential: "cred:p", images: ["hoot"] } } };
|
|
73
|
+
|
|
74
|
+
it("keeps the deployed ghcr block (operator data); the example ships none", () => {
|
|
75
|
+
expect(mergeConfig(example, { ...example, ghcr }).ghcr).toEqual(ghcr);
|
|
76
|
+
expect(mergeConfig(example, { ...example }).ghcr).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("lets the deployed ghcr win over one the example carries", () => {
|
|
80
|
+
const edited = { ...ghcr, dropDir: "/srv/other/drop" };
|
|
81
|
+
expect(mergeConfig({ ...example, ghcr }, { ...example, ghcr: edited }).ghcr).toEqual(edited);
|
|
82
|
+
expect(mergeConfig({ ...example, ghcr }, example).ghcr).toEqual(ghcr);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const shipped = {
|
|
88
|
+
listen: "unix:/run/gh-broker/gh-broker.sock",
|
|
89
|
+
upstream: "https://api.githubcopilot.com/mcp/x/all",
|
|
90
|
+
tools: [{ name: "get_me", mode: "read" }]
|
|
91
|
+
};
|
|
92
|
+
const noOpts = {};
|
|
93
|
+
const ask = async () => {
|
|
94
|
+
throw new Error("unexpected prompt");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
describe("layerConfig", () => {
|
|
98
|
+
it("replaces scalars and merges owners per owner and per field", () => {
|
|
99
|
+
const base = { listen: "unix:/a", upstream: "https://a", owners: { acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 7 } } };
|
|
100
|
+
const out = layerConfig(base, { listen: "unix:/b", defaultOwner: "acme", owners: { acme: { write: "cred:w2" }, beta: { read: "cred:r", write: "cred:w" } } });
|
|
101
|
+
expect(out.listen).toBe("unix:/b");
|
|
102
|
+
expect(out.upstream).toBe("https://a");
|
|
103
|
+
expect(out.defaultOwner).toBe("acme");
|
|
104
|
+
expect(out.owners.acme).toEqual({ read: "cred:acme-read", write: "cred:w2", installationId: 7 });
|
|
105
|
+
expect(out.owners.beta).toEqual({ read: "cred:r", write: "cred:w" });
|
|
106
|
+
expect(base.owners.acme.write).toBe("cred:acme-write"); // input untouched
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("merges app/secrets/npm per field and creates them when absent", () => {
|
|
110
|
+
const out = layerConfig({ npm: { token: "cred:t", scopes: ["@a"] } }, { npm: { registry: "https://r" }, secrets: { path: "/p" }, app: { appId: 1, privateKey: "cred:k" } });
|
|
111
|
+
expect(out.npm).toEqual({ token: "cred:t", scopes: ["@a"], registry: "https://r" });
|
|
112
|
+
expect(out.secrets).toEqual({ path: "/p" });
|
|
113
|
+
expect(out.app).toEqual({ appId: 1, privateKey: "cred:k" });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("merges ghcr scalars and ghcr owners per owner", () => {
|
|
117
|
+
const base = { owners: {}, ghcr: { dropDir: "/srv/a/b", owners: { acme: { username: "u", credential: "cred:acme-packages", images: ["x"] } } } };
|
|
118
|
+
const out = layerConfig(base, { ghcr: { protectReleaseTags: false, owners: { acme: { images: ["y"] }, beta: { username: "b", credential: "cred:beta-packages", images: ["z"] } } } });
|
|
119
|
+
expect(out.ghcr.dropDir).toBe("/srv/a/b");
|
|
120
|
+
expect(out.ghcr.protectReleaseTags).toBe(false);
|
|
121
|
+
expect(out.ghcr.owners.acme).toEqual({ username: "u", credential: "cred:acme-packages", images: ["y"] });
|
|
122
|
+
expect(Object.keys(out.ghcr.owners)).toEqual(["acme", "beta"]);
|
|
123
|
+
expect(layerConfig({}, { ghcr: { dropDir: "/srv/a/b" } }).ghcr).toEqual({ dropDir: "/srv/a/b" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("leaves a base without overlay data alone", () => {
|
|
127
|
+
expect(layerConfig({ listen: "unix:/a" }, {})).toEqual({ listen: "unix:/a" });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("overlayFromFlags", () => {
|
|
132
|
+
it("is empty with no flags", () => {
|
|
133
|
+
expect(overlayFromFlags(noOpts)).toEqual({});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("maps every flag to its config field", () => {
|
|
137
|
+
const o = overlayFromFlags({
|
|
138
|
+
listen: "unix:/x.sock",
|
|
139
|
+
upstream: "https://u",
|
|
140
|
+
defaultOwner: "acme",
|
|
141
|
+
owners: ["acme", "beta"],
|
|
142
|
+
installationIds: { acme: 42, gamma: 9 },
|
|
143
|
+
appId: 5,
|
|
144
|
+
appPrivateKey: "my-key",
|
|
145
|
+
secretsPath: "/s.json",
|
|
146
|
+
ghcrOwner: "acme",
|
|
147
|
+
ghcrUsername: "me",
|
|
148
|
+
ghcrImages: ["app", "app-*"],
|
|
149
|
+
ghcrDropDir: "/srv/x/drop",
|
|
150
|
+
ghcrMaxArchiveBytes: 1024,
|
|
151
|
+
protectReleaseTags: false
|
|
152
|
+
});
|
|
153
|
+
expect(o).toEqual({
|
|
154
|
+
listen: "unix:/x.sock",
|
|
155
|
+
upstream: "https://u",
|
|
156
|
+
defaultOwner: "acme",
|
|
157
|
+
owners: {
|
|
158
|
+
acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 42 },
|
|
159
|
+
beta: { read: "cred:beta-read", write: "cred:beta-write" },
|
|
160
|
+
gamma: { installationId: 9 }
|
|
161
|
+
},
|
|
162
|
+
app: { appId: 5, privateKey: "cred:my-key" },
|
|
163
|
+
secrets: { path: "/s.json" },
|
|
164
|
+
ghcr: {
|
|
165
|
+
dropDir: "/srv/x/drop",
|
|
166
|
+
maxArchiveBytes: 1024,
|
|
167
|
+
protectReleaseTags: false,
|
|
168
|
+
owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["app", "app-*"] } }
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("derives the App key name when only --app-id is given and handles --protect-release-tags", () => {
|
|
174
|
+
expect(overlayFromFlags({ appId: 5 }).app).toEqual({ appId: 5, privateKey: "cred:gh-app-key" });
|
|
175
|
+
expect(overlayFromFlags({ protectReleaseTags: true }).ghcr).toEqual({ protectReleaseTags: true });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("readOperatorConfig", () => {
|
|
180
|
+
const read = (text: string) => () => text;
|
|
181
|
+
|
|
182
|
+
it("returns a valid operator config", () => {
|
|
183
|
+
const cfg = { listen: "unix:/a", owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } } };
|
|
184
|
+
expect(readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toEqual(cfg);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reports an unreadable file and bad JSON", () => {
|
|
188
|
+
expect(() =>
|
|
189
|
+
readOperatorConfig("/missing.json", () => {
|
|
190
|
+
throw new Error("ENOENT: no such file");
|
|
191
|
+
})
|
|
192
|
+
).toThrow(/--config: cannot read\/parse \/missing.json: ENOENT/);
|
|
193
|
+
expect(() => readOperatorConfig("/c.json", read("{ nope"))).toThrow(/--config: cannot read\/parse/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("refuses a non-object root, tools, and unknown keys", () => {
|
|
197
|
+
expect(() => readOperatorConfig("/c.json", read("[]"))).toThrow(/must be a JSON object/);
|
|
198
|
+
expect(() => readOperatorConfig("/c.json", read("null"))).toThrow(/must be a JSON object/);
|
|
199
|
+
expect(() => readOperatorConfig("/c.json", read('{"tools":[]}'))).toThrow(/`tools` is the shipped allowlist/);
|
|
200
|
+
expect(() => readOperatorConfig("/c.json", read('{"lisen":"x","owners":{}}'))).toThrow(/unknown key\(s\) lisen/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it.each([
|
|
204
|
+
["a classic PAT", { owners: { acme: { read: "ghp_" + "a".repeat(36), write: "cred:w" } } }, "owners.acme.read"],
|
|
205
|
+
["a fine-grained PAT", { ghcr: { owners: { a: { images: ["github_pat_" + "A1".repeat(20)] } } } }, "ghcr.owners.a.images[0]"],
|
|
206
|
+
["an npm token", { npm: { token: "npm_" + "b".repeat(36), scopes: [] } }, "npm.token"],
|
|
207
|
+
["a token as a list entry", { npm: { scopes: ["ok", "ghs_" + "c".repeat(30)] } }, "npm.scopes[1]"]
|
|
208
|
+
])("refuses %s", (_name, cfg, where) => {
|
|
209
|
+
expect(() => readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toThrow(new RegExp(`--config: ${where.replace(/[[\]]/g, "\\$&")} looks like a token`));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("accepts cred refs, numbers and booleans alongside strings", () => {
|
|
213
|
+
const cfg = { app: { appId: 1, privateKey: "cred:gh-app-key" }, ghcr: { protectReleaseTags: true } };
|
|
214
|
+
expect(readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toEqual(cfg);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("buildConfig", () => {
|
|
219
|
+
const acme = { read: "cred:acme-read", write: "cred:acme-write" };
|
|
220
|
+
const build = (o: Record<string, unknown>) => buildConfig({ example: shipped, current: null, file: undefined, opts: noOpts, nonInteractive: true, ask, ...o } as never);
|
|
221
|
+
|
|
222
|
+
it("a first install takes owners from flags, defaults defaultOwner and sets ghcr.dropDir", async () => {
|
|
223
|
+
const { config, notes } = await build({
|
|
224
|
+
opts: { owners: ["acme"], ghcrOwner: "acme", ghcrUsername: "me", ghcrImages: ["app"] }
|
|
225
|
+
});
|
|
226
|
+
expect(config.tools).toEqual(shipped.tools);
|
|
227
|
+
expect(config.owners).toEqual({ acme });
|
|
228
|
+
expect(config.defaultOwner).toBe("acme");
|
|
229
|
+
expect(config.ghcr).toEqual({ dropDir: DEFAULT_GHCR_DROP_DIR, owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["app"] } } });
|
|
230
|
+
expect(notes).toHaveLength(2);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not pick a defaultOwner with several owners, or when one is set", async () => {
|
|
234
|
+
expect((await build({ opts: { owners: ["acme", "beta"] } })).config.defaultOwner).toBeUndefined();
|
|
235
|
+
expect((await build({ opts: { owners: ["acme", "beta"], defaultOwner: "beta" } })).config.defaultOwner).toBe("beta");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("layers example < deployed < --config < flags", async () => {
|
|
239
|
+
const current = { ...shipped, listen: "unix:/deployed", upstream: "https://deployed", owners: { acme }, defaultOwner: "acme" };
|
|
240
|
+
const file = { listen: "unix:/file", upstream: "https://file" };
|
|
241
|
+
const flags = { listen: "unix:/flag" };
|
|
242
|
+
const { config } = await build({ current, file, opts: flags });
|
|
243
|
+
expect(config.listen).toBe("unix:/flag"); // flag beats file
|
|
244
|
+
expect(config.upstream).toBe("https://file"); // file beats deployed
|
|
245
|
+
const deployedOnly = await build({ current });
|
|
246
|
+
expect(deployedOnly.config.listen).toBe("unix:/deployed"); // deployed beats example
|
|
247
|
+
expect(deployedOnly.config.upstream).toBe("https://deployed");
|
|
248
|
+
expect((await build({ current: { tools: [], owners: { acme } } })).config.listen).toBe(shipped.listen); // example fills gaps
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("keeps the deployed owners, ghcr and tools on an upgrade with no flags", async () => {
|
|
252
|
+
const ghcr = { dropDir: "/srv/old/drop", protectReleaseTags: false, maxArchiveBytes: 5, owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["x"] } } };
|
|
253
|
+
const current = { ...shipped, tools: [{ name: "gone", mode: "write" }], owners: { acme: { ...acme, installationId: 3 } }, ghcr };
|
|
254
|
+
const { config, notes } = await build({ current });
|
|
255
|
+
expect(config.owners).toEqual({ acme: { ...acme, installationId: 3 } });
|
|
256
|
+
expect(config.ghcr).toEqual(ghcr);
|
|
257
|
+
expect(config.tools).toEqual(shipped.tools); // allowlist from the example
|
|
258
|
+
expect(config.defaultOwner).toBeUndefined();
|
|
259
|
+
expect(notes).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("adds an owner on top of a deployed one without losing it", async () => {
|
|
263
|
+
const { config } = await build({ current: { ...shipped, owners: { acme: { ...acme, installationId: 3 } } }, opts: { owners: ["acme", "beta"] } });
|
|
264
|
+
expect(config.owners.acme.installationId).toBe(3);
|
|
265
|
+
expect(config.owners.beta).toEqual({ read: "cred:beta-read", write: "cred:beta-write" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("takes owners, ghcr and defaultOwner from a --config file", async () => {
|
|
269
|
+
const file = { owners: { acme }, defaultOwner: "acme", ghcr: { dropDir: "/srv/f/drop", owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["a"] } } } };
|
|
270
|
+
const { config } = await build({ file });
|
|
271
|
+
expect(config.owners).toEqual({ acme });
|
|
272
|
+
expect(config.ghcr.dropDir).toBe("/srv/f/drop");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("fails non-interactively when no owner exists anywhere, naming --owner and --config", async () => {
|
|
276
|
+
await expect(build({})).rejects.toThrow(/--owner <org>.*--config/);
|
|
277
|
+
await expect(build({ current: { ...shipped } })).rejects.toThrow(/no GitHub owner configured/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("asks for the owner on an interactive first install", async () => {
|
|
281
|
+
const asked: string[] = [];
|
|
282
|
+
const { config } = await build({
|
|
283
|
+
nonInteractive: false,
|
|
284
|
+
ask: async (q: string) => {
|
|
285
|
+
asked.push(q);
|
|
286
|
+
return " Acme-Corp \n";
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
expect(asked).toHaveLength(1);
|
|
290
|
+
expect(config.owners).toEqual({ "acme-corp": { read: "cred:acme-corp-read", write: "cred:acme-corp-write" } });
|
|
291
|
+
expect(config.defaultOwner).toBe("acme-corp");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("rejects an invalid owner typed at the prompt", async () => {
|
|
295
|
+
await expect(build({ nonInteractive: false, ask: async () => "not an org!" })).rejects.toThrow(/not a valid GitHub owner name/);
|
|
296
|
+
await expect(build({ nonInteractive: false, ask: async () => "" })).rejects.toThrow(/not a valid GitHub owner name/);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("does not prompt when owners are already configured", async () => {
|
|
300
|
+
const { config } = await build({ nonInteractive: false, current: { ...shipped, owners: { acme } } });
|
|
301
|
+
expect(Object.keys(config.owners)).toEqual(["acme"]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("refuses an owner that has no credentials (e.g. --installation-id for an unknown owner)", async () => {
|
|
305
|
+
await expect(build({ opts: { owners: ["acme"], installationIds: { gamma: 9 } } })).rejects.toThrow(/owner "gamma" has no credentials.*--owner gamma/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("keeps an explicit ghcr.dropDir instead of the default", async () => {
|
|
309
|
+
const { config, notes } = await build({ opts: { owners: ["acme"], ghcrOwner: "acme", ghcrUsername: "me", ghcrImages: ["a"], ghcrDropDir: "/srv/mine/drop" } });
|
|
310
|
+
expect(config.ghcr.dropDir).toBe("/srv/mine/drop");
|
|
311
|
+
expect(notes.some((n: string) => n.includes("dropDir"))).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("secretsBlock", () => {
|
|
316
|
+
it("returns just the provider, keeping a path only for the file provider", () => {
|
|
317
|
+
expect(secretsBlock("systemd-creds", undefined)).toEqual({ provider: "systemd-creds" });
|
|
318
|
+
expect(secretsBlock("file", { path: "/s.json" })).toEqual({ provider: "file", path: "/s.json" });
|
|
319
|
+
expect(secretsBlock("file", {})).toEqual({ provider: "file" });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("refuses a path with the systemd-creds provider", () => {
|
|
323
|
+
expect(() => secretsBlock("systemd-creds", { path: "/s.json" })).toThrow(/only used by the "file" provider/);
|
|
324
|
+
});
|
|
58
325
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { loadConfig, indexTools } from "../src/config.ts";
|
|
5
5
|
|
|
@@ -263,6 +263,16 @@ describe("loadConfig — secrets", () => {
|
|
|
263
263
|
expect(out.secrets).toEqual({ provider: "file", path: "/etc/gh-broker/secrets.json" });
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
+
it("carries an empty secrets block, and a provider without a path, through", () => {
|
|
267
|
+
const c = baseConfig();
|
|
268
|
+
c.secrets = {};
|
|
269
|
+
expect(loadConfig(writeConfig(c)).secrets).toEqual({});
|
|
270
|
+
c.secrets = { provider: "file" };
|
|
271
|
+
expect(loadConfig(writeConfig(c)).secrets).toEqual({ provider: "file" });
|
|
272
|
+
c.secrets = { provider: "systemd-creds" };
|
|
273
|
+
expect(loadConfig(writeConfig(c)).secrets).toEqual({ provider: "systemd-creds" });
|
|
274
|
+
});
|
|
275
|
+
|
|
266
276
|
it("rejects a path without the file provider (omitted → systemd-creds default)", () => {
|
|
267
277
|
const c = baseConfig();
|
|
268
278
|
c.secrets = { path: "/etc/gh-broker/secrets.json" };
|
|
@@ -312,3 +322,67 @@ describe("indexTools", () => {
|
|
|
312
322
|
expect(map.get("missing")).toBeUndefined();
|
|
313
323
|
});
|
|
314
324
|
});
|
|
325
|
+
|
|
326
|
+
describe("loadConfig — npm", () => {
|
|
327
|
+
it("is absent unless configured", () => {
|
|
328
|
+
expect(loadConfig(writeConfig(baseConfig())).npm).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("leaves registry out when it is not set", () => {
|
|
332
|
+
expect(loadConfig(writeConfig({ ...baseConfig(), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } })).npm).toEqual({ token: "cred:npm-token", scopes: ["@cldmv"] });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("accepts a token ref, scopes, an https registry and token orgs", () => {
|
|
336
|
+
const npm = { token: "cred:npm-token", scopes: ["@cldmv"], registry: "https://registry.npmjs.org", tokenOrgs: ["cldmv"] };
|
|
337
|
+
expect(loadConfig(writeConfig({ ...baseConfig(), npm })).npm).toEqual(npm);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it.each([
|
|
341
|
+
[[], /config.npm: must be an object/],
|
|
342
|
+
[{ scopes: ["@cldmv"] }, /config.npm.token/],
|
|
343
|
+
[{ token: "npm_inline_secret", scopes: ["@cldmv"] }, /config.npm.token/],
|
|
344
|
+
[{ token: "cred:npm-token" }, /config.npm.scopes: must be a non-empty array/],
|
|
345
|
+
[{ token: "cred:npm-token", scopes: [] }, /config.npm.scopes: must be a non-empty array/],
|
|
346
|
+
[{ token: "cred:npm-token", scopes: ["cldmv"] }, /config.npm.scopes\[0\]/],
|
|
347
|
+
[{ token: "cred:npm-token", scopes: ["@CLDMV"] }, /config.npm.scopes\[0\]/],
|
|
348
|
+
[{ token: "cred:npm-token", scopes: ["@cldmv"], registry: "http://registry.npmjs.org" }, /config.npm.registry: must be an https URL/],
|
|
349
|
+
[{ token: "cred:npm-token", scopes: ["@cldmv"], tokenOrgs: "cldmv" }, /config.npm.tokenOrgs/],
|
|
350
|
+
[{ token: "cred:npm-token", scopes: ["@cldmv"], tokenOrgs: ["@cldmv"] }, /config.npm.tokenOrgs/]
|
|
351
|
+
] as Array<[unknown, RegExp]>)("rejects an invalid npm block (%#)", (npm, msg) => {
|
|
352
|
+
expect(() => loadConfig(writeConfig({ ...baseConfig(), npm }))).toThrow(msg);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("loadConfig — ghcr", () => {
|
|
357
|
+
const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", owners: { cldmv: { username: "Shinrai", credential: "cred:cldmv-packages", images: ["hoot"] } } };
|
|
358
|
+
|
|
359
|
+
it("parses a ghcr block", () => {
|
|
360
|
+
const c = loadConfig(writeConfig({ ...baseConfig(), ghcr }));
|
|
361
|
+
expect(c.ghcr?.owners.cldmv?.credential).toBe("cred:cldmv-packages");
|
|
362
|
+
expect(c.ghcr?.protectReleaseTags).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("omits ghcr when absent and rejects a malformed block", () => {
|
|
366
|
+
expect(loadConfig(writeConfig(baseConfig())).ghcr).toBeUndefined();
|
|
367
|
+
expect(() => loadConfig(writeConfig({ ...baseConfig(), ghcr: { ...ghcr, dropDir: "/etc/x/y" } }))).toThrow(/config.ghcr.dropDir: may not be under \/etc/);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("ships no owner-specific data in config.example.json, and it loads once an owner is added", () => {
|
|
371
|
+
const raw = JSON.parse(readFileSync(join(process.cwd(), "config.example.json"), "utf8"));
|
|
372
|
+
expect(Object.keys(raw).sort()).toEqual(["listen", "tools", "upstream"]);
|
|
373
|
+
const c = loadConfig(writeConfig({ ...raw, owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } } }));
|
|
374
|
+
expect(c.tools.filter((t) => t.name.startsWith("ghcr_")).map((t) => [t.name, t.mode, t.custom])).toEqual([
|
|
375
|
+
["ghcr_push", "write", true],
|
|
376
|
+
["ghcr_push_status", "read", true],
|
|
377
|
+
["ghcr_tags", "read", true]
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("accepts the documentation-only config.sample.json merged onto the example tools", () => {
|
|
382
|
+
const raw = JSON.parse(readFileSync(join(process.cwd(), "config.example.json"), "utf8"));
|
|
383
|
+
const sample = JSON.parse(readFileSync(join(process.cwd(), "config.sample.json"), "utf8"));
|
|
384
|
+
const c = loadConfig(writeConfig({ ...sample, tools: raw.tools }));
|
|
385
|
+
expect(c.ghcr?.dropDir).toBe("/srv/gh-broker/ghcr-drop");
|
|
386
|
+
expect(c.defaultOwner).toBe("your-org");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { credentialPlan } from "../deploy/lib/credentials.mjs";
|
|
3
|
+
|
|
4
|
+
type Opts = Parameters<typeof credentialPlan>[0];
|
|
5
|
+
|
|
6
|
+
function plan(over: Partial<Opts> = {}, answers: string[] = []) {
|
|
7
|
+
const logs: string[] = [];
|
|
8
|
+
const promptHidden = vi.fn(async () => answers.shift() ?? "");
|
|
9
|
+
const mint = vi.fn(async () => "npm_MINTED");
|
|
10
|
+
const p = credentialPlan({
|
|
11
|
+
needed: ["cldmv-read", "cldmv-write", "npm-token"],
|
|
12
|
+
optional: new Set(["npm-token"]),
|
|
13
|
+
cliSecrets: {},
|
|
14
|
+
imported: {},
|
|
15
|
+
env: {},
|
|
16
|
+
existingFile: {},
|
|
17
|
+
decrypt: () => undefined,
|
|
18
|
+
mints: () => false,
|
|
19
|
+
mint,
|
|
20
|
+
appKeyName: undefined,
|
|
21
|
+
nonInteractive: false,
|
|
22
|
+
promptHidden,
|
|
23
|
+
log: (m: string) => logs.push(m),
|
|
24
|
+
...over
|
|
25
|
+
});
|
|
26
|
+
return { p, logs, promptHidden, mint };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A fake systemd-creds store: which .cred files exist, and what was encrypted. */
|
|
30
|
+
function creds(existing: string[] = []) {
|
|
31
|
+
const files = new Set(existing);
|
|
32
|
+
const written: Record<string, string> = {};
|
|
33
|
+
return {
|
|
34
|
+
written,
|
|
35
|
+
io: {
|
|
36
|
+
exists: (n: string) => files.has(n),
|
|
37
|
+
encrypt: (n: string, v: string) => {
|
|
38
|
+
files.add(n);
|
|
39
|
+
written[n] = v;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("credentialPlan", () => {
|
|
46
|
+
it("refuses a --secret name the config does not use", () => {
|
|
47
|
+
expect(() => plan({ cliSecrets: { "cldmv-raed": "x" } })).toThrow(/--secret names no credential this config uses: cldmv-raed \(it uses: cldmv-read, cldmv-write, npm-token\)/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("takes an explicit value from --secret, then --secrets-file, then env", () => {
|
|
51
|
+
const env = { GH_BROKER_SECRET_CLDMV_READ: "from-env" };
|
|
52
|
+
expect(plan({ env }).p.explicit("cldmv-read")).toBe("from-env");
|
|
53
|
+
expect(plan({ env, imported: { "cldmv-read": "from-file" } }).p.explicit("cldmv-read")).toBe("from-file");
|
|
54
|
+
expect(plan({ env, imported: { "cldmv-read": "from-file" }, cliSecrets: { "cldmv-read": "from-cli" } }).p.explicit("cldmv-read")).toBe("from-cli");
|
|
55
|
+
expect(plan({ imported: { "cldmv-read": "" }, env: { GH_BROKER_SECRET_CLDMV_READ: "" } }).p.explicit("cldmv-read")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("resolves explicit → stored file → decrypt → prompt", async () => {
|
|
59
|
+
expect(await plan({ cliSecrets: { "cldmv-read": "cli" }, existingFile: { "cldmv-read": "old" } }).p.resolve("cldmv-read")).toBe("cli");
|
|
60
|
+
expect(await plan({ existingFile: { "cldmv-read": "old" }, decrypt: () => "dec" }).p.resolve("cldmv-read")).toBe("old");
|
|
61
|
+
const d = plan({ decrypt: () => "dec" });
|
|
62
|
+
expect(await d.p.resolve("cldmv-read")).toBe("dec");
|
|
63
|
+
expect(d.logs).toContain(" cldmv-read: migrated from systemd-creds");
|
|
64
|
+
const prompted = plan({}, ["typed"]);
|
|
65
|
+
expect(await prompted.p.resolve("cldmv-read")).toBe("typed");
|
|
66
|
+
expect(prompted.promptHidden).toHaveBeenCalledWith(" cldmv-read (input hidden): ");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("fails a required credential that is missing or left empty", async () => {
|
|
70
|
+
await expect(plan({ nonInteractive: true }).p.resolve("cldmv-read")).rejects.toThrow(/missing credential "cldmv-read" — provide via --secret, --secrets-file or env GH_BROKER_SECRET_CLDMV_READ/);
|
|
71
|
+
await expect(plan({}, [""]).p.resolve("cldmv-read")).rejects.toThrow(/empty value for "cldmv-read"/);
|
|
72
|
+
await expect(plan({ appKeyName: "gh-app-key", needed: ["gh-app-key"] }).p.resolve("gh-app-key")).rejects.toThrow(/GitHub App private key/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("lets an optional credential be skipped, never failing", async () => {
|
|
76
|
+
expect(await plan({ nonInteractive: true }).p.resolve("npm-token")).toBeUndefined();
|
|
77
|
+
const i = plan({}, [""]);
|
|
78
|
+
expect(await i.p.resolve("npm-token")).toBeUndefined();
|
|
79
|
+
expect(i.promptHidden).toHaveBeenCalledWith(expect.stringMatching(/Enter to skip/));
|
|
80
|
+
expect(await plan({}, ["npm_typed"]).p.resolve("npm-token")).toBe("npm_typed");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("creates the npm token when this run mints it, unless one is supplied", async () => {
|
|
84
|
+
const m = plan({ mints: (n) => n === "npm-token" });
|
|
85
|
+
expect(await m.p.resolve("npm-token")).toBe("npm_MINTED");
|
|
86
|
+
expect(m.p.replaces("npm-token")).toBe(true);
|
|
87
|
+
const given = plan({ mints: (n) => n === "npm-token", cliSecrets: { "npm-token": "npm_given" } });
|
|
88
|
+
expect(await given.p.resolve("npm-token")).toBe("npm_given");
|
|
89
|
+
expect(given.mint).not.toHaveBeenCalled();
|
|
90
|
+
await expect(plan({ mints: () => true, nonInteractive: true }).p.resolve("npm-token")).rejects.toThrow(/needs a browser login/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("storeSystemd", () => {
|
|
94
|
+
it("keeps existing creds, encrypts new ones, and lists only what is stored", async () => {
|
|
95
|
+
const c = creds(["cldmv-read"]);
|
|
96
|
+
const { p, logs } = plan({}, ["write-typed", ""]);
|
|
97
|
+
const stored = await p.storeSystemd(c.io);
|
|
98
|
+
expect(stored).toEqual(["cldmv-read", "cldmv-write"]);
|
|
99
|
+
expect(c.written).toEqual({ "cldmv-write": "write-typed" });
|
|
100
|
+
expect(logs).toEqual([" cldmv-read: existing systemd-creds kept", " cldmv-write: encrypted (systemd-creds)", " npm-token: not stored — its tools stay hidden until it is (re-run the installer to add it)"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rotates on an explicit value or a plaintext entry, and notes a replaced npm token", async () => {
|
|
104
|
+
const c = creds(["cldmv-read", "cldmv-write", "npm-token"]);
|
|
105
|
+
const { p, logs } = plan({ cliSecrets: { "cldmv-read": "new-read" }, existingFile: { "cldmv-write": "file-write" }, mints: (n) => n === "npm-token" });
|
|
106
|
+
expect(await p.storeSystemd(c.io)).toEqual(["cldmv-read", "cldmv-write", "npm-token"]);
|
|
107
|
+
expect(c.written).toEqual({ "cldmv-read": "new-read", "cldmv-write": "file-write", "npm-token": "npm_MINTED" });
|
|
108
|
+
expect(logs).toContain(" cldmv-read: rotated (systemd-creds)");
|
|
109
|
+
expect(logs.some((l) => /previous npm-token is still valid/.test(l))).toBe(true);
|
|
110
|
+
expect(logs.filter((l) => /previous/.test(l))).toHaveLength(1);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("storeFile", () => {
|
|
115
|
+
it("keeps stored values, fills missing ones, rotates on an explicit value, and skips an optional one", async () => {
|
|
116
|
+
const { p, logs } = plan({ needed: ["cldmv-read", "cldmv-write", "cldmv-new", "npm-token"], existingFile: { "cldmv-read": "old-read", "cldmv-write": "old-write" }, cliSecrets: { "cldmv-write": "new-write", "cldmv-new": "fresh" }, nonInteractive: true });
|
|
117
|
+
expect(await p.storeFile()).toEqual({ "cldmv-read": "old-read", "cldmv-write": "new-write", "cldmv-new": "fresh" });
|
|
118
|
+
expect(logs).toEqual([" npm-token: not stored — its tools stay hidden until it is (re-run the installer to add it)"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("replaces a stored npm token with a newly created one and says the old one is still live", async () => {
|
|
122
|
+
const { p, logs } = plan({ existingFile: { "cldmv-read": "r", "cldmv-write": "w", "npm-token": "npm_old" }, mints: (n) => n === "npm-token" });
|
|
123
|
+
expect(await p.storeFile()).toEqual({ "cldmv-read": "r", "cldmv-write": "w", "npm-token": "npm_MINTED" });
|
|
124
|
+
expect(logs.some((l) => /previous npm-token is still valid/.test(l))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|