From 35791182f355ea8f70720fd279365e343b7001bc Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Sat, 1 Aug 2026 02:32:47 +0800 Subject: [PATCH] fix(schedule): support systemd 256 missing-unit diagnostic --- CHANGELOG.md | 8 +++ docs/releasing.md | 20 ++++---- internal/schedule/schedule_test.go | 62 +++++++++++++++++++++-- internal/schedule/system.go | 13 +++-- internal/schedule/system_test.go | 80 ++++++++++++++++++++++++------ 5 files changed, 148 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b6b64..8a4dd7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. +## v2.9.1 - 2026-08-01 + +- Accept the exact C-locale diagnostic emitted by systemd 256 and later when + `systemctl disable --now` targets an already-absent timer, then retain the + existing explicit stop and inactive confirmation before treating cleanup as + complete. This restores invitation creation on Debian 13 without hiding + permission, D-Bus, wrong-unit, or otherwise non-exact systemctl failures. + ## v2.9.0 - 2026-08-01 - Repair an incomplete audit-log tail before the next append so a crash cannot diff --git a/docs/releasing.md b/docs/releasing.md index 65a0777..79466e7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -431,10 +431,10 @@ git -c user.name='XXV.CC' \ -c user.signingkey="${TAG_SIGNING_FPR}!" \ -c gpg.format=openpgp \ -c gpg.program=/usr/bin/gpg \ - tag -s v2.9.0 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.9.0' + tag -s v2.9.1 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.9.1' git -c gpg.format=openpgp -c gpg.program=/usr/bin/gpg \ - verify-tag --raw v2.9.0 -git push origin v2.9.0 + verify-tag --raw v2.9.1 +git push origin v2.9.1 ``` Before pushing, the `VALIDSIG` record from `verify-tag --raw` must identify the @@ -501,7 +501,7 @@ printf '\n' >/dev/tty || fail "GH_TOKEN must be one non-empty token without whitespace" export GH_TOKEN exec /opt/lta-release-tools/prepare-release.sh \ - v2.9.0 /srv/linux-temp-admin /srv/release-transfer/v2.9.0-prepared + v2.9.1 /srv/linux-temp-admin /srv/release-transfer/v2.9.1-prepared LTA_PREPARE_RELEASE ``` @@ -521,7 +521,7 @@ the candidate or transfer media: LTA_SIGN_KEY=/offline/keys/release-v1.key LTA_TRUSTED_SIGNER=/opt/lta-release-tools/lta-release LTA_TRUSTED_SIGNER_SHA256='' -LTA_EXPECTED_TAG=v2.9.0 +LTA_EXPECTED_TAG=v2.9.1 LTA_EXPECTED_COMMIT='' LTA_EXPECTED_PREPARED_MANIFEST_SHA256='' LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='' @@ -533,7 +533,7 @@ LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='/dev/tty || fail "GH_TOKEN must be one non-empty token without whitespace" export GH_TOKEN exec /opt/lta-release-tools/publish-release.sh \ - /srv/release-transfer/v2.9.0-signed /srv/linux-temp-admin + /srv/release-transfer/v2.9.1-signed /srv/linux-temp-admin LTA_PUBLISH_RELEASE ``` @@ -684,7 +684,7 @@ noncanonical stable tag or mutable recovery target, excludes the failed `TAG`, and binds both the mutation and resulting Latest state to one numeric Release ID: ```bash -TAG=v2.9.0 # the failed release; verify this value before running +TAG=v2.9.1 # the failed release; verify this value before running /usr/bin/sudo /usr/bin/env -i \ HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ TAG="$TAG" /bin/bash -p <<'LTA_LATEST_RECOVERY' @@ -849,7 +849,7 @@ announcement: ```bash gh workflow run mirror-release.yml --repo xxvcc/linux-temp-admin \ - --ref main -f tag=v2.9.0 + --ref main -f tag=v2.9.1 gh run list --repo xxvcc/linux-temp-admin \ --workflow mirror-release.yml --event workflow_dispatch --limit 1 ``` @@ -1080,7 +1080,7 @@ the release audit/signing record and a separate authenticated channel, then run: ```bash INSTALLER_COMMIT='replace-with-the-audited-40-hex-commit' INSTALLER_SHA256='replace-with-the-independent-64-hex-script-hash' -LTA_RELEASE_TAG='v2.9.0' +LTA_RELEASE_TAG='v2.9.1' /usr/bin/sudo /usr/bin/env -i \ HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ INSTALLER_COMMIT="$INSTALLER_COMMIT" INSTALLER_SHA256="$INSTALLER_SHA256" \ diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go index abab519..2f9ccd3 100644 --- a/internal/schedule/schedule_test.go +++ b/internal/schedule/schedule_test.go @@ -255,6 +255,20 @@ func TestScheduleRollsBackPartiallyEnabledSystemdTimerBeforeAtFallback(t *testin if len(args) == 3 && args[0] == "enable" { return errors.New("enable failed after starting timer") } + if len(args) == 3 && args[0] == "disable" { + return &systemctlError{ + args: append([]string(nil), args...), + err: errors.New("exit status 1"), + output: "Failed to disable unit: Unit " + args[2] + " does not exist", + } + } + if len(args) == 2 && args[0] == "stop" { + return &systemctlError{ + args: append([]string(nil), args...), + err: errors.New("exit status 5"), + output: "Failed to stop " + args[1] + ": Unit " + args[1] + " not loaded.", + } + } return nil } s := newScheduler(dir, sys) @@ -282,6 +296,7 @@ func TestScheduleRollsBackPartiallyEnabledSystemdTimerBeforeAtFallback(t *testin "daemon-reload", "enable --now " + unit + ".timer", "disable --now " + unit + ".timer", + "stop " + unit + ".timer", "daemon-reload", } if gotCalls := joinedSystemctlCalls(sys.calls); strings.Join(gotCalls, "|") != strings.Join(wantCalls, "|") { @@ -480,7 +495,7 @@ func TestCancelTreatsMissingTimerAsSuccessWhenOnlyServiceRemains(t *testing.T) { return &systemctlError{ args: append([]string(nil), args...), err: errors.New("exit status 1"), - output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.", + output: "Failed to disable unit: Unit " + unit + ".timer does not exist", } } if len(args) == 2 && args[0] == "stop" { @@ -504,6 +519,45 @@ func TestCancelTreatsMissingTimerAsSuccessWhenOnlyServiceRemains(t *testing.T) { } } +func TestCancelTreatsModernMissingTimersInCurrentAndLegacyNamespacesAsSuccess(t *testing.T) { + sys := &fakeSystem{hasSystemctl: true} + s := newScheduler(t.TempDir(), sys) + s.LegacyUnitPrefixes = []string{"linux-temp-admin-revoke-"} + sys.systemctlErr = func(args ...string) error { + if len(args) == 3 && args[0] == "disable" { + return &systemctlError{ + args: append([]string(nil), args...), + err: errors.New("exit status 1"), + output: "Failed to disable unit: Unit " + args[2] + " does not exist", + } + } + if len(args) == 2 && args[0] == "stop" { + return &systemctlError{ + args: append([]string(nil), args...), + err: errors.New("exit status 5"), + output: "Failed to stop " + args[1] + ": Unit " + args[1] + " not loaded.", + } + } + return nil + } + + if err := s.Cancel("xxvcc-a1", ""); err != nil { + t.Fatalf("empty current and legacy namespaces should be idempotent success: %v", err) + } + want := []string{ + "disable --now linux-temp-admin-v2-revoke-xxvcc-a1.timer", + "stop linux-temp-admin-v2-revoke-xxvcc-a1.timer", + "disable --now linux-temp-admin-revoke-xxvcc-a1.timer", + "stop linux-temp-admin-revoke-xxvcc-a1.timer", + } + got := strings.Join(joinedSystemctlCalls(sys.calls), "|") + for _, call := range want { + if !strings.Contains(got, call) { + t.Errorf("missing systemctl call %q; calls=%v", call, sys.calls) + } + } +} + func TestCancelExplicitlyStopsActiveTimerWhoseUnitFileIsMissing(t *testing.T) { dir := t.TempDir() sys := &fakeSystem{hasSystemctl: true} @@ -520,7 +574,7 @@ func TestCancelExplicitlyStopsActiveTimerWhoseUnitFileIsMissing(t *testing.T) { return &systemctlError{ args: append([]string(nil), args...), err: errors.New("exit status 1"), - output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.", + output: "Failed to disable unit: Unit " + unit + ".timer does not exist", } case len(args) == 2 && args[0] == "stop": active = false @@ -560,7 +614,7 @@ func TestCancelPreservesEvidenceWhenMissingTimerStateIsUncertain(t *testing.T) { return &systemctlError{ args: append([]string(nil), args...), err: errors.New("exit status 1"), - output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.", + output: "Failed to disable unit: Unit " + unit + ".timer does not exist", } } if len(args) == 2 && args[0] == "stop" { @@ -594,7 +648,7 @@ func TestScheduleDoesNotFallbackWhenMissingFileRollbackCannotStopTimer(t *testin return &systemctlError{ args: append([]string(nil), args...), err: errors.New("exit status 1"), - output: "Failed to disable unit: Unit file " + unit + ".timer does not exist.", + output: "Failed to disable unit: Unit " + unit + ".timer does not exist", } case len(args) == 3 && args[0] == "is-active": return nil diff --git a/internal/schedule/system.go b/internal/schedule/system.go index 7e18241..3b1592c 100644 --- a/internal/schedule/system.go +++ b/internal/schedule/system.go @@ -127,10 +127,12 @@ func parseLoadedSystemdUnits(out string) ([]string, error) { return units, nil } -// systemctlUnitFileMissing reports only the exact failure produced when +// systemctlUnitFileMissing reports only the exact C-locale failures produced when // `systemctl disable --now` races with (or follows) removal of its target unit. -// It is not success by itself: systemctl returns from the disable phase before -// --now reaches stop, so callers must independently confirm the timer is inactive. +// systemd 256 and later dropped "file" and the final period from this diagnostic. +// Neither form is success by itself: systemctl returns from the disable phase +// before --now reaches stop, so callers must independently confirm the timer is +// inactive. func systemctlUnitFileMissing(err error, unit string) bool { var commandErr *systemctlError if !errors.As(err, &commandErr) || len(commandErr.args) != 3 { @@ -139,8 +141,9 @@ func systemctlUnitFileMissing(err error, unit string) bool { if commandErr.args[0] != "disable" || commandErr.args[1] != "--now" || commandErr.args[2] != unit { return false } - want := fmt.Sprintf("Failed to disable unit: Unit file %s does not exist.", unit) - return commandErr.output == want + oldDiagnostic := fmt.Sprintf("Failed to disable unit: Unit file %s does not exist.", unit) + modernDiagnostic := fmt.Sprintf("Failed to disable unit: Unit %s does not exist", unit) + return commandErr.output == oldDiagnostic || commandErr.output == modernDiagnostic } func systemctlStopUnitNotLoaded(err error, unit string) bool { diff --git a/internal/schedule/system_test.go b/internal/schedule/system_test.go index ecc0e3e..649a07b 100644 --- a/internal/schedule/system_test.go +++ b/internal/schedule/system_test.go @@ -65,29 +65,77 @@ func TestAtrmJobReportsFailureForStillQueuedJob(t *testing.T) { } func TestSystemctlMissingUnitErrorIsPreciselyClassified(t *testing.T) { - dir := t.TempDir() const unit = "linux-temp-admin-v2-revoke-xxvcc-a1.timer" - writeCommand(t, dir, "systemctl", "echo 'Failed to disable unit: Unit file "+unit+" does not exist.' >&2; exit 1") - t.Setenv("PATH", dir) - - err := (realSystem{}).Systemctl("disable", "--now", unit) - if !systemctlUnitFileMissing(err, unit) { - t.Fatalf("systemctlUnitFileMissing(%v) = false, want true", err) + tests := []struct { + name string + output string + }{ + { + name: "systemd 255 and earlier", + output: "Failed to disable unit: Unit file " + unit + " does not exist.", + }, + { + name: "systemd 256 and later", + output: "Failed to disable unit: Unit " + unit + " does not exist", + }, } - if systemctlUnitFileMissing(err, "different.timer") { - t.Fatal("a missing-unit error must only match its exact target") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "systemctl", "echo '"+tt.output+"' >&2; exit 1") + t.Setenv("PATH", dir) + + err := (realSystem{}).Systemctl("disable", "--now", unit) + if !systemctlUnitFileMissing(err, unit) { + t.Fatalf("systemctlUnitFileMissing(%v) = false, want true", err) + } + if systemctlUnitFileMissing(err, "different.timer") { + t.Fatal("a missing-unit error must only match its exact target") + } + }) } } func TestSystemctlOtherFailureIsNotClassifiedAsMissingUnit(t *testing.T) { - dir := t.TempDir() const unit = "linux-temp-admin-v2-revoke-xxvcc-a1.timer" - writeCommand(t, dir, "systemctl", "echo 'Failed to connect to bus: Permission denied' >&2; exit 1") - t.Setenv("PATH", dir) - - err := (realSystem{}).Systemctl("disable", "--now", unit) - if systemctlUnitFileMissing(err, unit) { - t.Fatalf("permission failure was misclassified as a missing unit: %v", err) + tests := []struct { + name string + args []string + output string + }{ + { + name: "permission failure", + args: []string{"disable", "--now", unit}, + output: "Failed to connect to bus: Permission denied", + }, + { + name: "unavailable D-Bus", + args: []string{"disable", "--now", unit}, + output: "Failed to connect to bus: No such file or directory", + }, + { + name: "unrecognized punctuation", + args: []string{"disable", "--now", unit}, + output: "Failed to disable unit: Unit " + unit + " does not exist.", + }, + { + name: "additional diagnostic", + args: []string{"disable", "--now", unit}, + output: "Failed to disable unit: Unit " + unit + " does not exist\nFailed to connect to bus: Permission denied", + }, + { + name: "different command", + args: []string{"enable", "--now", unit}, + output: "Failed to disable unit: Unit " + unit + " does not exist", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &systemctlError{args: tt.args, err: errors.New("exit status 1"), output: tt.output} + if systemctlUnitFileMissing(err, unit) { + t.Fatalf("non-exact failure was misclassified as a missing unit: %v", err) + } + }) } }