import * as pulumi from "@pulumi/pulumi"; import * as https from "https"; /** * A VyOS config subtree, managed as ONE Pulumi resource. * * Why a dynamic provider rather than the community Terraform providers: they * batch correctly (one `vyos_config_block_tree` becomes a single POST to * /configure, so one commit) but they never send `confirm_time`. VyOS's own API * supports it -- `ConfigureListModel.confirm_time`, and "A non-zero confirm_time * will start commit-confirm timer on commit" in rest/routers.py -- so a config * push that breaks your access to the router can undo itself. On a gateway that * is the difference between a mistake and an outage, and it is worth ~150 lines * to keep. * * The unit of change is a SUBTREE, not a line. `protocols bgp` is one resource; * so is `service dhcp-server`. That keeps one Pulumi resource == one commit, * rather than turning 300 config lines into 300 commits with no ordering * guarantee and no way to revert them as a unit. */ export interface VyosConfigTreeArgs { /** Router address, e.g. "10.0.1.252". */ host: pulumi.Input; /** API key from `set service https api keys id key `. */ apiKey: pulumi.Input; /** Subtree root as a path array, e.g. ["protocols", "bgp"]. */ path: pulumi.Input; /** * Desired state of the subtree: `set` command suffixes relative to `path`, * each already split into path components with the value last. */ commands: pulumi.Input; /** * Minutes before an unconfirmed commit reverts itself. 0 disables it. * Non-zero is strongly preferred for anything reachable only through the * router being changed. */ confirmMinutes?: pulumi.Input; /** Persist to config.boot after a successful confirm. */ save?: pulumi.Input; } interface Inputs { host: string; apiKey: string; path: string[]; commands: string[][]; confirmMinutes: number; save: boolean; } // Kept inside the module so the dynamic provider can serialise it. function apiCall( host: string, apiKey: string, endpoint: string, form: Record, ): Promise { const body = Object.entries(form) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join("&"); return new Promise((resolve, reject) => { const req = https.request({ host, port: 443, path: `/${endpoint}`, method: "POST", // VyOS ships a self-signed certificate by default. Verification is // disabled deliberately; if this ever leaves a trusted segment, // install a real certificate and turn it back on. rejectUnauthorized: false, headers: { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(body), }, timeout: 120_000, }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => { try { const parsed = JSON.parse(data); if (parsed.success === false) { reject(new Error(`VyOS API: ${parsed.error ?? data}`)); } else { resolve(parsed); } } catch { reject(new Error(`VyOS API returned non-JSON: ${data.slice(0, 300)}`)); } }); }); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("VyOS API timed out")); }); req.write(body); req.end(); }); } /** * Replace a subtree: delete it, then set the desired state, in ONE request so * it is a single commit. Deleting first makes the result the declared state * rather than a merge with whatever was there -- which is what makes `pulumi * up` converge instead of accumulating. */ async function applyTree(i: Inputs, desired: string[][]): Promise { const commands: any[] = [{ op: "delete", path: i.path }]; for (const c of desired) { commands.push({ op: "set", path: [...i.path, ...c] }); } // The payload MUST be {commands: [...], confirm_time: N}, not a bare array. // VyOS only reads confirm_time when the body parses as ConfigureListModel // (`if isinstance(data, (ConfigureModel, ConfigureListModel, ...))` in // rest/routers.py). A bare array is accepted and committed happily -- with // NO timer armed, silently discarding the safety net. Verified both ways: // bare array gives no "Initialized commit-confirm" in the response, the // object form returns "Initialized commit-confirm; N minutes to confirm". const payload: any = { commands }; if (i.confirmMinutes > 0) payload.confirm_time = i.confirmMinutes; const res = await apiCall(i.host, i.apiKey, "configure", { key: i.apiKey, data: JSON.stringify(payload) }); if (i.confirmMinutes > 0) { // Refuse to continue if the timer did not actually arm -- otherwise a // silent fallback to an unprotected commit is exactly the failure this // resource exists to prevent. const said = String((res && res.data) || ""); if (!said.includes("commit-confirm")) { throw new Error( "commit-confirm was requested but VyOS did not arm a timer " + `(response: ${said.trim() || ""}). Refusing to proceed.`); } } if (i.confirmMinutes > 0) { // The commit landed but is on a timer. Reaching the API again proves // the box is still answering *after* the change -- the only evidence // worth confirming on. If this throws we deliberately do NOT confirm, // and the router reverts itself. await apiCall(i.host, i.apiKey, "retrieve", { key: i.apiKey, data: JSON.stringify({ op: "showConfig", path: [] }), }); // There is no /confirm endpoint -- confirm is an op on /configure, and // it requires a `path` field even though it ignores it (the Union // resolves to ConfigureModel, which mandates path). Without it the API // answers "missing 'path' field" and the timer keeps running. await apiCall(i.host, i.apiKey, "configure", { key: i.apiKey, data: JSON.stringify({ op: "confirm", path: [] }), }); } if (i.save) { await apiCall(i.host, i.apiKey, "config-file", { key: i.apiKey, data: JSON.stringify({ op: "save" }), }); } } const provider: pulumi.dynamic.ResourceProvider = { async create(inputs: Inputs) { await applyTree(inputs, inputs.commands); return { id: `${inputs.host}:${inputs.path.join("/")}`, outs: inputs }; }, async update(_id, _old: Inputs, news: Inputs) { await applyTree(news, news.commands); return { outs: news }; }, async delete(_id, props: Inputs) { const form: Record = { key: props.apiKey, data: JSON.stringify([{ op: "delete", path: props.path }]), }; if (props.confirmMinutes > 0) form.confirm_time = String(props.confirmMinutes); await apiCall(props.host, props.apiKey, "configure", form); if (props.confirmMinutes > 0) { await apiCall(props.host, props.apiKey, "configure", { key: props.apiKey, data: JSON.stringify({ op: "confirm", path: [] }) }); } }, async diff(_id, olds: Inputs, news: Inputs) { const changed = JSON.stringify(olds.commands) !== JSON.stringify(news.commands) || JSON.stringify(olds.path) !== JSON.stringify(news.path) || olds.host !== news.host; return { changes: changed, // Changing which subtree or which router is a different resource. replaces: olds.host !== news.host || JSON.stringify(olds.path) !== JSON.stringify(news.path) ? ["path"] : [], }; }, }; export class VyosConfigTree extends pulumi.dynamic.Resource { constructor(name: string, args: VyosConfigTreeArgs, opts?: pulumi.CustomResourceOptions) { super(provider, name, { confirmMinutes: 5, save: true, ...args, }, opts); } }