Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -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='<offline-recorded signer sha256>'
LTA_EXPECTED_TAG=v2.9.0
LTA_EXPECTED_TAG=v2.9.1
LTA_EXPECTED_COMMIT='<independently recorded 40-hex commit>'
LTA_EXPECTED_PREPARED_MANIFEST_SHA256='<independently recorded sha256>'
LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='<independently recorded 64-hex OLD public key>'
Expand All @@ -533,7 +533,7 @@ LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='<independently recorded 64-hex OLD public ke
LTA_EXPECTED_PREPARED_MANIFEST_SHA256="$LTA_EXPECTED_PREPARED_MANIFEST_SHA256" \
LTA_EXPECTED_RELEASE_SIGNER_PUBKEY="$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" \
/opt/lta-release-tools/offline-sign-release.sh \
/media/in/v2.9.0-prepared /media/out/v2.9.0-signed
/media/in/v2.9.1-prepared /media/out/v2.9.1-signed
```

The script copies the removable input into a size-bounded private local snapshot
Expand Down Expand Up @@ -584,7 +584,7 @@ printf '\n' >/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
```

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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" \
Expand Down
62 changes: 58 additions & 4 deletions internal/schedule/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, "|") {
Expand Down Expand Up @@ -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" {
Expand All @@ -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}
Expand All @@ -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
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions internal/schedule/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
80 changes: 64 additions & 16 deletions internal/schedule/system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}

Expand Down