feat: patch results on the wire - ApplyUpdatesCmd scope, reboot and deadline, PatchResult, inventory boot time

This commit is contained in:
2026-09-15 07:25:41 +00:00
parent bca39e605a
commit aa1c87037b
3 changed files with 97 additions and 3 deletions
+46
View File
@@ -0,0 +1,46 @@
package pb
import (
"encoding/json"
"testing"
)
// An empty ApplyUpdatesCmd must stay an empty object on the wire, so an old
// agent and a new server, or a new agent and an old server, agree that it
// means "install everything, no reboot, no deadline".
func TestApplyUpdatesCmdEmptyIsEmptyObject(t *testing.T) {
b, err := json.Marshal(ApplyUpdatesCmd{})
if err != nil {
t.Fatal(err)
}
if string(b) != "{}" {
t.Fatalf("got %s, want {}", b)
}
}
func TestPatchResultRoundTrip(t *testing.T) {
in := AgentMessage{PatchResult: &PatchResult{
CommandId: "c1", Status: PatchStatusOK, OutputTail: "done",
PendingAfter: 0, RebootRequired: true, Rebooting: true,
}}
b, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var out AgentMessage
if err := json.Unmarshal(b, &out); err != nil {
t.Fatal(err)
}
if out.PatchResult == nil || *out.PatchResult != *in.PatchResult {
t.Fatalf("round trip lost data: %+v", out.PatchResult)
}
}
func TestInventoryBootTimeOnWire(t *testing.T) {
b, _ := json.Marshal(InventoryReport{BootTimeUnix: 1757800000})
var m map[string]any
_ = json.Unmarshal(b, &m)
if m["boot_time_unix"] != float64(1757800000) {
t.Fatalf("boot_time_unix missing: %s", b)
}
}
+36 -1
View File
@@ -123,6 +123,7 @@ type InventoryReport struct {
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
RebootRequired bool `json:"reboot_required,omitempty"`
BootTimeUnix int64 `json:"boot_time_unix,omitempty"` // every report; proves a reboot happened
}
type InventoryReportResponse struct{}
@@ -161,7 +162,40 @@ type ReportChecksRequest struct {
}
type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
// ApplyUpdatesCmd installs pending OS updates. The zero value means what the
// command always meant: every pending update, no reboot, no deadline. That is
// what keeps old servers and new agents, and new servers and old agents,
// compatible - but only in that direction for Scope: an agent that predates
// these fields installs everything even when asked for security only, which
// is why the control plane gates on agent version before sending a scope.
type ApplyUpdatesCmd struct {
Scope string `json:"scope,omitempty"` // "" or PatchScopeAll | PatchScopeSecurity
RebootIfRequired bool `json:"reboot_if_required,omitempty"` // reboot only if the OS reports one is owed
DeadlineUnix int64 `json:"deadline_unix,omitempty"` // 0 = none; the agent caps the upgrade at 2h
}
const (
PatchScopeAll = "all"
PatchScopeSecurity = "security"
PatchStatusOK = "ok"
PatchStatusFailed = "failed"
PatchStatusUnsupported = "unsupported"
PatchStatusBusy = "busy"
)
// PatchResult answers an ApplyUpdatesCmd. Rebooting is sent immediately before
// the agent restarts the host, so the control plane knows to wait for a
// post-boot inventory report rather than a second result.
type PatchResult struct {
CommandId string `json:"command_id"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
OutputTail string `json:"output_tail,omitempty"` // at most 64KB, newest bytes
PendingAfter int32 `json:"pending_after"` // -1 when the post-apply check failed
RebootRequired bool `json:"reboot_required,omitempty"`
Rebooting bool `json:"rebooting,omitempty"`
}
type OpenProxyCmd struct {
ProxyId string `json:"proxy_id"`
@@ -240,6 +274,7 @@ type AgentMessage struct {
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
PatchResult *PatchResult `json:"patch_result,omitempty"`
}
type AgentReady struct{}