Merge origin/main into the join: provisioner asks bounded, mesh-files fixes, step answer bound
Brings #664 (licences_switches carried entries), #665 (mesh-files lists a place a secret is kept), #666 (provisioner: bounded retiring ask id, a refused ask said, provisioner_health), #667 (mesh-build-go's step end rewritten without the taken versions when over the steps state's bound), #668 (mesh-files find writes the NUL itself). #667's buildstep/step.go and digestshape_test.go conflict with G's deletion (the step runner moved to mesh-delivery-sdk/steprun, SD5): G's side is kept; its intent, an answer over the bound not lost, is G's A-290 (the answer kept as an output object). Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
@@ -92,6 +92,21 @@ func (r *Reader) Tools() []stdio.Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// carriedForm gives an entry carried over from the old table `audit` (§12) the fields of §37: the old manager wrote a
|
||||
// switch as `consumer` (one), `licence` (the one moved to), `from` and `by`, and no `consumers` or `to`.
|
||||
func carriedForm(e map[string]any) {
|
||||
if _, ok := e["to"]; !ok {
|
||||
if l, ok := e["licence"]; ok {
|
||||
e["to"] = l
|
||||
}
|
||||
}
|
||||
if _, ok := e["consumers"]; !ok {
|
||||
if c, ok := e["consumer"]; ok {
|
||||
e["consumers"] = []any{c}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before
|
||||
// since, at most limit (§37).
|
||||
func (r *Reader) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) {
|
||||
@@ -118,6 +133,7 @@ func (r *Reader) Switches(licence, consumer string, since time.Time, limit int)
|
||||
if json.Unmarshal(all[i].Value, &e) != nil {
|
||||
continue
|
||||
}
|
||||
carriedForm(e)
|
||||
at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"]))
|
||||
if at.Before(since) {
|
||||
continue
|
||||
|
||||
@@ -229,6 +229,26 @@ func TestSwitchesAnswersBothCauses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §37, §12: a switch carried over from the old table keeps the old field names (consumer,
|
||||
// licence) and is answered with `to` and `consumers`, and found by them.
|
||||
func TestSwitchesAnswersCarriedEntries(t *testing.T) {
|
||||
rt := newRuntime(t)
|
||||
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-11T21:10:33Z", "consumer": "n1",
|
||||
"licence": "d@example.org", "from": "c@example.org", "by": "a@example.org"})
|
||||
r := rt.reader()
|
||||
for _, args := range []map[string]any{nil, {"consumer": "n1"}, {"licence": "d@example.org"}} {
|
||||
got := run(t, r, "licences_switches", args).([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%v: %v", args, got)
|
||||
}
|
||||
e := got[0].(map[string]any)
|
||||
if e["to"] != "d@example.org" || e["from"] != "c@example.org" || e["by"] != "a@example.org" ||
|
||||
len(e["consumers"].([]any)) != 1 || e["consumers"].([]any)[0] != "n1" {
|
||||
t.Fatalf("%v: %v", args, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §40/AC1, B18, design 39 parts §3: a value set at node level is answered with its level, the
|
||||
// rest with the default, each with its value and default, and an invalid one saying why, read from the settings file;
|
||||
// mesh-config not answering is an error, never the default.
|
||||
|
||||
@@ -58,7 +58,7 @@ an override the override and the owned paths it went through. The caller is the
|
||||
## What it refuses
|
||||
|
||||
- A place a secret is kept (`MESH_SECRET_PLACES`, and the known credential files in `refuse.go`) is refused by
|
||||
name for `read`, `list` and `search`. Copying or moving such a place is allowed: nothing is printed.
|
||||
name for `read` and `search`; `list` answers its entries, never content, marked `secret-place`. Copying or moving such a place is allowed: nothing is printed.
|
||||
- A copy or move to or from `<node>:/path` is refused: copying between nodes comes in step 2 (issue 903).
|
||||
- A write without a reason; a path that is not absolute; a mode that is not a mode.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type fakeRoot struct {
|
||||
calls []string
|
||||
cat map[string]string
|
||||
out map[string]string // the answer to a whole call, "bin arg arg"
|
||||
fail map[string]error // the error of a whole call
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -31,6 +32,15 @@ func (r *fakeRoot) Run(bin string, args ...string) ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, bin+" "+strings.Join(args, " "))
|
||||
for _, a := range append([]string{bin}, args...) {
|
||||
if strings.ContainsRune(a, 0) {
|
||||
// The kernel refuses a program argument holding a NUL byte, as exec does on a node.
|
||||
return nil, fmt.Errorf("fork/exec /usr/bin/sudo: invalid argument")
|
||||
}
|
||||
}
|
||||
if e, ok := r.fail[bin+" "+strings.Join(args, " ")]; ok {
|
||||
return nil, e
|
||||
}
|
||||
if o, ok := r.out[bin+" "+strings.Join(args, " ")]; ok {
|
||||
return []byte(o), nil
|
||||
}
|
||||
@@ -668,7 +678,7 @@ func TestTheReadsFallBackToRoot(t *testing.T) {
|
||||
t.Errorf("readlink through root: %+v %v", rl, err)
|
||||
}
|
||||
// list
|
||||
g.root.out["/usr/bin/find -H "+in+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] =
|
||||
g.root.out["/usr/bin/find -H "+in+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\\0"] =
|
||||
"f\t1000\t998\t640\t2\t\tf\x00d\t1000\t1000\t755\t4096\t\tsub dir\x00l\t1000\t1000\t777\t1\tf\tl\x00"
|
||||
ls, err := g.List(in)
|
||||
if err != nil || ls.Count != 3 {
|
||||
@@ -685,7 +695,7 @@ func TestTheReadsFallBackToRoot(t *testing.T) {
|
||||
t.Errorf("entries: %+v", ls.Entries)
|
||||
}
|
||||
// search: the files are listed by find, the secret ones skipped by name, the rest read through cat
|
||||
g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\x00"] =
|
||||
g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\\0"] =
|
||||
in + "/f\x00" + locked + "/.ssh/id_rsa\x00"
|
||||
g.root.cat[in+"/f"] = "one\nneedle here\n"
|
||||
g.root.reach(in + "/f")
|
||||
@@ -699,11 +709,20 @@ func TestTheReadsFallBackToRoot(t *testing.T) {
|
||||
if g.root.called("/usr/bin/cat -- " + locked + "/.ssh/id_rsa") {
|
||||
t.Error("root read a credential file")
|
||||
}
|
||||
// a secret place is refused by name, and root is never asked
|
||||
g.root.calls = nil
|
||||
if _, err := g.List(g.path("home/.ssh")); err == nil {
|
||||
t.Error("a secret place was listed")
|
||||
// a secret place is listed, as root lists it, its entries marked and never their content (ADR 0394 §7); a path
|
||||
// that is not there under a directory the account may not enter is said missing (design 39 parts §15)
|
||||
secretDir := locked + "/.ssh"
|
||||
g.root.out["/usr/bin/find -H "+secretDir+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\\0"] =
|
||||
"f\t1000\t1000\t600\t9\t\tid_rsa\x00"
|
||||
if ls, err := g.List(secretDir); err != nil || ls.Secret == "" || len(ls.Entries) != 1 || ls.Entries[0].Name != "id_rsa" {
|
||||
t.Errorf("a secret place was not listed through root: %+v %v", ls, err)
|
||||
}
|
||||
g.root.fail = map[string]error{"/usr/bin/stat -c " + statFormat + " -- " + in + "/gone": errors.New(
|
||||
"sudo -n /usr/bin/stat: stat: cannot statx '" + in + "/gone': No such file or directory")}
|
||||
if st, err := g.Stat([]string{in + "/gone"}, false); err != nil || !strings.Contains(st.Paths[0].Stopped, "does not exist") {
|
||||
t.Errorf("a missing path under a closed directory: %+v %v", st, err)
|
||||
}
|
||||
g.root.calls = nil
|
||||
if _, err := g.Read(locked+"/.ssh/id_rsa", ReadAsk{}); err == nil || len(g.root.calls) != 0 {
|
||||
t.Errorf("a credential file was read: %v %v", err, g.root.calls)
|
||||
}
|
||||
|
||||
@@ -171,7 +171,9 @@ func object(props map[string]any, required ...string) map[string]any {
|
||||
|
||||
// The descriptions of the reads are the controller's (internal/catalogue/files.go): the reads' own, with
|
||||
// where they are read as restated for the operator's account.
|
||||
const asAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
|
||||
const readsAs = "as the node's operator account, through sudo -n where its rights do not reach"
|
||||
|
||||
const asAccount = readsAs + "; a place a " +
|
||||
"secret is kept, or a known credential file, is refused by name"
|
||||
|
||||
func boolArg(a map[string]any, k string) bool { return arg(a, k) == "true" }
|
||||
@@ -266,7 +268,7 @@ func tools(f *Files) []stdio.Tool {
|
||||
func readTools(r Reader) []stdio.Tool {
|
||||
ts := rawReadTools(r)
|
||||
for i := range ts {
|
||||
if !strings.Contains(ts[i].Description, asAccount) {
|
||||
if !strings.Contains(ts[i].Description, readsAs) {
|
||||
ts[i].Description += " Read " + asAccount + "."
|
||||
}
|
||||
}
|
||||
@@ -286,8 +288,9 @@ func rawReadTools(r Reader) []stdio.Tool {
|
||||
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.",
|
||||
"target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept, at any depth "+
|
||||
"beneath it, is listed too and marked secret-place: its entries, never their content; what reaches a "+
|
||||
"process's memory or files (/proc/<pid>/environ, mem, fd, map_files, root, cwd) is refused by name. Read "+readsAs+".",
|
||||
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 "+
|
||||
|
||||
@@ -100,6 +100,12 @@ var errRefusedByName = errors.New("refused by name")
|
||||
// (/proc/self/fd) is judged by name like any path asked. A deleted file, or one the kernel names no path for, is
|
||||
// refused.
|
||||
func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileInfo, error) {
|
||||
return r.openJudgedIn(path, nofollow, dir, false)
|
||||
}
|
||||
|
||||
// openJudgedIn is openJudged where a list may open a place a secret is kept (listing it reads no content;
|
||||
// novox/hq ADR 0394 §7): what reaches a process's memory or files stays refused.
|
||||
func (r Reader) openJudgedIn(path string, nofollow, dir, listing bool) (*os.File, fs.FileInfo, error) {
|
||||
stat := os.Stat
|
||||
if nofollow {
|
||||
stat = os.Lstat
|
||||
@@ -136,9 +142,9 @@ func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileIn
|
||||
return nil, nil, fmt.Errorf("%s is refused: the kernel names no path for what was opened, so it cannot be "+
|
||||
"judged by name (novox/hq ADR 0394 §5)", path)
|
||||
}
|
||||
if why := r.Refused(real); why != "" {
|
||||
if why := r.Refused(real); why != "" && !(listing && !reachesProcess(real)) {
|
||||
f.Close()
|
||||
return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §5): %w", path, real, why, errRefusedByName)
|
||||
return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §7): %w", path, real, why, errRefusedByName)
|
||||
}
|
||||
return f, info, nil
|
||||
}
|
||||
@@ -308,6 +314,10 @@ var rootTypes = map[string]string{"regular file": "file", "regular empty file":
|
||||
func (r Reader) componentAsRoot(p string, names accounts, mounts map[string]Mount) (Component, bool) {
|
||||
out, err := r.Root.Run("/usr/bin/stat", "-c", statFormat, "--", p)
|
||||
f := strings.Split(strings.TrimSpace(string(out)), "|")
|
||||
if err != nil && strings.Contains(err.Error(), "No such file or directory") {
|
||||
// root reached the place and it is not there: said as missing, not as refused by the file modes.
|
||||
return Component{Path: p, Unreachd: why(fs.ErrNotExist)}, true
|
||||
}
|
||||
if err != nil || len(f) != 6 {
|
||||
return Component{}, false
|
||||
}
|
||||
@@ -478,6 +488,9 @@ type ListAnswer struct {
|
||||
Count int `json:"count"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
// Secret says why a read under the place is refused, when the directory is a place a secret is kept: its
|
||||
// entries are answered, never their content (novox/hq ADR 0394 §7).
|
||||
Secret string `json:"secret-place,omitempty"`
|
||||
}
|
||||
|
||||
// refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a
|
||||
@@ -494,17 +507,45 @@ func (r Reader) refusedRead(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// listedPlace judges a directory asked to be listed, and where it leads: a place a secret is kept is listed
|
||||
// (its entries, never content) and said so; what reaches a process's memory or files is refused by name
|
||||
// (novox/hq ADR 0394 §7).
|
||||
func (r Reader) listedPlace(path string) (string, error) {
|
||||
var secret string
|
||||
judge := func(p, how string) error {
|
||||
why := r.Refused(p)
|
||||
switch {
|
||||
case why == "":
|
||||
case reachesProcess(p):
|
||||
return fmt.Errorf("%s%s is refused by name: %s (novox/hq ADR 0394 §7)", path, how, why)
|
||||
case secret == "":
|
||||
secret = why
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := judge(path, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path {
|
||||
if err := judge(leads, " leads to "+leads+", which"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func (r Reader) List(raw string) (ListAnswer, error) {
|
||||
path, err := absolute(raw)
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
if err := r.refusedRead(path); err != nil {
|
||||
secret, err := r.listedPlace(path)
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
names := r.names()
|
||||
var all []Entry
|
||||
dir, _, err := r.openJudged(path, false, true)
|
||||
dir, _, err := r.openJudgedIn(path, false, true, true)
|
||||
switch {
|
||||
case err != nil && r.Root != nil && errors.Is(err, fs.ErrPermission):
|
||||
if all, err = r.listAsRoot(path, names); err != nil {
|
||||
@@ -540,15 +581,15 @@ func (r Reader) List(raw string) (ListAnswer, error) {
|
||||
}
|
||||
all = append(all, en)
|
||||
}
|
||||
return r.finishList(path, all, len(entries)), nil
|
||||
return r.finishList(path, all, len(entries), secret), nil
|
||||
}
|
||||
return r.finishList(path, all, len(all)), nil
|
||||
return r.finishList(path, all, len(all), secret), nil
|
||||
}
|
||||
|
||||
// finishList bounds a list and holds each link's target to the content rule. total is how many entries the
|
||||
// directory holds.
|
||||
func (r Reader) finishList(path string, all []Entry, total int) ListAnswer {
|
||||
a := ListAnswer{Path: path, Count: total, Entries: []Entry{}}
|
||||
func (r Reader) finishList(path string, all []Entry, total int, secret string) ListAnswer {
|
||||
a := ListAnswer{Path: path, Count: total, Entries: []Entry{}, Secret: secret}
|
||||
if total > MaxEntries {
|
||||
a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, total)
|
||||
}
|
||||
@@ -569,7 +610,7 @@ func (r Reader) finishList(path string, all []Entry, total int) ListAnswer {
|
||||
// a name cannot confuse it: the fields end with a NUL, and the name is the last.
|
||||
func (r Reader) listAsRoot(path string, names accounts) ([]Entry, error) {
|
||||
out, err := r.Root.Run("/usr/bin/find", "-H", path, "-mindepth", "1", "-maxdepth", "1", "-printf",
|
||||
"%y\t%U\t%G\t%m\t%s\t%l\t%f\x00")
|
||||
"%y\t%U\t%G\t%m\t%s\t%l\t%f\\0") // find writes the NUL: an argument may not hold one
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s cannot be listed, not even through sudo: %v", path, err)
|
||||
}
|
||||
@@ -926,7 +967,7 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
|
||||
// and searches each: the names are judged first, so no credential file is read.
|
||||
func (r Reader) searchBelowAsRoot(p string, depth int, needle string, fold bool, bound int, deadline time.Time, a *SearchAnswer) error {
|
||||
out, err := r.Root.Run("/usr/bin/find", "-H", p, "-maxdepth", strconv.Itoa(depth), "-xdev", "-type", "f",
|
||||
"-printf", "%p\x00")
|
||||
"-printf", "%p\\0") // find writes the NUL: an argument may not hold one
|
||||
if err != nil {
|
||||
a.Unreadable++
|
||||
return nil
|
||||
|
||||
@@ -85,8 +85,33 @@ func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) {
|
||||
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)
|
||||
// a list of a place a secret is kept answers its entries, never content, marked (ADR 0394 §7)
|
||||
if a, err := r.List(vault); err != nil || a.Secret == "" || a.Count != 1 || a.Entries[0].Name != "ledger" {
|
||||
t.Errorf("the vault's directory was not listed as a secret place: %+v %v", a, err)
|
||||
}
|
||||
if a, err := r.List(filepath.Join(vault, "ledger")); err != nil || a.Secret == "" || a.Count != 1 {
|
||||
t.Errorf("beneath the vault's directory: %+v %v", a, err)
|
||||
}
|
||||
if a, err := r.List(filepath.Join(dir, "home/ops")); err != nil || a.Secret != "" {
|
||||
t.Errorf("a plain directory is marked: %+v %v", a, err)
|
||||
}
|
||||
// a link in a secret place: the entry is answered, its target held to the content rule (ADR 0394 §8)
|
||||
if err := os.Symlink(aToken, filepath.Join(vault, "ledger/tokenlink")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a, err := r.List(filepath.Join(vault, "ledger")); err != nil || a.Secret == "" || len(a.Withheld) != 1 ||
|
||||
a.Withheld[0].At != "a link's target" || strings.Contains(a.Entries[0].Link+a.Entries[1].Link, aToken) {
|
||||
t.Errorf("a secret-shaped target in a secret place: %+v %v", a, err)
|
||||
}
|
||||
// the served text of list says it lists a secret place, and no longer that it refuses one
|
||||
for _, tl := range readTools(r) {
|
||||
if strings.HasSuffix(tl.Name, ".list") && (strings.Contains(tl.Description, "known credential file, is refused") ||
|
||||
!strings.HasSuffix(tl.Description, "where its rights do not reach.")) {
|
||||
t.Errorf("the list text: %s", tl.Description)
|
||||
}
|
||||
}
|
||||
if _, err := r.List("/proc/self/fd"); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("a process's open files were listed: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "tmp/innocent")
|
||||
write(t, filepath.Join(dir, "tmp/x"), "")
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
// What is refused by name, before any read (novox/hq ADR 0394 §5), and what an answer withholds (§4).
|
||||
// What is refused by name, before any read (novox/hq ADR 0394 §7), 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,
|
||||
@@ -103,6 +103,12 @@ func (r Refuser) Refused(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// reachesProcess says whether a path is an entry of a process that reaches its memory, environment or files.
|
||||
func reachesProcess(path string) bool {
|
||||
parts := strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/")
|
||||
return len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]]
|
||||
}
|
||||
|
||||
func within(path, place string) bool {
|
||||
return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/")
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -105,8 +107,8 @@ type Composer func(ctx context.Context, node string, keptAt map[string]string) C
|
||||
// Composition is a node's plans at its wanted builds and at its newest, and where the time went.
|
||||
type Composition struct {
|
||||
AtWants, Newest HeldPlans
|
||||
// Unpublished is each module whose composition at the build it is read at is not published yet: the node's picture
|
||||
// is not whole, so no retirement is judged on it (issue 940).
|
||||
// Unpublished is each module whose composition at the build it is read at is not published yet: its own plan and keys
|
||||
// are kept as they were, and nothing else on the node waits for it (issue 940).
|
||||
Unpublished []string
|
||||
// UnpublishedNewest is each module whose composition at its newest build (which is not its wanted build) is not
|
||||
// published yet: the newest build's needs are unread, so no key only that build requires is judged (issue 1966).
|
||||
@@ -1070,6 +1072,25 @@ const askHeldWithin = time.Minute
|
||||
// askWaits is what a retiring key's why says while its ask waits for an open one to end.
|
||||
const askWaits = "its ask waits until fewer than 3 of mesh-provisioner's asks stand open, the router showing at most 3 per asker"
|
||||
|
||||
// askRefusedNote opens what a retiring key's why says once its ask is refused: the ask is not raised, and the key says
|
||||
// so here, and provisioner_health names the key with those words.
|
||||
const askRefusedNote = "its ask is refused: "
|
||||
|
||||
// askID is a retiring ask's id: letters, digits and - only, of bounded length (the router refuses over 64), made from the
|
||||
// key by its digest and the time, so one key's asks differ and no key's length matters.
|
||||
func askID(key string, at time.Time) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return "retire-" + hex.EncodeToString(sum[:8]) + "-" + fmt.Sprint(at.Unix())
|
||||
}
|
||||
|
||||
// withoutAskNotes is why without what ask() said of its ask (askWaits, askRefusedNote).
|
||||
func withoutAskNotes(why string) string {
|
||||
if i := strings.Index(why, askRefusedNote); i >= 0 {
|
||||
why = why[:i]
|
||||
}
|
||||
return strings.TrimSuffix(strings.TrimSuffix(why, askWaits), "; ")
|
||||
}
|
||||
|
||||
// errAskWaits is an ask held back by askOpenAtMost: no fault, raised once one ends.
|
||||
var errAskWaits = errors.New(askWaits)
|
||||
|
||||
@@ -1095,21 +1116,26 @@ func (h *Holder) ask(p *meshprovisionerclient.Provision, key string) error {
|
||||
}
|
||||
if h.openAsks() >= askOpenAtMost {
|
||||
if !strings.Contains(p.Why, askWaits) {
|
||||
p.Why = strings.TrimPrefix(p.Why+"; "+askWaits, "; ")
|
||||
p.Why = strings.TrimPrefix(withoutAskNotes(p.Why)+"; "+askWaits, "; ")
|
||||
}
|
||||
return errAskWaits
|
||||
}
|
||||
now := h.Now().UTC()
|
||||
id := "retire-" + strings.ReplaceAll(key, ".", "-") + "-" + fmt.Sprint(now.Unix())
|
||||
id := askID(key, now)
|
||||
a := retiringAsk(key, *p, now, id)
|
||||
if err := occ.Check(a, now); err != nil {
|
||||
// A refused ask is said in the key's why, where the operator reads why it stands, and on provisioner_health.
|
||||
refused := func(err error) error {
|
||||
p.Why = strings.TrimPrefix(withoutAskNotes(p.Why)+"; "+askRefusedNote+err.Error(), "; ")
|
||||
return err
|
||||
}
|
||||
if err := occ.Check(a, now); err != nil {
|
||||
return refused(err)
|
||||
}
|
||||
if err := h.Asks.Publish(oc.AcceptSubjectAsk(Module), a, id); err != nil {
|
||||
return fmt.Errorf("the retiring ask of %s could not be published: %w", key, err)
|
||||
return refused(fmt.Errorf("the retiring ask of %s could not be published: %w", key, err))
|
||||
}
|
||||
p.Ask = id
|
||||
p.Why = strings.TrimSuffix(strings.TrimSuffix(p.Why, askWaits), "; ")
|
||||
p.Why = withoutAskNotes(p.Why)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1340,6 +1366,22 @@ func (h *Holder) Faults() map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// RefusedAsks is each retiring key whose ask the router's rules refuse, as its key with its why: read from the keys
|
||||
// themselves, so a restart keeps it (ask).
|
||||
func (h *Holder) RefusedAsks() map[string]string {
|
||||
out := map[string]string{}
|
||||
keys, _ := h.Store.Keys(ProvisionsState)
|
||||
for _, k := range keys {
|
||||
if e, err := h.Store.Get(ProvisionsState, k); err == nil && e != nil {
|
||||
if p, err := meshprovisionerclient.Decode(e.Value); err == nil && p.State == meshprovisionerclient.Retiring &&
|
||||
p.Ask == "" && strings.Contains(p.Why, askRefusedNote) {
|
||||
out[k] = p.Why
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LastingHolds is each module held unpublished at one build for longer than unpublishedFor, as "<node>.<module>" with
|
||||
// the hold's words, which name its build, and since when.
|
||||
func (h *Holder) LastingHolds() map[string]string {
|
||||
@@ -1514,8 +1556,8 @@ func orSaid(words ...string) string {
|
||||
// builds a module is read at the fingerprint keptAt names, else at the catalog entry's fingerprint (FP2); at the newest, each placed
|
||||
// module at FP2. A key whose engine refused the module holds it with the engine's words; a key not published yet holds
|
||||
// it at its wanted build (its last plan kept, as for a refusal) and at its newest too (issue 1966), whose build the engine
|
||||
// writes once it is done (ADR 0593 §6), and says it unpublished, so the pass judges no retirement on the node (issue
|
||||
// 940). A placed module no build of which is known is held too. A plan absent from a key is a module that hands the
|
||||
// writes once it is done (ADR 0593 §6), and says it unpublished, so only that module's own plan and keys are kept as they were and nothing else on the
|
||||
// node waits for it (issue 940). A placed module no build of which is known is held too. A plan absent from a key is a module that hands the
|
||||
// provisioner nothing.
|
||||
func FromCompositions(s Store, cat CatalogHalf) Composer {
|
||||
return func(_ context.Context, node string, keptAt map[string]string) Composition {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
@@ -33,6 +34,8 @@ type router struct {
|
||||
cancelled []string
|
||||
// failCancel is a router that refuses a cancel.
|
||||
failCancel bool
|
||||
// refuses is a router whose rules refuse every ask, with these words.
|
||||
refuses string
|
||||
}
|
||||
|
||||
func (r *router) Publish(subject string, body any, id string) error {
|
||||
@@ -47,6 +50,9 @@ func (r *router) Publish(subject string, body any, id string) error {
|
||||
delete(r.records, c.ID)
|
||||
return nil
|
||||
}
|
||||
if r.refuses != "" {
|
||||
return errors.New(r.refuses)
|
||||
}
|
||||
r.asked[id] = body.(oc.Ask)
|
||||
if !r.drop {
|
||||
r.records[id], _ = json.Marshal(map[string]any{"state": "open", "ask": body})
|
||||
@@ -852,3 +858,33 @@ func TestAnAskTheRouterDoesNotHoldIsRaisedAgain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1966, U7 (design 47 fix-unpublished-placed-module): a retiring ask's id is bounded and made from the key, so a
|
||||
// long key is asked; an ask the router refuses is said in the key's why and in provisioner_health, and the key is
|
||||
// asked once the refusal ends.
|
||||
func TestARetiringAskIsBoundedAndARefusedOneIsSaid(t *testing.T) {
|
||||
m := newMesh()
|
||||
long := "claude-licence-manager-ab" // with this key the old id "retire-g14-...-<unix>" is 65 characters
|
||||
key := "g14." + long + ".postgres-database"
|
||||
m.atWant["g14"] = map[string]catalogue.Plan{long: planOf(pgDB), "dunst": planOf(oidc)}
|
||||
m.compose(t, "g14")
|
||||
m.atWant["g14"] = map[string]catalogue.Plan{"dunst": planOf(oidc)}
|
||||
m.r.refuses = "its id is refused"
|
||||
c := m.compose(t, "g14")
|
||||
p := m.state(t, key)
|
||||
if p.Ask != "" || !strings.Contains(p.Why, askRefusedNote) || !strings.Contains(p.Why, "its id is refused") || !strings.Contains(strings.Join(c.Problems, " "), "its id is refused") {
|
||||
t.Errorf("a refused ask: ask %q, why %q, problems %v", p.Ask, p.Why, c.Problems)
|
||||
}
|
||||
if a := m.h.health(); a.Healthy || !strings.Contains(a.Why, key) || !strings.Contains(a.Why, "its id is refused") {
|
||||
t.Errorf("health with a refused ask: %+v", a)
|
||||
}
|
||||
m.r.refuses = ""
|
||||
m.compose(t, "g14")
|
||||
p = m.state(t, key)
|
||||
if len(p.Ask) == 0 || len(p.Ask) > 64 || !occ.UsableID(p.Ask) || strings.Contains(p.Why, askRefusedNote) {
|
||||
t.Errorf("the ask once the refusal ends: %q, why %q", p.Ask, p.Why)
|
||||
}
|
||||
if a := m.h.health(); !a.Healthy {
|
||||
t.Errorf("health once the ask is raised: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,17 +91,17 @@ func holderTools(h *Holder) []stdio.Tool {
|
||||
"a pass held back a retirement of most of a node's consumers at once as a fault of the mesh (issue 940), " +
|
||||
"naming the node, the consumers and the likely cause, or while a module's composition has stayed " +
|
||||
"unpublished at one build, wanted or newest, for longer than 45 minutes (longer than a build takes), naming the module, " +
|
||||
"the build and since when.",
|
||||
"the build and since when, or while a retiring provision's ask is refused by the operator channel's rules, naming the key and the refusal.",
|
||||
Run: func(map[string]any) (any, error) { return h.health(), nil }},
|
||||
}
|
||||
}
|
||||
|
||||
// health is the holder's runtime health: unhealthy while a retirement is held back as a fault of the mesh.
|
||||
func (h *Holder) health() health.Answer {
|
||||
faults, holds := h.Faults(), h.LastingHolds()
|
||||
if len(faults)+len(holds) == 0 {
|
||||
faults, holds, refused := h.Faults(), h.LastingHolds(), h.RefusedAsks()
|
||||
if len(faults)+len(holds)+len(refused) == 0 {
|
||||
return health.Healthy("No pass holds back a retirement of most of a node's consumers, and no module's " +
|
||||
"composition has stayed unpublished at one build for longer than " + unpublishedFor.String() + ".")
|
||||
"composition has stayed unpublished at one build for longer than " + unpublishedFor.String() + ", and no retiring ask is refused.")
|
||||
}
|
||||
var said []string
|
||||
for _, n := range sortedKeys(faults) {
|
||||
@@ -110,6 +110,9 @@ func (h *Holder) health() health.Answer {
|
||||
for _, k := range sortedKeys(holds) {
|
||||
said = append(said, k+": "+holds[k]+"; its plan and its provisions are kept as they were.")
|
||||
}
|
||||
for _, k := range sortedKeys(refused) {
|
||||
said = append(said, k+" is retiring and "+refused[k]+".")
|
||||
}
|
||||
return health.Unhealthy(strings.Join(said, " "))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user