fix(patch): gate phases on the window deadline, queue undelivered results, retry startup inventory
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:
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user