Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c0396f6b | ||
|
|
c36677b9e5 |
@@ -19,5 +19,3 @@ modules/mesh-issues/cmd/mesh-issues/mesh-issues
|
||||
modules/mounts/cmd/mounts/mounts
|
||||
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
||||
modules/mesh-knowledge/mesh-knowledge
|
||||
modules/mesh-reads/mesh-reads
|
||||
modules/mesh-reads/cmd/mesh-reads/mesh-reads
|
||||
|
||||
@@ -104,9 +104,6 @@ func statusOf(d *Delivery) (string, string) {
|
||||
case Superseded:
|
||||
return "warning", clip("superseded: "+why, 140)
|
||||
case Stopped:
|
||||
if d.coveredStop() {
|
||||
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 480)
|
||||
}
|
||||
return "error", clip("stopped: "+why, 140)
|
||||
case Retired:
|
||||
return "success", "history: merged before this owner delivered anything, never walked"
|
||||
@@ -127,9 +124,6 @@ const viewMarker = "<!-- mesh-delivery:view -->"
|
||||
func ViewBody(d *Delivery, g *Group) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
|
||||
if d.coveredStop() {
|
||||
fmt.Fprintf(&b, "**Covered** after its stop: a later merge that contains it was delivered: %s\n\n", d.CoveredBy)
|
||||
}
|
||||
if d.HeldWhy != "" && d.State == Held {
|
||||
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
|
||||
}
|
||||
@@ -284,16 +278,6 @@ func (h *Holder) Flush() {
|
||||
d.Owed = owed(d.Owed)
|
||||
h.keep(d)
|
||||
}
|
||||
// Counted for delivery_status, so a window's writes can be judged against its tries (novox/hq issue 407).
|
||||
h.tried.last = h.Now()
|
||||
switch {
|
||||
case err == nil:
|
||||
h.tried.done++
|
||||
case dropped != "":
|
||||
h.tried.givenUp++
|
||||
default:
|
||||
h.tried.failed++
|
||||
}
|
||||
if dropped != "" {
|
||||
h.refuse(fmt.Errorf("%s: its owed %s given up — %s: %v", j.id, j.e.Kind, dropped, err))
|
||||
}
|
||||
|
||||
@@ -67,16 +67,6 @@ type Holder struct {
|
||||
// leaveRefused is, by walk, what the controller answered when it refused both to leave a stopped merge out and
|
||||
// to stop the walk whole.
|
||||
leaveRefused map[string]string
|
||||
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
|
||||
tried owedTries
|
||||
}
|
||||
|
||||
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
|
||||
// and a time, however many were tried. Not kept: a restarted owner counts from its start, and says since when, so a
|
||||
// window's count is the difference of two readings in the same run (novox/hq issue 407).
|
||||
type owedTries struct {
|
||||
done, failed, givenUp int
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// unmatched is a walk no delivery is known for yet: its merge not heard, or merged before this owner held.
|
||||
@@ -963,16 +953,12 @@ func (h *Holder) plan() []func() {
|
||||
// grace, it is stopped saying so — never left waiting for a start that cannot come.
|
||||
for _, d := range h.deliveries {
|
||||
ended := d.endedUnstarted()
|
||||
if d.coveredStop() && !d.CoverTold {
|
||||
h.tellCovered(d, now) // covered before this holder told it, or before a restart (novox/hq issue 480)
|
||||
continue
|
||||
}
|
||||
if strandedUncovered(d) {
|
||||
// Stopped and final, but still read for a later merge that delivered its modules: until one did,
|
||||
// `stalled` keeps listing it (review of novox/hq issue 474).
|
||||
var ask []string
|
||||
if d.CoveredBy, ask = h.coverOf(d); d.CoveredBy != "" {
|
||||
h.tellCovered(d, now)
|
||||
h.keep(d)
|
||||
}
|
||||
for _, in := range ask {
|
||||
if a := h.askContains(d, in, now); a != nil {
|
||||
@@ -1117,25 +1103,6 @@ func (h *Holder) laterOnItsWay(d *Delivery) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// tellCovered tells the forge that a stop is covered since, once: its status says covered and by which merge, its view
|
||||
// says so, and its note keeps the line on its head and its merge commit (novox/hq issue 480). No transition: the stop
|
||||
// stays final, and the bus's transition event is not said again.
|
||||
func (h *Holder) tellCovered(d *Delivery, now time.Time) {
|
||||
d.CoverTold = true
|
||||
line := oneLine(fmt.Sprintf("%s delivery %s: stopped, %s", now.UTC().Format(time.RFC3339), d.ID, d.coveredStopSaid()))
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now})
|
||||
if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) {
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.MergedAs, Line: line, Since: now})
|
||||
}
|
||||
if d.shownOnItsPullRequest() {
|
||||
d.owe(Effect{Kind: EffectView, Since: now})
|
||||
state, desc := statusOf(d)
|
||||
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: stopped, %s", d.ID, d.coveredStopSaid())
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// strandedUncovered is whether a delivery stopped because its ended walk delivered nothing is still uncovered: a
|
||||
// merged change that never reached the machines, which `stalled` keeps listing until a later delivered merge
|
||||
// carries its modules (review of novox/hq issue 474).
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// triedSaid is delivery_status's count of owed effects tried (novox/hq issue 407), read as a test reads it.
|
||||
func triedSaid(t *testing.T, w *world) map[string]any {
|
||||
t.Helper()
|
||||
got, ok := w.h.Status("listening")["owed-tried"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("delivery_status says no owed-tried: %v", w.h.Status("listening"))
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func countOf(t *testing.T, m map[string]any, key string) int {
|
||||
t.Helper()
|
||||
n, ok := m[key].(int)
|
||||
if !ok {
|
||||
t.Fatalf("owed-tried has no count %q: %v", key, m)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// novox/hq issue 407: delivery_status says how many owed effects were tried since this owner started, how
|
||||
// many were done, failed to be tried again, or were given up, and when the last was tried — so a window's
|
||||
// writes can be checked against the tries made in it (issue 326's rule) by reading it at both ends.
|
||||
func TestDeliveryStatusCountsTheOwedEffectsTried(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
start := w.now
|
||||
tried := triedSaid(t, w)
|
||||
if countOf(t, tried, "tried") != 0 || countOf(t, tried, "done") != 0 || countOf(t, tried, "failed") != 0 ||
|
||||
countOf(t, tried, "given-up") != 0 {
|
||||
t.Fatalf("a holder that tried nothing says %v", tried)
|
||||
}
|
||||
if tried["since"] != start.Format(time.RFC3339) {
|
||||
t.Fatalf("its count is since %v, the holder started %s", tried["since"], start.Format(time.RFC3339))
|
||||
}
|
||||
if _, there := tried["last"]; there {
|
||||
t.Fatalf("nothing tried, yet a last try is said: %v", tried)
|
||||
}
|
||||
|
||||
// A head seen: its event and its note are owed and done.
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
tried = triedSaid(t, w)
|
||||
done := countOf(t, tried, "done")
|
||||
if done < len(w.said)+len(w.forge.notesOn(head)) || done == 0 {
|
||||
t.Fatalf("said %d and noted %d, yet done counts %d", len(w.said), len(w.forge.notesOn(head)), done)
|
||||
}
|
||||
if countOf(t, tried, "tried") != done || countOf(t, tried, "failed") != 0 {
|
||||
t.Fatalf("every try succeeded, yet %v", tried)
|
||||
}
|
||||
if tried["last"] != w.now.Format(time.RFC3339) {
|
||||
t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// The forge away: its effects fail and are counted failed; quiet passes try nothing more.
|
||||
w.forge.down = true
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.Flush()
|
||||
tried = triedSaid(t, w)
|
||||
failed := countOf(t, tried, "failed")
|
||||
if failed == 0 {
|
||||
t.Fatalf("the forge away failed nothing: %v", tried)
|
||||
}
|
||||
if countOf(t, tried, "tried") != countOf(t, tried, "done")+failed+countOf(t, tried, "given-up") {
|
||||
t.Fatalf("tries do not add up: %v", tried)
|
||||
}
|
||||
if tried["last"] != w.now.Format(time.RFC3339) {
|
||||
t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339))
|
||||
}
|
||||
before := countOf(t, tried, "tried")
|
||||
w.h.Flush() // before each failure's next try: nothing is tried
|
||||
if n := countOf(t, triedSaid(t, w), "tried"); n != before {
|
||||
t.Fatalf("a pass before any next try tried %d", n-before)
|
||||
}
|
||||
|
||||
// The forge back, past every next try: the failed effects are done.
|
||||
w.forge.down = false
|
||||
w.later(time.Hour)
|
||||
w.h.Flush()
|
||||
tried = triedSaid(t, w)
|
||||
if countOf(t, tried, "tried") != before+failed || countOf(t, tried, "done") <= done {
|
||||
t.Fatalf("the forge back: tried %d (was %d, %d failed), %v", countOf(t, tried, "tried"), before, failed, tried)
|
||||
}
|
||||
}
|
||||
|
||||
// The count is this run's: a restarted holder says it counts since its own start, from nothing.
|
||||
func TestOwedTriedCountsSinceTheStart(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if countOf(t, triedSaid(t, w), "tried") == 0 {
|
||||
t.Fatal("nothing counted before the restart")
|
||||
}
|
||||
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
|
||||
tried := triedSaid(t, again)
|
||||
if countOf(t, tried, "tried") != 0 || tried["since"] != again.now.Format(time.RFC3339) {
|
||||
t.Fatalf("a restarted holder says %v", tried)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **An unanswered line says the state of the delivery it holds, as data** (novox/hq issue 451): for a delivery in
|
||||
// ready or rejected, whose check answered once, the controller advises a recheck rather than the build queue, and
|
||||
// it reads that state from the line, never from its words. A head with no delivery held says none.
|
||||
func TestAnUnansweredLineNamesTheStateOfItsDelivery(t *testing.T) {
|
||||
at := time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC)
|
||||
for _, c := range []struct {
|
||||
gate string
|
||||
want State
|
||||
}{{"pass", Ready}, {"fail", Rejected}} {
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), at)
|
||||
id := heldIn(t, w, c.gate, c.want)
|
||||
w.forge.open = openPullsOf(t, readyRepoCheckPending)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Unanswered || lines[0].Held != c.want {
|
||||
t.Fatalf("a %s delivery's unanswered line does not say its state as data: %+v", c.want, lines)
|
||||
}
|
||||
raw, _ := json.Marshal(lines[0])
|
||||
if !strings.Contains(string(raw), `"held":"`+string(c.want)+`"`) {
|
||||
t.Fatalf("the line the controller reads carries no held state: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The same head with no delivery held here: unanswered, and no state to say.
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), at)
|
||||
w.forge.open = openPullsOf(t, readyRepoCheckPending)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].State != Unanswered || lines[0].Held != "" {
|
||||
t.Fatalf("a head with no delivery held says a state: %+v", lines)
|
||||
}
|
||||
if raw, _ := json.Marshal(lines[0]); strings.Contains(string(raw), `"held"`) {
|
||||
t.Fatalf("a line with no delivery held carries a held field: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 480: a delivery stopped because its walk ended done before its word without building its modules
|
||||
// (issue 474) is still read for a later merge that delivers them, and kept CoveredBy once one did. Its state stays
|
||||
// stopped — a final state never moves, as for a covered superseded member (issue 442) — but the forge's status, its
|
||||
// view, its note and `show` went on saying "stopped" for a change that reached every machine. Once covered they say
|
||||
// covered, and by which merge. And while `stalled` lists such a stop, `deliveries` lists it too, past a day.
|
||||
|
||||
// strandedNight is theNight with #215 stopped, its walk past the grace with nothing later carrying build-agent.
|
||||
func strandedNight(t *testing.T) (*world, string) {
|
||||
w := theNight(t)
|
||||
id := IDOf(ctl474, head215)
|
||||
w.later(strandedAfter + 5*time.Minute)
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.State != Stopped || d.Stranded == "" {
|
||||
t.Fatalf("#215 is %s, stranded %q", d.State, d.Stranded)
|
||||
}
|
||||
return w, id
|
||||
}
|
||||
|
||||
func TestAStopALaterMergeCoversReadsCoveredByThatMerge(t *testing.T) {
|
||||
w, id := strandedNight(t)
|
||||
if got := statusOn(w, ctl474, head215); !strings.HasPrefix(got, "error stopped: ") {
|
||||
t.Fatalf("stopped, its status says %q", got)
|
||||
}
|
||||
w.forge.contains[merge215+" in "+merge219] = true
|
||||
by := deliverLater(w, ctl474, 219, "main", head219, merge219, w.now, walkDone, "build-agent")
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Stopped || !strings.Contains(d.CoveredBy, by) {
|
||||
t.Fatalf("after #219 it is %s, covered by %q", d.State, d.CoveredBy)
|
||||
}
|
||||
got := statusOn(w, ctl474, head215)
|
||||
if !strings.HasPrefix(got, "success covered") || !strings.Contains(got, by) {
|
||||
t.Fatalf("covered by %s, its status still says %q", by, got)
|
||||
}
|
||||
shown, err := w.h.Show(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reads, _ := shown.(map[string]any)["reads"].(string); !strings.HasPrefix(reads, "covered") || !strings.Contains(reads, by) {
|
||||
t.Fatalf("show reads %q", reads)
|
||||
}
|
||||
view := w.forge.views[ctl474+"#215"]
|
||||
if !strings.Contains(view, "Covered") || !strings.Contains(view, by) {
|
||||
t.Fatalf("its view does not say it was covered by %s:\n%s", by, view)
|
||||
}
|
||||
noted := false
|
||||
for _, l := range w.forge.notesOn(merge215) {
|
||||
noted = noted || strings.Contains(l, "covered") && strings.Contains(l, by)
|
||||
}
|
||||
if !noted {
|
||||
t.Fatalf("its merge's note does not say it was covered: %v", w.forge.notesOn(merge215))
|
||||
}
|
||||
// Told once: further ticks owe nothing more.
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); len(d.Owed) != 0 || !d.CoverTold {
|
||||
t.Fatalf("still owes %+v, told %v", d.Owed, d.CoverTold)
|
||||
}
|
||||
}
|
||||
|
||||
// A stop covered before this was built (CoveredBy kept, nothing told) is told after a restart, from what was kept.
|
||||
func TestACoveredStopKeptBeforeIsToldAfterARestart(t *testing.T) {
|
||||
w, id := strandedNight(t)
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
d.CoveredBy = IDOf(ctl474, head219) + " (merged as " + merge219[:8] + ")"
|
||||
if err := w.store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.mu.Unlock()
|
||||
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Hour))
|
||||
again.settleAll()
|
||||
if got := statusOn(again, ctl474, head215); !strings.HasPrefix(got, "success covered") ||
|
||||
!strings.Contains(got, IDOf(ctl474, head219)) {
|
||||
t.Fatalf("after a restart its status says %q", got)
|
||||
}
|
||||
if d := again.delivery(id); d.State != Stopped || len(d.Transitions) != len(w.delivery(id).Transitions) {
|
||||
t.Fatalf("a restart moved it: %s", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a stop for a walk that never built its modules reads covered: a person's stop, or one the cover test has
|
||||
// not answered, keeps its error.
|
||||
func TestOnlyACoveredStrandedStopReadsCovered(t *testing.T) {
|
||||
for _, d := range []*Delivery{
|
||||
{State: Stopped, CoveredBy: "x"}, // a person's stop, with a cover found while it was held
|
||||
{State: Stopped, Stranded: "never reached"}, // stranded, not covered
|
||||
} {
|
||||
if state, _ := statusOf(d); state != "error" {
|
||||
t.Fatalf("%+v reads %s", d, state)
|
||||
}
|
||||
if d.coveredStop() {
|
||||
t.Fatalf("%+v reads as a covered stop", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStopStalledListsStaysInDeliveries(t *testing.T) {
|
||||
w, id := strandedNight(t)
|
||||
w.later(30 * time.Hour)
|
||||
w.settleAll()
|
||||
listed := func() *Line {
|
||||
for _, l := range w.h.Deliveries("", "", "", false) {
|
||||
if l.ID == id {
|
||||
return &l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(w.h.Stalled()) != 1 {
|
||||
t.Fatalf("stalled lists %+v", w.h.Stalled())
|
||||
}
|
||||
l := listed()
|
||||
if l == nil || !l.Stalled || !strings.Contains(l.Waits, "build-agent") {
|
||||
t.Fatalf("listed by stalled, deliveries shows %+v", l)
|
||||
}
|
||||
// Covered, it ends like any final delivery: a day after its stop.
|
||||
w.forge.contains[merge215+" in "+merge219] = true
|
||||
deliverLater(w, ctl474, 219, "main", head219, merge219, w.now, walkDone, "build-agent")
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.h.Stalled()) != 0 || listed() != nil {
|
||||
t.Fatalf("covered, stalled lists %+v and deliveries %+v", w.h.Stalled(), listed())
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
seat := "mesh-delivery."
|
||||
return []stdio.Tool{
|
||||
{Name: seat + "deliveries",
|
||||
Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " +
|
||||
Description: "Every delivery not final, and those that ended in the last day, one line each: its id " +
|
||||
"(owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, " +
|
||||
"repository or group.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("one state"),
|
||||
@@ -347,8 +347,7 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
}},
|
||||
{Name: "delivery_status",
|
||||
Description: "Whether this owner works: how many deliveries in each state, the groups, what is owed to " +
|
||||
"the forge and the bus and not yet done, how many owed effects were tried since it started (done, failed " +
|
||||
"and tried again, given up) and when the last was, walks with no delivery yet, whether its events arrive, when " +
|
||||
"the forge and the bus and not yet done, walks with no delivery yet, whether its events arrive, when " +
|
||||
"the controller's walks were last read or why they could not be, and the last refusals.",
|
||||
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
|
||||
}
|
||||
|
||||
@@ -68,9 +68,6 @@ type Delivery struct {
|
||||
// Stranded says why a merge whose walk ended done before its word, without moving every module it moves, is
|
||||
// stopped: nothing later delivered them either (novox/hq issue 474). Set once the cover test says no.
|
||||
Stranded string `json:"stranded,omitempty"`
|
||||
// CoverTold is whether the forge was told that such a stop was covered since: its status, its view and its note
|
||||
// (novox/hq issue 480). Kept, so a restart tells it once, and a stop covered before this was built is told too.
|
||||
CoverTold bool `json:"cover_told,omitempty"`
|
||||
// TurnAwaits says, for a group member whose turn waits for a later merge to carry a superseded member before it,
|
||||
// which merges and until when; TurnAwaitsUntil is that moment, which bounds the wait (ADR 0276 decision 9,
|
||||
// novox/hq issue 445). `show` shows both, and `stalled` says them past that moment.
|
||||
@@ -251,20 +248,6 @@ func (d *Delivery) FromForgesWord() bool {
|
||||
|
||||
// shownOnItsPullRequest is whether its view and status are kept on its pull request: not for history, whose
|
||||
// pull request the forge may no longer hold — or holds under its number another one.
|
||||
// coveredStop is whether a delivery stopped because its walk ended done before its word without building its modules
|
||||
// (novox/hq issue 474) was covered since: a later merge into its trunk that contains it was delivered, carrying every
|
||||
// module it moves to every machine running it (ADR 0270's cover test, kept as CoveredBy). Its state stays stopped —
|
||||
// final, as a covered superseded member stays superseded (issue 442) — and it reads covered, by that merge (novox/hq
|
||||
// issue 480). A stop for any other reason never reads covered.
|
||||
func (d *Delivery) coveredStop() bool {
|
||||
return d.State == Stopped && d.Stranded != "" && d.CoveredBy != ""
|
||||
}
|
||||
|
||||
// coveredStopSaid is how a covered stop reads: on the forge's status, in its view and note, and in `show`.
|
||||
func (d *Delivery) coveredStopSaid() string {
|
||||
return "covered after its stop: a later merge that contains it was delivered: " + d.CoveredBy
|
||||
}
|
||||
|
||||
func (d *Delivery) shownOnItsPullRequest() bool {
|
||||
return d.Number > 0 && d.History == "" && d.State != Retired
|
||||
}
|
||||
|
||||
@@ -250,8 +250,8 @@ func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
|
||||
h2 = "none: its delivery is " + string(u.held) + ", a state the operator moves — the operator's"
|
||||
}
|
||||
out = append(out, StalledLine{ID: id, Number: u.pull.Number, State: state, For: now.Sub(u.first).Round(time.Second).String(),
|
||||
Bound: u.bound().String(), Held: u.held,
|
||||
H2: h2, Says: says, Waiting: waiting})
|
||||
Bound: u.bound().String(),
|
||||
H2: h2, Says: says, Waiting: waiting})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -71,11 +71,6 @@ func (h *Holder) waitsFor(d *Delivery) string {
|
||||
if d.Walk != nil {
|
||||
return "its walk: " + clip(d.Walk.Note, 120)
|
||||
}
|
||||
case Stopped:
|
||||
if strandedUncovered(d) {
|
||||
return "a later delivered merge to carry " + strings.Join(d.notMovedBy(), ", ") + ": it is stopped, and " +
|
||||
"listed by stalled until one does (novox/hq issue 480)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -91,8 +86,7 @@ func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
|
||||
group != "" && d.Group != group {
|
||||
continue
|
||||
}
|
||||
// A final delivery `stalled` still lists stays, however old: the two never disagree (novox/hq issue 480).
|
||||
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour && !strandedUncovered(d) {
|
||||
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour {
|
||||
continue
|
||||
}
|
||||
l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(),
|
||||
@@ -103,7 +97,7 @@ func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
|
||||
if d.Walk != nil {
|
||||
l.Walk = d.Walk.ID
|
||||
}
|
||||
l.Stalled = pastBound(d, now) || strandedUncovered(d)
|
||||
l.Stalled = pastBound(d, now)
|
||||
out = append(out, l)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
@@ -121,9 +115,6 @@ func (h *Holder) Show(id string) (any, error) {
|
||||
defer h.mu.Unlock()
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
out := map[string]any{"delivery": d, "waits": h.waitsFor(d)}
|
||||
if d.coveredStop() {
|
||||
out["reads"] = d.coveredStopSaid() // stopped, and its change reached the machines (novox/hq issue 480)
|
||||
}
|
||||
if g := h.groups[d.Group]; g != nil {
|
||||
state, why := GroupState(g, h.membersOf(g))
|
||||
out["group"] = map[string]any{"group": g, "state": state, "why": why}
|
||||
@@ -199,14 +190,10 @@ type StalledLine struct {
|
||||
// the operator's own zone, which it cannot do inside a finished sentence (novox/hq issue 443). Waiting stays
|
||||
// beside them for a controller that does not read them yet.
|
||||
Checks []WaitingCheck `json:"checks,omitempty"`
|
||||
// Held is the state of the delivery a line of the state Unanswered is about, ready or rejected, when one is held
|
||||
// here (novox/hq issues 441, 451): its check answered once, so the controller advises a recheck of it rather than
|
||||
// the build queue, reading the state here and never from Says. Empty for a head with no delivery.
|
||||
Held State `json:"held,omitempty"`
|
||||
For string `json:"for"`
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
For string `json:"for"`
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
|
||||
@@ -648,15 +635,6 @@ func (h *Holder) Status(listening string) map[string]any {
|
||||
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
|
||||
"walks-unmatched": len(h.unmatched), "events": listening, "started": h.started.Format(time.RFC3339),
|
||||
"seat-on-record": h.held}
|
||||
// The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again,
|
||||
// or given up, and when the last was tried. A count since the start, said with that start: two readings in
|
||||
// one run give a window's tries.
|
||||
tried := map[string]any{"since": h.started.Format(time.RFC3339), "done": h.tried.done, "failed": h.tried.failed,
|
||||
"given-up": h.tried.givenUp, "tried": h.tried.done + h.tried.failed + h.tried.givenUp}
|
||||
if !h.tried.last.IsZero() {
|
||||
tried["last"] = h.tried.last.Format(time.RFC3339)
|
||||
}
|
||||
out["owed-tried"] = tried
|
||||
// Each held delivery by name, with why and the verb that releases it (novox/hq issue 529): a count alone
|
||||
// pointed a reader at nothing.
|
||||
var held []string
|
||||
|
||||
@@ -23,7 +23,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
|
||||
| `reserve` | `kind`, `for` | the next decision or research number, asked of the issue tracker (`issue-tracker.reserve`); spent whether or not its record is accepted |
|
||||
| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`). Each version proposed is then reviewed by the knowledge base itself (below) |
|
||||
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
|
||||
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
|
||||
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again |
|
||||
|
||||
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
|
||||
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
|
||||
@@ -78,7 +78,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
|
||||
- 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one
|
||||
clause, clause numbering, bold lead, clause size, the Context, Consequences and "How it is checked" sections,
|
||||
a research effort's overview, link kind, link target, link fit, no link to itself, what it extends, what it
|
||||
supersedes or changes is accepted, a changed clause is in force or changed (a drafted one too), a superseded record cited beside its
|
||||
supersedes or changes is accepted, a changed clause is in force, a superseded record cited beside its
|
||||
superseder, a graduated effort says what it became, and the links in its text resolve;
|
||||
- 4 on the prose: no word the glossary retired, no daemon's line, a deferral names its tracking issue, and a
|
||||
progressive insight in the dated, marked form.
|
||||
@@ -178,12 +178,6 @@ the older clause marked `changed`. When every prose record of a topic has been t
|
||||
operator is told once (`operator-channel.notify`, a warning) with the counts. `return` sends a record back to be
|
||||
drafted again.
|
||||
|
||||
A drafted clause may be changed by a later record (`changes 0281 §9`, novox/hq issue 494, ADR 0321): on its acceptance the
|
||||
clause is marked `changed` with the change named, keeps its number and stays `drafted`, so it is still never served
|
||||
as in force, and `get` says both. A redraft numbers a record's clauses anew, so `return` refuses a record any of
|
||||
whose clauses another record changed or links by number, served or proposed, naming each link: a link never comes
|
||||
to point at another clause.
|
||||
|
||||
## Where it keeps its records
|
||||
|
||||
In a database in the store: the module requires a `postgres-database` provision named `knowledge`, as
|
||||
|
||||
@@ -70,13 +70,6 @@ func held(r *Record, c Clause) string {
|
||||
if c.Unconfirmed != "" {
|
||||
s += " (the second judgement did not confirm it: " + c.Unconfirmed + ")"
|
||||
}
|
||||
if len(c.ChangedBy) > 0 {
|
||||
if c.Status == ClauseSuperseded {
|
||||
s += "; superseded by " + strings.Join(c.ChangedBy, ", ")
|
||||
} else {
|
||||
s += "; changed by " + strings.Join(c.ChangedBy, ", ") + ": read both"
|
||||
}
|
||||
}
|
||||
return s
|
||||
case r.Status == StatusProposed || c.Status == ClauseProposed:
|
||||
return "proposed: not in force"
|
||||
|
||||
@@ -503,15 +503,6 @@ func (k *KB) Return(caller string, key Key, reason string) (map[string]any, erro
|
||||
if r.Drafting == nil {
|
||||
return nil, fmt.Errorf("%s's clauses were written by its author, not drafted: a written clause is changed by a new record", key)
|
||||
}
|
||||
all, err := k.Store.All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if held := clauseHolds(r, all); len(held) > 0 {
|
||||
return nil, fmt.Errorf("%s is not returned: drafting it again would number its clauses anew, and these hold them to their numbers: %s. "+
|
||||
"A clause keeps its number once another record links it (novox/hq issue 494). To say a drafted clause is wrong "+
|
||||
"or no longer holds, propose a record that changes it (changes %s §N)", key, strings.Join(held, "; "), strings.TrimPrefix(key.String(), "ADR "))
|
||||
}
|
||||
r.Clauses = nil
|
||||
r.Drafting = &Drafting{State: DraftWaiting, Returned: reason, At: k.now()}
|
||||
why := "returned to be drafted again: " + reason
|
||||
@@ -521,36 +512,3 @@ func (k *KB) Return(caller string, key Key, reason string) (map[string]any, erro
|
||||
k.emit(EventReturned, r, caller, why, e.Versions+1)
|
||||
return map[string]any{"record": key.String(), "says": fmt.Sprintf("%s's clauses are taken back and will be drafted again, with your reason; until then it serves none.", key)}, nil
|
||||
}
|
||||
|
||||
// clauseHolds is what holds a record's clauses to their numbers: each clause another record changed or superseded,
|
||||
// and each link of another record, served or proposed, that names one of its clauses. A redraft numbers the clauses
|
||||
// anew, so a record any of these name is never returned: its links would point at other clauses.
|
||||
func clauseHolds(r *Record, all []*Entry) []string {
|
||||
var out []string
|
||||
for _, c := range r.Clauses {
|
||||
if len(c.ChangedBy) > 0 && (c.Status == ClauseChanged || c.Status == ClauseSuperseded) {
|
||||
out = append(out, fmt.Sprintf("§%d is %s by %s", c.N, c.Status, strings.Join(c.ChangedBy, ", ")))
|
||||
}
|
||||
}
|
||||
key := r.Key()
|
||||
seen := map[string]bool{}
|
||||
for _, e := range all {
|
||||
for _, v := range []*Record{e.Current, e.Pending} {
|
||||
if v == nil || v.Key() == key || v.Status == StatusWithdrawn {
|
||||
continue
|
||||
}
|
||||
for _, l := range v.Links {
|
||||
to, ok := l.To.Record()
|
||||
if !ok || to != key || l.To.Clause == 0 {
|
||||
continue
|
||||
}
|
||||
s := fmt.Sprintf("%s (%s) %s §%d", v.Key(), v.Status, l.Kind, l.To.Clause)
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 494: a record may change a clause the knowledge base holds only as drafted, and a record whose
|
||||
// clauses another record links by number is never drafted again, so no link ever points at another clause.
|
||||
|
||||
// draftedNotConfirmed drafts 0240 and has the second judgement reject its first clause: it stays drafted.
|
||||
func draftedNotConfirmed(t *testing.T, w *world) Key {
|
||||
t.Helper()
|
||||
k := Key{KindDecision, 240}
|
||||
draftUntil(t, w, k, DraftDrafted)
|
||||
e := mustGet(t, w.k.Store, k)
|
||||
r := e.Current.Clone()
|
||||
r.Clauses[0].Text += " NOT EXACT"
|
||||
if err := w.k.Store.Save(context.Background(), Version{By: "test", Why: "a draft the checker will reject", Record: r}, true, false, e.Revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = draftUntil(t, w, k, DraftDone)
|
||||
if r.Clauses[0].Source != SourceDrafted || !r.Accepted() {
|
||||
t.Fatalf("0240 §1 is not a drafted clause of an accepted record: %s %+v", r.Status, r.Clauses[0])
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func TestARecordMayChangeAClauseHeldOnlyAsDrafted(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
k := draftedNotConfirmed(t, w)
|
||||
n := w.reserved(t, "author@test")
|
||||
p := valid(n)
|
||||
p.Links = append(p.Links, LinkAsk{Kind: LinkChanges, To: "0240 §1", From: 2})
|
||||
if _, err := w.k.Propose("author@test", p); err != nil {
|
||||
t.Fatalf("a change of a drafted clause was refused: %v", err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
if e := mustGet(t, s, Key{KindDecision, n}); !e.Current.Accepted() {
|
||||
t.Fatalf("not accepted: %s", e.Current.Status)
|
||||
}
|
||||
// The drafted clause is changed by it, keeps its number and stays drafted: still never served as in force.
|
||||
r := mustGet(t, s, k).Current
|
||||
c := r.Clause(1)
|
||||
want := itoa4(n) + " §2"
|
||||
if c == nil || c.Status != ClauseChanged || c.Source != SourceDrafted || len(c.ChangedBy) != 1 || !strings.HasSuffix(c.ChangedBy[0], want) {
|
||||
t.Fatalf("0240 §1 after the change: %+v", c)
|
||||
}
|
||||
if Served(r, *c) {
|
||||
t.Fatal("a drafted clause that was changed is served as holding")
|
||||
}
|
||||
g, err := w.k.Get(GetAsk{Kind: KindDecision, Number: 240})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := g.Clauses[0].Holds; !strings.HasPrefix(h, "drafted, not confirmed") || !strings.Contains(h, "changed by ADR "+want) {
|
||||
t.Fatalf("get says of 0240 §1: %q", h)
|
||||
}
|
||||
// The whole-set rules find nothing wrong with the change.
|
||||
f, _ := s.Findings(context.Background())
|
||||
for _, x := range f {
|
||||
if x.Rule == "partial" {
|
||||
t.Fatalf("a whole-set finding: %+v", x)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestARecordWhoseClausesAreLinkedByNumberIsNeverReturned(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, s Store) {
|
||||
w := imported(t, s)
|
||||
k := draftedNotConfirmed(t, w)
|
||||
// A proposed record cites 0240 §2: returning 0240 would point it at whatever is drafted second next time.
|
||||
n := w.reserved(t, "author@test")
|
||||
p := valid(n)
|
||||
p.Links = append(p.Links, LinkAsk{Kind: LinkCites, To: "0240 §2", From: 1})
|
||||
w.judge.review = `{"verdict":"changes","findings":["not yet"]}`
|
||||
if _, err := w.k.Propose("author@test", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
before := mustGet(t, s, k)
|
||||
_, err := w.k.Return("operator@shanks", k, "clause 1 says more than the section")
|
||||
if err == nil {
|
||||
t.Fatal("a record a proposed record links by clause was returned")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ADR "+itoa4(n)+" (proposed) cites §2") || !strings.Contains(err.Error(), "changes 0240 §N") {
|
||||
t.Fatalf("the refusal does not say what holds the clauses or the way forward: %v", err)
|
||||
}
|
||||
after := mustGet(t, s, k)
|
||||
if after.Versions != before.Versions || len(after.Current.Clauses) != len(before.Current.Clauses) || len(w.said.of(EventReturned)) != 0 {
|
||||
t.Fatalf("a refused return changed 0240: %d versions (was %d), %d clauses", after.Versions, before.Versions, len(after.Current.Clauses))
|
||||
}
|
||||
|
||||
// A clause another record changed holds the record to its numbers too, with no link left to scan.
|
||||
w2 := imported(t, newMemStore())
|
||||
k2 := draftedNotConfirmed(t, w2)
|
||||
n2 := w2.reserved(t, "author@test")
|
||||
p2 := valid(n2)
|
||||
p2.Links = append(p2.Links, LinkAsk{Kind: LinkChanges, To: "0240 §1", From: 2})
|
||||
w2.accepted(t, "author@test", p2)
|
||||
if _, err := w2.k.Return("operator@shanks", k2, "again"); err == nil || !strings.Contains(err.Error(), "§1 is changed by ADR "+itoa4(n2)+" §2") {
|
||||
t.Fatalf("a record with a changed clause was returned, or the refusal does not name the change: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1010,8 +1010,7 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
{Name: Seat + ".return",
|
||||
Description: "Send the clauses drafted from a prose decision back to be drafted again, with your reason, which the next " +
|
||||
"drafting reads. Until they are drafted and confirmed again the record serves none. Refused: a record whose clauses its " +
|
||||
"author wrote, an empty reason, and a record whose clauses another record changed or links by number (a redraft " +
|
||||
"numbers them anew; a record that changes the drafted clause says it is wrong instead).",
|
||||
"author wrote, an empty reason.",
|
||||
Input: schema([]string{"number", "reason"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
|
||||
"reason": text("what is wrong with the drafted clauses")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
|
||||
@@ -48,7 +48,7 @@ var PerRecordRules = []Rule{
|
||||
{"self-link", "no link points at the record itself", "records.py supersession"},
|
||||
{"rests-on", "a record it extends is accepted or proposed, never superseded or withdrawn", "records.py rests-on"},
|
||||
{"supersedes-accepted", "a record it supersedes or changes is accepted", "records.py supersession, partial-supersession"},
|
||||
{"changes-in-force", "a clause it changes is in force or changed, never superseded or proposed; a drafted one may be changed", "records.py partial-supersession"},
|
||||
{"changes-in-force", "a clause it changes is in force or changed, never superseded, proposed or drafted", "records.py partial-supersession"},
|
||||
{"live-citation", "a superseded record it cites is cited beside the record that replaced it", "records.py live-citation"},
|
||||
{"became", "a graduated research effort names what it became", "cycle.py research"},
|
||||
{"text-links", "every link its text writes to a decision record or research effort names one the knowledge base holds", "records.py links"},
|
||||
@@ -261,10 +261,8 @@ func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusa
|
||||
if !target.Accepted() || target.Status == StatusSuperseded {
|
||||
refuse("supersedes-accepted", "%s changes %s, which is %s, not accepted", self, to, target.Status)
|
||||
} else if c := target.Clause(l.To.Clause); c != nil {
|
||||
// A drafted clause may be changed (novox/hq issue 494, ADR 0321): the change is recorded against the clause the
|
||||
// knowledge base holds, by its number, which nothing renumbers once another record links it (Return).
|
||||
if c.Status != ClauseInForce && c.Status != ClauseChanged {
|
||||
refuse("changes-in-force", "%s changes %s §%d, which is %s", self, to, l.To.Clause, c.Status)
|
||||
if (c.Status != ClauseInForce && c.Status != ClauseChanged) || c.Source == SourceDrafted {
|
||||
refuse("changes-in-force", "%s changes %s §%d, which is %s (%s)", self, to, l.To.Clause, c.Status, c.Source)
|
||||
}
|
||||
}
|
||||
case LinkCites:
|
||||
|
||||
@@ -27,25 +27,14 @@ Before any read, whatever the file modes would allow (ADR 0310 §5):
|
||||
names (`id_ed25519`, `*.key`, `ssh_host_*_key`); `/etc/shadow`; `/etc/ssl/private`, `/etc/wireguard`
|
||||
and the network manager's connections.
|
||||
|
||||
- **Histories and profiles**: shell and program histories (`.zsh_history`, `.bash_history`, `.psql_history`,
|
||||
`.viminfo` …), browser and mail profiles (`.mozilla`, `.config/google-chrome`, `.config/chromium` …), a
|
||||
process's `environ`, `mem`, `fd`, `map_files`, `root` and `cwd` under `/proc`, and `/etc/rancher` and
|
||||
`/etc/kubernetes/admin.conf`.
|
||||
|
||||
**What was opened is judged, not the name.** The type is checked before the open, so a device or a fifo is
|
||||
never opened; the file is opened non-blocking (in a search without following a link), checked again on its
|
||||
descriptor, and the path the kernel gives for that descriptor is judged by name like any path asked. A link
|
||||
flipped between the check and the open therefore reaches nothing a name refuses, and a deleted file is refused.
|
||||
**Limit:** a hard link to a secret file under another name is not seen by the name rule; the file's own mode,
|
||||
and the kernel's protected hard links, are what bound it. `stat` and `readlink` say a secret place's owner
|
||||
A link is judged by where it leads as well as by its name. `stat` and `readlink` say a secret place's owner
|
||||
and mode, and mark it, because those are not its content; `list`, `read` and `search` refuse it.
|
||||
|
||||
## The content rule
|
||||
|
||||
Every answer passes the content rule's secret class, from `mesh-sdk`'s `outward` package (ADR 0310 §4): a
|
||||
line of a file or a matching line shaped like a secret is withheld and its number said, as is a link target
|
||||
or an account's words. Every line of a `BEGIN … END` block — a private key's or a certificate's — is withheld whole, because one
|
||||
base64 line of a key can pass the rule alone. A path and an entry's name are not judged: they are what the caller named or what the
|
||||
or an account's words. A path and an entry's name are not judged: they are what the caller named or what the
|
||||
file system calls a thing, and the rule would take a temporary directory's or a container layer's name for a
|
||||
token.
|
||||
|
||||
@@ -57,7 +46,7 @@ token.
|
||||
| `readlink` | `readlink`, `readlink -f`, `realpath` | one path |
|
||||
| `list` | `ls`, `ls -la` | 2000 entries, cut and said |
|
||||
| `read` | `cat`, `head`, `tail`, `less` | 64 KiB, or up to 256 KiB when asked; cut at a line and said; text only |
|
||||
| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds (checked inside a file too); the named path's file system only, the others named; the bound met is said |
|
||||
| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds; the bound met is said |
|
||||
| `account` | `getent passwd`, `getent group`, `id`, `groups` | 2000 accounts and groups; never a password field |
|
||||
|
||||
No answer is larger than 1 MiB. Nothing here writes a file.
|
||||
|
||||
@@ -174,8 +174,8 @@ func tools(r Reader) []stdio.Tool {
|
||||
}),
|
||||
verb("search", "Find a fixed string (never a pattern) in one file or under one directory: every matching "+
|
||||
"line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories "+
|
||||
"deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds, on the file system of the path "+
|
||||
"named; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+
|
||||
"deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds; links are not followed and "+
|
||||
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+
|
||||
"a secret is withheld. The answer says which bound it met.",
|
||||
object(map[string]any{"text": str("the fixed string to find"),
|
||||
"path": str("an absolute path to a file or a directory"),
|
||||
|
||||
@@ -90,82 +90,6 @@ func why(err error) string {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// errRefusedByName marks a refusal by name, which a search names rather than counts as unreadable.
|
||||
var errRefusedByName = errors.New("refused by name")
|
||||
|
||||
// openJudged opens a path and judges what was opened, not its name (the review of #244: a link flipped between
|
||||
// the name check and the open read a secret place 3744 times in 20000): the type is checked before the open, so
|
||||
// a device or a fifo is never opened; the descriptor is opened non-blocking, without a controlling terminal, and
|
||||
// in a search without following a link; then it is checked again by fstat, and the path the kernel says it is
|
||||
// (/proc/self/fd) is judged by name like any path asked. A deleted file, or one the kernel names no path for, is
|
||||
// refused.
|
||||
func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileInfo, error) {
|
||||
stat := os.Stat
|
||||
if nofollow {
|
||||
stat = os.Lstat
|
||||
}
|
||||
before, err := stat(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||
}
|
||||
if err := wantType(path, before, dir); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
flags := os.O_RDONLY | syscall.O_NONBLOCK | syscall.O_NOCTTY | syscall.O_CLOEXEC
|
||||
if nofollow {
|
||||
flags |= syscall.O_NOFOLLOW
|
||||
}
|
||||
if dir {
|
||||
flags |= syscall.O_DIRECTORY
|
||||
}
|
||||
f, err := os.OpenFile(path, flags, 0)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err == nil {
|
||||
err = wantType(path, info, dir)
|
||||
}
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
real, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", f.Fd()))
|
||||
if err != nil || !strings.HasPrefix(real, "/") || strings.HasSuffix(real, " (deleted)") {
|
||||
f.Close()
|
||||
return nil, nil, fmt.Errorf("%s is refused: the kernel names no path for what was opened, so it cannot be "+
|
||||
"judged by name (novox/hq ADR 0310 §5)", path)
|
||||
}
|
||||
if why := r.Refused(real); why != "" {
|
||||
f.Close()
|
||||
return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0310 §5): %w", path, real, why, errRefusedByName)
|
||||
}
|
||||
return f, info, nil
|
||||
}
|
||||
|
||||
func wantType(path string, info fs.FileInfo, dir bool) error {
|
||||
switch {
|
||||
case dir && !info.IsDir():
|
||||
return fmt.Errorf("%s is a %s, not a directory", path, typeOf(info.Mode()))
|
||||
case !dir && !info.Mode().IsRegular():
|
||||
return fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceOf is the file system a path is on.
|
||||
func deviceOf(path string) (uint64, bool) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(st.Dev), true
|
||||
}
|
||||
|
||||
// ---- stat ----
|
||||
|
||||
// Component is one component of a path as lstat sees it.
|
||||
@@ -437,16 +361,10 @@ func (r Reader) List(raw string) (ListAnswer, error) {
|
||||
if err := r.refusedRead(path); err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
dir, _, err := r.openJudged(path, false, true)
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
entries, err := dir.ReadDir(-1)
|
||||
dir.Close()
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return ListAnswer{}, fmt.Errorf("%s cannot be listed: %s", path, why(err))
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
names := r.names()
|
||||
a := ListAnswer{Path: path, Count: len(entries), Entries: []Entry{}}
|
||||
if len(entries) > MaxEntries {
|
||||
@@ -521,11 +439,19 @@ func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) {
|
||||
if ask.Lines < 0 {
|
||||
return ReadAnswer{}, errors.New("lines is a count: name how many, or none for as many as fit")
|
||||
}
|
||||
f, info, err := r.openJudged(path, false, false)
|
||||
// Non-blocking, so a fifo answers rather than waits; and judged by what was opened, not by its name.
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return ReadAnswer{}, err
|
||||
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return ReadAnswer{}, fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode()))
|
||||
}
|
||||
a := ReadAnswer{Path: path, Size: info.Size()}
|
||||
lines, cut, err := pick(io.LimitReader(f, MaxScan), ask, bound)
|
||||
if err != nil {
|
||||
@@ -538,9 +464,6 @@ func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) {
|
||||
var text strings.Builder
|
||||
for _, l := range lines {
|
||||
s, what := clean(l.text)
|
||||
if l.block {
|
||||
s, what = "‹withheld: a key or certificate block›", "a key or certificate block"
|
||||
}
|
||||
if what != "" {
|
||||
a.Withheld = append(a.Withheld, l.n)
|
||||
a.Why = "a line shaped like a secret is withheld by the content rule (novox/hq ADR 0310 §4)"
|
||||
@@ -559,7 +482,6 @@ type line struct {
|
||||
text string
|
||||
newline bool
|
||||
size int
|
||||
block bool // inside a BEGIN … END block, whose every line is withheld
|
||||
}
|
||||
|
||||
// pick reads lines and keeps the ones asked, within bound bytes. A NUL byte is a binary file, refused.
|
||||
@@ -571,7 +493,6 @@ func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) {
|
||||
if from == 0 {
|
||||
from = 1
|
||||
}
|
||||
var blocks blockState
|
||||
for {
|
||||
raw, err := br.ReadString('\n')
|
||||
if raw == "" && err != nil {
|
||||
@@ -584,8 +505,7 @@ func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) {
|
||||
if strings.IndexByte(raw, 0) >= 0 {
|
||||
return nil, "", errors.New("it is a binary file, and read answers text")
|
||||
}
|
||||
l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw),
|
||||
block: blocks.in(raw)}
|
||||
l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw)}
|
||||
if from > 0 {
|
||||
if n < from {
|
||||
continue
|
||||
@@ -638,7 +558,6 @@ type SearchAnswer struct {
|
||||
Bound string `json:"bound-met,omitempty"`
|
||||
Skipped []string `json:"refused-by-name,omitempty"`
|
||||
Unreadable int `json:"unreadable,omitempty"`
|
||||
OtherFS []string `json:"other-file-systems-skipped,omitempty"`
|
||||
Binary int `json:"binary-skipped,omitempty"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
}
|
||||
@@ -688,7 +607,6 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
|
||||
}
|
||||
a := SearchAnswer{Text: ask.Text, Path: root, Matches: []Match{}}
|
||||
deadline := r.Now().Add(SearchTime)
|
||||
rootDev, hasDev := deviceOf(root)
|
||||
base := strings.Count(root, "/")
|
||||
if root == "/" {
|
||||
base = 0
|
||||
@@ -718,12 +636,6 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
|
||||
if p != root && strings.Count(p, "/")-base > depth {
|
||||
return fs.SkipDir
|
||||
}
|
||||
// The root's file system only, as grep -r with find's -xdev: a hard network mount would hang a
|
||||
// read past every bound, and /proc and /sys are searched only from within them.
|
||||
if dev, ok := deviceOf(p); p != root && hasDev && ok && dev != rootDev {
|
||||
a.OtherFS = append(a.OtherFS, p)
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
@@ -734,7 +646,7 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
|
||||
return errBound
|
||||
}
|
||||
a.Files++
|
||||
return r.searchFile(p, needle, ask.CaseInsensitive, matches, deadline, &a)
|
||||
return r.searchFile(p, needle, ask.CaseInsensitive, matches, &a)
|
||||
})
|
||||
if err != nil && !errors.Is(err, errBound) {
|
||||
return SearchAnswer{}, fmt.Errorf("%s cannot be searched: %s", root, why(err))
|
||||
@@ -742,14 +654,10 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time.Time, a *SearchAnswer) error {
|
||||
f, _, err := r.openJudged(p, true, false)
|
||||
func (r Reader) searchFile(p, needle string, fold bool, bound int, a *SearchAnswer) error {
|
||||
f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRefusedByName) {
|
||||
a.Skipped = append(a.Skipped, p)
|
||||
} else {
|
||||
a.Unreadable++
|
||||
}
|
||||
a.Unreadable++
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -758,7 +666,6 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
|
||||
a.Binary++
|
||||
return nil
|
||||
}
|
||||
var blocks blockState
|
||||
n := 0
|
||||
for {
|
||||
raw, err := br.ReadString('\n')
|
||||
@@ -766,11 +673,6 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
|
||||
return nil
|
||||
}
|
||||
n++
|
||||
if n%1000 == 0 && r.Now().After(deadline) {
|
||||
a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime)
|
||||
return errBound
|
||||
}
|
||||
block := blocks.in(raw)
|
||||
hay := raw
|
||||
if fold {
|
||||
hay = strings.ToLower(hay)
|
||||
@@ -784,14 +686,14 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
|
||||
if r := []rune(text); len(r) > MaxLine {
|
||||
text = string(r[:MaxLine]) + "…"
|
||||
}
|
||||
if s, what := clean(text); what != "" || block {
|
||||
if block {
|
||||
s, what = "‹withheld: a key or certificate block›", "a key or certificate block"
|
||||
file := p
|
||||
for _, f := range []*string{&text} {
|
||||
if s, what := clean(*f); what != "" {
|
||||
a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what})
|
||||
*f = s
|
||||
}
|
||||
a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what})
|
||||
text = s
|
||||
}
|
||||
a.Matches = append(a.Matches, Match{File: p, Line: n, Text: text})
|
||||
a.Matches = append(a.Matches, Match{File: file, Line: n, Text: text})
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -799,25 +701,6 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
|
||||
}
|
||||
}
|
||||
|
||||
// blockState follows BEGIN … END blocks through a file (the review of #244): a private key's body is base64 a
|
||||
// line at a time, and one line of it can pass the content rule alone, so every line from a BEGIN through its
|
||||
// END is withheld — a certificate's too, which costs nothing a read needs.
|
||||
type blockState struct{ open bool }
|
||||
|
||||
func (b *blockState) in(raw string) bool {
|
||||
switch {
|
||||
case strings.Contains(raw, "-----BEGIN "):
|
||||
b.open = !strings.Contains(raw, "-----END ")
|
||||
return true
|
||||
case b.open:
|
||||
if strings.Contains(raw, "-----END ") {
|
||||
b.open = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- accounts ----
|
||||
|
||||
// Account is one account as the account file says it, never its password field.
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -454,109 +450,3 @@ func TestItServesTheSeatsVerbs(t *testing.T) {
|
||||
t.Errorf("verbs: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of #244 (required 1): a link flipped between a harmless file and a secret place, while it is read
|
||||
// and searched, never answers the secret place's content — what was opened is judged, not the name.
|
||||
func TestALinkFlippedDuringAReadNeverReadsASecretPlace(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
secret := filepath.Join(dir, "var/lib/mesh-reads")
|
||||
r.Places = []string{secret}
|
||||
write(t, filepath.Join(secret, "broker"), "THE-BROKER-CREDENTIAL\n")
|
||||
write(t, filepath.Join(dir, "plain"), "THE-PLAIN-WORDS\n")
|
||||
link := filepath.Join(dir, "shared/link")
|
||||
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
targets := []string{filepath.Join(dir, "plain"), filepath.Join(secret, "broker")}
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
tmp := link + ".new"
|
||||
os.Remove(tmp)
|
||||
if os.Symlink(targets[i%2], tmp) == nil {
|
||||
os.Rename(tmp, link)
|
||||
}
|
||||
}
|
||||
}()
|
||||
leaked := 0
|
||||
for i := 0; i < 5000; i++ {
|
||||
if a, err := r.Read(link, ReadAsk{}); err == nil && strings.Contains(a.Text, "BROKER") {
|
||||
leaked++
|
||||
}
|
||||
if a, err := r.Search(SearchAsk{Text: "BROKER", Path: filepath.Dir(link)}); err == nil && len(a.Matches) > 0 {
|
||||
leaked++
|
||||
}
|
||||
}
|
||||
close(stop)
|
||||
<-done
|
||||
if leaked > 0 {
|
||||
t.Errorf("a secret place was answered %d times through a flipped link", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of #244 (required 2): every line of a private key's block is withheld, in a read and in a search,
|
||||
// though the file's name is not refused.
|
||||
func TestEveryLineOfAKeyBlockIsWithheld(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
key, err := rsa.GenerateKey(crand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
|
||||
p := filepath.Join(dir, "server.pem")
|
||||
write(t, p, "# the server's key\n"+body+"after = the block\n")
|
||||
a, err := r.Read(p, ReadAsk{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Count(body, "\n")
|
||||
for _, l := range strings.Split(strings.TrimSpace(body), "\n")[1:] {
|
||||
if strings.Contains(a.Text, l) && !strings.HasPrefix(l, "-----END") {
|
||||
t.Fatalf("a line of the key was answered: %q", l)
|
||||
}
|
||||
}
|
||||
if len(a.Withheld) != lines || !strings.Contains(a.Text, "after = the block") || !strings.Contains(a.Text, "the server's key") {
|
||||
t.Errorf("withheld %d of the block's %d lines; text:\n%s", len(a.Withheld), lines, a.Text)
|
||||
}
|
||||
// A search for a character every base64 line holds answers none of the key.
|
||||
m, err := r.Search(SearchAsk{Text: "A", Path: p, Matches: MaxMatches})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, x := range m.Matches {
|
||||
if !strings.HasPrefix(x.Text, "‹withheld") {
|
||||
t.Errorf("a search answered a line of the key: %q", x.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The review of #244 (required 3 and should): histories, browser profiles, a process's private entries and the
|
||||
// cluster credentials are refused by name.
|
||||
func TestHistoriesProfilesAndProcessesAreRefusedByName(t *testing.T) {
|
||||
var r Refuser
|
||||
for _, p := range []string{"/home/ops/.zsh_history", "/home/ops/.bash_history", "/root/.psql_history",
|
||||
"/home/ops/.viminfo", "/home/ops/.mozilla/firefox/x.default/logins.json",
|
||||
"/home/ops/.config/google-chrome/Default/Cookies", "/home/ops/.config/chromium/Default/Login Data",
|
||||
"/proc/self/environ", "/proc/1234/mem", "/proc/self/fd/3", "/proc/thread-self/root/etc/shadow",
|
||||
"/etc/rancher/k3s/k3s.yaml", "/etc/kubernetes/admin.conf"} {
|
||||
if r.Refused(p) == "" {
|
||||
t.Errorf("%s is not refused", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"/proc/mdstat", "/proc/self/status", "/home/ops/.config/i3/config", "/etc/hosts"} {
|
||||
if why := r.Refused(p); why != "" {
|
||||
t.Errorf("%s is refused: %s", p, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
// knownDirs are directories that hold nothing but credentials, wherever they are: an account's keys.
|
||||
var knownDirs = map[string]bool{
|
||||
".ssh": true, ".gnupg": true, ".password-store": true, ".aws": true, ".kube": true, ".azure": true,
|
||||
".mozilla": true, ".thunderbird": true, // browser and mail profiles: saved logins and cookies
|
||||
}
|
||||
|
||||
// knownNames are file names that are a credential whatever directory they are in.
|
||||
@@ -29,10 +28,6 @@ var knownNames = map[string]bool{
|
||||
"id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, "id_ecdsa_sk": true, "id_ed25519_sk": true,
|
||||
"shadow": true, "gshadow": true, "shadow-": true, "gshadow-": true, "opasswd": true,
|
||||
"broker": true, // the mesh's name for a module's bus credential
|
||||
// Shell and program histories: where a token is typed.
|
||||
".bash_history": true, ".zsh_history": true, ".histfile": true, ".python_history": true, ".psql_history": true,
|
||||
".mysql_history": true, ".lesshst": true, ".viminfo": true, ".node_repl_history": true, ".sqlite_history": true,
|
||||
".rediscli_history": true,
|
||||
}
|
||||
|
||||
// knownSuffixes are file name endings a credential has.
|
||||
@@ -42,17 +37,8 @@ var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".cred
|
||||
var knownPaths = []string{
|
||||
"/etc/ssl/private", "/etc/wireguard", "/etc/NetworkManager/system-connections", "/etc/ipsec.d/private",
|
||||
"/var/lib/iwd", "/etc/docker/key.json", "/root/.docker/config.json", "/var/lib/sss/secrets",
|
||||
"/etc/rancher", "/etc/kubernetes/admin.conf",
|
||||
}
|
||||
|
||||
// knownProfiles are directories under an account's .config that hold a browser's saved logins and cookies.
|
||||
var knownProfiles = map[string]bool{"google-chrome": true, "chromium": true, "BraveSoftware": true, "vivaldi": true,
|
||||
"microsoft-edge": true}
|
||||
|
||||
// procPrivate are the entries of a process under /proc that reach its memory, its environment or its files.
|
||||
var procPrivate = map[string]bool{"environ": true, "mem": true, "fd": true, "map_files": true, "root": true,
|
||||
"cwd": true}
|
||||
|
||||
// Refuser says whether a path is a place a secret is kept.
|
||||
type Refuser struct {
|
||||
// Places are the mesh's, from the controller: refused with everything beneath them.
|
||||
@@ -73,13 +59,7 @@ func (r Refuser) Refused(path string) string {
|
||||
}
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
||||
if len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]] {
|
||||
return "it reaches a process's " + parts[2]
|
||||
}
|
||||
for i, part := range parts {
|
||||
if i > 0 && parts[i-1] == ".config" && knownProfiles[part] {
|
||||
return "it is " + part + "'s profile, which keeps saved logins"
|
||||
}
|
||||
if knownDirs[part] {
|
||||
return "it is in " + part + ", which holds an account's credentials"
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ module mesh-reads
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2 h1:1E5iapAO1o4vkwV/8CUBQvNfL3OjXRtiQGBaKHXJVW8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23 h1:/F4NwABPAuFZlBx1yK4rdj6sVL5K1XvRlOnZZfNnlsQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
|
||||
@@ -148,10 +148,7 @@ new message is shown, so it rings once and no second message stands beside the f
|
||||
notification by its id; Telegram deletes the earlier message). A message said again goes alone, never as a
|
||||
line of a digest beside the message it follows; one first said inside a digest gets a message of its own the
|
||||
next time, and later words replace or edit that one. An ask's buttons live on that one message, so an ask that
|
||||
ends takes them away everywhere; its expiry is said with its day when it is not today. An ask replaced by a
|
||||
newer one about the same thing ends its own message too, which then says "Replaced by a newer question"; the
|
||||
newer ask shows a message of its own (issue 548). A message left open by an ask that already ended is ended on
|
||||
the next minute's sweep.
|
||||
ends takes them away everywhere; its expiry is said with its day when it is not today.
|
||||
|
||||
- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to
|
||||
the channel that reaches the operator away; an ask to every kind that carries one of its answers.
|
||||
@@ -190,8 +187,7 @@ The controller's `plan-moved` says each walk's start and end: `walk`, `phase` (`
|
||||
open, one line each; the ones that cleared counted. Delivered with nothing held and nothing open: one line —
|
||||
and nothing on a kind listed in the setting `only-what-needs-me`. What it carries is marked told on a kind
|
||||
only once the message was sent there. Until then each is kept owed in the state (`owed`): a message lost with a
|
||||
restart before it was sent leaves them untold and owed, and the restarted router says each on its own. A
|
||||
record that clears owes nothing more.
|
||||
restart before it was sent leaves them untold and owed, and the restarted router says each on its own.
|
||||
- **The bound:** at its start plus `bound_seconds` (900 when not given), what is held and still open is said,
|
||||
with "The walk … is still running, since HH:MM", and the cleared counted; the walk holds nothing more. A walk
|
||||
whose end never comes is forgotten a day after it started. A start heard after its end opens nothing: the
|
||||
|
||||
@@ -691,58 +691,30 @@ func (h *Holder) showClosed(rec AskRecord) {
|
||||
}
|
||||
told := h.toldLine(rec) // read before the lock: it reads the ask's record
|
||||
h.mu.Lock()
|
||||
ownMessage := r.Origin == "ask"
|
||||
// The first line is how it ended, which an ask's own message shows as its title (sayOn); the rest follows.
|
||||
switch {
|
||||
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
|
||||
r.Answered = chosenLine(rec.Warrant, rec.Closed.In(h.zone())) + "\n" + told
|
||||
case rec.State == string(asks.OutcomeExpired):
|
||||
r.Answered = "No longer answerable: nobody answered in time.\n" + strings.TrimSuffix(capital(rec.Ask.OnExpiry), ".") + "."
|
||||
case rec.State == string(asks.OutcomeReplaced) && !ownMessage:
|
||||
// On a condition's message, which the newer ask takes over and shows in place.
|
||||
r.Answered = ""
|
||||
case rec.State == string(asks.OutcomeReplaced):
|
||||
// Its own message: the newer ask looks for its message under its own key and opens one of its own, so
|
||||
// this one ends here, on every channel that showed it (issue 548).
|
||||
r.Answered = "Replaced by a newer question.\n" + rec.Asker + " asked again about the same thing; that question is the one to answer."
|
||||
r.Answered = ""
|
||||
default:
|
||||
r.Answered = "No longer needed."
|
||||
}
|
||||
ownMessage := r.Origin == "ask"
|
||||
h.mu.Unlock()
|
||||
if r.told() {
|
||||
h.edit(r, "answered", false)
|
||||
}
|
||||
if ownMessage {
|
||||
if ownMessage && rec.State != string(asks.OutcomeReplaced) {
|
||||
h.mu.Lock()
|
||||
r.Cleared = h.Now()
|
||||
r.Owed = "" // an ended question is owed to nobody (the review of PR 238)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
h.persist(r)
|
||||
}
|
||||
|
||||
// endLeftovers ends the own message of an ask that already ended and whose message stayed open: one left by a
|
||||
// router before issue 548 was fixed, whose replaced asks kept their messages for good. Its copies say how the
|
||||
// ask ended, as its end would have.
|
||||
func (h *Holder) endLeftovers() {
|
||||
h.mu.Lock()
|
||||
var ended []AskRecord
|
||||
for _, r := range h.open {
|
||||
if r.Origin != "ask" || r.Ask == "" || !r.Cleared.IsZero() {
|
||||
continue
|
||||
}
|
||||
if e := h.asks[r.Ask]; e != nil && e.rec.State != StateOpen && e.rec.Record == r.Key {
|
||||
ended = append(ended, e.rec)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
sort.Slice(ended, func(i, j int) bool { return ended[i].Record < ended[j].Record })
|
||||
for _, rec := range ended {
|
||||
h.Logf("[messenger] the message of %s stayed open after its ask ended (%s): ended now", rec.Record, rec.State)
|
||||
h.showClosed(rec)
|
||||
}
|
||||
}
|
||||
|
||||
// chosenLine is how a chosen ask ended, in the operator's words: "You chose Approve at 13:30 on Telegram."
|
||||
func chosenLine(w *asks.Warrant, at time.Time) string {
|
||||
who, where := "Somebody", ""
|
||||
@@ -803,7 +775,6 @@ func (h *Holder) sweepAsks(now time.Time) {
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.endLeftovers()
|
||||
sort.Strings(expired)
|
||||
for _, k := range expired {
|
||||
e := h.askOf(k)
|
||||
|
||||
@@ -687,7 +687,6 @@ func (h *Holder) cleared(key string, quiet bool) {
|
||||
}
|
||||
old.Cleared = now
|
||||
old.Pending = ""
|
||||
old.Owed = "" // what a walk held of it is owed no more: it ended (the review of PR 238)
|
||||
old.Folded = false
|
||||
old.Seen = now
|
||||
told := old.told()
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// openAskRecords are the records still open that show an ask as their own message.
|
||||
func openAskRecords(h *Holder) []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var out []string
|
||||
for k, r := range h.open {
|
||||
if r.Origin == "ask" && r.Cleared.IsZero() {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Issue 548: an ask replaced by a newer one about the same thing kept its own message open for good, its
|
||||
// buttons on the phone answering nothing. Two asks with the same About from one asker leave exactly one open
|
||||
// record, and the older one's message says it was replaced, its buttons gone, on every channel that showed it.
|
||||
func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
a := ackAsk(c, "d-1")
|
||||
a.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
settle(h, c)
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("the first ask was not shown: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
b := ackAsk(c, "d-2")
|
||||
b.About = "delivery.hq"
|
||||
b.Headline = "Delivery of hq held, asked again"
|
||||
submit(t, h, "mesh-delivery", b)
|
||||
settle(h, c)
|
||||
if got := openAskRecords(h); len(got) != 1 || got[0] != "ask.mesh-delivery.d-2" {
|
||||
t.Fatalf("open ask records: %v; want only the newer ask's", got)
|
||||
}
|
||||
for _, ch := range []*fakeChannel{tg, dt} {
|
||||
id := map[string]string{"telegram": "1", "desktop": "g14=1"}[ch.name]
|
||||
m, edited := ch.edits[id]
|
||||
for _, r := range ch.replaced {
|
||||
if r.id == id {
|
||||
m, edited = r.m, true
|
||||
}
|
||||
}
|
||||
if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
|
||||
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
|
||||
}
|
||||
}
|
||||
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
|
||||
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// The leftover of issue 548: a record from before the fix whose ask already ended keeps its message open. The
|
||||
// next tick ends it, as the ask's end would have.
|
||||
func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
a := ackAsk(c, "d-1")
|
||||
a.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
settle(h, c)
|
||||
// Ended as replaced the way the router did before the fix: the ask's record only, its message untouched.
|
||||
key := "mesh-delivery.d-1"
|
||||
rec := store.asks[key]
|
||||
rec.State, rec.Closed, rec.Told = string(asks.OutcomeReplaced), c.now(), true
|
||||
rec.Warrant = &asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", About: a.About, Outcome: asks.OutcomeReplaced,
|
||||
Words: "a newer ask about the same thing replaced it", At: c.now()}
|
||||
store.asks[key] = rec
|
||||
h.keepAsk(key, rec, 99)
|
||||
if got := openAskRecords(h); len(got) != 1 {
|
||||
t.Fatalf("the leftover is not set up: %v", got)
|
||||
}
|
||||
c.pass(time.Minute)
|
||||
stand(h, c)
|
||||
h.Tick()
|
||||
h.Flush()
|
||||
if got := openAskRecords(h); len(got) != 0 {
|
||||
t.Fatalf("the message of an ended ask is still open: %v", got)
|
||||
}
|
||||
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
|
||||
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
|
||||
}
|
||||
}
|
||||
|
||||
// The review of PR 238: what a walk owed of a record is dropped when the record clears, so a router that
|
||||
// restarts never says a held message of something that already ended.
|
||||
func TestAClearedRecordOwesNothing(t *testing.T) {
|
||||
h, _, _, c, st := newHolder(t)
|
||||
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
|
||||
k.Raised = c.now()
|
||||
h.Condition(EventRaised, k)
|
||||
h.mu.Lock()
|
||||
h.open[k.Key].Owed = "raised"
|
||||
h.mu.Unlock()
|
||||
h.cleared(k.Key, false)
|
||||
if r := h.open[k.Key]; r.Owed != "" {
|
||||
t.Errorf("a cleared record still owes %q", r.Owed)
|
||||
}
|
||||
if r := st.recs[k.Key]; r.Owed != "" {
|
||||
t.Errorf("a cleared record still owes %q in the state", r.Owed)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func limitOf(a map[string]any, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func tools(monitor Monitor, users func(context.Context) ([]byte, error), written func(context.Context) (time.Time, error)) []stdio.Tool {
|
||||
func tools(monitor Monitor, users func(context.Context) ([]byte, error)) []stdio.Tool {
|
||||
get := func(path string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
@@ -123,28 +123,20 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
|
||||
return Users(c, arg(a, "user")), nil
|
||||
}},
|
||||
{Name: "nats_user_can",
|
||||
Description: "Whether a bus user may publish or subscribe to a subject, and the grant that allows it — the question behind a request that timed out because the bus refused it. Also whether the running server holds it: `in_force` is true only when the user list on the machine allows it and the server reloaded its configuration after that list was written (novox/hq issue 353: a list sent is not a list the bus enforces until it reloads).",
|
||||
Description: "Whether a bus user may publish or subscribe to a subject, and the grant that allows it — the question behind a request that timed out because the bus refused it.",
|
||||
Input: map[string]any{"user": str("the user exactly, e.g. g14.node-tools"), "action": str("publish or subscribe"), "subject": str("a concrete subject, e.g. $KV.claude-code_servers.all.x")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
c, err := conf()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer, err := UserCan(c, arg(a, "user"), arg(a, "action"), arg(a, "subject"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
at, werr := written(ctx)
|
||||
varz, verr := get("/varz")
|
||||
return InForce(answer, at, werr, varz, verr), nil
|
||||
return UserCan(c, arg(a, "user"), arg(a, "action"), arg(a, "subject"))
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(LiveMonitor(), ReadUsers, UsersWritten)); err != nil {
|
||||
if err := stdio.Serve("", tools(LiveMonitor(), ReadUsers)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -65,24 +65,3 @@ func ReadUsers(ctx context.Context) ([]byte, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UsersWritten is when the composed user list the server includes was last written, read in the container
|
||||
// to the nanosecond (`stat -c %y`): what the server's last reload must come after for the list to be in force.
|
||||
func UsersWritten(ctx context.Context) (time.Time, error) {
|
||||
container := env("MESH_NATS_CONTAINER", "mesh-broker-nats")
|
||||
path := env("MESH_NATS_USERS_FILE", "/etc/nats/accounts.conf")
|
||||
out, err := exec.CommandContext(ctx, "docker", "exec", container, "stat", "-c", "%y", path).Output()
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("when the bus's user list was written could not be read inside %s: %w", container, err)
|
||||
}
|
||||
return ParseWritten(string(out))
|
||||
}
|
||||
|
||||
// ParseWritten reads `stat -c %y`: "2026-10-09 17:38:13.422211788 +0000".
|
||||
func ParseWritten(s string) (time.Time, error) {
|
||||
t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700", strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("when the bus's user list was written is not a time: %q", strings.TrimSpace(s))
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -536,39 +536,3 @@ func UserCan(conf []byte, user, action, subject string) (map[string]any, error)
|
||||
}
|
||||
return nil, fmt.Errorf("the bus has no user %q; nats_users lists them", user)
|
||||
}
|
||||
|
||||
// InForce adds to a UserCan answer whether the running server holds what the file says (novox/hq issue 353):
|
||||
// `in_force` only when the grant is allowed in the file and the server's last configuration load
|
||||
// (`config_load_time` of /varz) came after the file was last written. Anything not known is not in force, said
|
||||
// in `why`: an asker waiting for its grant must not take a list sent for a list the bus enforces.
|
||||
func InForce(answer map[string]any, written time.Time, writtenErr error, varz []byte, varzErr error) map[string]any {
|
||||
answer["in_force"] = false
|
||||
switch {
|
||||
case writtenErr != nil:
|
||||
answer["why"] = writtenErr.Error()
|
||||
return answer
|
||||
case varzErr != nil:
|
||||
answer["why"] = "the server's last configuration load could not be read: " + varzErr.Error()
|
||||
return answer
|
||||
}
|
||||
var v struct {
|
||||
ConfigLoadTime time.Time `json:"config_load_time"`
|
||||
}
|
||||
if err := json.Unmarshal(varz, &v); err != nil || v.ConfigLoadTime.IsZero() {
|
||||
answer["why"] = "the server does not say when it last loaded its configuration"
|
||||
return answer
|
||||
}
|
||||
answer["file_written"] = written.UTC().Format(time.RFC3339Nano)
|
||||
answer["server_loaded"] = v.ConfigLoadTime.UTC().Format(time.RFC3339Nano)
|
||||
loaded := !v.ConfigLoadTime.Before(written)
|
||||
allowed, _ := answer["allowed"].(bool)
|
||||
switch {
|
||||
case !allowed:
|
||||
answer["why"] = "the user list on the machine does not allow it"
|
||||
case !loaded:
|
||||
answer["why"] = "the user list on the machine allows it, and the server has not reloaded since it was written"
|
||||
default:
|
||||
answer["in_force"] = true
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -172,39 +170,3 @@ func TestClosedConnectionsArePerUserAndNameAndSayWhichWereDropped(t *testing.T)
|
||||
t.Errorf("narrowed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 353: a grant written to the user list is in force only once the server reloaded after the
|
||||
// write; anything not known is not in force.
|
||||
func TestAGrantIsInForceOnlyWhenTheServerReloadedAfterTheListWasWritten(t *testing.T) {
|
||||
written, err := ParseWritten("2026-10-09 17:55:56.422211788 +0200\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
varz := func(at string) []byte { return []byte(`{"config_load_time":"` + at + `"}`) }
|
||||
allowed := func() map[string]any { return map[string]any{"allowed": true} }
|
||||
cases := []struct {
|
||||
name string
|
||||
answer map[string]any
|
||||
werr error
|
||||
varz []byte
|
||||
verr error
|
||||
want bool
|
||||
says string
|
||||
}{
|
||||
{"reloaded after the write", allowed(), nil, varz("2026-10-09T15:56:05.1Z"), nil, true, ""},
|
||||
{"reloaded before the write", allowed(), nil, varz("2026-10-09T15:55:56.4Z"), nil, false, "has not reloaded"},
|
||||
{"not allowed in the file", map[string]any{"allowed": false}, nil, varz("2026-10-09T15:56:05Z"), nil, false, "does not allow"},
|
||||
{"the write time unknown", allowed(), errors.New("no container"), varz("2026-10-09T15:56:05Z"), nil, false, "no container"},
|
||||
{"the server unknown", allowed(), nil, nil, errors.New("refused"), false, "could not be read"},
|
||||
{"a server that says no load time", allowed(), nil, []byte(`{}`), nil, false, "does not say"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := InForce(c.answer, written, c.werr, c.varz, c.verr)
|
||||
if got["in_force"] != c.want || (c.says != "" && !strings.Contains(fmt.Sprint(got["why"]), c.says)) {
|
||||
t.Errorf("%s: %v", c.name, got)
|
||||
}
|
||||
}
|
||||
if _, err := ParseWritten("yesterday"); err == nil {
|
||||
t.Error("a write time that is not one was read")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user