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:
Martin Pitt 2020-07-14 14:46:26 +02:00 committed by Marius Vollmer
parent 1f8ae19d4d
commit cf2eed5de7
5 changed files with 200 additions and 96 deletions

View File

@ -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>

View File

@ -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);
});

View File

@ -0,0 +1 @@
../../pkg/lib/long-running-process.js

View File

@ -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()}`);
}
});
}
}

View File

@ -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__':