From e60c24aee558c4eec3f30ee84d32593003b682d1 Mon Sep 17 00:00:00 2001 From: Erwin Dondorp Date: Fri, 16 Aug 2024 02:40:48 +0200 Subject: [PATCH] added support for orchestrations --- TODO.txt | 12 + docs/README.md | 16 ++ saltgui/static/scripts/Api.js | 9 + saltgui/static/scripts/Router.js | 6 +- saltgui/static/scripts/output/Output.js | 10 +- .../static/scripts/output/OutputHighstate.js | 3 +- .../static/scripts/pages/Orchestrations.js | 25 ++ saltgui/static/scripts/panels/Job.js | 8 +- saltgui/static/scripts/panels/Jobs.js | 2 + saltgui/static/scripts/panels/Login.js | 29 ++- .../static/scripts/panels/Orchestrations.js | 235 ++++++++++++++++++ 11 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 TODO.txt create mode 100644 saltgui/static/scripts/pages/Orchestrations.js create mode 100644 saltgui/static/scripts/panels/Orchestrations.js diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 000000000..a2d4a182c --- /dev/null +++ b/TODO.txt @@ -0,0 +1,12 @@ +Branch zzz-orchestrate +("zzz" to move it to the bottom of the list) +PoC for supporting the orchestration mechanism + +PRO: +* previously, orchestrations were not supported + +CON: +* so far, no one really need this + +VERDICT: +Keep until there is an actual request for it diff --git a/docs/README.md b/docs/README.md index 0bd9cfc88..20ae7d100 100644 --- a/docs/README.md +++ b/docs/README.md @@ -347,6 +347,22 @@ saltgui_hide_saltenvs: Typically only one of these variables should be set. Jobs that were started without the `saltenv` parameter are, for this purpose only, assumed to use the value `default` for this parameter. This allows these jobs to be hidden/showed using the same mechanism. SaltGUI does not replicate the internal logic of the salt-master and/or the salt-minion to determine which saltenv would actually have been used for such jobs. +## Orchestrations +The Orchestrations page shows the available orchestrations with their steps. Name, target and function are listed in separate columns. +All other details will be visible in the details column. The steps are listed in priority order. +But note that additional dependencies may cause an alternative execution sequence. + +In the configuration files, SaltStack does not clearly distingish between state-configuration and orchestration-configuration. +SaltGUI only shows information that has the orechestration format. + +An orchestration can be executed. The output resembles the output of highstate commands, but now each step is a whole salt command instead of a state. +Since the orchestration is run by the salt-master, the results are organized for only this host. +Note that SaltStack uses a slightly different minion-name for that. + +Note that each stage is started as a separate job. Neither SaltStack, nor SaltGUI, has information available to somehow group the results. + +Unlike the highstate system, there are no events available in the SaltStack that can be used to track the progress of an orchestration. + ## Issues The Issues page provides an overview of the system and reports any issues. When no issues are found, the list remains empty. diff --git a/saltgui/static/scripts/Api.js b/saltgui/static/scripts/Api.js index e0a98896d..af447572b 100644 --- a/saltgui/static/scripts/Api.js +++ b/saltgui/static/scripts/Api.js @@ -254,6 +254,15 @@ export class API { return this.apiRequest("POST", "/", params); } + getRunnerStateOrchestrateShowSls () { + const params = { + "arg": ["*"], + "client": "runner", + "fun": "state.orchestrate_show_sls" + }; + return this.apiRequest("POST", "/", params); + } + getWheelConfigValues () { const params = { "client": "wheel", diff --git a/saltgui/static/scripts/Router.js b/saltgui/static/scripts/Router.js index b2e86ad57..7f475e6b0 100644 --- a/saltgui/static/scripts/Router.js +++ b/saltgui/static/scripts/Router.js @@ -18,6 +18,7 @@ import {LogoutPage} from "./pages/Logout.js"; import {MinionsPage} from "./pages/Minions.js"; import {NodegroupsPage} from "./pages/Nodegroups.js"; import {OptionsPage} from "./pages/Options.js"; +import {OrchestrationsPage} from "./pages/Orchestrations.js"; import {Output} from "./output/Output.js"; import {PillarsMinionPage} from "./pages/PillarsMinion.js"; import {PillarsPage} from "./pages/Pillars.js"; @@ -57,6 +58,7 @@ export class Router { this._registerPage(Router.templatesPage = new TemplatesPage(this)); this._registerPage(Router.eventsPage = new EventsPage(this)); this._registerPage(Router.reactorsPage = new ReactorsPage(this)); + this._registerPage(Router.orchestrationsPage = new OrchestrationsPage(this)); this._registerPage(Router.optionsPage = new OptionsPage(this)); this._registerPage(Router.issuesPage = new IssuesPage(this)); this._registerPage(Router.logoutPage = new LogoutPage(this)); @@ -208,6 +210,7 @@ export class Router { this._registerMenuItem(null, "keys", "keys", "k"); this._registerMenuItem(null, "jobs", "jobs", "j"); this._registerMenuItem("jobs", "highstate", "highstate", "h"); + this._registerMenuItem("jobs", "orchestrations", "orchestrations", "o"); this._registerMenuItem("jobs", "templates", "templates", "t"); this._registerMenuItem(null, "events", "events", "e"); this._registerMenuItem("events", "reactors", "reactors", "r"); @@ -300,8 +303,9 @@ export class Router { Router._showMenuItem(pages, Router.beaconsPage); Router._showMenuItem(pages, Router.nodegroupsPage); Router._showMenuItem(pages, Router.keysPage); - Router._showMenuItem(pages, Router.jobsPage, ["highstate", "templates"]); + Router._showMenuItem(pages, Router.jobsPage, ["highstate", "orchestrations", "templates"]); Router._showMenuItem(pages, Router.highStatePage); + Router._showMenuItem(pages, Router.orchestrationsPage); Router._showMenuItem(pages, Router.templatesPage); Router._showMenuItem(pages, Router.eventsPage, ["reactors"]); Router._showMenuItem(pages, Router.reactorsPage); diff --git a/saltgui/static/scripts/output/Output.js b/saltgui/static/scripts/output/Output.js index 3e186a148..7ef4bcaa1 100644 --- a/saltgui/static/scripts/output/Output.js +++ b/saltgui/static/scripts/output/Output.js @@ -906,6 +906,10 @@ export class Output { let minionResponse = pResponse[minionId]; + if (commandCmd === "runner.state.orchestrate") { + minionResponse = minionResponse.return.return.data[minionId]; + } + const isSuccess = Output._getIsSuccess(minionResponse); minionResponse = Output._getMinionResponse(pCommand, minionResponse); // provide the same (simplified) object for download @@ -960,8 +964,10 @@ export class Output { // first put all the values in an array Object.keys(minionResponse).forEach( (taskKey) => { - minionResponse[taskKey].___key___ = taskKey; - tasks.push(minionResponse[taskKey]); + if (typeof minionResponse[taskKey] === "object") { + minionResponse[taskKey].___key___ = taskKey; + tasks.push(minionResponse[taskKey]); + } } ); // then sort the array diff --git a/saltgui/static/scripts/output/OutputHighstate.js b/saltgui/static/scripts/output/OutputHighstate.js index 787b00d4f..340eefe1d 100644 --- a/saltgui/static/scripts/output/OutputHighstate.js +++ b/saltgui/static/scripts/output/OutputHighstate.js @@ -21,8 +21,9 @@ export class OutputHighstate { case "state.highstate": case "state.sls": case "state.sls_id": - case "runners.state.orchestrate": break; + case "runner.state.orchestrate": + return true; case "state.low": // almost, but it is only one task // and we can handle only an object with tasks diff --git a/saltgui/static/scripts/pages/Orchestrations.js b/saltgui/static/scripts/pages/Orchestrations.js new file mode 100644 index 000000000..bd770e19c --- /dev/null +++ b/saltgui/static/scripts/pages/Orchestrations.js @@ -0,0 +1,25 @@ +/* global */ + +import {JobsSummaryPanel} from "../panels/JobsSummary.js"; +import {OrchestrationsPanel} from "../panels/Orchestrations.js"; +import {Page} from "./Page.js"; +import {Utils} from "../Utils.js"; + +export class OrchestrationsPage extends Page { + + constructor (pRouter) { + super("orchestrations", "Orchestrations", "page-orchestrations", "button-orchestrations", pRouter); + + this.orchestrations = new OrchestrationsPanel(); + super.addPanel(this.orchestrations); + this.jobs = new JobsSummaryPanel(); + super.addPanel(this.jobs); + } + + /* eslint-disable class-methods-use-this */ + isVisible () { + /* eslint-enable class-methods-use-this */ + // show orchestrations menu item if orchestrations defined + return Utils.getStorageItemBoolean("session", "orchestrations"); + } +} diff --git a/saltgui/static/scripts/panels/Job.js b/saltgui/static/scripts/panels/Job.js index b0d935457..6b898ee0f 100644 --- a/saltgui/static/scripts/panels/Job.js +++ b/saltgui/static/scripts/panels/Job.js @@ -263,8 +263,12 @@ export class JobPanel extends Panel { } else if (info.Function.startsWith("wheel.")) { minions = ["WHEEL"]; this.setWarningText("info", "WHEEL jobs are not associated with minions"); - } else if (info.Function.startsWith("runners.")) { - minions = ["RUNNER"]; + } else if (info.Function.startsWith("runner.")) { + if (typeof info.Result === "object") { + minions = Object.keys(info.Result); + } else { + minions = ["RUNNER"]; + } this.setWarningText("info", "RUNNER jobs are not associated with minions"); } else { minions = Object.keys(this.result); diff --git a/saltgui/static/scripts/panels/Jobs.js b/saltgui/static/scripts/panels/Jobs.js index 1cb2ae969..291b7cd84 100644 --- a/saltgui/static/scripts/panels/Jobs.js +++ b/saltgui/static/scripts/panels/Jobs.js @@ -175,6 +175,8 @@ export class JobsPanel extends Panel { this._hideJobs.push("runner.jobs.list_job"); this._hideJobs.push("runner.jobs.list_jobs"); this._hideJobs.push("runner.manage.versions"); + // do not hide "runner.state.orchestrate" + this._hideJobs.push("runner.state.orchestrate_show_sls"); // wheel jobs this._hideJobs.push("wheel.config.values"); this._hideJobs.push("wheel.key.accept"); diff --git a/saltgui/static/scripts/panels/Login.js b/saltgui/static/scripts/panels/Login.js index 420d03e69..65ea7a56d 100644 --- a/saltgui/static/scripts/panels/Login.js +++ b/saltgui/static/scripts/panels/Login.js @@ -304,13 +304,21 @@ export class LoginPanel extends Panel { // We need these functions to populate the dropdown boxes const wheelConfigValuesPromise = this.api.getWheelConfigValues(); + const runnerStateOrchestrateShowSlsPromise = this.api.getRunnerStateOrchestrateShowSls(); // these may have been hidden on a previous logout Utils.hideAllMenus(false); // We need these functions to populate the dropdown boxes + // or determine visibility of menu items wheelConfigValuesPromise.then((pWheelConfigValuesData) => { LoginPanel._handleLoginWheelConfigValues(pWheelConfigValuesData); + Router.updateMainMenu(); + return true; + }, () => false); + runnerStateOrchestrateShowSlsPromise.then((pRunnerStateOrchestrateShowSlsData) => { + LoginPanel._handleRunnerStateOrchestrateShowSls(pRunnerStateOrchestrateShowSlsData); + Router.updateMainMenu(); return true; }, () => false); @@ -339,6 +347,25 @@ export class LoginPanel extends Panel { }, 1000); } + static _handleRunnerStateOrchestrateShowSls (pRunnerStateOrchestrateShowSlsData) { + // until we prove it it available + Utils.setStorageItem("session", "orchestrations", "false"); + + const ret = pRunnerStateOrchestrateShowSlsData.return[0]; + for (const key in ret) { + const obj = ret[key]; + for (const stepkey in obj) { + const step = obj[stepkey].salt; + for (const item of step) { + if (item === "function" || item === "state" || item === "runner" || item === "wheel") { + Utils.setStorageItem("session", "orchestrations", "true"); + return; + } + } + } + } + } + static _handleLoginWheelConfigValues (pWheelConfigValuesData) { const wheelConfigValuesData = pWheelConfigValuesData.return[0].data.return; @@ -443,8 +470,6 @@ export class LoginPanel extends Panel { const fullReturn = wheelConfigValuesData.saltgui_full_return; Utils.setStorageItem("session", "full_return", fullReturn); - - Router.updateMainMenu(); } _onLoginFailure (error) { diff --git a/saltgui/static/scripts/panels/Orchestrations.js b/saltgui/static/scripts/panels/Orchestrations.js new file mode 100644 index 000000000..fa593e963 --- /dev/null +++ b/saltgui/static/scripts/panels/Orchestrations.js @@ -0,0 +1,235 @@ +/* global */ + +import {Character} from "../Character.js"; +import {DropDownMenu} from "../DropDown.js"; +import {Panel} from "./Panel.js"; +import {Utils} from "../Utils.js"; + +export class OrchestrationsPanel extends Panel { + + constructor () { + super("orchestrations"); + + this.addTitle("Orchestrations"); + this.addSearchButton(); + this.addWarningField(); + this.addTable(["-menu-", "Name", "Target", "Function", "Details"]); + this.setTableClickable("cmd"); + this.addMsg(); + } + + onShow () { + const runnerStateOrchestrateShowSlsPromise = this.router.api.getRunnerStateOrchestrateShowSls(); + + runnerStateOrchestrateShowSlsPromise.then((pStateOrchestrateShowSlsData) => { + this._handleOrchestrationsStateOrchestrateShowSls(pStateOrchestrateShowSlsData); + }, (pStateOrchestrateShowSlsMsg) => { + this._handleOrchestrationsStateOrchestrateShowSls(JSON.stringify(pStateOrchestrateShowSlsMsg)); + }); + } + + _handleOrchestrationsStateOrchestrateShowSls (pStateOrchestrateShowSlsData) { + if (this.showErrorRowInstead(pStateOrchestrateShowSlsData)) { + return; + } + + // should we update it or just use from cache (see commandbox) ? + let orchestrations = pStateOrchestrateShowSlsData.return[0]; + if (!orchestrations) { + orchestrations = {"dummy": {}}; + } + orchestrations = orchestrations[Object.keys(orchestrations)[0]]; + if (!orchestrations) { + orchestrations = {}; + } + + if (Array.isArray(orchestrations)) { + // that's a list of errors, show them + this.setWarningText("warn", "There " + + Utils.txtZeroOneMany(orchestrations.length, "are no errors", "is an error", "are errors") + + " in the orchestration configuration"); + for (const msg of orchestrations) { + const tr0 = Utils.createTr(); + const td = Utils.createTd("name", msg); + td.colSpan = 4; + tr0.appendChild(td); + this.table.tBodies[0].appendChild(tr0); + } + const txt = Utils.txtZeroOneMany(orchestrations.length, + "No errors", "{0} error", "{0} errors"); + this.setMsg(txt); + return; + } + + const keys = {}; + for (const key of Object.keys(orchestrations)) { + keys[orchestrations[key].__sls__] = []; + } + for (const key of Object.keys(orchestrations)) { + const orchestration = orchestrations[key]; + keys[orchestration.__sls__][key] = orchestration; + } + let nrOrchestrations = 0; + for (const key of Object.keys(keys).sort()) { + const orchestration = keys[key]; + if (this._addOrchestration(key, orchestration)) { + nrOrchestrations += 1; + } + } + + const txt = Utils.txtZeroOneMany(nrOrchestrations, + "No orchestrations", "{0} orchestration", "{0} orchestrations"); + this.setMsg(txt); + } + + _addOrchestration (pOrchestrationName, pOrchestrations) { + + const steps = []; + let ok = false; + for (const name of Object.keys(pOrchestrations)) { + const step = pOrchestrations[name]; + // add key-name to object itself + step.__key__ = name; + const salt = step.salt || []; + for (const item of salt) { + if (item && typeof item === "object") { + pOrchestrations[name] = Object.assign(step, item); + } else { + // assuming there is only one non-object... + step["__type__"] = item; + ok = true; + } + } + step.salt = []; + steps.push(step); + } + + if (!ok) { + // no evidence that this is an orchestration (likely just a highstate) + return false; + } + + const tr0 = Utils.createTr(); + + const menu = new DropDownMenu(tr0, "smaller"); + this._addMenuItemApplyOrchestration(menu, pOrchestrationName); + this._addMenuItemApplyOrchestrationTest(menu, pOrchestrationName); + + tr0.appendChild(Utils.createTd("name", pOrchestrationName)); + tr0.appendChild(Utils.createTd()); + tr0.appendChild(Utils.createTd()); + tr0.appendChild(Utils.createTd()); + + this.table.tBodies[0].appendChild(tr0); + + tr0.addEventListener("click", (pClickEvent) => { + const cmdArr = ["runners.state.orchestrate", pOrchestrationName]; + this.runCommand("", "", cmdArr); + pClickEvent.stopPropagation(); + }); + + steps.sort((aa, bb) => aa.order > bb.order); + + for (const step of steps) { + const tr1 = Utils.createTr(); + + // no menu per item + tr1.appendChild(Utils.createTd()); + + tr1.appendChild(Utils.createTd("name", Character.NO_BREAK_SPACE.repeat(3) + step.__key__)); + + // calculate targettype + const targetType = step["tgt_type"]; + // calculate target + const tgt = step["tgt"]; + if (!targetType && !tgt) { + tr1.appendChild(Utils.createTd("target value-none", "(none)")); + } else if (!tgt) { + // targetType cannot be null here + tr1.appendChild(Utils.createTd("target", targetType)); + } else if (targetType && targetType !== "glob" && targetType !== "list") { + // target cannot be null here + tr1.appendChild(Utils.createTd("target", targetType + " " + tgt)); + } else { + tr1.appendChild(Utils.createTd("target", tgt)); + } + + // calculate command + switch(step.__type__) { + case "function": + if (step["name"]) { + tr1.appendChild(Utils.createTd("command", step["name"])); + } else { + tr1.appendChild(Utils.createTd("command value-none", "(none)")); + } + break; + case "state": + tr1.appendChild(Utils.createTd("command", "salt.state")); + break; + case "runner": + tr1.appendChild(Utils.createTd("command", "salt.runner")); + break; + case "wheel": + tr1.appendChild(Utils.createTd("command", "salt.wheel")); + break; + default: + tr1.appendChild(Utils.createTd("command", step.__type__)); + } + + // calculate details + let details = ""; + for (const key in step) { + switch(key) { + case "__env__": + // TODO should support ENV + continue; + case "__key__": + continue; + case "__sls__": + continue; + case "__type__": + continue; + case "name": + if (step.__type__ === "function") { + continue; + } + break; + case "order": + continue; + case "salt": + continue; + case "tgt": + continue; + case "tgt_type": + continue; + default: + break; + } + details += ", " + key + "=" + JSON.stringify(step[key]); + } + if (details) { + tr1.appendChild(Utils.createTd("details", details.substring(2))); + } else { + tr1.appendChild(Utils.createTd("details value-none", "(none)")); + } + + this.table.tBodies[0].appendChild(tr1); + } + + return true; + } + + _addMenuItemApplyOrchestration (pMenu, pOrchestrationName) { + pMenu.addMenuItem("Apply orchestration...", () => { + const cmdArr = ["runners.state.orchestrate", pOrchestrationName]; + this.runCommand("", "", cmdArr); + }); + } + + _addMenuItemApplyOrchestrationTest (pMenu, pOrchestrationName) { + pMenu.addMenuItem("Test orchestration...", () => { + const cmdArr = ["runners.state.orchestrate", "test=", true, pOrchestrationName]; + this.runCommand("", "", cmdArr); + }); + } +}