Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9cead336 | ||
|
|
b5475cfa5c | ||
|
|
b8ce4f0c0a | ||
|
|
395a37ddda | ||
|
|
b4b22aac20 | ||
|
|
faaa6d3df2 | ||
|
|
0695ae25db | ||
|
|
eb1cf1bb44 | ||
|
|
7e905a6694 | ||
|
|
2455445dab | ||
|
|
bc433b8304 | ||
|
|
647c5c3c06 | ||
|
|
434214e615 | ||
|
|
21c86c819c | ||
|
|
3aae6c0890 | ||
|
|
f3b13de20d | ||
|
|
b7f878226e | ||
|
|
683fd547b2 | ||
|
|
f52545c4ba | ||
|
|
2066021fca | ||
|
|
ca8d6f33a5 | ||
|
|
0e6ca453be | ||
|
|
2d28a52d61 | ||
|
|
7531c3257d | ||
|
|
aec2ed3c24 | ||
|
|
bc27189627 | ||
|
|
6d67c22bf3 | ||
|
|
72cee59acc | ||
|
|
67dcf252ec | ||
|
|
82ac613b4e | ||
|
|
787563358b | ||
|
|
8f212e559a | ||
|
|
1aa22efb0d | ||
|
|
f3a57dcc56 | ||
|
|
35ff67bda0 |
@@ -0,0 +1,5 @@
|
||||
module checks/credentialform
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package credentialform refuses an offer that does not say the form of its credential: an own secret, an issuer, or
|
||||
// `form` pair or none (the lead, 2026-10-29). It reads every modules/*/module.json through mesh-sdk's
|
||||
// CredentialFormProblems.
|
||||
package credentialform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Problems is every offer of the manifests under root/modules that says no credential form, one line each.
|
||||
func Problems(root string) ([]string, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
var out []string
|
||||
reads := map[string]bool{}
|
||||
var binders []string
|
||||
for _, f := range files {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m catalogue.Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
out = append(out, catalogue.CredentialFormProblems(m)...)
|
||||
var kept struct {
|
||||
Reads []string `json:"reads"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &kept) // reads may hold objects in another module; only the provisioner's are read
|
||||
if m.Module == "mesh-provisioner" {
|
||||
for _, r := range kept.Reads {
|
||||
reads[r] = true
|
||||
}
|
||||
}
|
||||
if len(m.Provides) > 0 && keepsBound(kept.State) {
|
||||
binders = append(binders, m.Module)
|
||||
}
|
||||
}
|
||||
for _, module := range binders {
|
||||
if !reads[module+".bound"] {
|
||||
out = append(out, fmt.Sprintf("%s provides and keeps a `bound` state, and mesh-provisioner's manifest does not read %s.bound", module, module))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// keepsBound says whether a manifest's `state` names `bound`, as a plain name or an object with a name.
|
||||
func keepsBound(state []json.RawMessage) bool {
|
||||
for _, s := range state {
|
||||
var name string
|
||||
var obj struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(s, &name) == nil && name == "bound" || json.Unmarshal(s, &obj) == nil && obj.Name == "bound" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package credentialform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aCatalogue(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for module, body := range manifests {
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// An offer that says no form is refused and named; one that says pair, none or an own secret passes; and the real
|
||||
// catalogue is clean.
|
||||
func TestEveryOfferSaysTheFormOfItsCredential(t *testing.T) {
|
||||
root := aCatalogue(t, map[string]string{
|
||||
"silent": `{"module":"silent","provides":[{"name":"thing","scope":"mesh"}]}`,
|
||||
"binder": `{"module":"binder","provides":[{"name":"d","credential":{"form":"pair"}}],"state":["bound"]}`,
|
||||
"mesh-provisioner": `{"module":"mesh-provisioner","reads":["other.bound"]}`,
|
||||
"says": `{"module":"says","provides":[{"name":"a","credential":{"form":"pair"}},{"name":"b","credential":{"form":"none"}},{"name":"c","credential":{"own":"jwt"}}]}`,
|
||||
})
|
||||
got, err := Problems(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || !strings.Contains(got[0], `silent provides "thing"`) ||
|
||||
!strings.Contains(got[1], "binder") || !strings.Contains(got[1], "mesh-provisioner") {
|
||||
t.Fatalf("refused %v, wanted the silent offer and the binder the provisioner does not read", got)
|
||||
}
|
||||
real, err := Problems(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(real) != 0 {
|
||||
t.Fatalf("the catalogue has offers with no credential form:\n%s", strings.Join(real, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# What these tests read outside their package, as paths from the repository's root (issue 1936).
|
||||
# every manifest, for what each offer says of its credential's form.
|
||||
modules/*/module.json
|
||||
@@ -0,0 +1,8 @@
|
||||
# The calls of the controller's hand-act log a manifest of this catalogue may still invoke, each `<module> <verb> <why>`:
|
||||
# the fallback of a recorder (mesh-sdk handacts.Recorder) while the seat mesh-hand-acts has no holder that answers, until
|
||||
# the controller's lease is written to a holder that is no controller. The check of this directory (handacts) refuses any
|
||||
# other, and an entry whose module no longer invokes it: the list only shrinks, and it is empty before the lease write
|
||||
# (novox/hq ADR 0565 §11; issue 1953, issue 774).
|
||||
mesh-config hand-act fallback until the lease write: its sets, clears and held values record through mesh-hand-acts first
|
||||
mesh-config warranted fallback until the lease write: its warrants record through mesh-hand-acts first
|
||||
mesh-healers drill fallback until the lease write: a drill records through mesh-hand-acts first
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/handacts
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,109 @@
|
||||
// Command handacts refuses a manifest that records into the controller's hand-act log (novox/hq ADR 0565 §11): every
|
||||
// recorder records through the seat mesh-hand-acts from its delivery, so no manifest invokes
|
||||
// `seat:mesh-controller.hand-act`, `.warranted` or `.drill`. The recorders' fallbacks to the controller's log while the
|
||||
// seat has no holder are named in checks/handacts/fallbacks, each module and verb with why; that list only shrinks, and
|
||||
// an entry whose module no longer invokes the verb is refused too.
|
||||
//
|
||||
// go run . <catalog root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Verbs are the controller's hand-act verbs, as a manifest invokes them, and Allowed the list of named fallbacks.
|
||||
var Verbs = []string{"hand-act", "warranted", "drill"}
|
||||
|
||||
const (
|
||||
invokePrefix = "seat:mesh-controller."
|
||||
Allowed = "checks/handacts/fallbacks"
|
||||
)
|
||||
|
||||
// Findings is every refusal in the catalog at root, sorted.
|
||||
func Findings(root string) ([]string, error) {
|
||||
allowed := map[string]bool{}
|
||||
raw, err := os.ReadFile(filepath.Join(root, Allowed))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if f := strings.Fields(line); len(f) >= 3 && !strings.HasPrefix(f[0], "#") {
|
||||
allowed[f[0]+" "+f[1]] = true
|
||||
} else if len(f) > 0 && !strings.HasPrefix(f[0], "#") {
|
||||
return nil, fmt.Errorf("%s: %q is not `<module> <verb> <why>`", Allowed, line)
|
||||
}
|
||||
}
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
invoked := map[string]bool{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Invokes []string `json:"invokes"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) != nil || m.Module == "mesh-controller" {
|
||||
continue
|
||||
}
|
||||
for _, inv := range m.Invokes {
|
||||
verb, ok := strings.CutPrefix(strings.TrimSpace(inv), invokePrefix)
|
||||
if !ok || !contains(Verbs, verb) {
|
||||
continue
|
||||
}
|
||||
invoked[m.Module+" "+verb] = true
|
||||
if !allowed[m.Module+" "+verb] {
|
||||
out = append(out, fmt.Sprintf("%s invokes %s%s: record through seat:mesh-hand-acts.%s (novox/hq ADR 0565 §11), "+
|
||||
"or name the fallback in %s", m.Module, invokePrefix, verb, verb, Allowed))
|
||||
}
|
||||
}
|
||||
}
|
||||
for entry := range allowed {
|
||||
if !invoked[entry] {
|
||||
out = append(out, fmt.Sprintf("%s names %q, which is no longer invoked: take it off the list, which only shrinks",
|
||||
Allowed, entry))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: handacts <catalog root>")
|
||||
os.Exit(2)
|
||||
}
|
||||
found, err := Findings(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("no manifest records into the controller's hand-act log but the fallbacks named")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// No manifest invokes the controller's hand-act verbs but a named fallback; a named fallback no longer invoked is
|
||||
// refused (novox/hq ADR 0565 §11's catalog test).
|
||||
func TestFindings(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
want string // empty: clean
|
||||
}{
|
||||
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","invokes":["seat:mesh-hand-acts.hand-act"]}`}, ""},
|
||||
{"an unnamed call", map[string]string{"modules/a/module.json": `{"module":"a","invokes":["seat:mesh-controller.warranted"]}`},
|
||||
"a invokes seat:mesh-controller.warranted"},
|
||||
{"a named fallback, and the controller's own", map[string]string{
|
||||
"modules/a/module.json": `{"module":"a","invokes":["seat:mesh-controller.drill"]}`,
|
||||
"modules/mesh-controller/module.json": `{"module":"mesh-controller","invokes":["seat:mesh-controller.hand-act"]}`,
|
||||
Allowed: "# why\na drill fallback until the lease write\n"}, ""},
|
||||
{"a fallback no longer invoked", map[string]string{Allowed: "gone hand-act fallback\n"}, `names "gone hand-act"`},
|
||||
{"another verb of the controller", map[string]string{"modules/a/module.json": `{"module":"a","invokes":["seat:mesh-controller.conditions"]}`}, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for path, text := range row.files {
|
||||
p := filepath.Join(root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := Findings(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) {
|
||||
t.Fatalf("findings = %q, want one containing %q", all, row.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The run of this check over the real tree.
|
||||
func TestTheCatalogRecordsOnlyThroughTheSeat(t *testing.T) {
|
||||
got, err := Findings(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range got {
|
||||
t.Error(f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# What these tests read outside their package, as paths from the repository's root (issue 1936).
|
||||
# every manifest, for what it invokes.
|
||||
modules/*/module.json
|
||||
@@ -2,4 +2,4 @@ module checks/health
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module checks/manifests
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0 h1:t04baH2Xz/NUsOEV3ctKY59XjiY5WmEuststvH+XeOs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package manifests judges every module's manifest as the node-engine reads it, and the catalogue as one shelf: each
|
||||
// modules/*/module.json through mesh-sdk's ParseManifestWith against the predefined.json at the root, with its templates
|
||||
// read from the tree beside it (WithTemplatesFrom), then the rules between manifests (CatalogueProblems,
|
||||
// IdentityProblems, DataProblems).
|
||||
//
|
||||
// Why it is here (issue 774): on 2026-10-30 mesh-catalog ca8d6f33 gave power's bar item two attributes its bar places
|
||||
// by none of. ParseManifest refuses that, and every node-engine refused to compose at 19:13; no check of the pull
|
||||
// request had parsed the manifest. That judgement was mesh-controller's `module check`, which the merge gate ran over
|
||||
// every manifest before the checker moved to mesh-delivery; the checker runs this repository's Go tests and the
|
||||
// composition, and no test parsed a manifest whole. This one does, in the same functions the nodes use.
|
||||
package manifests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Problems is what is wrong with the manifests under root/modules, one line each: a manifest the mesh refuses, named by
|
||||
// its path, then the rules between the manifests that parse.
|
||||
func Problems(root string) ([]string, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
list, err := catalogue.ReadPredefined(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
shelf := catalogue.Shelf{}
|
||||
for _, f := range files {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, f)
|
||||
m, err := catalogue.ParseManifestWith(raw, list)
|
||||
if err == nil {
|
||||
m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(f))
|
||||
}
|
||||
if err != nil {
|
||||
out = append(out, fmt.Sprintf("%s: %v", rel, err))
|
||||
continue
|
||||
}
|
||||
if _, twice := shelf[m.Module]; twice {
|
||||
out = append(out, fmt.Sprintf("%s: %s was already given; two manifests name one module", rel, m.Module))
|
||||
continue
|
||||
}
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
between := catalogue.CatalogueProblems(shelf)
|
||||
between = append(between, catalogue.IdentityProblems(shelf, catalogue.DefaultLongestMachine)...)
|
||||
between = append(between, catalogue.DataProblems(shelf)...)
|
||||
sort.Strings(between)
|
||||
return append(out, between...), nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package manifests
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every manifest of the catalogue is one the nodes can use, and the catalogue holds together as one shelf. A row is a
|
||||
// manifest written into a catalogue of its own: power's bar item as mesh-catalog ca8d6f33 gave it (issue 774) is
|
||||
// refused naming the attribute, and the same item without it passes.
|
||||
func TestEveryManifestIsOneTheNodesCanUse(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name, attributes, refused string
|
||||
}{
|
||||
{name: "the bar places by bar and place", attributes: `"bar":"bottom","place":"status","order":"45"`},
|
||||
{name: "an attribute the bar places by none of (ca8d6f33)",
|
||||
attributes: `"bar":"bottom","place":"status","order":"45","shows":"state","state":"power.draw"`,
|
||||
refused: `"shows" is none of them`},
|
||||
} {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "modules", "bar-user")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"module":"bar-user","version":"1","moved":true,` +
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[{"collection":"contribution:node-bar:block","attributes":{` + row.attributes + `},"template":"files/bar"}]}`
|
||||
for path, body := range map[string]string{"module.json": manifest, "files/bar": "{\"shows\":\"command\"}\n"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := Problems(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(got, "\n")
|
||||
if (row.refused == "") != (len(got) == 0) || !strings.Contains(joined, row.refused) {
|
||||
t.Errorf("%s: refused %q, want %q", row.name, joined, row.refused)
|
||||
}
|
||||
}
|
||||
real, err := Problems(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(real) != 0 {
|
||||
t.Fatalf("the catalogue holds manifests the nodes cannot use:\n%s", strings.Join(real, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# What these tests read outside their package, as paths from the repository's root (issue 1936).
|
||||
# every module's directory (its manifest and the templates beside it), and the list of predefined keys.
|
||||
modules
|
||||
predefined.json
|
||||
@@ -0,0 +1,4 @@
|
||||
# The modules of this catalogue that still read the controller's assignments (mesh-controller.assignments), each with
|
||||
# why. The check of this directory (noplacement) refuses any other module that reads them, and an entry here whose module no longer
|
||||
# does: the list only shrinks, and it is empty before the controller's lease is written to a holder that is no
|
||||
# controller (novox/hq ADR 0549 §3, §6; issue 774).
|
||||
@@ -1,3 +1,7 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/noplacement
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
// 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16);
|
||||
//
|
||||
// 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…`
|
||||
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`.
|
||||
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`;
|
||||
//
|
||||
// 4. a manifest, other than mesh-controller's, that reads the controller's assignments (`mesh-controller.assignments`):
|
||||
// every reader takes placement from the seat mesh-placement (novox/hq ADR 0549 §3, ADR 0567), and the lease write is
|
||||
// refused while one still reads them (ADR 0549 §6; issue 774). The modules that still read them for what the
|
||||
// placement does not carry are named in checks/noplacement/assignments-readers, each with why; that list only
|
||||
// shrinks: an entry whose module no longer reads them is refused too.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
@@ -55,9 +61,74 @@ func Findings(root string) ([]string, error) {
|
||||
out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s))
|
||||
}
|
||||
}
|
||||
readers, err := assignmentsReaders(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, readers...)
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AssignmentsRead is the controller's assignments as a manifest reads them, and Allowed the file, relative to the
|
||||
// catalogue's root, that names the modules still reading them: `<module> <why>` per line, `#` a comment.
|
||||
const (
|
||||
AssignmentsRead = "mesh-controller.assignments"
|
||||
Allowed = "checks/noplacement/assignments-readers"
|
||||
)
|
||||
|
||||
// assignmentsReaders is every finding on the reads of the controller's assignments: a module other than the controller
|
||||
// reading them that Allowed does not name, and a module Allowed names that no longer reads them (issue 774).
|
||||
func assignmentsReaders(root string) ([]string, error) {
|
||||
allowed := map[string]bool{}
|
||||
raw, err := os.ReadFile(filepath.Join(root, Allowed))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if f := strings.Fields(line); len(f) > 0 && !strings.HasPrefix(f[0], "#") {
|
||||
allowed[f[0]] = true
|
||||
}
|
||||
}
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var found []string
|
||||
reads := map[string]bool{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Reads []string `json:"reads"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) != nil || m.Module == "mesh-controller" {
|
||||
continue // a manifest that does not read is the manifest checks' to refuse
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
if strings.TrimSpace(r) != AssignmentsRead {
|
||||
continue
|
||||
}
|
||||
reads[m.Module] = true
|
||||
if !allowed[m.Module] {
|
||||
found = append(found, fmt.Sprintf("%s reads %s: read where modules run from mesh-delivery-assigner.placement, "+
|
||||
"the seat mesh-placement's (novox/hq ADR 0549 §3, ADR 0567); the lease write is refused while a module reads "+
|
||||
"the assignments (ADR 0549 §6)", m.Module, AssignmentsRead))
|
||||
}
|
||||
}
|
||||
}
|
||||
for module := range allowed {
|
||||
if !reads[module] {
|
||||
found = append(found, fmt.Sprintf("%s names %s, which no longer reads %s: take it off the list, which only shrinks",
|
||||
Allowed, module, AssignmentsRead))
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// named collects the strings of a decoded manifest that start with one of the Prefixes.
|
||||
func named(v any, into []string) []string {
|
||||
switch v := v.(type) {
|
||||
@@ -95,5 +166,5 @@ func main() {
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("no placement directory, and no manifest reads the indexer's placement")
|
||||
fmt.Println("no placement directory, no manifest reads the indexer's placement, and only the modules listed read the controller's assignments")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/checks/trunk"
|
||||
)
|
||||
|
||||
func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
|
||||
@@ -13,12 +15,18 @@ func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
|
||||
files map[string]string
|
||||
want string // empty: clean
|
||||
}{
|
||||
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-controller.assignments"]}`}, ""},
|
||||
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-assigner.placement"]}`}, ""},
|
||||
{"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"},
|
||||
{"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"},
|
||||
{"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`},
|
||||
{"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"},
|
||||
{"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"},
|
||||
// Issue 774 (ADR 0549 §3, §6): a reader of the controller's assignments is refused unless the list names it, the
|
||||
// controller's own manifest is not judged, and a name on the list whose module no longer reads them is refused.
|
||||
{"an unlisted reader of the assignments", map[string]string{"modules/r/module.json": `{"module":"r","reads":["mesh-controller.assignments"]}`}, "r reads mesh-controller.assignments"},
|
||||
{"a listed reader and the controller", map[string]string{"modules/l/module.json": `{"module":"l","reads":["mesh-controller.assignments"]}`,
|
||||
"modules/mesh-controller/module.json": `{"module":"mesh-controller","reads":["mesh-controller.assignments"]}`, Allowed: "# why\nl its plan\n"}, ""},
|
||||
{"a listed module that reads them no more", map[string]string{Allowed: "gone no longer reads\n"}, "names gone, which no longer reads"},
|
||||
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
@@ -52,4 +60,29 @@ func TestTheCatalogueHoldsNoPlacementOfTheRepositoryPath(t *testing.T) {
|
||||
for _, f := range got {
|
||||
t.Error(f)
|
||||
}
|
||||
// Issue 774 (ADR 0549 §3, §6; the comparison of novox/hq ADR 0529 clause 2): the list of modules still reading the
|
||||
// controller's assignments gains none the trunk's copy at the merge base does not hold. Its first copy gains nothing.
|
||||
path, ok := trunk.Copy(t, Allowed)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
names := func(path string) map[string]bool {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if f := strings.Fields(line); len(f) > 0 && !strings.HasPrefix(f[0], "#") {
|
||||
out[f[0]] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
base := names(path)
|
||||
for module := range names(filepath.Join("..", "..", Allowed)) {
|
||||
if !base[module] {
|
||||
t.Errorf("%s gains %s, which the trunk's copy does not hold: the list only shrinks", Allowed, module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module checks/readable
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
@@ -0,0 +1,333 @@
|
||||
// Command readable refuses a catalogue where a module reads another module's state that the owner does not announce
|
||||
// readable (novox/hq ADR 0495 clause 3, issue 1790): the controller grants a read only from the owner's announcement, so
|
||||
// such a read is refused on the live mesh, said only in the controller's journal. The sweep of 2026-10-30 (issue 774)
|
||||
// found three: gitea's `bound` (read by mesh-provisioner, so no forge account was bound), disk-load's `readings` (read by
|
||||
// restic, nfs-server and mounts) and restic's `good` (read by the self-check). The merge gate's check of reads against
|
||||
// the owner's manifest went with clause 5, and announcetest holds a module to its own list, not to its readers.
|
||||
//
|
||||
// An owner announces from its code. A Go module announces when its code imports mesh-sdk's go/announce: every
|
||||
// announce.State literal there is read, its Name a string, a constant of the package, or a constant of mesh-sdk this
|
||||
// check knows (Known). A TypeScript module announces when its code says the event `states` or calls the SDK's keeps:
|
||||
// every `{ name: "...", readable: true }` there is read. An owner that announces nothing from code is read as before
|
||||
// on the mesh, and is not judged; neither is an owner outside the catalogue (another repository's).
|
||||
//
|
||||
// go run . <catalog root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// Known are the mesh-sdk constants an announcement may name a state by, by the selector the code writes.
|
||||
var Known = map[string]string{
|
||||
"health.GoodState": health.GoodState,
|
||||
"health.ReadingsState": health.ReadingsState,
|
||||
}
|
||||
|
||||
const announcePkg = `"git.novox.be/novox/mesh-sdk/go/announce"`
|
||||
|
||||
// Announced is what one owner's code announces: each state, readable or not.
|
||||
type Announced map[string]bool
|
||||
|
||||
// Findings is every refusal in the catalogue at root, sorted.
|
||||
func Findings(root string) ([]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
reads := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Reads []json.RawMessage `json:"reads"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dirs[m.Module] = filepath.Dir(path)
|
||||
for _, r := range m.Reads {
|
||||
var s string
|
||||
if json.Unmarshal(r, &s) == nil {
|
||||
reads[m.Module] = append(reads[m.Module], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
owners := map[string]Announced{}
|
||||
for reader, rs := range reads {
|
||||
for _, r := range rs {
|
||||
owner, state, ok := strings.Cut(r, ".")
|
||||
dir, inCatalogue := dirs[owner]
|
||||
if !ok || !inCatalogue || owner == reader {
|
||||
continue
|
||||
}
|
||||
a, seen := owners[owner]
|
||||
if !seen {
|
||||
var problems []string
|
||||
a, problems, err = AnnouncedBy(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners[owner] = a
|
||||
for _, p := range problems {
|
||||
out = append(out, owner+": "+p)
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
continue // announces nothing from code: read as before
|
||||
}
|
||||
readable, keeps := a[state]
|
||||
switch {
|
||||
case !keeps:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner))
|
||||
case !readable:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return dedupe(out), nil
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
var out []string
|
||||
for i, s := range in {
|
||||
if i == 0 || s != in[i-1] {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
tsAnnounces = regexp.MustCompile(`emit\(\s*"states"|\bkeeps\(`)
|
||||
tsState = regexp.MustCompile(`\{\s*name:\s*"([a-z0-9][a-z0-9-]*)"\s*(,\s*readable:\s*(true|false)\s*)?\}`)
|
||||
)
|
||||
|
||||
// AnnouncedBy is what the module in dir announces from its code, nil when it announces nothing; problems are
|
||||
// announcements whose state's name cannot be read.
|
||||
func AnnouncedBy(dir string) (Announced, []string, error) {
|
||||
var out Announced
|
||||
var problems []string
|
||||
pkgs := map[string][]*ast.File{}
|
||||
fset := token.NewFileSet()
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if n := d.Name(); path != dir && (n == "node_modules" || n == "dist" || n == "test" || n == "testdata" || strings.HasPrefix(n, ".")) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go"):
|
||||
f, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgs[filepath.Dir(path)] = append(pkgs[filepath.Dir(path)], f)
|
||||
case strings.HasSuffix(name, ".ts") && !strings.HasSuffix(name, ".d.ts") && !strings.HasSuffix(name, ".test.ts"):
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !tsAnnounces.Match(raw) && !strings.Contains(string(raw), "KEPT") {
|
||||
return nil
|
||||
}
|
||||
for _, m := range tsState.FindAllStringSubmatch(string(raw), -1) {
|
||||
if out == nil {
|
||||
out = Announced{}
|
||||
}
|
||||
out[m[1]] = out[m[1]] || m[3] == "true"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, files := range pkgs {
|
||||
imports := false
|
||||
consts := map[string]ast.Expr{}
|
||||
for _, f := range files {
|
||||
for _, im := range f.Imports {
|
||||
if im.Path.Value == announcePkg && (im.Name == nil || im.Name.Name == "announce") {
|
||||
imports = true
|
||||
}
|
||||
}
|
||||
for _, decl := range f.Decls {
|
||||
g, ok := decl.(*ast.GenDecl)
|
||||
if !ok || g.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
for _, spec := range g.Specs {
|
||||
v := spec.(*ast.ValueSpec)
|
||||
for i, n := range v.Names {
|
||||
if i < len(v.Values) {
|
||||
consts[n.Name] = v.Values[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !imports {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = Announced{}
|
||||
}
|
||||
for _, f := range files {
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
states := []*ast.CompositeLit{}
|
||||
switch {
|
||||
case isAnnounceState(lit.Type):
|
||||
states = append(states, lit)
|
||||
case isStateSlice(lit.Type):
|
||||
for _, e := range lit.Elts {
|
||||
if c, ok := e.(*ast.CompositeLit); ok && (c.Type == nil || isAnnounceState(c.Type)) {
|
||||
states = append(states, c)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
for _, s := range states {
|
||||
name, readable, problem := stateOf(s, consts)
|
||||
if problem != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s", fset.Position(s.Pos()), problem))
|
||||
continue
|
||||
}
|
||||
out[name] = out[name] || readable
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, problems, nil
|
||||
}
|
||||
|
||||
func isAnnounceState(e ast.Expr) bool {
|
||||
s, ok := e.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
x, ok := s.X.(*ast.Ident)
|
||||
return ok && x.Name == "announce" && s.Sel.Name == "State"
|
||||
}
|
||||
|
||||
func isStateSlice(e ast.Expr) bool {
|
||||
a, ok := e.(*ast.ArrayType)
|
||||
return ok && isAnnounceState(a.Elt)
|
||||
}
|
||||
|
||||
// stateOf is one announce.State literal's name and whether it is readable (Readable true, or Readers named).
|
||||
func stateOf(lit *ast.CompositeLit, consts map[string]ast.Expr) (string, bool, string) {
|
||||
var name string
|
||||
var readable bool
|
||||
named := false
|
||||
for _, e := range lit.Elts {
|
||||
kv, ok := e.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
return "", false, "an announce.State without field names cannot be read; name its fields"
|
||||
}
|
||||
key, _ := kv.Key.(*ast.Ident)
|
||||
if key == nil {
|
||||
continue
|
||||
}
|
||||
switch key.Name {
|
||||
case "Name":
|
||||
v, ok := resolve(kv.Value, consts, 0)
|
||||
if !ok {
|
||||
return "", false, fmt.Sprintf("the state's name %s cannot be read: name it by a string, a constant of the package, or a mesh-sdk constant in checks/readable's Known", exprString(kv.Value))
|
||||
}
|
||||
name, named = v, true
|
||||
case "Readable":
|
||||
if id, ok := kv.Value.(*ast.Ident); ok {
|
||||
readable = readable || id.Name == "true"
|
||||
}
|
||||
case "Readers":
|
||||
readable = true
|
||||
}
|
||||
}
|
||||
if !named {
|
||||
return "", false, "an announce.State without a Name"
|
||||
}
|
||||
return name, readable, ""
|
||||
}
|
||||
|
||||
func resolve(e ast.Expr, consts map[string]ast.Expr, depth int) (string, bool) {
|
||||
if depth > 8 {
|
||||
return "", false
|
||||
}
|
||||
switch v := e.(type) {
|
||||
case *ast.BasicLit:
|
||||
if v.Kind == token.STRING {
|
||||
s, err := strconv.Unquote(v.Value)
|
||||
return s, err == nil
|
||||
}
|
||||
case *ast.Ident:
|
||||
if c, ok := consts[v.Name]; ok {
|
||||
return resolve(c, consts, depth+1)
|
||||
}
|
||||
case *ast.SelectorExpr:
|
||||
s, ok := Known[exprString(v)]
|
||||
return s, ok
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func exprString(e ast.Expr) string {
|
||||
switch v := e.(type) {
|
||||
case *ast.Ident:
|
||||
return v.Name
|
||||
case *ast.SelectorExpr:
|
||||
return exprString(v.X) + "." + v.Sel.Name
|
||||
case *ast.BasicLit:
|
||||
return v.Value
|
||||
}
|
||||
return fmt.Sprintf("%T", e)
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Findings(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A read of another module's state is refused here when the owner's code announces that state not readable, or not at
|
||||
// all (the sweep of 2026-10-30, issue 774); an owner that announces nothing from code, or lives in another repository,
|
||||
// is not judged.
|
||||
func TestFindings(t *testing.T) {
|
||||
const goOwner = "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/announce\"\n\n"
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
want string // empty: clean
|
||||
}{
|
||||
{"go, readable by a constant", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "const S = \"s\"\n\nvar _ = announce.State{Name: S, Readable: true}\n",
|
||||
"modules/o/cmd/o/main_test.go": goOwner + "var _ = announce.State{Name: \"s\"}\n"}, ""},
|
||||
{"go, not readable", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var Kept = []announce.State{{Name: \"t\", Readable: true}, {Name: \"s\"}}\n"},
|
||||
"r reads o.s, which o's code announces not readable"},
|
||||
{"go, a known mesh-sdk name and readers", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.good","o.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var _ = []announce.State{{Name: health.GoodState, Readable: true}, {Name: \"s\", Readers: []string{\"r\"}}}\n"}, ""},
|
||||
{"go, a name that cannot be read", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: other.S, Readable: true}\n"},
|
||||
"the state's name other.S cannot be read"},
|
||||
{"go, a state not announced", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"t\"}\n"},
|
||||
"r reads o.s, which o's code does not announce"},
|
||||
{"typescript, not readable", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.bound"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/kept.ts": `export const KEPT = [{ name: "announced" }, { name: "bound" }];`},
|
||||
"r reads o.bound, which o's code announces not readable"},
|
||||
{"typescript, readable", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.bound"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/index.ts": `await keeps([{ name: "bound", readable: true }]);`}, ""},
|
||||
{"an owner that announces nothing, and one outside the catalogue", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.s","mesh-controller.lease"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/cmd/o/main.go": "package main\n\nvar _ = 1\n"}, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for path, text := range row.files {
|
||||
p := filepath.Join(root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := Findings(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(got, "\n")
|
||||
if row.want == "" && len(got) != 0 || row.want != "" && !strings.Contains(joined, row.want) {
|
||||
t.Errorf("findings %q, want %q", joined, row.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue itself: every state a module reads of another module that announces from code is announced readable.
|
||||
func TestTheCatalogueReadsOnlyWhatItsOwnersAnnounceReadable(t *testing.T) {
|
||||
got, err := Findings(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range got {
|
||||
t.Error(f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# What these tests read outside their package, as paths from the repository's root (issue 1936).
|
||||
# every manifest, for what it reads; and every module's Go and TypeScript code, for the states it announces.
|
||||
modules/*/module.json
|
||||
modules/*/cmd
|
||||
modules/*/*.go
|
||||
modules/*/*.ts
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// The artifact store's door and the bus, as the holder reaches them through this module's runtime.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"artifactstoretools/internal/registry"
|
||||
)
|
||||
|
||||
// door is registry.Store at the store's address as this machine reaches it.
|
||||
type door struct{ s registry.Store }
|
||||
|
||||
func (d *door) Address() string { return d.s.Address }
|
||||
func (d *door) Hold(ctx context.Context, r string) (bool, error) { return d.s.Hold(ctx, r) }
|
||||
func (d *door) Holds(ctx context.Context, r string) error { return d.s.Holds(ctx, r) }
|
||||
func (d *door) IsIndex(ctx context.Context, r string) (bool, error) {
|
||||
return d.s.IsIndex(ctx, r)
|
||||
}
|
||||
func (d *door) LetGo(ctx context.Context, r string) error { return d.s.LetGo(ctx, r) }
|
||||
func (d *door) SpareKept(ctx context.Context, keptImages, touching []string) error {
|
||||
spare, err := d.s.SpareKeptIndexes(ctx, keptImages, touching)
|
||||
if err == nil {
|
||||
d.s.Spare = spare
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DoorAt reads the store's address (host:port) from the file the manifest fills from the seat mesh-artifact-store's
|
||||
// reach, each time, so a store that moved is reached where it is now.
|
||||
func DoorAt(file string) func() (Door, error) {
|
||||
return func() (Door, error) {
|
||||
if file == "" {
|
||||
return nil, fmt.Errorf("this bundle was given no file naming the artifact store's address")
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the artifact store's address: %w", err)
|
||||
}
|
||||
address := strings.TrimSpace(string(raw))
|
||||
if address == "" {
|
||||
return nil, fmt.Errorf("the artifact store's address is empty in %s", file)
|
||||
}
|
||||
return &door{s: registry.Store{Address: address}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeBus is the holder's bus through the runtime: its reads, its findings and its seat's event.
|
||||
type runtimeBus struct{}
|
||||
|
||||
func (runtimeBus) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
func (runtimeBus) Get(state, key string) ([]byte, bool, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
func (runtimeBus) History(state, key string) ([]stdio.StateRevision, error) {
|
||||
return stdio.State(state).History(key)
|
||||
}
|
||||
func (runtimeBus) LogSince(read string, at time.Time) ([]stdio.LogEntry, error) {
|
||||
var out []stdio.LogEntry
|
||||
page, err := stdio.Log(read).Since(at, "", 0)
|
||||
for err == nil {
|
||||
out = append(out, page.Entries...)
|
||||
if page.Next == 0 {
|
||||
return out, nil
|
||||
}
|
||||
page, err = stdio.Log(read).Read(page.Next, "", 0)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (runtimeBus) Put(state, key string, value any) error {
|
||||
_, err := stdio.State(state).Put(key, value)
|
||||
return err
|
||||
}
|
||||
func (runtimeBus) Delete(state, key string) error { return stdio.State(state).Delete(key) }
|
||||
func (runtimeBus) Publish(subject string, body any) error {
|
||||
return stdio.SeatPublish(subject, body, "")
|
||||
}
|
||||
|
||||
// follow watches the builder's done keys and the wants: a build's end sweeps (once the lease names no controller) and
|
||||
// observes again; a want changed observes again. Event-driven: nothing reads them on a timer.
|
||||
func follow(h *Holder) {
|
||||
<-stdio.Served()
|
||||
watch := func(state string, handle func(stdio.StateChange) error) {
|
||||
for {
|
||||
if err := stdio.State(state).Watch("", handle); err == nil {
|
||||
return
|
||||
} else {
|
||||
h.logf("watching %s: %v; asked again in 10s", state, err)
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
observe := func() {
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
h.logf("image-missing could not be observed: %v", err)
|
||||
}
|
||||
}
|
||||
watch(BuildsRead, func(c stdio.StateChange) error {
|
||||
if c.Current || c.Op != stdio.OpPut {
|
||||
return nil
|
||||
}
|
||||
var b struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
if json.Unmarshal(c.Value, &b) == nil && b.State == "done" {
|
||||
go func() { h.AfterBuild(c.Key); observe() }()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
watch(WantsRead, func(c stdio.StateChange) error {
|
||||
if !c.Current {
|
||||
go observe()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
observe()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The seat mesh-artifact-sweep's holder whole against a real bus (novox/hq ADR 0571 clause 2, issue 1955): the builder's done
|
||||
// keys, the wants and the controller's lease in a real nats-server's key-value buckets, a build's end heard by a watch
|
||||
// of the builds as the runtime hands it over, each deletion recorded by a request to the seat mesh-hand-acts on the bus,
|
||||
// artifacts-gone said on the seat's subject, and S25's finding written to the holder's own bucket. Only the store's
|
||||
// door is in memory: its HTTP is the copied registry package's, tested there.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
)
|
||||
|
||||
// realBus is the holder's Bus over a real nats-server: each read state a bucket named as the runtime names it.
|
||||
type realBus struct {
|
||||
ctx context.Context
|
||||
nc *nats.Conn
|
||||
js jetstream.JetStream
|
||||
// log is the builder's log as the runtime's log verbs would read it (mesh-tools adds them; the sdk's own read of a
|
||||
// log is tested in mesh-sdk's process package).
|
||||
log []stdio.LogEntry
|
||||
}
|
||||
|
||||
func (b realBus) LogSince(_ string, at time.Time) ([]stdio.LogEntry, error) {
|
||||
var out []stdio.LogEntry
|
||||
for _, e := range b.log {
|
||||
if !e.At.Before(at) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func bucketOf(state string) string { return strings.Replace(state, ".", "_", 1) }
|
||||
|
||||
func (b realBus) kv(state string) (jetstream.KeyValue, error) {
|
||||
return b.js.KeyValue(b.ctx, bucketOf(state))
|
||||
}
|
||||
|
||||
func (b realBus) Keys(state string) ([]string, error) {
|
||||
kv, err := b.kv(state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := kv.Keys(b.ctx)
|
||||
if err == jetstream.ErrNoKeysFound {
|
||||
return nil, nil
|
||||
}
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (b realBus) Get(state, key string) ([]byte, bool, error) {
|
||||
kv, err := b.kv(state)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
e, err := kv.Get(b.ctx, key)
|
||||
if err == jetstream.ErrKeyNotFound {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value(), true, nil
|
||||
}
|
||||
|
||||
func (b realBus) History(state, key string) ([]stdio.StateRevision, error) {
|
||||
kv, err := b.kv(state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := kv.History(b.ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []stdio.StateRevision
|
||||
for _, e := range h {
|
||||
out = append(out, stdio.StateRevision{Key: key, Value: e.Value(), Revision: e.Revision(), At: e.Created()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b realBus) Put(state, key string, value any) error {
|
||||
kv, err := b.kv(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(value)
|
||||
_, err = kv.Put(b.ctx, key, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b realBus) Delete(state, key string) error {
|
||||
kv, err := b.kv(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return kv.Delete(b.ctx, key)
|
||||
}
|
||||
|
||||
func (b realBus) Publish(subject string, body any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
return b.nc.Publish(subject, raw)
|
||||
}
|
||||
|
||||
func TestTheSeatSweepsAtABuildsEndOnARealBusOnceTheLeaseIsAway(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
nc, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{BuildsRead, WantsRead, HavesRead, LeaseRead, ManifestsRead, "artifact-store-tools." + FindingsState} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucketOf(s), History: 5}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
bus := realBus{ctx: ctx, nc: nc, js: js}
|
||||
|
||||
// The mesh as aMesh lays it, copied onto the real buckets: letta's seven builds, a want, the store's listing.
|
||||
mem, memb, door, _ := aMesh(t, &buscompose.LeaseHolder{Instance: "c1", Host: "novox", Epoch: 3})
|
||||
bus.log = memb.log
|
||||
for _, s := range []string{BuildsRead, WantsRead, LeaseRead, ManifestsRead} {
|
||||
keys, _ := memb.Keys(s)
|
||||
for _, k := range keys[:min(len(keys), 6)] { // the seventh build ends later, on the bus
|
||||
raw, _, _ := memb.Get(s, k)
|
||||
if err := bus.Put(s, k, json.RawMessage(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond) // each build's end is its key's time on the bus: the seventh the newest
|
||||
|
||||
// The seat mesh-hand-acts, answering every act it records; artifacts-gone heard on the seat's subject.
|
||||
var mu sync.Mutex
|
||||
var acts, gone []string
|
||||
if _, err := nc.Subscribe("mesh.seat."+handacts.Seat+".tool.hand-act", func(m *nats.Msg) {
|
||||
mu.Lock()
|
||||
acts = append(acts, string(m.Data))
|
||||
mu.Unlock()
|
||||
_ = m.Respond([]byte(`{"ok":true}`))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := nc.Subscribe(GoneSubject, func(m *nats.Msg) {
|
||||
mu.Lock()
|
||||
gone = append(gone, string(m.Data))
|
||||
mu.Unlock()
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Now: time.Now, Logf: t.Logf}
|
||||
h.Acts = handacts.Recorder{LeaseAway: h.LeaseAway, Ask: func(key string, body any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
m, err := nc.Request("mesh.seat."+strings.TrimPrefix(strings.Replace(key, ".", ".tool.", 1), "seat:"), raw, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Data, nil
|
||||
}}
|
||||
|
||||
// A build's end heard from the builds bucket, as follow hands it over.
|
||||
kv, _ := bus.kv(BuildsRead)
|
||||
w, err := kv.WatchAll(ctx, jetstream.UpdatesOnly())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ended := func() {
|
||||
keys, _ := memb.Keys(BuildsRead)
|
||||
raw, _, _ := memb.Get(BuildsRead, keys[6])
|
||||
if err := bus.Put(BuildsRead, keys[6], json.RawMessage(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
h.AfterBuild(e.Key())
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the build's end was not heard")
|
||||
}
|
||||
}
|
||||
|
||||
// While the lease names the controller, a build's end lets nothing go.
|
||||
ended()
|
||||
if len(door.asked) != 0 {
|
||||
t.Fatalf("before the lease write the store was asked %v", door.asked)
|
||||
}
|
||||
// The lease handed away: the next build's end holds the kept archives and lets go of letta's 2nd, which the 7th moved
|
||||
// past the five newest and no want names (the want keeps the 1st), recording each deletion.
|
||||
if err := bus.Put(LeaseRead, buscompose.ControllerLeaseKey, buscompose.LeaseHolder{HandedTo: "mesh-grants", Why: "the lease write"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ended()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(gone)
|
||||
mu.Unlock()
|
||||
if n > 0 || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(acts) != 3 || !strings.Contains(acts[0], archiveRef("letta", 2)) || !strings.Contains(acts[1], imageRef("letta", 2)) ||
|
||||
!strings.Contains(acts[2], mirror(1)) {
|
||||
t.Fatalf("the hand acts recorded are %v; the store was asked %v", acts, door.asked)
|
||||
}
|
||||
if len(gone) != 1 || !strings.Contains(gone[0], `"fingerprint":"`+fp(2)+`"`) {
|
||||
t.Fatalf("artifacts-gone said %v", gone)
|
||||
}
|
||||
|
||||
// S25 through the real findings bucket: letta's wanted image missing from the store is a finding there.
|
||||
view, _ := mem.Listed(ctx)
|
||||
repo, digest, _ := splitReference(imageRef("letta", 1))
|
||||
delete(view.L.Repos[repo].Revisions, digest)
|
||||
if err := h.Observe(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if keys, _ := bus.Keys("artifact-store-tools." + FindingsState); len(keys) != 1 || keys[0] != "module.letta.ace.image-missing" {
|
||||
t.Fatalf("the findings are %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// findingsAt reads and writes the holder's own findings in its bucket, artifact-store-tools_findings.
|
||||
type findingsAt struct{ realBus }
|
||||
|
||||
func (f findingsAt) own(state string) string {
|
||||
if state == FindingsState {
|
||||
return Module + "." + state
|
||||
}
|
||||
return state
|
||||
}
|
||||
func (f findingsAt) Keys(state string) ([]string, error) { return f.realBus.Keys(f.own(state)) }
|
||||
func (f findingsAt) Get(state, key string) ([]byte, bool, error) {
|
||||
return f.realBus.Get(f.own(state), key)
|
||||
}
|
||||
func (f findingsAt) Put(state, key string, value any) error {
|
||||
return f.realBus.Put(f.own(state), key, value)
|
||||
}
|
||||
func (f findingsAt) Delete(state, key string) error { return f.realBus.Delete(f.own(state), key) }
|
||||
@@ -0,0 +1,351 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
"artifactstoretools/internal/registry"
|
||||
)
|
||||
|
||||
// memBus is the holder's bus in memory: states by name, each key's history, and what was published.
|
||||
type memBus struct {
|
||||
mu sync.Mutex
|
||||
states map[string]map[string][]byte
|
||||
history map[string][]stdio.StateRevision
|
||||
log []stdio.LogEntry
|
||||
published []string
|
||||
}
|
||||
|
||||
func (b *memBus) LogSince(read string, at time.Time) ([]stdio.LogEntry, error) {
|
||||
var out []stdio.LogEntry
|
||||
for _, e := range b.log {
|
||||
if read == delivery.BuildLogRead && !e.At.Before(at) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *memBus) Keys(state string) ([]string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var out []string
|
||||
for k := range b.states[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *memBus) Get(state, key string) ([]byte, bool, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
v, ok := b.states[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (b *memBus) History(state, key string) ([]stdio.StateRevision, error) {
|
||||
return b.history[state+" "+key], nil
|
||||
}
|
||||
|
||||
func (b *memBus) Put(state, key string, value any) error {
|
||||
raw, _ := json.Marshal(value)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.states[state] == nil {
|
||||
b.states[state] = map[string][]byte{}
|
||||
}
|
||||
b.states[state][key] = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *memBus) Delete(state, key string) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.states[state], key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *memBus) Publish(subject string, body any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.published = append(b.published, subject+" "+string(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
// memDoor is the store's door in memory: what it holds, which references are indexes, and what was asked.
|
||||
type memDoor struct {
|
||||
holds map[string]bool
|
||||
indexes map[string]bool
|
||||
asked []string
|
||||
}
|
||||
|
||||
func (d *memDoor) Address() string { return "10.0.0.5:5000" }
|
||||
func (d *memDoor) Hold(_ context.Context, r string) (bool, error) {
|
||||
d.asked = append(d.asked, "hold "+r)
|
||||
if !d.holds[r] {
|
||||
return false, registry.Gone
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
func (d *memDoor) Holds(_ context.Context, r string) error {
|
||||
if !d.holds[r] {
|
||||
return registry.Gone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *memDoor) IsIndex(_ context.Context, r string) (bool, error) { return d.indexes[r], nil }
|
||||
func (d *memDoor) LetGo(_ context.Context, r string) error {
|
||||
d.asked = append(d.asked, "let go "+r)
|
||||
delete(d.holds, r)
|
||||
return nil
|
||||
}
|
||||
func (d *memDoor) SpareKept(context.Context, []string, []string) error {
|
||||
d.asked = append(d.asked, "spare")
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirror is a copy of an upstream base in the store, the n-th.
|
||||
func mirror(n int) string {
|
||||
return "artifact-store://upstream/docker.io/library/alpine@sha256:" + strings.Repeat(fmt.Sprintf("%x", n%16), 64)
|
||||
}
|
||||
|
||||
// pinnedImage is the image letta's manifest names by itself, at the store's address.
|
||||
const pinnedImage = "10.0.0.5:5000/letta/pinned@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
|
||||
func digestOf(ref string) string { _, d, _ := splitReference(ref); return d }
|
||||
|
||||
func fp(n int) string { return "src2:" + strings.Repeat(fmt.Sprintf("%x", n%16), 64) }
|
||||
|
||||
func imageRef(module string, n int) string {
|
||||
return "artifact-store://" + module + "/image@sha256:" + strings.Repeat(fmt.Sprintf("%x", n%16), 64)
|
||||
}
|
||||
|
||||
func archiveRef(module string, n int) string {
|
||||
return "artifact-store://" + module + "/files/blobs/sha256:" + strings.Repeat(fmt.Sprintf("%x", (n+8)%16), 64)
|
||||
}
|
||||
|
||||
// A mesh with seven done builds of letta, the n-th ending n hours after a day began, each with an image and an archive;
|
||||
// a want names the first on ace; the store lists them all and one image no build names; the lease as given.
|
||||
func aMesh(t *testing.T, lease *buscompose.LeaseHolder) (*Holder, *memBus, *memDoor, *[]string) {
|
||||
t.Helper()
|
||||
day := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
b := &memBus{states: map[string]map[string][]byte{}, history: map[string][]stdio.StateRevision{}}
|
||||
door := &memDoor{holds: map[string]bool{}, indexes: map[string]bool{}}
|
||||
listing := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}}
|
||||
for n := 1; n <= 7; n++ {
|
||||
key, _ := delivery.BuilderBuildKey("letta", fp(n))
|
||||
raw, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []delivery.BuiltArtifact{
|
||||
{Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: imageRef("letta", n)},
|
||||
{Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: archiveRef("letta", n)},
|
||||
{Name: "pkg", Kind: "package", Digest: "sha256:" + strings.Repeat("f", 64), Reference: "letta@1.0." + fmt.Sprint(n)}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = b.Put(BuildsRead, key, json.RawMessage(raw))
|
||||
b.history[BuildsRead+" "+key] = []stdio.StateRevision{{Key: key, Value: raw, At: day.Add(time.Duration(n) * time.Hour)}}
|
||||
for _, r := range []string{imageRef("letta", n), archiveRef("letta", n)} {
|
||||
door.holds[r] = true
|
||||
repo, digest, kind := splitReference(r)
|
||||
if kind == "archive" {
|
||||
listing.Blobs[digest] = 10
|
||||
} else {
|
||||
listing.repo(repo).Revisions[digest] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
listing.repo("letta/image").Revisions["sha256:"+strings.Repeat("e", 64)] = true // no build names it
|
||||
listing.repo("letta/pinned").Revisions["sha256:"+strings.Repeat("c", 64)] = true
|
||||
// Three copies of a base: letta's 2nd stood on the first, its 7th on the second, and no build on the third.
|
||||
for n := 1; n <= 3; n++ {
|
||||
repo, digest, _ := splitReference(mirror(n))
|
||||
listing.repo(repo).Revisions[digest] = true
|
||||
door.holds[mirror(n)] = true
|
||||
}
|
||||
for n := 1; n <= 7; n++ {
|
||||
e := delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: "letta", FP: fp(n), Repository: "novox/x", Commit: "c",
|
||||
Node: "ace", At: day.Add(time.Duration(n) * time.Hour), Outcome: "done"}
|
||||
switch n {
|
||||
case 2:
|
||||
e.Against = []string{mirror(1)}
|
||||
case 7:
|
||||
e.Against = []string{mirror(2)}
|
||||
}
|
||||
raw, _ := delivery.EncodeBuildLogEntry(e)
|
||||
b.log = append(b.log, stdio.LogEntry{Sequence: uint64(n), Key: "letta", Value: raw, At: e.At})
|
||||
}
|
||||
manifest, _ := delivery.EncodeManifest(delivery.Manifest{Module: "letta",
|
||||
JSON: `{"module":"letta","resources":[{"id":"app","type":"container","image":"` + pinnedImage + `"},` +
|
||||
`{"id":"built","type":"container","image":"${artifact:image}"}]}`})
|
||||
mk, _ := delivery.ManifestsKey(strings.Repeat("d", 64))
|
||||
_ = b.Put(ManifestsRead, mk, json.RawMessage(manifest))
|
||||
want, _ := json.Marshal(delivery.Want{V: delivery.Version, FP: fp(1), Self: true})
|
||||
_ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want))
|
||||
if lease != nil {
|
||||
_ = b.Put(LeaseRead, buscompose.ControllerLeaseKey, lease)
|
||||
}
|
||||
var acts []string
|
||||
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil },
|
||||
Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil },
|
||||
Now: func() time.Time { return day.Add(48 * time.Hour) },
|
||||
Acts: handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
acts = append(acts, key+" "+string(raw))
|
||||
return json.RawMessage(`{}`), nil
|
||||
}}}
|
||||
h.Acts.LeaseAway = h.LeaseAway
|
||||
return h, b, door, &acts
|
||||
}
|
||||
|
||||
// ADR 0571 clause 2: the keep set from the store's listing, the wants and the five newest builds; nothing let go of while
|
||||
// the lease names a controller; once it names another holder, a build's end lets go of the eligible within the bound,
|
||||
// leaves an index for a confirmed collect, records each deletion and says artifacts-gone.
|
||||
func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsOnlyOnceTheLeaseIsAway(t *testing.T) {
|
||||
controller := &buscompose.LeaseHolder{Instance: "c1", Host: "novox", Epoch: 3}
|
||||
handed := &buscompose.LeaseHolder{HandedTo: "mesh-grants", HandedBy: "person.jochen", Why: "the lease write"}
|
||||
|
||||
h, _, _, _ := aMesh(t, controller)
|
||||
r, err := h.Artifacts("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states := map[string]string{}
|
||||
for _, x := range r.References {
|
||||
states[x.Reference] = x.State
|
||||
}
|
||||
// letta's 3rd to 7th are the five newest; its 1st a want names on ace, however old; its 2nd is eligible; the package
|
||||
// and the manifest no build names are no reference of the seat's.
|
||||
for n, want := range map[int]string{1: "kept", 2: "eligible", 3: "kept", 7: "kept"} {
|
||||
if states[imageRef("letta", n)] != want || states[archiveRef("letta", n)] != want {
|
||||
t.Errorf("letta's build %d is %s and %s, not %s", n, states[imageRef("letta", n)], states[archiveRef("letta", n)], want)
|
||||
}
|
||||
}
|
||||
// And the copies: the one letta's 2nd stood on is eligible with it, the 7th's kept; the third no build names.
|
||||
if states[mirror(1)] != "eligible" || states[mirror(2)] != "kept" || states[mirror(3)] != "" {
|
||||
t.Errorf("the copies are %s, %s and %q", states[mirror(1)], states[mirror(2)], states[mirror(3)])
|
||||
}
|
||||
if m, err := h.MirrorsNow(); err != nil || len(m.Copies) != 3 || m.Copies[2].State != "unrecorded" || m.Held != "" {
|
||||
t.Errorf("mirrors answered %+v: %v", m, err)
|
||||
}
|
||||
if len(r.References) != 16 || r.Counts["kept"] != 13 || r.Counts["eligible"] != 3 || r.Store != "10.0.0.5:5000" {
|
||||
t.Errorf("the references are %d, counted %v, at %q", len(r.References), r.Counts, r.Store)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
lease *buscompose.LeaseHolder
|
||||
index bool
|
||||
most int
|
||||
letGo []string
|
||||
left int
|
||||
skipped int
|
||||
unlog int
|
||||
}{
|
||||
{name: "the lease names a controller", lease: controller},
|
||||
{name: "no lease key", lease: nil},
|
||||
{name: "handed away", lease: handed, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2), mirror(1)}},
|
||||
{name: "an index waits", lease: handed, index: true, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), mirror(1)}, skipped: 1},
|
||||
{name: "bounded", lease: handed, most: 1, letGo: []string{archiveRef("letta", 2)}, left: 2},
|
||||
// A kept build the log no longer holds: what it stood on is unknown, so no copy goes.
|
||||
{name: "a kept build's bases unknown", lease: handed, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2)}, unlog: 3},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h, b, door, acts := aMesh(t, c.lease)
|
||||
door.indexes[imageRef("letta", 2)] = c.index
|
||||
if c.unlog > 0 {
|
||||
b.log = append(b.log[:c.unlog-1:c.unlog-1], b.log[c.unlog:]...)
|
||||
}
|
||||
if c.most == 0 {
|
||||
h.AfterBuild("letta.x")
|
||||
if _, err := h.Collect("tidy", true, 0); err == nil {
|
||||
t.Error("a confirmed collect was let through while the controller sweeps")
|
||||
}
|
||||
if len(door.asked) != 0 || len(*acts) != 0 || len(b.published) != 0 {
|
||||
t.Fatalf("before the lease write the store was asked %v, acts %v, said %v", door.asked, *acts, b.published)
|
||||
}
|
||||
return
|
||||
}
|
||||
a, err := h.Sweep(context.Background(), Sweeping{Most: c.most, By: "after the build letta.x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(a.LetGo) != fmt.Sprint(c.letGo) || a.Left != c.left || a.Skipped != c.skipped || a.Held != 6 || a.Missing != 0 {
|
||||
t.Fatalf("let go of %v (left %d, skipped %d, held %d, missing %d), want %v", a.LetGo, a.Left, a.Skipped, a.Held, a.Missing, c.letGo)
|
||||
}
|
||||
// Every kept archive held before the first deletion.
|
||||
first := -1
|
||||
for i, x := range door.asked {
|
||||
if strings.HasPrefix(x, "let go ") && first < 0 {
|
||||
first = i
|
||||
}
|
||||
}
|
||||
if first != 6 {
|
||||
t.Errorf("the store was asked %v: the six kept archives are held first", door.asked)
|
||||
}
|
||||
if len(*acts) != len(c.letGo) || !strings.HasPrefix((*acts)[0], "seat:mesh-hand-acts.hand-act ") ||
|
||||
!strings.Contains((*acts)[0], c.letGo[0]) {
|
||||
t.Errorf("the hand acts recorded are %v", *acts)
|
||||
}
|
||||
if len(b.published) != 1 || !strings.HasPrefix(b.published[0], GoneSubject+" ") ||
|
||||
!strings.Contains(b.published[0], `"fingerprint":"`+fp(2)+`"`) {
|
||||
t.Errorf("said %v", b.published)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// S25 (image-missing): a want whose build names an image the store does not hold is a finding in the seat's own state,
|
||||
// written once and deleted once the store holds it again; images names what a machine's wants and haves name.
|
||||
func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testing.T) {
|
||||
h, b, _, _ := aMesh(t, nil)
|
||||
view, _ := h.Listed(context.Background())
|
||||
repo, digest, _ := splitReference(imageRef("letta", 1))
|
||||
delete(view.L.Repos[repo].Revisions, digest)
|
||||
h.Listed = func(context.Context) (*View, error) { return view, nil }
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := health.FindingKey(health.ScopeModule, "letta.ace", ImageMissing)
|
||||
raw, found, _ := b.Get(FindingsState, key)
|
||||
f, err := health.DecodeFinding(raw)
|
||||
if !found || err != nil || f.Subject.Machine != "ace" || f.Source != "S25" || !strings.Contains(f.Evidence[0].Said, imageRef("letta", 1)) {
|
||||
t.Fatalf("the finding is %+v (%v, %v)", f, found, err)
|
||||
}
|
||||
view.L.Repos[repo].Revisions[digest] = true
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, _ := b.Get(FindingsState, key); found {
|
||||
t.Fatal("the finding stayed once the store held the image again")
|
||||
}
|
||||
// An image the manifest pins by itself, under the store's address, is judged as the controller judged it.
|
||||
delete(view.L.Repos["letta/pinned"].Revisions, "sha256:"+strings.Repeat("c", 64))
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, found, _ := b.Get(FindingsState, key); !found || !strings.Contains(string(raw), "letta/pinned@sha256:") {
|
||||
t.Fatalf("the pinnedImage image missing is %s", raw)
|
||||
}
|
||||
|
||||
have, _ := json.Marshal(delivery.Have{V: delivery.Version, FP: fp(7), Phase: "running"})
|
||||
_ = b.Put(HavesRead, "ace.letta", json.RawMessage(have))
|
||||
images, err := h.ImagesOf("ace", "10.0.0.5:5000")
|
||||
if err != nil || len(images.Images) != 3 || !images.SentKnown || images.Images[0].Image != "10.0.0.5:5000/letta/image@sha256:"+strings.Repeat("1", 64) ||
|
||||
fmt.Sprint(images.Images[0].In) != "[declared]" || fmt.Sprint(images.Images[1].In) != "[sent]" ||
|
||||
images.Images[2].Image != pinnedImage || fmt.Sprint(images.Images[2].In) != "[declared sent]" {
|
||||
t.Fatalf("the images of ace are %+v: %v", images, err)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
// The artifact-store-tools module's Go bundle (novox/hq ADR 0251 §1–3, to-be 51): a process the node's
|
||||
// runtime launches and speaks MCP over stdio to. It says what the artifact store holds — its
|
||||
// repositories, its size, and what the controller's records say of each manifest — read from the
|
||||
// store's own files through the store's own container, and writing nothing. A collection is asked of
|
||||
// the controller, which decides and records what it lets go of (ADR 0189).
|
||||
// The artifact-store-tools module's Go bundle (novox/hq ADR 0251 §1–3, ADR 0571 clause 2, to-be 51): a process the
|
||||
// node's runtime launches and speaks MCP over stdio to. It says what the artifact store holds — its repositories, its
|
||||
// size, and what the mesh keeps of each manifest — read from the store's own files through the store's own container,
|
||||
// and it holds the seat mesh-artifact-sweep: the keep set, the sweep at each build's end once the controller's lease names
|
||||
// no controller, and S25's image-missing.
|
||||
//
|
||||
// **A module beside the store, not the store's own.** A module that provides the artifact store cannot
|
||||
// also build an artifact: building publishes to the store, so the store would be needed to create
|
||||
// itself, and the controller refuses that manifest. So these tools are a second module, assigned to the
|
||||
// machine that holds the store. stdout is the protocol; what this bundle says, it says on stderr.
|
||||
// **A module beside the store, not the store's own.** A module that provides the artifact store cannot also build an
|
||||
// artifact: building publishes to the store, so the store would be needed to create itself. So these tools are a
|
||||
// second module, assigned to the machine that holds the store. stdout is the protocol; what this bundle says, it says
|
||||
// on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
s := &Store{
|
||||
Container: os.Getenv("MESH_ARTIFACT_STORE_CONTAINER"),
|
||||
Run: ExecRunner,
|
||||
UID: os.Getuid(),
|
||||
}
|
||||
h := &Holder{Bus: runtimeBus{}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE")), Listed: s.View,
|
||||
Now: time.Now, Logf: logf}
|
||||
h.Acts = handacts.Recorder{Ask: stdio.Ask, LeaseAway: h.LeaseAway}
|
||||
go follow(h)
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
|
||||
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask}, &Catalogue{Ask: stdio.Ask})); err != nil {
|
||||
if err := stdio.Serve("", append(Tools(s, h, &Catalogue{Ask: stdio.Ask}), SeatTools(h, s)...)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
// The seat mesh-artifact-sweep's verbs (novox/hq ADR 0571 clause 2; catalogue.ArtifactSweepSeat), each a tool named
|
||||
// `artifact-store.<verb>` as the runtime serves a seat's verb, answering as the controller's verb answered: {output,
|
||||
// ok, answer}.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// verbAnswer is the controller's verbs' answer shape, which the builder and docker read (answerOf).
|
||||
type verbAnswer struct {
|
||||
Output string `json:"output"`
|
||||
OK bool `json:"ok"`
|
||||
Answer any `json:"answer"`
|
||||
}
|
||||
|
||||
// SeatTools are the seat's verbs over h; s reads the store's listing for mirrors.
|
||||
func SeatTools(h *Holder, s *Store) []stdio.Tool {
|
||||
seat := catalogue.ArtifactSweepSeat + "."
|
||||
return []stdio.Tool{
|
||||
{Name: seat + "artifacts", Description: "Every artifact the mesh made that the store holds: kept and why, eligible, " +
|
||||
"or with collected those it no longer holds.",
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
r, err := h.Artifacts(text(args, "repository"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if text(args, "collected") != "true" {
|
||||
refs := r.References[:0:0]
|
||||
for _, x := range r.References {
|
||||
if x.State != "collected" {
|
||||
refs = append(refs, x)
|
||||
}
|
||||
}
|
||||
r.References = refs
|
||||
}
|
||||
return verbAnswer{Output: fmt.Sprintf("%d kept, %d eligible, %d collected\n", r.Counts["kept"],
|
||||
r.Counts["eligible"], r.Counts["collected"]), OK: true, Answer: r}, nil
|
||||
}},
|
||||
{Name: seat + "held", Description: "Whether the store holds each reference given: held, gone or unknown with why.",
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
a, err := h.Held(listOf(args["references"]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return verbAnswer{Output: fmt.Sprintf("the artifact store at %s holds %d of the %d reference(s) asked\n",
|
||||
a.Store, len(a.Held), len(a.Held)+len(a.Gone)+len(a.Unknown)), OK: true, Answer: a}, nil
|
||||
}},
|
||||
{Name: seat + "collect", Description: "The sweep now: a dry run without confirm; with confirm and why, every kept " +
|
||||
"archive held, then each eligible artifact let go of, each recorded in the hand-act log.",
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
most, err := count(args, "most", MostPerSweep, 1, MostAtAll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := h.Collect(text(args, "why"), text(args, "confirm") == "true", most)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return verbAnswer{Output: fmt.Sprintf("let go of %d, would let go of %d, %d left\n", len(a.LetGo),
|
||||
len(a.WouldLetGo), a.Left), OK: true, Answer: a}, nil
|
||||
}},
|
||||
{Name: seat + "mirrors", Description: "The bases the mesh copied into its store, each kept (a kept build stood on " +
|
||||
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood " +
|
||||
"on it: never removed).",
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
if text(args, "record") != "" {
|
||||
return nil, fmt.Errorf("mirrors records nothing by hand on this seat: a copy is recorded by the builds the " +
|
||||
"builder's log says stood on it (novox/hq ADR 0571 clause 1)")
|
||||
}
|
||||
m, err := h.MirrorsNow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return verbAnswer{Output: fmt.Sprintf("%d copies\n", len(m.Copies)), OK: true, Answer: m}, nil
|
||||
}},
|
||||
{Name: seat + "images", Description: "Every container image one machine's wants and haves name.",
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
node := text(args, "node")
|
||||
if node == "" {
|
||||
return nil, fmt.Errorf("images names the machine")
|
||||
}
|
||||
address := ""
|
||||
if d, err := h.Door(); err == nil {
|
||||
address = d.Address()
|
||||
}
|
||||
a, err := h.ImagesOf(node, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return verbAnswer{Output: fmt.Sprintf("%s: %d images\n", node, len(a.Images)), OK: true, Answer: a}, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// listOf is a list argument: a list, or one text separated by commas.
|
||||
func listOf(v any) []string {
|
||||
var out []string
|
||||
add := func(s string) {
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" && !contains(out, p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
add(x)
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
if s, ok := e.(string); ok {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -411,6 +413,11 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
var m struct {
|
||||
Tools []string `json:"tools"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Reads []string `json:"reads"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -428,10 +435,30 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
if !reflect.DeepEqual(served, listed) {
|
||||
t.Fatalf("served %v, manifest %v", served, listed)
|
||||
}
|
||||
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect", CatalogueModules, CatalogueModule}
|
||||
// ADR 0571 clause 2: it holds the seat mesh-artifact-sweep and serves its verbs, and calls no verb of the controller's;
|
||||
// the lease is the one controller state it reads (clause 10's exemption).
|
||||
want := []string{"seat:mesh-hand-acts.hand-act", CatalogueModules, CatalogueModule}
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Errorf("invokes %v", m.Invokes)
|
||||
}
|
||||
var verbs []string
|
||||
for _, tl := range SeatTools(&Holder{}, &Store{}) {
|
||||
verbs = append(verbs, strings.TrimPrefix(tl.Name, catalogue.ArtifactSweepSeat+"."))
|
||||
}
|
||||
seat, _ := catalogue.SeatNamed(catalogue.ArtifactSweepSeat)
|
||||
var contract []string
|
||||
for _, v := range seat.Serves {
|
||||
contract = append(contract, v.Name)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.ArtifactSweepSeat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) ||
|
||||
!reflect.DeepEqual(verbs, contract) {
|
||||
t.Errorf("claims %+v, serves %v, the seat's verbs %v", m.Claims, verbs, contract)
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
if strings.HasPrefix(r, "mesh-controller.") && r != LeaseRead {
|
||||
t.Errorf("reads the controller's %s", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASocketRefusalIsAskedAgainThroughSudo(t *testing.T) {
|
||||
|
||||
@@ -11,9 +11,16 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Recorded is what says which references the mesh keeps and lets go of them: the seat's holder (Holder, novox/hq ADR
|
||||
// 0571 clause 2), or before it the controller (Controller), each in the controller's answer's shape.
|
||||
type Recorded interface {
|
||||
Artifacts(repository string) (*Records, error)
|
||||
Collect(why string, confirm bool, most int) (*CollectAnswer, error)
|
||||
}
|
||||
|
||||
// Tools are the artifact store's tools (novox/hq ADR 0251 §1). The first three only read; artifact_store_collect
|
||||
// only reads too, unless it is a real run, and then it asks the controller, which decides and deletes.
|
||||
func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
// only reads too, unless it is a real run, and then it asks the seat's sweep, which decides and deletes.
|
||||
func Tools(s *Store, c Recorded, cat *Catalogue) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
repoArg := map[string]any{"type": "string", "description": "one repository, as the store names it (<module>/<artifact>)"}
|
||||
return []stdio.Tool{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
${seat:mesh-artifact-store:reach}
|
||||
@@ -1,5 +1,22 @@
|
||||
module artifactstoretools
|
||||
|
||||
go 1.22
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,2 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// blobBound is the most GetBlob reads: a templates archive is a few kilobytes; anything near this is no archive a
|
||||
// take-in reads text from.
|
||||
const blobBound = 64 << 20
|
||||
|
||||
// GetBlob is the archive a recorded reference names (`artifact-store://<repository>/blobs/sha256:<hex>`), read whole
|
||||
// and checked against the digest it names: bytes that hash to another digest are refused, never read (R4e: the
|
||||
// controller reads a templates archive by the reference of a builder's done key). Gone when the store does not hold
|
||||
// it; ErrNotOurs for a reference outside the store or one that is not an archive.
|
||||
func (s Store) GetBlob(ctx context.Context, reference string) ([]byte, error) {
|
||||
repository, digest, archive, err := s.archive(reference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !archive {
|
||||
return nil, fmt.Errorf("%w: %s is not an archive", ErrNotOurs, reference)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "blobs", digest), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := s.client().Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
return nil, Gone
|
||||
default:
|
||||
return nil, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, blobBound+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s/blobs/%s: %w", repository, digest, err)
|
||||
}
|
||||
if len(body) > blobBound {
|
||||
return nil, fmt.Errorf("%s/blobs/%s is larger than %d bytes, which no archive read here is", repository, digest, blobBound)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
|
||||
return nil, fmt.Errorf("the artifact store answered %s/blobs/%s with bytes of the digest %s: refused", repository,
|
||||
digest, got)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A take-in reads a templates archive by its reference, and only bytes of the digest the reference names.
|
||||
func TestGetBlobReadsOnlyTheBytesOfItsDigest(t *testing.T) {
|
||||
good := []byte("the archive")
|
||||
sum := sha256.Sum256(good)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
liar := "sha256:" + strings.Repeat("0", 64)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+digest), strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+liar):
|
||||
_, _ = w.Write(good)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
s := Store{Address: strings.TrimPrefix(server.URL, "http://")}
|
||||
ctx := context.Background()
|
||||
body, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+digest)
|
||||
if err != nil || string(body) != string(good) {
|
||||
t.Fatalf("the archive: %q %v", body, err)
|
||||
}
|
||||
if _, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+liar); err == nil || !strings.Contains(err.Error(), "refused") {
|
||||
t.Fatalf("bytes of another digest are read: %v", err)
|
||||
}
|
||||
if _, err := s.GetBlob(ctx, "artifact-store://x/other/blobs/"+digest); !errors.Is(err, Gone) {
|
||||
t.Fatalf("a blob the store does not hold: %v", err)
|
||||
}
|
||||
if _, err := s.GetBlob(ctx, "registry.example/x@"+digest); !errors.Is(err, ErrNotOurs) {
|
||||
t.Fatalf("a reference outside the store: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Every archive the store keeps is held by a manifest (novox/hq issue 253, ADR 0189).
|
||||
//
|
||||
// **The store's collector marks only from manifests.** The mesh's store is a stock registry, and
|
||||
// its nightly `registry garbage-collect` walks every manifest in every repository, marks the blobs
|
||||
// those manifests name, and deletes every blob it did not mark. An image is a manifest, so what
|
||||
// the mesh keeps of an image survives. An archive was not: the builder put it in the store as a
|
||||
// bare blob — upload, then `PUT ?digest=` — and nothing in the store names it. To the collector a
|
||||
// bare blob is unreferenced, so the first real collection would have deleted every archive the
|
||||
// mesh holds, kept or not, and every machine pinning a bundle would have found it gone. The
|
||||
// collector runs `--dry-run` until this is true.
|
||||
//
|
||||
// **So each archive gets a holder**: the smallest OCI image manifest that names it — the empty
|
||||
// config, one layer, nothing else — put in the archive's own repository, by digest, untagged. The
|
||||
// collector marks it and so keeps the archive; the sweep lets go of an archive by deleting its
|
||||
// holder first, which is what lets the bytes go at the next collection.
|
||||
//
|
||||
// **Nothing a machine reads changes.** The recorded reference stays
|
||||
// `artifact-store://<module>/<artifact>/blobs/sha256:…`, and machines fetch the blob exactly as
|
||||
// before. The holder is the store's bookkeeping, not a second way to reach anything.
|
||||
//
|
||||
// **Deterministic, so it never needs recording.** The holder is composed from the archive's digest
|
||||
// and size alone, in a fixed field order with no timestamps or annotations, so the sweep can
|
||||
// compute which manifest holds any archive from the reference it already has plus one HEAD for the
|
||||
// size. No schema change, no second record that could disagree with the store.
|
||||
|
||||
const (
|
||||
// mediaManifest is the type a holder is put and asked for as.
|
||||
mediaManifest = "application/vnd.oci.image.manifest.v1+json"
|
||||
// mediaEmpty is the OCI empty descriptor's type: a config that says nothing, for a manifest
|
||||
// whose only purpose is to name its layer.
|
||||
mediaEmpty = "application/vnd.oci.empty.v1+json"
|
||||
// emptyDigest is the digest of `{}`, the empty config's content, fixed by the OCI spec.
|
||||
emptyDigest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
||||
// mediaArchive is the layer type an archive is held as. Every archive the builder publishes is
|
||||
// `pack`'s gzipped tar, so this is the true type and not a placeholder — and it is a constant,
|
||||
// not read from anywhere, because the holder must be recomputable from the reference alone.
|
||||
mediaArchive = "application/vnd.oci.image.layer.v1.tar+gzip"
|
||||
)
|
||||
|
||||
// emptyConfig is the content emptyDigest names.
|
||||
var emptyConfig = []byte("{}")
|
||||
|
||||
// manifestAccept is what a manifest is asked for as. A registry answers a manifest HEAD only in a
|
||||
// type the caller named, and answers 404 to a bare one for a manifest it holds perfectly well
|
||||
// (measured 2026-09-28; internal/builder/registry.go says how that was found).
|
||||
var manifestAccept = []string{
|
||||
mediaManifest,
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
}
|
||||
|
||||
type descriptor struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type holderManifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Config descriptor `json:"config"`
|
||||
Layers []descriptor `json:"layers"`
|
||||
}
|
||||
|
||||
// Holder is the manifest that holds an archive in the store, and its digest.
|
||||
//
|
||||
// A pure function of the archive's digest and size: the same two in give the same bytes out,
|
||||
// always, because `encoding/json` writes a struct's fields in their declared order and there is
|
||||
// nothing here that varies by when or where it was composed.
|
||||
func Holder(digest string, size int64) (body []byte, holder string) {
|
||||
body, err := json.Marshal(holderManifest{
|
||||
SchemaVersion: 2,
|
||||
MediaType: mediaManifest,
|
||||
Config: descriptor{MediaType: mediaEmpty, Digest: emptyDigest, Size: int64(len(emptyConfig))},
|
||||
Layers: []descriptor{{MediaType: mediaArchive, Digest: digest, Size: size}},
|
||||
})
|
||||
if err != nil {
|
||||
// Marshalling a struct of strings and integers cannot fail.
|
||||
panic(err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return body, "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Hold makes sure the store holds this archive by a manifest, and says whether it had to write one.
|
||||
//
|
||||
// Takes a reference as the mesh records it. An image is its own manifest and needs no holder, so
|
||||
// it answers false and nothing is asked. Idempotent: a holder already there is left alone, which
|
||||
// is what lets the sweep run it over every kept archive on every build and so backfill the bare
|
||||
// blobs published before holders existed (novox/hq issue 253).
|
||||
//
|
||||
// Gone when the store does not hold the archive at all: there is nothing to hold, and that is a
|
||||
// fact the caller reports rather than one this invents a remedy for.
|
||||
func (s Store) Hold(ctx context.Context, reference string) (bool, error) {
|
||||
repository, digest, archive, err := s.archive(reference)
|
||||
if err != nil || !archive {
|
||||
return false, err
|
||||
}
|
||||
size, err := s.blobSize(ctx, repository, digest)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.HoldBlob(ctx, repository, digest, size)
|
||||
}
|
||||
|
||||
// Held is whether the store holds this archive by its manifest. Asks and changes nothing — the
|
||||
// question an operator needs answered with "none unheld" before the collector is let loose.
|
||||
//
|
||||
// An image answers true: it is its own manifest. An archive the store does not have answers Gone.
|
||||
func (s Store) Held(ctx context.Context, reference string) (bool, error) {
|
||||
repository, digest, archive, err := s.archive(reference)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !archive {
|
||||
return true, nil
|
||||
}
|
||||
size, err := s.blobSize(ctx, repository, digest)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, holder := Holder(digest, size)
|
||||
return s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...)
|
||||
}
|
||||
|
||||
// HoldBlob puts the holder for a blob of this digest and size into its repository, unless it is
|
||||
// there already. Answers whether it wrote one.
|
||||
//
|
||||
// The builder calls this with the size it has just uploaded; the sweep, through Hold, with the size
|
||||
// the store reports. Both arrive at the same holder, which is the point of composing it.
|
||||
func (s Store) HoldBlob(ctx context.Context, repository, digest string, size int64) (bool, error) {
|
||||
if s.Address == "" {
|
||||
return false, fmt.Errorf("this mesh has no artifact store on its network to hold %s/%s in", repository, digest)
|
||||
}
|
||||
body, holder := Holder(digest, size)
|
||||
there, err := s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if there {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// The config must be in the repository before a manifest naming it is accepted: a registry
|
||||
// refuses a manifest whose blobs it cannot find there, which is the property that makes a
|
||||
// holder mean something.
|
||||
if err := s.putBlob(ctx, repository, emptyDigest, emptyConfig); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// **By digest, never by tag.** A tag would be one more name to move and one more thing the
|
||||
// collector's `--delete-untagged` would read as meaningful; the mesh names nothing by tag that
|
||||
// it pins by digest, and an untagged manifest is kept by plain collection.
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut,
|
||||
s.url(repository, "manifests", holder), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
request.Header.Set("Content-Type", mediaManifest)
|
||||
response, err := s.client().Do(request)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
said, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
return false, fmt.Errorf("the artifact store refused to hold %s/%s: %s %s",
|
||||
repository, digest, response.Status, strings.TrimSpace(string(said)))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// letGoOfHolder deletes the manifest holding an archive, before the archive's own link goes.
|
||||
//
|
||||
// **Holder first.** Deleting the blob link alone leaves a manifest still naming the blob, and the
|
||||
// collector would keep its bytes for ever on the strength of it — the sweep would record the
|
||||
// archive collected while the disk said otherwise. Deleting the holder first and failing before
|
||||
// the link goes leaves an unheld archive that the next sweep still offers, which is safe.
|
||||
//
|
||||
// A store that no longer has the blob answers Gone: without its size the holder cannot be named,
|
||||
// and without the blob there is nothing left for a holder to keep. A store that never had a holder
|
||||
// for it — an archive published before holders, never backfilled — answers 404 to the delete, and
|
||||
// that is the outcome wanted.
|
||||
func (s Store) letGoOfHolder(ctx context.Context, repository, digest string) error {
|
||||
size, err := s.blobSize(ctx, repository, digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, holder := Holder(digest, size)
|
||||
err = s.remove(ctx, s.url(repository, "manifests", holder), repository+"/manifests/"+holder)
|
||||
if err == Gone {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// archive reads a recorded reference into its repository and digest, and whether it is an archive
|
||||
// at all. Refuses as ErrNotOurs anything the mesh did not put in its own store.
|
||||
func (s Store) archive(reference string) (repository, digest string, archive bool, err error) {
|
||||
path, kept := catalogue.InArtifactStore(reference)
|
||||
if !kept {
|
||||
return "", "", false, fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||
}
|
||||
if s.Address == "" {
|
||||
return "", "", false, fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
||||
}
|
||||
repository, kind, digest, err := split(path)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return repository, digest, kind == "blobs", nil
|
||||
}
|
||||
|
||||
// blobSize is how large the store says a blob is; Gone when it does not have it.
|
||||
func (s Store) blobSize(ctx context.Context, repository, digest string) (int64, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodHead, s.url(repository, "blobs", digest), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
response, err := s.client().Do(request)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
return 0, Gone
|
||||
default:
|
||||
return 0, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest)
|
||||
}
|
||||
// Read from the header rather than ContentLength: a HEAD's ContentLength is what the response
|
||||
// says it would have sent, which Go reports faithfully, but a proxy in between is free to drop
|
||||
// it, and the header is what the registry itself wrote.
|
||||
if length := response.Header.Get("Content-Length"); length != "" {
|
||||
if n, err := strconv.ParseInt(length, 10, 64); err == nil && n >= 0 {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
if response.ContentLength >= 0 {
|
||||
return response.ContentLength, nil
|
||||
}
|
||||
return 0, fmt.Errorf("the artifact store holds %s/blobs/%s and will not say how large it is", repository, digest)
|
||||
}
|
||||
|
||||
// putBlob uploads a small blob unless the repository already has it: ask where, then put it there
|
||||
// naming the digest — the registry's own two steps, the same the builder takes for an archive.
|
||||
func (s Store) putBlob(ctx context.Context, repository, digest string, body []byte) error {
|
||||
if there, err := s.has(ctx, s.url(repository, "blobs", digest)); err != nil {
|
||||
return err
|
||||
} else if there {
|
||||
return nil
|
||||
}
|
||||
start, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
"http://"+s.Address+"/v2/"+repository+"/blobs/uploads/", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
begun, err := s.client().Do(start)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot start an upload to %s: %w", repository, err)
|
||||
}
|
||||
begun.Body.Close()
|
||||
if begun.StatusCode != http.StatusAccepted {
|
||||
return fmt.Errorf("the artifact store answered %s when asked where to put a blob in %s", begun.Status, repository)
|
||||
}
|
||||
where := begun.Header.Get("Location")
|
||||
if where == "" {
|
||||
return fmt.Errorf("the artifact store accepted an upload to %s and said nowhere to put it", repository)
|
||||
}
|
||||
if strings.HasPrefix(where, "/") {
|
||||
where = "http://" + s.Address + where
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(where, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
put, err := http.NewRequestWithContext(ctx, http.MethodPut, where+separator+"digest="+digest, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
put.Header.Set("Content-Type", "application/octet-stream")
|
||||
done, err := s.client().Do(put)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer done.Body.Close()
|
||||
if done.StatusCode != http.StatusCreated {
|
||||
said, _ := io.ReadAll(io.LimitReader(done.Body, 4096))
|
||||
return fmt.Errorf("the artifact store refused a blob in %s: %s %s", repository, done.Status, strings.TrimSpace(string(said)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// has is whether the store answers 200 for a HEAD at that URL.
|
||||
func (s Store) has(ctx context.Context, url string, accept ...string) (bool, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, media := range accept {
|
||||
request.Header.Add("Accept", media)
|
||||
}
|
||||
response, err := s.client().Do(request)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("the artifact store answered %s for %s", response.Status, url)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store) url(repository, kind, digest string) string {
|
||||
return "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
|
||||
}
|
||||
|
||||
func (s Store) client() *http.Client {
|
||||
if s.HTTP != nil {
|
||||
return s.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Every kept archive is held by a manifest (novox/hq issue 253, ADR 0189).
|
||||
//
|
||||
// Against an in-memory registry that keeps blobs and manifests per repository and refuses what a
|
||||
// registry refuses — a blob whose digest does not match, a manifest whose digest does not match
|
||||
// or whose blobs the repository does not have, a manifest asked for without an Accept naming its
|
||||
// type. What is asserted is this side's decisions; the live test below asserts the registry's.
|
||||
|
||||
type memRegistry struct {
|
||||
mu sync.Mutex
|
||||
blobs map[string][]byte // repository + "@" + digest
|
||||
manifests map[string][]byte // repository + "@" + digest
|
||||
writes []string // every PUT and DELETE, as "METHOD path"
|
||||
}
|
||||
|
||||
func digestOf(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (m *memRegistry) serve(t *testing.T) Store {
|
||||
t.Helper()
|
||||
m.blobs = map[string][]byte{}
|
||||
m.manifests = map[string][]byte{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v2/")
|
||||
if r.Method == http.MethodPut || r.Method == http.MethodDelete {
|
||||
m.writes = append(m.writes, r.Method+" "+r.URL.Path)
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(path, "/blobs/uploads/"):
|
||||
repository := strings.TrimSuffix(path, "/blobs/uploads/")
|
||||
w.Header().Set("Location", "/upload/"+repository+"?state=x")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"):
|
||||
repository := strings.TrimPrefix(r.URL.Path, "/upload/")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
digest := r.URL.Query().Get("digest")
|
||||
if digest != digestOf(body) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
m.blobs[repository+"@"+digest] = body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case strings.Contains(path, "/blobs/"):
|
||||
repository, digest, _ := strings.Cut(path, "/blobs/")
|
||||
key := repository + "@" + digest
|
||||
body, ok := m.blobs[key]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodDelete:
|
||||
delete(m.blobs, key)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.Contains(path, "/manifests/"):
|
||||
repository, digest, _ := strings.Cut(path, "/manifests/")
|
||||
key := repository + "@" + digest
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
if _, ok := m.manifests[key]; !ok || !strings.Contains(r.Header.Get("Accept"), mediaManifest) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodPut:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if digest != digestOf(body) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var named holderManifest
|
||||
if err := json.Unmarshal(body, &named); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, d := range append([]descriptor{named.Config}, named.Layers...) {
|
||||
if _, ok := m.blobs[repository+"@"+d.Digest]; !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "MANIFEST_BLOB_UNKNOWN %s", d.Digest)
|
||||
return
|
||||
}
|
||||
}
|
||||
m.manifests[key] = body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case http.MethodDelete:
|
||||
if _, ok := m.manifests[key]; !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(m.manifests, key)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return Store{Address: strings.TrimPrefix(server.URL, "http://")}
|
||||
}
|
||||
|
||||
// bare puts an archive in the store the way the builder did before holders: a blob, nothing more.
|
||||
func (m *memRegistry) bare(repository string, body []byte) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
digest := digestOf(body)
|
||||
m.blobs[repository+"@"+digest] = body
|
||||
return catalogue.ArtifactStoreScheme + repository + "/blobs/" + digest
|
||||
}
|
||||
|
||||
func TestTheHolderIsComposedFromTheDigestAndSizeAlone(t *testing.T) {
|
||||
// The sweep must arrive at the very manifest the builder wrote, with nothing recorded between
|
||||
// them. Same inputs, same bytes — and a different size is a different holder, so a holder can
|
||||
// never be mistaken for one of a different blob.
|
||||
digest := "sha256:" + strings.Repeat("a", 64)
|
||||
one, first := Holder(digest, 42)
|
||||
two, second := Holder(digest, 42)
|
||||
if !bytes.Equal(one, two) || first != second {
|
||||
t.Fatalf("the same archive composed two holders:\n%s\n%s", one, two)
|
||||
}
|
||||
if _, other := Holder(digest, 43); other == first {
|
||||
t.Fatal("a different size composed the same holder")
|
||||
}
|
||||
want := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` +
|
||||
`"config":{"mediaType":"application/vnd.oci.empty.v1+json",` +
|
||||
`"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` +
|
||||
`"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"` + digest + `","size":42}]}`
|
||||
if string(one) != want {
|
||||
t.Fatalf("the holder is\n%s\nwant\n%s", one, want)
|
||||
}
|
||||
if digestOf(emptyConfig) != emptyDigest {
|
||||
t.Fatalf("the empty config's digest is %s, not %s", digestOf(emptyConfig), emptyDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoldBackfillsABareArchiveAndIsIdempotent(t *testing.T) {
|
||||
// The archives published before this have no holder. Hold, run over every kept archive on
|
||||
// every sweep, writes one the first time and nothing after.
|
||||
m := &memRegistry{}
|
||||
store := m.serve(t)
|
||||
ctx := context.Background()
|
||||
body := []byte("a theme")
|
||||
reference := m.bare("shell/config", body)
|
||||
|
||||
if held, err := store.Held(ctx, reference); err != nil || held {
|
||||
t.Fatalf("a bare blob reads as held=%v (%v)", held, err)
|
||||
}
|
||||
wrote, err := store.Hold(ctx, reference)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Fatal("holding a bare archive wrote nothing")
|
||||
}
|
||||
_, holder := Holder(digestOf(body), int64(len(body)))
|
||||
if _, ok := m.manifests["shell/config@"+holder]; !ok {
|
||||
t.Fatalf("the store holds manifests %v; want %s", m.manifests, holder)
|
||||
}
|
||||
if held, err := store.Held(ctx, reference); err != nil || !held {
|
||||
t.Fatalf("after holding, held=%v (%v)", held, err)
|
||||
}
|
||||
|
||||
writes := len(m.writes)
|
||||
wrote, err = store.Hold(ctx, reference)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrote || len(m.writes) != writes {
|
||||
t.Fatalf("holding again wrote %v", m.writes[writes:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageNeedsNoHolderAndAMissingArchiveIsGone(t *testing.T) {
|
||||
m := &memRegistry{}
|
||||
store := m.serve(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// An image is its own manifest: nothing is asked.
|
||||
wrote, err := store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/app@sha256:"+strings.Repeat("b", 64))
|
||||
if err != nil || wrote || len(m.writes) != 0 {
|
||||
t.Fatalf("holding an image wrote=%v err=%v writes=%v", wrote, err, m.writes)
|
||||
}
|
||||
// An archive the store does not have is a fact to report, not something to invent a holder for.
|
||||
_, err = store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/config/blobs/sha256:"+strings.Repeat("c", 64))
|
||||
if !errors.Is(err, Gone) {
|
||||
t.Fatalf("holding a missing archive answered %v, want Gone", err)
|
||||
}
|
||||
// And a reference that is not the mesh's is refused as such.
|
||||
if _, err := store.Hold(ctx, "docker.io/library/registry@sha256:abc"); !errors.Is(err, ErrNotOurs) {
|
||||
t.Fatalf("holding a vendor's image answered %v, want ErrNotOurs", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLettingGoOfAnArchiveDeletesItsHolderFirst(t *testing.T) {
|
||||
// A holder left behind would keep the bytes through every collection while the record said
|
||||
// collected; the link deleted first and the holder failing after would be that exactly.
|
||||
m := &memRegistry{}
|
||||
store := m.serve(t)
|
||||
ctx := context.Background()
|
||||
body := []byte("an old theme")
|
||||
reference := m.bare("shell/config", body)
|
||||
if _, err := store.Hold(ctx, reference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.writes = nil
|
||||
|
||||
if err := store.LetGo(ctx, reference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, holder := Holder(digestOf(body), int64(len(body)))
|
||||
want := []string{
|
||||
"DELETE /v2/shell/config/manifests/" + holder,
|
||||
"DELETE /v2/shell/config/blobs/" + digestOf(body),
|
||||
}
|
||||
if strings.Join(m.writes, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("the store was asked\n%s\nwant\n%s", strings.Join(m.writes, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if len(m.manifests) != 0 {
|
||||
t.Fatalf("a holder survived: %v", m.manifests)
|
||||
}
|
||||
// Asked again, the archive is already gone, which is the outcome wanted.
|
||||
if err := store.LetGo(ctx, reference); !errors.Is(err, Gone) {
|
||||
t.Fatalf("letting go twice answered %v, want Gone", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLettingGoOfAnUnheldArchiveStillDeletesIt(t *testing.T) {
|
||||
// An archive published before holders and let go of before any sweep held it: the holder's
|
||||
// delete answers 404, which is the outcome wanted, and the blob still goes.
|
||||
m := &memRegistry{}
|
||||
store := m.serve(t)
|
||||
reference := m.bare("shell/config", []byte("never held"))
|
||||
if err := store.LetGo(context.Background(), reference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.blobs) != 0 {
|
||||
t.Fatalf("the blob survived: %v", m.blobs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// An index is let go of with the platform manifests it names, except those a kept index of the same
|
||||
// repository names too (novox/hq ADR 0257). Each platform manifest is a manifest of the repository in
|
||||
// its own right, and the store's collector keeps every manifest a repository holds — so an index let go
|
||||
// of alone frees nothing of the images it names.
|
||||
|
||||
func digestN(n int) string { return fmt.Sprintf("sha256:%064x", n) }
|
||||
|
||||
// indexStore holds indexes and images in one repository, answers GETs with their documents, and records
|
||||
// every delete.
|
||||
type indexStore struct {
|
||||
mu sync.Mutex
|
||||
indexes map[string][]string // digest → the platform manifests it names
|
||||
images map[string]bool
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (s *indexStore) serve(t *testing.T) Store {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
digest := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if children, ok := s.indexes[digest]; ok {
|
||||
var named []string
|
||||
for _, c := range children {
|
||||
named = append(named, `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"`+c+`","size":1}`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
|
||||
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
|
||||
strings.Join(named, ",") + `]}`))
|
||||
return
|
||||
}
|
||||
if s.images[digest] {
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodHead:
|
||||
if _, ok := s.indexes[digest]; ok || s.images[digest] {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodDelete:
|
||||
if _, ok := s.indexes[digest]; !ok && !s.images[digest] {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.deleted = append(s.deleted, digest)
|
||||
delete(s.indexes, digest)
|
||||
delete(s.images, digest)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return Store{Address: strings.TrimPrefix(server.URL, "http://")}
|
||||
}
|
||||
|
||||
func TestAnIndexGoesWithItsPlatformsAndAKeptIndexKeepsItsOwn(t *testing.T) {
|
||||
// Two indexes of one image's repository: the old one names platforms 11 and 12, the kept one 12 and 13.
|
||||
s := &indexStore{
|
||||
indexes: map[string][]string{digestN(1): {digestN(11), digestN(12)}, digestN(2): {digestN(12), digestN(13)}},
|
||||
images: map[string]bool{digestN(11): true, digestN(12): true, digestN(13): true},
|
||||
}
|
||||
store := s.serve(t)
|
||||
repository := "upstream/docker.io/library/golang"
|
||||
old := catalogue.ArtifactStoreScheme + repository + "@" + digestN(1)
|
||||
kept := catalogue.ArtifactStoreScheme + repository + "@" + digestN(2)
|
||||
spare, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Spare = spare
|
||||
|
||||
if err := store.LetGo(context.Background(), old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Its own platform first, the index last; the platform the kept index names is left in place.
|
||||
if !slices.Equal(s.deleted, []string{digestN(11), digestN(1)}) {
|
||||
t.Fatalf("deleted %v; want the old index's own platform, then the index", s.deleted)
|
||||
}
|
||||
if !s.images[digestN(12)] || !s.images[digestN(13)] {
|
||||
t.Fatal("a platform a kept index names was let go of")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutSparingAnIndexGoesAloneAndAnImageNamesNoPlatforms(t *testing.T) {
|
||||
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true, digestN(5): true}}
|
||||
store := s.serve(t)
|
||||
// No Spare: as before ADR 0257, the index alone.
|
||||
if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(s.deleted, []string{digestN(1)}) {
|
||||
t.Fatalf("without a Spare, deleted %v", s.deleted)
|
||||
}
|
||||
// An image is one delete, with a Spare or without.
|
||||
s.deleted = nil
|
||||
spare, err := store.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "web/app@" + digestN(5)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Spare = spare
|
||||
if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@"+digestN(5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(s.deleted, []string{digestN(5)}) {
|
||||
t.Fatalf("an image was let go of as %v", s.deleted)
|
||||
}
|
||||
// An index the store no longer holds is Gone, and nothing is deleted on its account.
|
||||
s.deleted = nil
|
||||
err = store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(9))
|
||||
if !errors.Is(err, Gone) || len(s.deleted) != 0 {
|
||||
t.Fatalf("an index the store does not hold: %v, deleted %v", err, s.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASpareThatCannotBeReadKeepsTheIndex(t *testing.T) {
|
||||
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}}
|
||||
store := s.serve(t)
|
||||
store.Spare = func(context.Context, string) (map[string]bool, error) { return nil, fmt.Errorf("the store went away") }
|
||||
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1))
|
||||
if err == nil || !strings.Contains(err.Error(), "was kept") {
|
||||
t.Fatalf("a spare that could not be read: %v", err)
|
||||
}
|
||||
if len(s.deleted) != 0 {
|
||||
t.Fatalf("deleted %v without knowing what a kept index names", s.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// The spare list is read whole before the sweep: a kept index that cannot be read is an error before
|
||||
// anything is deleted, and a repository not read before keeps its index.
|
||||
func TestASpareListIsReadWholeBeforeAnythingGoes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
store := Store{Address: strings.TrimPrefix(server.URL, "http://")}
|
||||
kept := catalogue.ArtifactStoreScheme + "web/base@" + digestN(2)
|
||||
old := catalogue.ArtifactStoreScheme + "web/base@" + digestN(1)
|
||||
if _, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old}); err == nil {
|
||||
t.Fatal("a kept index the store would not answer for was taken as naming nothing")
|
||||
}
|
||||
|
||||
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}}
|
||||
good := s.serve(t)
|
||||
spare, err := good.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "other/repo@" + digestN(3)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good.Spare = spare
|
||||
if err := good.LetGo(context.Background(), old); err == nil || len(s.deleted) != 0 {
|
||||
t.Fatalf("an index in a repository not read before the sweep was let go of: %v, deleted %v", err, s.deleted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package registry speaks to the mesh's artifact store over its own door.
|
||||
//
|
||||
// Only what the mesh needs that nothing else does: letting go of something it put there
|
||||
// (novox/hq ADR 0189, issue 108), and holding every archive it keeps by a manifest so the store's
|
||||
// own collector does not take it (novox/hq issue 253). Pushing is the builder's, through the container runtime; reading
|
||||
// is every machine's, through its runtime. This is the one operation that belongs to the thing
|
||||
// holding the records, because it is the only one that is a decision rather than a transfer.
|
||||
//
|
||||
// Copied from mesh-controller internal/artifacts at e04846e9 (novox/hq ADR 0571 clause 2): the seat mesh-artifact-sweep lets
|
||||
// go of what it no longer keeps exactly as the controller's sweep did; the controller's copy goes with its lease.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Store is the artifact store at an address, as this machine reaches it.
|
||||
type Store struct {
|
||||
// Address is `host:port` — the store as the caller reaches it now, composed and never
|
||||
// recorded (novox/hq 04-ISSUES/102).
|
||||
Address string
|
||||
// HTTP is the client used; nil is a client with a modest timeout.
|
||||
HTTP *http.Client
|
||||
// Spare says, for one repository, which manifests a kept index there names: the platform
|
||||
// manifests an index being let go of must leave in place (novox/hq ADR 0257). Nil spares every
|
||||
// platform manifest — an index is then let go of alone, as it was before.
|
||||
Spare func(ctx context.Context, repository string) (map[string]bool, error)
|
||||
}
|
||||
|
||||
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
|
||||
var Gone = errors.New("the store does not hold it")
|
||||
|
||||
// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing
|
||||
// the store holds by digest.
|
||||
//
|
||||
// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite
|
||||
// responses — skip one and go on, abandon the sweep for the other — and collapsing them into "an
|
||||
// error" is how a cautious loop became one that did nothing while reporting the right number.
|
||||
var ErrNotOurs = errors.New("not a reference into the mesh's artifact store")
|
||||
|
||||
// LetGo asks the store to drop one artifact the mesh recorded making.
|
||||
//
|
||||
// Takes a reference as the mesh records it — `artifact-store://<module>/<artifact>@sha256:…` for
|
||||
// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses,
|
||||
// and composes the address here at the moment of use.
|
||||
//
|
||||
// An archive is let go of in two deletes, its holder manifest and then the blob's link (novox/hq
|
||||
// issue 253); an image in one.
|
||||
//
|
||||
// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep
|
||||
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
|
||||
// which of the two happened.
|
||||
func (s Store) LetGo(ctx context.Context, reference string) error {
|
||||
// **Strict, and deliberately** (novox/hq issue 226). Only a reference the mesh keeps in its
|
||||
// own vocabulary is addressed here. `Recorded` would read `docker.io/library/registry@sha256:…`
|
||||
// as the mesh's too — it cannot tell one registry host from another — so normalising belongs
|
||||
// where the provenance is known, which is the sweep reading its own build records, not here
|
||||
// where the only job is to refuse anything that is not plainly ours.
|
||||
path, kept := catalogue.InArtifactStore(reference)
|
||||
if !kept {
|
||||
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
|
||||
// delete for a reference of unknown shape is how a sweep reaches something that is not
|
||||
// the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on.
|
||||
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||
}
|
||||
if s.Address == "" {
|
||||
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
||||
}
|
||||
repository, kind, digest, err := split(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == "blobs" {
|
||||
// **An archive's holder goes before the archive** (novox/hq issue 253): a manifest left
|
||||
// naming the blob would keep its bytes through every collection while the record said
|
||||
// collected. Gone here means the store has no such blob, so there is nothing to let go.
|
||||
if err := s.letGoOfHolder(ctx, repository, digest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if kind == "manifests" && s.Spare != nil {
|
||||
// **An index's platform manifests go before the index** (novox/hq ADR 0257). Each was put
|
||||
// under its own digest, so each is a manifest of the repository in its own right, and the
|
||||
// store's collector keeps every manifest a repository holds: an index let go of alone frees
|
||||
// no byte of the images it names. Before, not after, so a sweep cut short leaves the index
|
||||
// to be asked about again, and with it the list of what is still to go.
|
||||
if err := s.letGoOfPlatforms(ctx, repository, digest, reference); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.remove(ctx, s.url(repository, kind, digest), reference)
|
||||
}
|
||||
|
||||
// letGoOfPlatforms deletes the manifests an index names, except those a kept index of the same
|
||||
// repository also names. A manifest that is not an index names none.
|
||||
func (s Store) letGoOfPlatforms(ctx context.Context, repository, digest, reference string) error {
|
||||
children, err := s.Platforms(ctx, repository, digest)
|
||||
if err != nil || len(children) == 0 {
|
||||
// Gone: there is no index to read, and the delete that follows says so.
|
||||
if errors.Is(err, Gone) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
spared, err := s.Spare(ctx, repository)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot say which platform manifests of %s a kept index names, so %s was kept: %w",
|
||||
repository, reference, err)
|
||||
}
|
||||
for _, child := range children {
|
||||
if spared[child] {
|
||||
continue
|
||||
}
|
||||
if err := s.remove(ctx, s.url(repository, "manifests", child), reference+" (its "+child+")"); err != nil && !errors.Is(err, Gone) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// anyManifest is every manifest the store may hold, indexes included, so it answers with the
|
||||
// document as it is.
|
||||
var anyManifest = []string{
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
}
|
||||
|
||||
// Platforms is the manifests an index names, by digest; none for a manifest that is not an index.
|
||||
// Gone when the store does not hold it.
|
||||
func (s Store) Platforms(ctx context.Context, repository, digest string) ([]string, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "manifests", digest), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", strings.Join(anyManifest, ", "))
|
||||
response, err := s.client().Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
return nil, Gone
|
||||
default:
|
||||
return nil, fmt.Errorf("the artifact store answered %s when asked for %s@%s", response.Status, repository, digest)
|
||||
}
|
||||
var document struct {
|
||||
Manifests []struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"manifests"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&document); err != nil {
|
||||
return nil, fmt.Errorf("%s@%s is not a manifest: %w", repository, digest, err)
|
||||
}
|
||||
var out []string
|
||||
for _, m := range document.Manifests {
|
||||
if strings.HasPrefix(m.Digest, "sha256:") {
|
||||
out = append(out, m.Digest)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SpareKeptIndexes is a Spare for a sweep, read whole before it starts: for every repository an
|
||||
// image reference of `touching` is in, each kept manifest there and every platform manifest a kept
|
||||
// index there names. Any read that fails is an error, and the sweep lets nothing go. A repository not
|
||||
// read here is answered with an error, so an index in it is kept. A kept index the store no longer
|
||||
// holds names nothing.
|
||||
func (s Store) SpareKeptIndexes(ctx context.Context, kept, touching []string) (func(context.Context, string) (map[string]bool, error), error) {
|
||||
imageIn := func(reference string) (repository, digest string, ok bool) {
|
||||
path, ours := catalogue.InArtifactStore(reference)
|
||||
if !ours {
|
||||
return "", "", false
|
||||
}
|
||||
repository, kind, digest, err := split(path)
|
||||
if err != nil || kind != "manifests" {
|
||||
return "", "", false
|
||||
}
|
||||
return repository, digest, true
|
||||
}
|
||||
read := map[string]map[string]bool{}
|
||||
for _, reference := range touching {
|
||||
if repository, _, ok := imageIn(reference); ok {
|
||||
read[repository] = map[string]bool{}
|
||||
}
|
||||
}
|
||||
for _, reference := range kept {
|
||||
repository, digest, ok := imageIn(reference)
|
||||
spared, touched := read[repository]
|
||||
if !ok || !touched {
|
||||
continue
|
||||
}
|
||||
spared[digest] = true
|
||||
children, err := s.Platforms(ctx, repository, digest)
|
||||
if errors.Is(err, Gone) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the kept %s@%s: %w", repository, digest, err)
|
||||
}
|
||||
for _, c := range children {
|
||||
spared[c] = true
|
||||
}
|
||||
}
|
||||
return func(_ context.Context, repository string) (map[string]bool, error) {
|
||||
spared, done := read[repository]
|
||||
if !done {
|
||||
return nil, fmt.Errorf("%s was not read before the sweep began", repository)
|
||||
}
|
||||
return spared, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// remove asks the store to delete what is at url. Gone when it has no such thing.
|
||||
func (s Store) remove(ctx context.Context, url, what string) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := s.client().Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusAccepted, http.StatusOK, http.StatusNoContent:
|
||||
return nil
|
||||
case http.StatusNotFound:
|
||||
return Gone
|
||||
case http.StatusMethodNotAllowed:
|
||||
// The registry was started without deletion enabled. Said plainly, because the remedy is
|
||||
// a setting on the store's module and not anything about this artifact.
|
||||
return fmt.Errorf(
|
||||
"the artifact store refuses deletion: its server was started without it enabled "+
|
||||
"(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+
|
||||
"module is applied again (novox/hq ADR 0189). Asking about %s", what)
|
||||
default:
|
||||
return fmt.Errorf("the artifact store answered %s for %s", response.Status, what)
|
||||
}
|
||||
}
|
||||
|
||||
// split reads a recorded path into the repository, which endpoint names the thing, and the digest.
|
||||
//
|
||||
// Two shapes, which are the two the mesh records: `<repository>@sha256:<hex>` is a manifest, and
|
||||
// `<repository>/blobs/sha256:<hex>` is a blob.
|
||||
func split(path string) (repository, kind, digest string, err error) {
|
||||
if before, after, ok := strings.Cut(path, "@sha256:"); ok {
|
||||
return before, "manifests", "sha256:" + after, nil
|
||||
}
|
||||
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
|
||||
return before, "blobs", "sha256:" + after, nil
|
||||
}
|
||||
return "", "", "", fmt.Errorf("%w: %q names nothing the store holds by digest", ErrNotOurs, path)
|
||||
}
|
||||
|
||||
// IsIndex is whether a recorded reference is an index the store holds: one that names manifests. A
|
||||
// blob, or a manifest that names none, is not. Gone when the store does not hold it.
|
||||
func (s Store) IsIndex(ctx context.Context, reference string) (bool, error) {
|
||||
path, ours := catalogue.InArtifactStore(reference)
|
||||
if !ours {
|
||||
return false, fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||
}
|
||||
repository, kind, digest, err := split(path)
|
||||
if err != nil || kind != "manifests" {
|
||||
return false, err
|
||||
}
|
||||
children, err := s.Platforms(ctx, repository, digest)
|
||||
return len(children) > 0, err
|
||||
}
|
||||
|
||||
// HoldsManifest is whether the store holds the manifest a recorded image reference names.
|
||||
func (s Store) HoldsManifest(ctx context.Context, reference string) (bool, error) {
|
||||
path, ours := catalogue.InArtifactStore(reference)
|
||||
if !ours {
|
||||
return false, fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||
}
|
||||
repository, kind, digest, err := split(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if kind != "manifests" {
|
||||
return false, fmt.Errorf("%w: %s names a blob, not a manifest", ErrNotOurs, reference)
|
||||
}
|
||||
return s.has(ctx, s.url(repository, kind, digest), anyManifest...)
|
||||
}
|
||||
|
||||
// Holds is whether the store holds what a recorded reference names: the blob of an archive, the manifest of an image.
|
||||
// Gone when it does not; asks and changes nothing (ADR 0526 clause 3: a registration by hand is refused at bytes that
|
||||
// are not there).
|
||||
func (s Store) Holds(ctx context.Context, reference string) error {
|
||||
path, ours := catalogue.InArtifactStore(reference)
|
||||
if !ours {
|
||||
return fmt.Errorf("%w: %s", ErrNotOurs, reference)
|
||||
}
|
||||
repository, kind, digest, err := split(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Address == "" {
|
||||
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
||||
}
|
||||
if kind == "blobs" {
|
||||
_, err := s.blobSize(ctx, repository, digest)
|
||||
return err
|
||||
}
|
||||
held, err := s.has(ctx, s.url(repository, kind, digest), anyManifest...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !held {
|
||||
return Gone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
||||
//
|
||||
// A fake store records what it was asked to delete, so what is asserted is the mesh's decision
|
||||
// and the shape of the request — not the registry's behaviour, which is the registry's to test.
|
||||
|
||||
func fakeStore(t *testing.T, answer int) (Store, *[]string) {
|
||||
t.Helper()
|
||||
var asked []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/") {
|
||||
// An archive's size, asked so its holder can be named (novox/hq issue 253). A store
|
||||
// that does not have the thing does not have its blob either.
|
||||
if answer == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", "7")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path)
|
||||
}
|
||||
asked = append(asked, r.URL.Path)
|
||||
w.WriteHeader(answer)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
|
||||
}
|
||||
|
||||
func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) {
|
||||
// The two shapes the mesh records: a manifest by digest, and a blob by digest. They are
|
||||
// different endpoints, and asking at the wrong one answers 404 — which this would then
|
||||
// record as collected, leaving the bytes on disk for ever while the record says otherwise.
|
||||
store, asked := fakeStore(t, http.StatusAccepted)
|
||||
ctx := context.Background()
|
||||
|
||||
image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123"
|
||||
archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456"
|
||||
if err := store.LetGo(ctx, image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.LetGo(ctx, archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The archive's holder goes first, then the archive (novox/hq issue 253).
|
||||
_, holder := Holder("sha256:def456", 7)
|
||||
want := []string{
|
||||
"/v2/web/app/manifests/sha256:abc123",
|
||||
"/v2/web/config/manifests/" + holder,
|
||||
"/v2/web/config/blobs/sha256:def456",
|
||||
}
|
||||
if strings.Join(*asked, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("the store was asked %v; want %v", *asked, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) {
|
||||
// The outcome wanted, already true. Told apart from success only so the sweep can say which
|
||||
// happened; both are recorded, because retrying for ever is the thing to avoid.
|
||||
store, _ := fakeStore(t, http.StatusNotFound)
|
||||
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
||||
if !errors.Is(err, Gone) {
|
||||
t.Fatalf("a store that does not hold it answered %v, want Gone", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) {
|
||||
// The registry answers 405 when it was started without deletion enabled. The remedy is a
|
||||
// setting on the store's module, and saying "405" would send somebody to the wrong place.
|
||||
store, _ := fakeStore(t, http.StatusMethodNotAllowed)
|
||||
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
||||
if err == nil {
|
||||
t.Fatal("a store that refuses deletion was read as success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") {
|
||||
t.Fatalf("the refusal does not name the remedy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
|
||||
// The whole safety of the sweep is that it names only what the mesh recorded putting there.
|
||||
// A reference of another shape — a vendor's image, a package version — is refused rather
|
||||
// than composed into a delete somewhere that is not the mesh's store.
|
||||
store, asked := fakeStore(t, http.StatusAccepted)
|
||||
for _, reference := range []string{
|
||||
"docker.io/library/registry@sha256:abc123",
|
||||
"registry@sha256:abc123",
|
||||
"1.4.2",
|
||||
} {
|
||||
if err := store.LetGo(context.Background(), reference); err == nil {
|
||||
t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference)
|
||||
}
|
||||
}
|
||||
if len(*asked) != 0 {
|
||||
t.Fatalf("the store was asked about %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A reference this sweep will not address says so as ErrNotOurs, which is a fact about the
|
||||
// record and not about the store (novox/hq issue 226).
|
||||
//
|
||||
// The sweep skips one and abandons itself for the other, so they cannot be the same error. The
|
||||
// first live run met a reference recorded with the store's old address, read the refusal as "the
|
||||
// store refuses everything", and collected none of the 1681 it had found.
|
||||
func TestAReferenceThisSweepWillNotAddressIsToldApartFromAStoreRefusing(t *testing.T) {
|
||||
store, asked := fakeStore(t, http.StatusAccepted)
|
||||
for _, reference := range []string{
|
||||
"docker.io/library/registry@sha256:abc123",
|
||||
"127.0.0.1:5100/mesh-tools/build@sha256:abc123",
|
||||
"1.4.2",
|
||||
} {
|
||||
err := store.LetGo(context.Background(), reference)
|
||||
if !errors.Is(err, ErrNotOurs) {
|
||||
t.Errorf("%s answered %v; a sweep must be able to skip it and go on", reference, err)
|
||||
}
|
||||
}
|
||||
if len(*asked) != 0 {
|
||||
t.Fatalf("the store was asked about %v", *asked)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,52 @@
|
||||
{
|
||||
"module": "artifact-store-tools",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-artifact-sweep",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"artifacts",
|
||||
"held",
|
||||
"collect",
|
||||
"mirrors",
|
||||
"images"
|
||||
]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"artifact-store"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.artifacts",
|
||||
"seat:mesh-controller.collect",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"mesh-catalog.catalog_modules",
|
||||
"mesh-catalog.catalog_module"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-assigner.wants",
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-builder.build-log",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"node-engine.have",
|
||||
"mesh-controller.lease"
|
||||
],
|
||||
"state": [
|
||||
"findings"
|
||||
],
|
||||
"tools": [
|
||||
"artifact_store_repositories",
|
||||
"artifact_store_usage",
|
||||
"artifact_store_references",
|
||||
"artifact_store_collect"
|
||||
],
|
||||
"config": {
|
||||
"older-store-names": {
|
||||
"type": "string",
|
||||
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
|
||||
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
|
||||
"default": "none"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"older-store-names": {
|
||||
"type": "string",
|
||||
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
|
||||
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
|
||||
"default": "none"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
@@ -36,6 +62,14 @@
|
||||
"mode": "0600",
|
||||
"trusted": false,
|
||||
"template": "files/store-names"
|
||||
},
|
||||
{
|
||||
"id": "store-address",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/store-address",
|
||||
"mode": "0600",
|
||||
"trusted": false,
|
||||
"template": "files/store-address"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -52,7 +86,8 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
|
||||
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
|
||||
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names",
|
||||
"MESH_ARTIFACT_STORE_ADDRESS_FILE": "${dir:state}/store-address"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "rootless-image-build",
|
||||
"reach": "machine"
|
||||
"reach": "machine",
|
||||
"credential": {
|
||||
"form": "none"
|
||||
}
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
|
||||
@@ -32,7 +34,59 @@ const (
|
||||
forgeSecret = "gitea-read.secret"
|
||||
)
|
||||
|
||||
func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) }
|
||||
func (p Paths) forgeSecret() string {
|
||||
if p.Forge != "" {
|
||||
return p.Forge
|
||||
}
|
||||
return filepath.Join(p.State, forgeSecret)
|
||||
}
|
||||
|
||||
// ForgeHealth is what this module says to the operator of its forge credential (the sweep of 2026-10-30, issue 774:
|
||||
// the file was root's, 0600, and the runtime, the operator's account, could not read it; said only in the journal,
|
||||
// some 870 times a machine). raised is the reason last raised, "" when none.
|
||||
type ForgeHealth struct {
|
||||
Node string
|
||||
raised string
|
||||
looked bool
|
||||
}
|
||||
|
||||
// forgeConditionKey is the condition's key: its last part is its kind.
|
||||
func (f *ForgeHealth) key() string { return "claude-code.machine." + f.Node + ".forge-credential" }
|
||||
|
||||
// Look is the condition to say after one placing of the credential, nil when nothing changed: raised on a failure, again
|
||||
// when its reason changes, cleared when it places again; on the first look a success clears what a run before raised.
|
||||
func (f *ForgeHealth) Look(err error) *asks.Condition {
|
||||
first := !f.looked
|
||||
f.looked = true
|
||||
subject := asks.Scope{Scope: "machine", ID: f.Node, Machine: f.Node}
|
||||
if err == nil {
|
||||
if f.raised == "" && !first {
|
||||
return nil
|
||||
}
|
||||
f.raised = ""
|
||||
return &asks.Condition{Key: f.key(), Subject: subject, State: asks.StateCleared}
|
||||
}
|
||||
why := err.Error()
|
||||
if why == f.raised {
|
||||
return nil
|
||||
}
|
||||
f.raised = why
|
||||
needs := "read claude-code's journal on " + f.Node + " and the owner of its forge credential file"
|
||||
return &asks.Condition{Key: f.key(), Severity: asks.SeverityWarning, State: asks.StateRaised, Subject: subject,
|
||||
Headline: "Agents on " + f.Node + " cannot use the forge credential",
|
||||
Summary: why, Needs: needs,
|
||||
Explanation: asks.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " +
|
||||
"clone or fetch the mesh's private repositories.",
|
||||
Resolved: "The forge's read credential is placed again on " + f.Node + "."}
|
||||
}
|
||||
|
||||
func sentence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || strings.HasSuffix(s, ".") {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:] + "."
|
||||
}
|
||||
|
||||
// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the
|
||||
// mesh's own.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"net/http/httptest"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
const agentToken = "0123456789abcdef0123456789abcdef01234567"
|
||||
@@ -260,3 +263,72 @@ func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The forge credential's file is named by a word of the bundle, so the runtime's account owns it and reads it (the
|
||||
// sweep of 2026-10-30: root's 0600 file, permission denied ~870 times a machine), and a failure to place it is said to
|
||||
// the operator as a condition, raised once per reason, cleared when it places again, and cleared at a first good look.
|
||||
func TestTheForgeCredentialIsTheRuntimesAndItsFailureIsAConditionOfTheModule(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Kind string `json:"kind"`
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
named := false
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind == "bundle" && a.Env["MESH_CLAUDE_CODE_FORGE"] == m.Secrets[ForgeProvision] && m.Secrets[ForgeProvision] != "" {
|
||||
named = true
|
||||
}
|
||||
}
|
||||
if !named {
|
||||
t.Errorf("no word of the bundle names the forge credential's file %q: the runtime cannot read it", m.Secrets[ForgeProvision])
|
||||
}
|
||||
p, _ := PathsFrom(envMap{"MESH_CLAUDE_CODE_STATE": "/s", "MESH_CLAUDE_CODE_FORGE": "/s/forge"}.get)
|
||||
if p.forgeSecret() != "/s/forge" {
|
||||
t.Errorf("the word is not read: %s", p.forgeSecret())
|
||||
}
|
||||
|
||||
f := &ForgeHealth{Node: "anchor"}
|
||||
denied := errors.New("the forge's read credential cannot be read: open /s/gitea-read.secret: permission denied")
|
||||
for i, row := range []struct {
|
||||
err error
|
||||
want string // "" nothing said, else the state said
|
||||
}{
|
||||
{nil, "cleared"}, // a first good look clears what a run before raised
|
||||
{nil, ""},
|
||||
{denied, "raised"},
|
||||
{denied, ""},
|
||||
{errors.New("the forge credential could not be written: disk full"), "raised"},
|
||||
{nil, "cleared"},
|
||||
{nil, ""},
|
||||
} {
|
||||
c := f.Look(row.err)
|
||||
got := ""
|
||||
if c != nil {
|
||||
got = c.State
|
||||
if _, err := asks.EncodeCondition(*c); err != nil {
|
||||
t.Errorf("look %d: the condition is refused: %v", i, err)
|
||||
}
|
||||
if c.Key != "claude-code.machine.anchor.forge-credential" {
|
||||
t.Errorf("look %d: key %s", i, c.Key)
|
||||
}
|
||||
}
|
||||
if got != row.want {
|
||||
t.Errorf("look %d: said %q, want %q", i, got, row.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type envMap map[string]string
|
||||
|
||||
func (e envMap) get(k string) string { return e[k] }
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
func say(format string, args ...any) {
|
||||
@@ -177,6 +178,19 @@ func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
|
||||
var memoryCarrier *MemoryCarrier
|
||||
|
||||
// sayCondition says a condition on the operator-channel seat under this module's name (novox/hq ADR 0437 §9).
|
||||
func sayCondition(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject("claude-code")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := asks.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
|
||||
}
|
||||
|
||||
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
|
||||
func notifyOperator(args map[string]any) error {
|
||||
_, err := ask("seat:operator-channel.notify", args)
|
||||
@@ -599,12 +613,21 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
// The forge's read credential (forge.go, ADR 0365 §4): placed as held, and put back when git or a person changed
|
||||
// it; the sessions' environment names it only while one is held.
|
||||
forgeHeld := ForgeCredentialHeld(p)
|
||||
forgeHealth := &ForgeHealth{Node: p.Node}
|
||||
placeForge := func() {
|
||||
if line, err := PlaceForgeCredential(p); err != nil {
|
||||
line, err := PlaceForgeCredential(p)
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
} else if line != "" {
|
||||
say("%s", line)
|
||||
}
|
||||
// Said to the operator as a condition, raised once per reason and cleared when it places again.
|
||||
if c := forgeHealth.Look(err); c != nil {
|
||||
if serr := sayCondition(*c); serr != nil {
|
||||
say("the forge credential's condition could not be said (%s): %v", c.State, serr)
|
||||
forgeHealth = &ForgeHealth{Node: p.Node} // said again at the next look
|
||||
}
|
||||
}
|
||||
if held := ForgeCredentialHeld(p); held != forgeHeld {
|
||||
forgeHeld = held
|
||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||
|
||||
@@ -54,14 +54,17 @@ func Fingerprint(s string) string {
|
||||
// AgentHome and OperatorHome are the runtime's words as given, read to judge whose home the home scope writes
|
||||
// (novox/hq issue 340).
|
||||
type Paths struct {
|
||||
State, Facts, Settings, Home, Node string
|
||||
State, Facts, Settings, Home, Node string
|
||||
// Forge is the file the forge's read credential arrives in (MESH_CLAUDE_CODE_FORGE): named by a word of the
|
||||
// bundle, so the runtime's account owns it and can read it (catalogue givenTo); empty is gitea-read.secret in State.
|
||||
Forge string
|
||||
Agent, Operator, AgentHome, OperatorHome string
|
||||
}
|
||||
|
||||
// PathsFrom reads them, or answers false outside a launch.
|
||||
func PathsFrom(env func(string) string) (Paths, bool) {
|
||||
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
|
||||
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_AGENT_HOME"), Node: env("MESH_NODE"),
|
||||
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Forge: env("MESH_CLAUDE_CODE_FORGE"), Home: env("MESH_AGENT_HOME"), Node: env("MESH_NODE"),
|
||||
Agent: env("MESH_AGENT_ACCOUNT"), Operator: env("MESH_OPERATOR_ACCOUNT"),
|
||||
AgentHome: env("MESH_AGENT_HOME"), OperatorHome: env("MESH_OPERATOR_HOME")}
|
||||
if (p.Home == "") != (p.Agent == "") {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
module claude-code
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -236,7 +236,8 @@
|
||||
"env": {
|
||||
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json",
|
||||
"MESH_CLAUDE_CODE_FORGE": "${dir:state}/gitea-read.secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
disk-load
|
||||
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Store is the state `readings` as the keeper uses it.
|
||||
type Store interface {
|
||||
Get(key string) (json.RawMessage, error)
|
||||
Put(key string, value any) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// busStore is the state through the runtime.
|
||||
type busStore struct{}
|
||||
|
||||
func (busStore) Get(key string) (json.RawMessage, error) {
|
||||
e, err := stdio.State(StateReadings).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}
|
||||
func (busStore) Put(key string, v any) error {
|
||||
_, err := stdio.State(StateReadings).Put(key, v)
|
||||
return err
|
||||
}
|
||||
func (busStore) Keys() ([]string, error) { return stdio.State(StateReadings).Keys() }
|
||||
|
||||
// Keeper measures the items of its node and keeps the readings.
|
||||
type Keeper struct {
|
||||
Node string
|
||||
// Items is every data item the modules placed on this node declare, composed from the bus (frombus.DataItems).
|
||||
Items func(ctx context.Context) (map[string][]catalogue.PlacedDataItem, error)
|
||||
Measurer Measurer
|
||||
Store Store
|
||||
Say func(string, ...any)
|
||||
|
||||
mu sync.Mutex // one round at a time
|
||||
}
|
||||
|
||||
// Round measures every item that is not a cache, one at a time, and writes the readings that changed. An item
|
||||
// measured by a walk is walked only when walks is true or its last walk is older than WalkEvery: the hourly round
|
||||
// reads counters, and a walk runs at most once a day (ADR 0233 §22).
|
||||
func (k *Keeper) Round(ctx context.Context, walks bool) (written, kept int, err error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
items, err := k.Items(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
modules := make([]string, 0, len(items))
|
||||
for m := range items {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
var failed []string
|
||||
for _, module := range modules {
|
||||
for _, it := range items[module] {
|
||||
if it.Class == ClassCache {
|
||||
continue
|
||||
}
|
||||
w, err := k.measureOne(ctx, it, walks)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Sprintf("%s.%s: %v", module, it.ID, err))
|
||||
continue
|
||||
}
|
||||
if w {
|
||||
written++
|
||||
} else {
|
||||
kept++
|
||||
}
|
||||
}
|
||||
}
|
||||
retired, err := k.retireGone(items)
|
||||
written += retired
|
||||
if err != nil {
|
||||
failed = append(failed, err.Error())
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return written, kept, errors.New("readings not kept: " + strings.Join(failed, "; "))
|
||||
}
|
||||
return written, kept, nil
|
||||
}
|
||||
|
||||
// retireGone marks retired every reading of this node whose item the node no longer declares — its module was unassigned
|
||||
// there, or is no longer pulled in — with when and why, and keeps it: the record of what the module left behind, until a
|
||||
// person deletes it (ADR 0233). It is cleared when the item is declared again (measureOne).
|
||||
func (k *Keeper) retireGone(declared map[string][]catalogue.PlacedDataItem) (int, error) {
|
||||
keys, err := k.Store.Keys()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
declaredNow := map[string]bool{}
|
||||
for module, its := range declared {
|
||||
for _, it := range its {
|
||||
declaredNow[health.ReadingKey(k.Node, module, it.ID)] = true
|
||||
}
|
||||
}
|
||||
now := k.Measurer.Now().UTC()
|
||||
n := 0
|
||||
for _, key := range keys {
|
||||
if !strings.HasPrefix(key, k.Node+".") || declaredNow[key] {
|
||||
continue
|
||||
}
|
||||
raw, err := k.Store.Get(key)
|
||||
if err != nil || len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
r, err := health.DecodeReading(raw)
|
||||
if err != nil || r.Node != k.Node || !r.RetiredAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
r.RetiredAt = now
|
||||
r.RetiredWhy = fmt.Sprintf("no longer declared on %s: its module was unassigned there, or is no longer pulled in", k.Node)
|
||||
if err := k.Store.Put(key, r); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, walks bool) (bool, error) {
|
||||
key := health.ReadingKey(k.Node, it.Module, it.ID)
|
||||
var prev *health.Reading
|
||||
raw, err := k.Store.Get(key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if p, err := health.DecodeReading(raw); err == nil {
|
||||
prev = &p
|
||||
}
|
||||
}
|
||||
now := k.Measurer.Now()
|
||||
if prev != nil && prev.RetiredAt.IsZero() && it.Measure == catalogue.MeasureWalk && !walks && prev.MeasuredAt != nil && now.Sub(*prev.MeasuredAt) < WalkEvery {
|
||||
return false, nil
|
||||
}
|
||||
m := k.Measurer.Measure(ctx, it)
|
||||
next, changed := health.NextReading(prev, k.Node, it, m, now)
|
||||
if prev != nil && !prev.RetiredAt.IsZero() {
|
||||
// Declared again: it is no longer retired.
|
||||
next.RetiredAt, next.RetiredWhy, changed = time.Time{}, "", true
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
return true, k.Store.Put(key, next)
|
||||
}
|
||||
|
||||
// MeasureNow measures one item now, walk or not, and writes its reading when it changed.
|
||||
func (k *Keeper) MeasureNow(ctx context.Context, module, item string) (health.Reading, bool, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
items, err := k.Items(ctx)
|
||||
if err != nil {
|
||||
return health.Reading{}, false, err
|
||||
}
|
||||
for _, it := range items[module] {
|
||||
if it.ID != item {
|
||||
continue
|
||||
}
|
||||
if it.Class == ClassCache {
|
||||
return health.Reading{}, false, fmt.Errorf("%s.%s is a cache, which is listed and never measured", module, item)
|
||||
}
|
||||
w, err := k.measureOne(ctx, it, true)
|
||||
if err != nil {
|
||||
return health.Reading{}, false, err
|
||||
}
|
||||
raw, err := k.Store.Get(health.ReadingKey(k.Node, module, item))
|
||||
if err != nil {
|
||||
return health.Reading{}, w, err
|
||||
}
|
||||
r, err := health.DecodeReading(raw)
|
||||
return r, w, err
|
||||
}
|
||||
return health.Reading{}, false, fmt.Errorf("%s declares no data item %q on this machine", module, item)
|
||||
}
|
||||
|
||||
// Loop runs a round after a few minutes and then every `every`, with the walks of the night's round when it is the
|
||||
// hour after the night; ctx ends it.
|
||||
func (k *Keeper) Loop(ctx context.Context, every time.Duration, first time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(first):
|
||||
}
|
||||
for {
|
||||
w, kept, err := k.Round(ctx, false)
|
||||
if err != nil {
|
||||
k.Say("measuring the data declared here: %v", err)
|
||||
} else {
|
||||
k.Say("measured the data declared here: %d readings changed, %d unchanged", w, kept)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(every):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RootRunner runs a command as root, through sudo without a prompt when this process is not root. A walk is bounded
|
||||
// by WalkFor itself; the runner's own bound is a little longer.
|
||||
func RootRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, WalkFor+time.Minute)
|
||||
defer cancel()
|
||||
program, argv := name, args
|
||||
if os.Getuid() != 0 {
|
||||
program, argv = "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
said := strings.TrimSpace(stderr.String())
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(stdout.String())
|
||||
}
|
||||
if program == "sudo" && strings.HasPrefix(said, "sudo:") {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
lines := strings.Split(said, "\n")
|
||||
if len(lines) > 3 {
|
||||
lines = lines[len(lines)-3:]
|
||||
}
|
||||
if said == "" {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return "", errors.New(strings.Join(lines, " / "))
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
@@ -1,20 +1,62 @@
|
||||
// The disk-load module's Go bundle (novox/hq ADR 0188, ADR 0193, issue 312): a process the node's
|
||||
// runtime launches and speaks MCP over stdio to. It reads the machine's storage load from /proc and
|
||||
// /sys — iowait, pressure stall, each disk's busy share, throughput, IOPS, wait and queue depth, the
|
||||
// filesystems' space and the processes doing the I/O — and changes nothing.
|
||||
// filesystems' space and the processes doing the I/O — and changes nothing of the machine.
|
||||
//
|
||||
// It also measures the data items the modules of its node declare (ADR 0233) and keeps each reading and the item's
|
||||
// seven-day peak as its own state `readings` (ADR 0571 clause 3), composed from the bus (no file).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
|
||||
)
|
||||
|
||||
func logf(format string, args ...any) { fmt.Fprintf(os.Stderr, "[disk-load] "+format+"\n", args...) }
|
||||
|
||||
func main() {
|
||||
// Readable: restic, nfs-server, mounts and the self-check read it (their manifests' `reads`); checks/readable holds it.
|
||||
if err := stdio.Keeps(announce.State{Name: StateReadings, Readable: true}); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
node := os.Getenv("MESH_NODE")
|
||||
k := &Keeper{Node: node, Store: busStore{}, Say: logf, Measurer: Measurer{Run: RootRunner, Now: time.Now},
|
||||
Items: func(ctx context.Context) (map[string][]catalogue.PlacedDataItem, error) {
|
||||
return placedItems(ctx, node)
|
||||
}}
|
||||
if node != "" {
|
||||
every := time.Hour
|
||||
if d, err := time.ParseDuration(os.Getenv("MESH_DISK_MEASURE_EVERY")); err == nil && d >= time.Minute {
|
||||
every = d
|
||||
}
|
||||
go k.Loop(context.Background(), every, 3*time.Minute)
|
||||
}
|
||||
tools := append(Tools(Here()), ReadingTools(k)...)
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): disk-load.
|
||||
if err := stdio.Serve("", Tools(Here())); err != nil {
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// placedItems is every data item the modules placed on node declare, composed from the bus as the node composes itself.
|
||||
func placedItems(ctx context.Context, node string) (map[string][]catalogue.PlacedDataItem, error) {
|
||||
items, unplaced, err := frombus.DataItems(ctx, fromruntime.Reader(), frombus.Mesh{}, frombus.Scope{Node: node})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(unplaced) > 0 {
|
||||
logf("data items left out as not placeable: %s", strings.Join(unplaced, "; "))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
// How an item is measured (novox/hq ADR 0233 §22 to §25), moved here from the backup holder, which only reads the
|
||||
// readings now (ADR 0571 clause 3). Never an unbounded walk of something large.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The bounds on a walk: a walk is for small items, and one that meets a large one stops and says so.
|
||||
var (
|
||||
WalkFor = 10 * time.Minute
|
||||
WalkFiles = 2_000_000
|
||||
WalkEvery = 23 * time.Hour
|
||||
)
|
||||
|
||||
// walkCommand sums the files under a path and finds its newest change, in one walk, bounded by time and by files.
|
||||
// One line out: "<bytes> <epoch seconds> <files>", then the status of find.
|
||||
func walkCommand(path string) string {
|
||||
return fmt.Sprintf("timeout %d find '%s' -xdev -printf '%%y %%s %%T@\\n' 2>/dev/null | head -n %d | "+
|
||||
"awk 'BEGIN{s=0;m=0;n=0} {n++; if ($1==\"f\") s+=$2; if ($3>m) m=$3} END {printf \"%%d %%.0f %%d\\n\", s, m, n}'; "+
|
||||
"echo \"${PIPESTATUS[0]:-0}\"", int(WalkFor.Seconds()), path, WalkFiles)
|
||||
}
|
||||
|
||||
// topCommand is the newest change among a path and its top-level entries, and how many there are.
|
||||
func topCommand(path string) string {
|
||||
return "timeout 60 find '" + path + "' -maxdepth 1 -printf '%T@\\n' 2>/dev/null | " +
|
||||
"awk 'BEGIN{m=0;n=0} {n++; if ($1>m) m=$1} END {printf \"%.0f %d\\n\", m, n-1}'"
|
||||
}
|
||||
|
||||
func epochTime(s string) *time.Time {
|
||||
epoch, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || epoch <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(epoch, 0).UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// Measurer measures items through a runner that has the root the walks need.
|
||||
type Measurer struct {
|
||||
Run Runner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (b Measurer) exists(ctx context.Context, path string) bool {
|
||||
_, err := b.Run(ctx, "test", "-e", path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Measure is one item, measured now, by its own method.
|
||||
func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) health.Measured {
|
||||
at := b.Now().UTC()
|
||||
m := health.Measured{MeasuredAt: &at}
|
||||
if !b.exists(ctx, it.Path) {
|
||||
m.Error = it.Path + " does not exist"
|
||||
zero := int64(0)
|
||||
m.SizeBytes, m.Precision = &zero, "exact"
|
||||
return m
|
||||
}
|
||||
m.Redundancy = b.redundancyOf(ctx, it.Path)
|
||||
switch it.Measure {
|
||||
case catalogue.MeasureDataset, catalogue.MeasureShallow:
|
||||
out, err := b.Run(ctx, "bash", "-c", topCommand(it.Path))
|
||||
if f := strings.Fields(out); err == nil && len(f) == 2 {
|
||||
m.LastWrite = epochTime(f[0])
|
||||
} else if err != nil {
|
||||
m.Error = err.Error()
|
||||
}
|
||||
m.Precision = "no size: the newest change among its top-level entries only"
|
||||
if it.Measure == catalogue.MeasureShallow {
|
||||
return m
|
||||
}
|
||||
out, err = b.Run(ctx, "zfs", "list", "-Hp", "-o", "name,used", it.Path)
|
||||
f := strings.Fields(out)
|
||||
if err != nil || len(f) != 2 {
|
||||
m.Precision = "no size: it is on no ZFS dataset to read one from; the newest change among its top-level entries only"
|
||||
return m
|
||||
}
|
||||
used, err := strconv.ParseInt(f[1], 10, 64)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
m.SizeBytes = &used
|
||||
m.Precision = "dataset " + f[0] + ": its whole size, from the filesystem's counters; the newest change among the top-level entries"
|
||||
return m
|
||||
}
|
||||
out, err := b.Run(ctx, "bash", "-c", walkCommand(it.Path))
|
||||
if err != nil {
|
||||
m.Error = err.Error()
|
||||
return m
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
f := strings.Fields(lines[0])
|
||||
if len(f) != 3 || len(lines) < 2 {
|
||||
m.Error = "the measurement said " + strings.TrimSpace(out)
|
||||
return m
|
||||
}
|
||||
size, err1 := strconv.ParseInt(f[0], 10, 64)
|
||||
files, err2 := strconv.Atoi(f[2])
|
||||
if err1 != nil || err2 != nil {
|
||||
m.Error = "the measurement said " + strings.TrimSpace(out)
|
||||
return m
|
||||
}
|
||||
m.SizeBytes, m.LastWrite = &size, epochTime(f[1])
|
||||
m.Precision = "exact"
|
||||
if status := strings.TrimSpace(lines[len(lines)-1]); status == "124" || files >= WalkFiles {
|
||||
m.Precision = fmt.Sprintf("partial: measured partially — stopped after %s or %d files; the size is a lower bound, "+
|
||||
"and this item wants measure: dataset or shallow", WalkFor, WalkFiles)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// redundancyOf is the redundant storage a path is on: a ZFS pool's health, an md array's members, a btrfs
|
||||
// filesystem's error counters. Nil for storage with no redundancy it knows how to read.
|
||||
func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redundancy {
|
||||
out, err := b.Run(ctx, "findmnt", "-no", "SOURCE,FSTYPE", "--target", path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
f := strings.Fields(out)
|
||||
if len(f) < 2 {
|
||||
return nil
|
||||
}
|
||||
source, fstype := f[0], f[1]
|
||||
yes, no := true, false
|
||||
switch {
|
||||
case fstype == "zfs":
|
||||
pool, _, _ := strings.Cut(source, "/")
|
||||
r := &health.Redundancy{Kind: "zfs", Where: pool}
|
||||
health, err := b.Run(ctx, "zpool", "list", "-H", "-o", "health", pool)
|
||||
if err != nil {
|
||||
r.Said = "zpool list: " + err.Error()
|
||||
return r
|
||||
}
|
||||
status, err := b.Run(ctx, "zpool", "status", "-x", pool)
|
||||
if err != nil {
|
||||
r.Said = "zpool status: " + err.Error()
|
||||
return r
|
||||
}
|
||||
health, status = strings.TrimSpace(health), strings.TrimSpace(status)
|
||||
r.Said = "health " + health + "; " + firstLineOf(status)
|
||||
if health == "ONLINE" && strings.Contains(status, "is healthy") {
|
||||
r.Healthy = &yes
|
||||
} else {
|
||||
r.Healthy = &no
|
||||
r.Said = "health " + health + "; " + oneLineOf(status)
|
||||
}
|
||||
return r
|
||||
case strings.HasPrefix(source, "/dev/md"):
|
||||
device := strings.TrimPrefix(source, "/dev/")
|
||||
r := &health.Redundancy{Kind: "md", Where: device}
|
||||
mdstat, err := b.Run(ctx, "cat", "/proc/mdstat")
|
||||
if err != nil {
|
||||
r.Said = err.Error()
|
||||
return r
|
||||
}
|
||||
healthy, said, found := mdHealth(mdstat, device)
|
||||
if !found {
|
||||
r.Said = device + " is not in /proc/mdstat"
|
||||
return r
|
||||
}
|
||||
r.Said = said
|
||||
if healthy {
|
||||
r.Healthy = &yes
|
||||
} else {
|
||||
r.Healthy = &no
|
||||
}
|
||||
return r
|
||||
case fstype == "btrfs":
|
||||
r := &health.Redundancy{Kind: "btrfs", Where: source}
|
||||
stats, err := b.Run(ctx, "btrfs", "device", "stats", "--check", path)
|
||||
if err != nil {
|
||||
r.Healthy, r.Said = &no, "device errors: "+oneLineOf(err.Error())
|
||||
return r
|
||||
}
|
||||
r.Healthy, r.Said = &yes, firstLineOf(stats)
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var mdMembers = regexp.MustCompile(`\[([U_]+)\]`)
|
||||
|
||||
// mdHealth reads one array's block of /proc/mdstat: healthy when every member is up and it is not recovering.
|
||||
func mdHealth(mdstat, device string) (bool, string, bool) {
|
||||
lines := strings.Split(mdstat, "\n")
|
||||
for i, l := range lines {
|
||||
if !strings.HasPrefix(l, device+" ") {
|
||||
continue
|
||||
}
|
||||
block := l
|
||||
for j := i + 1; j < len(lines) && strings.HasPrefix(lines[j], " "); j++ {
|
||||
block += " " + strings.TrimSpace(lines[j])
|
||||
}
|
||||
members := mdMembers.FindStringSubmatch(block)
|
||||
healthy := members != nil && !strings.Contains(members[1], "_") && !strings.Contains(block, "recovery")
|
||||
return healthy, oneLineOf(block), true
|
||||
}
|
||||
return false, "", false
|
||||
}
|
||||
|
||||
func firstLineOf(s string) string {
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
|
||||
return line
|
||||
}
|
||||
|
||||
func oneLineOf(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
// The readings layout, their key and the peak are mesh-sdk's (health.Reading, health.NextReading; novox/hq ADR 0571
|
||||
// clause 3): disk-load measures each placed data item of its node and keeps the readings as its own state.
|
||||
|
||||
import "git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
// StateReadings is the state disk-load keeps.
|
||||
const StateReadings = health.ReadingsState
|
||||
|
||||
// ClassCache is the class of an item that is listed and never measured.
|
||||
const ClassCache = "cache"
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
type memStore struct{ m map[string]json.RawMessage }
|
||||
|
||||
func (s *memStore) Get(k string) (json.RawMessage, error) { return s.m[k], nil }
|
||||
func (s *memStore) Put(k string, v any) error {
|
||||
raw, _ := json.Marshal(v)
|
||||
s.m[k] = raw
|
||||
return nil
|
||||
}
|
||||
func (s *memStore) Keys() ([]string, error) {
|
||||
var out []string
|
||||
for k := range s.m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// A round measures what the data file declares but caches, keeps the readings, writes nothing the second time, and a
|
||||
// walk is not repeated within a day unless asked.
|
||||
func TestARoundMeasuresTheDeclaredItemsAndWritesWhatChanged(t *testing.T) {
|
||||
text := "# pg\nitem data valuable /srv/pg /srv/pg backup walk\nitem tmp cache /srv/tmp - none\n# bad-line-module\nitem logs rebuildable /srv/logs - none shallow\n"
|
||||
placed, err := catalogue.ParseDataItems(text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
walks := 0
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
switch {
|
||||
case name == "test":
|
||||
return "", nil
|
||||
case name == "findmnt":
|
||||
return "", os.ErrNotExist
|
||||
case name == "bash" && strings.Contains(args[1], "-maxdepth 1"):
|
||||
return "1760000000 2\n", nil
|
||||
case name == "bash":
|
||||
walks++
|
||||
return "4096 1760000000 3\n0\n", nil
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
|
||||
store := &memStore{m: map[string]json.RawMessage{}}
|
||||
k := &Keeper{Node: "ace", Items: func(context.Context) (map[string][]catalogue.PlacedDataItem, error) { return placed, nil }, Store: store, Say: func(string, ...any) {},
|
||||
Measurer: Measurer{Run: run, Now: func() time.Time { return now }}}
|
||||
w, kept, err := k.Round(context.Background(), false)
|
||||
if err != nil || w != 2 || kept != 0 || len(store.m) != 2 {
|
||||
t.Fatalf("first round: %d written %d kept %v, keys %d", w, kept, err, len(store.m))
|
||||
}
|
||||
now = now.Add(time.Hour)
|
||||
if w, kept, _ := k.Round(context.Background(), false); w != 0 || kept != 2 || walks != 1 {
|
||||
t.Fatalf("second round: %d written, %d kept, %d walks; wanted none written and the walk not repeated", w, kept, walks)
|
||||
}
|
||||
if _, _, err := k.MeasureNow(context.Background(), "pg", "data"); err != nil || walks != 2 {
|
||||
t.Fatalf("measure now: %v, %d walks", err, walks)
|
||||
}
|
||||
// An item no longer declared is kept, retired with when and why; declared again, it is not retired any more.
|
||||
delete(placed, "pg")
|
||||
if _, _, err := k.Round(context.Background(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gone, _ := health.DecodeReading(store.m["ace.pg.data"])
|
||||
if gone.RetiredAt.IsZero() || !strings.Contains(gone.RetiredWhy, "no longer declared on ace") || gone.FirstSeen.IsZero() {
|
||||
t.Fatalf("a reading of an item no longer declared: %+v", gone)
|
||||
}
|
||||
placed["pg"] = []catalogue.PlacedDataItem{{Module: "pg", ID: "data", Class: "valuable", Path: "/srv/pg", Measure: catalogue.MeasureWalk}}
|
||||
if _, _, err := k.Round(context.Background(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back, _ := health.DecodeReading(store.m["ace.pg.data"]); !back.RetiredAt.IsZero() {
|
||||
t.Fatalf("a reading declared again is still retired: %+v", back)
|
||||
}
|
||||
if _, _, err := k.MeasureNow(context.Background(), "pg", "tmp"); err == nil {
|
||||
t.Fatal("a cache was measured")
|
||||
}
|
||||
}
|
||||
|
||||
// The redundant storage under an item is read: a ZFS pool healthy, then degraded; an md array with a member missing; and
|
||||
// plain storage, which is no redundancy at all. A dataset is measured from the filesystem's counters and a shallow item
|
||||
// from its top-level entries only; a walk that meets more than its bound stops and says so.
|
||||
func TestTheStorageUnderAnItemAndTheLargeMethodsAreRead(t *testing.T) {
|
||||
poolHealth, statusX, fs := "ONLINE\n", "pool 'storage' is healthy\n", "storage/media zfs\n"
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "test":
|
||||
return "", nil
|
||||
case name == "findmnt":
|
||||
return fs, nil
|
||||
case name == "zpool" && args[0] == "list":
|
||||
return poolHealth, nil
|
||||
case name == "zpool":
|
||||
return statusX, nil
|
||||
case name == "cat":
|
||||
return "Personalities : [raid1]\nmd127 : active raid1 sdb1[1] sda1[0]\n 976630464 blocks super 1.2 [2/1] [U_]\n\nunused devices: <none>\n", nil
|
||||
case name == "zfs" && args[len(args)-1] == "/storage/media/movies":
|
||||
return "storage/media\t97067690722560\n", nil
|
||||
case name == "zfs":
|
||||
return "", os.ErrNotExist
|
||||
case name == "bash" && strings.Contains(line, "-maxdepth 1"):
|
||||
return "1759744800 12673\n", nil
|
||||
case name == "bash":
|
||||
return "123 1759700000 2000000\n141\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
m := Measurer{Run: run, Now: time.Now}
|
||||
ctx := context.Background()
|
||||
got := m.Measure(ctx, catalogue.PlacedDataItem{Module: "media", ID: "movies", Path: "/storage/media/movies", Measure: catalogue.MeasureDataset})
|
||||
if got.SizeBytes == nil || *got.SizeBytes != 97067690722560 || !strings.HasPrefix(got.Precision, "dataset storage/media") ||
|
||||
got.LastWrite == nil || got.Redundancy == nil || got.Redundancy.Kind != "zfs" || got.Redundancy.Healthy == nil || !*got.Redundancy.Healthy {
|
||||
t.Fatalf("the library from its dataset: %+v", got)
|
||||
}
|
||||
poolHealth, statusX = "DEGRADED\n", " pool: storage\n state: DEGRADED\nstatus: One or more devices has been removed\n"
|
||||
if r := m.redundancyOf(ctx, "/storage/media/movies"); r.Healthy == nil || *r.Healthy || !strings.Contains(r.Said, "DEGRADED") {
|
||||
t.Fatalf("a degraded pool: %+v", r)
|
||||
}
|
||||
fs = "/dev/md127 ext4\n"
|
||||
if r := m.redundancyOf(ctx, "/srv/x"); r == nil || r.Kind != "md" || r.Healthy == nil || *r.Healthy {
|
||||
t.Fatalf("an array missing a member: %+v", r)
|
||||
}
|
||||
fs = "/dev/nvme0n1p3 ext4\n"
|
||||
if r := m.redundancyOf(ctx, "/var/lib/x"); r != nil {
|
||||
t.Fatalf("plain storage read as redundant: %+v", r)
|
||||
}
|
||||
if s := m.Measure(ctx, catalogue.PlacedDataItem{Path: "/mnt/plex/config", Measure: catalogue.MeasureShallow}); s.SizeBytes != nil || !strings.HasPrefix(s.Precision, "no size") {
|
||||
t.Fatalf("a shallow item: %+v", s)
|
||||
}
|
||||
if w := m.Measure(ctx, catalogue.PlacedDataItem{Path: "/srv/big", Measure: catalogue.MeasureWalk}); !strings.HasPrefix(w.Precision, "partial") {
|
||||
t.Fatalf("a walk stopped at its bound: %+v", w)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// ReadingTools are the tools over the state `readings`: every key, one key, how full the state is, and one item
|
||||
// measured now (a module's tools are its command line).
|
||||
func ReadingTools(k *Keeper) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "disk_readings",
|
||||
Description: "The data readings disk-load keeps for this machine's data items (ADR 0233, ADR 0571 clause 3): every key " +
|
||||
"`<node>.<module>.<item>` with its size, newest write, measured-at, precision and seven-day peak, or one key with " +
|
||||
"all it holds when `key` is given; and how full the state is: keys kept, their bytes, and the cap the bus puts on " +
|
||||
"a state (none declared). Filter by `module`. (r)",
|
||||
Input: map[string]any{
|
||||
"key": map[string]any{"type": "string", "description": "one key, <node>.<module>.<item>, with everything it holds"},
|
||||
"module": map[string]any{"type": "string", "description": "only this module's items"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
if key := text(args, "key"); key != "" {
|
||||
raw, err := k.Store.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("no reading is kept at %q", key)
|
||||
}
|
||||
r, err := health.DecodeReading(raw)
|
||||
return r, err
|
||||
}
|
||||
keys, err := k.Store.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
module := text(args, "module")
|
||||
type line struct {
|
||||
Key string `json:"key"`
|
||||
SizeBytes *int64 `json:"size_bytes,omitempty"`
|
||||
PeakBytes *int64 `json:"peak_bytes,omitempty"`
|
||||
Precision string `json:"precision,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Reading health.Reading `json:"-"`
|
||||
}
|
||||
var lines []line
|
||||
total := 0
|
||||
for _, key := range keys {
|
||||
raw, err := k.Store.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total += len(raw)
|
||||
r, err := health.DecodeReading(raw)
|
||||
if err != nil || (module != "" && r.Module != module) {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line{Key: key, SizeBytes: r.SizeBytes, PeakBytes: r.Peak, Precision: r.Precision, Error: r.Error})
|
||||
}
|
||||
return map[string]any{"readings": lines, "fill": map[string]any{"keys": len(keys), "bytes": total,
|
||||
"cap": "none declared: the bus's own limit on a state applies"}}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disk_measure",
|
||||
Description: "Measure one data item of this machine now, whatever its method (a walk too), and write its reading when it " +
|
||||
"changed. Reads the disks as root: a walk is bounded by time and files and says when it was partial.",
|
||||
Input: map[string]any{
|
||||
"module": map[string]any{"type": "string", "description": "the module that declares the item"},
|
||||
"item": map[string]any{"type": "string", "description": "the item's id"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
module, item := text(args, "module"), text(args, "item")
|
||||
if module == "" || item == "" {
|
||||
return nil, fmt.Errorf("module and item are required")
|
||||
}
|
||||
r, written, err := k.MeasureNow(context.Background(), module, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"written": written, "reading": r, "key": health.ReadingKey(k.Node, module, item),
|
||||
"note": strings.TrimSpace(map[bool]string{true: "the reading changed and was written", false: "the reading had not changed"}[written])}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -35,9 +36,11 @@ func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T
|
||||
t.Fatal(err)
|
||||
}
|
||||
var served []string
|
||||
for _, tl := range Tools(newFake(t).machine()) {
|
||||
k := &Keeper{Store: &memStore{m: map[string]json.RawMessage{}}}
|
||||
for _, tl := range append(Tools(newFake(t).machine()), ReadingTools(k)...) {
|
||||
served = append(served, tl.Name)
|
||||
if !strings.HasSuffix(tl.Description, "(r)") {
|
||||
// disk_measure writes the module's own state of readings and nothing else of the machine.
|
||||
if !strings.HasSuffix(tl.Description, "(r)") && tl.Name != "disk_measure" {
|
||||
t.Errorf("%s only reads and does not say so", tl.Name)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +53,7 @@ func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T
|
||||
t.Errorf("the module only reads, and declares %d resources", len(m.Resources))
|
||||
}
|
||||
for _, banned := range []string{"g14", "novox", "ace", "shanks", "jochen", "/home/"} {
|
||||
if strings.Contains(string(raw), banned) {
|
||||
if regexp.MustCompile(`(^|[^a-z0-9-])` + regexp.QuoteMeta(banned) + `($|[^a-z0-9-])`).Match(raw) {
|
||||
t.Errorf("the manifest says %q", banned)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module diskload
|
||||
|
||||
go 1.22
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"disk_filesystems",
|
||||
"disk_folders",
|
||||
"disk_devices",
|
||||
"cpu_top"
|
||||
"cpu_top",
|
||||
"disk_readings",
|
||||
"disk_measure"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
@@ -23,5 +25,16 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"state": [
|
||||
"readings"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-config.resolved",
|
||||
"mesh-delivery-assigner.placement",
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"node-engine.have"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "artifact-store",
|
||||
"scope": "mesh"
|
||||
"scope": "mesh",
|
||||
"credential": {
|
||||
"form": "none"
|
||||
}
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module dnsmasq
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
{
|
||||
"name": "wildcard-resolution",
|
||||
"scope": "mesh",
|
||||
"identity": false
|
||||
"identity": false,
|
||||
"credential": {
|
||||
"form": "none"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
|
||||
@@ -9,7 +9,7 @@ package main
|
||||
//
|
||||
// - every image a container on this machine uses, in any state;
|
||||
// - every image this machine's declaration names — what the mesh would send it now and what it was
|
||||
// last sent — as the controller's `images` verb answers. **No answer, no removal**: what the
|
||||
// last sent — as the mesh-artifact-sweep seat's `images` verb answers. **No answer, no removal**: what the
|
||||
// declaration names is the one thing that cannot be guessed;
|
||||
// - every image younger than the floor (seven days, the weekly prune's week);
|
||||
// - in each line of images holding one of those, the newest image besides them: the previous
|
||||
@@ -43,7 +43,7 @@ type PruneImagesAsk struct {
|
||||
Limit int
|
||||
}
|
||||
|
||||
// declaredImages is the controller's `images` answer (novox/hq ADR 0251 §5).
|
||||
// declaredImages is the mesh-artifact-sweep seat's `images` answer (novox/hq ADR 0251 §5).
|
||||
type declaredImages struct {
|
||||
Node string `json:"node"`
|
||||
Images []struct {
|
||||
@@ -129,7 +129,7 @@ func digestOf(ref string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// askDeclared asks the controller what this machine's declarations name. An error means unknown.
|
||||
// askDeclared asks the mesh-artifact-sweep seat what this machine's declarations name. An error means unknown.
|
||||
func (c *Client) askDeclared() (*declaredImages, error) {
|
||||
if c.Node == "" {
|
||||
return nil, errors.New("the runtime did not say which machine this is (MESH_NODE is empty)")
|
||||
@@ -137,20 +137,20 @@ func (c *Client) askDeclared() (*declaredImages, error) {
|
||||
if c.Ask == nil {
|
||||
return nil, errors.New("this bundle cannot ask the mesh")
|
||||
}
|
||||
raw, err := c.Ask("seat:mesh-controller.images", map[string]any{"node": c.Node})
|
||||
raw, err := c.Ask("seat:mesh-artifact-sweep.images", map[string]any{"node": c.Node})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the controller's images verb did not answer: %w", err)
|
||||
return nil, fmt.Errorf("the mesh-artifact-sweep seat's images verb did not answer: %w", err)
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the controller refused images: %s", firstLine(output))
|
||||
return nil, fmt.Errorf("the mesh-artifact-sweep seat refused images: %s", firstLine(output))
|
||||
}
|
||||
var d declaredImages
|
||||
if err := json.Unmarshal(answer, &d); err != nil {
|
||||
return nil, fmt.Errorf("the controller's images answer is not readable: %v", err)
|
||||
return nil, fmt.Errorf("the mesh-artifact-sweep seat's images answer is not readable: %v", err)
|
||||
}
|
||||
if d.Node != "" && d.Node != c.Node {
|
||||
return nil, fmt.Errorf("the controller answered for %q, not for this machine %q", d.Node, c.Node)
|
||||
return nil, fmt.Errorf("the mesh-artifact-sweep seat answered for %q, not for this machine %q", d.Node, c.Node)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func imagesMachine() *fake {
|
||||
|
||||
func declaring(t *testing.T, answer string) Asker {
|
||||
return func(key string, body any) (json.RawMessage, error) {
|
||||
if key != "seat:mesh-controller.images" {
|
||||
if key != "seat:mesh-artifact-sweep.images" {
|
||||
t.Errorf("asked %s", key)
|
||||
}
|
||||
if b, _ := body.(map[string]any); b["node"] != "laptop" {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module docker
|
||||
|
||||
go 1.22
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"privileged"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.images"
|
||||
"seat:mesh-artifact-sweep.images"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
|
||||
@@ -83,6 +83,12 @@ export function checkStatusOf(change: OwedChange): { owner: string; repo: string
|
||||
return { owner, repo, sha, revision, status };
|
||||
}
|
||||
|
||||
/** Whether the forge's refusal will not change by asking again: it forbids the write (403) or knows no such repository
|
||||
* or commit (404), as the client words it ("Gitea API <path>: <status> ..."). */
|
||||
export function refusedForGood(err: unknown): boolean {
|
||||
return /Gitea API \S+: (403|404) /.test(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
/** How many mesh/check writes this module has at the forge at once. */
|
||||
export const WRITES_AT_ONCE = 4;
|
||||
|
||||
@@ -115,6 +121,12 @@ export class CheckStatuses {
|
||||
try {
|
||||
asked = checkStatusOf(change);
|
||||
} catch (err) {
|
||||
// A deleted key is owed no more: its revision is seen, so a write of it still asked again ends (askAgain).
|
||||
if (change?.op === "delete" && typeof change.key === "string" && Number.isInteger(change.revision)) {
|
||||
const seen = this.seen.get(change.key);
|
||||
if (seen === undefined || change.revision > seen) this.remember(this.seen, change.key, change.revision);
|
||||
return "dropped";
|
||||
}
|
||||
this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`);
|
||||
return "dropped";
|
||||
}
|
||||
@@ -172,6 +184,12 @@ export class CheckStatuses {
|
||||
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
|
||||
let err = first;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
if (refusedForGood(err)) {
|
||||
// A repository the forge forbids or does not know answers the same at every ask (the sweep of 2026-10-30, issue
|
||||
// 774: hal and novadock, 119 asks each): said once, never asked again; a newer revision of the key asks anew.
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} is refused for good (${err instanceof Error ? err.message : err}); not asked again`);
|
||||
return;
|
||||
}
|
||||
const wait = bindWait(attempt);
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`);
|
||||
await this.sleep(wait);
|
||||
|
||||
@@ -192,17 +192,14 @@ func (r *teamRig) pass() []provider.Outcome {
|
||||
return out
|
||||
}
|
||||
|
||||
// bind makes module on node bind gitea's forge-account in the assignments mirror.
|
||||
// bind makes module on node bind gitea's forge-account in the seat mesh-provisioner's plans.
|
||||
func (r *teamRig) bind(node, module string) {
|
||||
r.bus.set(provider.AssignmentsBucket, node+"."+module, []byte(fmt.Sprintf(
|
||||
`{"node":%q,"module":%q,"provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s"}]}}`,
|
||||
node, module, strings.ReplaceAll(node+"_"+module, "-", "_"))))
|
||||
r.bus.bindPlan(node, module, "mesh_"+strings.ReplaceAll(node+"_"+module, "-", "_"))
|
||||
}
|
||||
|
||||
// unbind is the operator's yes to retiring module's forge-account on node (ADR 0570 clause 5).
|
||||
func (r *teamRig) unbind(node, module string) {
|
||||
r.bus.mu.Lock()
|
||||
delete(r.bus.buckets[provider.AssignmentsBucket], node+"."+module)
|
||||
r.bus.mu.Unlock()
|
||||
r.bus.retire(node, module)
|
||||
}
|
||||
|
||||
// key reports a key in module's state keys, of the kind and for the account given.
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
const bundleVar = "MESH_FORGE_ACCOUNT_TEST_BUNDLE"
|
||||
@@ -80,7 +81,7 @@ func startBus(t *testing.T) (*runtime, context.CancelFunc) {
|
||||
rt := &runtime{t: t, js: js, kvs: map[string]jetstream.KeyValue{}, ctx: ctx, wait: map[string]chan json.RawMessage{}}
|
||||
// The controller's job: a bucket per state the module declares (bound) or reads (the others).
|
||||
for state, bucket := range map[string]string{"bound": "gitea_bound", "ssh-client.keys": "ssh-client_keys",
|
||||
"mesh-controller.assignments": "mesh-controller_assignments"} {
|
||||
"mesh-provisioner.plans": "mesh-provisioner_plans", "mesh-provisioner.provisions": "mesh-provisioner_provisions"} {
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -296,9 +297,11 @@ func TestTheProviderWholeAnswersAMachineThatReportedItsKey(t *testing.T) {
|
||||
}
|
||||
rt, cancel := startBus(t)
|
||||
t.Cleanup(cancel)
|
||||
rt.put("mesh-controller.assignments", "g14.ssh-client", map[string]any{"node": "g14", "module": "ssh-client",
|
||||
"provisioning": map[string]any{"provisions": []map[string]any{{"provision": "forge-account", "provider": "novox",
|
||||
rt.put("mesh-provisioner.plans", "g14.ssh-client", map[string]any{"v": 1, "node": "g14", "module": "ssh-client",
|
||||
"plan": map[string]any{"provisions": []map[string]any{{"provision": "forge-account", "provider": "novox",
|
||||
"provider-module": "gitea", "as": "mesh_g14_ssh_client"}}}})
|
||||
rt.put("mesh-provisioner.provisions", "g14.ssh-client.forge-account",
|
||||
json.RawMessage(provisionKey("g14", "ssh-client", provisions.Asked, nil)))
|
||||
rt.put("ssh-client.keys", "g14.nox", map[string]string{"node": "g14", "account": "nox", "kind": "machine",
|
||||
"public": pubA + " nox@g14 (mesh)", "fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"})
|
||||
rt.start([]string{"MESH_NODE=novox", "MESH_MODULE=gitea", "MESH_SERVED_MODULE=gitea",
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestConfigFromEnvNamesWhatIsMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
// Issue 955: the runtime grants a module only the states its manifest `reads`, and the provider watches the machines'
|
||||
// keys and, through the SDK's provider, the controller's assignments (where it learns its consumers). Run as the
|
||||
// keys and, through the SDK's provider, the seat mesh-provisioner's plans and provisions (where it learns its consumers, ADR 0570). Run as the
|
||||
// bus test does, with every bucket made, a missing grant passes unseen; the manifest is read here instead.
|
||||
func TestTheManifestReadsEveryStateTheProviderWatches(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
@@ -38,7 +38,7 @@ func TestTheManifestReadsEveryStateTheProviderWatches(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys"} {
|
||||
for _, state := range []string{"mesh-provisioner.plans", "mesh-provisioner.provisions", "ssh-client.keys"} {
|
||||
if !slices.Contains(m.Reads, state) {
|
||||
t.Errorf("gitea's reads %v lack %q, so the runtime refuses its watch", m.Reads, state)
|
||||
}
|
||||
|
||||
@@ -184,7 +184,9 @@ func (s *Service) Make(ctx context.Context, c provider.Consumer) error {
|
||||
s.mu.Unlock()
|
||||
if !loaded {
|
||||
// After a restart the first pass can come before the keys arrive: nothing is removed or deleted on that.
|
||||
return fmt.Errorf("the keys of %s are not read yet, so nothing is made, removed or deleted", source)
|
||||
// Not ready, not failed: the entry waits and keeps serving what it served (provider.ErrNotReady).
|
||||
return fmt.Errorf("the keys of %s are not read yet, so nothing is made, removed or deleted: %w", source,
|
||||
provider.ErrNotReady)
|
||||
}
|
||||
have := s.keysFrom(source, node, kind)
|
||||
if len(have) == 0 {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
@@ -78,6 +79,22 @@ func (b *fakeBus) entry(t *testing.T, key string) provider.Entry {
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// serves is whether gitea's entry under key serves its consumer: there, in state (provided when none is given) and with
|
||||
// values (ADR 0570 clause 3: an entry the provider could not make is kept, failed, serving nothing; one it is not
|
||||
// ready to make waits and keeps serving).
|
||||
func (b *fakeBus) serves(key string, state ...string) bool {
|
||||
want := provisions.Provided
|
||||
if len(state) > 0 {
|
||||
want = state[0]
|
||||
}
|
||||
b.mu.Lock()
|
||||
raw, ok := b.buckets[provider.BucketOf("gitea")][key]
|
||||
b.mu.Unlock()
|
||||
var e provider.Entry
|
||||
return ok && json.Unmarshal(raw, &e) == nil && e.State == want && len(e.Values) > 0
|
||||
}
|
||||
|
||||
func (b *fakeBus) has(key string) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
@@ -85,11 +102,41 @@ func (b *fakeBus) has(key string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// bindClient makes ssh-client on node bind gitea's forge-account in the assignments mirror.
|
||||
// bindClient makes ssh-client on node bind gitea's forge-account in the seat mesh-provisioner's plans.
|
||||
func (b *fakeBus) bindClient(node string) {
|
||||
b.set(provider.AssignmentsBucket, node+".ssh-client", []byte(fmt.Sprintf(
|
||||
`{"node":%q,"module":"ssh-client","provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s_ssh_client"}]}}`,
|
||||
node, node)))
|
||||
b.bindPlan(node, "ssh-client", "mesh_"+node+"_ssh_client")
|
||||
}
|
||||
|
||||
// bindPlan makes module on node bind gitea's forge-account as the seat mesh-provisioner keeps it: its plan, and the
|
||||
// provision key asked of gitea on novox (novox/hq ADR 0570 clauses 1 to 3).
|
||||
func (b *fakeBus) bindPlan(node, module, as string) {
|
||||
b.set(provisions.PlansBucket, node+"."+module, []byte(fmt.Sprintf(
|
||||
`{"v":1,"node":%q,"module":%q,"plan":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":%q}]}}`,
|
||||
node, module, as)))
|
||||
b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Asked, nil))
|
||||
}
|
||||
|
||||
// retire is the operator's yes to the retiring of module's forge-account on node: its plan gone, its key retiring
|
||||
// with the warrant (ADR 0570 clause 5), the only way the provider lets an entry go.
|
||||
func (b *fakeBus) retire(node, module string) {
|
||||
b.mu.Lock()
|
||||
delete(b.buckets[provisions.PlansBucket], node+"."+module)
|
||||
b.mu.Unlock()
|
||||
b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Retiring,
|
||||
&provisions.Warrant{Ask: "ask-1", Option: "yes", By: "operator", At: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)}))
|
||||
}
|
||||
|
||||
func provisionKey(node, module, state string, w *provisions.Warrant) []byte {
|
||||
pr := provisions.Provision{Provision: "forge-account", State: state, Warrant: w,
|
||||
Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "gitea"}}
|
||||
if state == provisions.Retiring {
|
||||
pr.Why = "no want needs it"
|
||||
}
|
||||
raw, err := provisions.Encode(pr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// fakeForge is gitea's API as far as the provider uses it.
|
||||
@@ -343,9 +390,7 @@ func TestAConsumerThatNoLongerBindsLosesItsMeshKeysAndKeepsItsAccount(t *testing
|
||||
r.forge.mu.Lock()
|
||||
r.forge.held["nox-agent"] = append(r.forge.held["nox-agent"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
||||
r.forge.mu.Unlock()
|
||||
r.bus.mu.Lock()
|
||||
delete(r.bus.buckets[provider.AssignmentsBucket], "g14.ssh-client")
|
||||
r.bus.mu.Unlock()
|
||||
r.bus.retire("g14", "ssh-client")
|
||||
r.pass()
|
||||
if r.bus.has("g14.ssh-client.forge-account") {
|
||||
t.Fatal("the entry stayed")
|
||||
@@ -460,7 +505,7 @@ func TestADeletedKeyEntryRemovesTheMeshKeyAndTheBoundEntryButNotTheOperatorsKey(
|
||||
if got := r.forge.titles("nox-agent"); len(got) != 1 || got[0] != "my own" {
|
||||
t.Fatalf("keys = %v", got)
|
||||
}
|
||||
if r.bus.has("g14.ssh-client.forge-account") {
|
||||
if r.bus.serves("g14.ssh-client.forge-account") {
|
||||
t.Fatal("the entry still serves a machine whose key is gone")
|
||||
}
|
||||
}
|
||||
@@ -482,8 +527,8 @@ func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) {
|
||||
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "mesh:g14.nox,my own" {
|
||||
t.Fatalf("keys = %s", got)
|
||||
}
|
||||
if !r.bus.has("g14.ssh-client.forge-account") {
|
||||
t.Fatal("the entry was deleted before the keys were read")
|
||||
if !r.bus.serves("g14.ssh-client.forge-account", provisions.Waits) {
|
||||
t.Fatal("the entry stopped serving before the keys were read; it waits and keeps serving")
|
||||
}
|
||||
if len(out) != 1 || !strings.Contains(out[0].Why, "not read yet") {
|
||||
t.Fatalf("the wait should be said: %+v", out)
|
||||
@@ -493,7 +538,7 @@ func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) {
|
||||
r.svc.loaded = true
|
||||
r.svc.mu.Unlock()
|
||||
r.pass()
|
||||
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "my own" || r.bus.has("g14.ssh-client.forge-account") {
|
||||
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "my own" || r.bus.serves("g14.ssh-client.forge-account") {
|
||||
t.Fatalf("keys = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -515,7 +560,7 @@ func TestKeepRunningStartsTheProviderAgainAfterARefusedWatch(t *testing.T) {
|
||||
r.svc.KeepRunning(ctx, func(ctx context.Context) error {
|
||||
calls++
|
||||
if calls <= 2 {
|
||||
return errors.New(`watching mesh-controller_assignments: gitea reads no state called "mesh-controller.assignments"`)
|
||||
return errors.New(`watching mesh-provisioner_plans: gitea reads no state called "mesh-provisioner.plans"`)
|
||||
}
|
||||
close(serving)
|
||||
<-ctx.Done()
|
||||
@@ -535,7 +580,7 @@ func TestKeepRunningStartsTheProviderAgainAfterARefusedWatch(t *testing.T) {
|
||||
if len(pauses) != 2 || pauses[0] != 5*time.Second || pauses[1] != 10*time.Second {
|
||||
t.Fatalf("the backoff is %v", pauses)
|
||||
}
|
||||
if len(said) == 0 || !strings.Contains(said[0], "mesh-controller.assignments") {
|
||||
if len(said) == 0 || !strings.Contains(said[0], "mesh-provisioner.plans") {
|
||||
t.Fatalf("a refusal is said loudly: %v", said)
|
||||
}
|
||||
loud := 0
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestABuildModulesStateThatNeverAnswersHoldsBackOnlyItsOwnEntries(t *testing
|
||||
}
|
||||
|
||||
// 1482/AC4 (the provider's half): the manifest reads the states of both build modules beside ssh-client's and the
|
||||
// controller's assignments; the provider watches exactly the modules the code names as builders.
|
||||
// seat mesh-provisioner's plans; the provider watches exactly the modules the code names as builders.
|
||||
func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
@@ -154,7 +154,7 @@ func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} {
|
||||
for _, state := range []string{"mesh-provisioner.plans", "mesh-provisioner.provisions", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} {
|
||||
if !slices.Contains(m.Reads, state) {
|
||||
t.Errorf("gitea's reads %v lack %q", m.Reads, state)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module gitea
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue
|
||||
// 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is
|
||||
// granted what it said (clause 3). A state is readable when another module's manifest reads it; none reads gitea's.
|
||||
// test/kept.test.ts holds the list to the manifest's `state`.
|
||||
// granted what it said (clause 3). A state is readable when another module's manifest reads it: mesh-provisioner reads
|
||||
// `bound` (the forge accounts it binds). test/kept.test.ts holds the list to the manifest's `state`, and the catalogue's
|
||||
// checks/readable every state another manifest reads to a readable announcement.
|
||||
export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [
|
||||
{ name: "announced" },
|
||||
{ name: "bound" },
|
||||
{ name: "bound", readable: true },
|
||||
];
|
||||
|
||||
/** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1,
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "npm-package-registry",
|
||||
"credential": {"form": "pair"},
|
||||
"scope": "mesh",
|
||||
"identity": {
|
||||
"max": 40,
|
||||
@@ -408,6 +409,7 @@
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"credential": {"form": "none"},
|
||||
"scope": "mesh"
|
||||
},
|
||||
{
|
||||
@@ -420,7 +422,9 @@
|
||||
},
|
||||
{
|
||||
"name": "forge-account",
|
||||
"scope": "mesh"
|
||||
"credential": {"form": "none"},
|
||||
"scope": "mesh",
|
||||
"answered-by": "helper"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -564,7 +568,8 @@
|
||||
"announced"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-provisioner.plans",
|
||||
"mesh-provisioner.provisions",
|
||||
"ssh-client.keys",
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys",
|
||||
|
||||
@@ -175,3 +175,33 @@ test("the writes a watch's start hands over go to the forge a few at a time, nev
|
||||
assert.equal(done, 50);
|
||||
assert.equal(most, WRITES_AT_ONCE);
|
||||
});
|
||||
|
||||
// The sweep of 2026-10-30 (issue 774): a status the forge refuses with 403 or 404 was asked again every 30 s forever (hal
|
||||
// and novadock, 119 times each), and a key the checker deleted kept its loop running. Now a 403 or 404 is said once and
|
||||
// never asked again, a 5xx is still asked again, and a delete ends a loop that is running.
|
||||
test("a refusal for good is said once and not asked again; a delete ends a running ask again", async () => {
|
||||
const { CheckStatuses, refusedForGood } = await import("../check.ts");
|
||||
for (const [err, forGood] of [["Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden", true],
|
||||
["Gitea API /repos/jschoubben/novadock/statuses/x: 404 not found", true], ["Gitea API /repos/x/statuses: 502 bad gateway", false],
|
||||
["Gitea API /repos/x: no answer in 10 s, the request was cut", false]] as [string, boolean][]) {
|
||||
assert.equal(refusedForGood(new Error(err)), forGood, err);
|
||||
}
|
||||
const said: string[] = [];
|
||||
const forbidden = { setCommitStatus: async () => { throw new Error("Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden"); } };
|
||||
const checks = new CheckStatuses(forbidden, (l) => said.push(l), async () => { throw new Error("asked again"); });
|
||||
await checks.take(put(key(sha, "jschoubben/hal"), owed(), 3));
|
||||
await checks.settled();
|
||||
assert.equal(said.length, 1);
|
||||
assert.match(said[0], /refused for good.*403.*not asked again/);
|
||||
// A 502 asks again; the key's delete meanwhile ends the loop before its next ask.
|
||||
const g = forge();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
const later = new CheckStatuses(g, () => {}, () => gate);
|
||||
g.fail(5);
|
||||
await later.take(put(key(), owed(), 8));
|
||||
await later.take({ state: "mesh-delivery-checker.owed", key: key(), op: "delete", revision: 9, current: false } as any);
|
||||
release();
|
||||
await later.settled();
|
||||
assert.deepEqual(g.sent, [], "a deleted key is not written");
|
||||
});
|
||||
|
||||
@@ -2,20 +2,21 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// 1790: the states the module announces are the states its manifest declares, no more, no fewer; none is readable
|
||||
// because no other module's manifest reads one of them.
|
||||
// 1790: the states the module announces are the states its manifest declares, no more, no fewer; `bound` is readable,
|
||||
// because mesh-provisioner's manifest reads it (the sweep of 2026-10-30, issue 774: announced unreadable, the read was
|
||||
// refused and no forge account was bound).
|
||||
test("the states the module announces are the states its manifest declares", async () => {
|
||||
const { KEPT } = await import("../kept.ts");
|
||||
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||
const declared = (manifest.state as (string | { name: string })[]).map((s) => (typeof s === "string" ? s : s.name)).sort();
|
||||
assert.deepEqual(KEPT.map((k) => k.name).sort(), declared);
|
||||
assert.ok(KEPT.every((k) => !k.readable), "no other module's manifest reads a state of gitea");
|
||||
assert.deepEqual(KEPT.filter((k) => k.readable).map((k) => k.name), ["bound"], "bound alone is readable: mesh-provisioner reads it");
|
||||
});
|
||||
|
||||
test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => {
|
||||
const { statesAnnouncement } = await import("../kept.ts");
|
||||
assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound" }] });
|
||||
assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound" }] });
|
||||
assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }] });
|
||||
assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }] });
|
||||
});
|
||||
|
||||
test("index.ts says the announcement as the event `states` before the tools are served", () => {
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "go-toolchain",
|
||||
"reach": "machine"
|
||||
"reach": "machine",
|
||||
"credential": {
|
||||
"form": "none"
|
||||
}
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GrafanaClient, type GrafanaAlert } from "./client.js";
|
||||
import { Refusal } from "./refused.js";
|
||||
|
||||
// Constructed lazily so an unconfigured node (no auth) loads this entrypoint without crashing the
|
||||
// events host — it simply watches nothing.
|
||||
@@ -51,7 +52,13 @@ async function pollAlerts(client: GrafanaClient): Promise<void> {
|
||||
|
||||
if (grafana) {
|
||||
const client = grafana;
|
||||
const run = (): void => void pollAlerts(client).catch((err) => console.error(`[grafana] ${err}`));
|
||||
// A refused credential is said once and asked again hourly (refused.ts), not every 30 s.
|
||||
const refusal = new Refusal();
|
||||
const say = (line: string | undefined): void => { if (line) console.error(line); };
|
||||
const run = (): void => {
|
||||
if (!refusal.due(Date.now())) return;
|
||||
void pollAlerts(client).then(() => say(refusal.after(Date.now())), (err) => say(refusal.after(Date.now(), err)));
|
||||
};
|
||||
setInterval(run, 30_000);
|
||||
run();
|
||||
console.log("[grafana] watching for firing alerts");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// What the alert watcher does when Grafana refuses the vault's admin password (the sweep of 2026-10-30, issue 774:
|
||||
// a 401 every 30 s on ace since at least 2026-10-05, 145 times in the sweep's window, said only in the journal).
|
||||
//
|
||||
// The cause on ace: Grafana keeps the admin password it was first given in its own database, and applies
|
||||
// GF_SECURITY_ADMIN_PASSWORD__FILE only when it creates that database. ace's Grafana was adopted with its database
|
||||
// before the mesh minted `admin` (2026-09-30), so the minted value never took (the same kind as keycloak's admin,
|
||||
// hq issue 179). Asking again every 30 s changes nothing until the password in Grafana's database is the vault's.
|
||||
// So a refusal is said once, with that cause and what ends it, and asked again hourly, not every look; an answer
|
||||
// after it is said too.
|
||||
|
||||
/** How long a refused credential waits before the watcher asks again. */
|
||||
export const AskAgainRefusedMs = 60 * 60_000;
|
||||
|
||||
/** Whether err is Grafana refusing the credential (the client's "Grafana API <path>: 401 ..."). */
|
||||
export function refusedCredential(err: unknown): boolean {
|
||||
return /^Error: Grafana API \S+: 401 |^Grafana API \S+: 401 /.test(String(err));
|
||||
}
|
||||
|
||||
export class Refusal {
|
||||
private since: number | undefined;
|
||||
private lastAsk = 0;
|
||||
|
||||
/** Whether the watcher asks Grafana now. */
|
||||
due(now: number): boolean {
|
||||
return this.since === undefined || now - this.lastAsk >= AskAgainRefusedMs;
|
||||
}
|
||||
|
||||
/** What to say after one ask at now: its error, the refusal once, the recovery once; undefined for nothing. */
|
||||
after(now: number, err?: unknown): string | undefined {
|
||||
this.lastAsk = now;
|
||||
if (err === undefined) {
|
||||
if (this.since === undefined) return undefined;
|
||||
this.since = undefined;
|
||||
return "[grafana] Grafana accepts the vault's admin password again: watching for firing alerts every 30 s";
|
||||
}
|
||||
if (!refusedCredential(err)) return `[grafana] ${err}`;
|
||||
if (this.since !== undefined) return undefined;
|
||||
this.since = now;
|
||||
return (
|
||||
"[grafana] Grafana refuses the vault's admin password (401), so no firing alert is seen; asked again hourly. " +
|
||||
"Grafana keeps the admin password it was first given in its database and takes the vault's only when it creates " +
|
||||
"that database: an install adopted with its database keeps its old one. It ends when Grafana's admin password is " +
|
||||
"the vault's (grafana cli admin reset-admin-password in the container, with the value of own/<node>/grafana/admin)."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
// The sweep of 2026-10-30 (issue 774): a refused admin password was said every 30 s, forever. Now it is said once with
|
||||
// its cause, asked again hourly, and its end is said; any other error is still said each time.
|
||||
test("a refused credential is said once, asked again hourly, and its end said", async () => {
|
||||
const { Refusal, AskAgainRefusedMs } = await import("../refused.ts");
|
||||
const r = new Refusal();
|
||||
const refused = new Error('Grafana API /api/prometheus/grafana/api/v1/alerts: 401 {"message":"Invalid username or password"}');
|
||||
const rows: [number, Error | undefined, boolean, RegExp | undefined][] = [
|
||||
// at, error of the ask, due before it, what is said
|
||||
[0, refused, true, /refuses the vault's admin password.*reset-admin-password/],
|
||||
[30_000, undefined, false, undefined],
|
||||
[AskAgainRefusedMs, refused, true, undefined],
|
||||
[AskAgainRefusedMs + 1, new Error("Grafana API /api/health: 502 bad gateway"), false, undefined],
|
||||
[2 * AskAgainRefusedMs, undefined, true, /accepts the vault's admin password again/],
|
||||
[2 * AskAgainRefusedMs + 30_000, new Error("Grafana API /x: 502 bad gateway"), true, /502 bad gateway/],
|
||||
[2 * AskAgainRefusedMs + 60_000, new Error("Grafana API /x: 502 bad gateway"), true, /502 bad gateway/],
|
||||
];
|
||||
for (const [i, [at, err, due, said]] of rows.entries()) {
|
||||
assert.equal(r.due(at), due, `row ${i}: due`);
|
||||
if (!due) continue;
|
||||
const line = r.after(at, err);
|
||||
if (said) assert.match(line ?? "", said, `row ${i}`);
|
||||
else assert.equal(line, undefined, `row ${i}`);
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,9 @@
|
||||
"scope": "mesh",
|
||||
"identity": {
|
||||
"in": "an InfluxDB v1 authorization"
|
||||
},
|
||||
"credential": {
|
||||
"form": "pair"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider/harness"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
// grantGives writes a grant file (MESH_RECEIVES) naming consumers the plans do not move (issue 937): each one's
|
||||
@@ -38,11 +39,33 @@ func grantGives(t *testing.T, given ...map[string]any) string {
|
||||
return at
|
||||
}
|
||||
|
||||
// noOpPlan puts one assignment the bus can read that binds nothing of keycloak's, so the moved path has plans it
|
||||
// noOpPlan puts one plan the bus can read that binds nothing of keycloak's, so the moved path has plans it
|
||||
// could read (m.asked answers "read", issue 241) even when nothing is moved.
|
||||
func noOpPlan(bus *bucketBus, node, module string) {
|
||||
raw, _ := json.Marshal(map[string]any{"node": node, "module": module})
|
||||
_ = bus.Put(ctx, provider.AssignmentsBucket, node+"."+module, raw)
|
||||
seatPlan(bus, node, module, map[string]any{})
|
||||
}
|
||||
|
||||
// seatPlan puts a module's plan as the seat mesh-provisioner keeps it, and for each of its provisions served by
|
||||
// keycloak on novox the key the seat composes, asked (novox/hq ADR 0570 clauses 1 to 3).
|
||||
func seatPlan(bus *bucketBus, node, module string, plan map[string]any) {
|
||||
raw, _ := json.Marshal(map[string]any{"v": 1, "node": node, "module": module, "plan": plan})
|
||||
_ = bus.Put(ctx, provisions.PlansBucket, node+"."+module, raw)
|
||||
list, _ := plan["provisions"].([]any)
|
||||
for _, e := range list {
|
||||
entry := e.(map[string]any)
|
||||
name, _ := entry["provision"].(string)
|
||||
local, _ := entry["local"].(string)
|
||||
key, err := provisions.Key(node, module, name, local)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
asked, err := provisions.Encode(provisions.Provision{Provision: name, Local: local, State: provisions.Asked,
|
||||
Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "keycloak"}})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = bus.Put(ctx, provisions.ProvisionsBucket, key, asked)
|
||||
}
|
||||
}
|
||||
|
||||
// waitUntil polls cond until it is true or the timeout passes.
|
||||
|
||||
@@ -62,6 +62,14 @@ func TestLiveRetirement(t *testing.T) {
|
||||
if code := authorize(); code == 200 {
|
||||
t.Fatal("a retired client is still served")
|
||||
}
|
||||
// Its secret is removed (ADR 0570 clause 5): the mesh's no longer authenticates.
|
||||
if found, err := o.KC.FindClient(ctx, o.Realm, p.As); err != nil || found == nil {
|
||||
t.Fatal(found, err)
|
||||
} else if id, _ := found["id"].(string); true {
|
||||
if secret, err := o.KC.ClientSecretByID(ctx, o.Realm, id); err != nil || secret == p.Password {
|
||||
t.Fatal("a retired client keeps the mesh's secret", err)
|
||||
}
|
||||
}
|
||||
// Retired, so a person may delete it.
|
||||
if err := o.Delete(ctx, p.As); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -155,10 +155,9 @@ func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus := &bucketBus{buckets: map[string]map[string][]byte{}}
|
||||
plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana", "provisioning": map[string]any{
|
||||
seatPlan(bus, "home", "grafana", map[string]any{
|
||||
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox",
|
||||
"provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}}})
|
||||
_ = bus.Put(ctx, provider.AssignmentsBucket, "home.grafana", plan)
|
||||
"provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}})
|
||||
pr := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus,
|
||||
Vault: provider.ConsumersFile{Paths: []string{file}},
|
||||
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer,
|
||||
|
||||
@@ -24,15 +24,18 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider/harness"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider/harness"
|
||||
)
|
||||
|
||||
// Mark is the attribute marking a client as the mesh's own work.
|
||||
@@ -307,8 +310,10 @@ func (o OidcClients) Holds(ctx context.Context, p harness.Provision) (bool, erro
|
||||
return secret == p.Password, nil
|
||||
}
|
||||
|
||||
// Retire disables a consumer's client — only one the mesh made — and marks it with when and why.
|
||||
// Answers what happened: "retired", "absent" or "not ours".
|
||||
// Retire, on the operator's yes (novox/hq ADR 0570 clause 5), removes the client's secret — replaced by one nobody
|
||||
// is given, so the mesh's credential no longer authenticates — and disables and marks the client with when and why.
|
||||
// The client and its settings are kept; asked for again, Ensure sets the mesh's secret and enables it. Only a client
|
||||
// the mesh made. Answers what happened: "retired", "absent" or "not ours".
|
||||
func (o OidcClients) Retire(ctx context.Context, as, why string, at time.Time) (string, error) {
|
||||
found, err := o.KC.FindClient(ctx, o.Realm, as)
|
||||
if err != nil {
|
||||
@@ -332,6 +337,11 @@ func (o OidcClients) Retire(ctx context.Context, as, why string, at time.Time) (
|
||||
attrs[MarkRetiredWhy] = why
|
||||
merged["attributes"] = attrs
|
||||
merged["enabled"] = false
|
||||
unknown := make([]byte, 32)
|
||||
if _, err := rand.Read(unknown); err != nil {
|
||||
return "", err
|
||||
}
|
||||
merged["secret"] = hex.EncodeToString(unknown) // never kept, never said
|
||||
id, _ := found["id"].(string)
|
||||
return "retired", o.KC.UpdateClient(ctx, o.Realm, id, merged)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestAClientTheMeshDidNotMakeIsRefusedAndLeftAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Retired is disabled and marked, everything else kept; asked for again it is enabled as it was; only
|
||||
// Retired is its secret removed, disabled and marked, everything else kept (ADR 0570 clause 5); asked for again it is enabled as it was; only
|
||||
// a deletion removes it, and never while enabled (novox/hq ADR 0230).
|
||||
func TestARetiredClientIsDisabledKeptAndEnabledAgainAsItWas(t *testing.T) {
|
||||
f, o := oidcWorld(t)
|
||||
@@ -204,7 +204,7 @@ func TestARetiredClientIsDisabledKeptAndEnabledAgainAsItWas(t *testing.T) {
|
||||
t.Fatal(done, err)
|
||||
}
|
||||
c := f.clients[id]
|
||||
if c["enabled"] != false || c["secret"] != "s" || attributes(c)[MarkRetired] != "2026-10-06T12:00:00Z" ||
|
||||
if c["enabled"] != false || c["secret"] == "s" || c["secret"] == "" || attributes(c)[MarkRetired] != "2026-10-06T12:00:00Z" ||
|
||||
attributes(c)[MarkRetiredWhy] != "the mesh stopped asking for it" || c["description2"] == nil {
|
||||
t.Fatalf("%v", c)
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func TestARetiredClientIsDisabledKeptAndEnabledAgainAsItWas(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c = f.clients[id]
|
||||
if c["enabled"] != true || attributes(c)[MarkRetired] != nil || attributes(c)[MarkRetiredWhy] != nil || c["description2"] == nil {
|
||||
if c["enabled"] != true || c["secret"] != "s" || attributes(c)[MarkRetired] != nil || attributes(c)[MarkRetiredWhy] != nil || c["description2"] == nil {
|
||||
t.Fatalf("%v", c)
|
||||
}
|
||||
mustHold(t, o, p, true)
|
||||
|
||||
@@ -62,7 +62,8 @@ func (a provisioner) Create(ctx context.Context, p harness.Provision) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retire disables the consumer's client and marks it, never deletes it (novox/hq ADR 0230).
|
||||
// Retire removes the consumer's client secret and disables and marks the client, never deletes it (novox/hq ADR 0570
|
||||
// clause 5, ADR 0230).
|
||||
func (a provisioner) Retire(ctx context.Context, as string, _ map[string]any, why string, at time.Time) error {
|
||||
if err := a.refused(); err != nil {
|
||||
return err
|
||||
@@ -75,7 +76,7 @@ func (a provisioner) Retire(ctx context.Context, as string, _ map[string]any, wh
|
||||
case "not ours":
|
||||
a.log("[provisioner:oidc-client] %s: a client of that id exists that the mesh did not make — left alone", as)
|
||||
case "retired":
|
||||
a.log("[provisioner:oidc-client] retired client %s in realm %s: disabled, kept, marked to delete", as, a.clients.Realm)
|
||||
a.log("[provisioner:oidc-client] retired client %s in realm %s: its secret removed, disabled, kept, marked to delete", as, a.clients.Realm)
|
||||
a.announce("client.retired", map[string]any{"realm": a.clients.Realm, "clientId": as})
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -2,4 +2,4 @@ module keycloak
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "oidc-client",
|
||||
"credential": {"form": "pair"},
|
||||
"scope": "mesh",
|
||||
"identity": {
|
||||
"max": 255,
|
||||
@@ -240,6 +241,7 @@
|
||||
"bound"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments"
|
||||
"mesh-provisioner.plans",
|
||||
"mesh-provisioner.provisions"
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user