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

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
admin committed 2026-11-14 19:09:21 +01:00
1 parent 61feb95c06
commit ec7e699fd7
18 files changed
+333 -59

No files matched your search

+67 -30
View File
@@ -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
}