feat: answer ApplyUpdatesCmd with a PatchResult and reboot when the command asks
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/updates"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// minRebootLeeway is the least time that must remain in the window for the
|
||||
// agent to start a reboot. A reboot that lands after the window closes is the
|
||||
// outage the window existed to prevent.
|
||||
const minRebootLeeway = 5 * time.Minute
|
||||
|
||||
// shouldReboot is the whole reboot decision. The agent reboots a host only
|
||||
// when the command asked, the OS reports a reboot is owed, and the window
|
||||
// still has room for it. A command with no deadline is a manual run, which
|
||||
// never reboots.
|
||||
func shouldReboot(requested, owed bool, now, deadline time.Time) bool {
|
||||
if !requested || !owed || deadline.IsZero() {
|
||||
return false
|
||||
}
|
||||
return deadline.Sub(now) >= minRebootLeeway
|
||||
}
|
||||
|
||||
func handleApplyUpdates(send func(*pb.AgentMessage) error, cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
c := cmd.ApplyUpdates
|
||||
var deadline time.Time
|
||||
if c.DeadlineUnix > 0 {
|
||||
deadline = time.Unix(c.DeadlineUnix, 0)
|
||||
}
|
||||
log.Printf("applying OS updates (cmd=%s scope=%q reboot=%v)", cmd.CommandId, c.Scope, c.RebootIfRequired)
|
||||
|
||||
res, err := updates.Apply(updates.ApplyOptions{SecurityOnly: c.Scope == pb.PatchScopeSecurity, Deadline: deadline})
|
||||
pr := &pb.PatchResult{CommandId: cmd.CommandId, OutputTail: res.Output, PendingAfter: -1}
|
||||
switch {
|
||||
case errors.Is(err, updates.ErrBusy):
|
||||
pr.Status, pr.Message = pb.PatchStatusBusy, err.Error()
|
||||
case err != nil:
|
||||
pr.Status, pr.Message = pb.PatchStatusFailed, err.Error()
|
||||
case res.Unsupported:
|
||||
pr.Status, pr.Message = pb.PatchStatusUnsupported, res.Reason
|
||||
default:
|
||||
pr.Status = pb.PatchStatusOK
|
||||
}
|
||||
|
||||
// Refresh the pending list whether the run succeeded or not, so the counts
|
||||
// the operator sees are this host's real state. A busy refusal changed
|
||||
// nothing, and the run in progress will report for itself.
|
||||
if pr.Status != pb.PatchStatusBusy {
|
||||
pr.PendingAfter = int32(reportPendingUpdates(cfg))
|
||||
}
|
||||
pr.RebootRequired = updates.RebootRequired()
|
||||
pr.Rebooting = pr.Status == pb.PatchStatusOK && shouldReboot(c.RebootIfRequired, pr.RebootRequired, time.Now(), deadline)
|
||||
|
||||
if err := send(&pb.AgentMessage{ServerId: cfg.ServerID, AgentToken: cfg.AgentToken, PatchResult: pr}); err != nil {
|
||||
log.Printf("send patch result (cmd=%s): %v", cmd.CommandId, err)
|
||||
// A reboot nobody was told about looks like a crash. Without a
|
||||
// delivered result, do not reboot.
|
||||
return
|
||||
}
|
||||
log.Printf("patch result sent (cmd=%s status=%s pending_after=%d rebooting=%v)", cmd.CommandId, pr.Status, pr.PendingAfter, pr.Rebooting)
|
||||
if pr.Rebooting {
|
||||
if err := updates.ScheduleReboot(); err != nil {
|
||||
log.Printf("schedule reboot (cmd=%s): %v", cmd.CommandId, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportPendingUpdates re-checks pending updates and reports them, returning
|
||||
// the count, or -1 if either step failed.
|
||||
func reportPendingUpdates(cfg *config.Config) int {
|
||||
pkgs, err := updates.CheckAvailable()
|
||||
if err != nil {
|
||||
log.Printf("post-apply update check: %v", err)
|
||||
return -1
|
||||
}
|
||||
list := make([]pb.PackageUpdate, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
list[i] = pb.PackageUpdate{Name: p.Name, CurrentVersion: p.CurrentVersion, NewVersion: p.NewVersion}
|
||||
}
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("post-apply report dial: %v", err)
|
||||
return len(pkgs)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReportUpdates(cfg.ServerID, cfg.AgentToken, list); err != nil {
|
||||
log.Printf("post-apply ReportUpdates: %v", err)
|
||||
}
|
||||
return len(pkgs)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestShouldReboot(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 2, 30, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
requested, owed bool
|
||||
deadline time.Time
|
||||
want bool
|
||||
}{
|
||||
{"asked, owed, plenty of time", true, true, now.Add(time.Hour), true},
|
||||
{"not asked", false, true, now.Add(time.Hour), false},
|
||||
{"nothing owed", true, false, now.Add(time.Hour), false},
|
||||
{"exactly five minutes left", true, true, now.Add(5 * time.Minute), true},
|
||||
{"under five minutes left", true, true, now.Add(4*time.Minute + 59*time.Second), false},
|
||||
{"no deadline (manual run)", true, true, time.Time{}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := shouldReboot(c.requested, c.owed, now, c.deadline); got != c.want {
|
||||
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-17
@@ -341,7 +341,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
go handleUpdateAgent(cmd)
|
||||
}
|
||||
if cmd.ApplyUpdates != nil {
|
||||
go handleApplyUpdates(cfg, cmd)
|
||||
go handleApplyUpdates(send, cfg, cmd)
|
||||
}
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
@@ -480,22 +480,6 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId)
|
||||
if _, err := updates.Apply(updates.ApplyOptions{}); err != nil {
|
||||
log.Printf("OS upgrade failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
|
||||
}
|
||||
|
||||
func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
id := cmd.CleanupWorkspace.WorkspaceId
|
||||
dir := agentexec.WorkspacePath(id)
|
||||
|
||||
Reference in New Issue
Block a user