Old kernels removed by the box (R-1368, wrapper mode kernel-cleanup); security profiles refreshed after a kernel change (R-1388, mode profile-refresh); Go 1.27.4 (R-1391)
gates / gates (push) Successful in 40s

This commit is contained in:
admin committed 2026-11-14 12:11:06 +01:00
1 parent 91a2924ad0
commit cc63dd5204
11 files changed
+1542 -10

No files matched your search

+128
View File
@@ -21,6 +21,8 @@ import (
"fmt"
"log/slog"
"regexp"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
@@ -444,3 +446,129 @@ func (l *Leg) StageKernelSigned(ctx context.Context, vmid int, p KernelStepParam
}
return l.finish(ctx, lg, rep)
}
// ---- the clean-up of old kernels (R-1368, `09` §3 decision 286, `11` §5.11.1) ----
//
// The rule, its refusals and "may this box do it" live in the root wrapper (mode kernel-cleanup); the plan names NO
// package. This file decides only WHEN: in the night leg, after a healthy host step, never in a night with a kernel
// step. The wrapper adds its own 20-hour window after a step.
// KernelCleanupResult is the wrapper's kernel_cleanup object.
type KernelCleanupResult struct {
Outcome string `json:"outcome"` // removed | nothing | not_run | dry_run | failed
DryRun bool `json:"dry_run"`
Would string `json:"would"` // dry run only: remove | nothing | refuse | not_run
NotRun []struct {
Code string `json:"code"`
Reason string `json:"reason"`
} `json:"not_run"`
Refusals []struct {
Code string `json:"code"`
Reason string `json:"reason"`
} `json:"refusals"`
Kept map[string][]string `json:"kept"`
Removal []string `json:"removal"`
Removed []string `json:"removed"`
BytesGained *int64 `json:"bytes_gained"`
Leftovers []string `json:"leftovers"`
}
// kernelCleanupSkip says why tonight's pass must not call the clean-up ("" = call it).
func kernelCleanupSkip(appliance bool, trigger string, host, pve, kernel Report, blk hub.WireOSUpdate, okStep func(Report) bool) string {
switch {
case trigger != "night":
return "it runs only in the night leg (never a debug pass)"
case !appliance:
return "not an appliance install (a BYO host belongs to its owner)"
case !blk.Enabled:
return "OS updates are switched off for this box"
case host.Layer == "" || !okStep(host):
return "no healthy host step this pass"
case pve.Layer != "" && !okStep(pve):
return "the Proxmox step did not end healthy"
case kernel.Layer != "":
return "a kernel step ran in this night (" + kernel.Outcome + ") — never in the same night"
}
if due, _ := kernelDue(blk, trigger); due {
return "a kernel step is due tonight — never in the same night"
}
return ""
}
// runKernelCleanup calls the wrapper once. The hub hears about it only when there is something to tell: kernels were
// removed (outcome "cleaned"), the wrapper refused, or the purge failed. "Nothing to remove" and "not run" are log
// lines — one report per box per night would bury the kernel step's own line on the hub's System page.
func (l *Leg) runKernelCleanup(ctx context.Context, runID string, vmid int, trigger string, blk hub.WireOSUpdate) Report {
lg := l.log().With("run", runID, "layer", LayerKernel, "mode", "kernel-cleanup", "vmid", vmid, "trigger", trigger)
start := l.now()
wr, err := l.call(ctx, runID, kernelPlan("kernel-cleanup", vmid, map[string]any{"release_id": "kernel-cleanup",
"run_id": runID, "trigger": trigger, "ring": blk.Ring}))
// NO kernel view and no kernel version in this report: the hub reads a kernel-layer report's `kernel.to` /
// `release_id` as "the kernel this STEP was for" (kernelOf), and a clean-up is not a step for any kernel.
rep := Report{RunID: runID, Layer: LayerKernel, Trigger: trigger, Ring: blk.Ring, VMID: vmid, Mode: "kernel-cleanup",
ReleaseID: "kernel-cleanup", PassSeconds: wr.PassSeconds}
var res KernelCleanupResult
_ = json.Unmarshal(wr.KernelCleanup, &res)
took := l.now().Sub(start).Round(time.Second).String()
switch {
case err != nil:
lg.Warn("osupdate: kernel clean-up — the wrapper gave no answer; not retried tonight", "err", err, "took", took)
rep.Outcome, rep.HealthReason = "failed", "kernel clean-up: "+err.Error()
return l.finish(ctx, lg, rep)
case wr.refused():
lg.Warn("osupdate: kernel clean-up REFUSED — nothing was removed; not retried tonight", "refused", string(wr.Refused), "took", took)
rep.Outcome, rep.Refused, rep.HealthReason = "refused", wr.Refused, "the clean-up of old kernels was refused; nothing was removed"
return l.finish(ctx, lg, rep)
case wr.failed():
lg.Error("osupdate: kernel clean-up FAILED — look at the box; not retried tonight", "failed", string(wr.Failed), "took", took)
rep.Outcome, rep.Refused, rep.HealthReason = "failed", wr.Failed, "the clean-up of old kernels failed"
return l.finish(ctx, lg, rep)
}
switch res.Outcome {
case "removed":
gained := "unknown"
if res.BytesGained != nil {
gained = fmt.Sprintf("%.1f GiB", float64(*res.BytesGained)/(1<<30))
}
rep.Outcome, rep.Healthy = "cleaned", true
rep.HealthReason = truncate(fmt.Sprintf("removed %d old kernel package(s): %s; kept %s; %s gained; %d left-over path(s)",
len(res.Removed), strings.Join(res.Removed, ", "), strings.Join(sortedKeys(res.Kept), ", "), gained, len(res.Leftovers)), 600)
lg.Info("osupdate: kernel clean-up — old kernels removed", "removed", len(res.Removed), "names", strings.Join(res.Removed, ","),
"kept", strings.Join(sortedKeys(res.Kept), ","), "bytes_gained", res.BytesGained, "leftovers", len(res.Leftovers), "took", took)
return l.finish(ctx, lg, rep)
case "nothing":
lg.Info("osupdate: kernel clean-up — nothing to remove", "kept", strings.Join(sortedKeys(res.Kept), ","), "took", took)
case "not_run":
code, why := "", ""
if len(res.NotRun) > 0 {
code, why = res.NotRun[0].Code, res.NotRun[0].Reason
}
lg.Info("osupdate: kernel clean-up — not run on this box tonight", "code", code, "reason", why, "took", took)
default:
lg.Warn("osupdate: kernel clean-up — the wrapper answered with no outcome this agent knows (an older wrapper?)", "outcome", res.Outcome, "took", took)
}
return Report{}
}
// KernelCleanupDryRun asks the wrapper what the clean-up WOULD do, and changes nothing (the debug tool: `--selftest
// kernel-cleanup-dry-run`). The raw kernel_cleanup object, or the refusal.
func (l *Leg) KernelCleanupDryRun(ctx context.Context, vmid int) (json.RawMessage, error) {
wr, err := l.call(ctx, "kcdry"+l.now().UTC().Format("150405"), kernelPlan("kernel-cleanup", vmid,
map[string]any{"release_id": "kernel-cleanup", "dry_run": true}))
if err != nil {
return nil, err
}
if wr.refused() {
return nil, fmt.Errorf("kernel-cleanup dry run refused: %s", wr.Refused)
}
return wr.KernelCleanup, nil
}
func sortedKeys(m map[string][]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+185 -1
View File
@@ -28,13 +28,23 @@ func tonight(ring int) *hub.WireOSUpdate {
func kernelCalls(w *fakeWrapper) []string {
var m []string
for _, p := range w.plans {
if p["layer"] == LayerKernel {
if p["layer"] == LayerKernel && p["mode"] != "kernel-cleanup" { // the kernel STEP's calls (the clean-up: cleanupCalls)
m = append(m, p["mode"].(string))
}
}
return m
}
func cleanupCalls(w *fakeWrapper) []map[string]any {
var m []map[string]any
for _, p := range w.plans {
if p["layer"] == LayerKernel && p["mode"] == "kernel-cleanup" {
m = append(m, p)
}
}
return m
}
// Ring 0, a told night: after the healthy host step the leg stages the pending kernel (select pending-kernel, the
// kernel the household was told about), tells the hub "staged", THEN reboots — the kernel step ends the night.
func TestKernel_Ring0ToldNightStagesThenReboots(t *testing.T) {
@@ -326,3 +336,177 @@ func TestKernelSet(t *testing.T) {
t.Fatal("a non-kernel string must give no set")
}
}
// ---- the clean-up of old kernels (R-1368, `09` §3 decision 286, `11` §5.11.1) ----
func cleanupRep(js string) map[string][]WrapperReport {
return map[string][]WrapperReport{"kernel-cleanup": {{KernelCleanup: json.RawMessage(js)}}}
}
// The night: the clean-up is the LAST call, after the host step, exactly once, and its plan names NO package.
// COMPANION RED-PROOF (observed): make kernelCleanupSkip return "" before its checks → the BYO / debug / unhealthy
// sub-cases of TestKernelCleanup_WhenItMayNotRun fail; put the call before runFast → this test's order check fails.
func TestKernelCleanup_NightOrderOnceAndNoNames(t *testing.T) {
w := &fakeWrapper{t: t, kernelRep: cleanupRep(`{"outcome":"removed","removed":["proxmox-kernel-7.0.14-20-pve-signed"],` +
`"kept":{"7.0.14-22-pve":["running"],"7.0.2-6-pve":["from"]},"bytes_gained":1073741824,"leftovers":[]}`)}
l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true})
p := l.Run(context.Background(), 9201, "night")
cc := cleanupCalls(w)
if len(cc) != 1 {
t.Fatalf("clean-up calls = %d, want exactly one a night", len(cc))
}
if last := w.plans[len(w.plans)-1]; last["mode"] != "kernel-cleanup" {
t.Fatalf("the clean-up must be the night's last call (after the host step), last = %v", last)
}
hostAt, cleanAt := -1, -1
for i, x := range w.plans {
if x["layer"] == LayerHost {
hostAt = i
}
if x["mode"] == "kernel-cleanup" {
cleanAt = i
}
}
if hostAt < 0 || cleanAt < hostAt {
t.Fatalf("the clean-up ran before the host step: host %d clean-up %d", hostAt, cleanAt)
}
plan := cc[0]
if pk, _ := json.Marshal(plan["packages"]); string(pk) != "[]" {
t.Fatalf("the clean-up plan names packages: %s", pk)
}
for _, k := range []string{"select", "signed", "allow_new", "expect_kver", "dry_run"} {
if _, ok := plan[k]; ok {
t.Fatalf("the clean-up plan carries %q: %v", k, plan)
}
}
if plan["lane"] != "slow" || plan["layer"] != LayerKernel {
t.Fatalf("plan = %v", plan)
}
// the hub hears "cleaned" with what was removed, kept and gained — and NO kernel view / version (the hub would read
// it as a kernel step for that kernel)
rep := p.KernelCleanup
if rep.Outcome != "cleaned" || !rep.Healthy || rep.Mode != "kernel-cleanup" || rep.Layer != LayerKernel {
t.Fatalf("report = %+v", rep)
}
for _, want := range []string{"removed 1 old kernel package(s): proxmox-kernel-7.0.14-20-pve-signed", "kept 7.0.14-22-pve, 7.0.2-6-pve", "1.0 GiB gained"} {
if !strings.Contains(rep.HealthReason, want) {
t.Fatalf("report reason %q lacks %q", rep.HealthReason, want)
}
}
lastRep := h.reports[len(h.reports)-1]
if lastRep.Mode != "kernel-cleanup" || lastRep.Outcome != "cleaned" {
t.Fatalf("the hub did not hear the clean-up: %+v", lastRep)
}
if len(lastRep.Kernel) != 0 || kverRE.MatchString(lastRep.ReleaseID) {
t.Fatalf("the clean-up's report names a kernel (the hub reads that as a kernel step): %+v", lastRep)
}
// the same night again: the pass is skipped as a whole — never a second clean-up
l.Run(context.Background(), 9201, "night")
if len(cleanupCalls(w)) != 1 {
t.Fatalf("a second clean-up in the same night: %d calls", len(cleanupCalls(w)))
}
}
// "Nothing to remove" and "not run" are log lines, never a report (one a night would bury the kernel step's own line).
func TestKernelCleanup_NothingAndNotRunSendNoReport(t *testing.T) {
for name, js := range map[string]string{
"nothing": `{"outcome":"nothing","removal":[],"kept":{"7.0.14-22-pve":["running"]}}`,
"not_run": `{"outcome":"not_run","not_run":[{"code":"no-ring0-mark","reason":"ring 0 only in this release"}]}`,
} {
w := &fakeWrapper{t: t, kernelRep: cleanupRep(js)}
l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true})
p := l.Run(context.Background(), 9201, "night")
if len(cleanupCalls(w)) != 1 {
t.Fatalf("%s: the wrapper was not asked", name)
}
if p.KernelCleanup.Layer != "" {
t.Fatalf("%s: a report was made: %+v", name, p.KernelCleanup)
}
for _, r := range h.reports {
if r.Mode == "kernel-cleanup" {
t.Fatalf("%s: the hub got a clean-up report: %+v", name, r)
}
}
}
}
// A refusal and a failure reach the hub (the operator), once, and are not retried.
func TestKernelCleanup_RefusedAndFailedReachTheHub(t *testing.T) {
for name, wr := range map[string]WrapperReport{
"refused": {Refused: json.RawMessage(`{"code":"P3","reason":"the pinned default and grub.cfg disagree"}`)},
"failed": {Failed: json.RawMessage(`{"rc":3,"step":"verify","reason":"the default in grub.cfg moved"}`), KernelCleanup: json.RawMessage(`{"outcome":"failed"}`)},
} {
w := &fakeWrapper{t: t, kernelRep: map[string][]WrapperReport{"kernel-cleanup": {wr}}}
l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true})
p := l.Run(context.Background(), 9201, "night")
if p.KernelCleanup.Outcome != name || p.KernelCleanup.Healthy || len(p.KernelCleanup.Refused) == 0 {
t.Fatalf("%s: report = %+v", name, p.KernelCleanup)
}
if last := h.reports[len(h.reports)-1]; last.Mode != "kernel-cleanup" || last.Outcome != name {
t.Fatalf("%s: the hub did not hear it: %+v", name, last)
}
if len(cleanupCalls(w)) != 1 {
t.Fatalf("%s: retried the same night (%d calls)", name, len(cleanupCalls(w)))
}
}
}
// When it may NOT run: never with a kernel step in the night (due, staged, or reported), not in a debug pass, not on a
// BYO host, not with OS updates off, not after an unhealthy host step.
func TestKernelCleanup_WhenItMayNotRun(t *testing.T) {
type tc struct {
blk *hub.WireOSUpdate
trigger string
prep func(*fakeWrapper, *Leg)
}
bad := &Health{DockerOK: false}
cases := map[string]tc{
"a kernel step is due tonight (ring 0)": {tonight(0), "night", func(w *fakeWrapper, _ *Leg) {
w.kernelRep = map[string][]WrapperReport{"kernel-status": {{Kernel: kview("none")}},
"apply": {{Upgraded: []Package{{Name: "proxmox-kernel-7.0", Version: "7.0.14-22"}}, Authority: "ring0", Kernel: kview("staged")}},
"kernel-reboot": {{Kernel: kview("oneshot")}}}
}},
"a kernel step is due tonight (ring 1, nothing staged)": {tonight(1), "night", func(w *fakeWrapper, _ *Leg) {
w.kernelRep = map[string][]WrapperReport{"kernel-status": {{Kernel: kview("none")}}}
}},
"a debug pass": {&hub.WireOSUpdate{Ring: 0, Enabled: true}, "debug", nil},
"a BYO host": {&hub.WireOSUpdate{Ring: 0, Enabled: true}, "night", func(_ *fakeWrapper, l *Leg) { l.Appliance = false }},
"updates off": {&hub.WireOSUpdate{Ring: 0, Enabled: false}, "night", nil},
"host unhealthy": {&hub.WireOSUpdate{Ring: 0, Enabled: true, HostRelease: &hub.WireOSRelease{ID: "os-host-1", Packages: []hub.WireOSPackage{{Name: "openssl", Version: "u3", Origin: "Debian"}}}}, "night", func(w *fakeWrapper, _ *Leg) {
w.applyRep[LayerHost] = WrapperReport{Upgraded: []Package{{Name: "openssl", Version: "u3"}}, HealthAfter: &Health{HostServices: map[string]string{"pveproxy": "failed"}, GuestRunning: yes(), Guest: bad}}
w.healthSeq[LayerHost] = []*Health{{HostServices: map[string]string{"pveproxy": "failed"}, GuestRunning: yes(), Guest: bad}}
}},
}
for name, c := range cases {
w := &fakeWrapper{t: t}
l, _ := newLeg(t, w, c.blk)
if c.prep != nil {
c.prep(w, l)
}
l.HealthWait = time.Second
l.Run(context.Background(), 9201, c.trigger)
if n := len(cleanupCalls(w)); n != 0 {
t.Fatalf("%s: the clean-up ran (%d call(s)); calls = %s", name, n, calls(w))
}
}
}
// The dry run is the same mode with dry_run: true — and it sends nothing to the hub.
func TestKernelCleanup_DryRun(t *testing.T) {
w := &fakeWrapper{t: t, kernelRep: cleanupRep(`{"outcome":"dry_run","would":"remove","removal":["proxmox-kernel-7.0.14-20-pve-signed"]}`)}
l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true})
raw, err := l.KernelCleanupDryRun(context.Background(), 9201)
if err != nil || !strings.Contains(string(raw), `"would":"remove"`) {
t.Fatalf("dry run = %s, %v", raw, err)
}
cc := cleanupCalls(w)
if len(cc) != 1 || cc[0]["dry_run"] != true {
t.Fatalf("the dry run's plan = %v", cc)
}
if pk, _ := json.Marshal(cc[0]["packages"]); string(pk) != "[]" {
t.Fatalf("the dry run's plan names packages: %s", pk)
}
if len(h.reports) != 0 {
t.Fatalf("a dry run reported to the hub: %+v", h.reports)
}
}
+17 -1
View File
@@ -134,7 +134,12 @@ type WrapperReport struct {
// found after a boot; OutcomeHint "nothing" when no kernel was pending.
Kernel json.RawMessage `json:"kernel"`
KernelEvent string `json:"kernel_event"`
OutcomeHint string `json:"outcome_hint"`
// KernelCleanup (R-1368, `11` §5.11.1): mode kernel-cleanup's result {outcome, dry_run, kept, removal, removed,
// bytes_gained, not_run, refusals, leftovers}.
KernelCleanup json.RawMessage `json:"kernel_cleanup"`
// ProfileRefresh (R-1388, `11` §5.12): mode profile-refresh's result {outcome, kernel, rewritten, restarted, seconds}.
ProfileRefresh json.RawMessage `json:"profile_refresh"`
OutcomeHint string `json:"outcome_hint"`
// R-868 (v0.144.0): the agent's ids, echoed from the plan, so a report kept on disk can be sent without the
// agent process that started the pass. ReleaseID / VMID were always in the report.
RunID string `json:"run_id"`
@@ -531,6 +536,8 @@ func (l *Leg) call(ctx context.Context, runID string, plan map[string]any) (Wrap
// Pass is one leg's reports; an empty Layer means the step did not run.
type Pass struct {
Guest, Host, Docker, PVE, Kernel Report
// KernelCleanup (R-1368): the clean-up of old kernels. Layer "" when it did not run or had nothing to tell the hub.
KernelCleanup Report
}
// Run is one pass: the guest layer, then (on an appliance, after a good guest step) the host layer, then (ring 0
@@ -582,6 +589,15 @@ func (l *Leg) Run(ctx context.Context, vmid int, trigger string) Pass {
default:
p.Kernel = l.runKernel(ctx, g.RunID, vmid, trigger, blk)
}
// R-1368 (`09` §3 decision 286, `11` §5.11.1): the clean-up of old kernels — its own step, at most once a night (this
// pass is the night's only one), after the host step ended healthy, NEVER in a night with a kernel step. Whether
// this box may do it at all is the root wrapper's answer (its root-owned ring-0 mark), never the agent's.
// Pinned by TestKernelCleanup_*.
if why := kernelCleanupSkip(l.Appliance, trigger, h, p.PVE, p.Kernel, blk, okStep); why != "" {
lg.Info("osupdate: kernel clean-up skipped — " + why)
} else {
p.KernelCleanup = l.runKernelCleanup(ctx, g.RunID, vmid, trigger, blk)
}
return p
}
+10 -3
View File
@@ -26,6 +26,7 @@ type fakeWrapper struct {
keep bool // R-868: like the real wrapper, keep an apply report beside the plan
pveGateHeld bool
kernelRep map[string][]WrapperReport // R-836: per kernel-layer mode, successive answers (the last one repeats)
profileRep *WrapperReport // R-1388: the answer to mode profile-refresh
}
func yes() *bool { b := true; return &b }
@@ -71,6 +72,10 @@ func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byt
}
var rep WrapperReport
switch plan["mode"] {
case "profile-refresh":
if f.profileRep != nil {
rep = *f.profileRep
}
case "inventory":
rep = WrapperReport{Mode: "inventory", Pending: f.pending, HealthBefore: ok, HealthAfter: ok,
Installed: []Package{{Name: "libc6", Version: "u3", Origin: "Debian"}}}
@@ -176,8 +181,9 @@ func TestRing0_OneCallPerLayer(t *testing.T) {
if g.Outcome != "applied" || !g.Healthy || ho.Outcome != "applied" || !ho.Healthy {
t.Fatalf("guest %+v\nhost %+v", g, ho)
}
if calls(w) != "guest:apply,host:apply,guest:live-restore-on,docker:apply,pve:apply" {
t.Fatalf("calls = %s, want one apply per layer, guest first, then live-restore, the ring-0 docker step and the pve step", calls(w))
// R-1368: the clean-up of old kernels is the night's last call when no kernel step is due
if calls(w) != "guest:apply,host:apply,guest:live-restore-on,docker:apply,pve:apply,kernel:kernel-cleanup" {
t.Fatalf("calls = %s, want one apply per layer, guest first, then live-restore, the ring-0 docker step, the pve step, the kernel clean-up", calls(w))
}
for _, p := range w.plans[:2] {
if p["select"] != "pending-fast" || p["snapshot"] != "" || len(p["packages"].([]any)) != 0 {
@@ -221,7 +227,8 @@ func TestRing1_NoReleaseIsInventory(t *testing.T) {
w := &fakeWrapper{t: t, pending: pend}
l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true})
g, ho := run2(l, "night")
if g.Outcome != "nothing" || ho.Outcome != "nothing" || calls(w) != "guest:inventory,host:inventory" {
// R-1368: a ring-1 box asks the wrapper too — whether it may clean up is the wrapper's answer (its root-owned mark)
if g.Outcome != "nothing" || ho.Outcome != "nothing" || calls(w) != "guest:inventory,host:inventory,kernel:kernel-cleanup" {
t.Fatalf("g=%+v h=%+v calls=%s", g, ho, calls(w))
}
}
+101
View File
@@ -0,0 +1,101 @@
package osupdate
// The security-profile refresh (R-1388, R-1301, `11` §5.12). After a kernel went back and forth a box can load an OLD
// compiled AppArmor profile for a program (on the N100 this stopped the time service). The root wrapper's mode
// profile-refresh loads every profile from a fresh compile, once per kernel the box starts on, and restarts only a
// failed service whose own program's profile was stale. This file decides WHEN to ask: at every agent start and after
// a config bundle was installed (a new wrapper may know the mode the old one refused) — the wrapper itself answers
// "not needed" when this kernel was already done. Nothing is sent to the hub (no hub field exists for it; R-1383).
import (
"context"
"encoding/json"
"strings"
"time"
)
// ProfileRefreshResult is the wrapper's profile_refresh object.
type ProfileRefreshResult struct {
Outcome string `json:"outcome"` // refreshed | not_needed | dry_run | failed
Reason string `json:"reason"`
Kernel string `json:"kernel"`
Rewritten []string `json:"rewritten"`
Restarted []struct {
Unit string `json:"unit"`
Program string `json:"program"`
RC int `json:"rc"`
Active bool `json:"active"`
} `json:"restarted"`
Seconds float64 `json:"seconds"`
}
// ProfileGate takes the heavy-op gate (a backup, a restore-test or an OS step must not run beside the refresh).
type ProfileGate func() (release func(), busy string, ok bool)
// ProfileRefresh asks the wrapper once. It returns the outcome ("" when the wrapper could not be asked or refused).
// It waits for the gate: up to `tries` attempts, `wait` apart. why is the log's short reason for this call.
func (l *Leg) ProfileRefresh(ctx context.Context, why string, gate ProfileGate, tries int, wait time.Duration) string {
lg := l.log().With("mode", "profile-refresh", "why", why)
if !l.Appliance {
lg.Info("osupdate: profile refresh skipped — not an appliance install (a BYO host belongs to its owner)")
return ""
}
var release func()
for i := 1; ; i++ {
if gate == nil {
break
}
r, busy, ok := gate()
if ok {
release = r
break
}
if i >= tries || ctx.Err() != nil {
lg.Warn("osupdate: profile refresh NOT run — the box stayed busy; the next agent start asks again", "busy", busy, "tries", i)
return ""
}
lg.Info("osupdate: profile refresh waits — a heavy operation runs", "busy", busy, "try", i)
l.sleep(ctx, wait)
}
if release != nil {
defer release()
}
unlock := l.lockPass(true) // never beside an OS-update pass
defer unlock()
start := l.now()
wr, err := l.call(ctx, "prefresh"+l.now().UTC().Format("150405"), map[string]any{"release_id": "profile-refresh",
"layer": LayerHost, "mode": "profile-refresh"})
took := l.now().Sub(start).Round(100 * time.Millisecond).String()
switch {
case err != nil:
lg.Warn("osupdate: profile refresh — the wrapper gave no answer; the next agent start asks again", "err", err, "took", took)
return ""
case wr.refused():
lg.Info("osupdate: profile refresh refused (an older wrapper that does not know the mode, or a BYO host)", "refused", string(wr.Refused))
return ""
case wr.failed():
lg.Error("osupdate: profile refresh FAILED — the profiles are as before; the next agent start asks again", "failed", string(wr.Failed), "took", took)
return "failed"
}
var res ProfileRefreshResult
_ = json.Unmarshal(wr.ProfileRefresh, &res)
switch res.Outcome {
case "refreshed":
units := make([]string, 0, len(res.Restarted))
for _, u := range res.Restarted {
units = append(units, u.Unit)
}
if len(units) > 0 {
lg.Warn("osupdate: profile refresh — security profiles reloaded from a fresh compile; a FAILED service whose profile was stale was restarted",
"kernel", res.Kernel, "restarted", strings.Join(units, ","), "rewritten", len(res.Rewritten), "wrapper_seconds", res.Seconds, "took", took)
} else {
lg.Info("osupdate: profile refresh — security profiles reloaded from a fresh compile", "kernel", res.Kernel,
"rewritten", len(res.Rewritten), "wrapper_seconds", res.Seconds, "took", took)
}
case "not_needed":
lg.Info("osupdate: profile refresh not needed", "kernel", res.Kernel, "reason", res.Reason)
default:
lg.Warn("osupdate: profile refresh — the wrapper answered with no outcome this agent knows", "outcome", res.Outcome)
}
return res.Outcome
}
+97
View File
@@ -0,0 +1,97 @@
package osupdate
import (
"context"
"encoding/json"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// ---- the security-profile refresh (R-1388, R-1301, `11` §5.12) ----
func profileCalls(w *fakeWrapper) []map[string]any {
var m []map[string]any
for _, p := range w.plans {
if p["mode"] == "profile-refresh" {
m = append(m, p)
}
}
return m
}
func refreshed() *WrapperReport {
return &WrapperReport{ProfileRefresh: json.RawMessage(`{"outcome":"refreshed","kernel":"7.0.14-28-pve","rewritten":["f6da0254.0/usr.sbin.chronyd"],` +
`"restarted":[{"unit":"chrony.service","program":"/usr/sbin/chronyd","rc":0,"active":true}],"seconds":0.5}`)}
}
// One call, a host-layer plan that names NOTHING, and nothing to the hub.
// COMPANION RED-PROOF (observed): add "units" to the plan → this test fails; drop the Appliance check → the BYO test fails.
func TestProfileRefresh_OneCallNoNamesNoHubReport(t *testing.T) {
w := &fakeWrapper{t: t, profileRep: refreshed()}
l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true})
if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "refreshed" {
t.Fatalf("outcome = %q", got)
}
pc := profileCalls(w)
if len(pc) != 1 {
t.Fatalf("calls = %d", len(pc))
}
if len(pc[0]) != 3 || pc[0]["layer"] != LayerHost || pc[0]["release_id"] != "profile-refresh" {
t.Fatalf("the plan carries more than release_id, layer and mode: %v", pc[0])
}
if len(h.reports) != 0 {
t.Fatalf("the hub got a report: %+v", h.reports)
}
}
func TestProfileRefresh_ByoHostIsNeverAsked(t *testing.T) {
w := &fakeWrapper{t: t, profileRep: refreshed()}
l, _ := newLeg(t, w, nil)
l.Appliance = false
if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "" || len(profileCalls(w)) != 0 {
t.Fatalf("a BYO host was asked: %q %v", got, profileCalls(w))
}
}
// The heavy-op gate: busy → wait and ask again; still busy after the tries → not run (the next start asks again).
// COMPANION RED-PROOF (observed): ignore `ok` from the gate → the "always busy" half fails.
func TestProfileRefresh_WaitsForTheHeavyOpGate(t *testing.T) {
w := &fakeWrapper{t: t, profileRep: refreshed()}
l, _ := newLeg(t, w, nil)
n, released := 0, 0
gate := func() (func(), string, bool) {
n++
if n < 3 {
return nil, "primary-backup", false
}
return func() { released++ }, "", true
}
if got := l.ProfileRefresh(context.Background(), "agent start", gate, 5, time.Minute); got != "refreshed" {
t.Fatalf("outcome = %q after %d gate tries", got, n)
}
if n != 3 || released != 1 || len(profileCalls(w)) != 1 {
t.Fatalf("gate tries %d, released %d, calls %d", n, released, len(profileCalls(w)))
}
w2 := &fakeWrapper{t: t, profileRep: refreshed()}
l2, _ := newLeg(t, w2, nil)
busy := func() (func(), string, bool) { return nil, "restore-test", false }
if got := l2.ProfileRefresh(context.Background(), "agent start", busy, 3, time.Minute); got != "" || len(profileCalls(w2)) != 0 {
t.Fatalf("ran beside a heavy operation: %q %d call(s)", got, len(profileCalls(w2)))
}
}
// An older wrapper refuses the mode: logged, nothing else. A failure is said as "failed".
func TestProfileRefresh_RefusedAndFailed(t *testing.T) {
w := &fakeWrapper{t: t, profileRep: &WrapperReport{Refused: json.RawMessage(`{"code":"R11","reason":"unknown mode 'profile-refresh'"}`)}}
l, _ := newLeg(t, w, nil)
if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "" {
t.Fatalf("refused → %q", got)
}
w2 := &fakeWrapper{t: t, profileRep: &WrapperReport{Failed: json.RawMessage(`{"rc":1,"step":"profile-refresh"}`)}}
l2, _ := newLeg(t, w2, nil)
if got := l2.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "failed" {
t.Fatalf("failed → %q", got)
}
}