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
gates / gates (push) Successful in 40s
This commit is contained in:
1 parent
91a2924ad0
commit
cc63dd5204
11 files changed
+1542
-10
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user