lib: Add LongRunningProcess class
Extract the generic process tracking and starting logic from examples/long-running-process into a library. Keep journal reading/handling in the example. That part is very application-specific, callers may not even need it, and it's also not too complex.
This commit is contained in:
parent
1f8ae19d4d
commit
cf2eed5de7
|
@ -6,15 +6,15 @@
|
|||
<link href="../base1/cockpit.css" type="text/css" rel="stylesheet">
|
||||
<link href="long-running.css" type="text/css" rel="stylesheet">
|
||||
<script src="../base1/cockpit.js"></script>
|
||||
<script src="index.js"></script>
|
||||
<script type="module" src="long-running-process.js"></script>
|
||||
<script type="module" src="index.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<p><label>State:</label> <span id="state"></span></p>
|
||||
<p><input id="command" type="text" /> <button id="run" disabled>Start process</button></p>
|
||||
<p><input id="command" type="text" /> <button id="run" disabled>Start</button></p>
|
||||
<pre id="output"></pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
@ -1,106 +1,59 @@
|
|||
/* global cockpit */
|
||||
|
||||
import { LongRunningProcess, ProcessState } from './long-running-process.js';
|
||||
|
||||
// DOM objects
|
||||
let state, command, run_button, output;
|
||||
|
||||
// systemd D-Bus API names
|
||||
const O_SD_OBJ = "/org/freedesktop/systemd1";
|
||||
const I_SD_MGR = "org.freedesktop.systemd1.Manager";
|
||||
const I_SD_UNIT = "org.freedesktop.systemd1.Unit";
|
||||
const I_DBUS_PROP = "org.freedesktop.DBus.Properties";
|
||||
|
||||
// default shell command for the long-running process to run
|
||||
const default_command = "date; for i in `seq 30`; do echo $i; sleep 1; done";
|
||||
|
||||
// don't require superuser; this is only for reading the current state
|
||||
const systemd_client = cockpit.dbus("org.freedesktop.systemd1");
|
||||
|
||||
function error(ex) {
|
||||
state.innerHTML = "Error: " + ex.toString();
|
||||
run_button.setAttribute("disabled", "");
|
||||
}
|
||||
|
||||
// follow live output of the given unit, put into "output" <pre> area
|
||||
function showJournal(unitName, filter_arg) {
|
||||
// run at most one instance of journal tailing
|
||||
if (this.journalctl)
|
||||
if (showJournal.journalctl)
|
||||
return;
|
||||
|
||||
// reset previous output
|
||||
output.innerHTML = "";
|
||||
|
||||
const argv = ["journalctl", "--output=cat", "--unit", unitName, "--follow", "--lines=all", filter_arg];
|
||||
this.journalctl = cockpit.spawn(argv, { superuser: "require", err: "message" })
|
||||
showJournal.journalctl = cockpit.spawn(argv, { superuser: "require", err: "message" })
|
||||
.stream(data => output.append(document.createTextNode(data)))
|
||||
.catch(ex => { output.innerHTML = JSON.stringify(ex) });
|
||||
}
|
||||
|
||||
// check if the transient unit for our command is running
|
||||
function checkState(unit) {
|
||||
systemd_client.call(O_SD_OBJ, I_SD_MGR, "GetUnit", [unit], { type: "s" })
|
||||
.then(([unitObj]) => {
|
||||
/* Some time may pass between getting JobNew and the unit actually getting activated;
|
||||
* we may get an inactive unit here; watch for state changes. This will also update
|
||||
* the UI if the unit stops. */
|
||||
this.subscription = systemd_client.subscribe(
|
||||
{ interface: I_DBUS_PROP, member: "PropertiesChanged" },
|
||||
(path, iface, signal, args) => {
|
||||
if (path === unitObj && args[1].ActiveState)
|
||||
checkState(unit);
|
||||
});
|
||||
function update(process) {
|
||||
state.innerHTML = cockpit.format("$0 $1", process.serviceName, process.state);
|
||||
|
||||
systemd_client.call(unitObj, I_DBUS_PROP, "GetAll", [I_SD_UNIT], { type: "s" })
|
||||
.then(([props]) => {
|
||||
if (props.ActiveState.v === 'activating') {
|
||||
state.innerHTML = cockpit.format("$0 is running", unit);
|
||||
run_button.setAttribute("disabled", "");
|
||||
// StateChangeTimestamp property is in µs since epoch, but journalctl expects seconds
|
||||
showJournal(unit, "--since=@" + Math.floor(props.StateChangeTimestamp.v / 1000000));
|
||||
} else if (props.ActiveState.v === 'failed') {
|
||||
state.innerHTML = cockpit.format("$0 is not running and failed", unit);
|
||||
run_button.setAttribute("disabled", "");
|
||||
// Show the whole journal of this boot; this may be refined a bit with props.InvocationID
|
||||
showJournal(unit, "--boot");
|
||||
} else {
|
||||
/* Type=oneshot transient units only have state "activating" or "failed",
|
||||
* or don't exist at all (handled below in NoSuchUnit case).
|
||||
* If you don't care about "failed", call systemd-run with --collect */
|
||||
state.innerHTML = cockpit.format("Error: unexpected state of $0: $1", unit, props.ActiveState.v);
|
||||
}
|
||||
})
|
||||
.catch(error);
|
||||
})
|
||||
.catch(ex => {
|
||||
if (ex.name === "org.freedesktop.systemd1.NoSuchUnit") {
|
||||
if (this.subscription) {
|
||||
this.subscription.remove();
|
||||
this.subscription = null;
|
||||
}
|
||||
state.innerHTML = cockpit.format("$0 is not running", unit);
|
||||
run_button.removeAttribute("disabled");
|
||||
} else {
|
||||
error(ex);
|
||||
}
|
||||
});
|
||||
switch (process.state) {
|
||||
case ProcessState.INIT:
|
||||
break;
|
||||
case ProcessState.STOPPED:
|
||||
run_button.removeAttribute("disabled");
|
||||
break;
|
||||
case ProcessState.RUNNING:
|
||||
run_button.setAttribute("disabled", "");
|
||||
// StateChangeTimestamp property is in µs since epoch, but journalctl expects seconds
|
||||
showJournal(process.serviceName, "--since=@" + Math.floor(process.startTimestamp / 1000000));
|
||||
break;
|
||||
case ProcessState.FAILED:
|
||||
run_button.setAttribute("disabled", "");
|
||||
// Show the whole journal of this boot
|
||||
showJournal(process.serviceName, "--boot");
|
||||
break;
|
||||
default:
|
||||
throw new Error("unexpected process.state: " + process.state);
|
||||
}
|
||||
}
|
||||
|
||||
/* Start long-running process, called on clicking the "Start Process" button.
|
||||
* This runs as root, thus will be shared with all privileged Cockpit sessions.
|
||||
*/
|
||||
function run(unit) {
|
||||
const argv = ["systemd-run", "--unit", unit, "--service-type=oneshot", "--no-block", "--", "/bin/sh", "-ec", command.value];
|
||||
cockpit.spawn(argv, { superuser: "require", err: "message" })
|
||||
.catch(error);
|
||||
}
|
||||
|
||||
// called once after page initializes
|
||||
function setup() {
|
||||
// called once after page initializes; set up the page
|
||||
cockpit.transport.wait(() => {
|
||||
state = document.getElementById("state");
|
||||
command = document.getElementById("command");
|
||||
run_button = document.getElementById("run");
|
||||
output = document.getElementById("output");
|
||||
|
||||
state.innerHTML = "initializing";
|
||||
command.value = default_command;
|
||||
|
||||
/* Build a service name which contains exactly the identifying properties for the
|
||||
|
@ -109,16 +62,17 @@ function setup() {
|
|||
* etc. if the page is dealing with multiple commands. */
|
||||
const serviceName = "cockpit-longrunning.service";
|
||||
|
||||
run_button.addEventListener("click", () => run(serviceName));
|
||||
// Set up process manager; update() is called whenever the running state changes
|
||||
const process = new LongRunningProcess(serviceName, update);
|
||||
|
||||
// Watch for start event of the service
|
||||
systemd_client.subscribe({ interface: I_SD_MGR, member: "JobNew" }, (path, iface, signal, args) => {
|
||||
if (args[2] == serviceName)
|
||||
checkState(serviceName);
|
||||
/* Start process on clicking the "Start" button
|
||||
* This runs as root, thus will be shared with all privileged Cockpit sessions.
|
||||
*/
|
||||
run_button.addEventListener("click", () => {
|
||||
process.run(["/bin/sh", "-ec", command.value])
|
||||
.catch(ex => {
|
||||
state.innerHTML = "Error: " + ex.toString();
|
||||
run_button.setAttribute("disabled", "");
|
||||
});
|
||||
});
|
||||
// Check if it is already running
|
||||
checkState(serviceName);
|
||||
}
|
||||
|
||||
// Wait until page is loaded
|
||||
cockpit.transport.wait(setup);
|
||||
});
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../../pkg/lib/long-running-process.js
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* This file is part of Cockpit.
|
||||
*
|
||||
* Copyright (C) 2020 Red Hat, Inc.
|
||||
*
|
||||
* Cockpit is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU Lesser General Public License as published by
|
||||
* the Free Software Foundation; either version 2.1 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Cockpit is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* Manage a long-running precious process that runs independently from a Cockpit
|
||||
* session in a transient systemd service unit. See
|
||||
* examples/long-running-process/README.md for details.
|
||||
*
|
||||
* The unit will run as root, on the system systemd manager, so that every privileged
|
||||
* Cockpit session shares the same unit. The same approach works in principle on
|
||||
* the user's systemd instance, but the current code does not support that as it
|
||||
* is not a common use case for Cockpit.
|
||||
*/
|
||||
|
||||
/* global cockpit */
|
||||
|
||||
// systemd D-Bus API names
|
||||
const O_SD_OBJ = "/org/freedesktop/systemd1";
|
||||
const I_SD_MGR = "org.freedesktop.systemd1.Manager";
|
||||
const I_SD_UNIT = "org.freedesktop.systemd1.Unit";
|
||||
const I_DBUS_PROP = "org.freedesktop.DBus.Properties";
|
||||
|
||||
/* Possible LongRunningProcess.state values */
|
||||
export const ProcessState = {
|
||||
INIT: 'init',
|
||||
STOPPED: 'stopped',
|
||||
RUNNING: 'running',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
|
||||
export class LongRunningProcess {
|
||||
/* serviceName: systemd unit name to start or reattach to
|
||||
* updateCallback: function that gets called whenever the state changed; first and only
|
||||
* argument is `this` LongRunningProcess instance.
|
||||
*/
|
||||
constructor(serviceName, updateCallback) {
|
||||
// don't require superuser; this is only for reading the current state
|
||||
this.systemdClient = cockpit.dbus("org.freedesktop.systemd1");
|
||||
this.serviceName = serviceName;
|
||||
this.updateCallback = updateCallback;
|
||||
this._setState(ProcessState.INIT);
|
||||
this.startTimestamp = null; // µs since epoch
|
||||
|
||||
// Watch for start event of the service
|
||||
this.systemdClient.subscribe({ interface: I_SD_MGR, member: "JobNew" }, (path, iface, signal, args) => {
|
||||
if (args[2] == this.serviceName)
|
||||
this._checkState();
|
||||
});
|
||||
|
||||
// Check if it is already running
|
||||
this._checkState();
|
||||
}
|
||||
|
||||
/* Start long-running process. Only call this in states STOPPED or FAILED.
|
||||
* This runs as root, thus will be shared with all privileged Cockpit sessions.
|
||||
* Return cockpit.spawn promise. You need to handle exceptions, but not success.
|
||||
*/
|
||||
run(argv, options) {
|
||||
if (this.state !== ProcessState.STOPPED && this.state !== ProcessState.FAILED)
|
||||
throw new Error(`cannot start LongRunningProcess in state ${ this.state }`);
|
||||
|
||||
// no need to directly react to this -- JobNew and _checkState() will pick up when the unit runs
|
||||
return cockpit.spawn(["systemd-run", "--unit", this.serviceName, "--service-type=oneshot", "--no-block", "--"].concat(argv),
|
||||
{ superuser: "require", err: "message", ...options });
|
||||
}
|
||||
|
||||
/*
|
||||
* below are internal private methods
|
||||
*/
|
||||
|
||||
_setState(state) {
|
||||
/* PropertiesChanged often gets fired multiple times with the same values, avoid UI flicker */
|
||||
if (state === this.state)
|
||||
return;
|
||||
this.state = state;
|
||||
if (this.updateCallback)
|
||||
this.updateCallback(this);
|
||||
}
|
||||
|
||||
_setStateFromProperties(activeState, stateChangeTimestamp) {
|
||||
switch (activeState) {
|
||||
case 'activating':
|
||||
this.startTimestamp = stateChangeTimestamp;
|
||||
this._setState(ProcessState.RUNNING);
|
||||
break;
|
||||
case 'failed':
|
||||
this.startTimestamp = null; // TODO: can we derive this from InvocationID?
|
||||
this._setState(ProcessState.FAILED);
|
||||
break;
|
||||
case 'inactive':
|
||||
this._setState(ProcessState.STOPPED);
|
||||
break;
|
||||
case 'deactivating':
|
||||
/* ignore these transitions */
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected state of unit ${this.serviceName}: ${activeState}`);
|
||||
}
|
||||
}
|
||||
|
||||
// check if the transient unit for our command is running
|
||||
_checkState() {
|
||||
this.systemdClient.call(O_SD_OBJ, I_SD_MGR, "GetUnit", [this.serviceName], { type: "s" })
|
||||
.then(([unitObj]) => {
|
||||
/* Some time may pass between getting JobNew and the unit actually getting activated;
|
||||
* we may get an inactive unit here; watch for state changes. This will also update
|
||||
* the UI if the unit stops. */
|
||||
this.subscription = this.systemdClient.subscribe(
|
||||
{ interface: I_DBUS_PROP, member: "PropertiesChanged" },
|
||||
(path, iface, signal, args) => {
|
||||
if (path === unitObj && args[1].ActiveState && args[1].StateChangeTimestamp)
|
||||
this._setStateFromProperties(args[1].ActiveState.v, args[1].StateChangeTimestamp.v);
|
||||
});
|
||||
|
||||
this.systemdClient.call(unitObj, I_DBUS_PROP, "GetAll", [I_SD_UNIT], { type: "s" })
|
||||
.then(([props]) => this._setStateFromProperties(props.ActiveState.v, props.StateChangeTimestamp.v))
|
||||
.catch(ex => {
|
||||
throw new Error(`unexpected failure of GetAll(${unitObj}): ${ex.toString()}`);
|
||||
});
|
||||
})
|
||||
.catch(ex => {
|
||||
if (ex.name === "org.freedesktop.systemd1.NoSuchUnit") {
|
||||
if (this.subscription) {
|
||||
this.subscription.remove();
|
||||
this.subscription = null;
|
||||
}
|
||||
this._setState(ProcessState.STOPPED);
|
||||
} else {
|
||||
throw new Error(`unexpected failure of GetUnit(${this.serviceName}): ${ex.toString()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -87,7 +87,7 @@ class TestLongRunning(MachineCase):
|
|||
self.login_and_go("/long-running-process")
|
||||
|
||||
self.assertEqual(m.execute("systemctl is-active cockpit-longrunning.service || true").strip(), "inactive")
|
||||
b.wait_text("#state", "cockpit-longrunning.service is not running")
|
||||
b.wait_text("#state", "cockpit-longrunning.service stopped")
|
||||
b.wait_text("#output", "")
|
||||
|
||||
# run a command that the test can control synchronously
|
||||
|
@ -95,7 +95,7 @@ class TestLongRunning(MachineCase):
|
|||
b.set_val("#command", "date; echo STEP_A; until [ -e %s ]; do sleep 1; done; echo STEP_B; sleep 1; echo DONE" % ack_file)
|
||||
b.click("button#run")
|
||||
|
||||
b.wait_text("#state", "cockpit-longrunning.service is running")
|
||||
b.wait_text("#state", "cockpit-longrunning.service running")
|
||||
b.wait_in_text("#output", "\nSTEP_A\n")
|
||||
self.assertNotIn("\nSTEP_B", b.text("#output"))
|
||||
self.assertEqual(m.execute("systemctl is-active cockpit-longrunning.service || true").strip(), "activating")
|
||||
|
@ -103,7 +103,7 @@ class TestLongRunning(MachineCase):
|
|||
# reattaches in new session
|
||||
b.logout()
|
||||
b.login_and_go("/long-running-process")
|
||||
b.wait_text("#state", "cockpit-longrunning.service is running")
|
||||
b.wait_text("#state", "cockpit-longrunning.service running")
|
||||
b.wait_in_text("#output", "\nSTEP_A\n")
|
||||
self.assertEqual(m.execute("systemctl is-active cockpit-longrunning.service || true").strip(), "activating")
|
||||
|
||||
|
@ -112,23 +112,23 @@ class TestLongRunning(MachineCase):
|
|||
b.wait_in_text("#output", "\nSTEP_B\n")
|
||||
# wait for completion
|
||||
b.wait_in_text("#output", "\nDONE\n")
|
||||
b.wait_text("#state", "cockpit-longrunning.service is not running")
|
||||
b.wait_text("#state", "cockpit-longrunning.service stopped")
|
||||
self.assertEqual(m.execute("systemctl is-active cockpit-longrunning.service || true").strip(), "inactive")
|
||||
|
||||
# in next session it is back at "not running"
|
||||
b.logout()
|
||||
b.login_and_go("/long-running-process")
|
||||
b.wait_text("#state", "cockpit-longrunning.service is not running")
|
||||
b.wait_text("#state", "cockpit-longrunning.service stopped")
|
||||
b.wait_text("#output", "")
|
||||
|
||||
# failing process
|
||||
m.execute("rm -f " + ack_file)
|
||||
b.set_val("#command", "date; echo BREAK_A; until [ -e %s ]; do sleep 1; done; false; echo NOTME" % ack_file)
|
||||
b.click("button#run")
|
||||
b.wait_text("#state", "cockpit-longrunning.service is running")
|
||||
b.wait_text("#state", "cockpit-longrunning.service running")
|
||||
b.wait_in_text("#output", "\nBREAK_A\n")
|
||||
m.execute("touch " + ack_file)
|
||||
b.wait_text("#state", "cockpit-longrunning.service is not running and failed")
|
||||
b.wait_text("#state", "cockpit-longrunning.service failed")
|
||||
b.wait_in_text("#output", "cockpit-longrunning.service: Main process exited, code=exited, status=1/FAILURE")
|
||||
out = b.text("#output")
|
||||
self.assertNotIn("\nNOTME", out)
|
||||
|
@ -138,11 +138,12 @@ class TestLongRunning(MachineCase):
|
|||
# failing state gets picked up on page reconnect
|
||||
b.logout()
|
||||
b.login_and_go("/long-running-process")
|
||||
b.wait_text("#state", "cockpit-longrunning.service is not running and failed")
|
||||
b.wait_text("#state", "cockpit-longrunning.service failed")
|
||||
b.wait_in_text("#output", "cockpit-longrunning.service: Main process exited, code=exited, status=1/FAILURE")
|
||||
out = b.text("#output")
|
||||
self.assertIn("\nBREAK_A\n", out)
|
||||
self.assertNotIn("\nNOTME", out)
|
||||
b.wait_present("button#run:disabled")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
Loading…
Reference in New Issue