Compare commits
158
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15db34169e | ||
|
|
ebcc7761f1 | ||
|
|
3ebcb15a4b | ||
|
|
d9f2b7bcfa | ||
|
|
dbcba161d5 | ||
|
|
c8e321c8e1 | ||
|
|
b8e8be619d | ||
|
|
cc8a354f75 | ||
|
|
6a57bb7218 | ||
|
|
36d9d73984 | ||
|
|
9cf5c1ddbd | ||
|
|
3b92e02862 | ||
|
|
a8131809b2 | ||
|
|
b8ce072c9f | ||
|
|
baaf2e56dd | ||
|
|
9e545658a5 | ||
|
|
4e10ab7b2e | ||
|
|
954e3afe56 | ||
|
|
6cf98386e9 | ||
|
|
bd65b023bd | ||
|
|
31ce47d1cc | ||
|
|
6eccfc286a | ||
|
|
110c631b5f | ||
|
|
256a412590 | ||
|
|
b63e8ae58a | ||
|
|
3991211993 | ||
|
|
9cc62818f4 | ||
|
|
2b604e0dad | ||
|
|
9770dab09e | ||
|
|
15094d6e72 | ||
|
|
5ca3876bea | ||
|
|
f29a9ad563 | ||
|
|
4179be7a56 | ||
|
|
e85cf002a4 | ||
|
|
1c03299721 | ||
|
|
e995e40519 | ||
|
|
9dfb2eae0e | ||
|
|
ae056a44dd | ||
|
|
ea74be9e2e | ||
|
|
19994c1817 | ||
|
|
3c0b4d29a8 | ||
|
|
7cc81512a3 | ||
|
|
462db1c257 | ||
|
|
4a3914ee1c | ||
|
|
ce3284fc8f | ||
|
|
ad6e813fb0 | ||
|
|
b373e34b29 | ||
|
|
067b9a47da | ||
|
|
bfa4a0851a | ||
|
|
b7f78d47c5 | ||
|
|
c4463ba47f | ||
|
|
c39503e322 | ||
|
|
6c61d9facf | ||
|
|
a39b72c377 | ||
|
|
e712cd227f | ||
|
|
12059ad3cc | ||
|
|
6a29e41175 | ||
|
|
1948da9cb4 | ||
|
|
b84a2f4dc1 | ||
|
|
9c42423d58 | ||
|
|
c12f1a16db | ||
|
|
09ea4a40bf | ||
|
|
6b8e7fc621 | ||
|
|
3c3fdd3446 | ||
|
|
06f28fbadb | ||
|
|
62644aa012 | ||
|
|
bdfd8db265 | ||
|
|
89ec9429db | ||
|
|
b9cfa4fe16 | ||
|
|
8b184d8322 | ||
|
|
02d57c4a53 | ||
|
|
5a7561fe1d | ||
|
|
5e537f851f | ||
|
|
0a6a5aba68 | ||
|
|
407fc6c9e6 | ||
|
|
6299b47f4f | ||
|
|
24bf59219e | ||
|
|
e3b4ecde8c | ||
|
|
fd9147087e | ||
|
|
a3d141e793 | ||
|
|
4fbe6e347f | ||
|
|
3d70bd3660 | ||
|
|
a4e5a8ffef | ||
|
|
13dea6b1aa | ||
|
|
6d2ce96630 | ||
|
|
a7dc4534d7 | ||
|
|
4387e77f28 | ||
|
|
443a340f06 | ||
|
|
15f81780b9 | ||
|
|
ea4dfaa5a2 | ||
|
|
7630e3cc9d | ||
|
|
994b25f540 | ||
|
|
503411e628 | ||
|
|
4e2a1a5a1f | ||
|
|
6a24e4d0c7 | ||
|
|
80186d0798 | ||
|
|
89cd8a2e8e | ||
|
|
4658e1cb1c | ||
|
|
6e6b325cf1 | ||
|
|
bbe107fa2a | ||
|
|
ac94f8338f | ||
|
|
a0459a8553 | ||
|
|
6cf7f67a1d | ||
|
|
08fbc51af2 | ||
|
|
1ef1b13560 | ||
|
|
7fd4ed5898 | ||
|
|
bc8cb3b153 | ||
|
|
9480557537 | ||
|
|
c88be88ccf | ||
|
|
53d173aa7d | ||
|
|
4becc7bd83 | ||
|
|
61737962fc | ||
|
|
b4192c525e | ||
|
|
d03b455ea6 | ||
|
|
1171b684d0 | ||
|
|
6547933b4d | ||
|
|
0cd706b044 | ||
|
|
908f3918de | ||
|
|
8b84ac7487 | ||
|
|
9698bcd934 | ||
|
|
21aa11aef1 | ||
|
|
145cf7e33a | ||
|
|
9a3356bd38 | ||
|
|
83732dcd53 | ||
|
|
adcddb40cc | ||
|
|
0c465fb949 | ||
|
|
bac7f503f0 | ||
|
|
80b8002a94 | ||
|
|
1c1b03c3a0 | ||
|
|
67d9ccb1d4 | ||
|
|
0f52013878 | ||
|
|
36c19694c9 | ||
|
|
77b30bd296 | ||
|
|
81287cb11d | ||
|
|
962e8547b1 | ||
|
|
ea25e4d589 | ||
|
|
422b22ab0e | ||
|
|
91edd0bfdb | ||
|
|
81874e4748 | ||
|
|
62fe1cc36d | ||
|
|
7bf15a3dc4 | ||
|
|
ba17347518 | ||
|
|
8c67c224c2 | ||
|
|
f2b332de73 | ||
|
|
980ade9338 | ||
|
|
7c0ce2fe21 | ||
|
|
be7d649d1f | ||
|
|
c8bcb2cdf5 | ||
|
|
608c4d4be6 | ||
|
|
da1356cd7c | ||
|
|
fa73674e22 | ||
|
|
1da83c29dd | ||
|
|
428ef8bbe5 | ||
|
|
0220dfa92b | ||
|
|
b6d85847f8 | ||
|
|
1d07778c88 | ||
|
|
ddb2ae9420 | ||
|
|
332d373114 |
@@ -20,6 +20,7 @@ dist/
|
|||||||
/checks/predefined/predefined
|
/checks/predefined/predefined
|
||||||
/checks/readable/readable
|
/checks/readable/readable
|
||||||
/checks/removedpackages/removedpackages
|
/checks/removedpackages/removedpackages
|
||||||
|
/checks/requiredmodules/requiredmodules
|
||||||
/checks/secrets/secrets
|
/checks/secrets/secrets
|
||||||
/checks/sshdir/sshdir
|
/checks/sshdir/sshdir
|
||||||
/checks/templates/templates
|
/checks/templates/templates
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
|
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
|
||||||
// exempt from that, and said so. Every build module, test step or not, must also hold a test that calls
|
// exempt from that, and said so. Every build module, test step or not, must also hold a test that calls
|
||||||
// conformance.LeftRunning with its own start sweep, so a step a previous process left running is ended at its start
|
// conformance.LeftRunning with its own start sweep, so a step a previous process left running is ended at its start
|
||||||
// (issue 1890).
|
// (issue 1890), and one that calls conformance.Passing, so a step whose network did not answer is said as a transport fault
|
||||||
|
// (issue 1748). Its manifest asks the capability seat-settle-delay (ADR 0664 §5).
|
||||||
package failurekit
|
package failurekit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -27,9 +29,15 @@ type manifest struct {
|
|||||||
Claims []struct {
|
Claims []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"claims"`
|
} `json:"claims"`
|
||||||
Tools []json.RawMessage `json:"tools"`
|
Tools []json.RawMessage `json:"tools"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// settleDelay is the capability a tool runner states when it can settle a seat's ask with a delayed negative acknowledgement
|
||||||
|
// (mesh-sdk CapSeatSettleDelay): a build module gives its ask again after a delay by it, so the delivery plan places a tool
|
||||||
|
// runner that states it before the module (ADR 0664 §1, §5).
|
||||||
|
const settleDelay = "seat-settle-delay"
|
||||||
|
|
||||||
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
|
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
|
||||||
// seat.
|
// seat.
|
||||||
func Judge(root string) ([]Judged, error) {
|
func Judge(root string) ([]Judged, error) {
|
||||||
@@ -83,6 +91,22 @@ func Judge(root string) ([]Judged, error) {
|
|||||||
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
|
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
|
||||||
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
|
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
|
||||||
}
|
}
|
||||||
|
// Every build module meets the network (a fetch, a download, an install, a publish), so every one runs the passing case: its
|
||||||
|
// steps say a network that did not answer as a transport fault the bus gives the ask again for (ADR 0664, issue 1748).
|
||||||
|
says, err := runsTheKit(dir, "conformance.Passing(")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !says {
|
||||||
|
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.Passing "+
|
||||||
|
"(mesh-build-go buildstep/conformance): run the kit's passing case with the module's own path from a failed run to its step's error (issue 1748)", seat))
|
||||||
|
}
|
||||||
|
// Every build module gives a step that met a transport fault again by the bus (ADR 0664 §5): it asks the tool runner's
|
||||||
|
// capability in its manifest, so the delivery plan places a tool runner that states it first.
|
||||||
|
if !slices.Contains(m.Capabilities, settleDelay) {
|
||||||
|
refused = append(refused, fmt.Sprintf("it claims %s, and its manifest's capabilities do not ask %s: a step given again by the bus needs "+
|
||||||
|
"a tool runner that can settle with a delay (ADR 0664 §5, issue 1748)", seat, settleDelay))
|
||||||
|
}
|
||||||
j.Refused = strings.Join(refused, "; ")
|
j.Refused = strings.Join(refused, "; ")
|
||||||
out = append(out, j)
|
out = append(out, j)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,28 @@ import (
|
|||||||
|
|
||||||
// 1901/AC7 and 1890/AC8: a build module with a test step and no kit is refused, one that runs the kit passes, one with no
|
// 1901/AC7 and 1890/AC8: a build module with a test step and no kit is refused, one that runs the kit passes, one with no
|
||||||
// test step is exempt from the failure cases but not from the left-running case, a module that runs only the failure
|
// test step is exempt from the failure cases but not from the left-running case, a module that runs only the failure
|
||||||
// cases is refused, and a module that claims no node-build-* seat is not judged.
|
// cases is refused, a module that does not ask the capability seat-settle-delay is refused (ADR 0664 §5), and a module that
|
||||||
|
// claims no node-build-* seat is not judged.
|
||||||
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
|
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
|
||||||
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
|
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\nfunc TestPassing(t *testing.T) { conformance.Passing(t, nil, nil) }\n"
|
||||||
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
|
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
|
||||||
for path, body := range map[string]string{
|
for path, body := range map[string]string{
|
||||||
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
|
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
|
||||||
"modules/a/cmd/a/check_test.go": "package main\n",
|
"modules/a/cmd/a/check_test.go": "package main\n",
|
||||||
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"tools":[{"name":"build_b_test"}]}`,
|
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"capabilities":["seat-settle-delay"],"tools":[{"name":"build_b_test"}]}`,
|
||||||
"modules/b/cmd/b/kit_test.go": kit,
|
"modules/b/cmd/b/kit_test.go": kit,
|
||||||
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"tools":["build_c_build"]}`,
|
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"capabilities":["seat-settle-delay"],"tools":["build_c_build"]}`,
|
||||||
"modules/c/cmd/c/kit_test.go": imp + sweep,
|
"modules/c/cmd/c/kit_test.go": imp + sweep,
|
||||||
"modules/f/module.json": `{"claims":[{"name":"node-build-f"}],"tools":["build_f_build"]}`,
|
"modules/f/module.json": `{"claims":[{"name":"node-build-f"}],"tools":["build_f_build"]}`,
|
||||||
"modules/g/module.json": `{"claims":[{"name":"node-build-g"}],"tools":["build_g_test"]}`,
|
"modules/g/module.json": `{"claims":[{"name":"node-build-g"}],"tools":["build_g_test"]}`,
|
||||||
"modules/g/cmd/g/kit_test.go": imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n",
|
"modules/g/cmd/g/kit_test.go": imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n",
|
||||||
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
|
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
|
||||||
|
"modules/h/module.json": `{"claims":[{"name":"node-build-h"}],"tools":["build_h_build"]}`, // the sweep, and no passing case
|
||||||
|
"modules/h/cmd/h/kit_test.go": imp + "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n",
|
||||||
|
"modules/i/module.json": `{"claims":[{"name":"node-build-i"}],"capabilities":["other"],"tools":["build_i_build"]}`, // the whole kit, and no seat-settle-delay
|
||||||
|
"modules/i/cmd/i/kit_test.go": imp + sweep,
|
||||||
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
|
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
|
||||||
"modules/e/cmd/e/kit.go": kit, // not a test file
|
"modules/e/cmd/e/kit.go": kit, // not a test file
|
||||||
} {
|
} {
|
||||||
@@ -40,9 +45,9 @@ func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused"}
|
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused", "h": "refused", "i": "refused"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("judged %+v, want a, b, c, e, f and g", got)
|
t.Fatalf("judged %+v, want a, b, c, e, f, g, h and i", got)
|
||||||
}
|
}
|
||||||
for _, j := range got {
|
for _, j := range got {
|
||||||
is := "passes"
|
is := "passes"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/requiredmodules
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Command requiredmodules refuses a Go module that imports a package of a mesh module its go.mod does not require
|
||||||
|
// (issue 1921, issue 1966; the second time a local workspace hid it: the candidate's build resolves only what go.mod says).
|
||||||
|
//
|
||||||
|
// A developer's go.work resolves every sibling module, so a source can import a package whose module the go.mod never
|
||||||
|
// names and still build; the build seat builds against the go.mod alone and then fails with "finding module for
|
||||||
|
// package". This check reads every go.mod under the roots it reports and every Go source beside it (test files and files
|
||||||
|
// behind a build tag included, testdata, vendor and node_modules left out) and, for each import under
|
||||||
|
// git.novox.be/novox/, finds the module that owns the package (the longest `module` line, among the modules of every
|
||||||
|
// root it is given, that is a prefix of the import) and demands that module to be the go.mod's own or one it requires.
|
||||||
|
// Where no known module owns the import, any own or required module that is a prefix of it satisfies it.
|
||||||
|
//
|
||||||
|
// go run . <root> [<root> ...]
|
||||||
|
//
|
||||||
|
// The first root is reported; the others (the repositories cloned beside, MESH_CHECK_BESIDE) only say which module owns
|
||||||
|
// which path. With -all every root is reported. Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const prefix = "git.novox.be/novox/"
|
||||||
|
|
||||||
|
// Mod is a go.mod: where it is, its module path and the modules it requires.
|
||||||
|
type Mod struct {
|
||||||
|
Dir string
|
||||||
|
Path string
|
||||||
|
Requires map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modules reads every go.mod under root.
|
||||||
|
func Modules(root string) ([]Mod, error) {
|
||||||
|
var out []Mod
|
||||||
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
switch d.Name() {
|
||||||
|
case "testdata", "vendor", "node_modules", ".git":
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.Name() != "go.mod" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := Mod{Dir: filepath.Dir(p), Requires: map[string]bool{}}
|
||||||
|
in := false
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if i := strings.Index(line, "//"); i >= 0 {
|
||||||
|
line = strings.TrimSpace(line[:i])
|
||||||
|
}
|
||||||
|
f := strings.Fields(line)
|
||||||
|
switch {
|
||||||
|
case len(f) == 2 && f[0] == "module":
|
||||||
|
m.Path = strings.Trim(f[1], `"`)
|
||||||
|
case len(f) >= 1 && f[0] == "require" && len(f) >= 2 && f[1] == "(":
|
||||||
|
in = true
|
||||||
|
case line == ")":
|
||||||
|
in = false
|
||||||
|
case len(f) >= 3 && f[0] == "require":
|
||||||
|
m.Requires[f[1]] = true
|
||||||
|
case in && len(f) >= 2:
|
||||||
|
m.Requires[f[0]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Path == "" {
|
||||||
|
return fmt.Errorf("%s names no module", p)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func within(path, module string) bool { return path == module || strings.HasPrefix(path, module+"/") }
|
||||||
|
|
||||||
|
// Findings reports, for every go.mod under the report roots, each imported package whose module the go.mod neither is nor
|
||||||
|
// requires; known are the other roots that say which module owns a path.
|
||||||
|
func Findings(report, known []string) ([]string, error) {
|
||||||
|
var owners []string
|
||||||
|
var reported []Mod
|
||||||
|
for _, r := range report {
|
||||||
|
ms, err := Modules(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reported = append(reported, ms...)
|
||||||
|
}
|
||||||
|
for _, r := range append(append([]string{}, report...), known...) {
|
||||||
|
ms, err := Modules(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, m := range ms {
|
||||||
|
owners = append(owners, m.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, m := range reported {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
err := filepath.WalkDir(m.Dir, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
switch d.Name() {
|
||||||
|
case "testdata", "vendor", "node_modules", ".git":
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
if p != m.Dir {
|
||||||
|
if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil {
|
||||||
|
return fs.SkipDir // a nested module is judged by its own go.mod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(p, ".go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := parser.ParseFile(token.NewFileSet(), p, nil, parser.ImportsOnly)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", p, err)
|
||||||
|
}
|
||||||
|
for _, im := range f.Imports {
|
||||||
|
path, _ := strconv.Unquote(im.Path.Value)
|
||||||
|
if !strings.HasPrefix(path, prefix) || seen[path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
owner := ""
|
||||||
|
for _, o := range owners {
|
||||||
|
if within(path, o) && len(o) > len(owner) {
|
||||||
|
owner = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
if owner != "" {
|
||||||
|
ok = owner == m.Path || m.Requires[owner]
|
||||||
|
} else {
|
||||||
|
ok = within(path, m.Path)
|
||||||
|
for r := range m.Requires {
|
||||||
|
ok = ok || within(path, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
seen[path] = true
|
||||||
|
if owner == "" {
|
||||||
|
owner = "(its module)"
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(m.Dir, p)
|
||||||
|
out = append(out, fmt.Sprintf("%s: %s imports %s (module %s), which its go.mod does not require", filepath.Join(m.Dir, "go.mod"), rel, path, owner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
args := os.Args[1:]
|
||||||
|
all := len(args) > 0 && args[0] == "-all"
|
||||||
|
if all {
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: requiredmodules [-all] <root> [<root> ...]")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
report, known := args[:1], args[1:]
|
||||||
|
if all {
|
||||||
|
report, known = args, nil
|
||||||
|
}
|
||||||
|
found, err := Findings(report, known)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
fmt.Println("every module requires the mesh modules it imports")
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tree(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for p, c := range files {
|
||||||
|
full := filepath.Join(root, p)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(c), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
const aMod = "module git.novox.be/novox/r/a\n\ngo 1.22\n"
|
||||||
|
const bMod = "module git.novox.be/novox/r/b/sub\n\ngo 1.22\n"
|
||||||
|
|
||||||
|
// A module that imports a package of a mesh module its go.mod does not require is refused, in a test file and behind a
|
||||||
|
// build tag too; the module's own packages, a required module's and a nested module's (only when required) are not.
|
||||||
|
func TestAModuleImportsOnlyWhatItRequires(t *testing.T) {
|
||||||
|
src := func(imp string) string { return "package x\n\nimport _ \"" + imp + "\"\n" }
|
||||||
|
for _, row := range []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"its own package", map[string]string{"a/go.mod": aMod, "a/x.go": src("git.novox.be/novox/r/a/other")}, ""},
|
||||||
|
{"a required module", map[string]string{"a/go.mod": aMod + "\nrequire git.novox.be/novox/r/b/sub v0.0.0\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, ""},
|
||||||
|
{"a required module in a block", map[string]string{"a/go.mod": aMod + "\nrequire (\n\tgit.novox.be/novox/r/b/sub v0.0.0 // indirect\n)\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, ""},
|
||||||
|
{"a module not required", map[string]string{"a/go.mod": aMod, "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x.go imports git.novox.be/novox/r/b/sub/pkg"},
|
||||||
|
{"a test file", map[string]string{"a/go.mod": aMod, "a/x_test.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x_test.go imports"},
|
||||||
|
{"an integration file", map[string]string{"a/go.mod": aMod, "a/x.go": "//go:build integration\n\n" + src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x.go imports"},
|
||||||
|
{"a module the longest owner is not", map[string]string{"a/go.mod": aMod + "\nrequire git.novox.be/novox/r/b v0.0.0\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": "module git.novox.be/novox/r/b\n", "b/sub/go.mod": bMod}, "does not require"},
|
||||||
|
{"a testdata fixture", map[string]string{"a/go.mod": aMod, "a/testdata/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "a/x.go": "package x\n"}, ""},
|
||||||
|
{"an outside module", map[string]string{"a/go.mod": aMod, "a/x.go": src("github.com/x/y")}, ""},
|
||||||
|
} {
|
||||||
|
t.Run(row.name, func(t *testing.T) {
|
||||||
|
got, err := Findings([]string{tree(t, row.files)}, nil)
|
||||||
|
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 script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the repositories cloned beside it, when
|
||||||
|
// the check says where, tell which module owns a path.
|
||||||
|
func TestEveryCatalogueModuleRequiresWhatItImports(t *testing.T) {
|
||||||
|
var known []string
|
||||||
|
if beside := os.Getenv("MESH_CHECK_BESIDE"); beside != "" {
|
||||||
|
es, _ := os.ReadDir(beside)
|
||||||
|
for _, e := range es {
|
||||||
|
if e.IsDir() && e.Name() != "mesh-catalog" {
|
||||||
|
known = append(known, filepath.Join(beside, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := Findings([]string{filepath.Join("..", "..")}, known)
|
||||||
|
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 (a glob, or a directory read whole;
|
||||||
|
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
|
||||||
|
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
|
||||||
|
# every go.mod and Go source of the modules and of the checks, whose imports and requires are read.
|
||||||
|
modules
|
||||||
|
checks
|
||||||
@@ -43,7 +43,7 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The builder's keys are read through its published package, taken by its series (ADR 0634 §1).
|
// The builder's keys are read through its published package, taken by its series (ADR 0634 §1).
|
||||||
if m.Packages["builds"] != "0.1" {
|
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
|
||||||
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
||||||
}
|
}
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"module": "artifact-store-tools",
|
"module": "artifact-store-tools",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"packages": {
|
"packages": {
|
||||||
"builds": "0.1"
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
|
||||||
},
|
},
|
||||||
"claims": [
|
"claims": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
|||||||
|
|
||||||
| what | how |
|
| what | how |
|
||||||
|---|---|
|
|---|---|
|
||||||
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
|
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206), and said whole as the event `holdings-changed` at start, on every change and again when the licence manager says `holdings.asked` (design 39 bus-and-switch §23) |
|
||||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||||
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
| what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence.<key>.access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) |
|
||||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||||
@@ -150,8 +150,8 @@ record is how a habit that found a way round is seen.
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
|
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull` (asks the vault for this node's
|
||||||
manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
|
licence token now), `claude_code_grant` (for the licence manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
|
||||||
more).
|
more).
|
||||||
|
|
||||||
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
|
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
|
||||||
|
|||||||
@@ -457,13 +457,9 @@ func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing
|
|||||||
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
|
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
|
||||||
p, w := node(t, "anchor")
|
p, w := node(t, "anchor")
|
||||||
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
||||||
ask := func(address string, args any) (json.RawMessage, error) {
|
var asked []string
|
||||||
key := args.(map[string]any)["public_key"].(string)
|
ask := vaulted(t, Current{Licence: "personal", Key: "personal", Kind: "subscription", Generation: 4,
|
||||||
g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000})
|
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}, "at", &asked)
|
||||||
box, _ := Seal(string(g), key)
|
|
||||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box,
|
|
||||||
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}})
|
|
||||||
}
|
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -282,31 +283,52 @@ func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
|
// seat answers `current` as the manager does, the licence and its key and never a token, and `get` as the vault does:
|
||||||
|
// the licence's access secret sealed to the key the node sent (design 39 bus-and-switch §18, §19).
|
||||||
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
||||||
|
return vaulted(t, Current{Licence: licence, Key: licence, Kind: "subscription", Generation: gen}, token, asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaulted answers `current` with c and the vault's `get` of c's access secret with token.
|
||||||
|
func vaulted(t *testing.T, c Current, token string, asked *[]string) Ask {
|
||||||
return func(address string, args any) (json.RawMessage, error) {
|
return func(address string, args any) (json.RawMessage, error) {
|
||||||
*asked = append(*asked, address)
|
*asked = append(*asked, address)
|
||||||
key := args.(map[string]any)["public_key"].(string)
|
a := args.(map[string]any)
|
||||||
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
|
switch address {
|
||||||
box, err := Seal(string(g), key)
|
case SeatVerb("current"):
|
||||||
if err != nil {
|
if _, sent := a["public_key"]; sent || a["consumer"] == nil {
|
||||||
t.Fatal(err)
|
t.Fatalf("current asked with %v", a)
|
||||||
|
}
|
||||||
|
return json.Marshal(c)
|
||||||
|
case VaultGet:
|
||||||
|
if a["name"] != AccessSecret(c.Key) {
|
||||||
|
t.Fatalf("the vault asked for %v", a["name"])
|
||||||
|
}
|
||||||
|
// Each answer a little later, as each rotation hands a later token.
|
||||||
|
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000 + int64(len(*asked))*1000})
|
||||||
|
box, err := Seal(string(g), a["public_key"].(string))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"name": a["name"], "generation": 1, "sealed": box})
|
||||||
}
|
}
|
||||||
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
|
t.Fatalf("asked %s", address)
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
// design 39 bus-and-switch §19: a newer generation asks the seat which licence, and the vault for its token.
|
||||||
|
func TestANewerGenerationFetchesTheTokenOnceFromTheVault(t *testing.T) {
|
||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
var asked []string
|
var asked []string
|
||||||
ask := seat(t, "personal", "at-1", 3, &asked)
|
ask := seat(t, "personal", "at-1", 3, &asked)
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
|
if len(asked) != 2 || asked[0] != SeatVerb("current") || asked[1] != VaultGet || creds(t, p)["accessToken"] != "at-1" {
|
||||||
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||||
}
|
}
|
||||||
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
|
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 2 {
|
||||||
t.Fatal("an equal generation asked again")
|
t.Fatal("an equal generation asked again")
|
||||||
}
|
}
|
||||||
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
||||||
@@ -497,30 +519,90 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
|
// design 39 bus-and-switch §19, B11: only a newer generation than the one applied is fetched; a lower one is not, and
|
||||||
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
|
// the vault's secret-changed for the licence held asks its token again, for another secret nothing.
|
||||||
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
|
func TestTheTokenIsAskedOnANewerGenerationAndOnTheVaultsChange(t *testing.T) {
|
||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
var asked []string
|
var asked []string
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
|
||||||
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
|
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The rotation that came with it: a later token, as a refresh hands one over.
|
if done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
|
||||||
later := func(address string, args any) (json.RawMessage, error) {
|
seat(t, "personal", "at-low", 3, &asked), writer(w)); err != nil || done != "" || len(asked) != 2 {
|
||||||
asked = append(asked, address)
|
t.Fatalf("a lower generation was fetched: %q %v, asked %v", done, err, asked)
|
||||||
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
|
|
||||||
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
|
|
||||||
}
|
}
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
|
rotated := seat(t, "personal", "at-new", 16, &asked)
|
||||||
later, writer(w)); err != nil {
|
for _, other := range []string{`{"name":"licence.personal.grant","generation":5}`, `{"name":"licence.other.access","generation":5}`} {
|
||||||
|
if done, err := OnSecretChanged(p, []byte(other), rotated, writer(w)); err != nil || done != "" || len(asked) != 2 {
|
||||||
|
t.Fatalf("%s asked again: %q %v, asked %v", other, done, err, asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := OnSecretChanged(p, []byte(`{"name":"licence.personal.access","generation":5,"by":"x","at":"2026-11-11T00:00:00Z"}`),
|
||||||
|
rotated, writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
|
if len(asked) != 4 || asked[3] != VaultGet || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 16 {
|
||||||
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
|
t.Fatalf("asked %v, credentials %v: the vault's change was not applied", asked, creds(t, p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §22/AC1, B22: binding.changed, decoded from a copy of the manager's form, is acted on only
|
||||||
|
// when it names this node's consumer; an event for another consumer is ignored.
|
||||||
|
func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) {
|
||||||
|
p, w := node(t, "server")
|
||||||
|
var asked []string
|
||||||
|
ask := seat(t, "api", "at-3", 3, &asked)
|
||||||
|
said, err := os.ReadFile("testdata/binding-changed.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || done != "" || len(asked) != 0 {
|
||||||
|
t.Fatalf("an event for laptop was acted on by server: %q %v, asked %v", done, err, asked)
|
||||||
|
}
|
||||||
|
p, w = node(t, "laptop")
|
||||||
|
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 2 {
|
||||||
|
t.Fatalf("laptop's own event: %q %v, asked %v", done, err, asked)
|
||||||
|
}
|
||||||
|
if done, _ := OnBindingChanged(p, []byte(`{"consumer":"laptop","licence":"","generation":3,"from":"api"}`), ask, writer(w)); !strings.Contains(done, "released") {
|
||||||
|
t.Fatalf("a release said %q", done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §22, B10: at its start the module asks the seat's bindings for its own consumer and applies
|
||||||
|
// that first view; its manifest consumes binding.changed and no longer reads the manager's state.
|
||||||
|
func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
var asked []string
|
||||||
|
current := vaulted(t, Current{Licence: "api", Key: "api", Kind: "subscription", Generation: 9}, "at-9", &asked)
|
||||||
|
ask := func(address string, args any) (json.RawMessage, error) {
|
||||||
|
if address == SeatVerb("bindings") {
|
||||||
|
asked = append(asked, address)
|
||||||
|
if args.(map[string]any)["consumer"] != "laptop" {
|
||||||
|
t.Fatalf("bindings asked with %v", args)
|
||||||
|
}
|
||||||
|
return json.RawMessage(`[{"consumer":"laptop","licence":"api","key":"api","kind":"api-key","generation":9,"why":"carried over","by":"claude-licence-manager"}]`), nil
|
||||||
|
}
|
||||||
|
return current(address, args)
|
||||||
|
}
|
||||||
|
if done, err := FirstBinding(p, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":9`) {
|
||||||
|
t.Fatalf("the first view: %q %v, asked %v", done, err, asked)
|
||||||
|
}
|
||||||
|
if len(asked) != 3 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" {
|
||||||
|
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||||
|
}
|
||||||
|
// §19: at every start the vault is asked, though the binding is the one applied.
|
||||||
|
if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet {
|
||||||
|
t.Fatalf("a start asked %v", asked)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile("../../module.json")
|
||||||
|
var m struct {
|
||||||
|
Consumes, Invokes []string
|
||||||
|
Reads []string
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Consumes, SecretChanged) ||
|
||||||
|
!slices.Contains(m.Invokes, SeatVerb("bindings")) || !slices.Contains(m.Invokes, VaultGet) || len(m.Reads) != 0 {
|
||||||
|
t.Fatalf("the manifest consumes %v, invokes %v, reads %v", m.Consumes, m.Invokes, m.Reads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,10 +276,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_ = json.Unmarshal(raw, &m)
|
_ = json.Unmarshal(raw, &m)
|
||||||
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
|
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
|
||||||
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
|
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The
|
||||||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) ||
|
// licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9).
|
||||||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
|
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet}) ||
|
||||||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) {
|
len(m.Reads) != 0 ||
|
||||||
|
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged, SecretChanged}) {
|
||||||
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
|
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
|
||||||
}
|
}
|
||||||
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
|
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as
|
||||||
|
// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again
|
||||||
|
// when the licence manager says `holdings.asked`.
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// HoldingsChanged is the event that says what a node holds.
|
||||||
|
const HoldingsChanged = "holdings-changed"
|
||||||
|
|
||||||
|
// HoldingsAsked is the licence manager's event asking every node to say what it holds.
|
||||||
|
const HoldingsAsked = Manager + ".holdings.asked"
|
||||||
|
|
||||||
|
// HoldingsSayer keeps and says what this node holds.
|
||||||
|
type HoldingsSayer struct {
|
||||||
|
Put func(Holdings) error
|
||||||
|
Emit func(Holdings) error
|
||||||
|
said string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A
|
||||||
|
// failure says nothing was said, so the next call tries again.
|
||||||
|
func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) {
|
||||||
|
raw, _ := json.Marshal(now)
|
||||||
|
if !again && string(raw) == s.said {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := s.Put(now); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := s.Emit(now); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
s.said = string(raw)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the
|
||||||
|
// module's own state each time, the report whole and never a token.
|
||||||
|
func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) {
|
||||||
|
var kept, said []string
|
||||||
|
failing := false
|
||||||
|
s := &HoldingsSayer{
|
||||||
|
Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil },
|
||||||
|
Emit: func(h Holdings) error {
|
||||||
|
if failing {
|
||||||
|
return errors.New("no responders")
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(h)
|
||||||
|
said = append(said, string(raw))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fp := "sha256:0123456789abcdef"
|
||||||
|
start := Holdings{Node: "laptop"}
|
||||||
|
changed := Holdings{Node: "laptop"}
|
||||||
|
changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp
|
||||||
|
for i, step := range []struct {
|
||||||
|
h Holdings
|
||||||
|
again, out bool
|
||||||
|
}{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} {
|
||||||
|
if got, err := s.Say(step.h, step.again); err != nil || got != step.out {
|
||||||
|
t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(said) != 3 || len(kept) != 3 {
|
||||||
|
t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept))
|
||||||
|
}
|
||||||
|
if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) {
|
||||||
|
t.Fatalf("the report is not said whole: %s", said[1])
|
||||||
|
}
|
||||||
|
failing = true
|
||||||
|
if got, err := s.Say(start, false); err == nil || got {
|
||||||
|
t.Fatal("a failed event counts as said")
|
||||||
|
}
|
||||||
|
failing = false
|
||||||
|
if got, _ := s.Say(start, false); !got {
|
||||||
|
t.Fatal("what failed to be said is not said at the next call")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,7 +109,9 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
|||||||
t.Errorf("replacements: %s", got)
|
t.Errorf("replacements: %s", got)
|
||||||
}
|
}
|
||||||
t.Run("the again tool answers the operator and the healers only", func(t *testing.T) {
|
t.Run("the again tool answers the operator and the healers only", func(t *testing.T) {
|
||||||
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.mesh-healers": false, "shanks.mesh-config": true} {
|
// An operator's session through the mesh MCP server is proved as its machine's tool runner (issue 627, issue 1966).
|
||||||
|
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.node-tools": false, "shanks.mesh-healers": false,
|
||||||
|
"shanks.mesh-config": true} {
|
||||||
if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
|
if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
|
||||||
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
|
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ func instructionsText(node, role string) string {
|
|||||||
node +
|
node +
|
||||||
"`\n- **Role:** " +
|
"`\n- **Role:** " +
|
||||||
role +
|
role +
|
||||||
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||||
@@ -345,7 +346,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
|||||||
return GuardStatus(p, last), nil
|
return GuardStatus(p, last), nil
|
||||||
}},
|
}},
|
||||||
{Name: "claude_code_pull",
|
{Name: "claude_code_pull",
|
||||||
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
Description: "Ask the licence manager which licence this node holds and the vault for its access token now, and apply it, rather than waiting for its binding or the token to change.",
|
||||||
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
||||||
{Name: "claude_code_grant",
|
{Name: "claude_code_grant",
|
||||||
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
||||||
@@ -567,15 +568,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
|||||||
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
||||||
// the old inode would go quiet. Fingerprints and expiries only.
|
// the old inode would go quiet. Fingerprints and expiries only.
|
||||||
holdings := stdio.State("holdings")
|
holdings := stdio.State("holdings")
|
||||||
reported := ""
|
var sayMu sync.Mutex
|
||||||
report := func() {
|
sayer := &HoldingsSayer{
|
||||||
|
Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err },
|
||||||
|
Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) },
|
||||||
|
}
|
||||||
|
sayHoldings := func(again bool) {
|
||||||
|
sayMu.Lock()
|
||||||
|
defer sayMu.Unlock()
|
||||||
now := HoldingsOf(p)
|
now := HoldingsOf(p)
|
||||||
raw, _ := json.Marshal(now)
|
said := false
|
||||||
if string(raw) == reported {
|
persist("reporting what this node holds", func() error {
|
||||||
return
|
var err error
|
||||||
}
|
said, err = sayer.Say(now, again)
|
||||||
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
return err
|
||||||
reported = string(raw)
|
}, func(int) {
|
||||||
|
if !said {
|
||||||
|
return
|
||||||
|
}
|
||||||
account := "no account"
|
account := "no account"
|
||||||
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
||||||
account = now.Identity.EmailAddress
|
account = now.Identity.EmailAddress
|
||||||
@@ -593,30 +603,50 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
|||||||
say("%s", line)
|
say("%s", line)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
report := func() { sayHoldings(false) }
|
||||||
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
// The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on
|
||||||
// generation is fetched with the seat's `current`, sealed to this module's key.
|
// its resync.
|
||||||
go persist("watching this node's licence binding", func() error {
|
go persist("hearing the licence manager ask what this node holds", func() error {
|
||||||
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error {
|
||||||
if c.Key != p.Node {
|
go sayHoldings(true)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var b *BindingState
|
|
||||||
if c.Op == "put" {
|
|
||||||
b = &BindingState{}
|
|
||||||
if err := json.Unmarshal(c.Value, b); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
|
||||||
say("fetching this node's token failed: %v", err)
|
|
||||||
} else if done != "" {
|
|
||||||
say("%s", done)
|
|
||||||
}
|
|
||||||
go report()
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
}, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) })
|
||||||
|
|
||||||
|
// What this node should hold (design 39 bus-and-switch §19, §22): the manager's binding.changed naming this node's
|
||||||
|
// consumer, and its first view, the seat's bindings for this consumer, asked once each time the events are heard
|
||||||
|
// anew (the start, or a reconnect); the token is asked from the vault at that first view and on a newer generation.
|
||||||
|
applied := func(done string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
say("fetching this node's token failed: %v", err)
|
||||||
|
} else if done != "" {
|
||||||
|
say("%s", done)
|
||||||
|
}
|
||||||
|
go report()
|
||||||
|
}
|
||||||
|
go persist("hearing this node's licence binding", func() error {
|
||||||
|
err := stdio.Subscribe(BindingChanged, func(e stdio.Envelope) error {
|
||||||
|
applied(OnBindingChanged(p, e.Body, ask, writeManaged))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
go persist("asking this node's licence binding", func() error {
|
||||||
|
done, err := FirstBinding(p, ask, writeManaged)
|
||||||
|
if err == nil {
|
||||||
|
applied(done, nil)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}, func(int) {})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}, func(n int) { say("hearing this node's licence binding%s", refusals(n)) })
|
||||||
|
// The token of the licence this node holds, asked again when the vault says its access secret changed (§19).
|
||||||
|
go persist("hearing the vault's changes of this node's licence token", func() error {
|
||||||
|
return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error {
|
||||||
|
applied(OnSecretChanged(p, e.Body, ask, writeManaged))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}, func(n int) { say("hearing %s%s", SecretChanged, refusals(n)) })
|
||||||
|
|
||||||
report()
|
report()
|
||||||
var last string
|
var last string
|
||||||
@@ -667,8 +697,10 @@ func refusals(n int) string {
|
|||||||
return fmt.Sprintf(" (after %d refusal(s))", n)
|
return fmt.Sprintf(" (after %d refusal(s))", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7).
|
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7). A call through
|
||||||
var seatListAgainCallers = []string{stdio.AnyPerson, "mesh-healers"}
|
// the mesh MCP server is proved only as its machine's tool runner (<node>.node-tools, issue 627), so node-tools stands for
|
||||||
|
// an operator's session until a session is proved (issue 1966).
|
||||||
|
var seatListAgainCallers = []string{stdio.AnyPerson, "node-tools", "mesh-healers"}
|
||||||
|
|
||||||
// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The
|
// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The
|
||||||
// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called.
|
// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called.
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ package main
|
|||||||
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
||||||
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
||||||
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
||||||
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
// - What this node should hold is the manager's binding, heard as `binding.changed` and asked at start; the
|
||||||
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
// seat's `current` says which licence, and its `access` secret is asked from the vault, sealed to this
|
||||||
|
// module's key, at start, on a newer generation and on the vault's `secret-changed` for it, and written
|
||||||
|
// access-token-only (design 39 bus-and-switch §19).
|
||||||
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
||||||
// every node, `<node>.<server>` for one — which every node watches.
|
// every node, `<node>.<server>` for one — which every node watches.
|
||||||
|
|
||||||
@@ -30,12 +32,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
// Seat is the licence manager's role, and Manager the module whose events this one consumes.
|
||||||
const (
|
const (
|
||||||
Seat = "anthropic-licence-manager"
|
Seat = "anthropic-licence-manager"
|
||||||
Manager = "claude-licence-manager"
|
Manager = "claude-licence-manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// VaultGet is the vault's verb a licence's access secret is asked by, and SecretChanged the vault's event that it
|
||||||
|
// changed (design 39 bus-and-switch §19, B9).
|
||||||
|
const (
|
||||||
|
VaultGet = "seat:mesh-vault.get"
|
||||||
|
SecretChanged = "mesh-vault.secret-changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessSecret is the name of a licence's access secret in the vault, by the licence's key as the manager says it
|
||||||
|
// (§15, B7: the key is carried, never formed here).
|
||||||
|
func AccessSecret(key string) string { return "licence." + key + ".access" }
|
||||||
|
|
||||||
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
||||||
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
||||||
|
|
||||||
@@ -309,13 +322,14 @@ type BindingState struct {
|
|||||||
By string `json:"by,omitempty"`
|
By string `json:"by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
// Current is what the seat answers to `current`: the licence this node is bound to, its key and its account, never
|
||||||
|
// its token (§18, B7).
|
||||||
type Current struct {
|
type Current struct {
|
||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
Kind string `json:"kind"`
|
Key string `json:"key"`
|
||||||
Generation int64 `json:"generation"`
|
Kind string `json:"kind"`
|
||||||
Sealed *SealedBox `json:"sealed"`
|
Generation int64 `json:"generation"`
|
||||||
Identity *Identity `json:"identity"`
|
Identity *Identity `json:"identity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
||||||
@@ -404,40 +418,50 @@ func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
|||||||
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull asks the seat for this node's current token and applies it.
|
// Pull asks the seat which licence this node holds, then the vault for that licence's access secret, sealed to this
|
||||||
|
// module's key, and applies it (§19).
|
||||||
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
||||||
keys, err := Keypair(p)
|
keys, err := Keypair(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var c Current
|
var c Current
|
||||||
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
if err := json.Unmarshal(raw, &c); err != nil || c.Licence == "" || c.Key == "" {
|
||||||
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
||||||
}
|
}
|
||||||
return Apply(p, c, write)
|
raw, err = ask(VaultGet, map[string]any{"name": AccessSecret(c.Key), "public_key": keys.PublicKey})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Sealed *SealedBox `json:"sealed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &got); err != nil || got.Sealed == nil {
|
||||||
|
return nil, fmt.Errorf("the vault answered no sealed %s", AccessSecret(c.Key))
|
||||||
|
}
|
||||||
|
return Apply(p, c, *got.Sealed, write)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
// OnBinding takes this node's binding as the manager said it: the token is fetched when its generation is newer than
|
||||||
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
|
// the one applied (§19). A home that holds no token of the applied kind has nothing applied, so it is fetched too. A
|
||||||
// lives hours, and says so.
|
// released binding keeps the last token, which lives hours, and says so.
|
||||||
//
|
|
||||||
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
|
|
||||||
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
|
|
||||||
// a node that waited for a number above its own ignored every binding it was sent, its login and the
|
|
||||||
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
|
|
||||||
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return "this node's binding was released; it keeps its last token until it expires", nil
|
return "this node's binding was released; it keeps its last token until it expires", nil
|
||||||
}
|
}
|
||||||
var applied Binding
|
var applied Binding
|
||||||
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence &&
|
if readJSON(p.binding(), &applied) && b.Generation <= applied.Generation && holdsApplied(p, applied.Kind) {
|
||||||
holdsApplied(p, applied.Kind) {
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
return fetched(p, b, ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetched pulls this node's token for binding b and says the line naming it.
|
||||||
|
func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||||
out, err := Pull(p, ask, write)
|
out, err := Pull(p, ask, write)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -455,14 +479,86 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
|||||||
return string(raw), nil
|
return string(raw), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
// BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this
|
||||||
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
// module consumes (§22).
|
||||||
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
const BindingChanged = Manager + ".binding.changed"
|
||||||
|
|
||||||
|
// bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an
|
||||||
|
// empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json).
|
||||||
|
type bindingOf struct {
|
||||||
|
Consumer string `json:"consumer"`
|
||||||
|
BindingState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bindingOf) state() *BindingState {
|
||||||
|
if b.Licence == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &b.BindingState
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnBindingChanged takes one binding.changed: acted on only when it names this node's consumer (§22).
|
||||||
|
func OnBindingChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
|
||||||
|
var b bindingOf
|
||||||
|
if err := json.Unmarshal(body, &b); err != nil {
|
||||||
|
return "", fmt.Errorf("%s is not a binding's change: %w", BindingChanged, err)
|
||||||
|
}
|
||||||
|
if b.Consumer != p.Node {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return OnBinding(p, b.state(), ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstBinding asks the seat's verb bindings for this node's consumer, its first view (§22, B10), and applies it: at
|
||||||
|
// the module's start, and again whenever it hears the manager's events anew.
|
||||||
|
func FirstBinding(p Paths, ask Ask, write WriteManaged) (string, error) {
|
||||||
|
raw, err := ask(SeatVerb("bindings"), map[string]any{"consumer": p.Node})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var listed []bindingOf
|
||||||
|
if err := json.Unmarshal(raw, &listed); err != nil {
|
||||||
|
return "", fmt.Errorf("the seat's bindings answered what is not a list of bindings: %w", err)
|
||||||
|
}
|
||||||
|
for _, b := range listed {
|
||||||
|
if b.Consumer == p.Node && b.state() != nil {
|
||||||
|
// At its start the module asks the vault whatever it applied before (§19).
|
||||||
|
return fetched(p, b.state(), ask, write)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OnBinding(p, nil, ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretChanged is the vault's `secret-changed` as it says it: the secret's name and generation, never its value.
|
||||||
|
type secretChanged struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnSecretChanged takes one `secret-changed`: acted on only when it names the access secret of the licence this node
|
||||||
|
// holds, which is then asked again (§19).
|
||||||
|
func OnSecretChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
|
||||||
|
var e secretChanged
|
||||||
|
if err := json.Unmarshal(body, &e); err != nil {
|
||||||
|
return "", fmt.Errorf("%s is not a secret's change: %w", SecretChanged, err)
|
||||||
|
}
|
||||||
|
var applied Binding
|
||||||
|
if !readJSON(p.binding(), &applied) || applied.Key == "" || e.Name != AccessSecret(applied.Key) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return fetched(p, &BindingState{Licence: applied.Licence, Kind: applied.Kind, Generation: applied.Generation,
|
||||||
|
Why: "the vault's " + e.Name + " changed"}, ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies the access secret the vault handed over for the licence the seat named. A switch replaces the grant
|
||||||
|
// whole and cleans up after the old licence; whatever it is, the file is written without a refresh token, so the
|
||||||
|
// agent here never refreshes.
|
||||||
|
func Apply(p Paths, c Current, sealed SealedBox, write WriteManaged) (map[string]any, error) {
|
||||||
keys, err := Keypair(p)
|
keys, err := Keypair(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
plain, err := Open(sealed, keys.PrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -522,7 +618,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
|||||||
if gen == 0 {
|
if gen == 0 {
|
||||||
gen = previous.Generation
|
gen = previous.Generation
|
||||||
}
|
}
|
||||||
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
raw, _ := json.Marshal(Binding{Licence: c.Licence, Key: c.Key, Kind: c.Kind, Generation: gen})
|
||||||
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ type Settings struct {
|
|||||||
|
|
||||||
// Binding is the licence this node holds, as it was last applied.
|
// Binding is the licence this node holds, as it was last applied.
|
||||||
type Binding struct {
|
type Binding struct {
|
||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
|
// Key is the licence's key as the manager says it, which names its access secret in the vault (§19, B7).
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
Kind string `json:"kind"` // subscription | api-key
|
Kind string `json:"kind"` // subscription | api-key
|
||||||
Generation int64 `json:"generation,omitempty"`
|
Generation int64 `json:"generation,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"}
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
"withKey": {
|
"withKey": {
|
||||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
||||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
||||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||||
},
|
},
|
||||||
"plain": {
|
"plain": {
|
||||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
||||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
||||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,14 +28,20 @@
|
|||||||
"seat:mesh-catalog.seat-definitions",
|
"seat:mesh-catalog.seat-definitions",
|
||||||
"mesh-delivery-indexer.indexer_read_modules",
|
"mesh-delivery-indexer.indexer_read_modules",
|
||||||
"mesh-delivery-indexer.indexer_read_manifests",
|
"mesh-delivery-indexer.indexer_read_manifests",
|
||||||
"mesh-delivery-assigner.assigner_read_assignments"
|
"mesh-delivery-assigner.assigner_read_assignments",
|
||||||
|
"seat:anthropic-licence-manager.bindings",
|
||||||
|
"seat:anthropic-licence-manager.current",
|
||||||
|
"seat:mesh-vault.get"
|
||||||
],
|
],
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"mesh-delivery-indexer.views.modules",
|
"mesh-delivery-indexer.views.modules",
|
||||||
"mesh-delivery-indexer.views.manifests",
|
"mesh-delivery-indexer.views.manifests",
|
||||||
"mesh-delivery-assigner.views.assignments",
|
"mesh-delivery-assigner.views.assignments",
|
||||||
"mesh-catalog.seat-definition-changed",
|
"mesh-catalog.seat-definition-changed",
|
||||||
"mesh-catalog.seat-definition-removed"
|
"mesh-catalog.seat-definition-removed",
|
||||||
|
"claude-licence-manager.holdings.asked",
|
||||||
|
"claude-licence-manager.binding.changed",
|
||||||
|
"mesh-vault.secret-changed"
|
||||||
],
|
],
|
||||||
"state": [
|
"state": [
|
||||||
"servers",
|
"servers",
|
||||||
@@ -48,10 +54,8 @@
|
|||||||
"findings"
|
"findings"
|
||||||
],
|
],
|
||||||
"emits": [
|
"emits": [
|
||||||
"judged"
|
"judged",
|
||||||
],
|
"holdings-changed"
|
||||||
"reads": [
|
|
||||||
"claude-licence-manager.bindings"
|
|
||||||
],
|
],
|
||||||
"tools": [
|
"tools": [
|
||||||
"claude_code_status",
|
"claude_code_status",
|
||||||
|
|||||||
@@ -1,58 +1,103 @@
|
|||||||
# claude-licence-manager
|
# claude-licence-manager
|
||||||
|
|
||||||
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
||||||
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
|
rotation source, its consumers moved off a licence near its limit by the manager itself (novox/hq ADR 0183,
|
||||||
|
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
|
||||||
|
keeps none.
|
||||||
|
|
||||||
## How a licence comes to exist
|
## How a licence comes to exist
|
||||||
|
|
||||||
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
|
Nothing is configured. Every node running `claude-code` says what it holds as the event
|
||||||
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
|
`claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change,
|
||||||
when it starts and watches them:
|
and again when this module says `holdings.asked` (at every start, after the carry-over, and on `licences_resync`):
|
||||||
|
|
||||||
1. A report with a refresh token it does not hold is a **candidate**.
|
1. A report with a refresh token it does not hold is a **candidate**.
|
||||||
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
|
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
|
||||||
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
|
3. **It refreshes it**, under the account's lease. If the vendor exchanges the token, the licence is new, named by its
|
||||||
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
|
account's address and kept under that name's key (`@` as `_at_`, any other character outside letters, digits, `-`
|
||||||
is recorded dead and nothing is adopted.
|
and `.` as `_`); its grant and access token go to the vault (`licence-put`), and from then on this module is the
|
||||||
|
only refresher. If not, the candidate is recorded dead and nothing is adopted. An adoption whose key another licence
|
||||||
|
has is refused.
|
||||||
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
|
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
|
||||||
5. A node reporting that account and bound to nothing is bound to it.
|
5. The node it came from is bound to it; a node reporting that account and bound to nothing is bound to it.
|
||||||
|
|
||||||
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
|
From its next reading a new subscription has a room and is a target of the switch. An API key enters through `adopt`,
|
||||||
appearing on a node later can only be a person's login — which wins if it refreshes.
|
from a file on this module's node or sealed by a node's `claude_code_add_api_key`; it is never a target.
|
||||||
|
|
||||||
An API key enters through `adopt`, from a file on this module's node.
|
## What it keeps on the bus
|
||||||
|
|
||||||
## What each consumer holds
|
Each piece asked for in `requires` by its template (ADR 0595), read by `licences_kept`:
|
||||||
|
|
||||||
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
|
| name | template | what |
|
||||||
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
|
|---|---|---|
|
||||||
generation, asks `current` with its public key.
|
| `licences` | state, history 1 | one key per licence: account, address, organisation, expiries, failures, adoption, the vault's generation, the latest reading; never a token |
|
||||||
|
| `bindings` | state, history 8 | one key per consumer (a node's name): licence, kind, generation, since, why and by |
|
||||||
|
| `leases` | state, a lifetime per key | `licence.<key>`, `account.<id>`: one refresh or adoption at a time, two minutes |
|
||||||
|
| `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days |
|
||||||
|
| `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole |
|
||||||
|
| `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) |
|
||||||
|
| `carried` | state | `postgres`: when the carry-over ran, and per table the rows read and entries written |
|
||||||
|
| `conditions` | state | each condition said raised, and since when |
|
||||||
|
|
||||||
|
Its database (`postgres-database`, read with the module's own credential) and `grant.key` are kept only as the
|
||||||
|
carry-over's source: at its first start, before it hears an event, refreshes or reads usage, the manager reads every
|
||||||
|
row of today's six tables and writes them here and to the vault, then `carried.postgres`. Until then it serves its
|
||||||
|
reads alone and answers every act with "the carry-over has not ended"; a failed read or write raises
|
||||||
|
`carry-over-failed`. With `carried.postgres` present it never reads the database again. The database is never written.
|
||||||
|
|
||||||
|
## Switching on usage
|
||||||
|
|
||||||
|
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
|
||||||
|
weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly
|
||||||
|
percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
|
||||||
|
a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence
|
||||||
|
with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one
|
||||||
|
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
|
||||||
|
|
||||||
|
Its conditions are said on the operator channel's condition subject, the router deciding where they go:
|
||||||
|
`licence.<key>.no-room` (urgent; no licence has room), `licence.<key>.near-limit` (at `warn_at_pct`, once per
|
||||||
|
crossing), `licence.<key>.usage-unread`, `carry-over-failed` and `settings.<setting>.invalid`.
|
||||||
|
|
||||||
## The seat's verbs
|
## The seat's verbs
|
||||||
|
|
||||||
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
|
`licences`, `bindings` (with `consumer` for one), `bind`, `switch`, `release`, `refresh`, `usage` (`licence`,
|
||||||
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
`since`, `limit` up to 2000, `raw`), `adopt`, `public-key`, `current` (the licence, its key, kind, generation and
|
||||||
|
health, never the token) — through the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
||||||
|
|
||||||
|
## Its tools
|
||||||
|
|
||||||
|
`licences_switches` (every switch and why), `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the
|
||||||
|
rule now, even with `switch_auto` off), `licences_settings` (each value, its default and where it was set),
|
||||||
|
`licences_kept` (every entry it keeps, how full against its cap), `licences_resync` (say every binding, holdings.asked
|
||||||
|
and each condition again, and hand each access token to the vault again), `licences_conditions`, `licences_carried`
|
||||||
|
(the carry-over's record; `run` while it has not ended).
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
|
Through mesh-config, each `trusted`, read again at every reading: `cadence_minutes` (240), `floor_minutes` (60),
|
||||||
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
|
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3), `switch_auto` (on), `switch_at_pct` (99,
|
||||||
|
1 to 100), `room_below_pct` (90, 1 to 100), `warn_at_pct` (90, 1 to 100), `usage_minutes` (5, 1 to 60),
|
||||||
|
`usage_stale_minutes` (15, 5 to 180). A value outside its bound keeps its default and raises its condition.
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
|
`licence.adopted`, `licence.refused`, `licence.switched`, `licence.failing`, `usage.read`, `binding.changed`,
|
||||||
|
`holdings.asked` — none carries a secret. It consumes `claude-code.holdings-changed`.
|
||||||
|
|
||||||
## Code and tests
|
## Code and tests
|
||||||
|
|
||||||
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
|
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs, the tools and the daemon in one launched bundle. The
|
||||||
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
|
sealed box is `claude-code`'s own format, byte for byte — the two modules carry the same `seal.go` — and a test opens
|
||||||
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
|
one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines.
|
||||||
Go one replaced, so the format is the one already on the machines.
|
|
||||||
|
|
||||||
go test ./...
|
go test ./...
|
||||||
# the store against a real postgres:
|
# the whole module on a real bus, and today's rows on a real postgres:
|
||||||
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
go test -tags integration ./...
|
||||||
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
|
||||||
|
The whole tests (`whole_integration_test.go`) start the built binary as its runtime does, its kept data in a real
|
||||||
|
nats-server, and today's database on `MESH_TEST_POSTGRES` or a private postgres started from the machine's `initdb`
|
||||||
|
on a socket of its own; with neither, the tests that need one skip and say so. The vault, the agent modules, the
|
||||||
|
operator channel and the vendor are played beside it; a condition is read through the operator channel's client.
|
||||||
|
|
||||||
## Files it does not know
|
## Files it does not know
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The manager's kept data on the bus (design 39 bus-and-switch §2, §3, B11, B22): the state `licences`, one key per
|
||||||
|
// licence by its key form (§10), and the state `bindings`, one key per consumer, its value the form the consumers
|
||||||
|
// have always read. Written by the manager alone.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keptState is a state this module keeps, as mesh-sdk's KeptState answers it.
|
||||||
|
type keptState interface {
|
||||||
|
Get(key string) (*stdio.StateEntry, error)
|
||||||
|
Put(key string, value any) (uint64, error)
|
||||||
|
PutTTL(key string, value any, ttl time.Duration) (uint64, error)
|
||||||
|
Create(key string, value any) (uint64, error)
|
||||||
|
CreateTTL(key string, value any, ttl time.Duration) (uint64, error)
|
||||||
|
Delete(key string) error
|
||||||
|
Keys() ([]string, error)
|
||||||
|
History(key string) ([]stdio.StateRevision, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindingState is the value of a consumer's key in `bindings` (§3, B22: the form 0 its readers decode): no secret,
|
||||||
|
// only what it should hold, which generation, since when, why and by whom.
|
||||||
|
type BindingState struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
Since string `json:"since,omitempty"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
By string `json:"by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusStore keeps the manager's data in the bus's own storage.
|
||||||
|
type BusStore struct {
|
||||||
|
LicencesState keptState
|
||||||
|
BindingsState keptState
|
||||||
|
LeasesState keptState
|
||||||
|
OfferedState keptState
|
||||||
|
UsageLog keptLog
|
||||||
|
AuditLog keptLog
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) get(st keptState, key string, into any) (bool, error) {
|
||||||
|
e, err := st.Get(key)
|
||||||
|
if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, json.Unmarshal(e.Value, into)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) Licences(context.Context) ([]Licence, error) {
|
||||||
|
keys, err := s.LicencesState.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Licence{}
|
||||||
|
for _, k := range keys {
|
||||||
|
var l Licence
|
||||||
|
if ok, err := s.get(s.LicencesState, k, &l); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) Licence(_ context.Context, name string) (*Licence, error) {
|
||||||
|
var l Licence
|
||||||
|
ok, err := s.get(s.LicencesState, KeyFor(name), &l)
|
||||||
|
if err != nil || !ok || l.Name != name {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
|
||||||
|
all, err := s.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, l := range all {
|
||||||
|
if l.AccountUUID == account {
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) SaveLicence(_ context.Context, l Licence) error {
|
||||||
|
if l.Name == "" {
|
||||||
|
return errors.New("a licence has a name")
|
||||||
|
}
|
||||||
|
_, err := s.LicencesState.Put(KeyFor(l.Name), l)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) Bindings(context.Context) ([]Binding, error) {
|
||||||
|
keys, err := s.BindingsState.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Binding{}
|
||||||
|
for _, k := range keys {
|
||||||
|
if b, err := s.binding(k); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if b != nil {
|
||||||
|
out = append(out, *b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) binding(consumer string) (*Binding, error) {
|
||||||
|
var v BindingState
|
||||||
|
ok, err := s.get(s.BindingsState, consumer, &v)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Binding{Consumer: consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Since: v.Since,
|
||||||
|
Why: v.Why, By: v.By}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) Binding(_ context.Context, consumer string) (*Binding, error) {
|
||||||
|
return s.binding(consumer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind writes a consumer's key at its next generation: one more than the higher of its own and the floor (B11).
|
||||||
|
func (s *BusStore) Bind(_ context.Context, consumer, licence string, m Move) (Binding, error) {
|
||||||
|
before, err := s.binding(consumer)
|
||||||
|
if err != nil {
|
||||||
|
return Binding{}, err
|
||||||
|
}
|
||||||
|
generation := m.floor(consumer)
|
||||||
|
if before != nil {
|
||||||
|
generation = max(generation, before.Generation)
|
||||||
|
}
|
||||||
|
b := Binding{Consumer: consumer, Licence: licence, Kind: m.Kind, Generation: generation + 1,
|
||||||
|
Since: s.Now().UTC().Format(time.RFC3339), Why: m.Why, By: m.By}
|
||||||
|
_, err = s.BindingsState.Put(consumer, BindingState{Licence: b.Licence, Kind: b.Kind, Generation: b.Generation,
|
||||||
|
Since: b.Since, Why: b.Why, By: b.By})
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) Unbind(_ context.Context, consumer string) (bool, error) {
|
||||||
|
before, err := s.binding(consumer)
|
||||||
|
if err != nil || before == nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, s.BindingsState.Delete(consumer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance gives every consumer bound to licence its next generation, keeping its licence and since.
|
||||||
|
func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Binding, error) {
|
||||||
|
all, err := s.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Binding
|
||||||
|
for _, b := range all {
|
||||||
|
if b.Licence != licence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.Generation = max(b.Generation, m.floor(b.Consumer)) + 1
|
||||||
|
b.Kind, b.Why, b.By = m.Kind, m.Why, m.By
|
||||||
|
if _, err := s.BindingsState.Put(b.Consumer, BindingState{Licence: b.Licence, Kind: b.Kind,
|
||||||
|
Generation: b.Generation, Since: b.Since, Why: b.Why, By: b.By}); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- leases and offers (§4, §5) ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// leaseValue is a lease's key in `leases`: its holder and until when.
|
||||||
|
type leaseValue struct {
|
||||||
|
Holder string `json:"holder"`
|
||||||
|
Until string `json:"until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's
|
||||||
|
// own compare-and-set. A key its own holder holds is taken again.
|
||||||
|
func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||||
|
v := leaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)}
|
||||||
|
_, err := s.LeasesState.CreateTTL(key, v, d)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, stdio.ErrChanged) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var held leaseValue
|
||||||
|
if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder {
|
||||||
|
return false, gerr
|
||||||
|
}
|
||||||
|
_, err = s.LeasesState.PutTTL(key, v, d)
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlease deletes a lease its holder holds; another's is left to it or its lifetime.
|
||||||
|
func (s *BusStore) Unlease(_ context.Context, key, holder string) error {
|
||||||
|
var held leaseValue
|
||||||
|
if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.LeasesState.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives.
|
||||||
|
const offeredFor = 90 * 24 * time.Hour
|
||||||
|
|
||||||
|
// offeredValue is a grant's key in `offered`: what became of it, where and when (§5, B13: every outcome).
|
||||||
|
type offeredValue struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13).
|
||||||
|
func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) }
|
||||||
|
|
||||||
|
func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) {
|
||||||
|
var v offeredValue
|
||||||
|
if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return v.Outcome, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error {
|
||||||
|
return s.putOffered(fingerprint, offeredValue{Node: node, Account: account, Outcome: o, Why: why,
|
||||||
|
At: s.Now().UTC().Format(time.RFC3339)}, offeredFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Duration) error {
|
||||||
|
_, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// keptLog is a log this module keeps, as mesh-sdk's KeptLog answers it.
|
||||||
|
type keptLog interface {
|
||||||
|
Append(key string, value any) (uint64, error)
|
||||||
|
Read(from uint64, keys string, limit int) (*stdio.LogPage, error)
|
||||||
|
Last(key string) (*stdio.LogEntry, error)
|
||||||
|
Info() (*stdio.LogInfo, error)
|
||||||
|
Newest(keys string) ([]stdio.LogEntry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054).
|
||||||
|
type UsageEntry struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Reading UsageReading `json:"reading"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`.
|
||||||
|
var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried",
|
||||||
|
"settings"}
|
||||||
|
|
||||||
|
func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
||||||
|
_, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano),
|
||||||
|
Reading: r, Raw: raw})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage is the latest readings, newest first, of one licence or every one, none before since, the vendor's answer
|
||||||
|
// with raw (§35).
|
||||||
|
func (s *BusStore) Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error) {
|
||||||
|
names := map[string]string{}
|
||||||
|
all, err := s.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, l := range all {
|
||||||
|
names[KeyFor(l.Name)] = l.Name
|
||||||
|
}
|
||||||
|
keys := ""
|
||||||
|
if licence != "" {
|
||||||
|
keys = KeyFor(licence)
|
||||||
|
}
|
||||||
|
entries, err := s.newest(s.UsageLog, keys, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []UsageRow{}
|
||||||
|
for _, e := range entries {
|
||||||
|
var u UsageEntry
|
||||||
|
if json.Unmarshal(e.Value, &u) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at, _ := time.Parse(time.RFC3339Nano, u.At)
|
||||||
|
if at.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := names[e.Key]
|
||||||
|
if name == "" {
|
||||||
|
name = e.Key
|
||||||
|
}
|
||||||
|
row := UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}
|
||||||
|
if raw {
|
||||||
|
row.Raw = u.Raw
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles
|
||||||
|
// until it holds limit entries or reaches the first.
|
||||||
|
func (s *BusStore) newest(l keptLog, keys string, limit int) ([]stdio.LogEntry, error) {
|
||||||
|
info, err := l.Info()
|
||||||
|
if err != nil || info.Last == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for span := uint64(max(limit, 1)); ; span *= 2 {
|
||||||
|
from := uint64(1)
|
||||||
|
if info.Last > span {
|
||||||
|
from = info.Last - span + 1
|
||||||
|
}
|
||||||
|
var got []stdio.LogEntry
|
||||||
|
for next := from; next != 0; {
|
||||||
|
page, err := l.Read(next, keys, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got = append(got, page.Entries...)
|
||||||
|
next = page.Next
|
||||||
|
}
|
||||||
|
if len(got) >= limit || from <= max(info.First, 1) {
|
||||||
|
if len(got) > limit {
|
||||||
|
got = got[len(got)-limit:]
|
||||||
|
}
|
||||||
|
for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
got[i], got[j] = got[j], got[i]
|
||||||
|
}
|
||||||
|
return got, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20).
|
||||||
|
func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
||||||
|
kind := "drift"
|
||||||
|
for _, k := range AuditKinds {
|
||||||
|
if k == what {
|
||||||
|
kind = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)}
|
||||||
|
if kind != what {
|
||||||
|
entry["what"] = what
|
||||||
|
}
|
||||||
|
for k, v := range detail {
|
||||||
|
entry[k] = v
|
||||||
|
}
|
||||||
|
if _, err := s.AuditLog.Append(kind, entry); err != nil {
|
||||||
|
return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes.
|
||||||
|
type memState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
now func() time.Time
|
||||||
|
values map[string]json.RawMessage
|
||||||
|
until map[string]time.Time
|
||||||
|
history map[string][]stdio.StateRevision
|
||||||
|
rev uint64
|
||||||
|
writes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemState(now func() time.Time) *memState {
|
||||||
|
return &memState{now: now, values: map[string]json.RawMessage{}, until: map[string]time.Time{},
|
||||||
|
history: map[string][]stdio.StateRevision{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) live(key string) bool {
|
||||||
|
if u, ok := s.until[key]; ok && !s.now().Before(u) {
|
||||||
|
delete(s.values, key)
|
||||||
|
delete(s.until, key)
|
||||||
|
}
|
||||||
|
_, ok := s.values[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Get(key string) (*stdio.StateEntry, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.live(key) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &stdio.StateEntry{Key: key, Value: s.values[key]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) write(key string, value any, ttl time.Duration, create bool) (uint64, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if create && s.live(key) {
|
||||||
|
return 0, stdio.ErrChanged
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s.rev++
|
||||||
|
s.writes++
|
||||||
|
s.values[key] = raw
|
||||||
|
delete(s.until, key)
|
||||||
|
if ttl > 0 {
|
||||||
|
s.until[key] = s.now().Add(ttl)
|
||||||
|
}
|
||||||
|
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Value: raw, Revision: s.rev, Op: "put", At: s.now()})
|
||||||
|
return s.rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Put(key string, value any) (uint64, error) { return s.write(key, value, 0, false) }
|
||||||
|
func (s *memState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||||
|
return s.write(key, value, ttl, false)
|
||||||
|
}
|
||||||
|
func (s *memState) Create(key string, value any) (uint64, error) { return s.write(key, value, 0, true) }
|
||||||
|
func (s *memState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||||
|
return s.write(key, value, ttl, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Delete(key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.rev++
|
||||||
|
s.writes++
|
||||||
|
delete(s.values, key)
|
||||||
|
delete(s.until, key)
|
||||||
|
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Revision: s.rev, Op: "delete", At: s.now()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Keys() ([]string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := []string{}
|
||||||
|
for k := range s.values {
|
||||||
|
if s.live(k) {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) History(key string) ([]stdio.StateRevision, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]stdio.StateRevision(nil), s.history[key]...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §2: a rotation and a reading each write the licence's key, by its key form, and no field
|
||||||
|
// holds a token.
|
||||||
|
func TestARotationAndAReadingWriteTheLicenceKey(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
key := KeyFor(licence1)
|
||||||
|
before := len(mm.licences.history[key])
|
||||||
|
if _, err := mm.m.Rotate(ctx, licence1, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := len(mm.licences.history[key]); got < before+2 {
|
||||||
|
t.Fatalf("the key %s was written %d times by a rotation and a reading, want 2", key, got-before)
|
||||||
|
}
|
||||||
|
for _, r := range mm.licences.history[key] {
|
||||||
|
for _, token := range []string{"rt-", "at-"} {
|
||||||
|
if strings.Contains(string(r.Value), token) {
|
||||||
|
t.Fatalf("a value of %s holds %s: %s", key, token, r.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var l Licence
|
||||||
|
_ = json.Unmarshal(mm.licences.values[key], &l)
|
||||||
|
if l.Name != licence1 || l.AccountUUID != "acct-1" || !strings.Contains(string(mm.licences.values[key]), `"account":"acct-1"`) {
|
||||||
|
t.Fatalf("the key holds %+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §3, B11: bind, switch, release and a rotation each write or delete the consumer's key with
|
||||||
|
// its why and by; each consumer counts its own generations, past what its node reported.
|
||||||
|
func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
|
||||||
|
mm.m.Reported = func(node string) int64 {
|
||||||
|
if node == "server" {
|
||||||
|
return 40
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "jane"})
|
||||||
|
if _, err := mm.m.Bind(asked, "server", "api"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b := mm.bound("server"); b.Licence != "api" || b.Generation != 41 || b.Why != "the switch verb" || b.By != "jane" ||
|
||||||
|
b.Since == "" || b.Kind != "api-key" {
|
||||||
|
t.Fatalf("server's binding %+v", b)
|
||||||
|
}
|
||||||
|
laptop := mm.bound("laptop").Generation
|
||||||
|
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b := mm.bound("laptop"); b.Generation != laptop+1 || b.Licence != "api" {
|
||||||
|
t.Fatalf("laptop's switch %+v, from generation %d", b, laptop)
|
||||||
|
}
|
||||||
|
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "jane"}), "server"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := mm.bindings.values["server"]; ok {
|
||||||
|
t.Fatal("release left the key")
|
||||||
|
}
|
||||||
|
if h, _ := mm.bindings.History("server"); len(h) != 2 || h[1].Op != "delete" {
|
||||||
|
t.Fatalf("server's history %v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §4, §5, B13: a lease is a key created with a two-minute lifetime that another holder cannot
|
||||||
|
// create; an offered grant's outcome is kept under its fingerprint in the key form for 90 days.
|
||||||
|
func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
key := "licence." + KeyFor(licence1)
|
||||||
|
if ok, err := mm.store.Lease(ctx, key, "one", leaseFor); !ok || err != nil {
|
||||||
|
t.Fatalf("the first lease: %v %v", ok, err)
|
||||||
|
}
|
||||||
|
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); ok {
|
||||||
|
t.Fatal("a second holder took a live lease")
|
||||||
|
}
|
||||||
|
if u := mm.leases.until[key]; !u.Equal(mm.now.Add(2 * time.Minute)) {
|
||||||
|
t.Fatalf("the lease lives until %v", u)
|
||||||
|
}
|
||||||
|
mm.now = mm.now.Add(2 * time.Minute)
|
||||||
|
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); !ok {
|
||||||
|
t.Fatal("a lease past its lifetime was not taken")
|
||||||
|
}
|
||||||
|
_ = mm.store.Unlease(ctx, key, "one")
|
||||||
|
if _, held := mm.leases.values[key]; !held {
|
||||||
|
t.Fatal("another holder's lease was ended")
|
||||||
|
}
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-dead", false, t0)})
|
||||||
|
fp := OfferedKey(Fingerprint("rt-dead"))
|
||||||
|
if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") {
|
||||||
|
t.Fatalf("the offered key %q is not in the key form", fp)
|
||||||
|
}
|
||||||
|
var v offeredValue
|
||||||
|
_ = json.Unmarshal(mm.offered.values[fp], &v)
|
||||||
|
if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" {
|
||||||
|
t.Fatalf("offered %+v", v)
|
||||||
|
}
|
||||||
|
if u := mm.offered.until[fp]; !u.Equal(mm.now.Add(90 * 24 * time.Hour)) {
|
||||||
|
t.Fatalf("the outcome lives until %v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry.
|
||||||
|
type memLog struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
now func() time.Time
|
||||||
|
entries []stdio.LogEntry
|
||||||
|
full bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} }
|
||||||
|
|
||||||
|
func (l *memLog) Append(key string, value any) (uint64, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.full {
|
||||||
|
return 0, errors.New("the log is full: maximum bytes exceeded")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
seq := uint64(len(l.entries) + 1)
|
||||||
|
l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()})
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))}
|
||||||
|
for _, e := range l.entries {
|
||||||
|
if e.Sequence >= from && (keys == "" || e.Key == keys) {
|
||||||
|
if limit > 0 && len(p.Entries) == limit {
|
||||||
|
p.Next = e.Sequence
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.Entries = append(p.Entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *memLog) Last(key string) (*stdio.LogEntry, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for i := len(l.entries) - 1; i >= 0; i-- {
|
||||||
|
if l.entries[i].Key == key {
|
||||||
|
e := l.entries[i]
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *memLog) Info() (*stdio.LogInfo, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))}
|
||||||
|
if len(l.entries) > 0 {
|
||||||
|
i.First = 1
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *memLog) Newest(keys string) ([]stdio.LogEntry, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
last := map[string]int{}
|
||||||
|
for i, e := range l.entries {
|
||||||
|
if keys == "" || e.Key == keys {
|
||||||
|
last[e.Key] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := []stdio.LogEntry{}
|
||||||
|
for i, e := range l.entries {
|
||||||
|
if j, ok := last[e.Key]; ok && j == i {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// under is every entry of a log's subject, decoded.
|
||||||
|
func (l *memLog) under(key string) []map[string]any {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
var out []map[string]any
|
||||||
|
for _, e := range l.entries {
|
||||||
|
if key == "" || e.Key == key {
|
||||||
|
var v map[string]any
|
||||||
|
_ = json.Unmarshal(e.Value, &v)
|
||||||
|
v["what"] = e.Key
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer.
|
||||||
|
func TestAReadingAppendsOneUsageEntry(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := mm.usage.under(KeyFor(licence1))
|
||||||
|
if len(got) != 1 || len(mm.usage.entries) != 1 {
|
||||||
|
t.Fatalf("a reading appended %v", mm.usage.entries)
|
||||||
|
}
|
||||||
|
if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil {
|
||||||
|
t.Fatalf("the entry %v", got[0])
|
||||||
|
}
|
||||||
|
rows, _ := mm.store.Usage(ctx, licence1, time.Time{}, 20, false)
|
||||||
|
if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil {
|
||||||
|
t.Fatalf("usage answers %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly.
|
||||||
|
func TestEachActAppendsOneAuditEntry(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
|
||||||
|
_, _ = mm.m.Bind(ctx, "laptop", "api")
|
||||||
|
_, _ = mm.m.Release(ctx, "laptop")
|
||||||
|
for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} {
|
||||||
|
if got := len(mm.audit.under(kind)); got != n {
|
||||||
|
t.Errorf("%s: %d entries, want %d", kind, got, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range mm.audit.under("") {
|
||||||
|
if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" {
|
||||||
|
t.Errorf("the entry %v has no at or by", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mm.audit.full = true
|
||||||
|
if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") {
|
||||||
|
t.Fatalf("a bind with a full audit log: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The carry-over (design 39 bus-and-switch §8, §11 to §13, A3, B14): at its first start the manager reads every row of
|
||||||
|
// today's database, the module's own and read only, and writes it into its kept data on the bus and its grants into
|
||||||
|
// the vault, then the key `carried.postgres`. Until that key exists it serves its read verbs alone and does nothing
|
||||||
|
// else. With the key present it never reads the database again.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CarriedKey is the key of the state `carried` the carry-over writes at its end (§8).
|
||||||
|
const CarriedKey = "postgres"
|
||||||
|
|
||||||
|
// NotCarried is the answer of every acting verb and tool until the carry-over has ended (§11).
|
||||||
|
const NotCarried = "the carry-over has not ended"
|
||||||
|
|
||||||
|
// usageKeptFor is how far back the carry-over reads usage: the log `usage` keeps no older reading (§6, §12).
|
||||||
|
const usageKeptFor = 35 * 24 * time.Hour
|
||||||
|
|
||||||
|
// The tables of today's database, in the order the carry-over reads them (§11, §12).
|
||||||
|
var CarriedTables = []string{"licence", "binding", "lease", "offered", "usage", "audit"}
|
||||||
|
|
||||||
|
// LicenceRow is a row of the table `licence`: the licence and its grant at rest, sealed with `grant.key`.
|
||||||
|
type LicenceRow struct {
|
||||||
|
Licence
|
||||||
|
Sealed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferedRow is a row of the table `offered`.
|
||||||
|
type OfferedRow struct {
|
||||||
|
Fingerprint, Node, Account string
|
||||||
|
Outcome Outcome
|
||||||
|
Why string
|
||||||
|
At time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageTableRow is a row of the table `usage`: the reading and the vendor's answer whole.
|
||||||
|
type UsageTableRow struct {
|
||||||
|
Licence string
|
||||||
|
At int64 // milliseconds
|
||||||
|
Reading UsageReading
|
||||||
|
Raw map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRow is a row of the table `audit`.
|
||||||
|
type AuditRow struct {
|
||||||
|
At time.Time
|
||||||
|
What string
|
||||||
|
Detail map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// CarrySource reads the six tables of today's database, each oldest first; a table that does not exist is no rows.
|
||||||
|
type CarrySource interface {
|
||||||
|
Licences(ctx context.Context) ([]LicenceRow, error)
|
||||||
|
Bindings(ctx context.Context) ([]Binding, error)
|
||||||
|
Leases(ctx context.Context) ([]LeaseRow, error)
|
||||||
|
Offered(ctx context.Context) ([]OfferedRow, error)
|
||||||
|
Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error)
|
||||||
|
Audit(ctx context.Context) ([]AuditRow, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaseRow is one row of today's table `lease`: its key (`licence:<name>` or `account:<id>`), holder and until when.
|
||||||
|
type LeaseRow struct {
|
||||||
|
Key string
|
||||||
|
Holder string
|
||||||
|
Until time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CarriedTable is one table in `carried.postgres`: the rows read and the entries written for them.
|
||||||
|
type CarriedTable struct {
|
||||||
|
Read int `json:"read"`
|
||||||
|
Written int `json:"written"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CarriedRecord is the value of `carried.postgres` (§8): when, and for each table the rows read and entries written.
|
||||||
|
type CarriedRecord struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Tables map[string]CarriedTable `json:"tables"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry is the carry-over's gate: closed until `carried.postgres` exists, and the failure of its last run.
|
||||||
|
type Carry struct {
|
||||||
|
State keptState // the state `carried`
|
||||||
|
// Source opens today's database; called only while the key is absent.
|
||||||
|
Source func(ctx context.Context) (CarrySource, error)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
failure string
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Carry) init() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.done == nil {
|
||||||
|
c.done = make(chan struct{})
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ended is whether the carry-over has ended: the key `carried.postgres` exists.
|
||||||
|
func (c *Carry) Ended() bool {
|
||||||
|
if c == nil {
|
||||||
|
return true // a manager built with no carry-over to wait on: the tests' own
|
||||||
|
}
|
||||||
|
c.init()
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if e, err := c.State.Get(CarriedKey); err == nil && e != nil && len(e.Value) > 0 {
|
||||||
|
c.end()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Carry) end() { c.once.Do(func() { close(c.done) }) }
|
||||||
|
|
||||||
|
// Wait is closed once the carry-over has ended.
|
||||||
|
func (c *Carry) Wait() <-chan struct{} {
|
||||||
|
c.init()
|
||||||
|
return c.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acts answers an acting verb or tool: nothing until the carry-over has ended (§11).
|
||||||
|
func (c *Carry) Acts() error {
|
||||||
|
if c.Ended() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New(NotCarried)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure is what the last run that failed names, empty when none failed.
|
||||||
|
func (c *Carry) Failure() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run carries over every row when `carried.postgres` is absent, and says nothing else happened when it is present
|
||||||
|
// (§13). A run that fails names its table and error, and leaves the key absent, so the next is done again whole.
|
||||||
|
func (c *Carry) Run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) {
|
||||||
|
if c.Ended() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.running {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil, errors.New("a carry-over is running")
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
rec, err := c.run(ctx, m, s)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.running = false
|
||||||
|
if err != nil {
|
||||||
|
c.failure = err.Error()
|
||||||
|
} else {
|
||||||
|
c.failure = ""
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
m.Log("the carry-over failed: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.end()
|
||||||
|
m.Log("carried over today's database: %v", rec.Tables)
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Carry) run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) {
|
||||||
|
src, err := c.Source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the database cannot be opened: %w", err)
|
||||||
|
}
|
||||||
|
if closer, ok := src.(interface{ Close() }); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
co := carryOver{m: m, s: s, src: src, now: m.Now(), tables: map[string]CarriedTable{}}
|
||||||
|
if err := co.all(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rec := CarriedRecord{At: co.now.UTC().Format(time.RFC3339), Tables: co.tables}
|
||||||
|
detail := map[string]any{"why": "the first start of the manager on the bus", "by": Itself, "tables": rec.Tables}
|
||||||
|
if err := s.Audit(ctx, "carried", detail); err != nil {
|
||||||
|
return nil, fmt.Errorf("carried: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := c.State.Put(CarriedKey, rec); err != nil {
|
||||||
|
return nil, fmt.Errorf("carried: the key %s was not written: %w", CarriedKey, err)
|
||||||
|
}
|
||||||
|
return &rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// carryOver is one run of the carry-over.
|
||||||
|
type carryOver struct {
|
||||||
|
m *Manager
|
||||||
|
s *BusStore
|
||||||
|
src CarrySource
|
||||||
|
now time.Time
|
||||||
|
tables map[string]CarriedTable
|
||||||
|
audit []AuditRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (co *carryOver) all(ctx context.Context) error {
|
||||||
|
// The audit is read first: a licence's adoptedFrom comes from it (B14).
|
||||||
|
audit, err := co.src.Audit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("audit: %w", err)
|
||||||
|
}
|
||||||
|
co.audit = audit
|
||||||
|
for _, table := range CarriedTables {
|
||||||
|
var t CarriedTable
|
||||||
|
var err error
|
||||||
|
switch table {
|
||||||
|
case "licence":
|
||||||
|
t, err = co.licences(ctx)
|
||||||
|
case "binding":
|
||||||
|
t, err = co.bindings(ctx)
|
||||||
|
case "lease":
|
||||||
|
t, err = co.leases(ctx)
|
||||||
|
case "offered":
|
||||||
|
t, err = co.offered(ctx)
|
||||||
|
case "usage":
|
||||||
|
t, err = co.usage(ctx)
|
||||||
|
case "audit":
|
||||||
|
t, err = co.audits()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", table, err)
|
||||||
|
}
|
||||||
|
co.tables[table] = t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptedFrom is the node the newest adoption of a licence names in the audit (B14).
|
||||||
|
func (co *carryOver) adoptedFrom(name string) string {
|
||||||
|
from := ""
|
||||||
|
for _, r := range co.audit {
|
||||||
|
if r.What != "adopted" || r.Detail["licence"] != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, _ := r.Detail["node"].(string); n != "" {
|
||||||
|
from = n
|
||||||
|
} else if f, _ := r.Detail["from"].(string); f != "" {
|
||||||
|
from = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
// licences writes a key of `licences` per row, its grant opened with `grant.key` and handed to the vault at
|
||||||
|
// generation 1, the grant first (§12, §15).
|
||||||
|
func (co *carryOver) licences(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
l := r.Licence
|
||||||
|
l.Generation, l.Usage = 0, nil
|
||||||
|
l.AdoptedFrom = co.adoptedFrom(l.Name)
|
||||||
|
if r.Sealed != "" {
|
||||||
|
if co.m.Crypt == nil {
|
||||||
|
return t, fmt.Errorf("the grant of %s cannot be opened: no grant.key", l.Name)
|
||||||
|
}
|
||||||
|
plain, err := co.m.Crypt.Open(r.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
return t, fmt.Errorf("the grant of %s: %w", l.Name, err)
|
||||||
|
}
|
||||||
|
grant, access := plain, plain
|
||||||
|
if l.Kind == "subscription" {
|
||||||
|
var g FullGrant
|
||||||
|
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||||
|
return t, fmt.Errorf("the grant of %s is not a grant", l.Name)
|
||||||
|
}
|
||||||
|
access = accessOf(g)
|
||||||
|
}
|
||||||
|
if err := co.m.handOver(ctx, &l, grant, access); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := co.s.SaveLicence(ctx, l); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindings writes a key of `bindings` per row, its generation kept, why "carried over", by the manager, since the
|
||||||
|
// carry-over's time (§12, B14); a key a run before this one wrote for the same row keeps its since, so it is put again
|
||||||
|
// with the same value (§13).
|
||||||
|
func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
kinds := map[string]string{}
|
||||||
|
if all, err := co.s.Licences(ctx); err == nil {
|
||||||
|
for _, l := range all {
|
||||||
|
kinds[l.Name] = l.Kind
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation,
|
||||||
|
Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself}
|
||||||
|
var held BindingState
|
||||||
|
ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why {
|
||||||
|
v.Since = held.Since
|
||||||
|
}
|
||||||
|
if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
// Said after its key is written, as every binding's change is (§21), so a reader's first view taken during the
|
||||||
|
// carry-over is brought up to date by the event.
|
||||||
|
from := ""
|
||||||
|
if ok && held.Licence != v.Licence {
|
||||||
|
from = held.Licence
|
||||||
|
}
|
||||||
|
co.m.changed(Binding{Consumer: r.Consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Why: v.Why, By: v.By}, from)
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// leases writes a key of `leases` per lease still live at the run, with its remaining lifetime, so the manager never
|
||||||
|
// refreshes a licence the bundle before it holds a lease on, whatever the delivery's order; an expired one is counted
|
||||||
|
// read and not written (B44). Its key takes the bus's form: `licence.<licence key>` or `account.<id>` (§4).
|
||||||
|
func (co *carryOver) leases(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Leases(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
left := r.Until.Sub(co.now)
|
||||||
|
if left <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := ""
|
||||||
|
if name, ok := strings.CutPrefix(r.Key, "licence:"); ok {
|
||||||
|
key = "licence." + KeyFor(name)
|
||||||
|
} else if account, ok := strings.CutPrefix(r.Key, "account:"); ok {
|
||||||
|
key = "account." + account
|
||||||
|
} else {
|
||||||
|
co.m.Log("the lease %s names neither a licence nor an account; it is not carried", r.Key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := leaseValue{Holder: r.Holder, Until: r.Until.UTC().Format(time.RFC3339)}
|
||||||
|
if _, err := co.s.LeasesState.PutTTL(key, v, left); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offered writes a key of `offered` per row, its lifetime counted from the row's `at`; a row past its lifetime is
|
||||||
|
// gone already and written nowhere (§5, §12).
|
||||||
|
func (co *carryOver) offered(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Offered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
left := offeredFor - co.now.Sub(r.At)
|
||||||
|
if left <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := offeredValue{Node: r.Node, Account: r.Account, Outcome: r.Outcome, Why: r.Why,
|
||||||
|
At: r.At.UTC().Format(time.RFC3339)}
|
||||||
|
if err := co.s.putOffered(r.Fingerprint, v, left); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastAt is the `at` of a log's last entry for a subject, zero when it has none: rows at or before it are in the log
|
||||||
|
// already (§13).
|
||||||
|
func lastAt(l keptLog, key string) (time.Time, error) {
|
||||||
|
e, err := l.Last(key)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(e.Value, &v) != nil {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
at, _ := time.Parse(time.RFC3339Nano, v.At)
|
||||||
|
return at, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage appends one entry of the log `usage` per row of the last 35 days, oldest first, its `at` kept (§12).
|
||||||
|
func (co *carryOver) usage(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Usage(ctx, co.now.Add(-usageKeptFor))
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
last := map[string]time.Time{}
|
||||||
|
for _, r := range rows {
|
||||||
|
key := KeyFor(r.Licence)
|
||||||
|
before, seen := last[key]
|
||||||
|
if !seen {
|
||||||
|
if before, err = lastAt(co.s.UsageLog, key); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
last[key] = before
|
||||||
|
}
|
||||||
|
at := time.UnixMilli(r.At).UTC()
|
||||||
|
if !at.After(before) {
|
||||||
|
t.Written++ // in the log already, from a run before this one
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := co.s.UsageLog.Append(key, UsageEntry{At: at.Format(time.RFC3339Nano), Reading: r.Reading,
|
||||||
|
Raw: r.Raw}); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audits appends one entry of the log `audit` per row, oldest first, its `at` kept, under the subject its `what`
|
||||||
|
// names, `drift` for any other (§7, §12).
|
||||||
|
func (co *carryOver) audits() (CarriedTable, error) {
|
||||||
|
t := CarriedTable{Read: len(co.audit)}
|
||||||
|
last := map[string]time.Time{}
|
||||||
|
for _, r := range co.audit {
|
||||||
|
kind := "drift"
|
||||||
|
for _, k := range AuditKinds {
|
||||||
|
if k == r.What {
|
||||||
|
kind = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before, seen := last[kind]
|
||||||
|
if !seen {
|
||||||
|
var err error
|
||||||
|
if before, err = lastAt(co.s.AuditLog, kind); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
last[kind] = before
|
||||||
|
}
|
||||||
|
if !r.At.After(before) {
|
||||||
|
t.Written++ // in the log already, from a run before this one
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := map[string]any{}
|
||||||
|
for k, v := range r.Detail {
|
||||||
|
entry[k] = v
|
||||||
|
}
|
||||||
|
entry["at"] = r.At.UTC().Format(time.RFC3339Nano)
|
||||||
|
if kind != r.What {
|
||||||
|
entry["what"] = r.What
|
||||||
|
}
|
||||||
|
if _, err := co.s.AuditLog.Append(kind, entry); err != nil {
|
||||||
|
return t, fmt.Errorf("the audit did not take the carried %s entry: %w", r.What, err)
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memSource is today's database as the carry-over reads it, from rows given; failAt makes one table's read fail.
|
||||||
|
type memSource struct {
|
||||||
|
licences []LicenceRow
|
||||||
|
bindings []Binding
|
||||||
|
leases []LeaseRow
|
||||||
|
offered []OfferedRow
|
||||||
|
usage []UsageTableRow
|
||||||
|
audit []AuditRow
|
||||||
|
failAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memSource) fail(table string) error {
|
||||||
|
if s.failAt == table {
|
||||||
|
return errors.New("connection reset by peer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memSource) Licences(context.Context) ([]LicenceRow, error) {
|
||||||
|
return s.licences, s.fail("licence")
|
||||||
|
}
|
||||||
|
func (s *memSource) Bindings(context.Context) ([]Binding, error) {
|
||||||
|
return s.bindings, s.fail("binding")
|
||||||
|
}
|
||||||
|
func (s *memSource) Leases(context.Context) ([]LeaseRow, error) { return s.leases, s.fail("lease") }
|
||||||
|
func (s *memSource) Offered(context.Context) ([]OfferedRow, error) {
|
||||||
|
return s.offered, s.fail("offered")
|
||||||
|
}
|
||||||
|
func (s *memSource) Audit(context.Context) ([]AuditRow, error) { return s.audit, s.fail("audit") }
|
||||||
|
func (s *memSource) Usage(_ context.Context, since time.Time) ([]UsageTableRow, error) {
|
||||||
|
var out []UsageTableRow
|
||||||
|
for _, u := range s.usage {
|
||||||
|
if u.At >= since.UnixMilli() {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, s.fail("usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// todaysRows is a copy of the shape of today's rows: a subscription and an API key, two bindings, a lease, two offers,
|
||||||
|
// three readings (one older than 35 days) and four audit rows (one of a kind the log does not name).
|
||||||
|
func todaysRows(t *testing.T, mm *miniMesh) *memSource {
|
||||||
|
t.Helper()
|
||||||
|
grant, _ := json.Marshal(FullGrant{AccessToken: "at-old", RefreshToken: "rt-old", ExpiresAt: 7})
|
||||||
|
pct := 42.0
|
||||||
|
return &memSource{
|
||||||
|
licences: []LicenceRow{
|
||||||
|
{Licence: Licence{Name: licence1, Kind: "subscription", AccountUUID: "acct-1", Email: licence1,
|
||||||
|
RefreshFingerprint: Fingerprint("rt-old"), AccessExpiresAt: 7, AdoptedAt: 1}, Sealed: sealAtRest(t, mm.m.Crypt, string(grant))},
|
||||||
|
{Licence: Licence{Name: "api", Kind: "api-key", AdoptedAt: 2}, Sealed: sealAtRest(t, mm.m.Crypt, "sk-ant-api03-x")},
|
||||||
|
},
|
||||||
|
bindings: []Binding{{Consumer: "laptop", Licence: licence1, Generation: 1013}, {Consumer: "server", Licence: "api", Generation: 1016}},
|
||||||
|
leases: []LeaseRow{{Key: "licence:x", Holder: "novox:1", Until: t0.Add(-time.Minute)}},
|
||||||
|
offered: []OfferedRow{{Fingerprint: "sha256:aa", Node: "laptop", Account: "acct-1", Outcome: Adopted, Why: "refreshed", At: t0.Add(-24 * time.Hour)},
|
||||||
|
{Fingerprint: "sha256:bb", Node: "server", Outcome: Dead, Why: "400", At: t0.Add(-48 * time.Hour)}},
|
||||||
|
usage: []UsageTableRow{
|
||||||
|
{Licence: licence1, At: t0.Add(-40 * 24 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}},
|
||||||
|
{Licence: licence1, At: t0.Add(-2 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}, Raw: map[string]any{"five_hour": 1.0}},
|
||||||
|
{Licence: licence1, At: t0.Add(-1 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}},
|
||||||
|
},
|
||||||
|
audit: []AuditRow{
|
||||||
|
{At: t0.Add(-72 * time.Hour), What: "adopted", Detail: map[string]any{"licence": licence1, "node": "laptop"}},
|
||||||
|
{At: t0.Add(-71 * time.Hour), What: "bound", Detail: map[string]any{"consumer": "laptop"}},
|
||||||
|
{At: t0.Add(-70 * time.Hour), What: "switched", Detail: map[string]any{"consumer": "server"}},
|
||||||
|
{At: t0.Add(-69 * time.Hour), What: "grant-moved", Detail: map[string]any{"licence": "api"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func carryFor(mm *miniMesh, carried *memState, src CarrySource) *Carry {
|
||||||
|
return &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { return src, nil }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCarried(t *testing.T, carried *memState) CarriedRecord {
|
||||||
|
t.Helper()
|
||||||
|
var rec CarriedRecord
|
||||||
|
e, _ := carried.Get(CarriedKey)
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("carried.postgres is not written")
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(e.Value, &rec)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §11/AC1, §12/AC1: every row is carried over and the key written; the counts in
|
||||||
|
// carried.postgres equal the rows read for every table but lease, and the vault answers each licence's grant.
|
||||||
|
func TestEveryRowIsCarriedOver(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
src := todaysRows(t, mm)
|
||||||
|
c := carryFor(mm, carried, src)
|
||||||
|
if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := readCarried(t, carried)
|
||||||
|
want := map[string]int{"licence": 2, "binding": 2, "lease": 1, "offered": 2, "usage": 2, "audit": 4}
|
||||||
|
for table, n := range want {
|
||||||
|
got := rec.Tables[table]
|
||||||
|
if got.Read != n || (table != "lease" && got.Written != got.Read) {
|
||||||
|
t.Errorf("%s: read %d written %d, want %d read and as many written", table, got.Read, got.Written, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g, err := mm.grants.Get(context.Background(), KeyFor(licence1), PartGrant); err != nil || !strings.Contains(g, "rt-old") {
|
||||||
|
t.Fatalf("the vault answers the grant %q, %v", g, err)
|
||||||
|
}
|
||||||
|
if a, _ := mm.grants.Get(context.Background(), KeyFor(licence1), PartAccess); strings.Contains(a, "rt-old") || !strings.Contains(a, "at-old") {
|
||||||
|
t.Fatalf("the access secret is %q", a)
|
||||||
|
}
|
||||||
|
if a, _ := mm.grants.Get(context.Background(), KeyFor("api"), PartAccess); a != "sk-ant-api03-x" {
|
||||||
|
t.Fatalf("the API key's access is %q", a)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(context.Background(), licence1)
|
||||||
|
if l == nil || l.Generation != 1 || l.AdoptedFrom != "laptop" || l.AccountUUID != "acct-1" || l.AccessExpiresAt != 7 {
|
||||||
|
t.Fatalf("the licence is %+v", l)
|
||||||
|
}
|
||||||
|
if b := mm.bound("laptop"); b.Generation != 1013 || b.Why != "carried over" || b.By != Itself || b.Kind != "subscription" || b.Since == "" {
|
||||||
|
t.Fatalf("laptop's binding %+v", b)
|
||||||
|
}
|
||||||
|
if u := mm.offered.until[OfferedKey("sha256:aa")]; !u.Equal(t0.Add(-24 * time.Hour).Add(offeredFor)) {
|
||||||
|
t.Fatalf("the offer lives until %v", u)
|
||||||
|
}
|
||||||
|
if got := mm.audit.under("drift"); len(got) != 1 || got[0]["licence"] != "api" {
|
||||||
|
t.Fatalf("drift holds %v", got)
|
||||||
|
}
|
||||||
|
if got := mm.audit.under("adopted"); len(got) != 1 || got[0]["at"] != t0.Add(-72*time.Hour).Format(time.RFC3339Nano) {
|
||||||
|
t.Fatalf("adopted holds %v", got)
|
||||||
|
}
|
||||||
|
if got := mm.usage.under(KeyFor(licence1)); len(got) != 2 || got[0]["raw"] == nil {
|
||||||
|
t.Fatalf("usage holds %v", got)
|
||||||
|
}
|
||||||
|
if len(mm.audit.under("carried")) != 1 || !c.Ended() {
|
||||||
|
t.Fatal("the carry-over did not say it carried, or did not end")
|
||||||
|
}
|
||||||
|
// §21: each carried binding is said as binding.changed after its key is written.
|
||||||
|
said := map[string]bool{}
|
||||||
|
for _, e := range mm.changedSince(0) {
|
||||||
|
if e["why"] == "carried over" && e["key"] == KeyFor(fmt.Sprint(e["licence"])) {
|
||||||
|
said[fmt.Sprint(e["consumer"])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !said["laptop"] || !said["server"] || len(mm.changedSince(0)) != 2 {
|
||||||
|
t.Fatalf("the carry-over said %v", mm.changedSince(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and
|
||||||
|
// writes nothing.
|
||||||
|
func TestASecondStartCarriesNothing(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
if _, err := carryFor(mm, carried, todaysRows(t, mm)).Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writes := carried.writes + mm.licences.writes + mm.bindings.writes + mm.offered.writes
|
||||||
|
entries := len(mm.audit.entries) + len(mm.usage.entries)
|
||||||
|
puts := len(mm.grants.puts)
|
||||||
|
opened := false
|
||||||
|
again := &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { opened = true; return nil, errors.New("no") }}
|
||||||
|
if rec, err := again.Run(context.Background(), mm.m, mm.store); rec != nil || err != nil || opened {
|
||||||
|
t.Fatalf("a second start answered %v %v, opened the database: %v", rec, err, opened)
|
||||||
|
}
|
||||||
|
if carried.writes+mm.licences.writes+mm.bindings.writes+mm.offered.writes != writes ||
|
||||||
|
len(mm.audit.entries)+len(mm.usage.entries) != entries || len(mm.grants.puts) != puts {
|
||||||
|
t.Fatal("a second start wrote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §13/AC2: a carry-over stopped after bindings leaves no key, names its table, and a start
|
||||||
|
// again carries over whole: every count equals the rows read, and no log entry is appended twice.
|
||||||
|
func TestAStoppedCarryOverIsDoneAgainWhole(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
src := todaysRows(t, mm)
|
||||||
|
src.failAt = "offered"
|
||||||
|
c := carryFor(mm, carried, src)
|
||||||
|
if _, err := c.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(c.Failure(), "offered") {
|
||||||
|
t.Fatalf("a stopped carry-over answered %v, its failure %q", err, c.Failure())
|
||||||
|
}
|
||||||
|
if e, _ := carried.Get(CarriedKey); e != nil || c.Ended() {
|
||||||
|
t.Fatal("a stopped carry-over wrote its key")
|
||||||
|
}
|
||||||
|
src.failAt = ""
|
||||||
|
since := mm.bound("laptop").Since
|
||||||
|
mm.now = mm.now.Add(time.Minute)
|
||||||
|
if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for table, got := range readCarried(t, carried).Tables {
|
||||||
|
if table != "lease" && got.Written != got.Read {
|
||||||
|
t.Errorf("%s: read %d written %d", table, got.Read, got.Written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b := mm.bound("laptop"); b.Since != since {
|
||||||
|
t.Fatalf("the binding put again changed its since from %s to %s", since, b.Since)
|
||||||
|
}
|
||||||
|
if n := len(mm.audit.under("")); n != 5 {
|
||||||
|
t.Fatalf("the audit holds %d entries, want the four rows and one carried", n)
|
||||||
|
}
|
||||||
|
// the run stopped before the logs; a second pass over a log already written appends nothing.
|
||||||
|
co := carryOver{m: mm.m, s: mm.store, src: src, now: mm.now, tables: map[string]CarriedTable{}, audit: src.audit}
|
||||||
|
if _, err := co.usage(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := co.audits(); err != nil || got.Written != 4 || len(mm.audit.under("")) != 5 || len(mm.usage.under("")) != 2 {
|
||||||
|
t.Fatalf("a log written again appended: %+v %v, %d usage entries", got, err, len(mm.usage.under("")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §11/AC2: with the database unreachable the carry-over fails naming it, every acting verb
|
||||||
|
// answers "the carry-over has not ended", a read verb is served, and no vendor call is made.
|
||||||
|
func TestAnUnreachableDatabaseActsNotAndCallsNoVendor(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
mm.m.Carry = &Carry{State: carried, Source: func(context.Context) (CarrySource, error) {
|
||||||
|
return nil, errors.New("dial tcp 192.0.2.1:5432: connect: connection refused")
|
||||||
|
}}
|
||||||
|
if _, err := mm.m.Carry.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(mm.m.Carry.Failure(), "connection refused") {
|
||||||
|
t.Fatalf("the run answered %v, its failure %q", err, mm.m.Carry.Failure())
|
||||||
|
}
|
||||||
|
built = mm.m
|
||||||
|
t.Cleanup(func() { built = nil })
|
||||||
|
served := map[string]stdTool{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
served[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"bind", "switch", "release", "refresh", "adopt"} {
|
||||||
|
if _, err := served[name](map[string]any{"consumer": "laptop", "licence": licence1, "name": "api", "file": "/none"}); err == nil || err.Error() != NotCarried {
|
||||||
|
t.Errorf("%s answered %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"licences", "bindings", "usage", "public-key"} {
|
||||||
|
if _, err := served[name](map[string]any{}); err != nil {
|
||||||
|
t.Errorf("the read verb %s answered %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mm.vendor.exchanged) != 0 {
|
||||||
|
t.Fatalf("the vendor was called: %v", mm.vendor.exchanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stdTool func(map[string]any) (any, error)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §11/AC3: a table that does not exist (a fresh install) is read as no rows; any other
|
||||||
|
// failure of the database is one.
|
||||||
|
func TestAMissingTableIsNoRows(t *testing.T) {
|
||||||
|
if !noTable(&pgconn.PgError{Code: "42P01", Message: `relation "offered" does not exist`}) {
|
||||||
|
t.Fatal("a missing table is a failure")
|
||||||
|
}
|
||||||
|
for _, err := range []error{&pgconn.PgError{Code: "28P01"}, errors.New("connection refused"), nil} {
|
||||||
|
if noTable(err) {
|
||||||
|
t.Fatalf("%v is read as a missing table", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §1/AC3: no code of the manager writes to its database.
|
||||||
|
func TestNoWriteToTheDatabase(t *testing.T) {
|
||||||
|
write := regexp.MustCompile(`(?i)\b(insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+(table|sequence|index)|drop\s+(table|sequence|index)|alter\s+table|truncate\s|setval\s*\(|nextval\s*\()`)
|
||||||
|
files, _ := os.ReadDir(".")
|
||||||
|
for _, f := range files {
|
||||||
|
if !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w := write.Find(raw); w != nil {
|
||||||
|
t.Errorf("%s writes to the database: %q", f.Name(), w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §12, B44: a lease still live at the run is carried with its remaining lifetime, and a
|
||||||
|
// refresh waits for it to end; an expired one is counted read and not written.
|
||||||
|
func TestALiveLeaseIsCarriedAndBlocksARefreshUntilItEnds(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
src := todaysRows(t, mm)
|
||||||
|
src.leases = []LeaseRow{{Key: "licence:" + licence1, Holder: "the old bundle", Until: mm.now.Add(90 * time.Second)},
|
||||||
|
{Key: "account:acct-1", Holder: "the old bundle", Until: mm.now.Add(-time.Second)}}
|
||||||
|
if _, err := carryFor(mm, carried, src).Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := readCarried(t, carried).Tables["lease"]; got.Read != 2 || got.Written != 1 {
|
||||||
|
t.Fatalf("lease: read %d written %d, want 2 read and 1 written", got.Read, got.Written)
|
||||||
|
}
|
||||||
|
if e, _ := mm.leases.Get("account.acct-1"); e != nil {
|
||||||
|
t.Fatalf("an expired lease was carried: %s", e.Value)
|
||||||
|
}
|
||||||
|
r, err := mm.m.Rotate(context.Background(), licence1, true)
|
||||||
|
if err != nil || r["refreshed"] != false || r["reason"] != "another run holds its lease" {
|
||||||
|
t.Fatalf("a refresh under the carried lease answered %v, %v", r, err)
|
||||||
|
}
|
||||||
|
mm.now = mm.now.Add(91 * time.Second)
|
||||||
|
if r, err := mm.m.Rotate(context.Background(), licence1, true); err != nil || r["reason"] == "another run holds its lease" {
|
||||||
|
t.Fatalf("once the carried lease ended the refresh answered %v, %v", r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The manager's conditions (design 39 bus-and-switch §9, §11, §29 to §31, §33, B17, B37 to B39): each said on the
|
||||||
|
// operator channel as any module says one (novox/hq ADR 0437 §9), the router alone deciding whether and where the
|
||||||
|
// operator is told, and each it said raised kept in its state `conditions`, written before it is said and deleted when
|
||||||
|
// it is said cleared. A raise or a clear is said again only when what it says changes (ADR 0289 §1); at its start the
|
||||||
|
// manager judges every kind anew, says raised again each it still holds and cleared each key it no longer holds.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||||
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publisher is the name the manager says its conditions under, and the id of their subject (B17).
|
||||||
|
const Publisher = "claude-licence-manager"
|
||||||
|
|
||||||
|
// CarryOverFailed is the condition of a carry-over that failed (§11).
|
||||||
|
const CarryOverFailed = "carry-over-failed"
|
||||||
|
|
||||||
|
// HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39).
|
||||||
|
const HeadlineLength = 60
|
||||||
|
|
||||||
|
// KeptCondition is the value of a key of `conditions` (§9): the condition said raised, and since when.
|
||||||
|
type KeptCondition struct {
|
||||||
|
Body oc.Condition `json:"body"`
|
||||||
|
Since string `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SayCondition publishes a condition on the operator channel's condition accept, under the manager's name, in the form
|
||||||
|
// its client encodes (ADR 0437 §9).
|
||||||
|
func SayCondition(c oc.Condition) error {
|
||||||
|
subject, err := occ.ConditionSubject(Publisher)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := occ.EncodeCondition(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// condition is a raised condition of the manager: its key, severity, words and the line said when it is over. An
|
||||||
|
// empty needs is "Nothing for you to do."; the explanation is opened by its verdict here.
|
||||||
|
func condition(key, severity, headline, summary, needs, explanation, resolved string) oc.Condition {
|
||||||
|
verdict := occ.NothingToDo
|
||||||
|
if needs != "" {
|
||||||
|
verdict = occ.NeedsYou + needs + "."
|
||||||
|
}
|
||||||
|
if explanation != "" {
|
||||||
|
verdict += " " + explanation
|
||||||
|
}
|
||||||
|
return oc.Condition{Key: key, Severity: severity, State: occ.StateRaised,
|
||||||
|
Subject: oc.ConditionSubject{Scope: "module", ID: Publisher},
|
||||||
|
Headline: cut(headline), Summary: summary, Needs: needs, Explanation: verdict, Resolved: resolved}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cut keeps a headline within the operator channel's limit, ending a longer one in "…" (B39).
|
||||||
|
func cut(headline string) string {
|
||||||
|
r := []rune(headline)
|
||||||
|
if len(r) <= HeadlineLength {
|
||||||
|
return headline
|
||||||
|
}
|
||||||
|
return string(r[:HeadlineLength-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// short is a licence's short name for a headline: its address before the "@", or its name (B39).
|
||||||
|
func short(name string) string {
|
||||||
|
if at := strings.Index(name, "@"); at > 0 {
|
||||||
|
return name[:at]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// machines is a count of consumers in a headline (B39).
|
||||||
|
func machines(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 machine"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d machines", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge is every condition the manager holds now, judged anew from its own state and readings, by key.
|
||||||
|
func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) {
|
||||||
|
held := map[string]oc.Condition{}
|
||||||
|
if m.Carry != nil && !m.Carry.Ended() {
|
||||||
|
if why := m.Carry.Failure(); why != "" {
|
||||||
|
held[CarryOverFailed] = condition(CarryOverFailed, occ.SeverityUrgent,
|
||||||
|
"The licence manager could not carry over its licences",
|
||||||
|
"the carry-over of today's database failed: "+why,
|
||||||
|
"the licence manager could not carry over its licences; no consumer is moved and no token is refreshed until it does",
|
||||||
|
"It failed: "+why+".", "The licence manager carried over its licences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := m.settings()
|
||||||
|
for _, o := range s.OutOfBound {
|
||||||
|
bound := fmt.Sprintf("at least %g", o.Min)
|
||||||
|
if o.HasMax {
|
||||||
|
bound = fmt.Sprintf("between %g and %g", o.Min, o.Max)
|
||||||
|
}
|
||||||
|
key := "settings." + o.Setting + ".invalid"
|
||||||
|
held[key] = condition(key, occ.SeverityWarning, o.Setting+" is outside its bound",
|
||||||
|
fmt.Sprintf("%s is %g, outside its bound; its default %g is used", o.Setting, o.Value, o.Default),
|
||||||
|
fmt.Sprintf("set %s %s", o.Setting, bound),
|
||||||
|
fmt.Sprintf("%s is set to %g; the licence manager uses its default %g until it is fixed.", o.Setting,
|
||||||
|
o.Value, o.Default), o.Setting+" is within its bound again")
|
||||||
|
}
|
||||||
|
for _, p := range s.Invalid() {
|
||||||
|
key := "settings." + p.Setting + ".invalid"
|
||||||
|
if _, ok := held[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
held[key] = condition(key, occ.SeverityWarning, p.Setting+" is above switch_at_pct",
|
||||||
|
fmt.Sprintf("%s is %g, above switch_at_pct %g; no consumer is moved", p.Setting, p.Value, p.SwitchAt),
|
||||||
|
fmt.Sprintf("set %s at or below switch_at_pct (%g)", p.Setting, p.SwitchAt),
|
||||||
|
fmt.Sprintf("%s is %g, above switch_at_pct %g; the licence manager moves no consumer until it is fixed.",
|
||||||
|
p.Setting, p.Value, p.SwitchAt), p.Setting+" is at or below switch_at_pct again")
|
||||||
|
}
|
||||||
|
licences, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bindings, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, l := range licences {
|
||||||
|
for key, c := range m.judgeLicence(l, licences, bindings, s) {
|
||||||
|
held[key] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return held, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeLicence is what licence l holds of no-room (§29), near-limit (§30) and usage-unread (§31).
|
||||||
|
func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding, s Settings) map[string]oc.Condition {
|
||||||
|
held := map[string]oc.Condition{}
|
||||||
|
key := "licence." + KeyFor(l.Name) + "."
|
||||||
|
consumers := consumersOf(l.Name, bindings)
|
||||||
|
list := strings.Join(consumers, ", ")
|
||||||
|
if r := m.RuleFor(l, licences, bindings, s); r.NoTarget {
|
||||||
|
o := r.Over
|
||||||
|
var rooms []string
|
||||||
|
for _, other := range licences {
|
||||||
|
if other.Name != l.Name {
|
||||||
|
rooms = append(rooms, other.Name+" "+m.roomWords(other, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(rooms)
|
||||||
|
others := "no other licence is held"
|
||||||
|
if len(rooms) > 0 {
|
||||||
|
others = strings.Join(rooms, "; ")
|
||||||
|
}
|
||||||
|
held[key+"no-room"] = condition(key+"no-room", occ.SeverityUrgent,
|
||||||
|
"No licence has room for "+machines(len(consumers)),
|
||||||
|
fmt.Sprintf("no licence has room for %s, bound to %s at %g %% of its %s window", list, l.Name, o.Pct, o.Window),
|
||||||
|
fmt.Sprintf("add a licence, or wait for %s's %s window to reset at %s", l.Name, o.Window, o.ResetsAt),
|
||||||
|
fmt.Sprintf("%s is at %g %% of its %s window, with %s bound to it; %s.", l.Name, o.Pct, o.Window, list, others),
|
||||||
|
list+" have a licence with room again")
|
||||||
|
}
|
||||||
|
if window, pct, resetsAt, ok := windowAt(l.Usage, s.WarnAt); ok && len(consumers) > 0 {
|
||||||
|
needs, plan := "", ""
|
||||||
|
if to, _ := m.targetFor(l, licences, s); to != "" {
|
||||||
|
plan = fmt.Sprintf("%s move to %s at %g %%. ", list, to, s.SwitchAt)
|
||||||
|
} else {
|
||||||
|
needs = "no other licence has room"
|
||||||
|
}
|
||||||
|
held[key+"near-limit"] = condition(key+"near-limit", occ.SeverityWarning,
|
||||||
|
fmt.Sprintf("%s at %g%% %s", short(l.Name), pct, window),
|
||||||
|
fmt.Sprintf("%s at %g %% of its %s window", l.Name, pct, window), needs,
|
||||||
|
fmt.Sprintf("%s%s is at %g %% of its %s window, which resets at %s; %s bound to it.", plan, l.Name, pct,
|
||||||
|
window, resetsAt, list), fmt.Sprintf("%s is below %g %% again", l.Name, s.WarnAt))
|
||||||
|
}
|
||||||
|
if since, stale := m.unread(l, s); stale {
|
||||||
|
needs := ""
|
||||||
|
if len(consumers) > 0 {
|
||||||
|
needs = list + " are not moved off it while its usage is unknown"
|
||||||
|
}
|
||||||
|
why := l.Usage.readError()
|
||||||
|
held[key+"usage-unread"] = condition(key+"usage-unread", occ.SeverityWarning,
|
||||||
|
short(l.Name)+"'s usage cannot be read",
|
||||||
|
fmt.Sprintf("the usage of %s cannot be read: %s", l.Name, why), needs,
|
||||||
|
fmt.Sprintf("%s, older than %g minutes; the vendor answers: %s.", since, s.UsageStale.Minutes(), why),
|
||||||
|
l.Name+"'s usage is read again")
|
||||||
|
}
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
|
||||||
|
// readError is the vendor's reason a reading failed, or that none was given.
|
||||||
|
func (u *UsageReading) readError() string {
|
||||||
|
if u == nil || u.ReadError == "" {
|
||||||
|
return "no reason given"
|
||||||
|
}
|
||||||
|
return u.ReadError
|
||||||
|
}
|
||||||
|
|
||||||
|
// unread is whether subscription l's latest successful reading is older than usage_stale_minutes (§31), or, never
|
||||||
|
// read successfully, its adoption is (B38); and the words naming since when.
|
||||||
|
func (m *Manager) unread(l Licence, s Settings) (string, bool) {
|
||||||
|
if l.Kind != "subscription" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if l.Usage != nil && l.Usage.ReadAt != "" {
|
||||||
|
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
|
||||||
|
if err != nil {
|
||||||
|
return "its latest successful reading has no time", true
|
||||||
|
}
|
||||||
|
return "its latest successful reading was at " + l.Usage.ReadAt, m.Now().Sub(read) > s.UsageStale
|
||||||
|
}
|
||||||
|
if l.AdoptedAt == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
adopted := time.UnixMilli(l.AdoptedAt)
|
||||||
|
return "no reading has succeeded since its adoption at " + adopted.UTC().Format(time.RFC3339),
|
||||||
|
m.Now().Sub(adopted) > s.UsageStale
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomWords is another licence's room for the condition no-room, or why it is not known or not enough (§29).
|
||||||
|
func (m *Manager) roomWords(o Licence, s Settings) string {
|
||||||
|
room, known := Room(o, m.Now(), s)
|
||||||
|
switch {
|
||||||
|
case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow):
|
||||||
|
return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow)
|
||||||
|
case known:
|
||||||
|
return fmt.Sprintf("has %g %% room", room)
|
||||||
|
case o.Kind != "subscription":
|
||||||
|
return "is an API key, whose room is never known"
|
||||||
|
case o.Usage == nil || o.Usage.ReadAt == "":
|
||||||
|
return "has no reading"
|
||||||
|
case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil:
|
||||||
|
return "has no session or weekly window in its reading"
|
||||||
|
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
|
||||||
|
return "does not refresh"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("was not read within %g minutes", s.UsageStale.Minutes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SayConditions judges every kind anew and says what changed: each condition held whose words differ from those kept
|
||||||
|
// is written in `conditions`, then said raised; each kept key no longer held is said cleared, then deleted. With
|
||||||
|
// again (at the start, and by licences_resync), each held is said raised again whether or not it changed.
|
||||||
|
func (m *Manager) SayConditions(ctx context.Context, again bool) error {
|
||||||
|
if m.Conditions == nil || m.Say == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.conditionsMu.Lock()
|
||||||
|
defer m.conditionsMu.Unlock()
|
||||||
|
held, err := m.Judge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys, err := m.Conditions.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := held[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var kept KeptCondition
|
||||||
|
e, err := m.Conditions.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e != nil && len(e.Value) > 0 {
|
||||||
|
_ = json.Unmarshal(e.Value, &kept)
|
||||||
|
}
|
||||||
|
cleared := kept.Body
|
||||||
|
cleared.Key, cleared.State = key, occ.StateCleared
|
||||||
|
cleared.Subject = oc.ConditionSubject{Scope: "module", ID: Publisher}
|
||||||
|
if err := m.Say(cleared); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("the clearing of %s could not be said: %w", key, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.Conditions.Delete(key); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var order []string
|
||||||
|
for key := range held {
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
sort.Strings(order)
|
||||||
|
for _, key := range order {
|
||||||
|
c := held[key]
|
||||||
|
kept := KeptCondition{Body: c, Since: m.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if e, err := m.Conditions.Get(key); err == nil && e != nil && len(e.Value) > 0 {
|
||||||
|
var was KeptCondition
|
||||||
|
if json.Unmarshal(e.Value, &was) == nil {
|
||||||
|
if !again && sameCondition(was.Body, c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if was.Since != "" {
|
||||||
|
kept.Since = was.Since
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := m.Conditions.Put(key, kept); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.Say(c); err != nil {
|
||||||
|
// Not said: the key goes again, so the next judgement says it.
|
||||||
|
_ = m.Conditions.Delete(key)
|
||||||
|
errs = append(errs, fmt.Errorf("%s could not be said: %w", key, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("conditions: %v", errs)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameCondition is whether two raised conditions say the same.
|
||||||
|
func sameCondition(a, b oc.Condition) bool {
|
||||||
|
ra, _ := json.Marshal(a)
|
||||||
|
rb, _ := json.Marshal(b)
|
||||||
|
return string(ra) == string(rb)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||||
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator
|
||||||
|
// channel's client encodes, refusing what it refuses.
|
||||||
|
func saying(mm *miniMesh) (*memState, *[]oc.Condition) {
|
||||||
|
kept := newMemState(func() time.Time { return mm.now })
|
||||||
|
said := &[]oc.Condition{}
|
||||||
|
mm.m.Conditions = kept
|
||||||
|
mm.m.Say = func(c oc.Condition) error {
|
||||||
|
if _, err := occ.EncodeCondition(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := occ.ConditionSubject(Publisher); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*said = append(*said, c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return kept, said
|
||||||
|
}
|
||||||
|
|
||||||
|
// of is what was said of key, as "state" per saying.
|
||||||
|
func of(said []oc.Condition, key string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range said {
|
||||||
|
if c.Key == key {
|
||||||
|
out = append(out, c.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §9/AC1, §11, §33, B17, B37: each kind of condition the manager holds of itself is written in
|
||||||
|
// `conditions` with its body and since, said once with its key, severity, scope and id, its words opened by their
|
||||||
|
// verdict, and said cleared, its key deleted, once it no longer holds.
|
||||||
|
func TestAConditionIsKeptThenSaidOnce(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
for _, c := range []struct {
|
||||||
|
name, key, severity string
|
||||||
|
raise, fix func(mm *miniMesh)
|
||||||
|
}{
|
||||||
|
{"an invalid pair", "settings.room_below_pct.invalid", occ.SeverityWarning,
|
||||||
|
func(mm *miniMesh) { mm.m.Settings.RoomBelow = 100 }, func(mm *miniMesh) { mm.m.Settings.RoomBelow = 90 }},
|
||||||
|
{"a setting outside its bound", "settings.switch_at_pct.invalid", occ.SeverityWarning,
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 150}`), 0o600)
|
||||||
|
mm.m.Settings = SettingsFrom(file)
|
||||||
|
},
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600)
|
||||||
|
mm.m.Settings = SettingsFrom(file)
|
||||||
|
}},
|
||||||
|
{"a carry-over that failed", CarryOverFailed, occ.SeverityUrgent,
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }),
|
||||||
|
Source: func(context.Context) (CarrySource, error) { return nil, errors.New("connection refused") }}
|
||||||
|
_, _ = mm.m.Carry.Run(ctx, mm.m, mm.store)
|
||||||
|
},
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
mm.m.Carry.Source = func(context.Context) (CarrySource, error) { return &memSource{}, nil }
|
||||||
|
_, _ = mm.m.Carry.Run(ctx, mm.m, mm.store)
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
mm := newMesh(t)
|
||||||
|
kept, said := saying(mm)
|
||||||
|
c.raise(mm)
|
||||||
|
for range 2 {
|
||||||
|
if err := mm.m.SayConditions(ctx, false); err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := of(*said, c.key); strings.Join(got, ",") != "raised" {
|
||||||
|
t.Fatalf("%s: said %v of %s, all said %+v", c.name, got, c.key, *said)
|
||||||
|
}
|
||||||
|
s := (*said)[0]
|
||||||
|
if s.Severity != c.severity || s.Subject.Scope != "module" || s.Subject.ID != Publisher || s.Headline == "" ||
|
||||||
|
s.Summary == "" || s.Resolved == "" || !strings.HasPrefix(s.Explanation, occ.NeedsYou+s.Needs) || s.Needs == "" {
|
||||||
|
t.Fatalf("%s: said %+v", c.name, s)
|
||||||
|
}
|
||||||
|
var k KeptCondition
|
||||||
|
if e, _ := kept.Get(c.key); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Body.Key != c.key || k.Since == "" {
|
||||||
|
t.Fatalf("%s: kept %+v", c.name, k)
|
||||||
|
}
|
||||||
|
c.fix(mm)
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if got := of(*said, c.key); strings.Join(got, ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("%s: said %v", c.name, got)
|
||||||
|
}
|
||||||
|
if e, _ := kept.Get(c.key); e != nil {
|
||||||
|
t.Fatalf("%s: the key is kept after its clearing", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §9/AC2: at its start the manager says cleared each key of `conditions` it no longer holds,
|
||||||
|
// and says raised again each it still holds, unchanged or not.
|
||||||
|
func TestAStartClearsWhatItNoLongerHolds(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := newMesh(t)
|
||||||
|
kept, said := saying(mm)
|
||||||
|
gone := condition("licence.gone.no-room", occ.SeverityUrgent, "No licence has room for 1 machine", "s", "add one",
|
||||||
|
"", "x has a licence with room again")
|
||||||
|
mm.m.Settings.WarnAt = 100
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
_, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"})
|
||||||
|
*said = nil
|
||||||
|
if err := mm.m.SayConditions(ctx, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" {
|
||||||
|
t.Fatalf("a start said %+v", *said)
|
||||||
|
}
|
||||||
|
if (*said)[0].Resolved != gone.Resolved {
|
||||||
|
t.Fatalf("the clearing carries %+v", (*said)[0])
|
||||||
|
}
|
||||||
|
if e, _ := kept.Get(gone.Key); e != nil {
|
||||||
|
t.Fatal("a key no longer held is kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §29/AC1, B39: with every licence at its threshold, no-room is said once, its headline a
|
||||||
|
// count of machines, its words naming the licence, the window's reset and every other licence's room; a reading with
|
||||||
|
// room clears it.
|
||||||
|
func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := fourOnA(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 20)))
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
|
||||||
|
for range 2 {
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
}
|
||||||
|
key := "licence.a.no-room"
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised" {
|
||||||
|
t.Fatalf("said %+v", *said)
|
||||||
|
}
|
||||||
|
var c oc.Condition
|
||||||
|
for _, s := range *said {
|
||||||
|
if s.Key == key {
|
||||||
|
c = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
|
||||||
|
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
|
||||||
|
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
|
||||||
|
!strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") ||
|
||||||
|
c.Resolved != "ace, g14, laptop, server have a licence with room again" {
|
||||||
|
t.Fatalf("no-room %+v", c)
|
||||||
|
}
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("b", read(10, 20)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("said %v after a reading with room", of(*said, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §30/AC1, B39: a rising series of readings says near-limit once at the crossing and again only
|
||||||
|
// when its words change; with no other licence having room it needs the operator; a reading after the reset clears it.
|
||||||
|
func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := fourOnA(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
key := "licence.a.near-limit"
|
||||||
|
var last oc.Condition
|
||||||
|
for _, w := range []float64{80, 89, 91, 91, 95} {
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(10, w)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
for _, s := range *said {
|
||||||
|
if s.Key == key {
|
||||||
|
last = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised,raised" || last.Headline != "a at 95% weekly" || last.Needs != "" ||
|
||||||
|
!strings.HasPrefix(last.Explanation, occ.NothingToDo+" ace, g14, laptop, server move to b at 99 %.") ||
|
||||||
|
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
|
||||||
|
t.Fatalf("said %v, last %+v", of(*said, key), last)
|
||||||
|
}
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 0)))
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("c", read(95, 0)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
|
||||||
|
t.Fatalf("with no target said %+v", c)
|
||||||
|
}
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(3, 1)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if got := of(*said, key); got[len(got)-1] != "cleared" || len(got) != 4 {
|
||||||
|
t.Fatalf("after the reset said %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §31/AC1, B34, B38: three failed readings in a row, the latest success then older than
|
||||||
|
// usage_stale_minutes, say usage-unread naming the vendor's reason, needing the operator for the consumers bound to it;
|
||||||
|
// one success clears it. A subscription never read successfully is said once its adoption is that old.
|
||||||
|
func TestUsageUnreadIsSaidAndCleared(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := newMesh(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := "licence." + KeyFor(licence1) + ".usage-unread"
|
||||||
|
mm.vendor.usageErr = errors.New("429 Too Many Requests: rate limited")
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
mm.now = t0.Add(time.Duration(i) * 6 * time.Minute)
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := len(of(*said, key)); (i < 3 && got != 0) || (i == 3 && got != 1) {
|
||||||
|
t.Fatalf("after %d failed readings said %v", i, of(*said, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := (*said)[len(*said)-1]
|
||||||
|
if c.Severity != occ.SeverityWarning || c.Needs != "laptop are not moved off it while its usage is unknown" ||
|
||||||
|
!strings.Contains(c.Explanation, "the vendor answers: 429 Too Many Requests: rate limited") {
|
||||||
|
t.Fatalf("usage-unread %+v", c)
|
||||||
|
}
|
||||||
|
mm.vendor.usageErr = nil
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("after a success said %v", of(*said, key))
|
||||||
|
}
|
||||||
|
// B38: never read successfully since its adoption.
|
||||||
|
never := Licence{Name: "new@example.org", Kind: "subscription", AdoptedAt: mm.now.UnixMilli()}
|
||||||
|
_ = mm.store.SaveLicence(ctx, never)
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
newKey := "licence." + KeyFor(never.Name) + ".usage-unread"
|
||||||
|
if len(of(*said, newKey)) != 0 {
|
||||||
|
t.Fatal("said at its adoption")
|
||||||
|
}
|
||||||
|
mm.now = mm.now.Add(16 * time.Minute)
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if strings.Join(of(*said, newKey), ",") != "raised" {
|
||||||
|
t.Fatalf("said %v of a licence never read", of(*said, newKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ package main
|
|||||||
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Crypt seals and opens what the store keeps.
|
// Crypt opens what today's database keeps at rest.
|
||||||
type Crypt struct{ key []byte }
|
type Crypt struct{ key []byte }
|
||||||
|
|
||||||
// NewCrypt is the store's cipher from the vault's secret.
|
// NewCrypt is the store's cipher from the vault's secret.
|
||||||
@@ -35,16 +34,8 @@ func CryptFromFile(path string) (*Crypt, error) {
|
|||||||
return NewCrypt(string(raw))
|
return NewCrypt(string(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
|
// Open decrypts a grant today's database keeps at rest, `v1.<iv>.<ciphertext and tag>`: the carry-over's alone, since
|
||||||
func (c *Crypt) Seal(plaintext string) string {
|
// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15).
|
||||||
gcm, _ := newGCM(c.key)
|
|
||||||
iv := make([]byte, 12)
|
|
||||||
_, _ = rand.Read(iv)
|
|
||||||
b64 := base64.StdEncoding.EncodeToString
|
|
||||||
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open decrypts what Seal made with the same key.
|
|
||||||
func (c *Crypt) Open(sealed string) (string, error) {
|
func (c *Crypt) Open(sealed string) (string, error) {
|
||||||
parts := strings.Split(sealed, ".")
|
parts := strings.Split(sealed, ".")
|
||||||
if len(parts) != 3 || parts[0] != "v1" {
|
if len(parts) != 3 || parts[0] != "v1" {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says
|
||||||
|
// `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says
|
||||||
|
// `holdings.asked` at every start, after its carry-over, and on its resync, and takes each report it hears as that
|
||||||
|
// node's latest.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HoldingsChanged is the agent module's event saying what one node holds, which the manager consumes.
|
||||||
|
const HoldingsChanged = "claude-code.holdings-changed"
|
||||||
|
|
||||||
|
// HoldingsAsked is the manager's event asking every node to say what it holds again.
|
||||||
|
const HoldingsAsked = "holdings.asked"
|
||||||
|
|
||||||
|
// Heard is what every node last said it holds, by node.
|
||||||
|
type Heard struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
reports map[string]Holdings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take keeps one `holdings-changed` as its node's latest report.
|
||||||
|
func (h *Heard) Take(body []byte) (Holdings, error) {
|
||||||
|
var r Holdings
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return r, fmt.Errorf("%s is not a holdings report: %w", HoldingsChanged, err)
|
||||||
|
}
|
||||||
|
if r.Node == "" {
|
||||||
|
return r, fmt.Errorf("%s names no node", HoldingsChanged)
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.reports == nil {
|
||||||
|
h.reports = map[string]Holdings{}
|
||||||
|
}
|
||||||
|
h.reports[r.Node] = r
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// All is every node's latest report, by node's name.
|
||||||
|
func (h *Heard) All() []Holdings {
|
||||||
|
h.mu.Lock()
|
||||||
|
all := make([]Holdings, 0, len(h.reports))
|
||||||
|
for _, r := range h.reports {
|
||||||
|
all = append(all, r)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of is the generation node last reported applying.
|
||||||
|
func (h *Heard) Of(node string) int64 {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.reports[node].Generation
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskHoldings says `holdings.asked`, its time and who asks (§23).
|
||||||
|
func (m *Manager) AskHoldings(by string) error {
|
||||||
|
return m.Emit(HoldingsAsked, map[string]any{"at": m.Now().UTC().Format(time.RFC3339Nano), "by": by})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A licence's key form (design 39 bus-and-switch §10, B6): the name every verb and tool takes and answers, written
|
||||||
|
// in the characters a key of the bus takes. The key is the store's alone.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyFor is a licence's key: its name in lower case, each `@` written as `_at_`, and every other character
|
||||||
|
// outside letters, digits, `-` and `.` written as `_`.
|
||||||
|
func KeyFor(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(name) {
|
||||||
|
switch {
|
||||||
|
case r == '@':
|
||||||
|
b.WriteString("_at_")
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyTaken names the licence other than name whose key is name's, or "" when none is.
|
||||||
|
func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) {
|
||||||
|
all, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := KeyFor(name)
|
||||||
|
for _, l := range all {
|
||||||
|
if l.Name != name && KeyFor(l.Name) == key {
|
||||||
|
return l.Name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §10, B6.
|
||||||
|
func TestALicencesKeyForm(t *testing.T) {
|
||||||
|
for name, key := range map[string]string{
|
||||||
|
"jane@example.org": "jane_at_example.org",
|
||||||
|
"Jane.Doe@Example.ORG": "jane.doe_at_example.org",
|
||||||
|
"0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5",
|
||||||
|
"team key_1": "team_key_1",
|
||||||
|
"a+b/c:d": "a_b_c_d",
|
||||||
|
"ops-key": "ops-key",
|
||||||
|
} {
|
||||||
|
if got := KeyFor(name); got != key {
|
||||||
|
t.Errorf("KeyFor(%q) = %q, want %q", name, got, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event.
|
||||||
|
func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Run("an API key", func(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "Ops-Key", Kind: "api-key"})
|
||||||
|
_, err := mm.m.adoptKey(ctx, "ops-key", "sk-ant-"+strings.Repeat("a", 40), "a test")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Ops-Key") {
|
||||||
|
t.Fatalf("adopted a key whose key form is taken: %v", err)
|
||||||
|
}
|
||||||
|
if l, _ := mm.store.Licence(ctx, "ops-key"); l != nil {
|
||||||
|
t.Fatal("the clashing licence was kept")
|
||||||
|
}
|
||||||
|
assertRefused(t, mm)
|
||||||
|
})
|
||||||
|
t.Run("a login", func(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "ACCT-1@example.org", Kind: "api-key"})
|
||||||
|
r := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
|
||||||
|
adopted, err := mm.m.Consider(ctx, []Holdings{r})
|
||||||
|
if err != nil || len(adopted) != 0 {
|
||||||
|
t.Fatalf("adopted %v (%v), want none", adopted, err)
|
||||||
|
}
|
||||||
|
if len(mm.vendor.exchanged) != 0 {
|
||||||
|
t.Fatalf("a refused login was exchanged: %v", mm.vendor.exchanged)
|
||||||
|
}
|
||||||
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-laptop")); o != Refused {
|
||||||
|
t.Fatalf("outcome %q, want refused", o)
|
||||||
|
}
|
||||||
|
assertRefused(t, mm)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRefused(t *testing.T, mm *miniMesh) {
|
||||||
|
t.Helper()
|
||||||
|
said := false
|
||||||
|
for _, e := range mm.events {
|
||||||
|
said = said || strings.HasPrefix(e, "licence.refused ")
|
||||||
|
}
|
||||||
|
audited := false
|
||||||
|
for _, a := range mm.audit.under("") {
|
||||||
|
audited = audited || a["what"] == "refused"
|
||||||
|
}
|
||||||
|
if !said || !audited {
|
||||||
|
t.Fatalf("refusal said %v, audited %v: %v", said, audited, mm.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,6 @@
|
|||||||
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
||||||
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
||||||
//
|
//
|
||||||
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
|
|
||||||
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
|
|
||||||
//
|
|
||||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -16,7 +13,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,14 +28,6 @@ func say(format string, args ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "prepare" {
|
|
||||||
if err := prepare(); err != nil {
|
|
||||||
say("preparing the store failed: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
say("the store's schema is what this version needs")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go daemon()
|
go daemon()
|
||||||
if err := stdio.Serve("", tools()); err != nil {
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
say("%v", err)
|
say("%v", err)
|
||||||
@@ -47,17 +35,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepare() error {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
store, err := PgStoreFromEnv(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer store.Close()
|
|
||||||
return store.Migrate(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- what the binary is handed -----------------------------------------------------------------------
|
// ---- what the binary is handed -----------------------------------------------------------------------
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -85,19 +62,21 @@ func manager() (*Manager, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
store, err := PgStoreFromEnv(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
node, _ := os.Hostname()
|
node, _ := os.Hostname()
|
||||||
if n := os.Getenv("MESH_NODE"); n != "" {
|
if n := os.Getenv("MESH_NODE"); n != "" {
|
||||||
node = n
|
node = n
|
||||||
}
|
}
|
||||||
bindings := stdio.State("bindings")
|
|
||||||
built = &Manager{
|
built = &Manager{
|
||||||
Store: store,
|
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"),
|
||||||
|
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"),
|
||||||
|
AuditLog: stdio.Log("audit"), Now: time.Now},
|
||||||
Vendor: LiveVendor(),
|
Vendor: LiveVendor(),
|
||||||
Crypt: crypt,
|
Crypt: crypt,
|
||||||
|
// Today's database is the carry-over's source alone, read once and never written (§11, A3).
|
||||||
|
Carry: &Carry{State: stdio.State("carried"), Source: func(ctx context.Context) (CarrySource, error) {
|
||||||
|
return PgSourceFromEnv(ctx)
|
||||||
|
}},
|
||||||
|
Grants: VaultGrants{Keys: keys},
|
||||||
Keys: keys,
|
Keys: keys,
|
||||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
var a GrantAnswer
|
var a GrantAnswer
|
||||||
@@ -107,20 +86,48 @@ func manager() (*Manager, error) {
|
|||||||
}
|
}
|
||||||
return a, json.Unmarshal(raw, &a)
|
return a, json.Unmarshal(raw, &a)
|
||||||
},
|
},
|
||||||
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
|
Reported: heard.Of,
|
||||||
_, err := bindings.Put(consumer, b)
|
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
||||||
|
Tell: func(_ context.Context, headline string, lines []string) error {
|
||||||
|
_, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"subject": Seat, "headline": headline,
|
||||||
|
"lines": lines})
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
|
Now: time.Now,
|
||||||
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
Log: say,
|
||||||
Now: time.Now,
|
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
||||||
Log: say,
|
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
||||||
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
|
||||||
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
Conditions: stdio.State("conditions"),
|
||||||
|
Say: SayCondition,
|
||||||
|
ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) },
|
||||||
}
|
}
|
||||||
return built, nil
|
return built, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configLevels asks mesh-config where each of the manager's own settings was set on its node (§40, B18): the source
|
||||||
|
// of each resolved value, by its key.
|
||||||
|
func configLevels(node string) (map[string]string, error) {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": Publisher, "node": node})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var answer struct {
|
||||||
|
Resolved []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"resolved"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||||
|
return nil, fmt.Errorf("mesh-config's answer: %w", err)
|
||||||
|
}
|
||||||
|
levels := map[string]string{}
|
||||||
|
for _, r := range answer.Resolved {
|
||||||
|
levels[r.Key] = r.Source
|
||||||
|
}
|
||||||
|
return levels, nil
|
||||||
|
}
|
||||||
|
|
||||||
// keypair is this module's own, made once in its state directory; the private half never leaves it.
|
// keypair is this module's own, made once in its state directory; the private half never leaves it.
|
||||||
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
|
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
|
||||||
func keypair(dir string) (KeyPair, error) {
|
func keypair(dir string) (KeyPair, error) {
|
||||||
@@ -148,9 +155,33 @@ func keypair(dir string) (KeyPair, error) {
|
|||||||
|
|
||||||
// ---- the long-running half ---------------------------------------------------------------------------
|
// ---- the long-running half ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node.
|
||||||
|
var heard = &Heard{}
|
||||||
|
|
||||||
|
// carryOverFirst runs the carry-over at the start, before the manager hears an event, refreshes or reads usage (§11), and
|
||||||
|
// answers once it has ended: at once when `carried.postgres` exists, else when a run writes it, this one or one asked
|
||||||
|
// by hand later.
|
||||||
|
func carryOverFirst() {
|
||||||
|
<-stdio.Served()
|
||||||
|
m, err := manager()
|
||||||
|
if err != nil {
|
||||||
|
say("the carry-over cannot start: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.Carry.Ended() {
|
||||||
|
bus, _ := m.Store.(*BusStore)
|
||||||
|
_, _ = m.Carry.Run(context.Background(), m, bus) // a failure is said, and the run is asked again by hand (§44)
|
||||||
|
}
|
||||||
|
// At its start the manager judges every condition anew: raised again each it holds, cleared each it no longer
|
||||||
|
// holds, carry-over-failed among them (§9, §11).
|
||||||
|
if err := m.SayConditions(context.Background(), true); err != nil {
|
||||||
|
say("saying the conditions at the start: %v", err)
|
||||||
|
}
|
||||||
|
<-m.Carry.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func daemon() {
|
func daemon() {
|
||||||
var mu sync.Mutex
|
carryOverFirst()
|
||||||
reports := map[string]Holdings{}
|
|
||||||
var passing sync.Mutex
|
var passing sync.Mutex
|
||||||
pass := func() {
|
pass := func() {
|
||||||
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
|
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
|
||||||
@@ -160,14 +191,7 @@ func daemon() {
|
|||||||
say("not ready: %v", err)
|
say("not ready: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
adopted, err := m.Consider(context.Background(), heard.All())
|
||||||
all := make([]Holdings, 0, len(reports))
|
|
||||||
for _, r := range reports {
|
|
||||||
all = append(all, r)
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
|
||||||
adopted, err := m.Consider(context.Background(), all)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("considering the reports failed: %v", err)
|
say("considering the reports failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -176,48 +200,45 @@ func daemon() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
|
// What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's
|
||||||
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
|
// latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the
|
||||||
// arrive after this one.
|
// carry-over). Asked again until it answers — the channel to the runtime opens as the bundle starts.
|
||||||
go func() {
|
go func() {
|
||||||
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||||
for attempt := 0; ; attempt++ {
|
for attempt := 0; ; attempt++ {
|
||||||
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
|
err := stdio.Subscribe(HoldingsChanged, func(e stdio.Envelope) error {
|
||||||
mu.Lock()
|
if _, err := heard.Take(e.Body); err != nil {
|
||||||
if c.Op == "put" {
|
say("%v", err)
|
||||||
var h Holdings
|
return nil
|
||||||
if json.Unmarshal(c.Value, &h) == nil {
|
|
||||||
reports[c.Key] = h
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
delete(reports, c.Key)
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
// During the current values the pass waits for the whole set: newest login first needs all.
|
|
||||||
if !c.Current {
|
|
||||||
go pass()
|
|
||||||
}
|
}
|
||||||
|
go pass()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
mu.Lock()
|
say("hearing what the nodes hold")
|
||||||
n := len(reports)
|
if m, err := manager(); err != nil {
|
||||||
mu.Unlock()
|
say("not ready to ask what the nodes hold: %v", err)
|
||||||
say("watching what %d node(s) hold", n)
|
} else if err := m.AskHoldings(Itself); err != nil {
|
||||||
pass()
|
say("asking what the nodes hold could not be said: %v", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pause := time.Minute
|
pause := time.Minute
|
||||||
if attempt < len(waits) {
|
if attempt < len(waits) {
|
||||||
pause = waits[attempt]
|
pause = waits[attempt]
|
||||||
}
|
}
|
||||||
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
|
say("what the nodes hold cannot be heard yet (%v); asking again in %s", err, pause)
|
||||||
time.Sleep(pause)
|
time.Sleep(pause)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
refresh := time.NewTicker(time.Minute)
|
refresh := time.NewTicker(time.Minute)
|
||||||
usage := time.NewTicker(5 * time.Minute)
|
// Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings.
|
||||||
|
every := Defaults.UsageEvery
|
||||||
|
if m, err := manager(); err == nil {
|
||||||
|
every = m.settings().UsageEvery
|
||||||
|
}
|
||||||
|
usage := time.NewTimer(every)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-refresh.C:
|
case <-refresh.C:
|
||||||
@@ -237,7 +258,9 @@ func daemon() {
|
|||||||
if err := m.ReadUsage(context.Background()); err != nil {
|
if err := m.ReadUsage(context.Background()); err != nil {
|
||||||
say("reading usage failed: %v", err)
|
say("reading usage failed: %v", err)
|
||||||
}
|
}
|
||||||
|
every = m.settings().UsageEvery
|
||||||
}
|
}
|
||||||
|
usage.Reset(every)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +276,8 @@ func text(a map[string]any, k string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||||
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
|
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). A verb
|
||||||
|
// that acts answers nothing but "the carry-over has not ended" until it has (§11).
|
||||||
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||||
Run: func(a map[string]any) (any, error) {
|
Run: func(a map[string]any) (any, error) {
|
||||||
@@ -261,11 +285,41 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !reads[name] {
|
||||||
|
if err := m.Carry.Acts(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
|
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
|
||||||
return run(ctx, m, a)
|
return run(ctx, m, a)
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reads are the seat's verbs that only read, served while the carry-over has not ended (§11).
|
||||||
|
var reads = map[string]bool{"licences": true, "bindings": true, "usage": true, "public-key": true, "current": true}
|
||||||
|
|
||||||
|
// sinceOf is the argument since, RFC 3339; absent is the zero time.
|
||||||
|
func sinceOf(a map[string]any) (time.Time, error) {
|
||||||
|
v, _ := a["since"].(string)
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitOf is the argument limit: def when absent or not positive, at most most.
|
||||||
|
func limitOf(a map[string]any, def, most int) int {
|
||||||
|
limit := def
|
||||||
|
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
return min(limit, most)
|
||||||
|
}
|
||||||
|
|
||||||
func str(description string) map[string]any {
|
func str(description string) map[string]any {
|
||||||
return map[string]any{"type": "string", "description": description}
|
return map[string]any{"type": "string", "description": description}
|
||||||
}
|
}
|
||||||
@@ -278,11 +332,15 @@ func two(a map[string]any, k1, k2 string) (string, string, error) {
|
|||||||
|
|
||||||
func tools() []stdio.Tool {
|
func tools() []stdio.Tool {
|
||||||
consumer := str("the node's name")
|
consumer := str("the node's name")
|
||||||
return []stdio.Tool{
|
return append([]stdio.Tool{
|
||||||
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
|
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it, its latest usage with its reset times, its room or why it is not known, whether it is where a full licence's consumers move next, when and from where it was adopted, and its open conditions. Never a token.",
|
||||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
|
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
|
||||||
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
|
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.",
|
||||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
|
map[string]any{"consumer": str("a consumer; absent for every one")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
c, _ := a["consumer"].(string)
|
||||||
|
return m.BindingsOf(ctx, strings.TrimSpace(c))
|
||||||
|
}),
|
||||||
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
|
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
|
||||||
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
|
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
|
||||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
@@ -318,15 +376,18 @@ func tools() []stdio.Tool {
|
|||||||
}
|
}
|
||||||
return m.RotateDue(ctx)
|
return m.RotateDue(ctx)
|
||||||
}),
|
}),
|
||||||
verb("usage", "Usage readings, the latest first, for every licence or one.",
|
verb("usage", "Usage readings, the latest first, for every licence or one: the flattened reading with its three reset times, none before since, at most limit (default 20, at most 2000), and with raw the vendor's answer whole.",
|
||||||
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
|
map[string]any{"licence": str("a licence; absent for all"), "since": str("a time, RFC 3339: no reading before it"),
|
||||||
|
"limit": map[string]any{"type": "number", "description": "how many readings (default 20, at most 2000)"},
|
||||||
|
"raw": map[string]any{"type": "boolean", "description": "also the vendor's answer whole"}},
|
||||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
l, _ := a["licence"].(string)
|
l, _ := a["licence"].(string)
|
||||||
limit := 20
|
since, err := sinceOf(a)
|
||||||
if v, ok := a["limit"].(float64); ok && v > 0 {
|
if err != nil {
|
||||||
limit = int(v)
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.Store.Usage(ctx, l, limit)
|
raw, _ := a["raw"].(bool)
|
||||||
|
return m.Store.Usage(ctx, l, since, limitOf(a, 20, 2000), raw)
|
||||||
}),
|
}),
|
||||||
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
|
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
|
||||||
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
|
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
|
||||||
@@ -356,14 +417,14 @@ func tools() []stdio.Tool {
|
|||||||
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
|
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
return map[string]any{"public_key": m.Keys.PublicKey}, nil
|
return map[string]any{"public_key": m.Keys.PublicKey}, nil
|
||||||
}),
|
}),
|
||||||
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
|
verb("current", "For a consumer's agent module: which licence it is bound to, the licence's key, kind and generation, its health (ok, failing or expiring) and when its access token expires; never the token, which the vault hands. Null when it is bound to nothing.",
|
||||||
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
|
map[string]any{"consumer": consumer},
|
||||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
c, k, err := two(a, "consumer", "public_key")
|
c, err := text(a, "consumer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.Current(ctx, c, k)
|
return m.Current(ctx, c)
|
||||||
}),
|
}),
|
||||||
}
|
}, moduleTools()...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
|
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
|
||||||
@@ -53,17 +56,6 @@ type Holdings struct {
|
|||||||
Generation int64 `json:"generation"`
|
Generation int64 `json:"generation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
|
|
||||||
// generation.
|
|
||||||
type BindingState struct {
|
|
||||||
Licence string `json:"licence"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Generation int64 `json:"generation"`
|
|
||||||
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
By string `json:"by,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
|
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
|
||||||
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
||||||
type Cause struct {
|
type Cause struct {
|
||||||
@@ -90,63 +82,48 @@ func CauseOf(ctx context.Context) Cause {
|
|||||||
return Cause{Why: "a rule of this module's own", By: Itself}
|
return Cause{Why: "a rule of this module's own", By: Itself}
|
||||||
}
|
}
|
||||||
|
|
||||||
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
|
// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in
|
||||||
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
|
// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and
|
||||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
|
// `rotated` are said no more (B16). Never a token.
|
||||||
_ = m.Store.Audit(ctx, what, detail)
|
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
|
||||||
|
if err := m.Store.Audit(ctx, what, detail); err != nil {
|
||||||
|
m.Log("%s, but %v", line, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
m.Log("%s", line)
|
m.Log("%s", line)
|
||||||
_ = m.Emit("licence."+what, detail)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
|
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
|
||||||
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
|
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
|
||||||
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
|
func (m *Manager) changed(b Binding, from string) {
|
||||||
if from != "" {
|
key := ""
|
||||||
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
|
if b.Licence != "" {
|
||||||
|
key = KeyFor(b.Licence)
|
||||||
}
|
}
|
||||||
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
_ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
|
||||||
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
|
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
|
||||||
"why": c.Why, "by": c.By})
|
"at": m.Now().UTC().Format(time.RFC3339Nano)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
|
||||||
type Settings struct {
|
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
|
||||||
Cadence time.Duration // rotate a grant once older than this
|
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
|
||||||
Floor time.Duration // refresh in any case with less than this left
|
|
||||||
FailuresToNotify int
|
|
||||||
Cooldown time.Duration // at most one notification per licence in this window
|
|
||||||
RefreshWarn time.Duration // warn this long before a refresh token itself expires
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are the settings a fresh mesh runs with.
|
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
|
||||||
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
|
// §27's fields), and as binding.changed.
|
||||||
|
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
|
||||||
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
|
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
|
||||||
func SettingsFrom(path string) Settings {
|
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
|
||||||
s := Defaults
|
"why": c.Why, "by": c.By}
|
||||||
raw, err := os.ReadFile(path)
|
if from != "" && from != b.Licence {
|
||||||
if err != nil {
|
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
|
||||||
return s
|
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
|
||||||
}
|
}
|
||||||
var f map[string]float64
|
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
|
||||||
if json.Unmarshal(raw, &f) != nil {
|
m.changed(b, from)
|
||||||
return s
|
return err
|
||||||
}
|
|
||||||
set := func(k string, unit time.Duration, into *time.Duration) {
|
|
||||||
if v := f[k]; v > 0 {
|
|
||||||
*into = time.Duration(v * float64(unit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set("cadence_minutes", time.Minute, &s.Cadence)
|
|
||||||
set("floor_minutes", time.Minute, &s.Floor)
|
|
||||||
set("cooldown_hours", time.Hour, &s.Cooldown)
|
|
||||||
if v := f["refresh_warn_days"]; v > 0 {
|
|
||||||
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
|
|
||||||
}
|
|
||||||
if v := f["failures_to_notify"]; v > 0 {
|
|
||||||
s.FailuresToNotify = int(v)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
|
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
|
||||||
@@ -160,20 +137,38 @@ type GrantAnswer struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
Store Store
|
Store Store
|
||||||
Vendor Vendor
|
Vendor Vendor
|
||||||
Crypt *Crypt
|
// Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3).
|
||||||
|
Crypt *Crypt
|
||||||
|
// Carry is the carry-over's gate (§11): no act until it has ended. Nil is a manager with nothing to carry over.
|
||||||
|
Carry *Carry
|
||||||
|
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
|
||||||
|
Grants Grants
|
||||||
Keys KeyPair
|
Keys KeyPair
|
||||||
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
||||||
// is the node not answering; a nil Sealed is no login waiting.
|
// is the node not answering; a nil Sealed is no login waiting.
|
||||||
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
|
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
|
||||||
// PutBinding and DeleteBinding are this module's `bindings` state.
|
// Reported is the binding generation a node last reported applying (B11), 0 when none is known.
|
||||||
PutBinding func(ctx context.Context, consumer string, b BindingState) error
|
Reported func(node string) int64
|
||||||
DeleteBinding func(ctx context.Context, consumer string) error
|
Emit func(event string, body map[string]any) error
|
||||||
Emit func(event string, body map[string]any) error
|
// Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28).
|
||||||
Now func() time.Time
|
Tell func(ctx context.Context, headline string, lines []string) error
|
||||||
Log func(format string, args ...any)
|
Now func() time.Time
|
||||||
|
Log func(format string, args ...any)
|
||||||
// Holder names this process in a lease, so a second run is told apart.
|
// Holder names this process in a lease, so a second run is told apart.
|
||||||
Holder string
|
Holder string
|
||||||
Settings Settings
|
// Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile
|
||||||
|
// keeps them.
|
||||||
|
Settings Settings
|
||||||
|
SettingsFile string
|
||||||
|
settingsMu sync.RWMutex
|
||||||
|
// Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator
|
||||||
|
// channel; either nil says none.
|
||||||
|
Conditions keptState
|
||||||
|
Say func(c oc.Condition) error
|
||||||
|
conditionsMu sync.Mutex
|
||||||
|
// ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by
|
||||||
|
// setting (§40, B18); nil answers every one as the default.
|
||||||
|
ConfigLevels func(ctx context.Context) (map[string]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const leaseFor = 2 * time.Minute
|
const leaseFor = 2 * time.Minute
|
||||||
@@ -239,17 +234,9 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
|
|||||||
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
|
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
|
||||||
// exchanged. Answers what was adopted.
|
// exchanged. Answers what was adopted.
|
||||||
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
|
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
|
||||||
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
|
// **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own,
|
||||||
// counts from one again, while every node still holds the number it last applied. The nodes no longer
|
// and moves past the number its node reported applying.
|
||||||
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
|
floor := m.floorIn(reports)
|
||||||
// skipped. So the count is moved past every number reported, before anything here binds.
|
|
||||||
var highest int64
|
|
||||||
for _, r := range reports {
|
|
||||||
highest = max(highest, r.Generation)
|
|
||||||
}
|
|
||||||
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
byAccount, err := m.CandidatesIn(ctx, reports)
|
byAccount, err := m.CandidatesIn(ctx, reports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -262,19 +249,19 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
|
|||||||
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
|
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
|
||||||
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
|
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
|
||||||
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
|
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
|
||||||
if err := m.bindReporters(ctx, reports); err != nil {
|
if err := m.bindReporters(ctx, reports, floor); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var adopted []string
|
var adopted []string
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
list := byAccount[account]
|
list := byAccount[account]
|
||||||
key := "account:" + account
|
key := "account." + account
|
||||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for i, c := range list {
|
for i, c := range list {
|
||||||
name, err := m.adoptOne(ctx, c, reports)
|
name, err := m.adoptOne(ctx, c, reports, floor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.Log("adopting %s's login failed: %v", c.Node, err)
|
m.Log("adopting %s's login failed: %v", c.Node, err)
|
||||||
continue
|
continue
|
||||||
@@ -293,7 +280,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
|
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
|
||||||
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error {
|
||||||
for _, rep := range reports {
|
for _, rep := range reports {
|
||||||
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -311,21 +298,19 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
|||||||
if l == nil {
|
if l == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
|
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
||||||
|
b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
if err := m.moved(ctx, b, "", c); err != nil {
|
||||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
|
|
||||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
|
|
||||||
Why: c.Why, By: c.By}); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
|
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (string, error) {
|
||||||
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
|
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not answering is not an answer: asked again on the next pass.
|
// Not answering is not an answer: asked again on the next pass.
|
||||||
@@ -348,12 +333,34 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new licence whose key another licence already has is refused before its grant is exchanged
|
||||||
|
// (design 39 bus-and-switch §10).
|
||||||
|
if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if held == nil {
|
||||||
|
taken, err := m.keyTaken(ctx, NameFor(c.Identity))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if taken != "" {
|
||||||
|
why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken
|
||||||
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why)
|
||||||
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why})
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
|
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
|
||||||
// only holder of a live refresh token for the account.
|
// only holder of a live refresh token for the account.
|
||||||
r := m.Vendor.Refresh(ctx, offered)
|
r := m.Vendor.Refresh(ctx, offered)
|
||||||
if !r.OK {
|
if !r.OK {
|
||||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
|
||||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||||
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
|
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
|
||||||
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
|
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
|
||||||
@@ -363,7 +370,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
||||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
||||||
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
|
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
|
||||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||||
"reason": "the account the node reported is not the one the vendor answered for"})
|
"reason": "the account the node reported is not the one the vendor answered for"})
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -375,9 +384,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
}
|
}
|
||||||
now := m.Now().UnixMilli()
|
now := m.Now().UnixMilli()
|
||||||
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
||||||
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
|
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID, AdoptedFrom: c.Node}
|
||||||
if held != nil {
|
if held != nil {
|
||||||
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
|
// A newer login keeps what the key holds beside its grant: its latest reading (§2, §24).
|
||||||
|
l.Name, l.NotifiedAt, l.Generation, l.Usage = held.Name, held.NotifiedAt, held.Generation, held.Usage
|
||||||
if l.Email == "" {
|
if l.Email == "" {
|
||||||
l.Email = held.Email
|
l.Email = held.Email
|
||||||
}
|
}
|
||||||
@@ -385,7 +395,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
l.OrganizationUUID = held.OrganizationUUID
|
l.OrganizationUUID = held.OrganizationUUID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.keep(&l, r.Grant)
|
if err := m.keep(ctx, &l, r.Grant); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
l.AdoptedAt = max(now, c.ChangedAt)
|
l.AdoptedAt = max(now, c.ChangedAt)
|
||||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -395,18 +407,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
why = "a newer login"
|
why = "a newer login"
|
||||||
}
|
}
|
||||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
|
||||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||||
"vendorNamedAccount": r.Account != ""})
|
"vendorNamedAccount": r.Account != ""}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
||||||
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
||||||
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
||||||
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
||||||
ctx = WithCause(ctx, cause)
|
ctx = WithCause(ctx, cause)
|
||||||
|
// The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once
|
||||||
|
// (§21).
|
||||||
|
if err := m.publishAdvance(ctx, l, floor); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if b == nil || b.Licence != l.Name {
|
} else if b == nil || b.Licence != l.Name {
|
||||||
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
|
moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -414,7 +433,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
if b != nil {
|
if b != nil {
|
||||||
from = b.Licence
|
from = b.Licence
|
||||||
}
|
}
|
||||||
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
|
if err := m.moved(ctx, moved, from, cause); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, rep := range reports {
|
for _, rep := range reports {
|
||||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||||
@@ -423,23 +444,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
|
||||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
|
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
|
||||||
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
|
if err := m.moved(ctx, b, "", bc); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := m.publishAdvance(ctx, l); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
||||||
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
|
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
|
||||||
return l.Name, nil
|
return l.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
|
// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
|
||||||
func (m *Manager) keep(l *Licence, g FullGrant) {
|
// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
|
||||||
raw, _ := json.Marshal(g)
|
func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
|
||||||
l.Sealed = m.Crypt.Seal(string(raw))
|
grant, _ := json.Marshal(g)
|
||||||
|
if err := m.handOver(ctx, l, string(grant), accessOf(g)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
||||||
l.AccessExpiresAt = g.ExpiresAt
|
l.AccessExpiresAt = g.ExpiresAt
|
||||||
if g.RefreshTokenExpiresAt != nil {
|
if g.RefreshTokenExpiresAt != nil {
|
||||||
@@ -447,30 +470,73 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
|
|||||||
}
|
}
|
||||||
l.Failures = 0
|
l.Failures = 0
|
||||||
l.RotatedAt = m.Now().UnixMilli()
|
l.RotatedAt = m.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
// accessOf is a subscription's `access` secret: its access token and expiry, never its refresh token (§15).
|
||||||
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
|
func accessOf(g FullGrant) string {
|
||||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
||||||
bindings, err := m.Store.Advance(ctx, l.Name)
|
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
||||||
if err != nil {
|
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
||||||
return err
|
return string(access)
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
|
||||||
for _, b := range bindings {
|
// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15).
|
||||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
|
func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error {
|
||||||
Why: c.Why, By: c.By}); err != nil {
|
generation := l.Generation + 1
|
||||||
return err
|
for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} {
|
||||||
|
if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil {
|
||||||
|
return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
l.Generation = generation
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends.
|
||||||
|
func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
|
||||||
|
var g FullGrant
|
||||||
|
plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant)
|
||||||
|
if err != nil {
|
||||||
|
return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err)
|
||||||
|
}
|
||||||
|
return g, json.Unmarshal([]byte(plain), &g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why
|
||||||
|
// it fetched again (novox/hq issue 625).
|
||||||
|
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
|
||||||
|
c := CauseOf(ctx)
|
||||||
|
advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
||||||
|
for _, b := range advanced {
|
||||||
|
m.changed(b, b.Licence)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what
|
||||||
|
// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19).
|
||||||
|
func (m *Manager) floorIn(reports []Holdings) func(string) int64 {
|
||||||
|
return func(consumer string) int64 {
|
||||||
|
node, _, _ := strings.Cut(consumer, ".")
|
||||||
|
var f int64
|
||||||
|
for _, r := range reports {
|
||||||
|
if r.Node == node {
|
||||||
|
f = max(f, r.Generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Reported != nil {
|
||||||
|
f = max(f, m.Reported(node))
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- keeping grants alive ----------------------------------------------------------------------------
|
// ---- keeping grants alive ----------------------------------------------------------------------------
|
||||||
|
|
||||||
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
||||||
func Due(l Licence, now time.Time, s Settings) bool {
|
func Due(l Licence, now time.Time, s Settings) bool {
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ms := now.UnixMilli()
|
ms := now.UnixMilli()
|
||||||
@@ -484,7 +550,7 @@ func Due(l Licence, now time.Time, s Settings) bool {
|
|||||||
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
|
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
|
||||||
// whatever the age — the seat's `refresh` verb.
|
// whatever the age — the seat's `refresh` verb.
|
||||||
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
|
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
|
||||||
key := "licence:" + name
|
key := "licence." + KeyFor(name)
|
||||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -500,37 +566,38 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
|||||||
if l == nil {
|
if l == nil {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
||||||
}
|
}
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
||||||
}
|
}
|
||||||
now := m.Now()
|
now := m.Now()
|
||||||
if !force && !Due(*l, now, m.Settings) {
|
if !force && !Due(*l, now, m.settings()) {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
g, err := m.grantOf(ctx, *l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var g FullGrant
|
|
||||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r := m.Vendor.Refresh(ctx, g)
|
r := m.Vendor.Refresh(ctx, g)
|
||||||
|
g = FullGrant{}
|
||||||
if !r.OK {
|
if !r.OK {
|
||||||
l.Failures++
|
l.Failures++
|
||||||
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
||||||
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
|
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
|
||||||
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
|
return nil, err
|
||||||
|
}
|
||||||
|
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify)
|
||||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
||||||
}
|
}
|
||||||
m.keep(l, r.Grant)
|
if err := m.keep(ctx, l, r.Grant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if l.RefreshExpiresAt != 0 {
|
if l.RefreshExpiresAt != 0 {
|
||||||
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
||||||
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
||||||
left < m.Settings.RefreshWarn)
|
left < m.settings().RefreshWarn)
|
||||||
}
|
}
|
||||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -545,10 +612,12 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
||||||
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
||||||
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
|
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
|
||||||
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
|
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil {
|
||||||
if err := m.publishAdvance(ctx, *l); err != nil {
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
|
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
|
||||||
@@ -560,7 +629,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := m.Now().UnixMilli()
|
now := m.Now().UnixMilli()
|
||||||
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
|
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
l.NotifiedAt = now
|
l.NotifiedAt = now
|
||||||
@@ -575,7 +644,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
|||||||
}
|
}
|
||||||
var out []map[string]any
|
var out []map[string]any
|
||||||
for _, l := range all {
|
for _, l := range all {
|
||||||
if Due(l, m.Now(), m.Settings) {
|
if Due(l, m.Now(), m.settings()) {
|
||||||
r, err := m.Rotate(ctx, l.Name, false)
|
r, err := m.Rotate(ctx, l.Name, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -586,79 +655,165 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
|
// ReadUsage reads every subscription's usage, on the manager's cadence (§24): each reading written, the rule applied
|
||||||
|
// after it (§26), and the conditions said (§9).
|
||||||
func (m *Manager) ReadUsage(ctx context.Context) error {
|
func (m *Manager) ReadUsage(ctx context.Context) error {
|
||||||
|
_, err := m.ReadUsageOf(ctx, "", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read is one reading as licences_read_usage answers it (§38): the licence, the reading or why it failed, and what §26
|
||||||
|
// did or, with dry, would do.
|
||||||
|
type Read struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Reading *UsageReading `json:"reading,omitempty"`
|
||||||
|
Error string `json:"readError,omitempty"`
|
||||||
|
Switches []Switch `json:"switches"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadUsageOf reads the usage of one subscription, or every one when licence is empty, as §24 does, and applies §26
|
||||||
|
// after each reading unless dry; dry answers what §26 would do (§32, §38).
|
||||||
|
func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([]Read, error) {
|
||||||
|
if err := m.ReadSettings(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
all, err := m.Store.Licences(ctx)
|
all, err := m.Store.Licences(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
out := []Read{}
|
||||||
for _, l := range all {
|
for _, l := range all {
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
// The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4,
|
||||||
|
// §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31).
|
||||||
|
key := "licence." + KeyFor(l.Name)
|
||||||
|
leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return out, err
|
||||||
}
|
}
|
||||||
var g FullGrant
|
if !leased {
|
||||||
_ = json.Unmarshal([]byte(plain), &g)
|
out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}})
|
||||||
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
|
continue
|
||||||
if err != nil || raw == nil {
|
}
|
||||||
|
unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) }
|
||||||
|
if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil {
|
||||||
|
l = *fresh
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
g, err := m.grantOf(ctx, l)
|
||||||
|
if err == nil {
|
||||||
|
raw, err = m.Vendor.Usage(ctx, g.AccessToken)
|
||||||
|
}
|
||||||
|
g = FullGrant{}
|
||||||
|
if err == nil && raw == nil {
|
||||||
|
err = errors.New("the usage endpoint answered nothing")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// A failed reading keeps the last percentages and writes the vendor's status and reason (§24).
|
||||||
|
kept := UsageReading{}
|
||||||
|
if l.Usage != nil {
|
||||||
|
kept = *l.Usage
|
||||||
|
}
|
||||||
|
kept.ReadError = err.Error()
|
||||||
|
l.Usage = &kept
|
||||||
|
err := m.Store.SaveLicence(ctx, l)
|
||||||
|
unlease()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
m.Log("reading the usage of %s failed: %v", l.Name, err)
|
||||||
|
out = append(out, Read{Licence: l.Name, Error: kept.ReadError, Switches: []Switch{}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reading := FlattenUsage(raw)
|
reading := FlattenUsage(raw)
|
||||||
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
||||||
return err
|
unlease()
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
// The latest reading beside its licence, so licences answers from one key (§2).
|
||||||
|
latest := reading
|
||||||
|
latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
l.Usage = &latest
|
||||||
|
err = m.Store.SaveLicence(ctx, l)
|
||||||
|
unlease()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
}
|
}
|
||||||
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
||||||
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
|
"sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct,
|
||||||
|
"weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct,
|
||||||
|
"sonnetResetsAt": reading.SonnetResetsAt})
|
||||||
|
// On each reading, a licence at its threshold moves every consumer bound to it (§26).
|
||||||
|
acts, err := m.ApplyRule(ctx, l.Name, false, dry)
|
||||||
|
if acts == nil {
|
||||||
|
acts = []Switch{}
|
||||||
|
}
|
||||||
|
out = append(out, Read{Licence: l.Name, Reading: &latest, Switches: acts})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
// What the readings and the settings now hold is said, each condition only when what it says changed (§9).
|
||||||
|
return out, m.SayConditions(ctx, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the seat's verbs --------------------------------------------------------------------------------
|
// ---- the seat's verbs --------------------------------------------------------------------------------
|
||||||
|
|
||||||
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
|
// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7,
|
||||||
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
|
// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and
|
||||||
|
// for a subscription the account's identity the agent module points its account file at. Nil when it is bound to
|
||||||
// nothing.
|
// nothing.
|
||||||
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
|
func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) {
|
||||||
if !strings.Contains(publicKey, "PUBLIC KEY") {
|
|
||||||
return nil, errors.New("current seals to the consumer's public key, and none was given")
|
|
||||||
}
|
|
||||||
b, err := m.Store.Binding(ctx, consumer)
|
b, err := m.Store.Binding(ctx, consumer)
|
||||||
if err != nil || b == nil {
|
if err != nil || b == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
l, err := m.Store.Licence(ctx, b.Licence)
|
l, err := m.Store.Licence(ctx, b.Licence)
|
||||||
if err != nil || l == nil || l.Sealed == "" {
|
if err != nil || l == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation,
|
||||||
if err != nil {
|
"health": Health(*l, m.Now(), m.settings())}
|
||||||
return nil, err
|
if l.AccessExpiresAt != 0 {
|
||||||
|
out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
handed := plain
|
|
||||||
if l.Kind == "subscription" {
|
|
||||||
var g FullGrant
|
|
||||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
|
||||||
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
|
||||||
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
|
||||||
handed = string(access)
|
|
||||||
}
|
|
||||||
box, err := Seal(handed, publicKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
|
|
||||||
if l.AccountUUID != "" {
|
if l.AccountUUID != "" {
|
||||||
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring`
|
||||||
|
// when its refresh token expires within refresh_warn_days, else `ok`.
|
||||||
|
func Health(l Licence, now time.Time, s Settings) string {
|
||||||
|
switch {
|
||||||
|
case l.Failures >= s.FailuresToNotify:
|
||||||
|
return "failing"
|
||||||
|
case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds():
|
||||||
|
return "expiring"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged, each with
|
||||||
|
// its licence's key beside the §3 fields (B25).
|
||||||
|
func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) {
|
||||||
|
all, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Binding{}
|
||||||
|
for _, b := range all {
|
||||||
|
if consumer == "" || b.Consumer == consumer {
|
||||||
|
b.Key = KeyFor(b.Licence)
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
||||||
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
||||||
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
||||||
@@ -673,7 +828,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
b, err := m.Store.Bind(ctx, consumer, licence)
|
c := CauseOf(ctx)
|
||||||
|
b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -681,12 +837,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
|
|||||||
if before != nil {
|
if before != nil {
|
||||||
from = before.Licence
|
from = before.Licence
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
if err := m.moved(ctx, b, from, c); err != nil {
|
||||||
m.moved(ctx, consumer, from, licence, b.Generation, c)
|
|
||||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
|
|
||||||
Why: c.Why, By: c.By}); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if from != "" && from != licence {
|
||||||
|
_ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36)
|
||||||
|
}
|
||||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,12 +855,12 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
|
|||||||
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
|
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
|
||||||
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
|
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
|
||||||
}
|
}
|
||||||
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
c := CauseOf(ctx)
|
||||||
|
if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
||||||
|
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
|
||||||
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
|
||||||
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
|
|
||||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,22 +907,44 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
|||||||
if held != nil && held.Kind != "api-key" {
|
if held != nil && held.Kind != "api-key" {
|
||||||
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
|
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
|
||||||
}
|
}
|
||||||
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
|
taken, err := m.keyTaken(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if taken != "" {
|
||||||
|
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
|
||||||
|
if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
|
||||||
|
return nil, fmt.Errorf("%s is refused: %s", name, why)
|
||||||
|
}
|
||||||
|
l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli(), AdoptedFrom: from}
|
||||||
|
if held != nil {
|
||||||
|
l.Generation, l.Usage = held.Generation, held.Usage
|
||||||
|
}
|
||||||
|
if err := m.handOver(ctx, &l, key, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
c := CauseOf(ctx)
|
||||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
|
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
|
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
|
||||||
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
|
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
|
||||||
if err := m.publishAdvance(ctx, l); err != nil {
|
if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
|
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
|
||||||
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
|
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Licences is each licence as a person reads it: health and who is bound, never a token.
|
// Licences is each licence as a person reads it (design 39 bus-and-switch §34, §47, B19, B40): today's fields, its
|
||||||
|
// latest usage with its reset times, its room or why it is not known, whether it is the target a full licence's
|
||||||
|
// consumers move to next, when and from where it was adopted, and the conditions open about it. Never a token.
|
||||||
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
||||||
all, err := m.Store.Licences(ctx)
|
all, err := m.Store.Licences(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -776,27 +954,52 @@ func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
open, err := m.KeptConditions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := m.settings()
|
||||||
stamp := func(ms int64) any {
|
stamp := func(ms int64) any {
|
||||||
if ms == 0 {
|
if ms == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
targets := map[string]bool{}
|
||||||
|
for _, l := range all {
|
||||||
|
if len(consumersOf(l.Name, bindings)) > 0 {
|
||||||
|
if to, _ := m.targetFor(l, all, s); to != "" {
|
||||||
|
targets[to] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
out := []map[string]any{}
|
out := []map[string]any{}
|
||||||
for _, l := range all {
|
for _, l := range all {
|
||||||
bound := []string{}
|
bound := consumersOf(l.Name, bindings)
|
||||||
for _, b := range bindings {
|
if bound == nil {
|
||||||
if b.Licence == l.Name {
|
bound = []string{}
|
||||||
bound = append(bound, b.Consumer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
account := l.Email
|
account := l.Email
|
||||||
if account == "" {
|
if account == "" {
|
||||||
account = l.AccountUUID
|
account = l.AccountUUID
|
||||||
}
|
}
|
||||||
|
var room any = m.roomWords(l, s)
|
||||||
|
if r, known := Room(l, m.Now(), s); known {
|
||||||
|
room = r
|
||||||
|
}
|
||||||
|
conditions := []map[string]any{}
|
||||||
|
prefix := "licence." + KeyFor(l.Name) + "."
|
||||||
|
for _, c := range open {
|
||||||
|
if strings.HasPrefix(c.Body.Key, prefix) {
|
||||||
|
conditions = append(conditions, map[string]any{"key": c.Body.Key, "severity": c.Body.Severity,
|
||||||
|
"headline": c.Body.Headline, "since": c.Since})
|
||||||
|
}
|
||||||
|
}
|
||||||
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
||||||
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
|
"organisation": l.OrganizationUUID, "accessExpiresAt": stamp(l.AccessExpiresAt),
|
||||||
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
|
"refreshExpiresAt": stamp(l.RefreshExpiresAt), "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures,
|
||||||
|
"bound": bound, "usage": l.Usage, "room": room, "target": targets[l.Name], "adoptedAt": stamp(l.AdoptedAt),
|
||||||
|
"adoptedFrom": l.AdoptedFrom, "conditions": conditions})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ type stubVendor struct {
|
|||||||
issued int
|
issued int
|
||||||
account string
|
account string
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
// usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone.
|
||||||
|
usage map[string]any
|
||||||
|
usageErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
||||||
@@ -44,31 +47,90 @@ func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
||||||
|
if v.usageErr != nil {
|
||||||
|
return nil, v.usageErr
|
||||||
|
}
|
||||||
|
if v.usage != nil {
|
||||||
|
return v.usage, nil
|
||||||
|
}
|
||||||
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept.
|
||||||
|
type memGrants struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
held map[string]heldGrant
|
||||||
|
puts []string
|
||||||
|
// refuseAccess is the vault refusing a licence's access token to the manager's machine, as it does for a licence no
|
||||||
|
// binding there names (§17).
|
||||||
|
refuseAccess bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type heldGrant struct {
|
||||||
|
generation int64
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
name := SecretName(key, part)
|
||||||
|
if h, ok := g.held[name]; ok && h.generation == generation && h.value == value {
|
||||||
|
return nil // the vault keeps what it holds, unchanged
|
||||||
|
} else if ok && h.generation >= generation {
|
||||||
|
return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation)
|
||||||
|
}
|
||||||
|
g.held[name] = heldGrant{generation, value}
|
||||||
|
g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
h, ok := g.held[SecretName(key, part)]
|
||||||
|
if g.refuseAccess && part == PartAccess {
|
||||||
|
return "", fmt.Errorf("no consumer on this machine is bound to the licence %s", key)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part))
|
||||||
|
}
|
||||||
|
return h.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
type miniMesh struct {
|
type miniMesh struct {
|
||||||
m *Manager
|
grants *memGrants
|
||||||
store *MemoryStore
|
m *Manager
|
||||||
vendor *stubVendor
|
store *BusStore
|
||||||
logins map[string]FullGrant // node → what its credentials file holds
|
usage *memLog
|
||||||
state map[string]BindingState
|
audit *memLog
|
||||||
events []string
|
licences *memState
|
||||||
now time.Time
|
bindings *memState
|
||||||
keys KeyPair
|
leases *memState
|
||||||
askDown bool
|
offered *memState
|
||||||
|
vendor *stubVendor
|
||||||
|
logins map[string]FullGrant // node → what its credentials file holds
|
||||||
|
events []string
|
||||||
|
now time.Time
|
||||||
|
keys KeyPair
|
||||||
|
askDown bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMesh(t *testing.T) *miniMesh {
|
func newMesh(t *testing.T) *miniMesh {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
|
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
|
||||||
|
grants: &memGrants{held: map[string]heldGrant{}}}
|
||||||
now := func() time.Time { return mm.now }
|
now := func() time.Time { return mm.now }
|
||||||
mm.store = NewMemoryStore(now)
|
mm.usage, mm.audit = newMemLog(now), newMemLog(now)
|
||||||
|
mm.licences, mm.bindings = newMemState(now), newMemState(now)
|
||||||
|
mm.leases, mm.offered = newMemState(now), newMemState(now)
|
||||||
|
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases,
|
||||||
|
OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
|
||||||
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
||||||
crypt, _ := NewCrypt("a key the vault made")
|
crypt, _ := NewCrypt("a key the vault made")
|
||||||
mm.keys, _ = GenerateKeyPair()
|
mm.keys, _ = GenerateKeyPair()
|
||||||
mm.m = &Manager{
|
mm.m = &Manager{
|
||||||
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
|
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys,
|
||||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
if mm.askDown {
|
if mm.askDown {
|
||||||
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
||||||
@@ -81,8 +143,6 @@ func newMesh(t *testing.T) *miniMesh {
|
|||||||
box, err := Seal(string(raw), publicKey)
|
box, err := Seal(string(raw), publicKey)
|
||||||
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
|
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
|
||||||
},
|
},
|
||||||
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
|
|
||||||
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
|
|
||||||
Emit: func(event string, body map[string]any) error {
|
Emit: func(event string, body map[string]any) error {
|
||||||
raw, _ := json.Marshal(body)
|
raw, _ := json.Marshal(body)
|
||||||
mm.events = append(mm.events, event+" "+string(raw))
|
mm.events = append(mm.events, event+" "+string(raw))
|
||||||
@@ -93,6 +153,15 @@ func newMesh(t *testing.T) *miniMesh {
|
|||||||
return mm
|
return mm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bound is a consumer's value in the state bindings, as its readers decode it.
|
||||||
|
func (mm *miniMesh) bound(consumer string) BindingState {
|
||||||
|
var b BindingState
|
||||||
|
if e, _ := mm.bindings.Get(consumer); e != nil {
|
||||||
|
_ = json.Unmarshal(e.Value, &b)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
|
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
|
||||||
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
|
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
|
||||||
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
||||||
@@ -129,15 +198,14 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
|
|||||||
}
|
}
|
||||||
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
||||||
bs, _ := mm.store.Bindings(ctx)
|
bs, _ := mm.store.Bindings(ctx)
|
||||||
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
||||||
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
t.Fatalf("bindings %v, state %v", bs, mm.bindings.values)
|
||||||
}
|
}
|
||||||
// The adoption is said, and so is each binding it made (novox/hq issue 625).
|
// The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21).
|
||||||
events := strings.Join(mm.events, "|")
|
events := strings.Join(mm.events, "|")
|
||||||
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
|
for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} {
|
||||||
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
|
if strings.Count(events, want) != 1 {
|
||||||
if !strings.Contains(events, want) {
|
t.Fatalf("events say %s other than once: %v", want, mm.events)
|
||||||
t.Fatalf("events lack %s: %v", want, mm.events)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +237,12 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
||||||
before, _ := mm.store.Licence(ctx, licence1)
|
before, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
// §2: a licence says where it was adopted from, and a newer login keeps the latest reading the key holds.
|
||||||
|
pct := 42.0
|
||||||
|
before.Usage = &UsageReading{SessionPct: &pct, ReadAt: t0.Format(time.RFC3339Nano)}
|
||||||
|
if before.AdoptedFrom != "laptop" || mm.store.SaveLicence(ctx, *before) != nil {
|
||||||
|
t.Fatalf("the adopted licence is %+v", before)
|
||||||
|
}
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
||||||
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
||||||
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
||||||
@@ -179,6 +253,9 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
|||||||
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
||||||
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
||||||
}
|
}
|
||||||
|
if after.AdoptedFrom != "desktop" || after.Usage == nil || after.Usage.SessionPct == nil || *after.Usage.SessionPct != 42 {
|
||||||
|
t.Fatalf("after a newer login the licence is %+v", after)
|
||||||
|
}
|
||||||
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
||||||
t.Fatalf("one account became %d licences", len(ls))
|
t.Fatalf("one account became %d licences", len(ls))
|
||||||
}
|
}
|
||||||
@@ -201,11 +278,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
mm.now = t0.Add(5 * time.Hour)
|
mm.now = t0.Add(5 * time.Hour)
|
||||||
second := *mm.m
|
second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys,
|
||||||
second.Holder = "another run"
|
AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run",
|
||||||
|
Settings: mm.m.Settings}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
results := make([]map[string]any, 2)
|
results := make([]map[string]any, 2)
|
||||||
for i, m := range []*Manager{mm.m, &second} {
|
for i, m := range []*Manager{mm.m, second} {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
||||||
}
|
}
|
||||||
@@ -228,9 +306,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
|||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
g0 := mm.state["laptop"].Generation
|
g0 := mm.bound("laptop").Generation
|
||||||
_, _ = mm.m.Rotate(ctx, licence1, true)
|
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||||
g1 := mm.state["laptop"].Generation
|
g1 := mm.bound("laptop").Generation
|
||||||
if g1 <= g0 {
|
if g1 <= g0 {
|
||||||
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
||||||
}
|
}
|
||||||
@@ -242,43 +320,54 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
|||||||
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 {
|
||||||
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop"))
|
||||||
}
|
}
|
||||||
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := mm.state["laptop"]; ok {
|
if _, ok := mm.bindings.values["laptop"]; ok {
|
||||||
t.Fatal("a released consumer still has a binding in the state")
|
t.Fatal("a released consumer still has a binding in the state")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
|
// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health.
|
||||||
|
func TestCurrentHoldsNoToken(t *testing.T) {
|
||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
node, _ := GenerateKeyPair()
|
answer, err := mm.m.Current(ctx, "laptop")
|
||||||
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
|
if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" ||
|
||||||
if err != nil || answer["kind"] != "subscription" {
|
answer["accessExpiresAt"] == nil || answer["identity"] == nil {
|
||||||
t.Fatalf("%v %v", answer, err)
|
t.Fatalf("%v %v", answer, err)
|
||||||
}
|
}
|
||||||
box := answer["sealed"].(SealedBox)
|
raw, _ := json.Marshal(answer)
|
||||||
plain, err := Open(box, node.PrivateKey)
|
for _, token := range []string{"rt-", "at-", "sealed"} {
|
||||||
if err != nil {
|
if strings.Contains(string(raw), token) {
|
||||||
t.Fatal(err)
|
t.Fatalf("current answered %s: %s", token, raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var handed map[string]any
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
_ = json.Unmarshal([]byte(plain), &handed)
|
for failures, health := range map[int]string{0: "ok", 3: "failing"} {
|
||||||
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
|
l.Failures = failures
|
||||||
t.Fatalf("handed %v", handed)
|
if got := Health(*l, mm.now, mm.m.Settings); got != health {
|
||||||
|
t.Errorf("%d failures: %s, want %s", failures, got, health)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other, _ := GenerateKeyPair()
|
l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli()
|
||||||
if _, err := Open(box, other.PrivateKey); err == nil {
|
if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" {
|
||||||
t.Fatal("the hand-over opened with another key")
|
t.Errorf("a refresh token expiring in two days: %s", got)
|
||||||
}
|
}
|
||||||
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
|
if a, _ := mm.m.Current(ctx, "nobody"); a != nil {
|
||||||
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
||||||
}
|
}
|
||||||
|
if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" {
|
||||||
|
t.Fatalf("bindings of laptop: %v", bs)
|
||||||
|
}
|
||||||
|
// design 39 bus-and-switch B25: the verb bindings answers each binding's licence key beside the §3 fields.
|
||||||
|
if bs, _ := mm.m.BindingsOf(ctx, ""); len(bs) == 0 || bs[0].Key != KeyFor(bs[0].Licence) {
|
||||||
|
t.Fatalf("bindings answered %+v", bs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
||||||
@@ -290,15 +379,15 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
|||||||
_ = mm.m.ReadUsage(ctx)
|
_ = mm.m.ReadUsage(ctx)
|
||||||
ls, _ := mm.m.Licences(ctx)
|
ls, _ := mm.m.Licences(ctx)
|
||||||
bs, _ := mm.store.Bindings(ctx)
|
bs, _ := mm.store.Bindings(ctx)
|
||||||
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
|
everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs})
|
||||||
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
|
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
|
||||||
if strings.Contains(string(everything), token) {
|
if strings.Contains(string(everything), token) {
|
||||||
t.Fatalf("%s was published: %s", token, everything)
|
t.Fatalf("%s was published: %s", token, everything)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row, _ := mm.store.Licence(ctx, licence1)
|
row, _ := mm.store.Licence(ctx, licence1)
|
||||||
if strings.Contains(row.Sealed, "rt-") {
|
if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
|
||||||
t.Fatal("the grant is stored in the clear")
|
t.Fatalf("the licence keeps a token: %s", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,22 +413,40 @@ func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
// design 39 bus-and-switch §23, §46, B12, B22: a holdings-changed, decoded from a copy of the agent module's form, is
|
||||||
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
|
// kept as its node's latest report, and one carrying a fingerprint the manager does not hold is a candidate; the
|
||||||
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
|
// manager asks every node with holdings.asked, its time and who asks.
|
||||||
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
|
func TestAHoldingsChangedIsHeardAndMakesACandidate(t *testing.T) {
|
||||||
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
|
mm := newMesh(t)
|
||||||
"changedAt":"2026-10-04T11:00:00.000Z"}`
|
raw, err := os.ReadFile("testdata/holdings-changed.json")
|
||||||
var h Holdings
|
if err != nil {
|
||||||
if err := json.Unmarshal([]byte(raw), &h); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
heard := &Heard{}
|
||||||
|
if _, err := heard.Take(raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all := heard.All()
|
||||||
|
if len(all) != 1 || heard.Of("laptop") != 4 {
|
||||||
|
t.Fatalf("heard %+v", all)
|
||||||
|
}
|
||||||
|
h := all[0]
|
||||||
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
||||||
t.Fatalf("%+v", h)
|
t.Fatalf("%+v", h)
|
||||||
}
|
}
|
||||||
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
||||||
t.Fatalf("the report's time does not parse: %v", err)
|
t.Fatalf("the report's time does not parse: %v", err)
|
||||||
}
|
}
|
||||||
|
if c, err := mm.m.CandidatesIn(context.Background(), all); err != nil || len(c["u-1"]) != 1 {
|
||||||
|
t.Fatalf("candidates %v %v", c, err)
|
||||||
|
}
|
||||||
|
if _, err := heard.Take([]byte(`{"kind":"subscription"}`)); err == nil {
|
||||||
|
t.Fatal("a report naming no node was kept")
|
||||||
|
}
|
||||||
|
if err := mm.m.AskHoldings(Itself); err != nil || len(mm.events) != 1 ||
|
||||||
|
mm.events[0] != HoldingsAsked+` {"at":"`+t0.UTC().Format(time.RFC3339Nano)+`","by":"claude-licence-manager"}` {
|
||||||
|
t.Fatalf("said %v %v", mm.events, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
||||||
@@ -350,15 +457,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
|||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
||||||
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||||
if mm.state["laptop"].Licence != licence1 {
|
if mm.bound("laptop").Licence != licence1 {
|
||||||
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
|
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values)
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
|
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
|
||||||
t.Fatal("the older login was exchanged")
|
t.Fatal("the older login was exchanged")
|
||||||
}
|
}
|
||||||
g := mm.state["laptop"].Generation
|
g := mm.bound("laptop").Generation
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||||
if mm.state["laptop"].Generation != g {
|
if mm.bound("laptop").Generation != g {
|
||||||
t.Fatal("a node already bound was bound again")
|
t.Fatal("a node already bound was bound again")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,8 +475,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
|
|||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
|
||||||
if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
||||||
t.Fatalf("%v", mm.state)
|
t.Fatalf("%v", mm.bindings.values)
|
||||||
}
|
}
|
||||||
mm.now = t0.Add(time.Hour)
|
mm.now = t0.Add(time.Hour)
|
||||||
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
|
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
|
||||||
@@ -379,11 +486,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
|
|||||||
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
|
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
|
||||||
t.Fatalf("adopted %v", adopted)
|
t.Fatalf("adopted %v", adopted)
|
||||||
}
|
}
|
||||||
if mm.state["laptop"].Licence != "acct-2@example.org" {
|
if mm.bound("laptop").Licence != "acct-2@example.org" {
|
||||||
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
|
t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop"))
|
||||||
}
|
}
|
||||||
if mm.state["server"].Licence != licence1 {
|
if mm.bound("server").Licence != licence1 {
|
||||||
t.Fatalf("another node moved: %v", mm.state["server"])
|
t.Fatalf("another node moved: %v", mm.bound("server"))
|
||||||
}
|
}
|
||||||
ls, _ := mm.store.Licences(ctx)
|
ls, _ := mm.store.Licences(ctx)
|
||||||
if len(ls) != 2 {
|
if len(ls) != 2 {
|
||||||
@@ -396,11 +503,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
|
|||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
g := mm.state["laptop"]
|
g := mm.bound("laptop")
|
||||||
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
|
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
|
||||||
if mm.state["laptop"] != g {
|
if mm.bound("laptop") != g {
|
||||||
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
|
t.Fatalf("a dead login moved its node: %v", mm.bound("laptop"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,9 +520,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
|
|||||||
if err != nil || r["adopted"] != true {
|
if err != nil || r["adopted"] != true {
|
||||||
t.Fatalf("%v %v", r, err)
|
t.Fatalf("%v %v", r, err)
|
||||||
}
|
}
|
||||||
l, _ := mm.store.Licence(ctx, "api")
|
for _, part := range []string{PartGrant, PartAccess} {
|
||||||
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
||||||
t.Fatalf("stored %q", plain)
|
t.Fatalf("the vault holds %q as the %s", plain, part)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other, _ := GenerateKeyPair()
|
other, _ := GenerateKeyPair()
|
||||||
wrong, _ := Seal("sk", other.PublicKey)
|
wrong, _ := Seal("sk", other.PublicKey)
|
||||||
@@ -439,7 +547,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
|
|||||||
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
|
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if g := mm.state["laptop"].Generation; g <= 16 {
|
if g := mm.bound("laptop").Generation; g <= 16 {
|
||||||
t.Fatalf("the laptop applied generation 16 and was given %d", g)
|
t.Fatalf("the laptop applied generation 16 and was given %d", g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,28 +568,63 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changedSince is the binding.changed events said from mark on, decoded.
|
||||||
|
func (mm *miniMesh) changedSince(mark int) []map[string]any {
|
||||||
|
var out []map[string]any
|
||||||
|
for _, e := range mm.events[mark:] {
|
||||||
|
if body, ok := strings.CutPrefix(e, "binding.changed "); ok {
|
||||||
|
var v map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(body), &v)
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
||||||
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
|
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39
|
||||||
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key,
|
||||||
|
// after its key is written or deleted; a switch says one licence.switched with §27's fields.
|
||||||
|
func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) {
|
||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
var lines []string
|
var lines []string
|
||||||
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
once := func(mark int, consumer, licence, by string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
got := mm.changedSince(mark)
|
||||||
|
if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by {
|
||||||
|
t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by)
|
||||||
|
}
|
||||||
|
if licence != "" && got[0]["key"] != KeyFor(licence) {
|
||||||
|
t.Fatalf("binding.changed names the key %v", got[0]["key"])
|
||||||
|
}
|
||||||
|
if mm.bound(consumer).Licence != licence {
|
||||||
|
t.Fatalf("said before the key was written: %+v", mm.bound(consumer))
|
||||||
|
}
|
||||||
|
return got[0]
|
||||||
|
}
|
||||||
|
mark := len(mm.events)
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
||||||
t.Fatalf("a login's binding said %+v", s)
|
t.Fatalf("a login's binding said %+v", s)
|
||||||
}
|
}
|
||||||
|
once(mark, "laptop", licence1, Itself)
|
||||||
file := filepath.Join(t.TempDir(), "key")
|
file := filepath.Join(t.TempDir(), "key")
|
||||||
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
||||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mark = len(mm.events)
|
||||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
||||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil {
|
||||||
|
t.Fatalf("the switch said %v", e)
|
||||||
|
}
|
||||||
|
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||||
t.Fatalf("the node is told %+v", s)
|
t.Fatalf("the node is told %+v", s)
|
||||||
}
|
}
|
||||||
said := strings.Join(lines, "\n")
|
said := strings.Join(lines, "\n")
|
||||||
@@ -489,34 +632,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
|||||||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
||||||
t.Fatalf("the journal said:\n%s", said)
|
t.Fatalf("the journal said:\n%s", said)
|
||||||
}
|
}
|
||||||
var switched string
|
var switched []string
|
||||||
for _, e := range mm.events {
|
for _, e := range mm.events {
|
||||||
if strings.HasPrefix(e, "licence.switched ") {
|
if strings.HasPrefix(e, "licence.switched ") {
|
||||||
switched = e
|
switched = append(switched, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
if len(switched) != 1 {
|
||||||
if !strings.Contains(switched, want) {
|
t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events)
|
||||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
}
|
||||||
|
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} {
|
||||||
|
if !strings.Contains(switched[0], want) {
|
||||||
|
t.Fatalf("the event %q lacks %s", switched[0], want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
last := mm.store.Audits[len(mm.store.Audits)-1]
|
all := mm.audit.under("switched")
|
||||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
|
||||||
t.Fatalf("the audit kept %s", raw)
|
t.Fatalf("the audit kept %s", raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, _ = mm.m.Bind(ctx, "server", licence1)
|
||||||
|
mark = len(mm.events)
|
||||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
||||||
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
|
once(mark, "server", licence1, "novox.node-tools")
|
||||||
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
mark = len(mm.events)
|
||||||
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" {
|
||||||
|
t.Fatalf("the release said %v", e)
|
||||||
|
}
|
||||||
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
||||||
t.Fatalf("the journal said:\n%s", said)
|
t.Fatalf("the journal said:\n%s", said)
|
||||||
}
|
}
|
||||||
for _, e := range mm.events {
|
for _, e := range mm.events {
|
||||||
|
for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} {
|
||||||
|
if strings.HasPrefix(e, gone) {
|
||||||
|
t.Fatalf("an event no longer said was said: %s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
||||||
t.Fatalf("an event carries a secret: %s", e)
|
t.Fatalf("an event carries a secret: %s", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
|
||||||
|
// step — and asks for each piece of its kept data by template.
|
||||||
|
func TestTheManifestKeepsNoOldForm(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, old := range []string{"reads", "state"} {
|
||||||
|
if _, ok := m[old]; ok {
|
||||||
|
t.Errorf("the manifest keeps the old section %q", old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var resources []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(m["resources"], &resources)
|
||||||
|
for _, r := range resources {
|
||||||
|
if r.ID == "prepare" {
|
||||||
|
t.Error("the manifest keeps the run-once step prepare")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var requires []json.RawMessage
|
||||||
|
_ = json.Unmarshal(m["requires"], &requires)
|
||||||
|
kept := map[string]string{}
|
||||||
|
for _, r := range requires {
|
||||||
|
var e struct{ Name, Template string }
|
||||||
|
if json.Unmarshal(r, &e) == nil {
|
||||||
|
kept[e.Name] = e.Template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
|
||||||
|
"offered": "state", "usage": "log", "audit": "log", "carried": "state", "conditions": "state"} {
|
||||||
|
if kept[name] != template {
|
||||||
|
t.Errorf("bus-state entry %s is %q, want template %s", name, kept[name], template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +1,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
|
// Today's database, read once by the carry-over and never written (design 39 bus-and-switch §1, §11, A3): the
|
||||||
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
|
// module's own, opened with the credential the mesh still provisions for it alone. Every query is a select; a table
|
||||||
// statement idempotent.
|
// that does not exist (a fresh install) is read as no rows.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema is what this version needs.
|
// PgSource reads today's database.
|
||||||
var Schema = []string{
|
type PgSource struct{ pool *pgxpool.Pool }
|
||||||
`create table if not exists licence (
|
|
||||||
name text primary key,
|
|
||||||
kind text not null check (kind in ('subscription', 'api-key')),
|
|
||||||
account_uuid text unique,
|
|
||||||
email text,
|
|
||||||
organization_uuid text,
|
|
||||||
sealed text,
|
|
||||||
refresh_fingerprint text,
|
|
||||||
access_expires_at bigint,
|
|
||||||
refresh_expires_at bigint,
|
|
||||||
failures integer not null default 0,
|
|
||||||
notified_at bigint,
|
|
||||||
adopted_at bigint not null,
|
|
||||||
rotated_at bigint)`,
|
|
||||||
`create sequence if not exists binding_generation`,
|
|
||||||
`create table if not exists binding (
|
|
||||||
consumer text primary key,
|
|
||||||
licence text not null references licence(name),
|
|
||||||
generation bigint not null)`,
|
|
||||||
`create table if not exists lease (
|
|
||||||
key text primary key,
|
|
||||||
holder text not null,
|
|
||||||
until timestamptz not null)`,
|
|
||||||
`create table if not exists offered (
|
|
||||||
fingerprint text primary key,
|
|
||||||
node text not null,
|
|
||||||
account_uuid text,
|
|
||||||
outcome text not null,
|
|
||||||
why text not null,
|
|
||||||
at timestamptz not null default now())`,
|
|
||||||
`create table if not exists usage (
|
|
||||||
licence text not null,
|
|
||||||
at bigint not null,
|
|
||||||
reading jsonb not null,
|
|
||||||
raw jsonb not null)`,
|
|
||||||
`create index if not exists usage_by_licence on usage (licence, at desc)`,
|
|
||||||
`create table if not exists audit (
|
|
||||||
at timestamptz not null default now(),
|
|
||||||
what text not null,
|
|
||||||
detail jsonb not null)`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// PgStore is the store on postgres.
|
// PgSourceFromEnv opens the database the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
|
||||||
type PgStore struct{ pool *pgxpool.Pool }
|
func PgSourceFromEnv(ctx context.Context) (*PgSource, error) {
|
||||||
|
|
||||||
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
|
|
||||||
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
|
||||||
file := os.Getenv("DATABASE_URL_FILE")
|
file := os.Getenv("DATABASE_URL_FILE")
|
||||||
if file == "" {
|
if file == "" {
|
||||||
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
|
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
|
||||||
@@ -74,218 +30,132 @@ func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
|
return OpenPgSource(ctx, strings.TrimSpace(string(raw)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenPgStore opens a store at a URL.
|
// OpenPgSource opens a database at a URL, and reaches it once, so an unreachable one fails here.
|
||||||
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
|
func OpenPgSource(ctx context.Context, url string) (*PgSource, error) {
|
||||||
pool, err := pgxpool.New(ctx, url)
|
pool, err := pgxpool.New(ctx, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PgStore{pool: pool}, nil
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PgSource{pool: pool}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate brings the schema to this version's shape.
|
// Close lets the database go.
|
||||||
func (s *PgStore) Migrate(ctx context.Context) error {
|
func (s *PgSource) Close() { s.pool.Close() }
|
||||||
for _, q := range Schema {
|
|
||||||
if _, err := s.pool.Exec(ctx, q); err != nil {
|
// noTable is whether err is postgres saying a table does not exist (SQLSTATE 42P01).
|
||||||
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
|
func noTable(err error) bool {
|
||||||
|
var pg *pgconn.PgError
|
||||||
|
return errors.As(err, &pg) && pg.Code == "42P01"
|
||||||
|
}
|
||||||
|
|
||||||
|
// each runs a select and scans each row; a table that does not exist is no rows.
|
||||||
|
func (s *PgSource) each(ctx context.Context, q string, scan func(pgx.Rows) error, args ...any) error {
|
||||||
|
rows, err := s.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
if noTable(err) {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
if err := scan(rows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil && !noTable(err) {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
|
func (s *PgSource) Licences(ctx context.Context) ([]LicenceRow, error) {
|
||||||
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
|
out := []LicenceRow{}
|
||||||
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
|
err := s.each(ctx, `select name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
|
||||||
|
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0),
|
||||||
func scanLicence(row pgx.Row) (*Licence, error) {
|
coalesce(refresh_expires_at,0), failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)
|
||||||
var l Licence
|
from licence order by adopted_at, name`, func(r pgx.Rows) error {
|
||||||
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
var l LicenceRow
|
||||||
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
err := r.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
||||||
return nil, nil
|
out = append(out, l)
|
||||||
}
|
return err
|
||||||
return &l, err
|
})
|
||||||
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
|
func (s *PgSource) Bindings(ctx context.Context) ([]Binding, error) {
|
||||||
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
|
out := []Binding{}
|
||||||
if err != nil {
|
err := s.each(ctx, `select consumer, licence, generation from binding order by generation, consumer`,
|
||||||
return nil, err
|
func(r pgx.Rows) error {
|
||||||
}
|
var b Binding
|
||||||
defer rows.Close()
|
err := r.Scan(&b.Consumer, &b.Licence, &b.Generation)
|
||||||
var out []Licence
|
out = append(out, b)
|
||||||
for rows.Next() {
|
return err
|
||||||
l, err := scanLicence(rows)
|
})
|
||||||
if err != nil {
|
return out, err
|
||||||
return nil, err
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Leases(ctx context.Context) ([]LeaseRow, error) {
|
||||||
|
out := []LeaseRow{}
|
||||||
|
err := s.each(ctx, `select key, holder, until from lease order by key`, func(r pgx.Rows) error {
|
||||||
|
var l LeaseRow
|
||||||
|
err := r.Scan(&l.Key, &l.Holder, &l.Until)
|
||||||
|
out = append(out, l)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Offered(ctx context.Context) ([]OfferedRow, error) {
|
||||||
|
out := []OfferedRow{}
|
||||||
|
err := s.each(ctx, `select fingerprint, node, coalesce(account_uuid,''), outcome, why, at from offered
|
||||||
|
order by at, fingerprint`, func(r pgx.Rows) error {
|
||||||
|
var o OfferedRow
|
||||||
|
var outcome string
|
||||||
|
err := r.Scan(&o.Fingerprint, &o.Node, &o.Account, &outcome, &o.Why, &o.At)
|
||||||
|
o.Outcome = Outcome(outcome)
|
||||||
|
out = append(out, o)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) {
|
||||||
|
out := []UsageTableRow{}
|
||||||
|
err := s.each(ctx, `select licence, at, reading, raw from usage where at >= $1 order by at, licence`,
|
||||||
|
func(r pgx.Rows) error {
|
||||||
|
var u UsageTableRow
|
||||||
|
var reading, raw []byte
|
||||||
|
if err := r.Scan(&u.Licence, &u.At, &reading, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(reading, &u.Reading)
|
||||||
|
_ = json.Unmarshal(raw, &u.Raw)
|
||||||
|
out = append(out, u)
|
||||||
|
return nil
|
||||||
|
}, since.UnixMilli())
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Audit(ctx context.Context) ([]AuditRow, error) {
|
||||||
|
out := []AuditRow{}
|
||||||
|
err := s.each(ctx, `select at, what, detail from audit order by at`, func(r pgx.Rows) error {
|
||||||
|
var a AuditRow
|
||||||
|
var detail []byte
|
||||||
|
if err := r.Scan(&a.At, &a.What, &detail); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
out = append(out, *l)
|
_ = json.Unmarshal(detail, &a.Detail)
|
||||||
}
|
out = append(out, a)
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
|
|
||||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
|
|
||||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullable(s string) any {
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
})
|
||||||
return s
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func nullableInt(v int64) any {
|
|
||||||
if v == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
|
|
||||||
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
|
|
||||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
||||||
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
|
|
||||||
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
|
|
||||||
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
|
|
||||||
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
|
|
||||||
rotated_at = excluded.rotated_at`,
|
|
||||||
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
|
|
||||||
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
|
|
||||||
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
|
|
||||||
// it live and does nothing.
|
|
||||||
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
|
|
||||||
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
|
|
||||||
on conflict (key) do update set holder = excluded.holder, until = excluded.until
|
|
||||||
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return tag.RowsAffected() == 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []Binding
|
|
||||||
for rows.Next() {
|
|
||||||
var b Binding
|
|
||||||
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, b)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
|
|
||||||
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
|
|
||||||
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
|
|
||||||
if err != nil || len(out) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &out[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
|
|
||||||
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
|
|
||||||
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
|
|
||||||
returning consumer, licence, generation`, consumer, licence)
|
|
||||||
if err != nil {
|
|
||||||
return Binding{}, err
|
|
||||||
}
|
|
||||||
return out[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
|
|
||||||
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
|
|
||||||
return err == nil && tag.RowsAffected() == 1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
|
|
||||||
if generation <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// setval with is_called, so the next nextval is generation+1; only ever forward.
|
|
||||||
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
|
|
||||||
where not is_called or last_value < $1`, generation)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
|
|
||||||
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
|
|
||||||
returning consumer, licence, generation`, licence)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
|
|
||||||
var o string
|
|
||||||
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return Outcome(o), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
|
|
||||||
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
|
|
||||||
fp, node, nullable(account), string(o), why)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
|
||||||
reading, _ := json.Marshal(r)
|
|
||||||
rawJSON, _ := json.Marshal(raw)
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []UsageRow
|
|
||||||
for rows.Next() {
|
|
||||||
var u UsageRow
|
|
||||||
var reading []byte
|
|
||||||
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(reading, &u.Reading)
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
|
|
||||||
raw, _ := json.Marshal(detail)
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Close() { s.pool.Close() }
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in
|
||||||
|
// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
|
||||||
|
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
|
||||||
|
// so it is never a target.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Room is a licence's room at now, and whether it is known.
|
||||||
|
func Room(l Licence, now time.Time, s Settings) (float64, bool) {
|
||||||
|
if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
|
||||||
|
if err != nil || now.Sub(read) > s.UsageStale {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time.
|
||||||
|
var vendorUsage = map[string]any{
|
||||||
|
"five_hour": map[string]any{"utilization": 41.0, "resets_at": "2026-11-11T23:00:00Z"},
|
||||||
|
"seven_day": map[string]any{"utilization": 97.0, "resets_at": "2026-11-14T09:00:00Z"},
|
||||||
|
"seven_day_sonnet": map[string]any{"utilization": 12.0, "resets_at": "2026-11-15T09:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §24/AC1: a vendor answer is flattened with its three reset times, kept beside the licence
|
||||||
|
// with when it was read, and said as usage.read with those fields.
|
||||||
|
func TestAReadingIsFlattenedWithThreeResets(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
mm.vendor.usage = vendorUsage
|
||||||
|
mm.events = nil
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
u := l.Usage
|
||||||
|
if u == nil || *u.SessionPct != 41 || u.SessionResetsAt != "2026-11-11T23:00:00Z" || *u.WeeklyPct != 97 ||
|
||||||
|
u.WeeklyResetsAt != "2026-11-14T09:00:00Z" || *u.SonnetPct != 12 || u.SonnetResetsAt != "2026-11-15T09:00:00Z" ||
|
||||||
|
u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || u.ReadError != "" {
|
||||||
|
t.Fatalf("kept %+v", u)
|
||||||
|
}
|
||||||
|
said := strings.Join(mm.events, "\n")
|
||||||
|
for _, want := range []string{`"weeklyResetsAt":"2026-11-14T09:00:00Z"`, `"sonnetResetsAt":"2026-11-15T09:00:00Z"`,
|
||||||
|
`"sessionResetsAt":"2026-11-11T23:00:00Z"`} {
|
||||||
|
if !strings.Contains(said, "usage.read ") || !strings.Contains(said, want) {
|
||||||
|
t.Fatalf("said %s, lacking %s", said, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §24/AC2: a failed answer keeps the last percentages and their time, writes the vendor's
|
||||||
|
// status and reason, and appends no reading.
|
||||||
|
func TestAFailedReadingKeepsThePercentages(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
mm.vendor.usage = vendorUsage
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
mm.now = t0.Add(5 * time.Minute)
|
||||||
|
mm.vendor.usageErr = errors.New(`the usage endpoint answered 429: {"error":"rate_limited"}`)
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
u := l.Usage
|
||||||
|
if u == nil || *u.WeeklyPct != 97 || u.ReadAt != t0.UTC().Format(time.RFC3339Nano) ||
|
||||||
|
u.ReadError != `the usage endpoint answered 429: {"error":"rate_limited"}` {
|
||||||
|
t.Fatalf("kept %+v", u)
|
||||||
|
}
|
||||||
|
if n := len(mm.usage.entries); n != 1 {
|
||||||
|
t.Fatalf("a failed reading appended: %d entries", n)
|
||||||
|
}
|
||||||
|
// §16, §31: a grant the vault does not answer is a failed reading, written and gone past, never the end of the round.
|
||||||
|
mm.vendor.usageErr = nil
|
||||||
|
mm.grants.mu.Lock()
|
||||||
|
delete(mm.grants.held, SecretName(KeyFor(licence1), PartGrant))
|
||||||
|
mm.grants.mu.Unlock()
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatalf("a grant the vault did not answer ended the round: %v", err)
|
||||||
|
}
|
||||||
|
l, _ = mm.store.Licence(ctx, licence1)
|
||||||
|
if !strings.Contains(l.Usage.ReadError, "cannot be read from the vault") || *l.Usage.WeeklyPct != 97 {
|
||||||
|
t.Fatalf("after the vault's refusal the reading is %+v", l.Usage)
|
||||||
|
}
|
||||||
|
// §4, §16: a reading is taken under the licence's lease; with another run holding it, nothing is read or written.
|
||||||
|
if ok, _ := mm.store.Lease(ctx, "licence."+KeyFor(licence1), "another run", time.Minute); !ok {
|
||||||
|
t.Fatal("the lease was not taken")
|
||||||
|
}
|
||||||
|
reads, err := mm.m.ReadUsageOf(ctx, licence1, true)
|
||||||
|
if err != nil || len(reads) != 1 || reads[0].Error != "another run holds its lease" {
|
||||||
|
t.Fatalf("a reading under another's lease answered %+v, %v", reads, err)
|
||||||
|
}
|
||||||
|
if again, _ := mm.store.Licence(ctx, licence1); again.Usage.ReadError != l.Usage.ReadError {
|
||||||
|
t.Fatalf("a reading under another's lease wrote %+v", again.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives.
|
||||||
|
func TestTheRoomOfEachLicence(t *testing.T) {
|
||||||
|
now := t0
|
||||||
|
pct := func(v float64) *float64 { return &v }
|
||||||
|
read := func(session, weekly float64, ago time.Duration) *UsageReading {
|
||||||
|
return &UsageReading{SessionPct: pct(session), WeeklyPct: pct(weekly), ReadAt: now.Add(-ago).Format(time.RFC3339Nano)}
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
l Licence
|
||||||
|
room float64
|
||||||
|
known bool
|
||||||
|
}{
|
||||||
|
{"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
|
||||||
|
{"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true},
|
||||||
|
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
|
||||||
|
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
|
||||||
|
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
|
||||||
|
u := read(10, 30, 5*time.Minute)
|
||||||
|
u.ReadError = "the usage endpoint answered 500: x"
|
||||||
|
return u
|
||||||
|
}()}, 70, true},
|
||||||
|
{"never read", Licence{Kind: "subscription"}, 0, false},
|
||||||
|
{"no weekly window", Licence{Kind: "subscription", Usage: &UsageReading{SessionPct: pct(10),
|
||||||
|
ReadAt: now.Format(time.RFC3339Nano)}}, 0, false},
|
||||||
|
{"refresh failing", Licence{Kind: "subscription", Failures: 3, Usage: read(10, 10, time.Minute)}, 0, false},
|
||||||
|
{"refresh failed twice", Licence{Kind: "subscription", Failures: 2, Usage: read(10, 10, time.Minute)}, 90, true},
|
||||||
|
{"refresh token expired", Licence{Kind: "subscription", RefreshExpiresAt: now.UnixMilli(),
|
||||||
|
Usage: read(10, 10, time.Minute)}, 0, false},
|
||||||
|
{"an API key", Licence{Kind: "api-key", Usage: read(0, 0, 0)}, 0, false},
|
||||||
|
} {
|
||||||
|
room, known := Room(c.l, now, Defaults)
|
||||||
|
if room != c.room || known != c.known {
|
||||||
|
t.Errorf("%s: room %v known %v, want %v %v", c.name, room, known, c.room, c.known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each
|
||||||
|
// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the
|
||||||
|
// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a
|
||||||
|
// value read is written in the audit under `settings`.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings are the manager's own, declared with defaults.
|
||||||
|
type Settings struct {
|
||||||
|
Cadence time.Duration // rotate a grant once older than this
|
||||||
|
Floor time.Duration // refresh in any case with less than this left
|
||||||
|
FailuresToNotify int
|
||||||
|
Cooldown time.Duration // at most one notification per licence in this window
|
||||||
|
RefreshWarn time.Duration // warn this long before a refresh token itself expires
|
||||||
|
// SwitchAuto is whether a reading at its threshold moves the licence's consumers (§26).
|
||||||
|
SwitchAuto bool
|
||||||
|
// SwitchAt, RoomBelow and WarnAt are percentages: where a licence's consumers move, what a target's windows must
|
||||||
|
// both be below, and where a licence with consumers is warned about.
|
||||||
|
SwitchAt, RoomBelow, WarnAt float64
|
||||||
|
// UsageEvery is how often each subscription's usage is read.
|
||||||
|
UsageEvery time.Duration
|
||||||
|
// UsageStale is how old a licence's latest successful reading may be for its room to be known (§25).
|
||||||
|
UsageStale time.Duration
|
||||||
|
// OutOfBound is every setting the file sets outside the bound the manifest declares, kept at its default and said
|
||||||
|
// as settings.<setting>.invalid until it is fixed (B37).
|
||||||
|
OutOfBound []OutOfBound
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutOfBound is one setting set outside its bound: the value set, the bound and the default kept in its place.
|
||||||
|
type OutOfBound struct {
|
||||||
|
Setting string
|
||||||
|
Value float64
|
||||||
|
Min, Max float64
|
||||||
|
Default float64
|
||||||
|
HasMax bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are the settings a fresh mesh runs with.
|
||||||
|
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour,
|
||||||
|
RefreshWarn: 72 * time.Hour, SwitchAuto: true, SwitchAt: 99, RoomBelow: 90, WarnAt: 90, UsageEvery: 5 * time.Minute,
|
||||||
|
UsageStale: 15 * time.Minute}
|
||||||
|
|
||||||
|
// SettingsFrom reads the settings file. Today's five keep a default for anything absent or not positive; the six of
|
||||||
|
// the switch keep a default for anything absent or outside the bound the manifest declares (§33).
|
||||||
|
func SettingsFrom(path string) Settings {
|
||||||
|
s := Defaults
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var f map[string]any
|
||||||
|
if json.Unmarshal(raw, &f) != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok }
|
||||||
|
// below is a value under the manifest's minimum of 0 for today's five: kept at its default and said (B37).
|
||||||
|
below := func(k string, v float64, def float64) {
|
||||||
|
if v < 0 {
|
||||||
|
s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Default: def})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set := func(k string, unit time.Duration, into *time.Duration) {
|
||||||
|
v, ok := num(k)
|
||||||
|
if ok && v > 0 {
|
||||||
|
*into = time.Duration(v * float64(unit))
|
||||||
|
} else if ok {
|
||||||
|
below(k, v, float64(*into)/float64(unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set("cadence_minutes", time.Minute, &s.Cadence)
|
||||||
|
set("floor_minutes", time.Minute, &s.Floor)
|
||||||
|
set("cooldown_hours", time.Hour, &s.Cooldown)
|
||||||
|
set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn)
|
||||||
|
if v, ok := num("failures_to_notify"); ok && v > 0 {
|
||||||
|
s.FailuresToNotify = int(v)
|
||||||
|
} else if ok {
|
||||||
|
below("failures_to_notify", v, float64(s.FailuresToNotify))
|
||||||
|
}
|
||||||
|
if v, ok := f["switch_auto"].(bool); ok {
|
||||||
|
s.SwitchAuto = v
|
||||||
|
}
|
||||||
|
bounded := func(k string, lo, hi float64) (float64, bool) {
|
||||||
|
v, ok := num(k)
|
||||||
|
if ok && (v < lo || v > hi) {
|
||||||
|
def, _ := Defaults.Values()[k].(float64)
|
||||||
|
s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Min: lo, Max: hi, Default: def, HasMax: true})
|
||||||
|
}
|
||||||
|
return v, ok && v >= lo && v <= hi
|
||||||
|
}
|
||||||
|
for _, p := range []struct {
|
||||||
|
key string
|
||||||
|
into *float64
|
||||||
|
}{{"switch_at_pct", &s.SwitchAt}, {"room_below_pct", &s.RoomBelow}, {"warn_at_pct", &s.WarnAt}} {
|
||||||
|
if v, ok := bounded(p.key, 1, 100); ok {
|
||||||
|
*p.into = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := bounded("usage_minutes", 1, 60); ok {
|
||||||
|
s.UsageEvery = time.Duration(v * float64(time.Minute))
|
||||||
|
}
|
||||||
|
if v, ok := bounded("usage_stale_minutes", 5, 180); ok {
|
||||||
|
s.UsageStale = time.Duration(v * float64(time.Minute))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values are the settings by their names in the manifest's `config`, in its units.
|
||||||
|
func (s Settings) Values() map[string]any {
|
||||||
|
return map[string]any{"cadence_minutes": s.Cadence.Minutes(), "floor_minutes": s.Floor.Minutes(),
|
||||||
|
"failures_to_notify": float64(s.FailuresToNotify), "cooldown_hours": s.Cooldown.Hours(),
|
||||||
|
"refresh_warn_days": s.RefreshWarn.Hours() / 24, "switch_auto": s.SwitchAuto, "switch_at_pct": s.SwitchAt,
|
||||||
|
"room_below_pct": s.RoomBelow, "warn_at_pct": s.WarnAt, "usage_minutes": s.UsageEvery.Minutes(),
|
||||||
|
"usage_stale_minutes": s.UsageStale.Minutes()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid is a setting above switch_at_pct, which moves no consumer until it is fixed (§33): the setting and both
|
||||||
|
// values.
|
||||||
|
type Invalid struct {
|
||||||
|
Setting string
|
||||||
|
Value float64
|
||||||
|
SwitchAt float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid is every setting of the pairs §33 names that is above switch_at_pct: room_below_pct, then warn_at_pct.
|
||||||
|
func (s Settings) Invalid() []Invalid {
|
||||||
|
var out []Invalid
|
||||||
|
if s.RoomBelow > s.SwitchAt {
|
||||||
|
out = append(out, Invalid{Setting: "room_below_pct", Value: s.RoomBelow, SwitchAt: s.SwitchAt})
|
||||||
|
}
|
||||||
|
if s.WarnAt > s.SwitchAt {
|
||||||
|
out = append(out, Invalid{Setting: "warn_at_pct", Value: s.WarnAt, SwitchAt: s.SwitchAt})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings are those in force.
|
||||||
|
func (m *Manager) settings() Settings {
|
||||||
|
m.settingsMu.RLock()
|
||||||
|
defer m.settingsMu.RUnlock()
|
||||||
|
return m.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit
|
||||||
|
// under `settings`, its value before and now, and the settings read are those in force from then on.
|
||||||
|
func (m *Manager) ReadSettings(ctx context.Context) error {
|
||||||
|
if m.SettingsFile == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
next := SettingsFrom(m.SettingsFile)
|
||||||
|
m.settingsMu.Lock()
|
||||||
|
before := m.Settings
|
||||||
|
m.Settings = next
|
||||||
|
m.settingsMu.Unlock()
|
||||||
|
was, now := before.Values(), next.Values()
|
||||||
|
changed := map[string]any{}
|
||||||
|
for k, v := range now {
|
||||||
|
if !reflect.DeepEqual(was[k], v) {
|
||||||
|
changed[k] = map[string]any{"from": was[k], "to": v}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(changed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §33/AC1: each setting's default is the manifest's, a value at its bound is taken, and one
|
||||||
|
// outside its bound or absent keeps the default.
|
||||||
|
func TestEachSettingsDefaultAndBound(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Config map[string]struct {
|
||||||
|
Default any `json:"default"`
|
||||||
|
Minimum *float64 `json:"minimum"`
|
||||||
|
Maximum *float64 `json:"maximum"`
|
||||||
|
Trusted bool `json:"trusted"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defaults := Defaults.Values()
|
||||||
|
if len(m.Config) != len(defaults) {
|
||||||
|
t.Fatalf("the manifest declares %d settings, the code %d", len(m.Config), len(defaults))
|
||||||
|
}
|
||||||
|
file := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
read := func(k string, v any) any {
|
||||||
|
b, _ := json.Marshal(map[string]any{k: v})
|
||||||
|
_ = os.WriteFile(file, b, 0o600)
|
||||||
|
return SettingsFrom(file).Values()[k]
|
||||||
|
}
|
||||||
|
for k, c := range m.Config {
|
||||||
|
if !c.Trusted || c.Default != defaults[k] {
|
||||||
|
t.Errorf("%s: the manifest's default %v (trusted %v), the code's %v", k, c.Default, c.Trusted, defaults[k])
|
||||||
|
}
|
||||||
|
if c.Maximum == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lo, hi := *c.Minimum, *c.Maximum
|
||||||
|
for v, want := range map[float64]any{lo: lo, hi: hi, lo - 1: defaults[k], hi + 1: defaults[k]} {
|
||||||
|
if got := read(k, v); got != want {
|
||||||
|
t.Errorf("%s set to %v reads %v, want %v", k, v, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if read("switch_auto", false) != false || read("switch_at_pct", "high") != 99.0 {
|
||||||
|
t.Error("switch_auto off, or a value of the wrong type, is not read as written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §33: room_below_pct or warn_at_pct above switch_at_pct is an invalid pair naming both
|
||||||
|
// values; equal is valid.
|
||||||
|
func TestAnInvalidPairIsFound(t *testing.T) {
|
||||||
|
s := Defaults
|
||||||
|
if len(s.Invalid()) != 0 {
|
||||||
|
t.Fatal("the defaults are invalid")
|
||||||
|
}
|
||||||
|
s.SwitchAt, s.RoomBelow, s.WarnAt = 80, 85, 80
|
||||||
|
got := s.Invalid()
|
||||||
|
if len(got) != 1 || got[0] != (Invalid{Setting: "room_below_pct", Value: 85, SwitchAt: 80}) {
|
||||||
|
t.Fatalf("invalid %+v", got)
|
||||||
|
}
|
||||||
|
s.WarnAt = 95
|
||||||
|
if got := s.Invalid(); len(got) != 2 || got[1].Setting != "warn_at_pct" {
|
||||||
|
t.Fatalf("invalid %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is
|
||||||
|
// written once in the audit under settings.
|
||||||
|
func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600)
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 {
|
||||||
|
t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600)
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 {
|
||||||
|
t.Fatalf("in force %+v", s)
|
||||||
|
}
|
||||||
|
got := mm.audit.under("settings")
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("audited %v", got)
|
||||||
|
}
|
||||||
|
if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 {
|
||||||
|
t.Fatalf("audited %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +1,63 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
|
// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation,
|
||||||
// with a generation, what became of each login it was offered, usage readings, and the audit. One
|
// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage
|
||||||
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
|
// (busstore.go). Every rule is tested on the same store over a bus kept in memory.
|
||||||
// rule is tested without a database, and the database is asked only to keep rows.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Licence is one licence: an account, or an API key.
|
// Licence is one licence: an account, or an API key. Its form is the value of the state `licences` (design 39
|
||||||
|
// bus-and-switch §2): never a secret, which the vault holds (§15).
|
||||||
type Licence struct {
|
type Licence struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"` // subscription | api-key
|
Kind string `json:"kind"` // subscription | api-key
|
||||||
AccountUUID string `json:"accountUuid,omitempty"`
|
AccountUUID string `json:"account,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"address,omitempty"`
|
||||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
OrganizationUUID string `json:"organisation,omitempty"`
|
||||||
Sealed string `json:"-"` // the grant or the key, encrypted at rest
|
RefreshFingerprint string `json:"refreshFingerprint,omitempty"`
|
||||||
RefreshFingerprint string `json:"-"`
|
|
||||||
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
||||||
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
||||||
Failures int `json:"failures"`
|
Failures int `json:"failures"`
|
||||||
NotifiedAt int64 `json:"-"`
|
NotifiedAt int64 `json:"notifiedAt,omitempty"`
|
||||||
AdoptedAt int64 `json:"adoptedAt"`
|
AdoptedAt int64 `json:"adoptedAt"`
|
||||||
|
AdoptedFrom string `json:"adoptedFrom,omitempty"`
|
||||||
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
||||||
|
// Generation is the generation of the licence's secrets in the vault (§2, §15).
|
||||||
|
Generation int64 `json:"generation,omitempty"`
|
||||||
|
// Usage is the latest reading of the licence's usage (§2, §24).
|
||||||
|
Usage *UsageReading `json:"usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
|
// Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and
|
||||||
|
// why and by whom it was moved last.
|
||||||
type Binding struct {
|
type Binding struct {
|
||||||
Consumer string `json:"consumer"`
|
Consumer string `json:"consumer"`
|
||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
|
// Key is the licence's key (§10), answered by the verb bindings (B25); the store keeps none.
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
Generation int64 `json:"generation"`
|
Generation int64 `json:"generation"`
|
||||||
|
Since string `json:"since,omitempty"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
By string `json:"by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move is what moves a binding: the licence's kind, why and by whom, and the floor a consumer's generation must pass
|
||||||
|
// (B11: the generation its node last reported); a nil Floor is none.
|
||||||
|
type Move struct {
|
||||||
|
Kind, Why, By string
|
||||||
|
Floor func(consumer string) int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// floor is the floor m gives consumer.
|
||||||
|
func (m Move) floor(consumer string) int64 {
|
||||||
|
if m.Floor == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.Floor(consumer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
|
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
|
||||||
@@ -52,6 +76,8 @@ type UsageRow struct {
|
|||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
At int64 `json:"at"`
|
At int64 `json:"at"`
|
||||||
Reading UsageReading `json:"reading"`
|
Reading UsageReading `json:"reading"`
|
||||||
|
// Raw is the vendor's answer whole, answered only when asked (§35).
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store is what the manager keeps.
|
// Store is what the manager keeps.
|
||||||
@@ -60,213 +86,22 @@ type Store interface {
|
|||||||
Licence(ctx context.Context, name string) (*Licence, error)
|
Licence(ctx context.Context, name string) (*Licence, error)
|
||||||
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
||||||
SaveLicence(ctx context.Context, l Licence) error
|
SaveLicence(ctx context.Context, l Licence) error
|
||||||
|
Bindings(ctx context.Context) ([]Binding, error)
|
||||||
|
Binding(ctx context.Context, consumer string) (*Binding, error)
|
||||||
|
// Bind binds (or switches) a consumer at its next generation.
|
||||||
|
Bind(ctx context.Context, consumer, licence string, m Move) (Binding, error)
|
||||||
|
Unbind(ctx context.Context, consumer string) (bool, error)
|
||||||
|
// Advance gives every consumer of a licence its next generation: what a rotation is to them.
|
||||||
|
Advance(ctx context.Context, licence string, m Move) ([]Binding, error)
|
||||||
// Lease takes a lease for d, or answers false while another holder's is live.
|
// Lease takes a lease for d, or answers false while another holder's is live.
|
||||||
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
|
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
|
||||||
Unlease(ctx context.Context, key, holder string) error
|
Unlease(ctx context.Context, key, holder string) error
|
||||||
Bindings(ctx context.Context) ([]Binding, error)
|
|
||||||
Binding(ctx context.Context, consumer string) (*Binding, error)
|
|
||||||
// Bind binds (or switches) a consumer at the next generation.
|
|
||||||
Bind(ctx context.Context, consumer, licence string) (Binding, error)
|
|
||||||
Unbind(ctx context.Context, consumer string) (bool, error)
|
|
||||||
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
|
|
||||||
Advance(ctx context.Context, licence string) ([]Binding, error)
|
|
||||||
// GenerationsAbove makes every generation given from now on greater than this one.
|
|
||||||
GenerationsAbove(ctx context.Context, generation int64) error
|
|
||||||
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
||||||
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
|
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
|
||||||
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
|
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
|
||||||
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
|
// Usage is the latest readings, newest first, of one licence or every one: at most limit, none before since (zero
|
||||||
|
// is none), the vendor's answer with raw (§35).
|
||||||
|
Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error)
|
||||||
|
// Audit appends one entry for an act; an error is the act failing (B20).
|
||||||
Audit(ctx context.Context, what string, detail map[string]any) error
|
Audit(ctx context.Context, what string, detail map[string]any) error
|
||||||
Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemoryStore is the tests' store.
|
|
||||||
type MemoryStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
now func() time.Time
|
|
||||||
rows map[string]Licence
|
|
||||||
binds map[string]Binding
|
|
||||||
leases map[string]struct {
|
|
||||||
holder string
|
|
||||||
until time.Time
|
|
||||||
}
|
|
||||||
outcomes map[string]Outcome
|
|
||||||
usage []UsageRow
|
|
||||||
Audits []map[string]any
|
|
||||||
generation int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMemoryStore is an empty store whose leases age by now.
|
|
||||||
func NewMemoryStore(now func() time.Time) *MemoryStore {
|
|
||||||
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
|
|
||||||
leases: map[string]struct {
|
|
||||||
holder string
|
|
||||||
until time.Time
|
|
||||||
}{}, outcomes: map[string]Outcome{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
out := make([]Licence, 0, len(m.rows))
|
|
||||||
for _, l := range m.rows {
|
|
||||||
out = append(out, l)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if l, ok := m.rows[name]; ok {
|
|
||||||
return &l, nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
for _, l := range m.rows {
|
|
||||||
if l.AccountUUID == account {
|
|
||||||
return &l, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.rows[l.Name] = l
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
m.leases[key] = struct {
|
|
||||||
holder string
|
|
||||||
until time.Time
|
|
||||||
}{holder, m.now().Add(d)}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.leases[key].holder == holder {
|
|
||||||
delete(m.leases, key)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
out := make([]Binding, 0, len(m.binds))
|
|
||||||
for _, b := range m.binds {
|
|
||||||
out = append(out, b)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if b, ok := m.binds[consumer]; ok {
|
|
||||||
return &b, nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.generation++
|
|
||||||
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
|
|
||||||
m.binds[consumer] = b
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
_, ok := m.binds[consumer]
|
|
||||||
delete(m.binds, consumer)
|
|
||||||
return ok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.generation = max(m.generation, generation)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
var out []Binding
|
|
||||||
for c, b := range m.binds {
|
|
||||||
if b.Licence != licence {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m.generation++
|
|
||||||
b.Generation = m.generation
|
|
||||||
m.binds[c] = b
|
|
||||||
out = append(out, b)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.outcomes[fp], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.outcomes[fp] = o
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
var out []UsageRow
|
|
||||||
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
|
|
||||||
if licence == "" || m.usage[i].Licence == licence {
|
|
||||||
out = append(out, m.usage[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
d := map[string]any{"what": what}
|
|
||||||
for k, v := range detail {
|
|
||||||
d[k] = v
|
|
||||||
}
|
|
||||||
m.Audits = append(m.Audits, d)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Close() {}
|
|
||||||
|
|||||||
@@ -1,95 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"crypto/rand"
|
||||||
"os"
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
|
|
||||||
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
|
|
||||||
//
|
|
||||||
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
|
||||||
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
|
||||||
func TestTheStoreOnPostgres(t *testing.T) {
|
|
||||||
url := os.Getenv("MESH_TEST_POSTGRES")
|
|
||||||
if url == "" {
|
|
||||||
t.Skip("MESH_TEST_POSTGRES unset")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
s, err := OpenPgStore(ctx, url)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer s.Close()
|
|
||||||
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
|
|
||||||
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
|
|
||||||
}
|
|
||||||
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
if err := s.Migrate(ctx); err != nil {
|
|
||||||
t.Fatalf("migration %d: %v", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
|
|
||||||
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
|
|
||||||
if err := s.SaveLicence(ctx, l); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
l.Failures = 2
|
|
||||||
_ = s.SaveLicence(ctx, l)
|
|
||||||
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
|
|
||||||
t.Fatalf("%+v", got)
|
|
||||||
}
|
|
||||||
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
|
|
||||||
t.Fatalf("%v %v", none, err)
|
|
||||||
}
|
|
||||||
lease := func(holder string) bool {
|
|
||||||
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
if !lease("one") || lease("two") || !lease("one") {
|
|
||||||
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
|
|
||||||
}
|
|
||||||
_ = s.Unlease(ctx, "licence:a", "one")
|
|
||||||
if !lease("two") {
|
|
||||||
t.Fatal("a released lease was not taken")
|
|
||||||
}
|
|
||||||
b1, _ := s.Bind(ctx, "laptop", l.Name)
|
|
||||||
adv, _ := s.Advance(ctx, l.Name)
|
|
||||||
b3, _ := s.Bind(ctx, "laptop", l.Name)
|
|
||||||
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
|
|
||||||
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
|
|
||||||
}
|
|
||||||
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
|
|
||||||
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
|
|
||||||
t.Fatalf("outcome %q", o)
|
|
||||||
}
|
|
||||||
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
|
|
||||||
t.Fatalf("an unknown login is %q", o)
|
|
||||||
}
|
|
||||||
pct := 1.0
|
|
||||||
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
|
|
||||||
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
|
|
||||||
t.Fatalf("usage %v", u)
|
|
||||||
}
|
|
||||||
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
|
|
||||||
t.Fatal("unbind")
|
|
||||||
}
|
|
||||||
all, _ := s.Licences(ctx)
|
|
||||||
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
|
|
||||||
t.Fatalf("%v", all)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
||||||
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
||||||
in := int64(60)
|
in := int64(60)
|
||||||
@@ -109,7 +26,7 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
|||||||
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
||||||
a, _ := NewCrypt("one key")
|
a, _ := NewCrypt("one key")
|
||||||
b, _ := NewCrypt("another key")
|
b, _ := NewCrypt("another key")
|
||||||
sealed := a.Seal(`{"refreshToken":"rt"}`)
|
sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`)
|
||||||
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
||||||
if strings.Contains(sealed, "refreshToken") {
|
if strings.Contains(sealed, "refreshToken") {
|
||||||
t.Fatal("stored in the clear")
|
t.Fatal("stored in the clear")
|
||||||
@@ -124,3 +41,16 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
|||||||
t.Fatal("an empty key was accepted")
|
t.Fatal("an empty key was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sealAtRest seals a grant as today's database keeps it, `v1.<iv>.<ciphertext and tag>`: the rows the carry-over reads.
|
||||||
|
func sealAtRest(t *testing.T, c *Crypt, plaintext string) string {
|
||||||
|
t.Helper()
|
||||||
|
gcm, err := newGCM(c.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
iv := make([]byte, 12)
|
||||||
|
_, _ = rand.Read(iv)
|
||||||
|
b64 := base64.StdEncoding.EncodeToString
|
||||||
|
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
|
||||||
|
// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every
|
||||||
|
// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below
|
||||||
|
// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
|
||||||
|
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
|
||||||
|
// a licence at its threshold, so none flaps between two.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ByUsage is who moves a consumer by the rule (§26).
|
||||||
|
const ByUsage = "usage"
|
||||||
|
|
||||||
|
// Switch is one act of the rule: from L to the target, the consumers it moves, the window of L that reached its
|
||||||
|
// threshold, its percentage and reset, and the target's room.
|
||||||
|
type Switch struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Consumers []string `json:"consumers"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
Pct float64 `json:"pct"`
|
||||||
|
ResetsAt string `json:"resetsAt"`
|
||||||
|
Room float64 `json:"room"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// why is the cause a moved binding carries: L, the window, its percentage and its reset (§26).
|
||||||
|
func (s Switch) why() string {
|
||||||
|
return fmt.Sprintf("%s reached %g %% of its %s window, which resets at %s", s.From, s.Pct, s.Window, s.ResetsAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fields are the act's fields, the same in licence.switched and the audit entry switched (§27).
|
||||||
|
func (s Switch) fields() map[string]any {
|
||||||
|
f := switchedFields(s.From, s.To, s.Consumers, Cause{Why: s.why(), By: ByUsage})
|
||||||
|
f["window"], f["pct"], f["resetsAt"], f["room"] = s.Window, s.Pct, s.ResetsAt, s.Room
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence
|
||||||
|
// has room; Over is then the licence's consumers and the window at its threshold, for the condition no-room (§29).
|
||||||
|
type Rule struct {
|
||||||
|
Switch *Switch
|
||||||
|
NoTarget bool
|
||||||
|
Over *Switch
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumersOf are the consumers bound to licence, sorted.
|
||||||
|
func consumersOf(licence string, bindings []Binding) []string {
|
||||||
|
var consumers []string
|
||||||
|
for _, b := range bindings {
|
||||||
|
if b.Licence == licence {
|
||||||
|
consumers = append(consumers, b.Consumer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(consumers)
|
||||||
|
return consumers
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name,
|
||||||
|
// percentage and reset; ok false when neither is.
|
||||||
|
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
|
||||||
|
if u == nil {
|
||||||
|
return "", 0, "", false
|
||||||
|
}
|
||||||
|
session, weekly := u.SessionPct, u.WeeklyPct
|
||||||
|
switch {
|
||||||
|
case session != nil && *session >= pct && (weekly == nil || *session >= *weekly):
|
||||||
|
return "session", *session, u.SessionResetsAt, true
|
||||||
|
case weekly != nil && *weekly >= pct:
|
||||||
|
return "weekly", *weekly, u.WeeklyResetsAt, true
|
||||||
|
}
|
||||||
|
return "", 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below
|
||||||
|
// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room.
|
||||||
|
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
|
||||||
|
others := append([]Licence(nil), licences...)
|
||||||
|
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
|
||||||
|
to, best := "", -1.0
|
||||||
|
for _, o := range others {
|
||||||
|
if o.Name == l.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
room, known := Room(o, m.Now(), s)
|
||||||
|
if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best, to = room, o.Name
|
||||||
|
}
|
||||||
|
return to, best
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its
|
||||||
|
// threshold, or with no consumer, gives nothing.
|
||||||
|
func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule {
|
||||||
|
consumers := consumersOf(l.Name, bindings)
|
||||||
|
if len(consumers) == 0 {
|
||||||
|
return Rule{}
|
||||||
|
}
|
||||||
|
sw := Switch{From: l.Name, Consumers: consumers}
|
||||||
|
var ok bool
|
||||||
|
if sw.Window, sw.Pct, sw.ResetsAt, ok = windowAt(l.Usage, s.SwitchAt); !ok {
|
||||||
|
return Rule{}
|
||||||
|
}
|
||||||
|
if sw.To, sw.Room = m.targetFor(l, licences, s); sw.To == "" {
|
||||||
|
sw.Room = 0
|
||||||
|
return Rule{NoTarget: true, Over: &sw}
|
||||||
|
}
|
||||||
|
return Rule{Switch: &sw}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRule applies §26 to one licence, or to every one when licence is empty: after a reading, by switch_auto, or by
|
||||||
|
// hand, even with switch_auto off (§32, §39). With dry it answers the moves and moves nothing. An invalid pair of
|
||||||
|
// settings moves no consumer (§33).
|
||||||
|
func (m *Manager) ApplyRule(ctx context.Context, licence string, byHand, dry bool) ([]Switch, error) {
|
||||||
|
s := m.settings()
|
||||||
|
if (!s.SwitchAuto && !byHand) || len(s.Invalid()) > 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
licences, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bindings, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var acts []Switch
|
||||||
|
for _, l := range licences {
|
||||||
|
if licence != "" && l.Name != licence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := m.RuleFor(l, licences, bindings, s)
|
||||||
|
if r.Switch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acts = append(acts, *r.Switch)
|
||||||
|
if dry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.act(ctx, *r.Switch, licences); err != nil {
|
||||||
|
return acts, err
|
||||||
|
}
|
||||||
|
// What was moved is bound to the target now, for the next licence's rule.
|
||||||
|
if bindings, err = m.Store.Bindings(ctx); err != nil {
|
||||||
|
return acts, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// act moves every consumer of one act as the switch verb does, then says the act once and tells the operator (§26 to
|
||||||
|
// §28).
|
||||||
|
func (m *Manager) act(ctx context.Context, sw Switch, licences []Licence) error {
|
||||||
|
kind := ""
|
||||||
|
for _, l := range licences {
|
||||||
|
if l.Name == sw.To {
|
||||||
|
kind = l.Kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
floor := m.floorIn(nil)
|
||||||
|
for _, consumer := range sw.Consumers {
|
||||||
|
b, err := m.Store.Bind(ctx, consumer, sw.To, Move{Kind: kind, Why: sw.why(), By: ByUsage, Floor: floor})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("moving %s to %s: %w", consumer, sw.To, err)
|
||||||
|
}
|
||||||
|
m.changed(b, sw.From)
|
||||||
|
}
|
||||||
|
f := sw.fields()
|
||||||
|
if err := m.record(ctx, "switched", fmt.Sprintf("switched %s from %s to %s: %s; by %s",
|
||||||
|
strings.Join(sw.Consumers, ", "), sw.From, sw.To, sw.why(), ByUsage), f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = m.Emit("licence.switched", f)
|
||||||
|
if m.Tell != nil {
|
||||||
|
line := fmt.Sprintf("%s; %s has %g %% room", sw.why(), sw.To, sw.Room)
|
||||||
|
if err := m.Tell(ctx, strings.Join(sw.Consumers, ", ")+" moved to "+sw.To, []string{line}); err != nil {
|
||||||
|
m.Log("the switch of %s to %s could not be told: %v", strings.Join(sw.Consumers, ", "), sw.To, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// read is a licence's latest reading, read at t0.
|
||||||
|
func read(session, weekly float64) *UsageReading {
|
||||||
|
return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly,
|
||||||
|
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sub(name string, u *UsageReading) Licence {
|
||||||
|
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives.
|
||||||
|
func TestTheRuleMovesEachTable(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
on := func(consumers ...string) []Binding {
|
||||||
|
var out []Binding
|
||||||
|
for _, c := range consumers {
|
||||||
|
out = append(out, Binding{Consumer: c, Licence: "a"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
licences []Licence
|
||||||
|
bindings []Binding
|
||||||
|
to string
|
||||||
|
window string
|
||||||
|
noTarget bool
|
||||||
|
consumers int
|
||||||
|
}{
|
||||||
|
{"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0},
|
||||||
|
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
|
||||||
|
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
|
||||||
|
on("x", "y", "z", "w"), "c", "weekly", false, 4},
|
||||||
|
{"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1},
|
||||||
|
{"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))},
|
||||||
|
on("x"), "b", "session", false, 1},
|
||||||
|
{"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)),
|
||||||
|
sub("c", read(89, 89))}, on("x"), "c", "session", false, 1},
|
||||||
|
{"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)),
|
||||||
|
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
|
||||||
|
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
|
||||||
|
} {
|
||||||
|
r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults)
|
||||||
|
to, window, n := "", "", 0
|
||||||
|
if r.Switch != nil {
|
||||||
|
to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers)
|
||||||
|
}
|
||||||
|
if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget {
|
||||||
|
t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %.
|
||||||
|
func fourOnA(t *testing.T) *miniMesh {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} {
|
||||||
|
_ = mm.store.SaveLicence(ctx, l)
|
||||||
|
}
|
||||||
|
for _, c := range []string{"ace", "g14", "laptop", "server"} {
|
||||||
|
_, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"})
|
||||||
|
}
|
||||||
|
mm.events = nil
|
||||||
|
return mm
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window,
|
||||||
|
// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same
|
||||||
|
// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset.
|
||||||
|
func TestOneSwitchedPerAct(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
acts, err := mm.m.ApplyRule(context.Background(), "", false, false)
|
||||||
|
if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 {
|
||||||
|
t.Fatalf("acts %+v %v", acts, err)
|
||||||
|
}
|
||||||
|
var switched []map[string]any
|
||||||
|
changed := 0
|
||||||
|
for _, e := range mm.events {
|
||||||
|
name, body, _ := strings.Cut(e, " ")
|
||||||
|
var f map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(body), &f)
|
||||||
|
switch name {
|
||||||
|
case "licence.switched":
|
||||||
|
switched = append(switched, f)
|
||||||
|
case "binding.changed":
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(switched) != 1 || changed != 4 {
|
||||||
|
t.Fatalf("said %v", mm.events)
|
||||||
|
}
|
||||||
|
s := switched[0]
|
||||||
|
if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage ||
|
||||||
|
s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 {
|
||||||
|
t.Fatalf("licence.switched %v", s)
|
||||||
|
}
|
||||||
|
audit := mm.audit.under("switched")
|
||||||
|
if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 {
|
||||||
|
t.Fatalf("audit %v", audit)
|
||||||
|
}
|
||||||
|
b := mm.bound("laptop")
|
||||||
|
if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" {
|
||||||
|
t.Fatalf("laptop %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never.
|
||||||
|
func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
var told []string
|
||||||
|
mm.m.Tell = func(_ context.Context, headline string, lines []string) error {
|
||||||
|
told = append(told, headline+" | "+strings.Join(lines, " "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, _ = mm.m.ApplyRule(context.Background(), "", false, false)
|
||||||
|
if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+
|
||||||
|
"which resets at 2026-11-14T09:00:00Z; b has 70 % room" {
|
||||||
|
t.Fatalf("told %v", told)
|
||||||
|
}
|
||||||
|
_, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
|
||||||
|
if len(told) != 1 {
|
||||||
|
t.Fatalf("the switch verb told %v", told)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair
|
||||||
|
// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off.
|
||||||
|
func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := fourOnA(t)
|
||||||
|
dry, err := mm.m.ApplyRule(ctx, "a", true, true)
|
||||||
|
if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 {
|
||||||
|
t.Fatalf("dry %+v %v, said %v", dry, err, mm.events)
|
||||||
|
}
|
||||||
|
acts, _ := mm.m.ApplyRule(ctx, "a", true, false)
|
||||||
|
if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") {
|
||||||
|
t.Fatalf("dry %+v, act %+v", dry, acts)
|
||||||
|
}
|
||||||
|
for name, change := range map[string]func(*Settings){
|
||||||
|
"an invalid pair": func(s *Settings) { s.RoomBelow = 100 },
|
||||||
|
"switch_auto off": func(s *Settings) { s.SwitchAuto = false },
|
||||||
|
} {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
change(&mm.m.Settings)
|
||||||
|
if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" {
|
||||||
|
t.Errorf("%s moved %+v", name, acts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mm = fourOnA(t)
|
||||||
|
mm.m.Settings.SwitchAuto = false
|
||||||
|
if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" {
|
||||||
|
t.Fatalf("by hand with switch_auto off moved %+v", acts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once.
|
||||||
|
func TestAReadingAtTheThresholdApplies(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
|
||||||
|
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
|
||||||
|
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage {
|
||||||
|
t.Fatalf("laptop %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription","refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":4,"changedAt":"2026-10-04T11:00:00.000Z"}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The module's own tools (design 39 bus-and-switch §37 to §44, B40): beside the seat's verbs, they read everything the
|
||||||
|
// manager keeps and start by hand everything it does by itself. A tool that acts answers "the carry-over has not ended"
|
||||||
|
// until it has (§11); licences_carried's run is the carry-over itself.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tool is one of the module's own tools; acts says whether it acts, so the carry-over's gate holds it (§11).
|
||||||
|
func tool(name, description string, acts bool, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: name, Description: description, Input: input,
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
m, err := manager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if acts {
|
||||||
|
if err := m.Carry.Acts(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)})
|
||||||
|
return run(ctx, m, a)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolean(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "boolean", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "number", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleTools() []stdio.Tool {
|
||||||
|
licence := str("a licence, as `licences` names it; absent for all")
|
||||||
|
dry := boolean("answer what the rule would do, and do nothing")
|
||||||
|
return []stdio.Tool{
|
||||||
|
tool("licences_switches", "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.",
|
||||||
|
false, map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"),
|
||||||
|
"since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
l, _ := a["licence"].(string)
|
||||||
|
c, _ := a["consumer"].(string)
|
||||||
|
since, err := sinceOf(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000))
|
||||||
|
}),
|
||||||
|
tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.",
|
||||||
|
true, map[string]any{"licence": licence, "dry": dry},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
l, _ := a["licence"].(string)
|
||||||
|
d, _ := a["dry"].(bool)
|
||||||
|
return m.ReadUsageOf(ctx, strings.TrimSpace(l), d)
|
||||||
|
}),
|
||||||
|
tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.",
|
||||||
|
true, map[string]any{"licence": licence, "dry": dry},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
l, _ := a["licence"].(string)
|
||||||
|
d, _ := a["dry"].(bool)
|
||||||
|
acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d)
|
||||||
|
if acts == nil {
|
||||||
|
acts = []Switch{}
|
||||||
|
}
|
||||||
|
return acts, err
|
||||||
|
}),
|
||||||
|
tool("licences_settings", "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.",
|
||||||
|
false, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
|
return m.SettingsInForce(ctx)
|
||||||
|
}),
|
||||||
|
tool("licences_kept", "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.",
|
||||||
|
false, map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"),
|
||||||
|
"key": str("a key of a state, or a subject of a log")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
e, _ := a["entry"].(string)
|
||||||
|
k, _ := a["key"].(string)
|
||||||
|
return m.Kept(strings.TrimSpace(e), strings.TrimSpace(k))
|
||||||
|
}),
|
||||||
|
tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.",
|
||||||
|
true, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
|
return m.Resync(ctx)
|
||||||
|
}),
|
||||||
|
tool("licences_conditions", "The conditions the manager said and holds: each key of its state conditions with its body and since.",
|
||||||
|
false, nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
|
return m.KeptConditions()
|
||||||
|
}),
|
||||||
|
tool("licences_carried", "The carry-over's record (when, and per table the rows read and entries written), or, while it has not ended, the failure it names; with run and no record, carry over again. Refused once carried.",
|
||||||
|
false, map[string]any{"run": boolean("carry over again: only while it has not ended")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
run, _ := a["run"].(bool)
|
||||||
|
return m.Carried(ctx, run)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before
|
||||||
|
// since, at most limit (§37).
|
||||||
|
func (m *Manager) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) {
|
||||||
|
bus, ok := m.Store.(*BusStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("the audit is not kept on the bus")
|
||||||
|
}
|
||||||
|
info, err := bus.AuditLog.Info()
|
||||||
|
if err != nil || info.Last == 0 {
|
||||||
|
return []map[string]any{}, err
|
||||||
|
}
|
||||||
|
var all []stdio.LogEntry
|
||||||
|
for next := uint64(1); next != 0; {
|
||||||
|
page, err := bus.AuditLog.Read(next, "switched", 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, page.Entries...)
|
||||||
|
next = page.Next
|
||||||
|
}
|
||||||
|
out := []map[string]any{}
|
||||||
|
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
|
||||||
|
var e map[string]any
|
||||||
|
if json.Unmarshal(all[i].Value, &e) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"]))
|
||||||
|
if at.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if licence != "" && e["from"] != licence && e["to"] != licence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consumers, _ := e["consumers"].([]any)
|
||||||
|
if consumer != "" && !slices.Contains(consumers, any(consumer)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"],
|
||||||
|
"by": e["by"], "why": e["why"]}
|
||||||
|
if _, ok := e["window"]; ok {
|
||||||
|
s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"],
|
||||||
|
"room": e["room"]}
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingAnswer is one setting as licences_settings answers it (§40).
|
||||||
|
type SettingAnswer struct {
|
||||||
|
Setting string `json:"setting"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
Default any `json:"default"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Invalid string `json:"invalid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsInForce is each setting in force, its default, where mesh-config says it was set, and why it is invalid
|
||||||
|
// when it is (§40, B18).
|
||||||
|
func (m *Manager) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) {
|
||||||
|
levels := map[string]string{}
|
||||||
|
if m.ConfigLevels != nil {
|
||||||
|
got, err := m.ConfigLevels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err)
|
||||||
|
}
|
||||||
|
levels = got
|
||||||
|
}
|
||||||
|
s := m.settings()
|
||||||
|
held, err := m.Judge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values, defaults := s.Values(), Defaults.Values()
|
||||||
|
var out []SettingAnswer
|
||||||
|
for k, v := range values {
|
||||||
|
a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k]}
|
||||||
|
if a.Level == "" {
|
||||||
|
a.Level = "default"
|
||||||
|
}
|
||||||
|
if c, ok := held["settings."+k+".invalid"]; ok {
|
||||||
|
a.Invalid = c.Summary
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kept are the entries the manager keeps on the bus, by their local names in the manifest (§2 to §9).
|
||||||
|
func (m *Manager) kept() (map[string]keptState, map[string]keptLog) {
|
||||||
|
states, logs := map[string]keptState{}, map[string]keptLog{}
|
||||||
|
if bus, ok := m.Store.(*BusStore); ok {
|
||||||
|
states["licences"], states["bindings"] = bus.LicencesState, bus.BindingsState
|
||||||
|
states["leases"], states["offered"] = bus.LeasesState, bus.OfferedState
|
||||||
|
logs["usage"], logs["audit"] = bus.UsageLog, bus.AuditLog
|
||||||
|
}
|
||||||
|
if m.Carry != nil {
|
||||||
|
states["carried"] = m.Carry.State
|
||||||
|
}
|
||||||
|
if m.Conditions != nil {
|
||||||
|
states["conditions"] = m.Conditions
|
||||||
|
}
|
||||||
|
return states, logs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept answers one entry the manager keeps, or every one (§41): a state's keys and their count, a log's subjects with
|
||||||
|
// their last entry and its fill against its cap; with key, one key's value or one subject's last entry.
|
||||||
|
func (m *Manager) Kept(entry, key string) (map[string]any, error) {
|
||||||
|
states, logs := m.kept()
|
||||||
|
out := map[string]any{}
|
||||||
|
names := []string{}
|
||||||
|
for n := range states {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
for n := range logs {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
if entry != "" && !slices.Contains(names, entry) {
|
||||||
|
return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if entry != "" && n != entry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, ok := states[n]; ok {
|
||||||
|
a, err := keptStateAnswer(st, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", n, err)
|
||||||
|
}
|
||||||
|
out[n] = a
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, err := keptLogAnswer(logs[n], key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", n, err)
|
||||||
|
}
|
||||||
|
out[n] = a
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keptStateAnswer(st keptState, key string) (map[string]any, error) {
|
||||||
|
keys, err := st.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}}
|
||||||
|
if key != "" {
|
||||||
|
e, err := st.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
a["value"] = nil
|
||||||
|
} else {
|
||||||
|
a["value"] = e.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keptLogAnswer(l keptLog, key string) (map[string]any, error) {
|
||||||
|
info, err := l.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes,
|
||||||
|
"maxBytes": info.MaxBytes}}
|
||||||
|
if key != "" {
|
||||||
|
e, err := l.Last(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a["last"] = e
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
newest, err := l.Newest("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
subjects := map[string]any{}
|
||||||
|
for _, e := range newest {
|
||||||
|
subjects[e.Key] = e
|
||||||
|
}
|
||||||
|
a["subjects"] = subjects
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding,
|
||||||
|
// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said.
|
||||||
|
func (m *Manager) Resync(ctx context.Context) (map[string]any, error) {
|
||||||
|
bindings, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
said := map[string]any{}
|
||||||
|
consumers := []string{}
|
||||||
|
for _, b := range bindings {
|
||||||
|
m.changed(b, b.Licence)
|
||||||
|
consumers = append(consumers, b.Consumer)
|
||||||
|
}
|
||||||
|
said["binding.changed"] = consumers
|
||||||
|
if err := m.AskHoldings(CauseOf(ctx).By); err != nil {
|
||||||
|
return said, fmt.Errorf("holdings.asked could not be said: %w", err)
|
||||||
|
}
|
||||||
|
said["holdings.asked"] = true
|
||||||
|
if err := m.SayConditions(ctx, true); err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
conditions, err := m.KeptConditions()
|
||||||
|
if err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
keys := []string{}
|
||||||
|
for _, c := range conditions {
|
||||||
|
keys = append(keys, c.Body.Key)
|
||||||
|
}
|
||||||
|
said["conditions"] = keys
|
||||||
|
licences, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
put := []string{}
|
||||||
|
var errs []error
|
||||||
|
for _, l := range licences {
|
||||||
|
if l.Generation == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := KeyFor(l.Name)
|
||||||
|
// The access token is built from the grant, which the vault hands the manager; it hands a licence's access
|
||||||
|
// token only to a machine a binding names (§17), and the manager's machine may have none.
|
||||||
|
access, err := m.Grants.Get(ctx, key, PartGrant)
|
||||||
|
if err == nil && l.Kind == "subscription" {
|
||||||
|
var g FullGrant
|
||||||
|
if err = json.Unmarshal([]byte(access), &g); err == nil {
|
||||||
|
access = accessOf(g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access)
|
||||||
|
}
|
||||||
|
access = ""
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
put = append(put, l.Name)
|
||||||
|
}
|
||||||
|
said["licence-put"] = put
|
||||||
|
return said, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeptConditions is each key of the state conditions with its body and since (§43), by key.
|
||||||
|
func (m *Manager) KeptConditions() ([]KeptCondition, error) {
|
||||||
|
out := []KeptCondition{}
|
||||||
|
if m.Conditions == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
keys, err := m.Conditions.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
e, err := m.Conditions.Get(k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c KeptCondition
|
||||||
|
if e == nil || json.Unmarshal(e.Value, &c) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carried is the carry-over's record, or the failure its last run names while it has not ended; with run and no
|
||||||
|
// record it carries over again, and is refused once carried (§44).
|
||||||
|
func (m *Manager) Carried(ctx context.Context, run bool) (map[string]any, error) {
|
||||||
|
if m.Carry == nil {
|
||||||
|
return nil, errors.New("this manager has nothing to carry over")
|
||||||
|
}
|
||||||
|
if m.Carry.Ended() {
|
||||||
|
if run {
|
||||||
|
return nil, errors.New("the carry-over has ended: carried.postgres is written, and it is never done again")
|
||||||
|
}
|
||||||
|
e, err := m.Carry.State.Get(CarriedKey)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rec CarriedRecord
|
||||||
|
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"carried": rec}, nil
|
||||||
|
}
|
||||||
|
if !run {
|
||||||
|
return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, nil
|
||||||
|
}
|
||||||
|
bus, _ := m.Store.(*BusStore)
|
||||||
|
rec, err := m.Carry.Run(ctx, m, bus)
|
||||||
|
if serr := m.SayConditions(ctx, false); serr != nil {
|
||||||
|
m.Log("saying the conditions after the carry-over: %v", serr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, err
|
||||||
|
}
|
||||||
|
return map[string]any{"carried": rec}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// served is every verb and tool the binary serves, by name, run on mm's manager.
|
||||||
|
func served(t *testing.T, mm *miniMesh) map[string]stdTool {
|
||||||
|
t.Helper()
|
||||||
|
built = mm.m
|
||||||
|
t.Cleanup(func() { built = nil })
|
||||||
|
out := map[string]stdTool{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
out[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer is a tool's answer as JSON, decoded into a generic form.
|
||||||
|
func answer(t *testing.T, run stdTool, args map[string]any) any {
|
||||||
|
t.Helper()
|
||||||
|
got, err := run(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v answered %v", args, err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got)
|
||||||
|
var v any
|
||||||
|
_ = json.Unmarshal(raw, &v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §34/AC1, §47, B40: an answer per licence carries today's fields, its organisation, usage,
|
||||||
|
// room or why it is not known, whether it is the next target, when and from where it was adopted and its open
|
||||||
|
// conditions, and no token.
|
||||||
|
func TestLicencesAnswersEachFieldAndNoToken(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "k", Kind: "api-key", Generation: 1, AdoptedAt: t0.UnixMilli(),
|
||||||
|
AdoptedFrom: "a file"})
|
||||||
|
_, _ = saying(mm)
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
got := answer(t, served(t, mm)["licences"], nil).([]any)
|
||||||
|
by := map[string]map[string]any{}
|
||||||
|
for _, l := range got {
|
||||||
|
f := l.(map[string]any)
|
||||||
|
by[f["name"].(string)] = f
|
||||||
|
for _, k := range []string{"name", "kind", "account", "organisation", "accessExpiresAt", "refreshExpiresAt",
|
||||||
|
"failures", "bound", "usage", "room", "target", "adoptedAt", "adoptedFrom", "conditions"} {
|
||||||
|
if _, ok := f[k]; !ok {
|
||||||
|
t.Errorf("%s has no %s: %v", f["name"], k, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if by["b"]["target"] != true || by["c"]["target"] != false || by["a"]["room"] != 5.0 ||
|
||||||
|
by["k"]["room"] != "is an API key, whose room is never known" || by["k"]["adoptedFrom"] != "a file" ||
|
||||||
|
len(by["a"]["bound"].([]any)) != 4 || by["a"]["usage"].(map[string]any)["weeklyResetsAt"] != "2026-11-14T09:00:00Z" {
|
||||||
|
t.Fatalf("licences %v", by)
|
||||||
|
}
|
||||||
|
if cs := by["a"]["conditions"].([]any); len(cs) != 1 || cs[0].(map[string]any)["key"] != "licence.a.near-limit" {
|
||||||
|
t.Fatalf("a's conditions %v", cs)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got)
|
||||||
|
for _, token := range []string{"rt-", "at-", "local-"} {
|
||||||
|
if strings.Contains(string(raw), token) {
|
||||||
|
t.Fatalf("licences holds %s: %s", token, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §35/AC1: since and limit bound the answer, newest first; raw adds the vendor's answer.
|
||||||
|
func TestUsageIsBoundedBySinceAndLimit(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", nil))
|
||||||
|
for i := range 30 {
|
||||||
|
_ = mm.store.RecordUsage(ctx, "a", t0.Add(time.Duration(i)*time.Minute).UnixMilli(), UsageReading{},
|
||||||
|
map[string]any{"i": float64(i)})
|
||||||
|
}
|
||||||
|
run := served(t, mm)["usage"]
|
||||||
|
rows := answer(t, run, map[string]any{"licence": "a"}).([]any)
|
||||||
|
first := rows[0].(map[string]any)
|
||||||
|
if len(rows) != 20 || first["at"] != float64(t0.Add(29*time.Minute).UnixMilli()) || first["raw"] != nil {
|
||||||
|
t.Fatalf("by default %d rows, the first %v", len(rows), first)
|
||||||
|
}
|
||||||
|
since := t0.Add(25 * time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
rows = answer(t, run, map[string]any{"since": since, "limit": 3.0, "raw": true}).([]any)
|
||||||
|
if len(rows) != 3 || rows[0].(map[string]any)["raw"].(map[string]any)["i"] != 29.0 {
|
||||||
|
t.Fatalf("since and limit 3: %v", rows)
|
||||||
|
}
|
||||||
|
if rows = answer(t, run, map[string]any{"since": since, "limit": 5000.0}).([]any); len(rows) != 5 {
|
||||||
|
t.Fatalf("since: %d rows", len(rows))
|
||||||
|
}
|
||||||
|
if _, err := run(map[string]any{"since": "yesterday"}); err == nil {
|
||||||
|
t.Fatal("a since that is not a time is answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §37/AC1: a switch by usage and one by a person are answered, newest first, with their
|
||||||
|
// causes, a switch by usage with the reading that moved them; licence and consumer narrow the answer.
|
||||||
|
func TestSwitchesAnswersBothCauses(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.ApplyRule(ctx, "", false, false)
|
||||||
|
mm.now = t0.Add(time.Minute)
|
||||||
|
_, _ = mm.m.Bind(WithCause(ctx, Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
|
||||||
|
run := served(t, mm)["licences_switches"]
|
||||||
|
got := answer(t, run, nil).([]any)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("switches %v", got)
|
||||||
|
}
|
||||||
|
person, usage := got[0].(map[string]any), got[1].(map[string]any)
|
||||||
|
if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil ||
|
||||||
|
usage["by"] != ByUsage || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" {
|
||||||
|
t.Fatalf("switches %v", got)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, map[string]any{"licence": "c"}).([]any); len(got) != 1 {
|
||||||
|
t.Fatalf("of c %v", got)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, map[string]any{"consumer": "g14"}).([]any); len(got) != 1 || got[0].(map[string]any)["by"] != ByUsage {
|
||||||
|
t.Fatalf("of g14 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry
|
||||||
|
// reads, answers the moves and moves nothing.
|
||||||
|
func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, dry := range []bool{true, false} {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
|
||||||
|
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
|
||||||
|
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
|
||||||
|
got := answer(t, served(t, mm)["licences_read_usage"], map[string]any{"dry": dry}).([]any)
|
||||||
|
r := got[0].(map[string]any)
|
||||||
|
moved := mm.bound("laptop").Licence == "b@example.org"
|
||||||
|
if len(got) != 1 || r["reading"].(map[string]any)["weeklyPct"] != 99.0 ||
|
||||||
|
len(r["switches"].([]any)) != 1 || moved == dry || len(mm.usage.under("")) != 1 {
|
||||||
|
t.Fatalf("dry %v: answered %v, moved %v", dry, got, moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an
|
||||||
|
// acting tool waits for the carry-over (§11).
|
||||||
|
func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
mm.m.Settings.SwitchAuto = false
|
||||||
|
run := served(t, mm)["licences_switch_now"]
|
||||||
|
if got := answer(t, run, map[string]any{"dry": true}).([]any); len(got) != 1 || mm.bound("ace").Licence != "a" {
|
||||||
|
t.Fatalf("dry %v", got)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" {
|
||||||
|
t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence)
|
||||||
|
}
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })}
|
||||||
|
if _, err := run(nil); err == nil || err.Error() != NotCarried {
|
||||||
|
t.Fatalf("before the carry-over answered %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §40/AC1, B18: a value set at node level is answered with its level, the rest with the
|
||||||
|
// default, each with its value and default, and an invalid one saying why.
|
||||||
|
func TestSettingsAnswersTheLevel(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
mm.m.Settings.SwitchAt, mm.m.Settings.WarnAt = 80, 85
|
||||||
|
mm.m.ConfigLevels = func(context.Context) (map[string]string, error) {
|
||||||
|
return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil
|
||||||
|
}
|
||||||
|
got := answer(t, served(t, mm)["licences_settings"], nil).([]any)
|
||||||
|
by := map[string]map[string]any{}
|
||||||
|
for _, s := range got {
|
||||||
|
f := s.(map[string]any)
|
||||||
|
by[f["setting"].(string)] = f
|
||||||
|
}
|
||||||
|
if len(by) != len(Defaults.Values()) || by["switch_at_pct"]["level"] != "node" || by["switch_at_pct"]["value"] != 80.0 ||
|
||||||
|
by["switch_at_pct"]["default"] != 99.0 || by["usage_minutes"]["level"] != "default" ||
|
||||||
|
!strings.Contains(fmtAny(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") ||
|
||||||
|
!strings.Contains(fmtAny(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil {
|
||||||
|
t.Fatalf("settings %v", by)
|
||||||
|
}
|
||||||
|
mm.m.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") }
|
||||||
|
if _, err := served(t, mm)["licences_settings"](nil); err == nil {
|
||||||
|
t.Fatal("mesh-config not answering is answered as the default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtAny(v any) string { s, _ := v.(string); return s }
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §41/AC1, §20: every bus-state entry the manifest asks for is read by licences_kept, with its
|
||||||
|
// keys or subjects, one key's value and its fill; and no entry holds a token.
|
||||||
|
func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Requires []json.RawMessage `json:"requires"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = saying(mm)
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })}
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)})
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
run := served(t, mm)["licences_kept"]
|
||||||
|
all := answer(t, run, nil).(map[string]any)
|
||||||
|
entries := 0
|
||||||
|
for _, r := range m.Requires {
|
||||||
|
var e struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(r, &e) != nil || e.Template == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries++
|
||||||
|
got, ok := all[e.Name].(map[string]any)
|
||||||
|
if !ok || got["fill"] == nil || got["kind"] != map[string]string{"state": "state", "log": "log"}[e.Template] {
|
||||||
|
t.Errorf("licences_kept does not read %s as a %s: %v", e.Name, e.Template, all[e.Name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries != 8 || len(all) != entries {
|
||||||
|
t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", entries, len(all))
|
||||||
|
}
|
||||||
|
one := answer(t, run, map[string]any{"entry": "licences", "key": KeyFor(licence1)}).(map[string]any)
|
||||||
|
if one["licences"].(map[string]any)["value"] == nil {
|
||||||
|
t.Fatalf("one key %v", one)
|
||||||
|
}
|
||||||
|
if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects[KeyFor(licence1)] == nil {
|
||||||
|
t.Fatalf("usage subjects %v", subjects)
|
||||||
|
}
|
||||||
|
if _, err := run(map[string]any{"entry": "tables"}); err == nil {
|
||||||
|
t.Fatal("an entry not kept is answered")
|
||||||
|
}
|
||||||
|
everything, _ := json.Marshal(all)
|
||||||
|
for _, token := range []string{"rt-secret", "rt-1", "at-1", "local-"} {
|
||||||
|
if strings.Contains(string(everything), token) {
|
||||||
|
t.Fatalf("a kept entry holds %s", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition
|
||||||
|
// once, and licence-put of each licence's access once.
|
||||||
|
func TestResyncSaysEachOnce(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, said := saying(mm)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.login("server", "rt-b", true, t0)})
|
||||||
|
mm.m.Settings.WarnAt = 100
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
mm.events, *said = nil, nil
|
||||||
|
mm.grants.refuseAccess = true // §42: the access token is built from the grant, never read back from the vault
|
||||||
|
got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any)
|
||||||
|
count := map[string]int{}
|
||||||
|
for _, e := range mm.events {
|
||||||
|
name, _, _ := strings.Cut(e, " ")
|
||||||
|
count[name]++
|
||||||
|
}
|
||||||
|
puts, _ := got["licence-put"].([]any)
|
||||||
|
if count["binding.changed"] != 2 || count[HoldingsAsked] != 1 || len(*said) != 1 || len(puts) != 1 || puts[0] != licence1 {
|
||||||
|
t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §43/AC1: each key of conditions is answered with its body and since.
|
||||||
|
func TestConditionsAnswersEachKey(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = saying(mm)
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
|
||||||
|
mm.m.Settings.RoomBelow = 100
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
got := answer(t, served(t, mm)["licences_conditions"], nil).([]any)
|
||||||
|
var keys []string
|
||||||
|
for _, c := range got {
|
||||||
|
f := c.(map[string]any)
|
||||||
|
if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" {
|
||||||
|
t.Fatalf("condition %v", f)
|
||||||
|
}
|
||||||
|
keys = append(keys, f["body"].(map[string]any)["key"].(string))
|
||||||
|
}
|
||||||
|
if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" {
|
||||||
|
t.Fatalf("keys %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §44/AC1: licences_carried answers the failure while not carried, carries over with run, then
|
||||||
|
// answers the record and refuses run.
|
||||||
|
func TestCarriedRunIsRefusedOnceCarried(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
down := true
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }),
|
||||||
|
Source: func(context.Context) (CarrySource, error) {
|
||||||
|
if down {
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
}
|
||||||
|
return &memSource{}, nil
|
||||||
|
}}
|
||||||
|
run := served(t, mm)["licences_carried"]
|
||||||
|
if _, err := run(map[string]any{"run": true}); err == nil || len(of(*said, CarryOverFailed)) != 1 {
|
||||||
|
t.Fatalf("a failed run answered %v, said %v", err, *said)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, nil).(map[string]any); !strings.Contains(fmtAny(got["failure"]), "connection refused") {
|
||||||
|
t.Fatalf("not carried %v", got)
|
||||||
|
}
|
||||||
|
down = false
|
||||||
|
if got := answer(t, run, map[string]any{"run": true}).(map[string]any); got["carried"] == nil ||
|
||||||
|
strings.Join(of(*said, CarryOverFailed), ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("carried %v, said %v", got, *said)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, nil).(map[string]any); got["carried"].(map[string]any)["tables"] == nil {
|
||||||
|
t.Fatalf("the record %v", got)
|
||||||
|
}
|
||||||
|
if _, err := run(map[string]any{"run": true}); err == nil {
|
||||||
|
t.Fatal("run is answered once carried")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands
|
||||||
|
// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`,
|
||||||
|
// sealed to its own public key, for one act.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The parts of a licence the vault holds (§15).
|
||||||
|
const (
|
||||||
|
PartGrant = "grant"
|
||||||
|
PartAccess = "access"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grants is where a licence's two secrets are kept.
|
||||||
|
type Grants interface {
|
||||||
|
// Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused.
|
||||||
|
Put(ctx context.Context, key, part string, generation int64, value string) error
|
||||||
|
// Get reads one part of a licence from the vault.
|
||||||
|
Get(ctx context.Context, key, part string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretName is the name of one part of a licence in the vault.
|
||||||
|
func SecretName(key, part string) string { return "licence." + key + "." + part }
|
||||||
|
|
||||||
|
// VaultGrants is the vault on the bus, asked as the seat mesh-vault.
|
||||||
|
type VaultGrants struct{ Keys KeyPair }
|
||||||
|
|
||||||
|
// Put asks licence-put's two steps.
|
||||||
|
func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var opened struct {
|
||||||
|
Call string `json:"call"`
|
||||||
|
SealTo string `json:"seal_to"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &opened); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, err := base64.StdEncoding.DecodeString(opened.SealTo)
|
||||||
|
if err != nil || len(pub) != 32 || opened.Call == "" {
|
||||||
|
return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw)
|
||||||
|
}
|
||||||
|
var to [32]byte
|
||||||
|
copy(to[:], pub)
|
||||||
|
sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call,
|
||||||
|
"sealed": base64.StdEncoding.EncodeToString(sealed)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asks get, sealed to the manager's public key, and opens the answer.
|
||||||
|
func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var a struct {
|
||||||
|
Sealed SealedBox `json:"sealed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return Open(a.Sealed, v.Keys.PrivateKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first
|
||||||
|
// and then the access, at the next generation; the licence keeps no token once the call returns.
|
||||||
|
func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := KeyFor(licence1)
|
||||||
|
mm.grants.puts = nil
|
||||||
|
if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true {
|
||||||
|
t.Fatalf("%v %v", r, err)
|
||||||
|
}
|
||||||
|
want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"}
|
||||||
|
if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want)
|
||||||
|
}
|
||||||
|
if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) {
|
||||||
|
t.Fatalf("the vault holds the grant %s", g)
|
||||||
|
}
|
||||||
|
if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") {
|
||||||
|
t.Fatalf("the vault holds the access %s", a)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
raw, _ := json.Marshal(l)
|
||||||
|
for _, token := range []string{"rt-", "at-"} {
|
||||||
|
if strings.Contains(string(raw), token) {
|
||||||
|
t.Fatalf("the licence keeps %s: %s", token, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.Generation != 2 {
|
||||||
|
t.Fatalf("the licence's generation is %d, want 2", l.Generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
|
|||||||
return NextGrant(g, r, time.Now().UnixMilli())
|
return NextGrant(g, r, time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never
|
||||||
|
// only the status (design 39 bus-and-switch §24).
|
||||||
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -148,25 +150,38 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a
|
|||||||
req.Header.Set("authorization", "Bearer "+accessToken)
|
req.Header.Set("authorization", "Bearer "+accessToken)
|
||||||
resp, err := v.client.Do(req)
|
resp, err := v.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("the usage endpoint did not answer: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
if resp.StatusCode/100 != 2 {
|
if resp.StatusCode/100 != 2 {
|
||||||
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
|
reason := string(body)
|
||||||
|
if len(reason) > 400 {
|
||||||
|
reason = reason[:400]
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason)
|
||||||
}
|
}
|
||||||
var out map[string]any
|
var out map[string]any
|
||||||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
|
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows (design 39 bus-and-switch
|
||||||
|
// §24), with, as the licence keeps it (§2), when it was last read successfully and why the latest reading failed.
|
||||||
type UsageReading struct {
|
type UsageReading struct {
|
||||||
SessionPct *float64 `json:"sessionPct"`
|
SessionPct *float64 `json:"sessionPct"`
|
||||||
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
|
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
|
||||||
WeeklyPct *float64 `json:"weeklyPct"`
|
WeeklyPct *float64 `json:"weeklyPct"`
|
||||||
|
WeeklyResetsAt string `json:"weeklyResetsAt,omitempty"`
|
||||||
SonnetPct *float64 `json:"sonnetPct"`
|
SonnetPct *float64 `json:"sonnetPct"`
|
||||||
|
SonnetResetsAt string `json:"sonnetResetsAt,omitempty"`
|
||||||
|
ReadAt string `json:"readAt,omitempty"`
|
||||||
|
ReadError string `json:"readError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlattenUsage reads the windows the predecessor read.
|
// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24).
|
||||||
func FlattenUsage(u map[string]any) UsageReading {
|
func FlattenUsage(u map[string]any) UsageReading {
|
||||||
window := func(k string) (*float64, string) {
|
window := func(k string) (*float64, string) {
|
||||||
w, ok := u[k].(map[string]any)
|
w, ok := u[k].(map[string]any)
|
||||||
@@ -180,8 +195,9 @@ func FlattenUsage(u map[string]any) UsageReading {
|
|||||||
}
|
}
|
||||||
return &pct, resets
|
return &pct, resets
|
||||||
}
|
}
|
||||||
s, resets := window("five_hour")
|
s, sResets := window("five_hour")
|
||||||
w, _ := window("seven_day")
|
w, wResets := window("seven_day")
|
||||||
so, _ := window("seven_day_sonnet")
|
so, soResets := window("seven_day_sonnet")
|
||||||
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
|
return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so,
|
||||||
|
SonnetResetsAt: soResets}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,5 +3,11 @@
|
|||||||
"floor_minutes": 60,
|
"floor_minutes": 60,
|
||||||
"failures_to_notify": 3,
|
"failures_to_notify": 3,
|
||||||
"cooldown_hours": 24,
|
"cooldown_hours": 24,
|
||||||
"refresh_warn_days": 3
|
"refresh_warn_days": 3,
|
||||||
|
"switch_auto": true,
|
||||||
|
"switch_at_pct": 99,
|
||||||
|
"room_below_pct": 90,
|
||||||
|
"warn_at_pct": 90,
|
||||||
|
"usage_minutes": 5,
|
||||||
|
"usage_stale_minutes": 15
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,28 @@ module claude-licence-manager
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||||
|
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
github.com/nats-io/nats.go v1.54.0
|
||||||
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||||
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/compress v1.20.1 // indirect
|
github.com/klauspost/compress v1.20.1 // indirect
|
||||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
github.com/minio/highwayhash v1.0.4 // indirect
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.2 // 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/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
golang.org/x/time v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,38 +1,50 @@
|
|||||||
|
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
|
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||||
|
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||||
|
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
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/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
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/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
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/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
|
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -4,7 +4,74 @@
|
|||||||
"slug": "licmgr",
|
"slug": "licmgr",
|
||||||
"requires": [
|
"requires": [
|
||||||
"postgres-database",
|
"postgres-database",
|
||||||
"secret"
|
"secret",
|
||||||
|
{
|
||||||
|
"name": "licences",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bindings",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "leases",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1,
|
||||||
|
"key-ttl": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "offered",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1,
|
||||||
|
"key-ttl": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "usage",
|
||||||
|
"storage": "stream",
|
||||||
|
"template": "log",
|
||||||
|
"options": {
|
||||||
|
"max-age-days": 35,
|
||||||
|
"max-mib": 256
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audit",
|
||||||
|
"storage": "stream",
|
||||||
|
"template": "log",
|
||||||
|
"options": {
|
||||||
|
"max-mib": 64
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "carried",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "conditions",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"postgres-database": {
|
"postgres-database": {
|
||||||
@@ -24,6 +91,15 @@
|
|||||||
"grant-key": "${dir:state}/grant.key"
|
"grant-key": "${dir:state}/grant.key"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"uses": [
|
||||||
|
"operator-channel"
|
||||||
|
],
|
||||||
|
"invokes": [
|
||||||
|
"seat:operator-channel.tell",
|
||||||
|
"seat:mesh-vault.licence-put",
|
||||||
|
"seat:mesh-vault.get",
|
||||||
|
"seat:mesh-config.config"
|
||||||
|
],
|
||||||
"seats": [
|
"seats": [
|
||||||
{
|
{
|
||||||
"name": "anthropic-licence-manager",
|
"name": "anthropic-licence-manager",
|
||||||
@@ -63,18 +139,14 @@
|
|||||||
"emits": [
|
"emits": [
|
||||||
"licence.adopted",
|
"licence.adopted",
|
||||||
"licence.refused",
|
"licence.refused",
|
||||||
"licence.bound",
|
|
||||||
"licence.switched",
|
"licence.switched",
|
||||||
"licence.released",
|
|
||||||
"licence.rotated",
|
|
||||||
"licence.failing",
|
"licence.failing",
|
||||||
"usage.read"
|
"usage.read",
|
||||||
|
"binding.changed",
|
||||||
|
"holdings.asked"
|
||||||
],
|
],
|
||||||
"state": [
|
"consumes": [
|
||||||
"bindings"
|
"claude-code.holdings-changed"
|
||||||
],
|
|
||||||
"reads": [
|
|
||||||
"claude-code.holdings"
|
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"cadence_minutes": {
|
"cadence_minutes": {
|
||||||
@@ -111,6 +183,52 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"trusted": true,
|
"trusted": true,
|
||||||
"default": 3
|
"default": 3
|
||||||
|
},
|
||||||
|
"switch_auto": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"switch_at_pct": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 99
|
||||||
|
},
|
||||||
|
"room_below_pct": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"warn_at_pct": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"usage_minutes": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How often each subscription's usage is read, in minutes.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 60,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"usage_stale_minutes": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How old a subscription's last successful usage reading may grow before its room is not known and it is said, in minutes.",
|
||||||
|
"minimum": 5,
|
||||||
|
"maximum": 180,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 15
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -133,28 +251,33 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/settings.json",
|
"path": "${dir:state}/settings.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
|
"takes-config": [
|
||||||
|
"cadence_minutes",
|
||||||
|
"floor_minutes",
|
||||||
|
"failures_to_notify",
|
||||||
|
"cooldown_hours",
|
||||||
|
"refresh_warn_days",
|
||||||
|
"switch_auto",
|
||||||
|
"switch_at_pct",
|
||||||
|
"room_below_pct",
|
||||||
|
"warn_at_pct",
|
||||||
|
"usage_minutes",
|
||||||
|
"usage_stale_minutes"
|
||||||
|
],
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"template": "files/settings.json"
|
"template": "files/settings.json"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "prepare",
|
|
||||||
"type": "process",
|
|
||||||
"name": "claude-licence-manager-prepare",
|
|
||||||
"artifact": "code",
|
|
||||||
"run": [
|
|
||||||
"./claude-licence-manager",
|
|
||||||
"prepare"
|
|
||||||
],
|
|
||||||
"run-once": true,
|
|
||||||
"env": {
|
|
||||||
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
|
||||||
},
|
|
||||||
"restart-on": [
|
|
||||||
"database-url"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"tools": [
|
||||||
|
"licences_switches",
|
||||||
|
"licences_read_usage",
|
||||||
|
"licences_switch_now",
|
||||||
|
"licences_settings",
|
||||||
|
"licences_kept",
|
||||||
|
"licences_resync",
|
||||||
|
"licences_conditions",
|
||||||
|
"licences_carried"
|
||||||
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ func TestTheReadingsLayoutIsAPublishedPackageOfThisModule(t *testing.T) {
|
|||||||
if !slices.Contains(m.Reads, engineDataRead) {
|
if !slices.Contains(m.Reads, engineDataRead) {
|
||||||
t.Errorf("reads %v do not name %s", m.Reads, engineDataRead)
|
t.Errorf("reads %v do not name %s", m.Reads, engineDataRead)
|
||||||
}
|
}
|
||||||
for _, a := range m.Build.Artifacts {
|
// Issue 1966's bootstrap: the package artifact diskreadings is out of the manifest until the new builder runs; the follow-up
|
||||||
if a.Name == "diskreadings" && a.Kind == "package" && a.Series == "0.1" && len(a.Paths) == 1 && a.Paths[0] == "diskreadings" {
|
// puts this check back.
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Errorf("no package artifact diskreadings of series 0.1 in %s", raw)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,15 +24,6 @@
|
|||||||
"loads": [
|
"loads": [
|
||||||
"disk-load"
|
"disk-load"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "diskreadings",
|
|
||||||
"kind": "package",
|
|
||||||
"language": "go",
|
|
||||||
"paths": [
|
|
||||||
"diskreadings"
|
|
||||||
],
|
|
||||||
"series": "0.1"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) {
|
|||||||
t.Errorf("%s's body %s is not the contract's: %v", event, b, err)
|
t.Errorf("%s's body %s is not the contract's: %v", event, b, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The caller list is read on each call: one that cannot be read answers no verb, and registry_callers says it as read.
|
// The caller list is read held: one never read answers no verb, and registry_callers says it as read.
|
||||||
unread := func() (holder.Callers, error) { return nil, errors.New("config refused") }
|
unread := func() (holder.Callers, error) { return nil, errors.New("config refused") }
|
||||||
for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, unread) {
|
for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, unread) {
|
||||||
if tool.Name == holder.ImageSeat+"."+holder.VerbVersions {
|
if tool.Name == holder.ImageSeat+"."+holder.VerbVersions {
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ func arg[T any](args map[string]any) (T, error) {
|
|||||||
|
|
||||||
func str(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
func str(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
||||||
|
|
||||||
// tools are distribution's tools over v; each call that checks a caller reads the caller list on that call (callers:
|
// tools are distribution's tools over v; each call that checks a caller reads the caller list held (callers:
|
||||||
// the config value as mesh-config resolves it now), so an operator's change of it holds from the next call on.
|
// the config value as mesh-config resolves it now), so an operator's change of it holds from the next call on.
|
||||||
func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.Callers, error)) []stdio.Tool {
|
func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.Callers, error)) []stdio.Tool {
|
||||||
now := func() (*holder.Verbs, error) {
|
now := func() (*holder.Verbs, error) {
|
||||||
@@ -417,10 +417,10 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
|||||||
}
|
}
|
||||||
return map[string]any{"callers": c}, nil
|
return map[string]any{"callers": c}, nil
|
||||||
}},
|
}},
|
||||||
stdio.Tool{Name: "registry_health", Description: "distribution's health check, run by the runtime each minute: versions of each seat asked through the store's read side (the program's port 5000); fails naming the program when it does not answer.",
|
stdio.Tool{Name: "registry_health", Description: "distribution's health check, run by the runtime each minute: versions of each seat asked through the store's read side (the program's port 5100); fails naming the program when it does not answer.",
|
||||||
Input: obj(map[string]any{}),
|
Input: obj(map[string]any{}),
|
||||||
Run: func(map[string]any) (any, error) {
|
Run: func(map[string]any) (any, error) {
|
||||||
front, _ := url.Parse("http://127.0.0.1:5000")
|
front, _ := url.Parse("http://127.0.0.1:5100")
|
||||||
return holder.Health(&holder.OCI{Base: front}, finding)
|
return holder.Health(&holder.OCI{Base: front}, finding)
|
||||||
}},
|
}},
|
||||||
stdio.Tool{Name: "registry_incoming", Description: "Open transfers of incoming and its fill; with drop, drops one by hand (a hand act, why required).",
|
stdio.Tool{Name: "registry_incoming", Description: "Open transfers of incoming and its fill; with drop, drops one by hand (a hand act, why required).",
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func run(ctx context.Context, getenv func(string) string) error {
|
|||||||
d, err := oci.Digest(name, tag)
|
d, err := oci.Digest(name, tag)
|
||||||
return d != "", err
|
return d != "", err
|
||||||
}}
|
}}
|
||||||
srv := &http.Server{Addr: or(getenv("MESH_REGISTRY_LISTEN"), ":5000"), Handler: front, ReadHeaderTimeout: 30 * time.Second}
|
srv := &http.Server{Addr: or(getenv("MESH_REGISTRY_LISTEN"), ":5100"), Handler: front, ReadHeaderTimeout: 30 * time.Second}
|
||||||
errs := make(chan error, 1)
|
errs := make(chan error, 1)
|
||||||
go func() { errs <- srv.ListenAndServe() }()
|
go func() { errs <- srv.ListenAndServe() }()
|
||||||
logger.Printf("serving the store's port %s in front of %s", srv.Addr, store)
|
logger.Printf("serving the store's port %s in front of %s", srv.Addr, store)
|
||||||
|
|||||||
@@ -93,11 +93,15 @@ func ParseCallers(raw string) (Callers, error) {
|
|||||||
return selfNotReleasing(c)
|
return selfNotReleasing(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfiguredCallers is the config value `callers` as mesh-config resolved it for this machine, read through the tools
|
// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of
|
||||||
// runtime's stdio.Config on each call (never an environment variable: nothing renders a config value into a
|
// the value, so a call never asks (issue 1966: an ask that timed out refused every verb).
|
||||||
// program's environment); unset is DefaultCallers, and Self on release's list is refused as ParseCallers refuses it.
|
var configured = stdio.HoldVerbCallers(CallersConfig, VerbsOf(ImageSeat), DefaultCallers())
|
||||||
|
|
||||||
|
// ConfiguredCallers is the config value `callers` as mesh-config resolved it for this machine, held from the tools
|
||||||
|
// runtime's watch of it (never an environment variable: nothing renders a config value into a program's environment);
|
||||||
|
// unset is DefaultCallers, and Self on release's list is refused as ParseCallers refuses it.
|
||||||
func ConfiguredCallers() (Callers, error) {
|
func ConfiguredCallers() (Callers, error) {
|
||||||
c, err := stdio.ConfiguredVerbCallers(CallersConfig, VerbsOf(ImageSeat), DefaultCallers())
|
c, err := configured()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func Health(front Catalog, finding func(kind, subject, words string, raised bool
|
|||||||
for _, seat := range Seats {
|
for _, seat := range Seats {
|
||||||
got, err := front.Versions(ProbeName)
|
got, err := front.Versions(ProbeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
words := fmt.Sprintf("the store's read side does not answer %s's versions through distribution's program (port 5000): %v; is the program distribution running?", seat, err)
|
words := fmt.Sprintf("the store's read side does not answer %s's versions through distribution's program (port 5100): %v; is the program distribution running?", seat, err)
|
||||||
if finding != nil {
|
if finding != nil {
|
||||||
finding(FindingReadSide, seat, words, true)
|
finding(FindingReadSide, seat, words, true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,8 +242,13 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
|||||||
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
||||||
t.Errorf("resumed publish = %+v", a)
|
t.Errorf("resumed publish = %+v", a)
|
||||||
}
|
}
|
||||||
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err == nil {
|
// A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again
|
||||||
t.Error("a chunk sent twice was taken: the transfer goes on from the chunks held")
|
// (mesh-delivery's parts.Receiver.Take, 1966/AC203-1, AC203-2): its earlier chunks are dropped and it is taken.
|
||||||
|
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 2, Data: blob[:1000]}); err == nil {
|
||||||
|
t.Error("a chunk out of sequence was taken: the transfer goes on from the chunks held")
|
||||||
|
}
|
||||||
|
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
|
||||||
|
t.Errorf("a chunk 0 again was refused: %v", err)
|
||||||
}
|
}
|
||||||
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
|
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
|
||||||
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
|
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
|
||||||
@@ -835,18 +840,24 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
|||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
Consumes []string `json:"consumes"`
|
Consumes []string `json:"consumes"`
|
||||||
Packages map[string]string `json:"packages"`
|
Packages map[string]string `json:"packages"`
|
||||||
|
Listens []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
} `json:"listens"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The builder's question is its published package, taken by its series (ADR 0634 §1).
|
// The builder's question is its published package, taken by its series (ADR 0634 §1).
|
||||||
if m.Packages["builds"] != "0.1" {
|
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
|
||||||
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
||||||
}
|
}
|
||||||
// E15: the provision artifact-store retired (ADR 0632 §3): distribution provides and serves nothing, and the
|
// E15: the provision artifact-store retires (ADR 0632 §3); the bootstrap keeps it as an alias on port 5100 while
|
||||||
// store is found by its seats' holder and the listen it opens to the mesh.
|
// the running node-engines still find the store by it (issue 1966), and nothing else is provided or served.
|
||||||
if len(m.Provides) != 0 || len(m.Serves) != 0 {
|
alias, _ := m.Serves["artifact-store"].(map[string]any)
|
||||||
t.Errorf("provides = %+v, serves = %v, want neither: the provision artifact-store retired", m.Provides, m.Serves)
|
port, _ := alias["port"].(float64)
|
||||||
|
if len(m.Provides) != 1 || m.Provides[0].Name != "artifact-store" || len(m.Serves) != 1 || port != 5100 {
|
||||||
|
t.Errorf("provides = %+v, serves = %v, want only the artifact-store alias on port 5100", m.Provides, m.Serves)
|
||||||
}
|
}
|
||||||
claims := map[string][]string{}
|
claims := map[string][]string{}
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
@@ -867,7 +878,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
|||||||
switch r["id"] {
|
switch r["id"] {
|
||||||
case "store":
|
case "store":
|
||||||
store = i
|
store = i
|
||||||
if ports, _ := r["ports"].([]any); len(ports) != 1 || ports[0] != "127.0.0.1:5001:5000" {
|
if ports, _ := r["ports"].([]any); len(ports) != 1 || ports[0] != "127.0.0.1:5001:5001" {
|
||||||
t.Errorf("the store's ports = %v, want the loopback only", r["ports"])
|
t.Errorf("the store's ports = %v, want the loopback only", r["ports"])
|
||||||
}
|
}
|
||||||
// The check of another kind beside the program's tool (ADR 0227 rule 8, ADR 0240 rule 2; the operator,
|
// The check of another kind beside the program's tool (ADR 0227 rule 8, ADR 0240 rule 2; the operator,
|
||||||
@@ -885,6 +896,22 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
|||||||
if store < 0 || program < 0 || store > program {
|
if store < 0 || program < 0 || store > program {
|
||||||
t.Errorf("store at %d, program at %d: want the store listed first", store, program)
|
t.Errorf("store at %d, program at %d: want the store listed first", store, program)
|
||||||
}
|
}
|
||||||
|
// The store's address is the listen registry where the machine put it (ADR 0632 §1), and the program binds it
|
||||||
|
// itself: no container's mapping may name its port on either side, or the composer reads that mapping's loopback
|
||||||
|
// side as the address (the move of 2026-11-08 23:25 UTC cut the store off so; issue 1966 note 109).
|
||||||
|
if len(m.Listens) != 1 || m.Listens[0].Port != 5100 {
|
||||||
|
t.Fatalf("listens = %+v, want registry on 5100, where every machine fetches", m.Listens)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
ports, _ := r["ports"].([]any)
|
||||||
|
for _, p := range ports {
|
||||||
|
for _, side := range strings.Split(fmt.Sprint(p), ":") {
|
||||||
|
if side == "5100" {
|
||||||
|
t.Errorf("%v maps %v: want 5100 bound by the program, named by no container", r["id"], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap
|
// Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap
|
||||||
// (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key
|
// (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key
|
||||||
// in idle, a state of keys with their own time to live.
|
// in idle, a state of keys with their own time to live.
|
||||||
|
|||||||
@@ -2,7 +2,21 @@
|
|||||||
"module": "distribution",
|
"module": "distribution",
|
||||||
"version": "2",
|
"version": "2",
|
||||||
"packages": {
|
"packages": {
|
||||||
"builds": "0.1"
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
|
||||||
|
},
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "artifact-store",
|
||||||
|
"scope": "mesh",
|
||||||
|
"credential": {
|
||||||
|
"form": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serves": {
|
||||||
|
"artifact-store": {
|
||||||
|
"port": 5100
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"claims": [
|
"claims": [
|
||||||
{
|
{
|
||||||
@@ -133,7 +147,7 @@
|
|||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
"name": "registry",
|
"name": "registry",
|
||||||
"port": 5000,
|
"port": 5100,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "every machine pulls images and artifacts from here; distribution's program serves it in front of the store"
|
"why": "every machine pulls images and artifacts from here; distribution's program serves it in front of the store"
|
||||||
@@ -181,12 +195,13 @@
|
|||||||
"name": "mesh-registry",
|
"name": "mesh-registry",
|
||||||
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
||||||
"ports": [
|
"ports": [
|
||||||
"127.0.0.1:5001:5000"
|
"127.0.0.1:5001:5001"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh-registry:/var/lib/registry"
|
"/var/lib/mesh-registry:/var/lib/registry"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
|
"REGISTRY_HTTP_ADDR": ":5001",
|
||||||
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
@@ -211,7 +226,7 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/var/lib/mesh/registry/broker",
|
"MESH_BROKER_FILE": "/var/lib/mesh/registry/broker",
|
||||||
"MESH_REGISTRY_LOOPBACK": "http://127.0.0.1:5001",
|
"MESH_REGISTRY_LOOPBACK": "http://127.0.0.1:5001",
|
||||||
"MESH_REGISTRY_LISTEN": ":5000"
|
"MESH_REGISTRY_LISTEN": ":5100"
|
||||||
},
|
},
|
||||||
"user": "distribution",
|
"user": "distribution",
|
||||||
"health": {
|
"health": {
|
||||||
|
|||||||
@@ -122,3 +122,83 @@ func TestAddressWaitIsAtMostTwoSeconds(t *testing.T) {
|
|||||||
t.Fatal("mesh.conf has no ExecStartPre")
|
t.Fatal("mesh.conf has no ExecStartPre")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The two public upstreams are the module's settings, defaulting to the two the resolver always used, placed
|
||||||
|
// as server lines beside no-resolv: a person sets them per node or mesh-wide without a build.
|
||||||
|
func TestUpstreamsAreConfigValues(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Config map[string]struct {
|
||||||
|
Default string `json:"default"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conf, err := os.ReadFile(filepath.Join(moduleDir, "files", "dnsmasq.conf"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, def := range map[string]string{"upstream-resolver-first": "1.1.1.1", "upstream-resolver-second": "8.8.8.8"} {
|
||||||
|
c, ok := m.Config[key]
|
||||||
|
if !ok || c.Default != def {
|
||||||
|
t.Errorf("config %s = %+v, want default %s", key, c, def)
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(c.Pattern).MatchString(def) {
|
||||||
|
t.Errorf("pattern %q refuses the default %s", c.Pattern, def)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(conf), "\nserver=${config:"+key+"}\n") {
|
||||||
|
t.Errorf("dnsmasq.conf does not place %s as a server line", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(conf), "server=1.1.1.1") || strings.Contains(string(conf), "server=8.8.8.8") {
|
||||||
|
t.Error("dnsmasq.conf still hard-codes an upstream")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(conf), "\nno-resolv\n") {
|
||||||
|
t.Error("dnsmasq.conf lost no-resolv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolver's configuration keeps what made every earlier resolver of the mesh go wrong (moved from mesh-sdk's
|
||||||
|
// catalogue tests, which could not follow the file when its upstreams became settings): it answers on the private
|
||||||
|
// address and loopback only, by address and never by interface (novox/hq issue 110), binds once and never
|
||||||
|
// dynamically (issue 348), reads no hosts file and no operator's files (ADR 0199), and takes none of the addresses
|
||||||
|
// systemd-resolved holds. no-resolv and the two server lines are held by TestUpstreamsAreConfigValues.
|
||||||
|
func TestTheResolverConfigurationKeepsItsGuards(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "dnsmasq.conf"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
config := "\n" + string(raw)
|
||||||
|
for _, want := range []string{
|
||||||
|
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n",
|
||||||
|
"\nno-hosts\n", "\nconf-file=/etc/mesh-resolver/zones.conf\n", "\nconf-file=/etc/mesh-resolver/nodes.conf\n",
|
||||||
|
"\ndomain-needed\n", "\nbogus-priv\n", "\nbind-interfaces\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(config, want) {
|
||||||
|
t.Errorf("the resolver's configuration lacks %q", strings.TrimSpace(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(config, "\n") {
|
||||||
|
for _, never := range []string{"bind-dynamic", "interface=", "conf-dir=", "addn-hosts=", "listen-address=${setting:",
|
||||||
|
"listen-address=127.0.0.53", "listen-address=127.0.0.54", "listen-address=127.0.0.55"} {
|
||||||
|
if strings.HasPrefix(line, never) {
|
||||||
|
t.Errorf("the resolver's configuration says %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The zone and node files the configuration reads are the ones the manifest's facts place.
|
||||||
|
manifest, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/etc/mesh-resolver/zones.conf", "/etc/mesh-resolver/nodes.conf"} {
|
||||||
|
if !strings.Contains(string(manifest), `"path": "`+p+`"`) {
|
||||||
|
t.Errorf("the manifest places no file at %s, which the configuration reads", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ listen-address=127.0.0.1
|
|||||||
# upstreams would find itself, and every query it could not answer locally would
|
# upstreams would find itself, and every query it could not answer locally would
|
||||||
# loop until its receive queue filled. That is not theoretical: it filled with
|
# loop until its receive queue filled. That is not theoretical: it filled with
|
||||||
# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop
|
# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop
|
||||||
# impossible: the upstreams are the two lines below, and nothing on the machine
|
# impossible: the upstreams are the two lines below (the module's settings upstream-resolver-first and -second), and nothing on the machine
|
||||||
# can redirect them. The mesh's own names never reach them: the local= line in
|
# can redirect them. The mesh's own names never reach them: the local= line in
|
||||||
# the file above stops them here, answered or refused.
|
# the file above stops them here, answered or refused.
|
||||||
no-resolv
|
no-resolv
|
||||||
server=1.1.1.1
|
server=${config:upstream-resolver-first}
|
||||||
server=8.8.8.8
|
server=${config:upstream-resolver-second}
|
||||||
|
|
||||||
# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,
|
# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,
|
||||||
# and this passes that bit down rather than dropping it, which dnsmasq does
|
# and this passes that bit down rather than dropping it, which dnsmasq does
|
||||||
|
|||||||
@@ -38,6 +38,22 @@
|
|||||||
"dnsmasq_names",
|
"dnsmasq_names",
|
||||||
"dnsmasq_resolve"
|
"dnsmasq_resolve"
|
||||||
],
|
],
|
||||||
|
"config": {
|
||||||
|
"upstream-resolver-first": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The first public resolver this resolver forwards to for every name that is not the mesh's own, as an IP address. It is placed as a server line beside no-resolv, so it must never be an address that resolves through this machine; both upstreams should validate DNSSEC, since the authenticated-data bit is passed down (ADR 0194, ADR 0223). Set per node or for the whole mesh.",
|
||||||
|
"trusted": false,
|
||||||
|
"pattern": "^[0-9A-Fa-f.:]+$",
|
||||||
|
"default": "1.1.1.1"
|
||||||
|
},
|
||||||
|
"upstream-resolver-second": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The second public resolver this resolver forwards to, as an IP address, tried beside the first so one upstream being down does not stop name resolution. The same rules as upstream-resolver-first.",
|
||||||
|
"trusted": false,
|
||||||
|
"pattern": "^[0-9A-Fa-f.:]+$",
|
||||||
|
"default": "8.8.8.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
"name": "dns-udp",
|
"name": "dns-udp",
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The builder's question is its published package, taken by its series (ADR 0634 §1).
|
// The builder's question is its published package, taken by its series (ADR 0634 §1).
|
||||||
if m.Packages["builds"] != "0.1" {
|
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
|
||||||
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
||||||
}
|
}
|
||||||
// Both package seats are claimed serving the four verbs this bundle answers as <seat>.<verb>, over its own incoming
|
// Both package seats are claimed serving the four verbs this bundle answers as <seat>.<verb>, over its own incoming
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ package main
|
|||||||
// the built package-registry bundle, played over its real stdin and stdout as the node's tool runtime plays it, its
|
// the built package-registry bundle, played over its real stdin and stdout as the node's tool runtime plays it, its
|
||||||
// `incoming` an object store and its `transfers` and `findings` key-value buckets of a nats-server of this test alone
|
// `incoming` an object store and its `transfers` and `findings` key-value buckets of a nats-server of this test alone
|
||||||
// (so the publish in parts runs through mesh-sdk's parts.BusObjects and BusTransfers, ruling 4 of the lead's 14:0x), the
|
// (so the publish in parts runs through mesh-sdk's parts.BusObjects and BusTransfers, ruling 4 of the lead's 14:0x), the
|
||||||
// builder's builder_versions answered on mesh/ask, the config value `callers` on mesh/provision.get, and the forge an
|
// builder's builder_versions answered on mesh/ask, the config value `callers` watched on mesh/provision.watch, and the forge an
|
||||||
// httptest server that takes reads only with the tools' token and writes only with the packages account's.
|
// httptest server that takes reads only with the tools' token and writes only with the packages account's.
|
||||||
//
|
//
|
||||||
// A Go package the builder numbered goes up in two chunks, is uploaded whole as the packages account and says
|
// A Go package the builder numbered goes up in two chunks, is uploaded whole as the packages account and says
|
||||||
@@ -123,8 +123,12 @@ func (p *player) handle(m wire) {
|
|||||||
_ = json.Unmarshal(a.Body, &q)
|
_ = json.Unmarshal(a.Body, &q)
|
||||||
d, ok := p.numbered[q.Name+"@"+q.Version]
|
d, ok := p.numbered[q.Name+"@"+q.Version]
|
||||||
p.answer(m.ID, map[string]any{"recorded": ok && d == q.Digest, "why": "not numbered with that digest"}, nil)
|
p.answer(m.ID, map[string]any{"recorded": ok && d == q.Digest, "why": "not numbered with that digest"}, nil)
|
||||||
case m.Method == "mesh/provision.get" && a.Kind == "config":
|
case m.Method == "mesh/provision.watch" && a.Kind == "config":
|
||||||
p.answer(m.ID, nil, nil) // `callers` unset: design 51's list
|
// The watch of `callers`, as the runtime answers it: the value now as one change (mesh/provision,
|
||||||
|
// named by the watch's id) handed over before the watch is answered, here unset, so design 51's list holds.
|
||||||
|
p.send(map[string]any{"id": "change-" + watchID(m.ID), "method": "mesh/provision", "params": map[string]any{
|
||||||
|
"watch": watchID(m.ID), "change": map[string]any{"kind": "config", "key": a.Key}}})
|
||||||
|
p.answer(m.ID, map[string]any{}, nil)
|
||||||
case m.Method == "mesh/seat.publish":
|
case m.Method == "mesh/seat.publish":
|
||||||
p.answer(m.ID, map[string]any{}, p.nc.Publish(a.Subject, a.Body))
|
p.answer(m.ID, map[string]any{}, p.nc.Publish(a.Subject, a.Body))
|
||||||
case m.Method == "mesh/publish":
|
case m.Method == "mesh/publish":
|
||||||
@@ -138,6 +142,15 @@ func (p *player) handle(m wire) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// watchID is the watch's id as a string, the form the change names it by.
|
||||||
|
func watchID(raw json.RawMessage) string {
|
||||||
|
var id string
|
||||||
|
if json.Unmarshal(raw, &id) != nil {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
func (p *player) objects(m wire, local, name string, data []byte) {
|
func (p *player) objects(m wire, local, name string, data []byte) {
|
||||||
os, err := p.js.ObjectStore(p.ctx, "gitea_"+local)
|
os, err := p.js.ObjectStore(p.ctx, "gitea_"+local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ GITEA__database__NAME=${bound:postgres-database:as}
|
|||||||
GITEA__database__USER=${bound:postgres-database:as}
|
GITEA__database__USER=${bound:postgres-database:as}
|
||||||
GITEA__database__PASSWD=${secret:postgres-database}
|
GITEA__database__PASSWD=${secret:postgres-database}
|
||||||
GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1
|
GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1
|
||||||
|
# Issue 1966: cap the forge's own pool; the store's 100 connections are shared by every module on this machine.
|
||||||
|
# SSH git operations go through the forge's private API, so this pool carries them.
|
||||||
|
GITEA__database__MAX_OPEN_CONNS=${config:database-max-open-connections}
|
||||||
|
GITEA__database__MAX_IDLE_CONNS=${config:database-max-idle-connections}
|
||||||
|
GITEA__database__CONN_MAX_LIFETIME=${config:database-connection-lifetime}
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ func DefaultCallers() Callers {
|
|||||||
VerbVersions: {stdio.AnyCaller}}
|
VerbVersions: {stdio.AnyCaller}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfiguredCallers is the config value `callers` as mesh-config resolved it; unset is DefaultCallers.
|
// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of
|
||||||
func ConfiguredCallers() (Callers, error) {
|
// the value, so a call never asks (issue 1966).
|
||||||
return stdio.ConfiguredVerbCallers("callers", Verbs, DefaultCallers())
|
var configured = stdio.HoldVerbCallers("callers", Verbs, DefaultCallers())
|
||||||
}
|
|
||||||
|
// ConfiguredCallers is the config value `callers` as mesh-config resolved it, held from its watch; unset is DefaultCallers.
|
||||||
|
func ConfiguredCallers() (Callers, error) { return configured() }
|
||||||
|
|
||||||
// Publish is a publish as the seats' contract names it (mesh-contracts npmpackageregistry and gopackageregistry share
|
// Publish is a publish as the seats' contract names it (mesh-contracts npmpackageregistry and gopackageregistry share
|
||||||
// one shape): what the tools bundle reads from either seat's PublishInput.
|
// one shape): what the tools bundle reads from either seat's PublishInput.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"module": "gitea",
|
"module": "gitea",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"packages": {
|
"packages": {
|
||||||
"builds": "0.1"
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
|
||||||
},
|
},
|
||||||
"moved": true,
|
"moved": true,
|
||||||
"requires": [
|
"requires": [
|
||||||
@@ -187,6 +187,26 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
"database-max-open-connections": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The most connections the forge's own database pool holds open to the store. The store's 100 connections are shared by every module on its machine, and the forge's git operations over ssh go through the forge's private API, which this pool carries: 40 leaves room for the others.",
|
||||||
|
"trusted": false,
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 40
|
||||||
|
},
|
||||||
|
"database-max-idle-connections": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The connections the forge's database pool keeps idle, ready for the next request. They count against the store's 100 connections, shared on its machine, so keep this well under the open-connection cap.",
|
||||||
|
"trusted": false,
|
||||||
|
"minimum": 0,
|
||||||
|
"default": 10
|
||||||
|
},
|
||||||
|
"database-connection-lifetime": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How long the forge's database pool keeps one connection before it closes and reopens it, as a duration such as 5m. A shorter lifetime gives back connections of the store's 100 connections, shared on its machine, sooner.",
|
||||||
|
"trusted": false,
|
||||||
|
"default": "5m"
|
||||||
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.",
|
"description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.",
|
||||||
@@ -286,6 +306,9 @@
|
|||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
|
"restart-on": [
|
||||||
|
"server-env"
|
||||||
|
],
|
||||||
"health": {
|
"health": {
|
||||||
"kind": "http",
|
"kind": "http",
|
||||||
"endpoint": "web",
|
"endpoint": "web",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
// The forge's own database pool is capped (issue 1966): the store's connections are shared by every module on the machine.
|
||||||
|
// The caps are config values placed into the env-file, and a changed env-file recreates the container.
|
||||||
|
const env = readFileSync(new URL("../files/server.env", import.meta.url), "utf8");
|
||||||
|
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||||
|
|
||||||
|
test("the forge's pool caps are config values, placed in server.env, and a change recreates the container", () => {
|
||||||
|
const lines = env.split("\n");
|
||||||
|
const placed: Record<string, [string, unknown]> = {
|
||||||
|
GITEA__database__MAX_OPEN_CONNS: ["database-max-open-connections", 40],
|
||||||
|
GITEA__database__MAX_IDLE_CONNS: ["database-max-idle-connections", 10],
|
||||||
|
GITEA__database__CONN_MAX_LIFETIME: ["database-connection-lifetime", "5m"],
|
||||||
|
};
|
||||||
|
for (const [name, [key, def]] of Object.entries(placed)) {
|
||||||
|
assert.ok(lines.includes(`${name}=\${config:${key}}`), `server.env does not place ${key} at ${name}`);
|
||||||
|
assert.equal(manifest.config[key].default, def);
|
||||||
|
assert.equal(manifest.config[key].trusted, false);
|
||||||
|
assert.ok(manifest.config[key].description.includes("100 connections"));
|
||||||
|
}
|
||||||
|
assert.equal(manifest.config["database-max-open-connections"].minimum, 1);
|
||||||
|
const server = manifest.resources.find((r: { id: string }) => r.id === "server");
|
||||||
|
assert.deepEqual(server["restart-on"], ["server-env"]);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"module": "lab",
|
"module": "lab",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"moved": true,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime",
|
"container-runtime",
|
||||||
"virtualisation"
|
"virtualisation"
|
||||||
|
|||||||
@@ -125,10 +125,11 @@ function renderedForgeFiles(forge: string): string {
|
|||||||
|
|
||||||
test("the lab requires the forge account for its clones, and holds no registry credential", () => {
|
test("the lab requires the forge account for its clones, and holds no registry credential", () => {
|
||||||
const m = JSON.parse(readFileSync(join(labDir, "module.json"), "utf8")) as {
|
const m = JSON.parse(readFileSync(join(labDir, "module.json"), "utf8")) as {
|
||||||
requires: string[]; contributes?: unknown; secrets?: unknown; resources: { id: string; path?: string; template?: string; package?: string }[];
|
moved?: boolean; requires: string[]; contributes?: unknown; secrets?: unknown; resources: { id: string; path?: string; template?: string; package?: string }[];
|
||||||
build: { artifacts: { name: string; env?: Record<string, string> }[] };
|
build: { artifacts: { name: string; env?: Record<string, string> }[] };
|
||||||
};
|
};
|
||||||
assert.ok(m.requires.includes("forge-account") && m.requires.includes("npm-package-registry"));
|
assert.ok(m.requires.includes("forge-account") && m.requires.includes("npm-package-registry"));
|
||||||
|
assert.equal(m.moved, true, "its files are templates filled from the forge-account binding by the node-engine, which a module that has moved asks for");
|
||||||
assert.equal(m.contributes, undefined, "npm-package-registry reads are anonymous: nothing is contributed");
|
assert.equal(m.contributes, undefined, "npm-package-registry reads are anonymous: nothing is contributed");
|
||||||
assert.equal(m.secrets, undefined, "no registry secret is sealed to the lab");
|
assert.equal(m.secrets, undefined, "no registry secret is sealed to the lab");
|
||||||
const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {};
|
const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {};
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ already gone is no error, one a running step holds is refused); its caller calls
|
|||||||
failed fetch removes its own workspace. A workspace no caller released is removed two hours after its last use (ADR
|
failed fetch removes its own workspace. A workspace no caller released is removed two hours after its last use (ADR
|
||||||
0509), and the removal is said in the log as the caller's fault.
|
0509), and the removal is said in the log as the caller's fault.
|
||||||
|
|
||||||
|
The programs this module builds from a commit and keeps in its cache (composers of compose asks, checking programs of run
|
||||||
|
asks) are read by `build_go_kept` (kind, key, size, last use) and removed by `build_go_kept_drop {kind, key}`; one no ask used
|
||||||
|
for 30 days is swept with the workspaces (issue 1921).
|
||||||
|
|
||||||
A pin on a pull request's head (issue 1148): when `go mod download` fails on a `git.novox.be/novox/*` pseudo-version, the
|
A pin on a pull request's head (issue 1148): when `go mod download` fails on a `git.novox.be/novox/*` pseudo-version, the
|
||||||
fetch looks for that commit among the forge's `refs/pull/*/head`, places the repository at it under the workspace's
|
fetch looks for that commit among the forge's `refs/pull/*/head`, places the repository at it under the workspace's
|
||||||
`pinned/beside/` as a plain directory, and hands it to Go (the fetch's commands, and the build with `GOPROXY=off`) through a
|
`pinned/beside/` as a plain directory, and hands it to Go (the fetch's commands, and the build with `GOPROXY=off`) through a
|
||||||
|
|||||||
@@ -71,3 +71,11 @@ the command is mesh-build-node's type-check line without a tsconfig.json:
|
|||||||
node <typescript>/bin/tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . ok.ts seat.ts
|
node <typescript>/bin/tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . ok.ts seat.ts
|
||||||
|
|
||||||
and the file is its stdout followed by its stderr.
|
and the file is its stdout followed by its stderr.
|
||||||
|
|
||||||
|
## passing/ — a runner's words when the network did not answer (novox/hq issue 1748, ADR 0664 §1)
|
||||||
|
|
||||||
|
Not captured by running: a network cannot be cut on demand in a test. Each file holds the words a runner wrote when a
|
||||||
|
build step met the fault on the mesh (the journal of ace on 2026-11-09 for ssh and go, the forge's sshd penalty; npm's
|
||||||
|
error codes and buildah's pull failure as those programs print them), and for each a negative case, a failure of the
|
||||||
|
source whose words are never a network's (an unknown revision, a checksum, a missing package, a failed RUN). Name:
|
||||||
|
`<runner>-<case>.txt`; the kit's table (conformance.go, PassingCases) says which are transport faults.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Error: building at STEP "RUN false": exit status 1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Error: creating build container: initializing source docker://alpine: pinging container registry registry-1.docker.io: Get "https://registry-1.docker.io/v2/": dial tcp: lookup registry-1.docker.io on 10.10.0.3:53: i/o timeout
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Connection closed by 10.10.0.1 port 222
|
||||||
|
fatal: Could not read from remote repository.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
kex_exchange_identification: read: Connection reset by peer
|
||||||
|
Connection reset by 10.10.0.1 port 222
|
||||||
|
fatal: Could not read from remote repository.
|
||||||
|
|
||||||
|
Please make sure you have the correct access rights
|
||||||
|
and the repository exists.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
fatal: couldn't find remote ref 0123456789abcdef0123456789abcdef01234567
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ssh: Could not resolve hostname mesh-forge: Temporary failure in name resolution
|
||||||
|
fatal: Could not read from remote repository.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"verifying module: checksum mismatch"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"git.novox.be/novox/mesh-sdk/go@abc: dial tcp 10.10.0.1:5100: i/o timeout"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"git.novox.be/novox/mesh-sdk/go@abc: dial tcp: lookup git.novox.be on 10.10.0.3:53: read udp 10.10.0.3:42292->10.10.0.3:53: i/o timeout"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"invalid version: unknown revision abc"}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
npm error code E404
|
||||||
|
npm error 404 Not Found - GET https://git.novox.be/api/packages/novox/npm/@novox%2fx
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
npm error code EAI_AGAIN
|
||||||
|
npm error syscall getaddrinfo
|
||||||
|
npm error errno EAI_AGAIN
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
npm error code ECONNRESET
|
||||||
|
npm error syscall read
|
||||||
|
npm error errno -104
|
||||||
|
npm error network aborted
|
||||||
@@ -12,6 +12,7 @@ package conformance
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -199,3 +200,80 @@ func describe(failed []buildstep.Failure) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func quote(s string) string { return fmt.Sprintf("%q", s) }
|
func quote(s string) string { return fmt.Sprintf("%q", s) }
|
||||||
|
|
||||||
|
// The runners whose network failures the kit holds words of (Passing).
|
||||||
|
const (
|
||||||
|
GitFetch = "git fetch"
|
||||||
|
GoDownload = "go mod download"
|
||||||
|
NpmCI = "npm ci"
|
||||||
|
BuildahBud = "buildah bud"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PassingCase is the words of one runner and whether they are a transport fault: a network that did not answer, which the
|
||||||
|
// bus gives the ask again for (novox/hq ADR 0664 §1), or a failure of the source, which fails the step at once.
|
||||||
|
type PassingCase struct {
|
||||||
|
Name, Runner string
|
||||||
|
Transport bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassingCases are the kit's cases of the network's failures (captured/passing).
|
||||||
|
var PassingCases = []PassingCase{
|
||||||
|
{"git-fetch-kex", GitFetch, true}, {"git-fetch-closed", GitFetch, true}, {"git-fetch-resolve", GitFetch, true}, {"git-fetch-noref", GitFetch, false},
|
||||||
|
{"go-download-lookup", GoDownload, true}, {"go-download-dial", GoDownload, true}, {"go-download-unknown", GoDownload, false}, {"go-download-checksum", GoDownload, false},
|
||||||
|
{"npm-ci-econnreset", NpmCI, true}, {"npm-ci-eaiagain", NpmCI, true}, {"npm-ci-404", NpmCI, false},
|
||||||
|
{"buildah-bud-timeout", BuildahBud, true}, {"buildah-bud-run", BuildahBud, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed is a build module's own path from a runner's words to the error its step ends with: the same code its step runs on
|
||||||
|
// a failed command, fed the words of a runner's failed run.
|
||||||
|
type Failed func(runner, words string) error
|
||||||
|
|
||||||
|
// Passing runs every case of the runners through failed, each as a subtest, and fails a build module whose step does not
|
||||||
|
// say a network's failure as a buildstep.Passing (the bus then gives the ask again) or says a failure of the source as one.
|
||||||
|
// A build module names the runners its steps use; the case's words are in captured/passing.
|
||||||
|
func Passing(t *testing.T, runners []string, failed Failed) {
|
||||||
|
t.Helper()
|
||||||
|
ran := 0
|
||||||
|
for _, c := range PassingCases {
|
||||||
|
if !slices.Contains(runners, c.Runner) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ran++
|
||||||
|
t.Run(c.Name, func(t *testing.T) {
|
||||||
|
if problem := judgePassing(c, failed); problem != "" {
|
||||||
|
t.Error(problem)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ran == 0 {
|
||||||
|
t.Fatalf("the kit holds no passing case of the runners %v", runners)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JudgePassing is the problems of failed over every case of the runners: what Passing fails a build module for.
|
||||||
|
func JudgePassing(runners []string, failed Failed) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range PassingCases {
|
||||||
|
if slices.Contains(runners, c.Runner) {
|
||||||
|
if p := judgePassing(c, failed); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func judgePassing(c PassingCase, failed Failed) string {
|
||||||
|
words, err := captured.ReadFile("captured/passing/" + c.Name + ".txt")
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
got := failed(c.Runner, string(words))
|
||||||
|
switch {
|
||||||
|
case got == nil:
|
||||||
|
return c.Name + ": the step passed on a failed run"
|
||||||
|
case buildstep.IsPassing(got) != c.Transport:
|
||||||
|
return fmt.Sprintf("%s: the step ends %q, a transport fault %v, want %v", c.Name, got, buildstep.IsPassing(got), c.Transport)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package conformance
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,6 +24,19 @@ func TestTheFailureContract(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ADR 0664 §1 (issue 1748): the shared wrapper keeps the passing cases; a step that never says a network's failure as a
|
||||||
|
// transport fault, or says every failure as one, is refused.
|
||||||
|
t.Run("the passing cases", func(t *testing.T) {
|
||||||
|
all := []string{GitFetch, GoDownload, NpmCI, BuildahBud}
|
||||||
|
Passing(t, all, func(_, words string) error { return buildstep.Transport(errors.New(words)) })
|
||||||
|
if p := JudgePassing(all, func(_, words string) error { return errors.New(words) }); len(p) != 8 {
|
||||||
|
t.Errorf("a step that wraps nothing: %d problems, want 8 (one per transport case):\n%s", len(p), strings.Join(p, "\n"))
|
||||||
|
}
|
||||||
|
if p := JudgePassing(all, func(_, words string) error { return &sdkstep.Passing{Err: errors.New(words)} }); len(p) != 5 {
|
||||||
|
t.Errorf("a step that wraps everything: %d problems, want 5 (one per source failure):\n%s", len(p), strings.Join(p, "\n"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends
|
// 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends
|
||||||
// everything.
|
// everything.
|
||||||
t.Run("the left-running case", func(t *testing.T) {
|
t.Run("the left-running case", func(t *testing.T) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||||
dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey"
|
dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey"
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,16 @@ var (
|
|||||||
refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`)
|
refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gitFailure is a failed git command said in git's own words; one whose words are a network's (a lookup, a timeout, ssh's key
|
||||||
|
// exchange cut by a forge's per-source penalty) is a sdkstep.Passing, so the ask is given again after a delay (ADR 0664 §1).
|
||||||
|
func gitFailure(verb, said string) error {
|
||||||
|
err := fmt.Errorf("git %s: %s", verb, said)
|
||||||
|
if sdkstep.PassingCause(said) {
|
||||||
|
return &sdkstep.Passing{Err: err}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached
|
// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached
|
||||||
// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh://<ssh name>/<owner>/<repo>.git, the ssh name being the
|
// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh://<ssh name>/<owner>/<repo>.git, the ssh name being the
|
||||||
// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names
|
// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names
|
||||||
@@ -72,7 +83,7 @@ func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit,
|
|||||||
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
||||||
r := run(ctx, env, "git", full...)
|
r := run(ctx, env, "git", full...)
|
||||||
if r.Status != 0 || r.Err != "" {
|
if r.Status != 0 || r.Err != "" {
|
||||||
return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
|
return "", gitFailure(args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
|
||||||
}
|
}
|
||||||
return r.Stdout, nil
|
return r.Stdout, nil
|
||||||
}
|
}
|
||||||
@@ -133,7 +144,7 @@ func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, comm
|
|||||||
if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C
|
if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C
|
||||||
verb = args[2]
|
verb = args[2]
|
||||||
}
|
}
|
||||||
return fmt.Errorf("git %s: %s", verb, dforgekey.Said(r.Err+"\n"+r.Stderr))
|
return gitFailure(verb, dforgekey.Said(r.Err+"\n"+r.Stderr))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package buildstep
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -93,6 +94,28 @@ func TestFetchCommitFallsBackToThePullRef(t *testing.T) {
|
|||||||
if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil {
|
if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil {
|
||||||
t.Error("a commit that is not in the ref was accepted")
|
t.Error("a commit that is not in the ref was accepted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADR 0664 §1 (issue 1748): the words a forge's sshd penalty leaves (captured on ace, 2026-11-09) are a transport fault the bus
|
||||||
|
// gives the ask again for; a missing commit is not.
|
||||||
|
for _, c := range []struct {
|
||||||
|
said string
|
||||||
|
passing bool
|
||||||
|
}{
|
||||||
|
{"kex_exchange_identification: read: Connection reset by peer\nfatal: Could not read from remote repository.", true},
|
||||||
|
{"Connection closed by 10.10.0.1 port 222", true},
|
||||||
|
{"ssh: Could not resolve hostname mesh-forge: Temporary failure in name resolution", true},
|
||||||
|
{"fatal: couldn't find remote ref " + sha, false},
|
||||||
|
} {
|
||||||
|
x := &fakeGit{rule: func(l string) forgekey.Ran {
|
||||||
|
if strings.Contains(l, " fetch ") {
|
||||||
|
return forgekey.Ran{Status: 128, Stderr: c.said}
|
||||||
|
}
|
||||||
|
return forgekey.Ran{}
|
||||||
|
}}
|
||||||
|
if err := FetchCommit(context.Background(), x.run, "/w", "ssh://mesh-forge/o/r.git", sha, ""); err == nil || sdkstep.IsPassing(err) != c.passing {
|
||||||
|
t.Errorf("%q: %v, passing %v, want %v", c.said, err, err != nil && sdkstep.IsPassing(err), c.passing)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both.
|
// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
|
||||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000
|
git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000
|
||||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000
|
git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000
|
||||||
|
|||||||
@@ -27,16 +27,19 @@ const Bubblewrap = "bwrap"
|
|||||||
// - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries,
|
// - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries,
|
||||||
// certificates, time zone), with a hosts file naming only the loopback;
|
// certificates, time zone), with a hosts file naming only the loopback;
|
||||||
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
|
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
|
||||||
// downloaded) read-only;
|
// downloaded) read-only, and the ReadOnlyInside ones (inside the Workspace) read-only over it;
|
||||||
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
|
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
|
||||||
// (WriteCaches), so a candidate's code never changes what a later build reads.
|
// (WriteCaches), so a candidate's code never changes what a later build reads.
|
||||||
type Isolation struct {
|
type Isolation struct {
|
||||||
Workspace string
|
Workspace string
|
||||||
Writable []string
|
Writable []string
|
||||||
ReadOnly []string
|
ReadOnly []string
|
||||||
Caches []string
|
// ReadOnlyInside are directories inside the Workspace or a Writable one bound read-only after them, so they stay
|
||||||
WriteCaches bool
|
// read-only (a later bind of a path wins; a ReadOnly one inside the Workspace would be covered by it).
|
||||||
Env []string
|
ReadOnlyInside []string
|
||||||
|
Caches []string
|
||||||
|
WriteCaches bool
|
||||||
|
Env []string
|
||||||
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
|
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
|
||||||
Hosts string
|
Hosts string
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
|
|||||||
for _, group := range []struct {
|
for _, group := range []struct {
|
||||||
what string
|
what string
|
||||||
dirs []string
|
dirs []string
|
||||||
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} {
|
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"read-only directory", iso.ReadOnlyInside}, {"cache", iso.Caches}} {
|
||||||
for _, d := range group.dirs {
|
for _, d := range group.dirs {
|
||||||
if err := checkDir(group.what, d); err != nil {
|
if err := checkDir(group.what, d); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -118,6 +121,9 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
|
|||||||
for _, d := range iso.Writable {
|
for _, d := range iso.Writable {
|
||||||
argv = append(argv, "--bind", d, d)
|
argv = append(argv, "--bind", d, d)
|
||||||
}
|
}
|
||||||
|
for _, d := range iso.ReadOnlyInside {
|
||||||
|
argv = append(argv, "--ro-bind", d, d)
|
||||||
|
}
|
||||||
if iso.Hosts != "" {
|
if iso.Hosts != "" {
|
||||||
argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts")
|
argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
|
|||||||
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
||||||
r := run(ctx, env, "git", full...)
|
r := run(ctx, env, "git", full...)
|
||||||
if r.Status != 0 || r.Err != "" {
|
if r.Status != 0 || r.Err != "" {
|
||||||
return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
|
return "", gitFailure(args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
|
||||||
}
|
}
|
||||||
return r.Stdout, nil
|
return r.Stdout, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user