fix(patch): gate phases on the window deadline, queue undelivered results, retry startup inventory
Agent Release / build (push) Successful in 3m40s
Agent Release / msi (push) Successful in 4m54s

The window deadline no longer kills a running package manager: it only gates
the start of each phase, and a started upgrade runs under a 2 hour backstop
that sends SIGTERM on Linux. A PatchResult whose send fails is queued and
flushed on the next command stream, retaking the reboot decision. deb822
folded Suites continuation lines are filtered with the field. The startup
static inventory report is retried until it succeeds.
This commit is contained in:
2026-09-15 13:43:03 +00:00
parent ecd703d502
commit b5b9775d2b
12 changed files with 404 additions and 46 deletions
+27 -7
View File
@@ -68,14 +68,28 @@ func filterOneLine(content string) string {
return b.String()
}
// deb822Fields groups a paragraph's lines into fields. A line that starts
// with a space or tab continues the field above it (a folded or multi-line
// value, such as a long Suites list or an inline Signed-By key block).
func deb822Fields(para string) [][]string {
var fields [][]string
for _, l := range strings.Split(strings.Trim(para, "\n"), "\n") {
if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(fields) > 0 {
fields[len(fields)-1] = append(fields[len(fields)-1], l)
continue
}
fields = append(fields, []string{l})
}
return fields
}
func filterDeb822(content string) string {
var b strings.Builder
for _, para := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n\n") {
lines := strings.Split(strings.Trim(para, "\n"), "\n")
var out []string
isDeb, enabled, kept := false, true, false
for _, l := range lines {
key, val, found := strings.Cut(l, ":")
for _, field := range deb822Fields(para) {
key, val, found := strings.Cut(field[0], ":")
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(val)
switch {
@@ -88,19 +102,25 @@ func filterDeb822(content string) string {
case found && k == "enabled":
enabled = strings.ToLower(v) != "no"
case found && k == "suites":
// The value runs across every continuation line. The filtered
// result is written back as one line and the continuation
// lines are dropped with the rest of the original field.
all := strings.Fields(strings.Join(append([]string{v}, field[1:]...), " "))
var sec []string
for _, s := range strings.Fields(v) {
for _, s := range all {
if isSecuritySuite(s) {
sec = append(sec, s)
}
}
if len(sec) == 0 {
continue // drop the line; the paragraph is dropped below
continue // drop the field; the paragraph is dropped below
}
kept = true
l = "Suites: " + strings.Join(sec, " ")
out = append(out, "Suites: "+strings.Join(sec, " "))
continue
}
out = append(out, l)
// Every other field, continuation lines included, stays verbatim.
out = append(out, field...)
}
if isDeb && enabled && kept {
b.WriteString(strings.Join(out, "\n"))
+35
View File
@@ -78,3 +78,38 @@ func TestSecuritySourcesNone(t *testing.T) {
t.Fatal("ok = true with no security suite, want false")
}
}
// A folded Suites field continues on lines that start with whitespace. Those
// lines belong to Suites and must be filtered with it, not copied verbatim.
func TestSecuritySourcesDeb822FoldedSuites(t *testing.T) {
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble\n noble-security\nComponents: main\n"}
_, d822, ok := securitySources(files)
if !ok || !strings.Contains(d822, "Suites: noble-security\n") {
t.Fatalf("got ok=%v\n%s", ok, d822)
}
if strings.Contains(d822, "\n noble") {
t.Fatalf("the folded continuation line must be dropped:\n%s", d822)
}
}
// A folded Suites field with no security suite on any line drops the paragraph.
func TestSecuritySourcesDeb822FoldedSuitesNoSecurity(t *testing.T) {
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble\n\tnoble-updates\nComponents: main\n"}
if _, _, ok := securitySources(files); ok {
t.Fatal("no security suite across the folded lines, want ok=false")
}
}
// An inline Signed-By key block is a multi-line field of its own. Its
// continuation lines stay verbatim, including the "." blank-line marker.
func TestSecuritySourcesDeb822InlineSignedBy(t *testing.T) {
key := "Signed-By: -----BEGIN PGP PUBLIC KEY BLOCK-----\n .\n mQINBGRkZXYBEAC\n -----END PGP PUBLIC KEY BLOCK-----\n"
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble noble-security\nComponents: main\n" + key}
_, d822, ok := securitySources(files)
if !ok || !strings.Contains(d822, "Suites: noble-security\n") {
t.Fatalf("got ok=%v\n%s", ok, d822)
}
if !strings.Contains(d822, key) {
t.Fatalf("inline key block must be kept verbatim:\n%s", d822)
}
}
+23
View File
@@ -0,0 +1,23 @@
package updates
import (
"fmt"
"time"
)
// canStart is the whole "may this phase begin" decision. The maintenance
// window deadline only gates the start of a phase: a package manager that is
// already running is never interrupted by it, because killing apt, dnf or
// Windows Update partway through a transaction is worse than letting it
// finish late. A zero deadline is a manual run, which always may start.
func canStart(now, deadline time.Time) bool {
return deadline.IsZero() || now.Before(deadline)
}
// startGate returns the error reported when a phase is refused.
func startGate(deadline time.Time, phase string) error {
if canStart(time.Now(), deadline) {
return nil
}
return fmt.Errorf("the maintenance window ended before %s could start", phase)
}
+36
View File
@@ -0,0 +1,36 @@
package updates
import (
"strings"
"testing"
"time"
)
func TestCanStart(t *testing.T) {
now := time.Date(2026, 9, 20, 2, 30, 0, 0, time.UTC)
cases := []struct {
name string
deadline time.Time
want bool
}{
{"no deadline (manual run)", time.Time{}, true},
{"deadline ahead", now.Add(time.Second), true},
{"deadline exactly now", now, false},
{"deadline passed", now.Add(-time.Minute), false},
}
for _, c := range cases {
if got := canStart(now, c.deadline); got != c.want {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
func TestStartGate(t *testing.T) {
if err := startGate(time.Time{}, "the upgrade"); err != nil {
t.Fatalf("zero deadline: %v", err)
}
err := startGate(time.Now().Add(-time.Minute), "the upgrade")
if err == nil || !strings.Contains(err.Error(), "the maintenance window ended before the upgrade could start") {
t.Fatalf("passed deadline: %v", err)
}
}
+9 -7
View File
@@ -26,8 +26,11 @@ type ApplyOptions struct {
// metadata reports Unsupported and installs nothing: it never falls back
// to installing everything.
SecurityOnly bool
// Deadline is when the upgrade must be finished, normally the end of the
// maintenance window. Zero means defaultApplyCap from now.
// Deadline is the end of the maintenance window. It only gates the start
// of each phase (index refresh, upgrade, Windows install): a phase that
// has not started by then is not started, and one already running is
// allowed to finish, bounded by defaultApplyCap from its own start. Zero
// means a manual run with no window.
Deadline time.Time
}
@@ -42,6 +45,9 @@ type Result struct {
// ErrBusy means another Apply is already running on this host.
var ErrBusy = errors.New("an update run is already in progress on this host")
// defaultApplyCap is the backstop for one started upgrade command, counted
// from that command's own start. It exists for a package manager that hangs,
// not to enforce the window.
const defaultApplyCap = 2 * time.Hour
var applyMu sync.Mutex
@@ -53,11 +59,7 @@ func Apply(opts ApplyOptions) (Result, error) {
return Result{}, ErrBusy
}
defer applyMu.Unlock()
deadline := opts.Deadline
if deadline.IsZero() {
deadline = time.Now().Add(defaultApplyCap)
}
return apply(opts.SecurityOnly, deadline)
return apply(opts.SecurityOnly, opts.Deadline)
}
// ScheduleReboot restarts the host after a short grace period, so a result
+43 -26
View File
@@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
@@ -26,8 +27,6 @@ func detectPM() string {
return ""
}
func checkAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
@@ -47,30 +46,53 @@ func checkAvailable() ([]PackageUpdate, error) {
}
}
// aptRefreshTimeout bounds the index refresh alone. The upgrade itself runs
// until the caller's deadline: one shared five-minute limit used to kill large
// under defaultApplyCap: one shared five-minute limit used to kill large
// upgrades partway through.
const aptRefreshTimeout = 5 * time.Minute
func run(ctx context.Context, out io.Writer, env []string, name string, args ...string) error {
fmt.Fprintf(out, "$ %s %s\n", name, strings.Join(args, " "))
// termGrace is how long a command has to exit after SIGTERM before it is
// killed. Package managers finish or roll back their current step on TERM.
const termGrace = 5 * time.Minute
// phase is one package manager command, started only if the window deadline
// has not passed and then bounded by its own limit from its own start.
type phase struct {
deadline time.Time
out io.Writer
env []string
}
func (p phase) run(name string, limit time.Duration, args ...string) error {
return p.runNamed("the upgrade", name, limit, args...)
}
func (p phase) runNamed(label, name string, limit time.Duration, args ...string) error {
if err := startGate(p.deadline, label); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()
fmt.Fprintf(p.out, "$ %s %s\n", name, strings.Join(args, " "))
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout, cmd.Stderr = out, out
cmd.Env = append(os.Environ(), env...)
cmd.Stdout, cmd.Stderr = p.out, p.out
cmd.Env = append(os.Environ(), p.env...)
// On the backstop, ask the package manager to stop rather than killing it
// outright, and give it time to leave its database consistent.
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = termGrace
return cmd.Run()
}
func apply(securityOnly bool, deadline time.Time) (Result, error) {
out := newTailBuffer(outputTailMax)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
ph := phase{deadline: deadline, out: out}
var err error
switch pm := detectPM(); pm {
case "apt":
var res Result
res, err = applyApt(ctx, out, securityOnly)
res, err = applyApt(deadline, out, securityOnly)
if res.Unsupported {
res.Output = out.String()
return res, nil
@@ -80,12 +102,12 @@ func apply(securityOnly bool, deadline time.Time) (Result, error) {
if securityOnly {
args = append(args, "--security")
}
err = run(ctx, out, nil, pm, args...)
err = ph.run(pm, defaultApplyCap, args...)
case "zypper":
if securityOnly {
err = run(ctx, out, nil, "zypper", "--non-interactive", "patch", "--category", "security")
err = ph.run("zypper", defaultApplyCap, "--non-interactive", "patch", "--category", "security")
} else {
err = run(ctx, out, nil, "zypper", "--non-interactive", "update")
err = ph.run("zypper", defaultApplyCap, "--non-interactive", "update")
}
// 102 and 103 mean "installed, and a reboot or restart is now needed".
// That is success; RebootRequired reports the rest.
@@ -97,25 +119,22 @@ func apply(securityOnly bool, deadline time.Time) (Result, error) {
if securityOnly {
return Result{Unsupported: true, Reason: "pacman publishes no security metadata"}, nil
}
err = run(ctx, out, nil, "pacman", "-Syu", "--noconfirm")
err = ph.run("pacman", defaultApplyCap, "-Syu", "--noconfirm")
case "apk":
if securityOnly {
return Result{Unsupported: true, Reason: "apk publishes no security metadata"}, nil
}
if err = run(ctx, out, nil, "apk", "update"); err == nil {
err = run(ctx, out, nil, "apk", "upgrade")
if err = ph.runNamed("the apk index refresh", "apk", aptRefreshTimeout, "update"); err == nil {
err = ph.run("apk", defaultApplyCap, "upgrade")
}
default:
return Result{Unsupported: true, Reason: "no supported package manager found"}, nil
}
if ctx.Err() == context.DeadlineExceeded {
err = fmt.Errorf("stopped at the end of the maintenance window: %w", err)
}
return Result{Output: out.String()}, err
}
func applyApt(ctx context.Context, out io.Writer, securityOnly bool) (Result, error) {
env := []string{"DEBIAN_FRONTEND=noninteractive"}
func applyApt(deadline time.Time, out io.Writer, securityOnly bool) (Result, error) {
ph := phase{deadline: deadline, out: out, env: []string{"DEBIAN_FRONTEND=noninteractive"}}
var srcOpts []string
if securityOnly {
dir, res, err := writeSecuritySourceParts()
@@ -131,15 +150,13 @@ func applyApt(ctx context.Context, out io.Writer, securityOnly bool) (Result, er
"-o", "APT::Get::List-Cleanup=0",
}
}
rctx, rcancel := context.WithTimeout(ctx, aptRefreshTimeout)
defer rcancel()
if err := run(rctx, out, env, "apt-get", append([]string{"update", "-q"}, srcOpts...)...); err != nil {
if err := ph.runNamed("the apt index refresh", "apt-get", aptRefreshTimeout, append([]string{"update", "-q"}, srcOpts...)...); err != nil {
return Result{}, fmt.Errorf("apt-get update: %w", err)
}
args := []string{"upgrade", "-y", "-q",
"-o", "Dpkg::Options::=--force-confdef",
"-o", "Dpkg::Options::=--force-confold"}
return Result{}, run(ctx, out, env, "apt-get", append(args, srcOpts...)...)
return Result{}, ph.runNamed("the upgrade", "apt-get", defaultApplyCap, append(args, srcOpts...)...)
}
// writeSecuritySourceParts writes the security-only sources to a temporary
+7 -1
View File
@@ -62,7 +62,13 @@ func checkAvailable() ([]PackageUpdate, error) {
}
func apply(securityOnly bool, deadline time.Time) (Result, error) {
ctx, cancel := context.WithDeadline(context.Background(), deadline)
if err := startGate(deadline, "the Windows Update install"); err != nil {
return Result{}, err
}
// The window deadline only gates the start. Once running, the install is
// bounded by defaultApplyCap from its own start, with the default kill:
// Windows has no SIGTERM to offer PowerShell.
ctx, cancel := context.WithTimeout(context.Background(), defaultApplyCap)
defer cancel()
out, err := winexec.Run(ctx, applyScriptFor(securityOnly))