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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user