agent (unreleased): fixes after an independent review — one lock for the writers of agent.json, the trust run serialised and bounded, the CA copy keeps its previous root, the profile stanza from the wrapper's answer, a stale give-up ends, both boot tokens from one read
gates / gates (push) Successful in 48s
gates / gates (push) Successful in 48s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
61feb95c06
commit
ec7e699fd7
18 files changed
+333
-59
No files matched your search
@@ -232,6 +232,7 @@ type Leg struct {
|
||||
block *hub.WireOSUpdate
|
||||
profileAsk string // how the last ask of the profile refresh ended in THIS process ("" = never asked)
|
||||
profileAskAt time.Time
|
||||
profileLast ProfileRefreshStatus // the last refresh as the wrapper answered it to this process
|
||||
}
|
||||
|
||||
// planFile / reportFile: the plan the agent writes and the copy of the report the wrapper keeps beside it (R-868).
|
||||
|
||||
@@ -30,6 +30,9 @@ type ProfileRefreshResult struct {
|
||||
Active bool `json:"active"`
|
||||
} `json:"restarted"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
// The wrapper's own record of the refresh BEFORE this ask (it answers them at every ask, also "not needed").
|
||||
LastRefreshKernel string `json:"last_refresh_kernel"`
|
||||
LastRefreshAt string `json:"last_refresh_at"`
|
||||
}
|
||||
|
||||
// DefaultProfileRecordPath is where the root wrapper keeps the record of its last refresh (PROFILE_STATE, 0644).
|
||||
@@ -42,7 +45,7 @@ const DefaultProfileRecordPath = "/var/lib/felhom-os-apply/profile-refresh.json"
|
||||
type ProfileRefreshStatus struct {
|
||||
Kernel string `json:"kernel,omitempty"` // the kernel the last refresh ran on
|
||||
At string `json:"at,omitempty"` // RFC3339, when
|
||||
Rewritten int `json:"rewritten"` // how many compiled profiles changed
|
||||
Rewritten *int `json:"rewritten,omitempty"` // how many compiled profiles changed; nil = not known to this process
|
||||
Restarted []string `json:"restarted,omitempty"` // units it restarted (failed, with a stale profile)
|
||||
LastAsk string `json:"last_ask,omitempty"` // refreshed | not_needed | failed | refused | no_answer
|
||||
LastAskAt string `json:"last_ask_at,omitempty"`
|
||||
@@ -50,16 +53,27 @@ type ProfileRefreshStatus struct {
|
||||
|
||||
var profileNameRe = regexp.MustCompile(`^[A-Za-z0-9@._:+-]{1,120}$`)
|
||||
|
||||
// ProfileRefreshStanza builds the stanza, or an UNTYPED nil when there is nothing to say (no record and never asked:
|
||||
// a BYO host, or an agent that did not ask yet) — the report then omits the field. It reads one small root-written
|
||||
// file and never calls the wrapper. Names that are not plain unit / kernel names are dropped, the list is capped.
|
||||
// ProfileRefreshStanza builds the stanza, or an UNTYPED nil when there is nothing to say (never asked and no record: a
|
||||
// BYO host, or an agent that did not ask yet) — the report then omits the field.
|
||||
//
|
||||
// Two sources, the first that knows wins per field. (1) What the wrapper ANSWERED to this process's last ask: a
|
||||
// refresh gives everything; "not needed" gives the kernel and time of the earlier refresh (the wrapper answers its own
|
||||
// record at every ask) — so the last refresh is still told after an agent restart, without the detail. (2) The
|
||||
// wrapper's record file, when this process may read it: on boxes where its folder was first made by a signed job it
|
||||
// is root-only (0700), so the file is a bonus, never the only source (found by review, 2026-11-14; not read on a box).
|
||||
// It never calls the wrapper. Names that are not plain unit / kernel names are dropped, the list is capped.
|
||||
func (l *Leg) ProfileRefreshStanza() any {
|
||||
l.mu.Lock()
|
||||
st, ask, at := l.profileLast, l.profileAsk, l.profileAskAt
|
||||
l.mu.Unlock()
|
||||
have := ask != ""
|
||||
if have {
|
||||
st.LastAsk, st.LastAskAt = ask, at.UTC().Format(time.RFC3339)
|
||||
}
|
||||
path := l.ProfileRecordPath
|
||||
if path == "" {
|
||||
path = DefaultProfileRecordPath
|
||||
}
|
||||
var st ProfileRefreshStatus
|
||||
have := false
|
||||
if raw, err := os.ReadFile(path); err == nil && len(raw) < 64<<10 {
|
||||
var rec struct {
|
||||
Kernel string `json:"kernel"`
|
||||
@@ -69,37 +83,60 @@ func (l *Leg) ProfileRefreshStanza() any {
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && (rec.Kernel != "" || rec.At != "") {
|
||||
have = true
|
||||
if profileNameRe.MatchString(rec.Kernel) {
|
||||
st.Kernel = rec.Kernel
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, rec.At); err == nil {
|
||||
st.At = rec.At
|
||||
}
|
||||
st.Rewritten = len(rec.Rewritten)
|
||||
for _, u := range rec.Restarted {
|
||||
if len(st.Restarted) < 16 && profileNameRe.MatchString(u) {
|
||||
st.Restarted = append(st.Restarted, u)
|
||||
}
|
||||
fromRec := profileStatusFrom(rec.Kernel, rec.At, rec.Rewritten, rec.Restarted, true)
|
||||
if st.At == "" || fromRec.At == st.At || st.Rewritten == nil {
|
||||
fromRec.LastAsk, fromRec.LastAskAt = st.LastAsk, st.LastAskAt
|
||||
st = fromRec
|
||||
}
|
||||
}
|
||||
}
|
||||
l.mu.Lock()
|
||||
ask, at := l.profileAsk, l.profileAskAt
|
||||
l.mu.Unlock()
|
||||
if ask != "" {
|
||||
have = true
|
||||
st.LastAsk, st.LastAskAt = ask, at.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !have {
|
||||
return nil
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (l *Leg) noteProfileAsk(outcome string) {
|
||||
// profileStatusFrom checks and bounds what goes to the hub. detail=false: the count and the units are not known.
|
||||
func profileStatusFrom(kernel, at string, rewritten, restarted []string, detail bool) ProfileRefreshStatus {
|
||||
var st ProfileRefreshStatus
|
||||
if profileNameRe.MatchString(kernel) {
|
||||
st.Kernel = kernel
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, at); err == nil {
|
||||
st.At = at
|
||||
}
|
||||
if detail {
|
||||
n := len(rewritten)
|
||||
st.Rewritten = &n
|
||||
for _, u := range restarted {
|
||||
if len(st.Restarted) < 16 && profileNameRe.MatchString(u) {
|
||||
st.Restarted = append(st.Restarted, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// noteProfileAsk remembers how an ask ended and, when the wrapper answered, what it said about the last refresh.
|
||||
func (l *Leg) noteProfileAsk(outcome string, res *ProfileRefreshResult) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.profileAsk, l.profileAskAt = outcome, l.now()
|
||||
l.mu.Unlock()
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
switch outcome {
|
||||
case "refreshed":
|
||||
units := make([]string, 0, len(res.Restarted))
|
||||
for _, u := range res.Restarted {
|
||||
units = append(units, u.Unit)
|
||||
}
|
||||
l.profileLast = profileStatusFrom(res.Kernel, l.now().UTC().Format(time.RFC3339), res.Rewritten, units, true)
|
||||
case "not_needed":
|
||||
if res.LastRefreshAt != "" || res.LastRefreshKernel != "" {
|
||||
l.profileLast = profileStatusFrom(res.LastRefreshKernel, res.LastRefreshAt, nil, nil, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileGate takes the heavy-op gate (a backup, a restore-test or an OS step must not run beside the refresh).
|
||||
@@ -142,15 +179,15 @@ func (l *Leg) ProfileRefresh(ctx context.Context, why string, gate ProfileGate,
|
||||
switch {
|
||||
case err != nil:
|
||||
lg.Warn("osupdate: profile refresh — the wrapper gave no answer; the next agent start asks again", "err", err, "took", took)
|
||||
l.noteProfileAsk("no_answer")
|
||||
l.noteProfileAsk("no_answer", nil)
|
||||
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))
|
||||
l.noteProfileAsk("refused")
|
||||
l.noteProfileAsk("refused", nil)
|
||||
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)
|
||||
l.noteProfileAsk("failed")
|
||||
l.noteProfileAsk("failed", nil)
|
||||
return "failed"
|
||||
}
|
||||
var res ProfileRefreshResult
|
||||
@@ -174,7 +211,7 @@ func (l *Leg) ProfileRefresh(ctx context.Context, why string, gate ProfileGate,
|
||||
lg.Warn("osupdate: profile refresh — the wrapper answered with no outcome this agent knows", "outcome", res.Outcome)
|
||||
}
|
||||
if res.Outcome == "refreshed" || res.Outcome == "not_needed" {
|
||||
l.noteProfileAsk(res.Outcome)
|
||||
l.noteProfileAsk(res.Outcome, &res)
|
||||
}
|
||||
return res.Outcome
|
||||
}
|
||||
@@ -100,49 +100,66 @@ func TestProfileRefresh_RefusedAndFailed(t *testing.T) {
|
||||
}
|
||||
|
||||
// R-1383 (release 3): the refresh restarted a failed service on the N100 and only the box's journal knew. The stanza
|
||||
// that rides the host report: nothing before any refresh (an UNTYPED nil — the report omits the field); after a
|
||||
// refresh, the root wrapper's own record (so it outlives an agent restart) and how this process's last ask ended.
|
||||
// that rides the host report: nothing before any ask (an UNTYPED nil — the report omits the field); after a refresh,
|
||||
// everything; after an agent RESTART, the kernel and time of that refresh again — from the wrapper's answer to the new
|
||||
// process's ask, because the wrapper's record FILE may be root-only on a box (its folder is 0700 where a signed job
|
||||
// made it first). Where the file can be read, it adds the detail back.
|
||||
// COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-agent-b3.txt).
|
||||
func TestProfileRefreshStanza_RecordAndLastAsk(t *testing.T) {
|
||||
w := &fakeWrapper{t: t, profileRep: refreshed()}
|
||||
l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true})
|
||||
rec := filepath.Join(t.TempDir(), "profile-refresh.json")
|
||||
l.ProfileRecordPath = rec
|
||||
unreadable := filepath.Join(t.TempDir(), "root-only", "profile-refresh.json") // does not exist: as a 0700 folder reads
|
||||
l.ProfileRecordPath = unreadable
|
||||
now := time.Date(2026, 11, 14, 11, 30, 11, 0, time.UTC)
|
||||
l.Now = func() time.Time { return now }
|
||||
if st := l.ProfileRefreshStanza(); st != nil {
|
||||
t.Fatalf("before any refresh the stanza is %#v, want an untyped nil", st)
|
||||
t.Fatalf("before any ask the stanza is %#v, want an untyped nil", st)
|
||||
}
|
||||
// the wrapper refreshes and writes ITS record (the fake wrapper does not, so the test writes what the real one does)
|
||||
if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "refreshed" {
|
||||
t.Fatalf("outcome = %q", got)
|
||||
}
|
||||
b, _ := json.Marshal(l.ProfileRefreshStanza())
|
||||
if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","rewritten":1,"restarted":["chrony.service"],"last_ask":"refreshed","last_ask_at":"2026-11-14T11:30:11Z"}` {
|
||||
t.Fatalf("stanza after a refresh, record file unreadable = %s", b)
|
||||
}
|
||||
// an agent restart: a new process asks, the wrapper answers "not needed" with ITS record of the last refresh
|
||||
l2, _ := newLeg(t, &fakeWrapper{t: t, profileRep: &WrapperReport{ProfileRefresh: json.RawMessage(
|
||||
`{"outcome":"not_needed","reason":"this kernel was done","kernel":"7.0.14-28-pve","last_refresh_kernel":"7.0.14-28-pve","last_refresh_at":"2026-11-14T11:30:11Z"}`)}},
|
||||
&hub.WireOSUpdate{Ring: 1, Enabled: true})
|
||||
l2.ProfileRecordPath = unreadable
|
||||
l2.Now = func() time.Time { return now.Add(3 * time.Hour) }
|
||||
if st := l2.ProfileRefreshStanza(); st != nil {
|
||||
t.Fatalf("a new process that has not asked and cannot read the record: %#v, want nil", st)
|
||||
}
|
||||
if got := l2.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "not_needed" {
|
||||
t.Fatalf("outcome = %q", got)
|
||||
}
|
||||
b, _ = json.Marshal(l2.ProfileRefreshStanza())
|
||||
if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","last_ask":"not_needed","last_ask_at":"2026-11-14T14:30:11Z"}` {
|
||||
t.Fatalf("stanza after an agent restart (the wrapper's answer, no detail) = %s", b)
|
||||
}
|
||||
// where the record file CAN be read, the detail is back — and a name that is not a unit name is dropped
|
||||
rec := filepath.Join(t.TempDir(), "profile-refresh.json")
|
||||
if err := os.WriteFile(rec, []byte(`{"at":"2026-11-14T11:30:11Z","kernel":"7.0.14-28-pve","restarted":["chrony.service","bad name; rm -rf"],`+
|
||||
`"rewritten":["f6da0254.0/usr.sbin.chronyd","f6da0254.0/lxc-containers"]}`), 0o644); err != nil {
|
||||
`"rewritten":["a","b"]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(l.ProfileRefreshStanza())
|
||||
if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","rewritten":2,"restarted":["chrony.service"],"last_ask":"refreshed","last_ask_at":"2026-11-14T11:30:11Z"}` {
|
||||
t.Fatalf("stanza after a refresh = %s", b)
|
||||
}
|
||||
// an agent restart: a new process, the same record — the box's last refresh is still told
|
||||
l2, _ := newLeg(t, &fakeWrapper{t: t}, &hub.WireOSUpdate{Ring: 1, Enabled: true})
|
||||
l2.ProfileRecordPath = rec
|
||||
b, _ = json.Marshal(l2.ProfileRefreshStanza())
|
||||
if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","rewritten":2,"restarted":["chrony.service"]}` {
|
||||
t.Fatalf("stanza after an agent restart = %s", b)
|
||||
if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","rewritten":2,"restarted":["chrony.service"],"last_ask":"not_needed","last_ask_at":"2026-11-14T14:30:11Z"}` {
|
||||
t.Fatalf("stanza with a readable record = %s", b)
|
||||
}
|
||||
// a failed ask is told, beside the older record
|
||||
l2.Now = func() time.Time { return now.Add(time.Hour) }
|
||||
l2.Runner = (&fakeWrapper{t: t, profileRep: &WrapperReport{Failed: json.RawMessage(`{"code":"F1","reason":"apparmor_parser failed"}`)}})
|
||||
l2.Now = func() time.Time { return now.Add(4 * time.Hour) }
|
||||
if got := l2.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "failed" {
|
||||
t.Fatalf("outcome = %q", got)
|
||||
}
|
||||
b, _ = json.Marshal(l2.ProfileRefreshStanza())
|
||||
if !strings.Contains(string(b), `"last_ask":"failed","last_ask_at":"2026-11-14T12:30:11Z"`) || !strings.Contains(string(b), `"kernel":"7.0.14-28-pve"`) {
|
||||
if !strings.Contains(string(b), `"last_ask":"failed","last_ask_at":"2026-11-14T15:30:11Z"`) || !strings.Contains(string(b), `"kernel":"7.0.14-28-pve"`) {
|
||||
t.Fatalf("stanza after a failed ask = %s", b)
|
||||
}
|
||||
// a record that is not JSON, or huge, says nothing and breaks nothing
|
||||
// a record that is not JSON says nothing and breaks nothing
|
||||
_ = os.WriteFile(rec, []byte("not json"), 0o644)
|
||||
l3, _ := newLeg(t, &fakeWrapper{t: t}, &hub.WireOSUpdate{Ring: 1, Enabled: true})
|
||||
l3.ProfileRecordPath = rec
|
||||
|
||||
@@ -26,7 +26,7 @@ type PVECACopyResult struct {
|
||||
// PVECACopy asks the wrapper to make or refresh the copy. An error names a refusal (T1–T3, or R11 from a wrapper that
|
||||
// does not know the mode yet) or a wrapper that gave no answer.
|
||||
func (l *Leg) PVECACopy(ctx context.Context) (PVECACopyResult, error) {
|
||||
wr, err := l.call(ctx, "pveca"+l.now().UTC().Format("150405"), map[string]any{"release_id": "pve-ca-copy",
|
||||
wr, err := l.call(ctx, "pveca"+l.now().UTC().Format("150405.000"), map[string]any{"release_id": "pve-ca-copy",
|
||||
"layer": LayerHost, "mode": "pve-ca-copy"})
|
||||
if err != nil {
|
||||
return PVECACopyResult{}, err
|
||||
|
||||
Reference in new issue
Block a user