From 76d41a2cbb191c390b580361d796ba61fd4c7143 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 6 Nov 2026 18:18:51 -0600 Subject: [PATCH] chore: tier map for plugins that are not pinned yet --- docker/plugin-tiers.json | 32 ++++++++++++++ scripts/bundled-plugins.test.ts | 49 +++++++++++++++++++++ scripts/check-core-plugin-ids.cjs | 23 +++++++--- scripts/check-core-plugin-ids.test.ts | 14 ++++++ scripts/lib/bundled-plugins.cjs | 62 ++++++++++++++++++++++++--- scripts/pin-bundled-plugins.cjs | 11 ++++- 6 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 docker/plugin-tiers.json diff --git a/docker/plugin-tiers.json b/docker/plugin-tiers.json new file mode 100644 index 000000000..251a8ad81 --- /dev/null +++ b/docker/plugin-tiers.json @@ -0,0 +1,32 @@ +{ + "plugins": { + "ansible": { "tier": "full" }, + "backups": { "tier": "full" }, + "certificates": { "tier": "full" }, + "cron": { "tier": "full" }, + "dns": { "tier": "full" }, + "firewall": { "tier": "full" }, + "git": { "tier": "full" }, + "ip-kvm": { "tier": "full" }, + "log-search": { "tier": "full" }, + "network-devices": { "tier": "full" }, + "notes": { "tier": "full" }, + "object-storage": { "tier": "full" }, + "operations-center": { + "tier": "standard", + "onboarding": { "recommended": true, "consent": false } + }, + "out-of-band": { "tier": "full" }, + "package-manager": { "tier": "full" }, + "privileged-access": { "tier": "full" }, + "process-manager": { "tier": "full" }, + "security-center": { "tier": "full" }, + "storage-health": { "tier": "full" }, + "ups-manager": { "tier": "full" }, + "uptime": { "tier": "full" }, + "user-manager": { "tier": "full" }, + "virtual-machines": { "tier": "full" }, + "web-servers": { "tier": "full" }, + "wireguard": { "tier": "full" } + } +} diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index 414697e4f..9697d3181 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -10,6 +10,7 @@ import { fetchArtifact, extractArtifact, pinsFromIndex, + parsePluginTiers, buildBundledIndex, findLocalPluginBuilds, isLocalBuildStale, @@ -253,6 +254,34 @@ describe("buildBundledIndex", () => { }); }); +describe("parsePluginTiers", () => { + it("reads tiers and onboarding", () => { + expect( + parsePluginTiers({ + plugins: { + a: { tier: "full" }, + b: { tier: "standard", onboarding: { recommended: true } }, + }, + }), + ).toEqual({ + tiers: { + a: { tier: "full" }, + b: { + tier: "standard", + onboarding: { recommended: true, consent: false }, + }, + }, + problems: [], + }); + }); + + it("reports a bad tier", () => { + expect( + parsePluginTiers({ plugins: { a: { tier: "huge" } } }).problems, + ).toEqual(["a tier must be one of minimal, standard, full"]); + }); +}); + describe("pinsFromIndex", () => { const index = { plugins: [ @@ -305,6 +334,26 @@ describe("pinsFromIndex", () => { expect(pins[1].tier).toBe("full"); }); + it("takes tier and onboarding for a new plugin from the tier map", () => { + const defaults = { + beta: { + tier: "standard", + onboarding: { recommended: true, consent: false }, + }, + alpha: { tier: "minimal" }, + }; + const pins = pinsFromIndex( + index, + null, + [{ id: "alpha", tier: "full" }], + null, + defaults, + ); + expect(pins[0].tier).toBe("full"); + expect(pins[1].tier).toBe("standard"); + expect(pins[1].onboarding).toEqual({ recommended: true, consent: false }); + }); + it("skips releases whose engine.termix excludes the core version", () => { const withEngine = { plugins: [ diff --git a/scripts/check-core-plugin-ids.cjs b/scripts/check-core-plugin-ids.cjs index 64bf1fb9c..7760948e2 100644 --- a/scripts/check-core-plugin-ids.cjs +++ b/scripts/check-core-plugin-ids.cjs @@ -9,7 +9,7 @@ * so /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker". * * Plugins live in their own repos, so the ids are the official ones below - * plus anything in docker/bundled-plugins.json. electron/ and vite.config.ts + * plus anything in docker/bundled-plugins.json and docker/plugin-tiers.json. electron/ and vite.config.ts * are read as well. * * src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is @@ -21,12 +21,17 @@ * One plugin id is also an SSH term: "totp" is the one-time-code prompt kind * in keyboard-interactive auth, which the connect pipeline in * src/backend/hosts/connect/ classifies. It is not the login plugin. + * + * A few ids are also plain core words: the host "notes" column, the data + * "backups" folder and the node "dns" module. On their own they are not + * counted, but their routes and dotted ids ("notes.list") still are. */ const fs = require("node:fs"); const path = require("node:path"); const SSH_TERMS = new Set(["totp"]); +const CORE_WORDS = new Set(["notes", "backups", "dns"]); const OFFICIAL_IDS = [ "acme-ssl", @@ -77,6 +82,7 @@ function paths(root) { root, src, bundled: path.join(root, "docker", "bundled-plugins.json"), + tiers: path.join(root, "docker", "plugin-tiers.json"), extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")], exempt: [ path.join(src, "backend", "upgrade"), @@ -88,8 +94,15 @@ function paths(root) { }; } -function pluginIds(bundledFile) { +function pluginIds(bundledFile, tiersFile) { const ids = new Set(OFFICIAL_IDS); + if (tiersFile && fs.existsSync(tiersFile)) { + for (const id of Object.keys( + JSON.parse(fs.readFileSync(tiersFile, "utf8")).plugins ?? {}, + )) { + ids.add(id); + } + } if (bundledFile && fs.existsSync(bundledFile)) { for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8")) .plugins ?? []) { @@ -146,8 +159,8 @@ function regexLiterals(source) { } function scan(root = path.resolve(__dirname, "..")) { - const { src, bundled, extra, exempt, sshConnect } = paths(root); - const ids = pluginIds(bundled); + const { src, bundled, tiers, extra, exempt, sshConnect } = paths(root); + const ids = pluginIds(bundled, tiers); const found = {}; const add = (file, what) => { const key = path.relative(root, file).replaceAll("\\", "/"); @@ -167,7 +180,7 @@ function scan(root = path.resolve(__dirname, "..")) { const inConnect = file.startsWith(sshConnect + path.sep); for (const text of literals(source)) { const sshTerm = inConnect && SSH_TERMS.has(text); - if (ids.has(text) && !sshTerm) add(file, text); + if (ids.has(text) && !sshTerm && !CORE_WORDS.has(text)) add(file, text); for (const route of text.matchAll( /\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g, )) { diff --git a/scripts/check-core-plugin-ids.test.ts b/scripts/check-core-plugin-ids.test.ts index 6bb62c6a1..7cc470ba5 100644 --- a/scripts/check-core-plugin-ids.test.ts +++ b/scripts/check-core-plugin-ids.test.ts @@ -86,6 +86,20 @@ describe("scan beyond src", () => { expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/extra-thing"); }); + it("knows ids from plugin-tiers.json, but not core words alone", () => { + const dir = fixture({ + "docker/plugin-tiers.json": JSON.stringify({ + plugins: { "next-thing": { tier: "full" }, notes: { tier: "full" } }, + }), + "src/ui/x.ts": 'const a = "next-thing";\nconst b = "notes";\n', + "src/ui/y.ts": 'const route = "/plugin-api/notes/list";\n', + }); + expect(scan(dir)).toEqual({ + "src/ui/x.ts": ["next-thing"], + "src/ui/y.ts": ["/plugin-api/notes"], + }); + }); + it("reads electron", () => { const dir = fixture({ "electron/main.cjs": 'invoke("/plugin-api/tunnels/list");\n', diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index ec4bd1e14..590880673 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -161,6 +161,46 @@ function loadBundledPlugins(root) { return plugins; } +/** + * docker/plugin-tiers.json: tier and onboarding for official plugins that are + * not pinned yet, used the first time `--all` pins them. + */ +function parsePluginTiers(raw) { + const problems = []; + const tiers = {}; + const map = raw?.plugins; + if (!map || typeof map !== "object" || Array.isArray(map)) { + return { tiers, problems: ['plugin-tiers.json needs a "plugins" object'] }; + } + for (const [id, entry] of Object.entries(map)) { + if (!ID.test(id) || !entry || typeof entry !== "object") { + problems.push(`${id} needs an object`); + continue; + } + if (!PROFILES.includes(entry.tier)) { + problems.push(`${id} tier must be one of ${PROFILES.join(", ")}`); + continue; + } + const before = problems.length; + const onboarding = parseOnboarding(entry.onboarding, id, problems); + if (problems.length > before) continue; + tiers[id] = { tier: entry.tier, ...(onboarding ? { onboarding } : {}) }; + } + return { tiers, problems }; +} + +function loadPluginTiers(root) { + const file = path.join(root, "docker", "plugin-tiers.json"); + if (!fs.existsSync(file)) return {}; + const { tiers, problems } = parsePluginTiers( + JSON.parse(fs.readFileSync(file, "utf8")), + ); + if (problems.length > 0) { + throw new Error(`docker/plugin-tiers.json:\n ${problems.join("\n ")}`); + } + return tiers; +} + /** Refuses an artifact whose bytes are not the ones the config pinned. */ function checkSha256(buffer, expected, id) { const actual = crypto.createHash("sha256").update(buffer).digest("hex"); @@ -286,12 +326,19 @@ function pluginsForProfile(plugins, profile) { * Pins the newest registry version of each wanted plugin. `ids` is the list * to pin, or null for every plugin in the index. `current` is the existing * pin list, so fields the registry does not know (onboarding, tier) carry over; - * a plugin new to the list starts in the full tier. coreVersion, when given, + * a plugin new to the list takes them from `defaults` (plugin-tiers.json), + * else starts in the full tier. coreVersion, when given, * skips releases whose engine.termix range does not include it, so the image * never ships a plugin its own core cannot run. With ids null, a plugin new to * the list that has no usable release (only betas, say) is left out. */ -function pinsFromIndex(index, ids, current = [], coreVersion = null) { +function pinsFromIndex( + index, + ids, + current = [], + coreVersion = null, + defaults = {}, +) { const plugins = Array.isArray(index?.plugins) ? index.plugins : []; const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const currentById = new Map(current.map((entry) => [entry.id, entry])); @@ -315,9 +362,12 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) { if (ids === null && !currentById.has(id)) return null; throw new Error(`${id} is not in the registry index`); } - const onboarding = currentById.get(id)?.onboarding; - const defaultLook = currentById.get(id)?.defaultLook === true; - const tier = currentById.get(id)?.tier ?? "full"; + const existing = currentById.get(id); + const onboarding = existing + ? existing.onboarding + : defaults[id]?.onboarding; + const defaultLook = existing?.defaultLook === true; + const tier = existing?.tier ?? defaults[id]?.tier ?? "full"; return { id, source: "tmxplug", @@ -485,6 +535,8 @@ module.exports = { buildBundledIndex, pinsFromIndex, loadBundledPlugins, + parsePluginTiers, + loadPluginTiers, checkSha256, fetchArtifact, extractArtifact, diff --git a/scripts/pin-bundled-plugins.cjs b/scripts/pin-bundled-plugins.cjs index e01dd8507..8eb452371 100644 --- a/scripts/pin-bundled-plugins.cjs +++ b/scripts/pin-bundled-plugins.cjs @@ -4,7 +4,7 @@ * re-released. * * node scripts/pin-bundled-plugins.cjs refresh the plugins already listed - * node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full) + * node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier from docker/plugin-tiers.json, else full) * --core-version X skip releases whose engine.termix excludes X * * The release workflow runs it with --all and the version being released. @@ -14,6 +14,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { loadBundledPlugins, + loadPluginTiers, pinsFromIndex, } = require("./lib/bundled-plugins.cjs"); @@ -35,7 +36,13 @@ async function main() { const current = loadBundledPlugins(root); const ids = all ? null : current.map((plugin) => plugin.id); - const plugins = pinsFromIndex(index, ids, current, coreVersion); + const plugins = pinsFromIndex( + index, + ids, + current, + coreVersion, + loadPluginTiers(root), + ); fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`); console.log( `Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,