Read a one-shot that never ran as stopped, and one at rest as stopped to a stopped declaration (issue 1126)
The kernel module's boot-time cleanup, linux-modules-cleanup.service (Type=oneshot, declared stopped and enabled at boot), failed its delivery on ace with 'was asked to be stopped and is running': ServiceState read every inactive one-shot with ExecMainStatus=0 as running, and systemd says 0 for one that never ran too. Stopping an inactive unit changes nothing, so the read back said running again; after a boot that ran it, the same declaration would fail every apply. ServiceState now reads ExecMainStartTimestampMonotonic: 0 means never ran, read as stopped (a package's one-shot declared running is then started, and the hold no longer counts it as in use). The service apply reads a one-shot at rest as stopped to a declaration that it be stopped (ServiceAtRest), whatever its last run did. Quick fix weighed: declare the cleanup running in the catalogue. Thorough chosen: the reading was wrong for every one-shot, and this is its second fault of the kind (the firewall's was the first). Unit tests: three arch one-shot tests merged into one table (7 rows), one apply test (2 rows); 1263 to 1262.
This commit is contained in:
+80
-36
@@ -187,38 +187,11 @@ func describeAge(when, now time.Time) string {
|
||||
// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC
|
||||
// would have had to drop.
|
||||
func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
|
||||
out, asked := run(ctx, "systemctl", "show",
|
||||
"--property=LoadState", "--property=ActiveState", "--property=Type",
|
||||
"--property=RemainAfterExit", "--property=ExecMainStatus", "--", unit)
|
||||
|
||||
var load, active, kind, remains, exited string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "LoadState":
|
||||
load = value
|
||||
case "ActiveState":
|
||||
active = value
|
||||
case "Type":
|
||||
kind = value
|
||||
case "RemainAfterExit":
|
||||
remains = value
|
||||
case "ExecMainStatus":
|
||||
exited = value
|
||||
}
|
||||
u, err := showUnit(ctx, run, unit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch load {
|
||||
case "":
|
||||
// With why, where it said why: an account's manager that could not be reached says so
|
||||
// here, and nowhere else (novox/hq ADR 0177).
|
||||
if asked != nil {
|
||||
return "", fmt.Errorf("the service manager said nothing about %s: %w", unit, asked)
|
||||
}
|
||||
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
||||
switch u.load {
|
||||
case "not-found":
|
||||
return "", fmt.Errorf(
|
||||
"%s does not exist on this machine. A declaration naming a unit that is not "+
|
||||
@@ -227,7 +200,7 @@ func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string,
|
||||
case "masked":
|
||||
return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit)
|
||||
case "error", "bad-setting":
|
||||
return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load)
|
||||
return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, u.load)
|
||||
}
|
||||
|
||||
// **A one-shot that finished is not stopped.** A unit whose whole job is to apply something
|
||||
@@ -243,24 +216,95 @@ func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string,
|
||||
// wrong with.
|
||||
//
|
||||
// So for that shape, what "running" means is "it ran, and it worked".
|
||||
if kind == "oneshot" && remains != "yes" && active == "inactive" {
|
||||
if exited == "0" || exited == "" {
|
||||
//
|
||||
// **And one that never ran is not one that worked** (novox/hq issue 1126). systemd says
|
||||
// ExecMainStatus=0 for a unit that has not run since the manager loaded it exactly as for one
|
||||
// that succeeded; only its start timestamp, 0, separates them. Read as "running", a package's
|
||||
// boot-time one-shot declared stopped could never be satisfied — stopping an inactive unit
|
||||
// changes nothing, and the read back said running again — and one declared running was never
|
||||
// started. A timestamp the manager did not say keeps the reading it had before.
|
||||
if u.oneShotAtRest() {
|
||||
if u.started == "0" {
|
||||
return "stopped", nil
|
||||
}
|
||||
if u.exited == "0" || u.exited == "" {
|
||||
return "running", nil
|
||||
}
|
||||
return "stopped", nil
|
||||
}
|
||||
|
||||
switch active {
|
||||
switch u.active {
|
||||
case "active", "activating", "reloading":
|
||||
return "running", nil
|
||||
case "inactive", "failed", "deactivating":
|
||||
return "stopped", nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"the service manager reports %s as %q, which is neither running nor stopped", unit, active)
|
||||
"the service manager reports %s as %q, which is neither running nor stopped", unit, u.active)
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceAtRest is whether unit is a one-shot that does not linger and has nothing running now
|
||||
// (novox/hq issue 1126). Such a unit has no state in which a process is up between its runs, so a
|
||||
// declaration that it be stopped is satisfied whatever its last run did: there is nothing to stop,
|
||||
// and asking the manager to stop it changes nothing. A boot-time cleanup a package ships is declared
|
||||
// this way — enabled at boot, never started by an apply.
|
||||
func (arch) ServiceAtRest(ctx context.Context, run Runner, unit string) (bool, error) {
|
||||
u, err := showUnit(ctx, run, unit)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return u.oneShotAtRest(), nil
|
||||
}
|
||||
|
||||
// shownUnit is what `systemctl show` says of a unit, as far as its state is read.
|
||||
type shownUnit struct {
|
||||
load, active, kind, remains, exited, started string
|
||||
}
|
||||
|
||||
// oneShotAtRest is a one-shot that does not linger and is not running now.
|
||||
func (u shownUnit) oneShotAtRest() bool {
|
||||
return u.kind == "oneshot" && u.remains != "yes" && u.active == "inactive"
|
||||
}
|
||||
|
||||
// showUnit reads a unit's state properties. A manager that said nothing is an error, with why
|
||||
// where it said why: an account's manager that could not be reached says so here, and nowhere
|
||||
// else (novox/hq ADR 0177).
|
||||
func showUnit(ctx context.Context, run Runner, unit string) (shownUnit, error) {
|
||||
out, asked := run(ctx, "systemctl", "show",
|
||||
"--property=LoadState", "--property=ActiveState", "--property=Type",
|
||||
"--property=RemainAfterExit", "--property=ExecMainStatus",
|
||||
"--property=ExecMainStartTimestampMonotonic", "--", unit)
|
||||
var u shownUnit
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "LoadState":
|
||||
u.load = value
|
||||
case "ActiveState":
|
||||
u.active = value
|
||||
case "Type":
|
||||
u.kind = value
|
||||
case "RemainAfterExit":
|
||||
u.remains = value
|
||||
case "ExecMainStatus":
|
||||
u.exited = value
|
||||
case "ExecMainStartTimestampMonotonic":
|
||||
u.started = value
|
||||
}
|
||||
}
|
||||
if u.load == "" {
|
||||
if asked != nil {
|
||||
return u, fmt.Errorf("the service manager said nothing about %s: %w", unit, asked)
|
||||
}
|
||||
return u, fmt.Errorf("the service manager said nothing about %s", unit)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (arch) SetServiceState(ctx context.Context, run Runner, unit, state string) error {
|
||||
verb := "start"
|
||||
if state == "stopped" {
|
||||
|
||||
@@ -5,47 +5,60 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A one-shot unit that did its work and exited is satisfied, not stopped.
|
||||
// How a one-shot unit's state is read: what it did, not whether a process is up.
|
||||
//
|
||||
// **This made the firewall permanently unsatisfiable.** Its unit loads a rule set and exits, so it
|
||||
// is inactive the instant it succeeds — the host started it, it worked, the host read back
|
||||
// "stopped" and reported the machine as not doing what it was told. On every apply, for ever, with
|
||||
// the rules correctly in place the whole time.
|
||||
func TestAOneShotThatFinishedIsRunning(t *testing.T) {
|
||||
run := func(_ context.Context, _ string, _ ...string) (string, error) {
|
||||
return "LoadState=loaded\nActiveState=inactive\nType=oneshot\nRemainAfterExit=no\nExecMainStatus=0\n", nil
|
||||
}
|
||||
state, err := arch{}.ServiceState(context.Background(), run, "nftables.service")
|
||||
if err != nil {
|
||||
t.Fatalf("a one-shot that succeeded was an error: %v", err)
|
||||
}
|
||||
if state != "running" {
|
||||
t.Fatalf("a one-shot that did its work reads as %q, so it can never be satisfied", state)
|
||||
}
|
||||
}
|
||||
|
||||
// And one that failed is still stopped, or the host would report success for work that did not
|
||||
// happen — which is the opposite mistake and the worse one.
|
||||
func TestAOneShotThatFailedIsStopped(t *testing.T) {
|
||||
run := func(_ context.Context, _ string, _ ...string) (string, error) {
|
||||
return "LoadState=loaded\nActiveState=inactive\nType=oneshot\nRemainAfterExit=no\nExecMainStatus=1\n", nil
|
||||
}
|
||||
state, err := arch{}.ServiceState(context.Background(), run, "nftables.service")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if state != "stopped" {
|
||||
t.Fatalf("a one-shot that failed reads as %q, so a broken firewall reports success", state)
|
||||
}
|
||||
}
|
||||
|
||||
// A unit that lingers deliberately is unaffected: it says so, and its active state is the answer.
|
||||
func TestAOneShotThatRemainsIsJudgedByItsActiveState(t *testing.T) {
|
||||
run := func(_ context.Context, _ string, _ ...string) (string, error) {
|
||||
return "LoadState=loaded\nActiveState=active\nType=oneshot\nRemainAfterExit=yes\nExecMainStatus=0\n", nil
|
||||
}
|
||||
state, _ := arch{}.ServiceState(context.Background(), run, "thing.service")
|
||||
if state != "running" {
|
||||
t.Fatalf("a lingering one-shot reads as %q", state)
|
||||
// **A one-shot that did its work and exited is satisfied, not stopped.** Its unit loads a rule set
|
||||
// and exits, so it is inactive the instant it succeeds; reading that as stopped made the firewall
|
||||
// permanently unsatisfiable, on every apply, with the rules correctly in place the whole time.
|
||||
//
|
||||
// **One that failed is still stopped**, or the host would report success for work that did not
|
||||
// happen — the opposite mistake and the worse one.
|
||||
//
|
||||
// **One that never ran is stopped** (novox/hq issue 1126, the kernel's boot-time cleanup on ace):
|
||||
// systemd says ExecMainStatus=0 for a unit that never ran exactly as for one that succeeded, and
|
||||
// only its start timestamp separates them. Read as "running", a package's one-shot declared
|
||||
// running was never started, and one declared stopped could never be.
|
||||
//
|
||||
// **At rest** is whether a one-shot that does not linger has nothing running now: a declaration
|
||||
// that it be stopped is then satisfied, whatever it last did, because there is nothing to stop.
|
||||
func TestAOneShotIsReadByWhatItDid(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name, show, want string
|
||||
atRest bool
|
||||
}{
|
||||
{"finished", "ActiveState=inactive\nType=oneshot\nRemainAfterExit=no\nExecMainStatus=0\n" +
|
||||
"ExecMainStartTimestampMonotonic=5123456\n", "running", true},
|
||||
{"finished, no timestamp read", "ActiveState=inactive\nType=oneshot\nRemainAfterExit=no\n" +
|
||||
"ExecMainStatus=0\n", "running", true},
|
||||
{"failed", "ActiveState=inactive\nType=oneshot\nRemainAfterExit=no\nExecMainStatus=1\n" +
|
||||
"ExecMainStartTimestampMonotonic=5123456\n", "stopped", true},
|
||||
{"never ran", "ActiveState=inactive\nType=oneshot\nRemainAfterExit=no\nExecMainStatus=0\n" +
|
||||
"ExecMainStartTimestampMonotonic=0\n", "stopped", true},
|
||||
{"running now", "ActiveState=activating\nType=oneshot\nRemainAfterExit=no\nExecMainStatus=0\n" +
|
||||
"ExecMainStartTimestampMonotonic=5123456\n", "running", false},
|
||||
{"lingers", "ActiveState=active\nType=oneshot\nRemainAfterExit=yes\nExecMainStatus=0\n",
|
||||
"running", false},
|
||||
{"a daemon, stopped", "ActiveState=inactive\nType=simple\nRemainAfterExit=no\nExecMainStatus=0\n",
|
||||
"stopped", false},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
run := func(_ context.Context, _ string, _ ...string) (string, error) {
|
||||
return "LoadState=loaded\n" + c.show, nil
|
||||
}
|
||||
state, err := arch{}.ServiceState(context.Background(), run, "thing.service")
|
||||
if err != nil {
|
||||
t.Fatalf("read as an error: %v", err)
|
||||
}
|
||||
if state != c.want {
|
||||
t.Errorf("reads as %q, want %q", state, c.want)
|
||||
}
|
||||
rest, err := arch{}.ServiceAtRest(context.Background(), run, "thing.service")
|
||||
if err != nil {
|
||||
t.Fatalf("at rest read as an error: %v", err)
|
||||
}
|
||||
if rest != c.atRest {
|
||||
t.Errorf("at rest is %v, want %v", rest, c.atRest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user