Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2b6aacd8 | ||
|
|
994cab533b |
@@ -19,3 +19,5 @@ 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
|
||||
|
||||
@@ -65,11 +65,6 @@ type ChecksOf struct {
|
||||
Since string `json:"since"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
// Class, Opened and MergedAt are what the issue tracker measures a fix's time by (novox/hq ADR 0313): the
|
||||
// class of what it moves, when its pull request was first heard, and when it merged.
|
||||
Class string `json:"class,omitempty"`
|
||||
Opened string `json:"opened,omitempty"`
|
||||
MergedAt string `json:"merged_at,omitempty"`
|
||||
}
|
||||
|
||||
// Checks is the `checks` verb: a repository and a pull request's number, or a repository and a commit.
|
||||
@@ -109,13 +104,7 @@ func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAn
|
||||
}
|
||||
if d != nil {
|
||||
a.Delivery = &ChecksOf{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), Waits: d.waits,
|
||||
Group: d.Group, Class: fixClass(&d.Delivery)}
|
||||
if !d.opened.IsZero() {
|
||||
a.Delivery.Opened = d.opened.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !d.MergedAt.IsZero() {
|
||||
a.Delivery.MergedAt = d.MergedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
Group: d.Group}
|
||||
if d.Check != nil && sameCommit(d.Commit, a.Commit) {
|
||||
a.Verdict = d.Check
|
||||
}
|
||||
@@ -150,7 +139,6 @@ type checksDelivery struct {
|
||||
Delivery
|
||||
waits string
|
||||
groupSaid string
|
||||
opened time.Time
|
||||
}
|
||||
|
||||
// deliveryOf is the delivery of a commit — its head, or the commit it merged as — else, for a pull request,
|
||||
@@ -179,7 +167,7 @@ func (h *Holder) deliveryOf(repository string, number int, commit string) *check
|
||||
if found == nil {
|
||||
return nil
|
||||
}
|
||||
c := &checksDelivery{Delivery: *found, waits: h.waitsFor(found), opened: h.openedOf(found)}
|
||||
c := &checksDelivery{Delivery: *found, waits: h.waitsFor(found)}
|
||||
if found.Check != nil {
|
||||
v := *found.Check
|
||||
c.Check = &v
|
||||
|
||||
@@ -14,10 +14,8 @@ import (
|
||||
// the forge's holder, asked through its tools). Owed, never lost: kept with the delivery before anything is
|
||||
// tried, and tried again until done.
|
||||
|
||||
// transitionEvent is what `mesh-delivery.transition` says: no secret, no address. opened is when its pull
|
||||
// request was first heard (zero when not known); with its class and its merge's moment it is what the issue
|
||||
// tracker measures a fix's time by (novox/hq ADR 0313 §2, §4).
|
||||
func transitionEvent(d *Delivery, t Transition, opened time.Time) map[string]any {
|
||||
// transitionEvent is what `mesh-delivery.transition` says: no secret, no address.
|
||||
func transitionEvent(d *Delivery, t Transition) map[string]any {
|
||||
e := map[string]any{"id": d.ID, "repository": d.Repository, "commit": d.Commit, "from": t.From, "to": t.To,
|
||||
"event": t.Event, "why": t.Why, "at": t.At}
|
||||
if t.By != "" {
|
||||
@@ -38,57 +36,9 @@ func transitionEvent(d *Delivery, t Transition, opened time.Time) map[string]any
|
||||
if d.Plan != nil {
|
||||
e["plan"] = d.Plan.Summary
|
||||
}
|
||||
if c := fixClass(d); c != "" {
|
||||
e["class"] = c
|
||||
}
|
||||
if !opened.IsZero() {
|
||||
e["opened"] = opened.UTC()
|
||||
}
|
||||
if !d.MergedAt.IsZero() {
|
||||
e["merged_at"] = d.MergedAt.UTC()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// fixClass is the class of what a delivery moves (novox/hq ADR 0313 §2): its plan's, as the planner said it;
|
||||
// none for a plan that moves no module, from a planner that did not say a class; else its walk's class once its
|
||||
// delivery time is known; empty when nothing says it.
|
||||
func fixClass(d *Delivery) string {
|
||||
switch {
|
||||
case d.Plan != nil && d.Plan.Class != "":
|
||||
return d.Plan.Class
|
||||
case d.Plan != nil && len(d.Plan.Moved) == 0 && len(d.Plan.Dependents) == 0 && len(d.Plan.New) == 0:
|
||||
return classNone
|
||||
case d.Time != nil:
|
||||
return d.Time.Class
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// classNone is the class of a delivery that moves no module: nothing for a walk to move, and no fix budget.
|
||||
const classNone = "none"
|
||||
|
||||
// openedOf is when a delivery's pull request was first heard: the earliest head of it this owner holds, never
|
||||
// one made from the forge's word at its merge; zero when none is held or it has no pull request. Under the lock.
|
||||
func (h *Holder) openedOf(d *Delivery) time.Time {
|
||||
if d.Number <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
var first time.Time
|
||||
for _, c := range h.deliveries {
|
||||
if c.FromForge || c.Number != d.Number || !strings.EqualFold(c.Repository, d.Repository) || c.Created.IsZero() {
|
||||
continue
|
||||
}
|
||||
if first.IsZero() || c.Created.Before(first) {
|
||||
first = c.Created
|
||||
}
|
||||
}
|
||||
if first.IsZero() && !d.FromForge {
|
||||
first = d.Created
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// noteLine is one transition as the commit's note keeps it.
|
||||
func noteLine(d *Delivery, t Transition) string {
|
||||
by := ""
|
||||
|
||||
@@ -172,10 +172,9 @@ func (h *Holder) refuse(err error) {
|
||||
// moved records what a delivery's transitions owe outside, and keeps it.
|
||||
func (h *Holder) moved(d *Delivery, ts []Transition) {
|
||||
now := h.Now()
|
||||
opened := h.openedOf(d)
|
||||
for _, t := range ts {
|
||||
h.Logf("[mesh-delivery] %s: %s → %s (%s): %s", d.ID, orNothing(t.From), t.To, t.Event, t.Why)
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Line: string(mustJSON(transitionEvent(d, t, opened))), Since: now})
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Line: string(mustJSON(transitionEvent(d, t))), Since: now})
|
||||
line := noteLine(d, t)
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now})
|
||||
if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) && afterTheTrunk(t.To) {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 403, ADR 0313: the issue tracker measures a fix's time from the move to fixing to delivered and
|
||||
// splits it at the pull request's opening and its merge. A transition says what it reads them by: when the pull
|
||||
// request was first heard (its first head, not a later one), when it merged, and the class of what it moves —
|
||||
// core, leaf, or none for a change that moves no module. `checks` says the same for a fix the tracker hears of late.
|
||||
|
||||
// transitionsTo are the transition events said for a delivery that moved to a state.
|
||||
func transitionsTo(w *world, id string, to State) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, e := range w.said {
|
||||
if e["event-kind"] == "transition" && e["id"] == id && e["to"] == string(to) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestATransitionSaysWhenItsPullRequestOpenedMergedAndItsClass(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
repo := "novox/mesh-catalog"
|
||||
opened := w.now
|
||||
first := pr(repo, 9, "head9first", "fix/403-x", "modules/app/a.go")
|
||||
w.h.PullUpdated(first)
|
||||
w.later(3 * time.Minute)
|
||||
second := pr(repo, 9, "head9second", "fix/403-x", "modules/app/a.go")
|
||||
w.h.PullUpdated(second)
|
||||
c := verdictMoving(repo, 9, "head9second", "app")
|
||||
c.Plan.Class = "core"
|
||||
w.h.Checked(c)
|
||||
w.later(2 * time.Minute)
|
||||
mergedWhen := w.now
|
||||
w.h.PullMerged(mergedAt(second, "merge9aaaaaa", mergedWhen))
|
||||
walk := aWalk("plan-merge9", repo, "merge9aaaaaa", false)
|
||||
walk.Delivery = &WalkDelivery{Merges: []WalkMerge{{Repository: repo, Commit: "merge9aaaaaa"}}}
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
w.settleAll()
|
||||
walk = w.ctl.walk(walk.ID)
|
||||
walk.State, walk.Revision = walkDone, walk.Revision+1
|
||||
walk.Modules = map[string]*WalkModule{"app": {State: "built"}}
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
w.settleAll()
|
||||
|
||||
id := IDOf(repo, "head9second")
|
||||
if s := w.state(id); s != Delivered {
|
||||
t.Fatalf("the merge is %s, not delivered", s)
|
||||
}
|
||||
said := transitionsTo(w, id, Delivered)
|
||||
if len(said) == 0 {
|
||||
t.Fatalf("no transition to delivered was said: %v", w.said)
|
||||
}
|
||||
e := said[len(said)-1]
|
||||
if e["opened"] != opened.UTC().Format(time.RFC3339) {
|
||||
t.Errorf("opened %v, want the first head's %s (not the second head's)", e["opened"], opened.Format(time.RFC3339))
|
||||
}
|
||||
if e["merged_at"] != mergedWhen.UTC().Format(time.RFC3339) {
|
||||
t.Errorf("merged_at %v, want %s", e["merged_at"], mergedWhen.Format(time.RFC3339))
|
||||
}
|
||||
if e["class"] != "core" {
|
||||
t.Errorf("class %v, want the plan's core", e["class"])
|
||||
}
|
||||
|
||||
a, err := w.h.Checks(repo, 9, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Delivery == nil || a.Delivery.Opened != opened.UTC().Format(time.RFC3339) || a.Delivery.Class != "core" ||
|
||||
a.Delivery.MergedAt != mergedWhen.UTC().Format(time.RFC3339) {
|
||||
t.Errorf("checks says %+v", a.Delivery)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan that moves no module is class none, so the tracker gives its fix no budget; a plan from a controller that
|
||||
// says no class falls back to its walk's class once known, and says nothing before.
|
||||
func TestAFixClassIsNoneWhenItMovesNothing(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
why string
|
||||
d Delivery
|
||||
want string
|
||||
}{
|
||||
{"the planner's class", Delivery{Plan: &DeliveryPlan{Moved: []string{"app"}, Class: "leaf"}}, "leaf"},
|
||||
{"a plan that moves nothing, no class said", Delivery{Plan: planMoving("novox/x")}, "none"},
|
||||
{"no class said, its walk's known", Delivery{Plan: planMoving("novox/x", "app"), Time: &DeliveryTime{Class: "core"}}, "core"},
|
||||
{"no class said, nothing known", Delivery{Plan: planMoving("novox/x", "app")}, ""},
|
||||
} {
|
||||
if got := fixClass(&c.d); got != c.want {
|
||||
t.Errorf("%s: %q, want %q", c.why, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,9 +165,6 @@ type DeliveryPlan struct {
|
||||
Machines []MachinePlan `json:"machines,omitempty"`
|
||||
Steps []string `json:"steps,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
// Class is the class of the slowest-budgeted module it moves, as the planner says it: core, leaf, or none when
|
||||
// it moves no module (novox/hq ADR 0313 §2). Empty in a plan from a controller that did not say it.
|
||||
Class string `json:"class,omitempty"`
|
||||
}
|
||||
|
||||
// receiving are the machines the plan sends a module to, in the plan's order; none when the plan names no
|
||||
|
||||
@@ -28,7 +28,6 @@ what it refuses.
|
||||
| `groom` | optionally `pass`, `confirm` | the triage over the backlog: without arguments a dry run in the background, answering its pass id; with `pass` its progress and findings; with `confirm` and `pass` exactly that pass's findings applied, each checked again; see below |
|
||||
| `tracking` | optionally `conditions` (condition keys) | for every condition key an issue links (link kind `condition`), the issues that link it, those not closed first: status, severity, needs, worker with since, active and stale, and where the fix is (`fix`); the operator's channel says it in a condition's message (hq ADR 0285) |
|
||||
| `reserve` | `kind` (`decision` or `research`), optionally `for` (one line: what the record is about) | the next number of that kind, spent forever; answers `kind` and `number` (and `prefix`, the number as the record's name starts with it); see below |
|
||||
| `fix-times` | optionally `days` (1 to 90, default 7) | each class's fix budget, the median and worst fix time of the issues whose last fix was delivered in the window, within and over, and every issue over its budget with its longest phase; see *A fix's time* |
|
||||
|
||||
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
|
||||
folders until the import, and hq ADR 0284): a status is one of `open`,
|
||||
@@ -143,38 +142,6 @@ acceptance live, so a fix whose own walk failed and that a later merge carried i
|
||||
issue resolved or wontfix is never said stuck. A module that runs only
|
||||
its bundle cannot raise a controller condition (hq ADR 0240 rule 8, ADR 0256), so the warning is the channel's, not the controller's.
|
||||
|
||||
## A fix's time (hq ADR 0313)
|
||||
|
||||
An issue's **fix time** runs from its first move to `fixing` (kept as `fixing-from`; a take-over or a later move
|
||||
back to `fixing` does not restart it) to the moment the last of its fixes is delivered. An issue moved before the
|
||||
moment was kept has it read from its log: a move's own moment, or, for a value carried into the log after one at
|
||||
another status, its worker's `active` (the move), never its worker's `since`, which a diagnosis first keeps. Such a
|
||||
fix time says `from-log`: it is kept and shown, and never warned on, so the first fixes heard of after the tracker
|
||||
starts measuring raise no warning for work done before it. Once every fix it names is delivered, `get` carries it as `fix-time`:
|
||||
`from`, `end`, `took`, the issue's `class`, its **fix budget** (`budget`: five minutes for a leaf module, ten for a
|
||||
core one, counted from the move to `fixing`), `over`, the fix delivered last (`fix`), the `worker`, and four
|
||||
`phases` read from that fix, which add up to the fix time:
|
||||
|
||||
| phase | ends at | read from |
|
||||
|---|---|---|
|
||||
| implementing | its pull request opened | mesh-delivery's `opened` (the pull request's first head heard) |
|
||||
| review | the first approval at its head | the forge's reviews (`gitea_pull_reviews`): the forge's own approval, or a comment review whose first line is `Verdict: approve at <sha>`, the sha the head it was posted at or a prefix of at least seven characters (the forge refuses an approval of one's own pull request, so the mesh's independent reviews are comments) |
|
||||
| checks | its merge, required checks and any wait for the merge included | mesh-delivery's `merged_at` |
|
||||
| merge to running | delivered | the delivery's `delivered` (its delivery time) |
|
||||
|
||||
A phase whose end came before the end of the phase before it counts zero; a phase whose end is not known (no
|
||||
approval at the head on the forge, a delivery that did not say when the pull request opened) is `unknown`, and its
|
||||
time counts in the next phase, which says so. The class is that of the slowest-budgeted module the fixes move, as
|
||||
mesh-delivery says it from the delivery plan (`class`: `core`, `leaf`, or `none` for a fix that moves no module);
|
||||
an issue whose fixes move no module, or whose class is not said, is measured and shown with no fix budget.
|
||||
|
||||
**Over its budget is loud.** When the last fix is delivered and the fix time exceeds the class's fix budget, the
|
||||
operator's channel is told (`seat:operator-channel.notify`, key `issue-tracker.fix.<class>.over-budget`), naming the
|
||||
issue, its worker and its longest phase. Nothing is said while a fix is undelivered: a merged fix not delivered is
|
||||
loud as a stuck fix. The warning clears when the next issue of that class is delivered within its budget; the
|
||||
newest fix time of each class decides, and every sweep says or clears what a failed ask left. The delivery budget
|
||||
(mesh-delivery's `times`, from the merge) stays a measure inside the fix budget.
|
||||
|
||||
## The import of hq's issues (slice 3)
|
||||
|
||||
`issue-tracker.import` reads every `04-ISSUES/NNN-slug/00-report.md` of hq **through the records module
|
||||
@@ -397,13 +364,6 @@ reserve spending nothing, the first reserve refused while the knowledge base doe
|
||||
set, a kind held none of being no answer, hq's folders never read, and a
|
||||
reservation that cannot be kept leaving its number spent.
|
||||
|
||||
A fix's time is tested in `fixtime_test.go`: the start at the first move to `fixing` through a diagnosis, a
|
||||
take-over and a move away and back; phases that add up to the fix time, one ended early counting zero and one not
|
||||
known counted in the next; the class of the slowest fix, none and not known with no budget; no warning while
|
||||
undelivered, the warning once delivered over budget, cleared by the next of its class within budget and not by
|
||||
another class, and said at the next sweep when the channel did not take it; `fix-times` over a window, an issue
|
||||
before it not counted; the start read from the log; and the first approval at the head.
|
||||
|
||||
The import is tested over `testdata/hq`, copies of seven real reports of hq chosen for their shapes (a
|
||||
bracketed list with commas inside parentheses, quoted block items, an apostrophe in prose, a `resolved:`
|
||||
date, a replay, an empty list, a wontfix): the frontmatter read as written, each issue created at its own
|
||||
|
||||
@@ -145,7 +145,7 @@ func (t *Tracker) askUnheard() error {
|
||||
if since.IsZero() {
|
||||
since = t.now()
|
||||
}
|
||||
if _, err := t.recordFix(pr, d.heard(since), n); err != nil {
|
||||
if _, err := t.recordFix(pr, d.State, d.Why, d.Merged, since, n); err != nil {
|
||||
return err
|
||||
}
|
||||
heard = true
|
||||
|
||||
@@ -37,34 +37,6 @@ type Fix struct {
|
||||
Since time.Time `json:"since"`
|
||||
// StuckSaid says it was said stuck, on the issue and to the operator's channel.
|
||||
StuckSaid bool `json:"stuck-said,omitempty"`
|
||||
// Class is the class of what its delivery moves, as mesh-delivery says it: core, leaf, or none when it moves
|
||||
// no module; Opened when its pull request was first heard (novox/hq ADR 0313 §2, §4).
|
||||
Class string `json:"class,omitempty"`
|
||||
Opened *time.Time `json:"opened,omitempty"`
|
||||
}
|
||||
|
||||
// heardFix is what the delivery said of a pull request at one moment: its state, why, whether it is merged, and
|
||||
// what a fix's time is read by — the class of what it moves, when its pull request opened and when it merged,
|
||||
// each when said.
|
||||
type heardFix struct {
|
||||
State string
|
||||
Why string
|
||||
Merged bool
|
||||
At time.Time
|
||||
Class string
|
||||
Opened *time.Time
|
||||
MergedAt *time.Time
|
||||
}
|
||||
|
||||
// news says whether what was heard adds anything to what a fix keeps.
|
||||
func (h heardFix) news(f *Fix, kept string) bool {
|
||||
if f.State != h.State || f.Why != kept || (h.Merged && !f.Merged) || (h.Class != "" && h.Class != f.Class) {
|
||||
return true
|
||||
}
|
||||
if h.Opened != nil && (f.Opened == nil || !f.Opened.Equal(*h.Opened)) {
|
||||
return true
|
||||
}
|
||||
return h.MergedAt != nil && (f.MergedAt == nil || !f.MergedAt.Equal(*h.MergedAt))
|
||||
}
|
||||
|
||||
// reached says whether a delivery's state says the change reached the machines (mesh-delivery's Reached).
|
||||
@@ -175,8 +147,7 @@ func DeliveryLine(is *Issue) string {
|
||||
|
||||
// recordFix keeps what the delivery said of a pull request on every issue whose fixed-by names it (only, when
|
||||
// given, on that one issue), and answers the issues it changed.
|
||||
func (t *Tracker) recordFix(pr string, h heardFix, only uint64) ([]uint64, error) {
|
||||
state, why, merged, at := h.State, h.Why, h.Merged, h.At
|
||||
func (t *Tracker) recordFix(pr, state, why string, merged bool, at time.Time, only uint64) ([]uint64, error) {
|
||||
all, err := t.allIssues()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -187,7 +158,7 @@ func (t *Tracker) recordFix(pr string, h heardFix, only uint64) ([]uint64, error
|
||||
continue
|
||||
}
|
||||
kept := cut(why, MostReasonKept)
|
||||
if f := fixOf(is, pr); f != nil && (at.Before(f.Since) || !h.news(f, kept)) {
|
||||
if f := fixOf(is, pr); f != nil && (at.Before(f.Since) || (f.State == state && f.Why == kept && f.Merged == (merged || f.Merged))) {
|
||||
// Older than what is kept, or nothing new: transitions can arrive out of order.
|
||||
continue
|
||||
}
|
||||
@@ -208,17 +179,6 @@ func (t *Tracker) recordFix(pr string, h heardFix, only uint64) ([]uint64, error
|
||||
m := at
|
||||
f.MergedAt = &m
|
||||
}
|
||||
if h.MergedAt != nil && (merged || f.Merged) {
|
||||
m := h.MergedAt.UTC()
|
||||
f.MergedAt = &m // the delivery's own moment of the merge, not when it was heard
|
||||
}
|
||||
if h.Class != "" {
|
||||
f.Class = h.Class
|
||||
}
|
||||
if h.Opened != nil {
|
||||
o := h.Opened.UTC()
|
||||
f.Opened = &o
|
||||
}
|
||||
if reached(state) && !contains(i.Delivered, pr) {
|
||||
i.Delivered = append(i.Delivered, pr)
|
||||
}
|
||||
@@ -247,10 +207,6 @@ func (t *Tracker) afterFix(n uint64) error {
|
||||
t.Logf("[mesh-issues] the warning that %d is delivered and not resolved could not be cleared on the operator's channel (tried again at the next sweep): %v", n, err)
|
||||
}
|
||||
if allDelivered(is) {
|
||||
// Its fix time, final once its last fix is delivered (novox/hq ADR 0313).
|
||||
if err := t.timeFix(n); err != nil {
|
||||
return err
|
||||
}
|
||||
if unresolved(is) {
|
||||
prs := liveFixes(is)
|
||||
sort.Strings(prs)
|
||||
@@ -365,15 +321,12 @@ func prKey(repository string, number int) string {
|
||||
// to is kept on each issue whose fixed-by names it.
|
||||
func (t *Tracker) OnTransition(e stdio.Envelope) error {
|
||||
var body struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
To string `json:"to"`
|
||||
Why string `json:"why"`
|
||||
MergedAs string `json:"merged_as"`
|
||||
At time.Time `json:"at"`
|
||||
Class string `json:"class"`
|
||||
Opened *time.Time `json:"opened"`
|
||||
MergedAt *time.Time `json:"merged_at"`
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
To string `json:"to"`
|
||||
Why string `json:"why"`
|
||||
MergedAs string `json:"merged_as"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Body, &body); err != nil || body.To == "" || body.Number <= 0 {
|
||||
return nil
|
||||
@@ -383,8 +336,7 @@ func (t *Tracker) OnTransition(e stdio.Envelope) error {
|
||||
at = t.now()
|
||||
}
|
||||
merged := body.MergedAs != ""
|
||||
changed, err := t.recordFix(prKey(body.Repository, body.Number), heardFix{State: body.To, Why: body.Why, Merged: merged,
|
||||
At: at, Class: body.Class, Opened: nonZero(body.Opened), MergedAt: nonZero(body.MergedAt)}, 0)
|
||||
changed, err := t.recordFix(prKey(body.Repository, body.Number), body.To, body.Why, merged, at, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -404,24 +356,6 @@ type Delivery struct {
|
||||
Since time.Time
|
||||
// Merged is the pull request's own merged flag, as the forge says it.
|
||||
Merged bool
|
||||
// Class, Opened and MergedAt are what a fix's time is read by (novox/hq ADR 0313), when the delivery says them.
|
||||
Class string
|
||||
Opened *time.Time
|
||||
MergedAt *time.Time
|
||||
}
|
||||
|
||||
// heard is what a delivery answered, as a fix keeps it.
|
||||
func (d Delivery) heard(since time.Time) heardFix {
|
||||
return heardFix{State: d.State, Why: d.Why, Merged: d.Merged, At: since, Class: d.Class, Opened: d.Opened, MergedAt: d.MergedAt}
|
||||
}
|
||||
|
||||
// nonZero is a moment said, or nil for one not said.
|
||||
func nonZero(t *time.Time) *time.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
|
||||
// DeliveryState asks mesh-delivery for one pull request's delivery.
|
||||
@@ -436,12 +370,9 @@ func busDeliveryState(owner string) DeliveryState {
|
||||
}
|
||||
var out struct {
|
||||
Delivery struct {
|
||||
State string `json:"state"`
|
||||
Waits string `json:"waits"`
|
||||
Since time.Time `json:"since"`
|
||||
Class string `json:"class"`
|
||||
Opened *time.Time `json:"opened"`
|
||||
MergedAt *time.Time `json:"merged_at"`
|
||||
State string `json:"state"`
|
||||
Waits string `json:"waits"`
|
||||
Since time.Time `json:"since"`
|
||||
} `json:"delivery"`
|
||||
Pull struct {
|
||||
Merged bool `json:"merged"`
|
||||
@@ -450,8 +381,7 @@ func busDeliveryState(owner string) DeliveryState {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Delivery{}, fmt.Errorf("mesh-delivery's checks answered no delivery: %.200s", raw)
|
||||
}
|
||||
return Delivery{State: out.Delivery.State, Why: out.Delivery.Waits, Since: out.Delivery.Since.UTC(), Merged: out.Pull.Merged,
|
||||
Class: out.Delivery.Class, Opened: nonZero(out.Delivery.Opened), MergedAt: nonZero(out.Delivery.MergedAt)}, nil
|
||||
return Delivery{State: out.Delivery.State, Why: out.Delivery.Waits, Since: out.Delivery.Since.UTC(), Merged: out.Pull.Merged}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +430,7 @@ func (t *Tracker) OnMoved(e stdio.Envelope) error {
|
||||
if since.IsZero() {
|
||||
since = t.now()
|
||||
}
|
||||
if _, err := t.recordFix(pr, d.heard(since), body.Number); err != nil {
|
||||
if _, err := t.recordFix(pr, state, d.Why, d.Merged, since, body.Number); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -551,13 +481,6 @@ func (t *Tracker) Sweep() (int, error) {
|
||||
bound, now := t.stuckAfter(), t.now()
|
||||
said := 0
|
||||
for n, is := range all {
|
||||
// A fix time missed at its delivery (a failure, or a restart between) is kept now; one of an issue moved to
|
||||
// fixing before the tracker kept the moment is kept only when its fix is heard of again.
|
||||
if is.FixingFrom != nil && is.FixTime == nil && allDelivered(is) {
|
||||
if err := t.timeFix(n); err != nil {
|
||||
t.Logf("[mesh-issues] the fix time of %d could not be kept (tried again at the next sweep): %v", n, err)
|
||||
}
|
||||
}
|
||||
ok, err := t.sayNotResolved(is, now)
|
||||
if err != nil {
|
||||
t.Logf("[mesh-issues] that %d is delivered and not resolved could not be said on the operator's channel (said at the next sweep): %v", n, err)
|
||||
@@ -614,8 +537,5 @@ func (t *Tracker) Sweep() (int, error) {
|
||||
said++
|
||||
}
|
||||
}
|
||||
if err := t.judgeFixBudgets(); err != nil {
|
||||
t.Logf("[mesh-issues] a fix over its budget could not be said or cleared on the operator's channel (tried again at the next sweep): %v", err)
|
||||
}
|
||||
return said, nil
|
||||
}
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
package main
|
||||
|
||||
// A fix's time (novox/hq ADR 0313, issue 403). An issue's fix time runs from its first move to fixing to the moment
|
||||
// the last of its fixes is delivered to every machine it should reach. It is held to the fix budget of its class —
|
||||
// five minutes for a leaf module, ten for a core one — and split into four phases read from the fix delivered
|
||||
// last: implementing (to its pull request opened), review (to an approval at its head), checks (to its merge) and
|
||||
// merge to running (to delivered, its delivery time). A fix over its budget is said on the operator's channel once
|
||||
// it is final, naming the issue, its worker and its longest phase; the warning clears when the next issue of that
|
||||
// class is delivered within its budget. `fix-times` answers the last days' median and worst per class.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The classes of a fix (ADR 0282 §1, ADR 0313 §2) and the class of a fix that moves no module.
|
||||
const (
|
||||
ClassCore = "core"
|
||||
ClassLeaf = "leaf"
|
||||
ClassNone = "none"
|
||||
)
|
||||
|
||||
// FixBudgets are the fix budgets by class, counted from the move to fixing (ADR 0313 §2).
|
||||
var FixBudgets = map[string]time.Duration{ClassLeaf: 5 * time.Minute, ClassCore: 10 * time.Minute}
|
||||
|
||||
// fixClasses in the order `fix-times` says them.
|
||||
var fixClasses = []string{ClassLeaf, ClassCore}
|
||||
|
||||
// The phases of a fix time, in order (ADR 0313 §4).
|
||||
const (
|
||||
PhaseImplementing = "implementing"
|
||||
PhaseReview = "review"
|
||||
PhaseChecks = "checks"
|
||||
PhaseRunning = "merge to running"
|
||||
)
|
||||
|
||||
// FixTime is an issue's fix time, kept on it once the last of its fixes is delivered.
|
||||
type FixTime struct {
|
||||
// From is the first move to fixing; End when the fix delivered last was delivered.
|
||||
From time.Time `json:"from"`
|
||||
End time.Time `json:"end"`
|
||||
TookMS int64 `json:"took_ms"`
|
||||
Took string `json:"took"`
|
||||
// Class is core, leaf or none; empty when a fix's class is not known. A fix of class none, or one not known,
|
||||
// has no budget: its time is shown and never warned on.
|
||||
Class string `json:"class,omitempty"`
|
||||
Budget string `json:"budget,omitempty"`
|
||||
BudgetMS int64 `json:"budget_ms,omitempty"`
|
||||
Over bool `json:"over,omitempty"`
|
||||
// Fix is the pull request delivered last, which the phases are read from; Worker who worked on the issue.
|
||||
Fix string `json:"fix"`
|
||||
Worker string `json:"worker,omitempty"`
|
||||
Phases []FixPhase `json:"phases"`
|
||||
Longest string `json:"longest,omitempty"`
|
||||
Said string `json:"said"`
|
||||
// Warned says the over-budget warning was said for it on the operator's channel and not cleared since.
|
||||
Warned bool `json:"warned,omitempty"`
|
||||
// FromLog says its start was read back from the log, not kept by the move: an issue moved to fixing before the
|
||||
// tracker measured fix times. Its fix time is kept and shown, and never warned on, so the first fixes heard of
|
||||
// after the tracker starts measuring raise no warning for work done before it (novox/hq issue 403).
|
||||
FromLog bool `json:"from-log,omitempty"`
|
||||
}
|
||||
|
||||
// FixPhase is one phase of a fix time: measured, or unknown when the moment that ends it is not known — its time is
|
||||
// then counted in the phase after it, which says so.
|
||||
type FixPhase struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
TookMS int64 `json:"took_ms"`
|
||||
Took string `json:"took"`
|
||||
Said string `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// Approval is when a pull request was first approved at its head, as the forge says it; a test stubs the asking.
|
||||
// found is false when no approval at its head is on the forge.
|
||||
type Approval func(repository string, number int) (at time.Time, found bool, err error)
|
||||
|
||||
// classOfFixes is the class of an issue's fixes: that of the slowest-budgeted module they move — core when any
|
||||
// moves a core module, leaf when any moves a leaf one, none when none moves a module — and empty, with why, when
|
||||
// a fix's class is not known and none is core.
|
||||
func classOfFixes(is *Issue, prs []string) (string, string) {
|
||||
var unknown []string
|
||||
leaf := false
|
||||
for _, pr := range prs {
|
||||
f := fixOf(is, pr)
|
||||
switch {
|
||||
case f == nil || f.Class == "":
|
||||
unknown = append(unknown, pr)
|
||||
case f.Class == ClassCore:
|
||||
return ClassCore, ""
|
||||
case f.Class == ClassLeaf:
|
||||
leaf = true
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
return "", "the delivery did not say what " + strings.Join(unknown, ", ") + " moves"
|
||||
}
|
||||
if leaf {
|
||||
return ClassLeaf, ""
|
||||
}
|
||||
return ClassNone, ""
|
||||
}
|
||||
|
||||
// fixPhases splits the span from start to the delivered moment at the moments that end each phase, in order. A
|
||||
// phase whose end is not known is unknown, and its time counts in the next phase known; a phase whose end came
|
||||
// before the end of the phase before it counts zero (ADR 0313 §4). The phases add up to the fix time.
|
||||
func fixPhases(start time.Time, ends []struct {
|
||||
name string
|
||||
at *time.Time
|
||||
why string
|
||||
}) ([]FixPhase, string) {
|
||||
var out []FixPhase
|
||||
prev := start
|
||||
var carried []string
|
||||
for _, e := range ends {
|
||||
if e.at == nil {
|
||||
out = append(out, FixPhase{Name: e.name, State: "unknown", Took: "0s", Said: e.why})
|
||||
carried = append(carried, e.name)
|
||||
continue
|
||||
}
|
||||
took := time.Duration(0)
|
||||
if e.at.After(prev) {
|
||||
took = e.at.Sub(prev)
|
||||
prev = *e.at
|
||||
}
|
||||
p := FixPhase{Name: e.name, State: "measured", TookMS: took.Milliseconds(), Took: took.Round(time.Second).String()}
|
||||
if len(carried) > 0 {
|
||||
p.Said = "counts " + strings.Join(carried, " and ") + " too: its end is not known"
|
||||
carried = nil
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
best := -1
|
||||
for i, p := range out {
|
||||
if p.State == "measured" && (best < 0 || p.TookMS > out[best].TookMS) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
longest := ""
|
||||
if best >= 0 {
|
||||
longest = out[best].Name + " " + out[best].Took
|
||||
}
|
||||
return out, longest
|
||||
}
|
||||
|
||||
// startOfFixing is when an issue was first moved to fixing (ADR 0313 §1), and whether that moment came from its
|
||||
// log rather than from the move that kept it: the earliest of what it keeps (fixing-from, kept by the move since the
|
||||
// tracker measures fix times) and what its log says. An issue moved before the moment was kept has it read from its
|
||||
// log: a move's operation says its own moment. A value carried into the log, or imported, holds the issue whole and
|
||||
// no moment of its own (it is stamped when it was carried); when the value before it was not at fixing it is the
|
||||
// move's own write, and its worker's active is the move's moment (a move to fixing marks its worker active), not
|
||||
// its worker's since, which a move from diagnosing by the same worker keeps (ADR 0284 §6). Only a first value
|
||||
// already at fixing falls back to its worker's since, the best moment the log holds for it. Zero when it never was.
|
||||
func (t *Tracker) startOfFixing(is *Issue) (time.Time, bool) {
|
||||
var kept time.Time
|
||||
if is.FixingFrom != nil {
|
||||
kept = is.FixingFrom.UTC()
|
||||
}
|
||||
if t.Log == nil {
|
||||
return kept, false
|
||||
}
|
||||
entries, err := t.readKey(is.Number, 0)
|
||||
if err != nil {
|
||||
t.Logf("[mesh-issues] the log of %d could not be read for its first move to fixing (its kept moment is used): %v", is.Number, err)
|
||||
return kept, false
|
||||
}
|
||||
var logged time.Time
|
||||
status, seen := "", false
|
||||
for _, e := range entries {
|
||||
var op Operation
|
||||
if json.Unmarshal(e.Value, &op) != nil {
|
||||
continue
|
||||
}
|
||||
at, became := op.At, ""
|
||||
switch {
|
||||
case op.Issue != nil:
|
||||
became = op.Issue.Status
|
||||
if w := op.Issue.Worker; w != nil {
|
||||
switch {
|
||||
case seen && status != StatusFixing && !w.Active.IsZero():
|
||||
at = w.Active
|
||||
case !w.Since.IsZero():
|
||||
at = w.Since
|
||||
}
|
||||
}
|
||||
case op.Set["status"] != nil:
|
||||
_ = json.Unmarshal(op.Set["status"], &became)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if became == StatusFixing && status != StatusFixing && !at.IsZero() {
|
||||
logged = at.UTC()
|
||||
break
|
||||
}
|
||||
status, seen = became, true
|
||||
}
|
||||
switch {
|
||||
case kept.IsZero():
|
||||
return logged, !logged.IsZero()
|
||||
case !logged.IsZero() && logged.Before(kept):
|
||||
return logged, true
|
||||
}
|
||||
return kept, false
|
||||
}
|
||||
|
||||
// fixTimeOf is an issue's fix time once every fix it names is delivered; nil, with why, when it is not measured.
|
||||
func (t *Tracker) fixTimeOf(is *Issue) (*FixTime, string) {
|
||||
if !allDelivered(is) {
|
||||
return nil, "not every fix is delivered"
|
||||
}
|
||||
start, fromLog := t.startOfFixing(is)
|
||||
if start.IsZero() {
|
||||
return nil, "it was never moved to fixing"
|
||||
}
|
||||
prs := liveFixes(is)
|
||||
var last *Fix
|
||||
for _, pr := range prs {
|
||||
if f := fixOf(is, pr); f != nil && (last == nil || f.Since.After(last.Since)) {
|
||||
last = f
|
||||
}
|
||||
}
|
||||
if last == nil {
|
||||
return nil, "no fix is heard of"
|
||||
}
|
||||
end := last.Since.UTC()
|
||||
took := time.Duration(0)
|
||||
if end.After(start) {
|
||||
took = end.Sub(start)
|
||||
}
|
||||
ft := &FixTime{From: start, End: end, TookMS: took.Milliseconds(), Took: took.Round(time.Second).String(), Fix: last.PullRequest,
|
||||
FromLog: fromLog}
|
||||
if is.Worker != nil {
|
||||
ft.Worker = is.Worker.By
|
||||
} else if is.FixTime != nil {
|
||||
ft.Worker = is.FixTime.Worker
|
||||
}
|
||||
class, why := classOfFixes(is, prs)
|
||||
ft.Class = class
|
||||
if b, ok := FixBudgets[class]; ok {
|
||||
ft.Budget, ft.BudgetMS, ft.Over = b.String(), b.Milliseconds(), took > b
|
||||
}
|
||||
|
||||
// The phases, from the fix delivered last.
|
||||
type end_ = struct {
|
||||
name string
|
||||
at *time.Time
|
||||
why string
|
||||
}
|
||||
var approved *time.Time
|
||||
approvalWhy := "no approval at its head is on the forge: review and checks are counted together"
|
||||
if t.Approval != nil {
|
||||
repo, number, _ := strings.Cut(last.PullRequest, "#")
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(number, "%d", &n); err == nil {
|
||||
at, found, err := t.Approval(repo, n)
|
||||
switch {
|
||||
case err != nil:
|
||||
approvalWhy = "the forge could not be read for its approval (" + cut(err.Error(), 200) + "): review and checks are counted together"
|
||||
case found:
|
||||
a := at.UTC()
|
||||
approved = &a
|
||||
}
|
||||
}
|
||||
} else {
|
||||
approvalWhy = "the forge is not asked here: review and checks are counted together"
|
||||
}
|
||||
openedWhy := "the delivery did not say when its pull request opened: implementing is counted in review"
|
||||
mergedWhy := "the delivery did not say when it merged: checks are counted in merge to running"
|
||||
ft.Phases, ft.Longest = fixPhases(start, []end_{
|
||||
{PhaseImplementing, last.Opened, openedWhy},
|
||||
{PhaseReview, approved, approvalWhy},
|
||||
{PhaseChecks, last.MergedAt, mergedWhy},
|
||||
{PhaseRunning, &end, ""},
|
||||
})
|
||||
|
||||
var said []string
|
||||
said = append(said, fmt.Sprintf("fixed in %s, from the first move to fixing (%s) to %s delivered (%s)", ft.Took,
|
||||
start.Format(time.RFC3339), last.PullRequest, end.Format(time.RFC3339)))
|
||||
switch {
|
||||
case class == ClassNone:
|
||||
said = append(said, "its fixes move no module, so it has no fix budget")
|
||||
case class == "":
|
||||
said = append(said, "no fix budget: "+why)
|
||||
case ft.Over:
|
||||
said = append(said, fmt.Sprintf("over its %s fix budget of %s", class, ft.Budget))
|
||||
default:
|
||||
said = append(said, fmt.Sprintf("within its %s fix budget of %s", class, ft.Budget))
|
||||
}
|
||||
if ft.Longest != "" {
|
||||
said = append(said, "longest: "+ft.Longest)
|
||||
}
|
||||
if fromLog {
|
||||
said = append(said, "its start was read from its log (moved to fixing before fix times were measured), so it is never warned on")
|
||||
}
|
||||
ft.Said = strings.Join(said, "; ")
|
||||
return ft, ""
|
||||
}
|
||||
|
||||
// sameFixTime says whether two fix times say the same, the warning's mark aside.
|
||||
func sameFixTime(a, b *FixTime) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
x, y := *a, *b
|
||||
x.Warned, y.Warned = false, false
|
||||
ra, _ := json.Marshal(x)
|
||||
rb, _ := json.Marshal(y)
|
||||
return string(ra) == string(rb)
|
||||
}
|
||||
|
||||
// timeFix keeps an issue's fix time once every fix it names is delivered, and again when a later fix moves its
|
||||
// end; then the warnings of its class are judged.
|
||||
func (t *Tracker) timeFix(n uint64) error {
|
||||
is, _, err := read(t.Store, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ft, _ := t.fixTimeOf(is)
|
||||
if ft == nil || sameFixTime(ft, is.FixTime) {
|
||||
return nil
|
||||
}
|
||||
if _, err := t.change(n, Act{Op: "fix-time", By: Seat}, func(i *Issue) error {
|
||||
if i.FixTime != nil {
|
||||
ft.Warned = i.FixTime.Warned
|
||||
}
|
||||
i.FixTime = ft
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.judgeFixBudgets(); err != nil {
|
||||
t.Logf("[mesh-issues] a fix over its budget could not be said or cleared on the operator's channel (tried again at the next sweep): %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fixOverKey is the warning a fix over its class's budget raises (ADR 0313 §6).
|
||||
func fixOverKey(class string) string { return "issue-tracker.fix." + class + ".over-budget" }
|
||||
|
||||
// judgeFixBudgets says or clears each class's warning by the newest fix time of the class (ADR 0313 §6): the newest
|
||||
// over its budget and not yet said is said; the newest within its budget clears what was said for the class. Run
|
||||
// after every fix time kept and at every sweep, so a warning that failed is said, or cleared, at the next.
|
||||
func (t *Tracker) judgeFixBudgets() error {
|
||||
if t.Notify == nil {
|
||||
return nil
|
||||
}
|
||||
all, err := t.allIssues()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
for _, class := range fixClasses {
|
||||
var newest *Issue
|
||||
var warned []uint64
|
||||
for _, is := range all {
|
||||
ft := is.FixTime
|
||||
if ft == nil || ft.Class != class {
|
||||
continue
|
||||
}
|
||||
if ft.Warned {
|
||||
warned = append(warned, is.Number)
|
||||
}
|
||||
if newest == nil || ft.End.After(newest.FixTime.End) || (ft.End.Equal(newest.FixTime.End) && is.Number > newest.Number) {
|
||||
newest = is
|
||||
}
|
||||
}
|
||||
if newest == nil {
|
||||
continue
|
||||
}
|
||||
ft := newest.FixTime
|
||||
if ft.Over {
|
||||
if ft.Warned || ft.FromLog {
|
||||
continue
|
||||
}
|
||||
worker := ft.Worker
|
||||
if worker == "" {
|
||||
worker = "nobody named"
|
||||
}
|
||||
longest := ft.Longest
|
||||
if longest == "" {
|
||||
longest = "not known"
|
||||
}
|
||||
if err := t.Notify(fixOverKey(class), "warning",
|
||||
fmt.Sprintf("issue %d: fixed in %s, over its %s fix budget of %s (longest: %s)", newest.Number, ft.Took, class, ft.Budget, longest),
|
||||
fmt.Sprintf("A fix took longer than its budget (issue %d)", newest.Number),
|
||||
fmt.Sprintf("Issue %d was fixed in %s, from %s's move to fixing to its last fix delivered, over the %s fix "+
|
||||
"budget of %s. Its longest phase was %s. Nothing is needed from you: it says where fixes lose time. "+
|
||||
"It clears when the next %s fix is delivered within its budget.", newest.Number, ft.Took, worker, class,
|
||||
ft.Budget, longest, class), "", false); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := t.change(newest.Number, Act{Op: "warned", By: Seat}, func(i *Issue) error {
|
||||
if i.FixTime != nil {
|
||||
i.FixTime.Warned = true
|
||||
}
|
||||
return nil
|
||||
}); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(warned) == 0 || ft.BudgetMS == 0 {
|
||||
continue
|
||||
}
|
||||
if err := t.Notify(fixOverKey(class), "", "", "", "", fmt.Sprintf("issue %d's fix was delivered within its %s fix budget (%s of %s)",
|
||||
newest.Number, class, ft.Took, ft.Budget), true); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
sort.Slice(warned, func(i, j int) bool { return warned[i] < warned[j] })
|
||||
for _, n := range warned {
|
||||
if _, err := t.change(n, Act{Op: "warned", By: Seat}, func(i *Issue) error {
|
||||
if i.FixTime != nil {
|
||||
i.FixTime.Warned = false
|
||||
}
|
||||
return nil
|
||||
}); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// FixTimesClass is one class's line of `fix-times`.
|
||||
type FixTimesClass struct {
|
||||
Class string `json:"class"`
|
||||
Budget string `json:"budget"`
|
||||
BudgetMS int64 `json:"budget_ms"`
|
||||
Count int `json:"count"`
|
||||
Within int `json:"within"`
|
||||
Over int `json:"over"`
|
||||
Median string `json:"median,omitempty"`
|
||||
MedianMS int64 `json:"median_ms,omitempty"`
|
||||
Worst string `json:"worst,omitempty"`
|
||||
WorstMS int64 `json:"worst_ms,omitempty"`
|
||||
// WorstIssue is the issue whose fix took the worst time.
|
||||
WorstIssue uint64 `json:"worst_issue,omitempty"`
|
||||
}
|
||||
|
||||
// FixTimesRow is an issue as `fix-times` names it.
|
||||
type FixTimesRow struct {
|
||||
Number uint64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Took string `json:"took"`
|
||||
TookMS int64 `json:"took_ms"`
|
||||
Budget string `json:"budget,omitempty"`
|
||||
Longest string `json:"longest,omitempty"`
|
||||
Worker string `json:"worker,omitempty"`
|
||||
Delivered string `json:"delivered"`
|
||||
}
|
||||
|
||||
// FixTimes is the answer of `fix-times`.
|
||||
type FixTimes struct {
|
||||
Days int `json:"days"`
|
||||
Since string `json:"since"`
|
||||
Line string `json:"line"`
|
||||
Classes []FixTimesClass `json:"classes"`
|
||||
// Over is every issue over its budget in the window, newest first, with its longest phase.
|
||||
Over []FixTimesRow `json:"over,omitempty"`
|
||||
// Unbudgeted are the issues fixed in the window with no fix budget: their fixes move no module, or what they
|
||||
// move is not known.
|
||||
Unbudgeted []FixTimesRow `json:"unbudgeted,omitempty"`
|
||||
}
|
||||
|
||||
// MostFixTimesDays is the longest window `fix-times` reads.
|
||||
const MostFixTimesDays = 90
|
||||
|
||||
// FixTimes is the `fix-times` verb: each class's fix budget, the last days' median and worst fix time, and every
|
||||
// issue over its budget with its longest phase (ADR 0313 §5, §7). An issue counts in the window its last fix was
|
||||
// delivered in.
|
||||
func (t *Tracker) FixTimes(days int) (*FixTimes, error) {
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
if days > MostFixTimesDays {
|
||||
return nil, fmt.Errorf("days %d is more than the %d fix-times reads", days, MostFixTimesDays)
|
||||
}
|
||||
all, err := t.allIssues()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
since := t.now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
a := &FixTimes{Days: days, Since: since.Format(time.RFC3339)}
|
||||
byClass := map[string][]*Issue{}
|
||||
row := func(is *Issue) FixTimesRow {
|
||||
ft := is.FixTime
|
||||
return FixTimesRow{Number: is.Number, Title: is.Title, Class: ft.Class, Took: ft.Took, TookMS: ft.TookMS, Budget: ft.Budget,
|
||||
Longest: ft.Longest, Worker: ft.Worker, Delivered: ft.End.Format(time.RFC3339)}
|
||||
}
|
||||
for _, is := range all {
|
||||
ft := is.FixTime
|
||||
if ft == nil || ft.End.Before(since) {
|
||||
continue
|
||||
}
|
||||
if _, ok := FixBudgets[ft.Class]; !ok {
|
||||
a.Unbudgeted = append(a.Unbudgeted, row(is))
|
||||
continue
|
||||
}
|
||||
byClass[ft.Class] = append(byClass[ft.Class], is)
|
||||
if ft.Over {
|
||||
a.Over = append(a.Over, row(is))
|
||||
}
|
||||
}
|
||||
newestFirst := func(rows []FixTimesRow) {
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Delivered != rows[j].Delivered {
|
||||
return rows[i].Delivered > rows[j].Delivered
|
||||
}
|
||||
return rows[i].Number > rows[j].Number
|
||||
})
|
||||
}
|
||||
newestFirst(a.Over)
|
||||
newestFirst(a.Unbudgeted)
|
||||
var line []string
|
||||
for _, class := range fixClasses {
|
||||
b := FixBudgets[class]
|
||||
c := FixTimesClass{Class: class, Budget: b.String(), BudgetMS: b.Milliseconds()}
|
||||
is := byClass[class]
|
||||
sort.Slice(is, func(i, j int) bool {
|
||||
if is[i].FixTime.TookMS != is[j].FixTime.TookMS {
|
||||
return is[i].FixTime.TookMS < is[j].FixTime.TookMS
|
||||
}
|
||||
return is[i].Number < is[j].Number
|
||||
})
|
||||
c.Count = len(is)
|
||||
for _, i := range is {
|
||||
if i.FixTime.Over {
|
||||
c.Over++
|
||||
} else {
|
||||
c.Within++
|
||||
}
|
||||
}
|
||||
if n := len(is); n > 0 {
|
||||
ms := is[n/2].FixTime.TookMS
|
||||
if n%2 == 0 {
|
||||
ms = (is[n/2-1].FixTime.TookMS + is[n/2].FixTime.TookMS) / 2
|
||||
}
|
||||
med := time.Duration(ms) * time.Millisecond
|
||||
c.MedianMS, c.Median = ms, med.Round(time.Second).String()
|
||||
worst := is[n-1]
|
||||
c.WorstMS, c.Worst, c.WorstIssue = worst.FixTime.TookMS, worst.FixTime.Took, worst.Number
|
||||
line = append(line, fmt.Sprintf("%s: median %s, worst %s (issue %d), %d of %d within %s", class, c.Median,
|
||||
c.Worst, c.WorstIssue, c.Within, c.Count, c.Budget))
|
||||
} else {
|
||||
line = append(line, fmt.Sprintf("%s: no issue fixed", class))
|
||||
}
|
||||
a.Classes = append(a.Classes, c)
|
||||
}
|
||||
a.Line = fmt.Sprintf("fix time over the last %d day(s), from the move to fixing to delivered — %s", days, strings.Join(line, "; "))
|
||||
if len(a.Unbudgeted) > 0 {
|
||||
a.Line += fmt.Sprintf("; %d fixed with no fix budget", len(a.Unbudgeted))
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// firstApproval is when a pull request was first approved at its head among the forge's reviews: the forge's own
|
||||
// approval, or — the forge refusing an approval of one's own pull request, so the mesh's independent reviews are
|
||||
// posted as comments by the account that opened it — a comment review whose first line is "Verdict: approve at
|
||||
// <sha>", the sha the head it was posted at or a prefix of it of at least seven characters. An approval dismissed,
|
||||
// stale, or of an older head does not count.
|
||||
func firstApproval(raw json.RawMessage) (time.Time, bool, error) {
|
||||
var a struct {
|
||||
Reviews []struct {
|
||||
State string `json:"state"`
|
||||
AtHead bool `json:"at_head"`
|
||||
Commit string `json:"commit"`
|
||||
Body string `json:"body"`
|
||||
Submitted string `json:"submitted"`
|
||||
Stale bool `json:"stale"`
|
||||
Dismissed bool `json:"dismissed"`
|
||||
} `json:"reviews"`
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
raw = json.RawMessage(s)
|
||||
}
|
||||
var wrapped struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) > 0 {
|
||||
raw = json.RawMessage(wrapped.Content[0].Text)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Reviews == nil {
|
||||
return time.Time{}, false, fmt.Errorf("the forge's gitea_pull_reviews answered no reviews: %.200s", raw)
|
||||
}
|
||||
var first time.Time
|
||||
for _, r := range a.Reviews {
|
||||
if !r.AtHead || r.Stale || r.Dismissed {
|
||||
continue
|
||||
}
|
||||
switch r.State {
|
||||
case "APPROVED":
|
||||
case "COMMENT":
|
||||
if !verdictApproves(r.Body, r.Commit) {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, r.Submitted)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if first.IsZero() || at.Before(first) {
|
||||
first = at
|
||||
}
|
||||
}
|
||||
return first, !first.IsZero(), nil
|
||||
}
|
||||
|
||||
// verdictApproves says whether a review's body opens with the mesh's approving verdict on the commit it was posted
|
||||
// at: "Verdict: approve at <sha>", the sha that commit or a prefix of it of at least seven characters.
|
||||
func verdictApproves(body, commit string) bool {
|
||||
line, _, _ := strings.Cut(strings.TrimLeft(body, " \t\r\n"), "\n")
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "Verdict: approve at ")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
sha := strings.ToLower(strings.TrimRight(strings.TrimSpace(rest), "."))
|
||||
commit = strings.ToLower(strings.TrimSpace(commit))
|
||||
return len(sha) >= 7 && commit != "" && strings.Trim(sha, "0123456789abcdef") == "" && strings.HasPrefix(commit, sha)
|
||||
}
|
||||
|
||||
// busApproval asks the forge's reviews of a pull request over the bus (gitea_pull_reviews).
|
||||
func busApproval(owner func() string) Approval {
|
||||
return func(repository string, number int) (time.Time, bool, error) {
|
||||
raw, err := stdio.Ask("gitea.gitea_pull_reviews", map[string]any{"owner": owner(), "repo": repository, "number": number})
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return firstApproval(raw)
|
||||
}
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0313 (issue 403): an issue's fix time runs from its first move to fixing to its last fix
|
||||
// delivered; it is held to its class's fix budget, split into four phases that add up to it, shown in get and
|
||||
// fix-times, and a fix over its budget is said on the operator's channel once final, cleared at the next of its
|
||||
// class delivered within budget.
|
||||
|
||||
// fixClock is a harness whose clock a test moves.
|
||||
type fixClock struct {
|
||||
*harness
|
||||
at time.Time
|
||||
// approvals is when each pull request was approved at its head, as the forge would say it.
|
||||
approvals map[int]time.Time
|
||||
}
|
||||
|
||||
func newFixClock(t *testing.T) *fixClock {
|
||||
c := &fixClock{harness: newHarness(t), at: time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC), approvals: map[int]time.Time{}}
|
||||
c.Tracker.Now = func() time.Time { return c.at }
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *fixClock) approval(repository string, number int) (time.Time, bool, error) {
|
||||
a, ok := c.approvals[number]
|
||||
return a, ok, nil
|
||||
}
|
||||
|
||||
func (c *fixClock) later(d time.Duration) time.Time { c.at = c.at.Add(d); return c.at }
|
||||
|
||||
// delivery says one transition of a pull request as mesh-delivery says it, with what a fix's time is read by.
|
||||
func (c *fixClock) delivery(t *testing.T, number int, to, class string, opened, mergedAt *time.Time) {
|
||||
t.Helper()
|
||||
body := map[string]any{"repository": "novox/mesh-catalog", "number": number, "to": to, "at": c.at}
|
||||
if mergedAt != nil {
|
||||
body["merged_as"] = "m" + strings.Repeat("a", 11)
|
||||
body["merged_at"] = *mergedAt
|
||||
}
|
||||
if class != "" {
|
||||
body["class"] = class
|
||||
}
|
||||
if opened != nil {
|
||||
body["opened"] = *opened
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
if err := c.OnTransition(stdio.Envelope{Key: "mesh-delivery.transition", Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func at(t time.Time) *time.Time { return &t }
|
||||
|
||||
func phaseTook(t *testing.T, ft *FixTime) map[string]time.Duration {
|
||||
t.Helper()
|
||||
out := map[string]time.Duration{}
|
||||
var sum int64
|
||||
for _, p := range ft.Phases {
|
||||
out[p.Name] = time.Duration(p.TookMS) * time.Millisecond
|
||||
sum += p.TookMS
|
||||
}
|
||||
if sum != ft.TookMS {
|
||||
t.Fatalf("the phases add up to %dms, the fix time is %dms: %+v", sum, ft.TookMS, ft.Phases)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fixOne takes an issue through a fix: opened, moved to fixing, a pull request opened, approved, merged and
|
||||
// delivered after the given spans; its fix time as kept.
|
||||
func (c *fixClock) fixOne(t *testing.T, title string, pr int, class string, implementing, review, checks, running time.Duration) uint64 {
|
||||
t.Helper()
|
||||
n := c.open(t, title)
|
||||
c.fixing(t, n)
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14", FixedBy: []string{"mesh-catalog#" + itoa(pr)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened := c.later(implementing)
|
||||
c.delivery(t, pr, "proposed", "", &opened, nil)
|
||||
approved := c.later(review)
|
||||
c.approvals[pr] = approved
|
||||
merged := c.later(checks)
|
||||
c.delivery(t, pr, "published", class, &opened, &merged)
|
||||
c.later(running)
|
||||
c.delivery(t, pr, "delivered", class, &opened, &merged)
|
||||
return n
|
||||
}
|
||||
|
||||
func itoa(n int) string { b, _ := json.Marshal(n); return string(b) }
|
||||
|
||||
func TestAFixTimeRunsFromTheFirstMoveToFixingAndAddsUpItsPhases(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
c.Tracker.Approval = c.approval
|
||||
note := ¬ified{}
|
||||
c.Notify = note.notify
|
||||
|
||||
n := c.open(t, "a fix measured")
|
||||
c.later(time.Minute)
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix@g14"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.later(10 * time.Minute) // finding the cause is not the fix's time
|
||||
start := c.at
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14", FixedBy: []string{"mesh-catalog#31"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.later(30 * time.Second)
|
||||
// A take-over, and a move away and back, restart nothing.
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "other@shanks", TakeOver: "the first worker's session ended"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusOpen}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "other@shanks"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened := c.later(90 * time.Second) // implementing: 2m
|
||||
c.delivery(t, 31, "proposed", "", &opened, nil)
|
||||
c.approvals[31] = c.later(time.Minute) // review: 1m
|
||||
merged := c.later(3 * time.Minute) // checks: 3m
|
||||
c.delivery(t, 31, "published", "leaf", &opened, &merged)
|
||||
if is := get(t, c.harness, n); is.FixTime != nil {
|
||||
t.Fatalf("a fix merged and not delivered has a fix time: %+v", is.FixTime)
|
||||
}
|
||||
c.later(2 * time.Minute) // merge to running: 2m
|
||||
c.delivery(t, 31, "delivered", "leaf", &opened, &merged)
|
||||
|
||||
v, err := c.View(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ft := v.FixTime
|
||||
if ft == nil {
|
||||
t.Fatalf("no fix time once the fix is delivered: %+v", v.Issue)
|
||||
}
|
||||
if !ft.From.Equal(start) || ft.TookMS != (8*time.Minute).Milliseconds() {
|
||||
t.Fatalf("fix time from %s took %s, want from %s (the first move to fixing) and 8m0s", ft.From, ft.Took, start)
|
||||
}
|
||||
got := phaseTook(t, ft)
|
||||
if got[PhaseImplementing] != 2*time.Minute || got[PhaseReview] != time.Minute || got[PhaseChecks] != 3*time.Minute ||
|
||||
got[PhaseRunning] != 2*time.Minute {
|
||||
t.Fatalf("phases %v", got)
|
||||
}
|
||||
if ft.Class != ClassLeaf || !ft.Over || ft.Budget != "5m0s" || ft.Longest != "checks 3m0s" || ft.Worker != "other@shanks" {
|
||||
t.Fatalf("class %q over %v budget %q longest %q worker %q", ft.Class, ft.Over, ft.Budget, ft.Longest, ft.Worker)
|
||||
}
|
||||
// Over its budget: said once, naming the issue, its worker and its longest phase.
|
||||
if len(note.said) != 1 || !strings.HasPrefix(note.said[0], "issue-tracker.fix.leaf.over-budget warning issue "+itoa(int(n))) ||
|
||||
!strings.Contains(note.said[0], "checks 3m0s") {
|
||||
t.Fatalf("said %q", note.said)
|
||||
}
|
||||
// Heard again: nothing new is kept or said.
|
||||
c.delivery(t, 31, "delivered", "leaf", &opened, &merged)
|
||||
if len(note.said) != 1 {
|
||||
t.Fatalf("said again: %q", note.said)
|
||||
}
|
||||
}
|
||||
|
||||
// A phase whose end is not known is unknown and counted in the next; one that ended before the phase before it
|
||||
// counts zero (a pull request opened before the move to fixing).
|
||||
func TestAPhaseNotKnownIsCountedInTheNextAndOneEndedEarlyCountsZero(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
n := c.open(t, "a fix whose pull request came first")
|
||||
opened := c.at
|
||||
c.later(time.Minute)
|
||||
c.fixing(t, n)
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14", FixedBy: []string{"mesh-catalog#32"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := c.later(4 * time.Minute)
|
||||
c.later(time.Minute)
|
||||
c.delivery(t, 32, "delivered", "leaf", &opened, &merged)
|
||||
ft := get(t, c.harness, n).FixTime
|
||||
if ft == nil {
|
||||
t.Fatal("no fix time")
|
||||
}
|
||||
got := phaseTook(t, ft)
|
||||
if got[PhaseImplementing] != 0 || got[PhaseChecks] != 4*time.Minute || got[PhaseRunning] != time.Minute {
|
||||
t.Fatalf("phases %v", got)
|
||||
}
|
||||
for _, p := range ft.Phases {
|
||||
if p.Name == PhaseReview && (p.State != "unknown" || p.Said == "") {
|
||||
t.Fatalf("review with no approval asked: %+v", p)
|
||||
}
|
||||
if p.Name == PhaseChecks && !strings.Contains(p.Said, "review") {
|
||||
t.Fatalf("checks does not say it counts review: %+v", p)
|
||||
}
|
||||
}
|
||||
if ft.Over {
|
||||
t.Fatalf("5m of a leaf's 5m is within it: %+v", ft)
|
||||
}
|
||||
}
|
||||
|
||||
// The class is the slowest-budgeted of the fixes'; a fix that moves no module has no budget and is never warned
|
||||
// on; one whose class is not said has none either.
|
||||
func TestAnIssuesClassIsItsSlowestFixAndOneMovingNothingHasNoBudget(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
note := ¬ified{}
|
||||
c.Notify = note.notify
|
||||
|
||||
n := c.open(t, "two fixes, one core")
|
||||
c.fixing(t, n)
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14", FixedBy: []string{"mesh-catalog#41", "mesh-catalog#42"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := c.later(time.Minute)
|
||||
c.delivery(t, 41, "delivered", "leaf", nil, &merged)
|
||||
if get(t, c.harness, n).FixTime != nil {
|
||||
t.Fatal("a fix time before the last fix is delivered")
|
||||
}
|
||||
c.later(7 * time.Minute)
|
||||
c.delivery(t, 42, "delivered", "core", nil, &merged)
|
||||
ft := get(t, c.harness, n).FixTime
|
||||
if ft == nil || ft.Class != ClassCore || ft.Over || ft.Fix != "mesh-catalog#42" {
|
||||
t.Fatalf("8m of a core fix: %+v", ft)
|
||||
}
|
||||
|
||||
none := c.fixOne(t, "a fix in hq alone", 43, ClassNone, time.Minute, time.Minute, 10*time.Minute, time.Second)
|
||||
ft = get(t, c.harness, none).FixTime
|
||||
if ft == nil || ft.Class != ClassNone || ft.Over || ft.BudgetMS != 0 || !strings.Contains(ft.Said, "no fix budget") {
|
||||
t.Fatalf("a fix moving nothing: %+v", ft)
|
||||
}
|
||||
unknown := c.fixOne(t, "a fix whose class is not said", 44, "", time.Minute, time.Minute, 10*time.Minute, time.Second)
|
||||
ft = get(t, c.harness, unknown).FixTime
|
||||
if ft == nil || ft.Class != "" || ft.Over || !strings.Contains(ft.Said, "no fix budget") {
|
||||
t.Fatalf("a fix of no known class: %+v", ft)
|
||||
}
|
||||
if len(note.said) != 0 {
|
||||
t.Fatalf("warned on a fix with no budget: %q", note.said)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is said while a fix is undelivered, however long; once delivered over budget it is said; the next leaf
|
||||
// fix delivered within budget clears it.
|
||||
func TestAFixOverItsBudgetIsSaidOnceFinalAndClearedByTheNextWithin(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
c.Tracker.Approval = c.approval
|
||||
note := ¬ified{}
|
||||
c.Notify = note.notify
|
||||
|
||||
n := c.open(t, "a slow fix")
|
||||
c.fixing(t, n)
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14", FixedBy: []string{"mesh-catalog#51"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := c.later(20 * time.Minute)
|
||||
c.delivery(t, 51, "published", "leaf", nil, &merged)
|
||||
if _, err := c.Sweep(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(note.said) != 0 {
|
||||
t.Fatalf("said while undelivered: %q", note.said)
|
||||
}
|
||||
c.later(time.Minute)
|
||||
c.delivery(t, 51, "delivered", "leaf", nil, &merged)
|
||||
if len(note.said) != 1 || !strings.HasPrefix(note.said[0], fixOverKey(ClassLeaf)+" warning") {
|
||||
t.Fatalf("said %q", note.said)
|
||||
}
|
||||
if !get(t, c.harness, n).FixTime.Warned {
|
||||
t.Fatal("not marked said")
|
||||
}
|
||||
// A core fix within its budget clears nothing of the leaf's.
|
||||
c.fixOne(t, "a quick core fix", 52, ClassCore, time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
if len(note.clear) != 0 {
|
||||
t.Fatalf("cleared by another class: %q", note.clear)
|
||||
}
|
||||
c.fixOne(t, "a quick leaf fix", 53, ClassLeaf, time.Minute, 30*time.Second, time.Minute, time.Minute)
|
||||
if len(note.clear) != 1 || note.clear[0] != fixOverKey(ClassLeaf) {
|
||||
t.Fatalf("cleared %q", note.clear)
|
||||
}
|
||||
if get(t, c.harness, n).FixTime.Warned {
|
||||
t.Fatal("still marked said after the clear")
|
||||
}
|
||||
if _, err := c.Sweep(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(note.said) != 1 || len(note.clear) != 1 {
|
||||
t.Fatalf("the sweep said again: %q, cleared %q", note.said, note.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// A warning the channel did not take is said at the next sweep.
|
||||
func TestAWarningNotTakenIsSaidAtTheNextSweep(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
note := ¬ified{err: errFake("the channel is away")}
|
||||
c.Notify = note.notify
|
||||
n := c.fixOne(t, "slow", 61, ClassLeaf, 5*time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
if get(t, c.harness, n).FixTime == nil {
|
||||
t.Fatal("a fix time is kept although the warning was not taken")
|
||||
}
|
||||
note.err = nil
|
||||
if _, err := c.Sweep(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(note.said) != 1 || !get(t, c.harness, n).FixTime.Warned {
|
||||
t.Fatalf("said %q", note.said)
|
||||
}
|
||||
}
|
||||
|
||||
// fix-times answers each class's budget, the window's median and worst, and every issue over its budget with its
|
||||
// longest phase; an issue delivered before the window does not count, so a lever is judged by the medians before
|
||||
// and after it.
|
||||
func TestFixTimesAnswersTheWindowsMedianAndWorst(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
old := c.fixOne(t, "long ago", 70, ClassLeaf, 30*time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
c.later(8 * 24 * time.Hour)
|
||||
a := c.fixOne(t, "four minutes", 71, ClassLeaf, time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
b := c.fixOne(t, "six minutes", 72, ClassLeaf, 3*time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
c.fixOne(t, "eight minutes", 73, ClassLeaf, 5*time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
c.fixOne(t, "a core fix", 74, ClassCore, time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
c.fixOne(t, "hq alone", 75, ClassNone, time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
|
||||
out, err := c.call(t, "fix-times", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ft := out.(*FixTimes)
|
||||
leaf, core := ft.Classes[0], ft.Classes[1]
|
||||
if ft.Days != 7 || leaf.Class != ClassLeaf || leaf.Count != 3 || leaf.Median != "6m0s" || leaf.Worst != "8m0s" ||
|
||||
leaf.Within != 1 || leaf.Over != 2 || leaf.Budget != "5m0s" {
|
||||
t.Fatalf("leaf %+v (issue %d, delivered before the window, must not count)", leaf, old)
|
||||
}
|
||||
if core.Count != 1 || core.Median != "4m0s" || core.Within != 1 {
|
||||
t.Fatalf("core %+v", core)
|
||||
}
|
||||
if len(ft.Over) != 2 || ft.Over[0].Title != "eight minutes" || ft.Over[0].Longest != "implementing 5m0s" || ft.Over[1].Number != b {
|
||||
t.Fatalf("over %+v", ft.Over)
|
||||
}
|
||||
if len(ft.Unbudgeted) != 1 || ft.Unbudgeted[0].Title != "hq alone" {
|
||||
t.Fatalf("unbudgeted %+v", ft.Unbudgeted)
|
||||
}
|
||||
if !strings.Contains(ft.Line, "leaf: median 6m0s, worst 8m0s") {
|
||||
t.Fatalf("line %q", ft.Line)
|
||||
}
|
||||
// Wider, the old one counts and moves the median.
|
||||
out, _ = c.call(t, "fix-times", map[string]any{"days": 30})
|
||||
if l := out.(*FixTimes).Classes[0]; l.Count != 4 || l.Median != "7m0s" {
|
||||
t.Fatalf("over 30 days: %+v", l)
|
||||
}
|
||||
if _, err := c.call(t, "fix-times", map[string]any{"days": 91}); err == nil {
|
||||
t.Fatal("days 91 not refused")
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
|
||||
// An issue moved to fixing before the tracker kept the moment has its start read from its log.
|
||||
func TestTheStartOfAnIssueMovedBeforeItWasKeptIsReadFromItsLog(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
n := c.open(t, "moved to fixing long ago")
|
||||
c.later(time.Minute)
|
||||
start := c.at
|
||||
c.fixing(t, n)
|
||||
c.later(time.Hour)
|
||||
is := get(t, c.harness, n)
|
||||
if is.FixingFrom == nil || !is.FixingFrom.Equal(start) {
|
||||
t.Fatalf("fixing-from %v, want %s", is.FixingFrom, start)
|
||||
}
|
||||
if got, fromLog := c.startOfFixing(is); !got.Equal(start) || fromLog {
|
||||
t.Fatalf("kept: %s from the log %v, want %s kept by the move", got, fromLog, start)
|
||||
}
|
||||
is.FixingFrom = nil
|
||||
if got, fromLog := c.startOfFixing(is); !got.Equal(start) || !fromLog {
|
||||
t.Fatalf("from the log: %s (from the log %v), want %s", got, fromLog, start)
|
||||
}
|
||||
}
|
||||
|
||||
// A value carried into the log holds no moment of its own: after a value at diagnosing, the first value at fixing
|
||||
// is the move's own write, and its start is its worker's active (the move), not its worker's since (the start of
|
||||
// the diagnosis, ADR 0313 §1). A first value already at fixing falls back to its worker's since.
|
||||
func TestACarriedIssueStartsAtTheMoveNotTheDiagnosis(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
diagnosed := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC)
|
||||
moved := diagnosed.Add(40 * time.Minute)
|
||||
carry := func(n uint64, is Issue) {
|
||||
t.Helper()
|
||||
if _, err := c.log.AppendAt(keyOf(n), Operation{Op: OpCarried, By: Seat, At: c.at, Issue: &is}, c.log.lastOf(keyOf(n))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
carry(900, Issue{Number: 900, Title: "x", Status: StatusDiagnosing, Worker: &Worker{By: "fix@g14", Since: diagnosed, Active: diagnosed}})
|
||||
carry(900, Issue{Number: 900, Title: "x", Status: StatusFixing, Worker: &Worker{By: "fix@g14", Since: diagnosed, Active: moved}})
|
||||
if got, fromLog := c.startOfFixing(&Issue{Number: 900}); !got.Equal(moved) || !fromLog {
|
||||
t.Fatalf("start %s (from the log %v), want the move at %s, not the diagnosis at %s", got, fromLog, moved, diagnosed)
|
||||
}
|
||||
carry(901, Issue{Number: 901, Title: "y", Status: StatusFixing, Worker: &Worker{By: "fix@g14", Since: diagnosed, Active: moved}})
|
||||
if got, _ := c.startOfFixing(&Issue{Number: 901}); !got.Equal(diagnosed) {
|
||||
t.Fatalf("a first value at fixing: %s, want its worker's since %s", got, diagnosed)
|
||||
}
|
||||
}
|
||||
|
||||
// An issue moved to fixing before fix times were measured gets its fix time when its fix is next heard of, with its
|
||||
// start from its log; it is kept and shown, never warned on, and leaves the class's warning to a fix measured whole.
|
||||
func TestAFixWhoseStartCameFromTheLogIsNeverWarnedOn(t *testing.T) {
|
||||
c := newFixClock(t)
|
||||
note := ¬ified{}
|
||||
c.Notify = note.notify
|
||||
n := c.open(t, "fixed before the tracker measured")
|
||||
c.fixing(t, n)
|
||||
if _, err := c.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix@g14", FixedBy: []string{"mesh-catalog#81"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// As kept before this change: no fixing-from.
|
||||
if _, err := c.change(n, Act{Op: "test", By: Seat}, func(i *Issue) error { i.FixingFrom = nil; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := c.later(3 * time.Hour)
|
||||
c.delivery(t, 81, "delivered", ClassLeaf, nil, &merged)
|
||||
ft := get(t, c.harness, n).FixTime
|
||||
if ft == nil || !ft.Over || !ft.FromLog || ft.Warned || ft.TookMS != (3*time.Hour).Milliseconds() {
|
||||
t.Fatalf("fix time %+v", ft)
|
||||
}
|
||||
if _, err := c.Sweep(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(note.said) != 0 {
|
||||
t.Fatalf("warned on a fix whose start came from the log: %q", note.said)
|
||||
}
|
||||
// A fix measured whole after it is warned on as before.
|
||||
c.fixOne(t, "measured whole", 82, ClassLeaf, 6*time.Minute, time.Minute, time.Minute, time.Minute)
|
||||
if len(note.said) != 1 {
|
||||
t.Fatalf("said %q", note.said)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's reviews: the first approval at the head, never one dismissed, stale or of an older head.
|
||||
func TestTheReviewEndsAtTheFirstApprovalAtItsHead(t *testing.T) {
|
||||
raw := json.RawMessage(`{"reviews":[
|
||||
{"state":"APPROVED","at_head":false,"submitted":"2026-10-12T09:01:00Z"},
|
||||
{"state":"REQUEST_CHANGES","at_head":true,"submitted":"2026-10-12T09:02:00Z"},
|
||||
{"state":"APPROVED","at_head":true,"dismissed":true,"submitted":"2026-10-12T09:03:00Z"},
|
||||
{"state":"APPROVED","at_head":true,"submitted":"2026-10-12T09:05:00Z"},
|
||||
{"state":"APPROVED","at_head":true,"submitted":"2026-10-12T09:04:00Z"}]}`)
|
||||
at, found, err := firstApproval(raw)
|
||||
if err != nil || !found || at.Format(time.RFC3339) != "2026-10-12T09:04:00Z" {
|
||||
t.Fatalf("%s %v %v", at, found, err)
|
||||
}
|
||||
// The mesh's own reviews: comments by the account that opened the pull request, whose first line is the verdict.
|
||||
head := "43df7ebc5aa21c0bceacd1580f01f67d83d3495d"
|
||||
comments := json.RawMessage(`{"reviews":[
|
||||
{"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Verdict: request changes at ` + head + `\n\nBlocking: …","submitted":"2026-10-12T09:01:00Z"},
|
||||
{"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Looks fine. Verdict: approve at ` + head + `","submitted":"2026-10-12T09:02:00Z"},
|
||||
{"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Verdict: approve at 43df7eb\n\nAll clauses hold.","submitted":"2026-10-12T09:07:00Z"},
|
||||
{"state":"COMMENT","at_head":true,"commit":"` + head + `","body":"Verdict: approve at ` + head + `\n\nAgain.","submitted":"2026-10-12T09:08:00Z"}]}`)
|
||||
at, found, err = firstApproval(comments)
|
||||
if err != nil || !found || at.Format(time.RFC3339) != "2026-10-12T09:07:00Z" {
|
||||
t.Fatalf("a comment's verdict: %s %v %v", at, found, err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
body, commit string
|
||||
approves bool
|
||||
}{
|
||||
{"Verdict: approve at " + head, head, true},
|
||||
{"Verdict: approve at 43df7eb", head, true},
|
||||
{"Verdict: approve at 43df7e", head, false}, // shorter than seven characters
|
||||
{"Verdict: approve at 0123456789", head, false}, // another commit
|
||||
{"Verdict: request changes at " + head, head, false}, // not an approval
|
||||
{"Not yet.\nVerdict: approve at " + head, head, false}, // not the first line
|
||||
{"Verdict: approve at 43df7ebc", "", false}, // no commit to hold it to
|
||||
} {
|
||||
if got := verdictApproves(c.body, c.commit); got != c.approves {
|
||||
t.Errorf("%q at %q approves %v, want %v", c.body, c.commit, got, c.approves)
|
||||
}
|
||||
}
|
||||
if _, found, err := firstApproval(json.RawMessage(`{"reviews":[]}`)); err != nil || found {
|
||||
t.Fatalf("no reviews: %v %v", found, err)
|
||||
}
|
||||
if _, _, err := firstApproval(json.RawMessage(`"nothing"`)); err == nil {
|
||||
t.Fatal("an answer with no reviews read as none")
|
||||
}
|
||||
}
|
||||
|
||||
type errFake string
|
||||
|
||||
func (e errFake) Error() string { return string(e) }
|
||||
@@ -112,12 +112,6 @@ type Issue struct {
|
||||
Triage *Verdict `json:"triage,omitempty"`
|
||||
// Worker is who works on it, while it is diagnosing or fixing (ADR 0284); nil at every other status.
|
||||
Worker *Worker `json:"worker,omitempty"`
|
||||
// FixingFrom is when it was first moved to fixing: where its fix time starts (novox/hq ADR 0313 §1). A
|
||||
// take-over or a later move back to fixing keeps it.
|
||||
FixingFrom *time.Time `json:"fixing-from,omitempty"`
|
||||
// FixTime is its fix time, phase by phase, once the last of its fixes is delivered (ADR 0313 §4): measured,
|
||||
// never set by a verb.
|
||||
FixTime *FixTime `json:"fix-time,omitempty"`
|
||||
// Opening is the id of the call that opened it, so a write whose answer was lost is known as its own.
|
||||
Opening string `json:"opening"`
|
||||
// Imported says that the issue, or its status, was taken from its folder in hq by the import of
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
const Seat = "issue-tracker"
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest declares and claims them.
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set", "tracking", "reserve", "fix-times"}
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set", "tracking", "reserve"}
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
@@ -95,16 +95,13 @@ func main() {
|
||||
CallerOf: stdio.CallerOf,
|
||||
}
|
||||
t.Notify = busNotifier()
|
||||
forgeOwner := func() string {
|
||||
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.ForgeOwner) != "" {
|
||||
return strings.TrimSpace(s.ForgeOwner)
|
||||
}
|
||||
return "novox"
|
||||
}
|
||||
t.Deliveries = func(repository, number string) (Delivery, error) {
|
||||
return busDeliveryState(forgeOwner())(repository, number)
|
||||
owner := "novox"
|
||||
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.ForgeOwner) != "" {
|
||||
owner = strings.TrimSpace(s.ForgeOwner)
|
||||
}
|
||||
return busDeliveryState(owner)(repository, number)
|
||||
}
|
||||
t.Approval = busApproval(forgeOwner)
|
||||
if len(os.Args) > 1 && os.Args[1] == "dry-run" {
|
||||
os.Exit(dryRunAtTheTerminal(t, os.Args[2:]))
|
||||
}
|
||||
@@ -384,10 +381,8 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
Description: "One issue whole, by its number: title, status, severity, what it needs, report, who opened it " +
|
||||
"and when, the owners it is located in, who works on it and since when (worker, and worker-stale when " +
|
||||
"nobody did anything on it for a day), what fixed it and whether that is delivered, its notes and its " +
|
||||
"links; the issues it waits for, those that depend on it (needed-by), and whether it is ready; " +
|
||||
"whether its fix is delivered, as the delivery last said it (merged, held and why, delivered); and, once " +
|
||||
"the last of its fixes is delivered, its fix time (fix-time) from its first move to fixing, its class and " +
|
||||
"fix budget, and its phases: implementing, review, checks, merge to running.",
|
||||
"links; the issues it waits for, those that depend on it (needed-by), and whether it is ready; and " +
|
||||
"whether its fix is delivered, as the delivery last said it (merged, held and why, delivered).",
|
||||
Input: schema([]string{"number"}, map[string]any{"number": number("the issue's number")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("get", m, "number")
|
||||
@@ -715,26 +710,5 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
}
|
||||
return t.reserve(t.callerOf(m), ask)
|
||||
}},
|
||||
{Name: Seat + ".fix-times",
|
||||
Description: "How long fixes take, from the move to fixing to the last fix delivered (the fix time): each class's fix budget (leaf 5m, core 10m), the median and worst fix time of the issues whose last " +
|
||||
"fix was delivered in the last days (7 unless days says otherwise), how many were within and over, and every " +
|
||||
"issue over its budget with its longest phase (implementing, review, checks, merge to running), newest first; " +
|
||||
"issues whose fixes move no module, or whose class is not known, are listed apart with no budget. One issue's " +
|
||||
"fix time, phase by phase, is in its get. A lever meant to shorten fixes is judged by comparing this median " +
|
||||
"over the seven days before it was delivered with the seven days after. Refused: days above 90.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"days": map[string]any{"type": "integer", "minimum": 1, "maximum": MostFixTimesDays, "description": "the window, in days back from now (default 7)"},
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("fix-times", m, "days")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days, err := a.number("days", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.FixTimes(int(days))
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) {
|
||||
// the import and a reservation read the record through the records module (issue 424).
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened, "mesh-issues." + EventMoved, "mesh-delivery.transition"}) ||
|
||||
!reflect.DeepEqual(m.Invokes, []string{"claude-code.claude_code_judge", "seat:mesh-delivery.checks", "seat:operator-channel.notify",
|
||||
"gitea.gitea_pull_reviews", "records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) {
|
||||
"records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) {
|
||||
t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 || m.Build.Artifacts[0].From != "cmd/mesh-issues" ||
|
||||
|
||||
@@ -36,12 +36,8 @@ type Tracker struct {
|
||||
Judge Judgement
|
||||
// Deliveries answers a pull request's delivery (ADR 0279 §3, ADR 0281); nil asks nothing.
|
||||
Deliveries DeliveryState
|
||||
// Notify tells the operator's channel of a stuck fix (ADR 0281) and of a fix over its budget (ADR 0313); nil
|
||||
// says nothing there.
|
||||
// Notify tells the operator's channel of a stuck fix (ADR 0281); nil says nothing there.
|
||||
Notify Notifier
|
||||
// Approval answers when a pull request was first approved at its head (ADR 0313 §4); nil asks nothing, and a
|
||||
// fix's review is counted with its checks.
|
||||
Approval Approval
|
||||
// CallerOf is who called, for the arguments one call was handed (stdio.CallerOf); nil proves nobody.
|
||||
CallerOf func(map[string]any) string
|
||||
// Log is the tracker's record, the log `changes` (novox/hq ADR 0300); the bucket `issues` is its fold.
|
||||
@@ -460,11 +456,6 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) {
|
||||
return err
|
||||
}
|
||||
is.Status = to
|
||||
if to == StatusFixing && from != StatusFixing && is.FixingFrom == nil {
|
||||
// The first move to fixing starts the fix time; none after it restarts it (ADR 0313 §1).
|
||||
start := at
|
||||
is.FixingFrom = &start
|
||||
}
|
||||
if (to == StatusResolved || to == StatusWontfix) && from != to {
|
||||
is.Closed = &at
|
||||
} else if to != StatusResolved && to != StatusWontfix {
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
"groom",
|
||||
"set",
|
||||
"tracking",
|
||||
"reserve",
|
||||
"fix-times"
|
||||
"reserve"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -37,8 +36,7 @@
|
||||
"groom",
|
||||
"set",
|
||||
"tracking",
|
||||
"reserve",
|
||||
"fix-times"
|
||||
"reserve"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -63,7 +61,6 @@
|
||||
"claude-code.claude_code_judge",
|
||||
"seat:mesh-delivery.checks",
|
||||
"seat:operator-channel.notify",
|
||||
"gitea.gitea_pull_reviews",
|
||||
"records.records_status",
|
||||
"records.records_list",
|
||||
"records.records_read",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# mesh-reads
|
||||
|
||||
A node's files and accounts, read through the mesh (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41
|
||||
WP8; issue 418). It holds the node seat `node-reads` on every node, the control-node included, so reading a
|
||||
file, searching one, listing a directory, the owner and mode of each component of a path and looking up an
|
||||
account or a group never go through the login shell's `execute`, and an approval is asked for an act, never
|
||||
for a read.
|
||||
|
||||
## Who it reads as
|
||||
|
||||
The account `mesh-reads`, which this module makes on every node (`runs-as`, ADR 0259 §8): no login shell, a
|
||||
home of its own under `/var/lib`, and no group beside its own. The tool runner serves it in a runtime of its
|
||||
own, on its own bus credential, never the machine's runtime, which runs as the operator's account. So the
|
||||
node's file modes bound what it sees: a file only the operator, root or a module's own account may read is
|
||||
refused by its mode, and the answer says so. It refuses every read when it finds itself running as root.
|
||||
|
||||
## What it refuses by name
|
||||
|
||||
Before any read, whatever the file modes would allow (ADR 0310 §5):
|
||||
|
||||
- **The places the mesh keeps a secret on this node**, which the controller composes from the manifests
|
||||
and gives it as `MESH_SECRET_PLACES`: the directory of every module's own secret (its bus credential
|
||||
among them), every credential file and grant directory, the vault's directories, and where the
|
||||
operator-sealed secrets are kept. Without that list the module does not start.
|
||||
- **The known credential files of a machine's programs** (`refuse.go`): an account's `.ssh`, `.gnupg`,
|
||||
`.aws`, `.kube`; the agent's `.credentials.json`, `.netrc`, `.pgpass`, `.git-credentials`; private key
|
||||
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
|
||||
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
|
||||
file system calls a thing, and the rule would take a temporary directory's or a container layer's name for a
|
||||
token.
|
||||
|
||||
## Its verbs and their bounds
|
||||
|
||||
| verb | replaces | bounds |
|
||||
|---|---|---|
|
||||
| `stat` | `stat`, `ls -ld`, `namei -l`, `findmnt -T` | 200 paths, 64 components each; links followed only with `follow` |
|
||||
| `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 |
|
||||
| `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.
|
||||
@@ -0,0 +1,204 @@
|
||||
// mesh-reads (novox/hq ADR 0310, to-be 41 WP8): the holder of node-reads. A node's files and accounts read
|
||||
// through the mesh — the owner and mode of each component of a path, a link, a directory, a text file, a search
|
||||
// by fixed string, an account or a group — so these reads never go through the login shell's execute.
|
||||
//
|
||||
// It runs as an account of its own, mesh-reads, served by the tool runner in a runtime of its own (ADR 0259
|
||||
// §8): in no group that holds a secret, with no login shell and no home of the operator's, so the node's file
|
||||
// modes bound what it sees. On top, it refuses by name the places a secret is kept (refuse.go) and withholds
|
||||
// any piece of an answer shaped like a secret (mesh-sdk's outward). Read-only: nothing here writes a file.
|
||||
//
|
||||
// stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-reads"
|
||||
|
||||
// PlacesWord is where the controller names this node's secret places (novox/hq ADR 0310 §5).
|
||||
const PlacesWord = "MESH_SECRET_PLACES"
|
||||
|
||||
func main() {
|
||||
places, err := placesFrom(os.Getenv(PlacesWord))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := stdio.Serve("", tools(newReader(places))); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// placesFrom reads the controller's list. Absent is refused: without it, a module's own-secret directory
|
||||
// would be read wherever the account's modes allow, and a refusal the decision promises would be silent.
|
||||
func placesFrom(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, fmt.Errorf("%s is not given, so this node's secret places are unknown and nothing would be "+
|
||||
"refused by them (novox/hq ADR 0310 §5): a controller that knows node-reads composes it", PlacesWord)
|
||||
}
|
||||
var places []string
|
||||
if err := json.Unmarshal([]byte(raw), &places); err != nil {
|
||||
return nil, fmt.Errorf("%s is not a list of paths: %v", PlacesWord, err)
|
||||
}
|
||||
return places, nil
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
switch v := a[k].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func number(a map[string]any, k string) (int, error) {
|
||||
s := arg(a, k)
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s is a whole number, and %q was given", k, s)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// pathsArg is the paths given, one per line — or, from a caller that sends a list, the list.
|
||||
func pathsArg(a map[string]any) []string {
|
||||
var raw []string
|
||||
switch v := a["paths"].(type) {
|
||||
case string:
|
||||
raw = strings.Split(v, "\n")
|
||||
case []any:
|
||||
for _, p := range v {
|
||||
if s, ok := p.(string); ok {
|
||||
raw = append(raw, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for _, p := range raw {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bounded is the last bound (novox/hq ADR 0310): whatever a verb made, no answer is larger than MaxAnswer.
|
||||
func bounded(answer any, err error) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(answer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > MaxAnswer {
|
||||
return nil, fmt.Errorf("the answer is %d bytes, and an answer of node-reads is at most %d: ask for less", len(body), MaxAnswer)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||
// The descriptions are the controller's (internal/catalogue/reads.go), said again here for a caller of the
|
||||
// module's own address.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||
Run: func(a map[string]any) (any, error) { return bounded(run(a)) }}
|
||||
}
|
||||
|
||||
func object(props map[string]any, required ...string) map[string]any {
|
||||
if required == nil {
|
||||
required = []string{}
|
||||
}
|
||||
return map[string]any{"type": "object", "properties": props, "required": required}
|
||||
}
|
||||
|
||||
func tools(r Reader) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
verb("stat", "Each component of one path or several, from / down: its type, owner and group (name and "+
|
||||
"number), mode, whether it is a link and to what, and whether it is a mount point and of which file "+
|
||||
"system. A link is not followed unless follow is \"true\". Read as the account mesh-reads, which holds no "+
|
||||
"secret: a component it may not reach says so. At most 200 paths in one call.",
|
||||
object(map[string]any{"paths": str("one absolute path, or several separated by line breaks"),
|
||||
"follow": str("\"true\": also say where the path leads once every link is followed")}, "paths"),
|
||||
func(a map[string]any) (any, error) { return r.Stat(pathsArg(a), arg(a, "follow") == "true") }),
|
||||
verb("readlink", "What one link points to, as written, and where the path leads once every link is "+
|
||||
"followed, and whether that exists.",
|
||||
object(map[string]any{"path": str("an absolute path")}, "path"),
|
||||
func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }),
|
||||
verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+
|
||||
"target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+
|
||||
"refused by name.",
|
||||
object(map[string]any{"path": str("an absolute path to a directory")}, "path"),
|
||||
func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }),
|
||||
verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+
|
||||
"is asked), cut at a line and said so. A negative from counts from the end, as tail does. A binary file "+
|
||||
"is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped "+
|
||||
"like a secret is withheld and its number said. /proc and /sys are read as far as mesh-reads may.",
|
||||
object(map[string]any{"path": str("an absolute path to a file"),
|
||||
"from": str("the first line to answer, counted from 1; negative: that many lines from the end (optional)"),
|
||||
"lines": str("how many lines to answer (optional)"),
|
||||
"bytes": str("the most bytes to answer, up to 262144 (optional, 65536 unless given)")}, "path"),
|
||||
func(a map[string]any) (any, error) {
|
||||
var ask ReadAsk
|
||||
var err error
|
||||
if ask.From, err = number(a, "from"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ask.Lines, err = number(a, "lines"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ask.Bytes, err = number(a, "bytes"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Read(arg(a, "path"), ask)
|
||||
}),
|
||||
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 "+
|
||||
"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"),
|
||||
"depth": str("how many directories deep, up to 8 (optional)"),
|
||||
"matches": str("the most matches to answer, up to 500 (optional)"),
|
||||
"case": str("\"false\": match whatever the case (optional)")}, "text", "path"),
|
||||
func(a map[string]any) (any, error) {
|
||||
text, _ := a["text"].(string) // as given: a space at either end is part of what is found
|
||||
ask := SearchAsk{Text: text, Path: arg(a, "path"), CaseInsensitive: arg(a, "case") == "false"}
|
||||
var err error
|
||||
if ask.Depth, err = number(a, "depth"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ask.Matches, err = number(a, "matches"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Search(ask)
|
||||
}),
|
||||
verb("account", "One account or group by name or number, as this node's account files say: an account's "+
|
||||
"number, group, home, shell and every group it is in; a group's number and members. With neither, every "+
|
||||
"account and group. Never a password field.",
|
||||
object(map[string]any{"name": str("an account's name or number (optional)"),
|
||||
"group": str("a group's name or number (optional)")}),
|
||||
func(a map[string]any) (any, error) { return r.Account(arg(a, "name"), arg(a, "group")) }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ADR 0310 §3: the account the reads run as is the module's own, made by the mesh, with no login shell, no
|
||||
// home of the operator's and no group beside its own, and the module runs as it.
|
||||
func TestTheAccountHoldsNoSecretsGroupAndNoLogin(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
RunsAs string `json:"runs-as"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Claims []map[string]any `json:"claims"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.RunsAs != "mesh-reads" || m.SecretsOwner != "mesh-reads" {
|
||||
t.Errorf("runs as %q, secrets owned by %q", m.RunsAs, m.SecretsOwner)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0]["name"] != Seat {
|
||||
t.Errorf("claims %v", m.Claims)
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "user" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r["name"] != m.RunsAs {
|
||||
t.Errorf("a user other than its own: %v", r)
|
||||
}
|
||||
if r["shell"] != "/usr/bin/nologin" {
|
||||
t.Errorf("the account has a login shell: %v", r["shell"])
|
||||
}
|
||||
if home, _ := r["home"].(string); !strings.HasPrefix(home, "/var/lib/") {
|
||||
t.Errorf("the account's home is %q, not one of the mesh's", home)
|
||||
}
|
||||
if _, grouped := r["groups"]; grouped {
|
||||
t.Errorf("the account is put in groups: %v", r["groups"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the module makes no account of its own")
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "user" && r["type"] != "directory" {
|
||||
t.Errorf("a read-only holder declares a %v: %v", r["type"], r)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,562 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A token shaped as a secret, written into files the tests read.
|
||||
const aToken = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
func testReader(t *testing.T, places ...string) (Reader, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
passwd := filepath.Join(dir, "passwd")
|
||||
group := filepath.Join(dir, "group")
|
||||
mountinfo := filepath.Join(dir, "mountinfo")
|
||||
write(t, passwd, "root:x:0:0::/root:/usr/bin/bash\nops:x:1000:1000:The Operator:/home/ops:/usr/bin/zsh\n"+
|
||||
"mesh-reads:x:961:961::/var/lib/mesh-reads:/usr/bin/nologin\n")
|
||||
write(t, group, "root:x:0:\nwheel:x:998:ops\nops:x:1000:\nmesh-reads:x:961:\ndocker:x:970:ops,builder\n")
|
||||
data := filepath.Join(dir, "data")
|
||||
write(t, mountinfo, "22 1 0:21 / / rw,relatime - btrfs /dev/nvme0n1p2 rw\n"+
|
||||
"40 22 0:40 / "+strings.ReplaceAll(data, " ", `\040`)+" rw - nfs4 home-server:/tank/data rw\n")
|
||||
r := Reader{Refuser: Refuser{Places: places}, Passwd: passwd, Group: group, MountInfo: mountinfo,
|
||||
Euid: func() int { return 961 }, Now: time.Now}
|
||||
return r, dir
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §3: run as root, every read is refused, loudly — its bound is an account of its own.
|
||||
func TestEveryReadIsRefusedAsRoot(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
r.Euid = func() int { return 0 }
|
||||
write(t, filepath.Join(dir, "f"), "x\n")
|
||||
if _, err := r.Read(filepath.Join(dir, "f"), ReadAsk{}); err == nil || !strings.Contains(err.Error(), "root") {
|
||||
t.Errorf("a read as root: %v", err)
|
||||
}
|
||||
if _, err := r.Stat([]string{dir}, false); err == nil {
|
||||
t.Error("a stat as root was answered")
|
||||
}
|
||||
if _, err := r.Account("ops", ""); err == nil {
|
||||
t.Error("an account lookup as root was answered")
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §5: without the controller's list of secret places it does not start: a refusal it promises would be
|
||||
// silent.
|
||||
func TestItDoesNotStartWithoutTheNodesSecretPlaces(t *testing.T) {
|
||||
if _, err := placesFrom(""); err == nil || !strings.Contains(err.Error(), PlacesWord) {
|
||||
t.Errorf("no places: %v", err)
|
||||
}
|
||||
if p, err := placesFrom(`["/var/lib/mesh-vault"]`); err != nil || len(p) != 1 {
|
||||
t.Errorf("a list: %v %v", p, err)
|
||||
}
|
||||
if p, err := placesFrom(`[]`); err != nil || len(p) != 0 {
|
||||
t.Errorf("an empty list is a node with no secret place: %v %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §5: a path under the vault's directory, a module's own-secret directory and a known credential file
|
||||
// are each refused by name before any read — though the account's modes would allow it — and a link to one is
|
||||
// refused as the place is.
|
||||
func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) {
|
||||
r0, dir := testReader(t)
|
||||
vault := filepath.Join(dir, "var/lib/mesh-vault")
|
||||
own := filepath.Join(dir, "var/lib/desk-channel")
|
||||
r, _ := r0, dir
|
||||
r.Places = []string{vault, own}
|
||||
for _, p := range []string{
|
||||
filepath.Join(vault, "ledger/entries.json"),
|
||||
filepath.Join(own, "broker"),
|
||||
filepath.Join(own, "settings.json"),
|
||||
filepath.Join(dir, "home/ops/.claude/.credentials.json"),
|
||||
filepath.Join(dir, "home/ops/.ssh/config"),
|
||||
filepath.Join(dir, "home/ops/.pgpass"),
|
||||
filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key"),
|
||||
filepath.Join(dir, "etc/shadow"),
|
||||
filepath.Join(dir, "srv/tls/server.key"),
|
||||
} {
|
||||
write(t, p, "harmless words\n")
|
||||
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("%s was read: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("the vault's directory was listed: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "tmp/innocent")
|
||||
write(t, filepath.Join(dir, "tmp/x"), "")
|
||||
if err := os.Symlink(filepath.Join(own, "broker"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Read(link, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "leads to") {
|
||||
t.Errorf("a link to a secret place was read: %v", err)
|
||||
}
|
||||
// A search under a directory holding a secret place skips it and names it.
|
||||
write(t, filepath.Join(dir, "home/ops/notes.txt"), "harmless words\n")
|
||||
a, err := r.Search(SearchAsk{Text: "harmless", Path: filepath.Join(dir, "home"), Depth: 8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "notes.txt") || len(a.Skipped) != 3 {
|
||||
t.Errorf("search: matches %v, skipped %v", a.Matches, a.Skipped)
|
||||
}
|
||||
// What a public key or a certificate is, is not refused.
|
||||
for _, p := range []string{filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key.pub"), filepath.Join(dir, "etc/ssl/certs/ca.pem")} {
|
||||
write(t, p, "harmless words\n")
|
||||
if _, err := r.Read(p, ReadAsk{}); err != nil {
|
||||
t.Errorf("%s was refused: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §3: what the account's file modes refuse is refused — the first bound, below the names.
|
||||
func TestAFileTheAccountMayNotReadIsRefusedByItsMode(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("run as root, a file's mode refuses nothing")
|
||||
}
|
||||
r, dir := testReader(t)
|
||||
p := filepath.Join(dir, "operators-only")
|
||||
write(t, p, "the operator's words\n")
|
||||
if err := os.Chmod(p, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "may not reach it") {
|
||||
t.Errorf("a file of mode 0000: %v", err)
|
||||
}
|
||||
closed := filepath.Join(dir, "closed")
|
||||
write(t, filepath.Join(closed, "inside"), "x")
|
||||
if err := os.Chmod(closed, 0o700&^0o100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(closed, 0o755)
|
||||
a, err := r.Stat([]string{filepath.Join(closed, "inside")}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := a.Paths[0]; !strings.Contains(got.Stopped, "may not reach") {
|
||||
t.Errorf("a path through a directory it may not search: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §2 and the bounds: a read over the size bound is cut at a line and says so; more than the most is
|
||||
// refused; lines and a tail are answered as asked.
|
||||
func TestAReadIsBounded(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
p := filepath.Join(dir, "big")
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 20000; i++ {
|
||||
fmt.Fprintf(&b, "line %d of the file\n", i)
|
||||
}
|
||||
write(t, p, b.String())
|
||||
a, err := r.Read(p, ReadAsk{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Text) > DefaultReadSize || !strings.Contains(a.Cut, fmt.Sprint(DefaultReadSize)) || a.From != 1 {
|
||||
t.Errorf("a default read: %d bytes, cut %q", len(a.Text), a.Cut)
|
||||
}
|
||||
if _, err := r.Read(p, ReadAsk{Bytes: MaxReadSize + 1}); err == nil {
|
||||
t.Error("more than the most a read answers was answered")
|
||||
}
|
||||
a, err = r.Read(p, ReadAsk{From: 10, Lines: 2})
|
||||
if err != nil || a.Text != "line 10 of the file\nline 11 of the file\n" || a.From != 10 || a.Cut != "" {
|
||||
t.Errorf("lines 10 and 11: %+v %v", a, err)
|
||||
}
|
||||
a, err = r.Read(p, ReadAsk{From: -2})
|
||||
if err != nil || a.Text != "line 19999 of the file\nline 20000 of the file\n" || a.From != 19999 {
|
||||
t.Errorf("the last two lines: %q from %d, %v", a.Text, a.From, err)
|
||||
}
|
||||
if _, err := r.Read(p, ReadAsk{From: 30000}); err == nil || !strings.Contains(err.Error(), "20000 lines") {
|
||||
t.Errorf("a line past the end: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A binary file, a directory and a fifo are refused, and the fifo without waiting for a writer.
|
||||
func TestOnlyATextFileIsRead(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
bin := filepath.Join(dir, "bin")
|
||||
write(t, bin, "ELF\x00\x01\x02")
|
||||
if _, err := r.Read(bin, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "binary") {
|
||||
t.Errorf("a binary file: %v", err)
|
||||
}
|
||||
if _, err := r.Read(dir, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "directory") {
|
||||
t.Errorf("a directory: %v", err)
|
||||
}
|
||||
fifo := filepath.Join(dir, "fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := r.Read(fifo, ReadAsk{}); done <- err }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil || !strings.Contains(err.Error(), "fifo") {
|
||||
t.Errorf("a fifo: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("a read of a fifo waits for a writer")
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §4: a line shaped like a secret is withheld, its number said, and the rest is answered.
|
||||
func TestALineShapedLikeASecretIsWithheld(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
p := filepath.Join(dir, "app.conf")
|
||||
write(t, p, "listen = /run/app.sock\ntoken = "+aToken+"\nuser = app\n")
|
||||
a, err := r.Read(p, ReadAsk{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(a.Text, aToken) || !strings.Contains(a.Text, "user = app") ||
|
||||
!strings.Contains(a.Text, "listen = /run/app.sock") || fmt.Sprint(a.Withheld) != "[2]" {
|
||||
t.Errorf("read: %q withheld %v", a.Text, a.Withheld)
|
||||
}
|
||||
m, err := r.Search(SearchAsk{Text: "token", Path: dir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(m)
|
||||
if strings.Contains(string(body), aToken) || len(m.Matches) != 1 || len(m.Withheld) != 1 {
|
||||
t.Errorf("search: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The bounds of a search: matches, depth, the most asked, and time — each met is said.
|
||||
func TestASearchIsBounded(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
for i := 0; i < 30; i++ {
|
||||
write(t, filepath.Join(dir, fmt.Sprintf("f%02d", i)), "needle\nneedle\n")
|
||||
}
|
||||
a, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Matches) != 7 || !strings.HasPrefix(a.Bound, "matches") {
|
||||
t.Errorf("seven matches asked: %d, bound %q", len(a.Matches), a.Bound)
|
||||
}
|
||||
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: MaxMatches + 1}); err == nil {
|
||||
t.Error("more matches than the most were asked and answered")
|
||||
}
|
||||
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Depth: MaxDepth + 1}); err == nil {
|
||||
t.Error("deeper than the most was asked and answered")
|
||||
}
|
||||
deep := t.TempDir()
|
||||
write(t, filepath.Join(deep, "a/b/c/shallow"), "needle\n")
|
||||
write(t, filepath.Join(deep, "a/b/c/d/e/deep"), "needle\n")
|
||||
a, err = r.Search(SearchAsk{Text: "needle", Path: deep, Depth: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "shallow") {
|
||||
t.Errorf("three deep: %+v", a.Matches)
|
||||
}
|
||||
// Case: as asked.
|
||||
write(t, filepath.Join(deep, "case"), "NEEDLE\n")
|
||||
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case")}); len(a.Matches) != 0 {
|
||||
t.Error("a match whatever the case, unasked")
|
||||
}
|
||||
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case"), CaseInsensitive: true}); len(a.Matches) != 1 {
|
||||
t.Error("no match whatever the case, asked")
|
||||
}
|
||||
// Time: a clock past the bound stops the walk and says so.
|
||||
start := time.Now()
|
||||
calls := 0
|
||||
r.Now = func() time.Time { calls++; return start.Add(time.Duration(calls) * SearchTime / 3) }
|
||||
a, err = r.Search(SearchAsk{Text: "needle", Path: dir})
|
||||
if err != nil || !strings.HasPrefix(a.Bound, "time") {
|
||||
t.Errorf("the time bound: %q %v", a.Bound, err)
|
||||
}
|
||||
// A fixed string: a pattern's characters are themselves.
|
||||
write(t, filepath.Join(deep, "pattern"), "a.c\nabc\n")
|
||||
if a, _ := r.Search(SearchAsk{Text: "a.c", Path: filepath.Join(deep, "pattern")}); len(a.Matches) != 1 || a.Matches[0].Line != 1 {
|
||||
t.Errorf("a.c as a fixed string: %+v", a.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
// The survey issue 418 was asked for: each component of a path with its owner, group, mode, whether it is a
|
||||
// link and to what, and whether it is a mount point — the link not followed unless asked.
|
||||
func TestStatSaysEachComponent(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
data := filepath.Join(dir, "data")
|
||||
write(t, filepath.Join(data, "real/file"), "x")
|
||||
if err := os.Chmod(filepath.Join(data, "real"), os.ModeSetgid|0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("real", filepath.Join(data, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.Stat([]string{filepath.Join(data, "link"), filepath.Join(data, "real/file")}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := a.Paths[0]
|
||||
last := link.Components[len(link.Components)-1]
|
||||
if link.Components[0].Path != "/" || last.Type != "link" || last.Link != "real" || link.LeadsTo != "" {
|
||||
t.Errorf("the link, not followed: %+v", link)
|
||||
}
|
||||
var dataC, realC Component
|
||||
for _, c := range a.Paths[1].Components {
|
||||
switch c.Path {
|
||||
case data:
|
||||
dataC = c
|
||||
case filepath.Join(data, "real"):
|
||||
realC = c
|
||||
}
|
||||
}
|
||||
if dataC.Mount == nil || dataC.Mount.FS != "nfs4" {
|
||||
t.Errorf("the mount point is not said: %+v", dataC)
|
||||
}
|
||||
if realC.Mode != "2750" || realC.Type != "directory" || realC.UID == nil || realC.Perms == "" {
|
||||
t.Errorf("a component's mode: %+v", realC)
|
||||
}
|
||||
if uid := *realC.UID; uid == 1000 && realC.Owner != "ops" {
|
||||
t.Errorf("owner %d named %q", uid, realC.Owner)
|
||||
}
|
||||
a, err = r.Stat([]string{filepath.Join(data, "link")}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Paths[0].LeadsTo != filepath.Join(data, "real") || a.Paths[0].Leads == nil || a.Paths[0].Leads.Type != "directory" {
|
||||
t.Errorf("followed: %+v", a.Paths[0])
|
||||
}
|
||||
a, err = r.Stat([]string{filepath.Join(dir, "missing/deeper")}, false)
|
||||
if err != nil || !strings.Contains(a.Paths[0].Stopped, "does not exist") {
|
||||
t.Errorf("a missing path: %+v %v", a, err)
|
||||
}
|
||||
if _, err := r.Stat([]string{"relative/path"}, false); err == nil {
|
||||
t.Error("a relative path was answered")
|
||||
}
|
||||
many := make([]string, MaxPaths+1)
|
||||
for i := range many {
|
||||
many[i] = "/"
|
||||
}
|
||||
if _, err := r.Stat(many, false); err == nil {
|
||||
t.Error("more paths than the most were answered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadlinkSaysTheTargetAndWhereItLeads(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
write(t, filepath.Join(dir, "target"), "")
|
||||
if err := os.Symlink("target", filepath.Join(dir, "l1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("l1", filepath.Join(dir, "l2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("nowhere", filepath.Join(dir, "dangling")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.Readlink(filepath.Join(dir, "l2"))
|
||||
if err != nil || !a.IsLink || a.Link != "l1" || a.LeadsTo != filepath.Join(dir, "target") || !a.Exists {
|
||||
t.Errorf("l2: %+v %v", a, err)
|
||||
}
|
||||
a, err = r.Readlink(filepath.Join(dir, "dangling"))
|
||||
if err != nil || !a.IsLink || a.Exists || !strings.Contains(a.Stopped, "does not exist") {
|
||||
t.Errorf("dangling: %+v %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSaysEveryEntry(t *testing.T) {
|
||||
r, dir := testReader(t)
|
||||
write(t, filepath.Join(dir, "d/a"), "abc")
|
||||
if err := os.Symlink("a", filepath.Join(dir, "d/b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.List(filepath.Join(dir, "d"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Count != 2 || a.Entries[0].Name != "a" || *a.Entries[0].Size != 3 || a.Entries[1].Type != "link" || a.Entries[1].Link != "a" {
|
||||
t.Errorf("list: %+v", a)
|
||||
}
|
||||
for i := 0; i < MaxEntries+5; i++ {
|
||||
write(t, filepath.Join(dir, "many", fmt.Sprintf("e%05d", i)), "")
|
||||
}
|
||||
a, err = r.List(filepath.Join(dir, "many"))
|
||||
if err != nil || len(a.Entries) != MaxEntries || a.Count != MaxEntries+5 || a.Cut == "" {
|
||||
t.Errorf("a long directory: %d of %d, cut %q, %v", len(a.Entries), a.Count, a.Cut, err)
|
||||
}
|
||||
}
|
||||
|
||||
// getent passwd, getent group and id: an account with every group it is in, a group with its members, never a
|
||||
// password field.
|
||||
func TestAccountAndGroupLookup(t *testing.T) {
|
||||
r, _ := testReader(t)
|
||||
a, err := r.Account("ops", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := a.Accounts[0]
|
||||
sort.Strings(u.Groups)
|
||||
if u.UID != 1000 || u.Group != "ops" || u.Shell != "/usr/bin/zsh" || fmt.Sprint(u.Groups) != "[docker ops wheel]" {
|
||||
t.Errorf("ops: %+v", u)
|
||||
}
|
||||
if a, err := r.Account("961", ""); err != nil || a.Accounts[0].Name != "mesh-reads" {
|
||||
t.Errorf("by number: %+v %v", a, err)
|
||||
}
|
||||
g, err := r.Account("", "docker")
|
||||
if err != nil || g.Groups[0].GID != 970 || fmt.Sprint(g.Groups[0].Members) != "[ops builder]" {
|
||||
t.Errorf("docker: %+v %v", g, err)
|
||||
}
|
||||
if _, err := r.Account("nobody-here", ""); err == nil {
|
||||
t.Error("a missing account was answered")
|
||||
}
|
||||
all, err := r.Account("", "")
|
||||
if err != nil || len(all.Accounts) != 3 || len(all.Groups) != 5 {
|
||||
t.Errorf("all: %+v %v", all, err)
|
||||
}
|
||||
body, _ := json.Marshal(all)
|
||||
if strings.Contains(string(body), `"x"`) || strings.Contains(string(body), "password") {
|
||||
t.Errorf("a password field is answered: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// No answer is larger than the bound, whatever a verb made.
|
||||
func TestNoAnswerIsLargerThanTheBound(t *testing.T) {
|
||||
if _, err := bounded(strings.Repeat("x", MaxAnswer+1), nil); err == nil {
|
||||
t.Error("an answer over the bound was given")
|
||||
}
|
||||
if _, err := bounded("small", nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat's six verbs, as the controller defines them (mesh-controller internal/catalogue/reads.go).
|
||||
func TestItServesTheSeatsVerbs(t *testing.T) {
|
||||
r, _ := testReader(t)
|
||||
var names []string
|
||||
for _, tool := range tools(r) {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
if fmt.Sprint(names) != "[node-reads.stat node-reads.readlink node-reads.list node-reads.read node-reads.search node-reads.account]" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
// What is refused by name, before any read (novox/hq ADR 0310 §5), and what an answer withholds (§4).
|
||||
//
|
||||
// **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret
|
||||
// directory, every credential file and grant directory, the vault's directories — come from the controller,
|
||||
// which composes them from the manifests (MESH_SECRET_PLACES). The known credential files of the programs
|
||||
// on a machine are this module's list, below: the mesh does not write them, so nothing else names them. Both
|
||||
// hold whatever the account's file modes would allow: the file modes are the first bound, the names the second.
|
||||
|
||||
// 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.
|
||||
var knownNames = map[string]bool{
|
||||
".credentials.json": true, // the agent's credentials (Claude Code)
|
||||
".netrc": true, ".pgpass": true, ".git-credentials": true, ".npmrc": true, ".pypirc": true,
|
||||
".my.cnf": true, ".vault-token": true, "credentials": true, "credentials.json": true,
|
||||
"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.
|
||||
var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".creds", ".nk", ".kdbx", "_key"}
|
||||
|
||||
// knownPaths are the places on a machine that hold credentials and are not a module's.
|
||||
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.
|
||||
Places []string
|
||||
}
|
||||
|
||||
// Refused says why a path may not be read, or "" when it may. The path is clean and absolute.
|
||||
func (r Refuser) Refused(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
for _, p := range r.Places {
|
||||
if within(path, p) {
|
||||
return "it is a place the mesh keeps a secret (" + p + ")"
|
||||
}
|
||||
}
|
||||
for _, p := range knownPaths {
|
||||
if within(path, p) {
|
||||
return "it is a place a machine keeps credentials (" + p + ")"
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
if i == len(parts)-1 {
|
||||
if knownNames[part] {
|
||||
return part + " is a known credential file"
|
||||
}
|
||||
for _, s := range knownSuffixes {
|
||||
if strings.HasSuffix(part, s) && len(part) > len(s) {
|
||||
return part + " is named as a key or a credential (" + s + ")"
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(part, ".pem") && (strings.Contains(part, "key") || strings.Contains(part, "private")) {
|
||||
return part + " is named as a private key"
|
||||
}
|
||||
if part == "config.json" && i > 0 && parts[i-1] == ".docker" {
|
||||
return "a container registry login is kept there"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func within(path, place string) bool {
|
||||
return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/")
|
||||
}
|
||||
|
||||
// Withheld is one piece of an answer the content rule kept back: where it was, and the class it carried.
|
||||
type Withheld struct {
|
||||
At string `json:"at"`
|
||||
Class string `json:"why"`
|
||||
}
|
||||
|
||||
// clean is the content rule's secret class (mesh-sdk outward, novox/hq ADR 0310 §4) over one piece of an
|
||||
// answer: the piece, or a mark in its place when it is shaped like a secret.
|
||||
//
|
||||
// **What a file holds, not what the file system names.** It judges a file's lines, a matching line, a link's
|
||||
// target, a mount's source and an account's words. A path and an entry's name are not judged: they are what
|
||||
// the caller named or the file system calls a thing, and the rule's random-run test takes a temporary
|
||||
// directory's or a container layer's name for a token, which would withhold the very survey asked for. A
|
||||
// place where a secret sits by name is refused before any read instead (Refuser).
|
||||
func clean(s string) (string, string) {
|
||||
if refusal, ok := outward.CheckSecret(s); !ok {
|
||||
return "‹withheld: " + refusal.What + "›", refusal.What
|
||||
}
|
||||
return s, ""
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module mesh-reads
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2
|
||||
@@ -0,0 +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=
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"module": "mesh-reads",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-reads",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-reads",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"stat",
|
||||
"readlink",
|
||||
"list",
|
||||
"read",
|
||||
"search",
|
||||
"account"
|
||||
]
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-reads",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-reads",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-reads"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-reads",
|
||||
"place": "."
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-reads",
|
||||
"binary": "mesh-reads",
|
||||
"loads": [
|
||||
"mesh-reads"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user