Merge remote-tracking branch 'origin/main' into feat/47-G-join11
# Conflicts: # modules/distribution/internal/holder/holder_test.go
This commit is contained in:
@@ -5,6 +5,12 @@ rotation source, its consumers moved off a licence near its limit by the manager
|
||||
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
|
||||
keeps none.
|
||||
|
||||
It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its
|
||||
own, never in the machine's tool runner: its service part (the carry-over at start, the usage timer, the switch rule,
|
||||
the conditions) and its tools beside it, so every state, log and secret below is issued to its own account. Its
|
||||
account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and
|
||||
a node's `claude_code_grant`), the events under `emits` and `consumes`.
|
||||
|
||||
## How a licence comes to exist
|
||||
|
||||
Nothing is configured. Every node running `claude-code` says what it holds as the event
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control
|
||||
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
|
||||
// `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.
|
||||
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under
|
||||
// its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner,
|
||||
// and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the
|
||||
// carry-over at start, the usage timer, the switch rule, the conditions): 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. Its tools part, the
|
||||
// seat's verbs and the module's tools, is served beside it and acts only through that one running manager.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
@@ -58,7 +60,7 @@ func manager() (*Manager, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := keypair(dir)
|
||||
keys, err := keypair(dir, say)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -129,28 +131,60 @@ func configLevels(node string) (map[string]string, error) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
func keypair(dir string) (KeyPair, error) {
|
||||
// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it
|
||||
// finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said
|
||||
// in its journal (design 39 bus-and-switch C2): every use passes the public key per ask, so nothing depends on the old.
|
||||
func keypair(dir string, log func(string, ...any)) (KeyPair, error) {
|
||||
file := filepath.Join(dir, "manager-key.json")
|
||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
if err := writeKeyPair(dir, file, false); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
raw, _ := json.Marshal(k)
|
||||
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
_ = os.Link(tmp, file) // fails when another made it first, which is right
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
k, err := readKeyPair(file)
|
||||
if err == nil {
|
||||
return k, nil
|
||||
}
|
||||
log("the key pair %s cannot be read (%v); a new one is made in its place", file, err)
|
||||
if err := writeKeyPair(dir, file, true); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return readKeyPair(file)
|
||||
}
|
||||
|
||||
func readKeyPair(file string) (KeyPair, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
var k KeyPair
|
||||
return k, json.Unmarshal(raw, &k)
|
||||
if err := json.Unmarshal(raw, &k); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if k.PublicKey == "" || k.PrivateKey == "" {
|
||||
return KeyPair{}, errors.New("it holds no whole key pair")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// writeKeyPair writes a new pair whole beside file, then links it into place, or, replacing, renames it over the one
|
||||
// that cannot be read.
|
||||
func writeKeyPair(dir, file string, replace bool) error {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(k)
|
||||
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if replace {
|
||||
return os.Rename(tmp, file)
|
||||
}
|
||||
_ = os.Link(tmp, file) // fails when another made it first, which is right
|
||||
_ = os.Remove(tmp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the long-running half ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -48,3 +50,105 @@ func TestTheManifestKeepsNoOldForm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the
|
||||
// states and logs it keeps (`requires` by template), what it may ask (`invokes`) and say (`emits`).
|
||||
type issued struct {
|
||||
RunsAs, SecretsOwner string
|
||||
OwnSecrets map[string]string
|
||||
Resources []map[string]any
|
||||
States, Logs map[string]bool
|
||||
Invokes, Emits map[string]bool
|
||||
}
|
||||
|
||||
func manifestIssued(t testing.TB) issued {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
RunsAs string `json:"runs-as"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
Requires []json.RawMessage `json:"requires"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Emits []string `json:"emits"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := issued{RunsAs: m.RunsAs, SecretsOwner: m.SecretsOwner, OwnSecrets: m.OwnSecrets, Resources: m.Resources,
|
||||
States: map[string]bool{}, Logs: map[string]bool{}, Invokes: map[string]bool{}, Emits: map[string]bool{}}
|
||||
for _, r := range m.Requires {
|
||||
var e struct{ Name, Storage string }
|
||||
if json.Unmarshal(r, &e) != nil {
|
||||
continue
|
||||
}
|
||||
switch e.Storage {
|
||||
case "key-value":
|
||||
out.States[e.Name] = true
|
||||
case "stream":
|
||||
out.Logs[e.Name] = true
|
||||
}
|
||||
}
|
||||
for _, k := range m.Invokes {
|
||||
out.Invokes[k] = true
|
||||
}
|
||||
for _, e := range m.Emits {
|
||||
out.Emits[e] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`<module>.<tool>@<node>`)
|
||||
// is invoked as `<module>.<tool>`.
|
||||
func (i issued) mayAsk(key string) bool {
|
||||
k, _, _ := strings.Cut(key, "@")
|
||||
return i.Invokes[k]
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch C1: the manager runs as an account of its own, which it makes and which owns what it places
|
||||
// on its machine and its secrets, reaching the bus as itself; and that account is issued every ask its code makes,
|
||||
// since its runtime is not the machine's tool runner.
|
||||
func TestTheManagerRunsAsItsOwnAccount(t *testing.T) {
|
||||
m := manifestIssued(t)
|
||||
if m.RunsAs != Publisher {
|
||||
t.Fatalf("the manager runs as %q, not as its own account %s", m.RunsAs, Publisher)
|
||||
}
|
||||
if m.SecretsOwner != Publisher || m.OwnSecrets["broker"] == "" {
|
||||
t.Errorf("its secrets belong to %q and its own broker secret is %q: both are its account's", m.SecretsOwner,
|
||||
m.OwnSecrets["broker"])
|
||||
}
|
||||
made := false
|
||||
for _, r := range m.Resources {
|
||||
switch r["type"] {
|
||||
case "user":
|
||||
made = made || r["name"] == Publisher
|
||||
case "directory", "file":
|
||||
if r["owner"] != Publisher {
|
||||
t.Errorf("resource %v is owned by %v, not by the manager's account", r["id"], r["owner"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !made {
|
||||
t.Errorf("the manager does not make its account: no user resource named %s", Publisher)
|
||||
}
|
||||
files, _ := filepath.Glob("*.go")
|
||||
asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`)
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range asked.FindAllStringSubmatch(string(src), -1) {
|
||||
if !m.mayAsk(k[1]) {
|
||||
t.Errorf("%s asks %s, which the manifest's invokes do not name", f, k[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -41,3 +44,29 @@ func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
|
||||
t.Fatal("a box opened for another key")
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch C2: a key pair the manager finds and cannot read (left by another account, here mode 000) is
|
||||
// replaced in its place by a new one, said in its journal; the new one is read back by the next start.
|
||||
func TestAKeyPairItCannotReadIsMadeAnew(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Fatal("run as root, a file of mode 000 is still read: this test needs an ordinary account")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "manager-key.json")
|
||||
if err := os.WriteFile(file, []byte(`{"publicKey":"old","privateKey":"old"}`), 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var said []string
|
||||
log := func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
|
||||
k, err := keypair(dir, log)
|
||||
if err != nil {
|
||||
t.Fatalf("a key pair it cannot read stops the manager: %v", err)
|
||||
}
|
||||
if k.PublicKey == "" || k.PublicKey == "old" || len(said) != 1 || !strings.Contains(said[0], "manager-key.json") {
|
||||
t.Fatalf("not made anew and said: key %q, journal %q", k.PublicKey, said)
|
||||
}
|
||||
again, err := keypair(dir, log)
|
||||
if err != nil || again != k || len(said) != 1 {
|
||||
t.Fatalf("the new pair is not the one read at the next start: %v, %v, %q", again == k, err, said)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-7
@@ -287,6 +287,7 @@ type mesh struct {
|
||||
dir string
|
||||
dbURL string
|
||||
vendor *vendor
|
||||
own issued // what the manager's own runtime is issued from its manifest (C1)
|
||||
|
||||
mu sync.Mutex
|
||||
said []said
|
||||
@@ -299,11 +300,14 @@ type mesh struct {
|
||||
conditions []oc.Condition
|
||||
}
|
||||
|
||||
const busStates = "licences bindings leases offered carried conditions"
|
||||
const busLogs = "usage audit"
|
||||
|
||||
func newWholeMesh(t *testing.T, dbURL string) *mesh {
|
||||
t.Helper()
|
||||
// The manager runs as its own account, never in the machine's tool runner, whose account is issued none of its
|
||||
// logs or states (C1): this player plays that runtime of its own, issuing what the manifest names and nothing else.
|
||||
own := manifestIssued(t)
|
||||
if own.RunsAs != Publisher {
|
||||
t.Fatalf("the manager declares runs-as %q: it would run in the machine's tool runner, not as its own account", own.RunsAs)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
dir := t.TempDir()
|
||||
@@ -323,12 +327,12 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the bus module creates from the manifest's templates (§1 to §9) before the manager's want moves (§50).
|
||||
for _, s := range strings.Fields(busStates) {
|
||||
for s := range own.States {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Publisher + "_" + s, History: 8}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, l := range strings.Fields(busLogs) {
|
||||
for l := range own.Logs {
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l,
|
||||
Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -340,7 +344,7 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t),
|
||||
m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), own: own,
|
||||
news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{},
|
||||
callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}}
|
||||
sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) {
|
||||
@@ -521,6 +525,10 @@ func (p *started) handle(w wire) {
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Params, &a)
|
||||
if why := p.refused(w.Method, w.Params); why != nil {
|
||||
p.answer(w.ID, nil, why)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(w.Method, "mesh/log.") {
|
||||
p.log(w)
|
||||
return
|
||||
@@ -620,6 +628,28 @@ func (p *started) handle(w wire) {
|
||||
}
|
||||
}
|
||||
|
||||
// refused is the runtime's refusal of what the manager's own account was not issued (C1): a state or a log its
|
||||
// manifest does not ask for, an ask its invokes do not name, an event its emits do not name; nil when it was issued.
|
||||
func (p *started) refused(method string, params json.RawMessage) error {
|
||||
var a struct {
|
||||
State string `json:"state"`
|
||||
Log string `json:"log"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(params, &a)
|
||||
switch {
|
||||
case strings.HasPrefix(method, "mesh/state.") && !p.own.States[a.State]:
|
||||
return fmt.Errorf("%s keeps no state called %q", Publisher, a.State)
|
||||
case strings.HasPrefix(method, "mesh/log.") && !p.own.Logs[a.Log]:
|
||||
return fmt.Errorf("%s keeps no log called %q", Publisher, a.Log)
|
||||
case method == "mesh/ask" && !p.own.mayAsk(a.Key):
|
||||
return fmt.Errorf("this account may not call %s", a.Key)
|
||||
case method == "mesh/publish" && !p.own.Emits[a.Key]:
|
||||
return fmt.Errorf("%s does not emit %s", Publisher, a.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *started) say(key string, body json.RawMessage) {
|
||||
p.mu.Lock()
|
||||
p.said = append(p.said, said{key: key, body: append(json.RawMessage(nil), body...)})
|
||||
@@ -899,7 +929,7 @@ func (m *mesh) written() map[string]uint64 {
|
||||
i, _ := info.Info(m.ctx)
|
||||
out[s] = i.State.LastSeq
|
||||
}
|
||||
for _, l := range strings.Fields(busLogs) {
|
||||
for l := range m.own.Logs {
|
||||
info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l)
|
||||
if err != nil {
|
||||
m.t.Fatal(err)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"module": "claude-licence-manager",
|
||||
"version": "0.1",
|
||||
"slug": "licmgr",
|
||||
"runs-as": "claude-licence-manager",
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"secret",
|
||||
@@ -91,6 +92,10 @@
|
||||
"grant-key": "${dir:state}/grant.key"
|
||||
}
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "claude-licence-manager",
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
@@ -98,7 +103,8 @@
|
||||
"seat:operator-channel.tell",
|
||||
"seat:mesh-vault.licence-put",
|
||||
"seat:mesh-vault.get",
|
||||
"seat:mesh-config.config"
|
||||
"seat:mesh-config.config",
|
||||
"claude-code.claude_code_grant"
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
@@ -232,10 +238,18 @@
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "claude-licence-manager",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/claude-licence-manager"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "claude-licence-manager",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
@@ -244,6 +258,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/database.url",
|
||||
"mode": "0600",
|
||||
"owner": "claude-licence-manager",
|
||||
"template": "files/database.url"
|
||||
},
|
||||
{
|
||||
@@ -251,6 +266,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "claude-licence-manager",
|
||||
"takes-config": [
|
||||
"cadence_minutes",
|
||||
"floor_minutes",
|
||||
|
||||
@@ -223,7 +223,6 @@ const builder = "ace.mesh-build-go"
|
||||
// TestTheHolderOfTheTwoImageSeats holds design 51's holder cases at the unit level (CAT-D1, D2, D3, D3b, D4, D4b, D5):
|
||||
// one table or case per rule, the caller list and the store front's.
|
||||
func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
t.Run("a publish is checked against the builder's reservation", reservedChecksAPublishAgainstTheReservation)
|
||||
blob := []byte(strings.Repeat("a", 3000))
|
||||
part := Part{Digest: digestOf(blob), Size: int64(len(blob))}
|
||||
req := func(id string) PublishRequest {
|
||||
@@ -243,14 +242,14 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
||||
t.Errorf("resumed publish = %+v", a)
|
||||
}
|
||||
// A chunk 0 again starts the part afresh (it travels whole again, design 51 Publishing); a chunk that is neither the
|
||||
// cursor nor 0 is refused, naming the cursor.
|
||||
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 sent again was refused: %v", err)
|
||||
}
|
||||
// A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again
|
||||
// (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:]})
|
||||
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
|
||||
t.Fatalf("last chunk = %+v, %v; said %+v", a, err, b.said)
|
||||
@@ -292,12 +291,12 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
_, err := b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
return err
|
||||
}, "at their cap"},
|
||||
{"a version the builder did not reserve", func(b *bed) error {
|
||||
{"a version the builder did not number", func(b *bed) error {
|
||||
b.h.Builder = fakeBuilder{ok: false}
|
||||
b.store.has[part.Digest] = true // held already: the publish ends at once
|
||||
_, err := b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
return err
|
||||
}, "the builder did not reserve it"},
|
||||
}, "the builder did not number it"},
|
||||
{"an image on the artifact seat", func(b *bed) error {
|
||||
r := req("t")
|
||||
r.Kind = KindImage
|
||||
@@ -849,9 +848,9 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The reservation is asked through the seat mesh-builds, so the manifest takes no package of the builder (design 47 §381).
|
||||
if len(m.Packages) != 0 {
|
||||
t.Errorf("packages %v: want none", m.Packages)
|
||||
// The builder's question is its published package, taken by its series (ADR 0634 §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)
|
||||
}
|
||||
// E15: the provision artifact-store retires (ADR 0632 §3); the bootstrap keeps it as an alias on port 5100 while
|
||||
// the running node-engines still find the store by it (issue 1966), and nothing else is provided or served.
|
||||
@@ -870,7 +869,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
if _, ok := claims[OldSeat]; !ok {
|
||||
t.Error("the old seat mesh-artifact-store is no longer claimed while its window is open")
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed", "mesh-conditions.findings-asked"}) {
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed"}) {
|
||||
t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes)", m.Consumes)
|
||||
}
|
||||
// E14: the registry container, on the loopback, is listed before the program, so the port is free when it starts.
|
||||
@@ -916,34 +915,19 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
// 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
|
||||
// in idle, a state of keys with their own time to live.
|
||||
// The image registry is a foundation part (design 47 G §584): its bus state is in its old sections, incoming in
|
||||
// `objects`, the others in `state`.
|
||||
var req struct {
|
||||
Objects []struct {
|
||||
Name string `json:"name"`
|
||||
MaxMiB uint64 `json:"max-mib"`
|
||||
} `json:"objects"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
Requires []struct {
|
||||
Name string `json:"name"`
|
||||
Options map[string]any `json:"options"`
|
||||
} `json:"requires"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &req)
|
||||
opts := map[string]map[string]any{}
|
||||
for _, e := range req.State {
|
||||
var o map[string]any
|
||||
if json.Unmarshal(e, &o) != nil {
|
||||
var name string
|
||||
_ = json.Unmarshal(e, &name)
|
||||
o = map[string]any{"name": name}
|
||||
}
|
||||
opts[fmt.Sprint(o["name"])] = o
|
||||
for _, r := range req.Requires {
|
||||
opts[r.Name] = r.Options
|
||||
}
|
||||
var incoming uint64
|
||||
for _, o := range req.Objects {
|
||||
if o.Name == IncomingObjects {
|
||||
incoming = o.MaxMiB
|
||||
}
|
||||
}
|
||||
if incoming<<20 != IncomingCap {
|
||||
t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", incoming, uint64(IncomingCap))
|
||||
if mib, _ := opts[IncomingObjects]["max-mib"].(float64); uint64(mib)<<20 != IncomingCap {
|
||||
t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", opts[IncomingObjects]["max-mib"], uint64(IncomingCap))
|
||||
}
|
||||
if _, ok := opts[TransfersState]["ttl-seconds"]; ok {
|
||||
t.Errorf("transfers has a lifetime %v: the bus would drop a transfer's key and leave its parts", opts[TransfersState])
|
||||
|
||||
@@ -319,6 +319,9 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
|
||||
if err := unitArg(unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if q.Boot != "" {
|
||||
return nil, fmt.Errorf("boot %q: the journal of a unit is not read by boot; kernel-log takes a boot", q.Boot)
|
||||
}
|
||||
q, err := q.valid()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -332,28 +335,7 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
|
||||
return nil, err
|
||||
}
|
||||
known, envErr := m.unitSecrets(scope, unit)
|
||||
all := []string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l != "" {
|
||||
all = append(all, l)
|
||||
}
|
||||
}
|
||||
scanned := len(all)
|
||||
kept, redacted := []string{}, 0
|
||||
for _, l := range all {
|
||||
l, n := redact(l, known)
|
||||
redacted += n
|
||||
if q.Match != "" && !strings.Contains(l, q.Match) {
|
||||
continue
|
||||
}
|
||||
if len(l) > LongestLine {
|
||||
l = l[:LongestLine] + "…"
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if len(kept) > q.Lines {
|
||||
kept = kept[len(kept)-q.Lines:]
|
||||
}
|
||||
kept, scanned, redacted := shapeLines(out, known, q)
|
||||
answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)}
|
||||
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
|
||||
if v != "" {
|
||||
@@ -376,6 +358,74 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// shapeLines is the lines of journalctl's output that a query keeps, each redacted and cut, the last q.Lines of
|
||||
// those holding the match; and how many lines were read and how many secrets replaced.
|
||||
func shapeLines(out string, known []knownSecret, q JournalQuery) (kept []string, scanned, redacted int) {
|
||||
all := []string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l != "" {
|
||||
all = append(all, l)
|
||||
}
|
||||
}
|
||||
scanned = len(all)
|
||||
kept = []string{}
|
||||
for _, l := range all {
|
||||
l, n := redact(l, known)
|
||||
redacted += n
|
||||
if q.Match != "" && !strings.Contains(l, q.Match) {
|
||||
continue
|
||||
}
|
||||
if len(l) > LongestLine {
|
||||
l = l[:LongestLine] + "…"
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if len(kept) > q.Lines {
|
||||
kept = kept[len(kept)-q.Lines:]
|
||||
}
|
||||
return kept, scanned, redacted
|
||||
}
|
||||
|
||||
// KernelLog is the kernel's messages of one boot (journalctl -k, dmesg's, of this boot or an earlier one) that a
|
||||
// query keeps: the same window, priority, match, cap and redaction as Journal. The kernel's messages belong to
|
||||
// no unit and no manager, so it has no scope; the read is the system journal's, escalated as Journal's is.
|
||||
func (m *Manager) KernelLog(q JournalQuery) (map[string]any, error) {
|
||||
q, err := q.valid()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
read := q.Lines
|
||||
if q.Match != "" {
|
||||
read = MatchScan
|
||||
}
|
||||
out, err := m.call(System, "journalctl", q.kernelArgv(read)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept, scanned, redacted := shapeLines(out, nil, q)
|
||||
boot := q.Boot
|
||||
if boot == "" {
|
||||
boot = "0"
|
||||
}
|
||||
answer := map[string]any{"boot": boot, "lines": kept, "count": len(kept)}
|
||||
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
|
||||
if v != "" {
|
||||
answer[k] = v
|
||||
}
|
||||
}
|
||||
if q.Match != "" {
|
||||
answer["scanned"] = scanned
|
||||
if scanned >= MatchScan {
|
||||
answer["note"] = fmt.Sprintf("the match was looked for in the window's last %d lines only: narrow the window to reach earlier ones", MatchScan)
|
||||
}
|
||||
}
|
||||
if redacted > 0 {
|
||||
answer["redacted"] = redacted
|
||||
answer["leak"] = "the kernel's messages hold secrets, shown as [redacted: <what it was>]"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// unitSecrets are the values of the unit's own Environment= that must not be answered. Read with
|
||||
// systemctl show, which needs no escalation; a unit that does not exist has none.
|
||||
func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) {
|
||||
|
||||
@@ -28,6 +28,8 @@ const (
|
||||
LongestLine = 4096
|
||||
// LongestMatch is the longest text a match may be.
|
||||
LongestMatch = 256
|
||||
// MostBoots is how far back a kernel-log read may name a boot.
|
||||
MostBoots = 9999
|
||||
)
|
||||
|
||||
// JournalQuery is what a journal read is narrowed by.
|
||||
@@ -37,6 +39,8 @@ type JournalQuery struct {
|
||||
Until string
|
||||
Match string
|
||||
Priority string
|
||||
// Boot is which boot a kernel-log read is of: "0" this boot, "-1" the one before, and so on (empty is "0").
|
||||
Boot string
|
||||
}
|
||||
|
||||
// relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words.
|
||||
@@ -101,6 +105,13 @@ func (q JournalQuery) valid() (JournalQuery, error) {
|
||||
return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority)
|
||||
}
|
||||
}
|
||||
if q.Boot != "" {
|
||||
n, err := strconv.Atoi(q.Boot)
|
||||
if err != nil || n > 0 || n < -MostBoots {
|
||||
return q, fmt.Errorf("boot %q: 0 for this boot, -1 for the one before, and so on (down to -%d)", q.Boot, MostBoots)
|
||||
}
|
||||
q.Boot = strconv.Itoa(n)
|
||||
}
|
||||
if len(q.Match) > LongestMatch {
|
||||
return q, fmt.Errorf("a match is at most %d bytes", LongestMatch)
|
||||
}
|
||||
@@ -112,7 +123,21 @@ func (q JournalQuery) valid() (JournalQuery, error) {
|
||||
|
||||
// argv is journalctl's words for a valid query: each value inside the word of its own option.
|
||||
func (q JournalQuery) argv(unit string, lines int) []string {
|
||||
args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit}
|
||||
return q.window([]string{"--no-pager", "--output=short-iso", "--unit=" + unit}, lines)
|
||||
}
|
||||
|
||||
// kernelArgv is journalctl's words for a valid query of the kernel's messages of one boot (-k, as dmesg
|
||||
// reads them, but also of an earlier boot and in a window).
|
||||
func (q JournalQuery) kernelArgv(lines int) []string {
|
||||
boot := q.Boot
|
||||
if boot == "" {
|
||||
boot = "0"
|
||||
}
|
||||
return q.window([]string{"--no-pager", "--output=short-iso", "--dmesg", "--boot=" + boot}, lines)
|
||||
}
|
||||
|
||||
// window adds the window, the priority and the line count to the words that choose what is read.
|
||||
func (q JournalQuery) window(args []string, lines int) []string {
|
||||
if q.Since != "" {
|
||||
v, _, _ := when("since", q.Since, false)
|
||||
args = append(args, "--since="+v)
|
||||
@@ -149,7 +174,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
|
||||
default:
|
||||
return q, fmt.Errorf("lines: a whole number")
|
||||
}
|
||||
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority} {
|
||||
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority, "boot": &q.Boot} {
|
||||
switch v := a[name].(type) {
|
||||
case nil:
|
||||
case string:
|
||||
@@ -159,7 +184,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
|
||||
*into = strings.TrimSpace(v)
|
||||
}
|
||||
case float64:
|
||||
if name != "priority" {
|
||||
if name != "priority" && name != "boot" {
|
||||
return q, fmt.Errorf("%s: text", name)
|
||||
}
|
||||
*into = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
|
||||
@@ -241,3 +241,89 @@ func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) {
|
||||
t.Fatal("a scope that is none was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1975/AC1: kernel-log reads journalctl --dmesg --boot=N under journal's escalation (issue 1975): the kernel's messages of one boot, in the journal verb's window, priority,
|
||||
// match, cap and redaction, and a boot named 0, -1, and so on.
|
||||
func TestTheKernelLogIsOneBootsMessagesInTheJournalsForms(t *testing.T) {
|
||||
var calls []call
|
||||
m := journalOf([]string{"2026-11-12T10:00:00+0100 host kernel: usb 1-1: new device"}, "", &calls)
|
||||
got, err := m.KernelLog(JournalQuery{Lines: 50, Since: "-2h", Priority: "err", Boot: "-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "--no-pager --output=short-iso --dmesg --boot=-1 --since=-2h --priority=3 --lines=50"
|
||||
if a := strings.Join(journalArgs(t, calls), " "); a != want {
|
||||
t.Fatalf("journalctl was given\n %s\nnot\n %s", a, want)
|
||||
}
|
||||
if calls[0].cmd != "sudo" || got["boot"] != "-1" || got["count"] != 1 {
|
||||
t.Fatalf("not escalated, or the answer is wrong: %+v %+v", calls[0], got)
|
||||
}
|
||||
calls = nil
|
||||
if _, err := m.KernelLog(JournalQuery{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := strings.Join(journalArgs(t, calls), " "); a != "--no-pager --output=short-iso --dmesg --boot=0 --lines=100" {
|
||||
t.Fatalf("the default is this boot and a hundred lines: %s", a)
|
||||
}
|
||||
// Arguments arrive as the seat's strings or as numbers.
|
||||
for _, a := range []map[string]any{{"boot": float64(-2)}, {"boot": "-2"}} {
|
||||
q, err := journalQuery(a)
|
||||
if err != nil || q.Boot != "-2" {
|
||||
t.Errorf("%v: %+v %v", a, q, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1975/AC2: a boot that is not 0 to -9999 is refused and no command runs.
|
||||
func TestAKernelLogBootIsRefusedBeforeAnythingRunsUnlessItIsZeroOrBefore(t *testing.T) {
|
||||
for _, bad := range []string{"1", "abc", "--user", "-1 --merge", "-10000", "0x1", "-1;id"} {
|
||||
var calls []call
|
||||
m := journalOf(nil, "", &calls)
|
||||
if _, err := m.KernelLog(JournalQuery{Boot: bad}); err == nil {
|
||||
t.Errorf("boot %q accepted", bad)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Errorf("boot %q reached journalctl: %v", bad, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1975/AC3: the window, priority, match, 2000-line cap and redaction are journal's.
|
||||
func TestTheKernelLogKeepsTheCapTheMatchAndTheRedaction(t *testing.T) {
|
||||
var calls []call
|
||||
m := journalOf([]string{"k one", "k password=hunter2secretvalue here", "k two"}, "", &calls)
|
||||
got, err := m.KernelLog(JournalQuery{Match: "k t", Lines: 5000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, fmt.Sprintf("--lines=%d", MatchScan)) {
|
||||
t.Errorf("a match scans the window's last %d lines: %s", MatchScan, a)
|
||||
}
|
||||
if lines := got["lines"].([]string); len(lines) != 1 || lines[0] != "k two" {
|
||||
t.Errorf("the match kept %v", lines)
|
||||
}
|
||||
calls = nil
|
||||
m = journalOf([]string{"k password=hunter2secretvalue here"}, "", &calls)
|
||||
got, err = m.KernelLog(JournalQuery{Lines: 5000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, "--lines=2000") {
|
||||
t.Errorf("the cap is 2000: %s", a)
|
||||
}
|
||||
if strings.Contains(fmt.Sprint(got["lines"]), "hunter2") || got["redacted"] == nil {
|
||||
t.Errorf("a secret in a kernel line was answered: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1975/AC4: journal refuses a boot argument it does not use, never reading this boot quietly.
|
||||
func TestJournalRefusesABootArgument(t *testing.T) {
|
||||
var calls []call
|
||||
m := journalOf([]string{"a"}, "", &calls)
|
||||
if _, err := m.Journal(System, "mail.service", JournalQuery{Boot: "-1"}); err == nil {
|
||||
t.Fatal("journal accepted a boot and read this boot")
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("a refused boot reached the manager: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,24 @@ func tools(m *Manager) []stdio.Tool {
|
||||
}
|
||||
return m.Journal(scope, unit, q)
|
||||
}},
|
||||
{Name: seat + ".kernel-log",
|
||||
Description: "The kernel's messages (what journalctl -k and dmesg give) of this boot or an earlier one, at most 2000 lines, " +
|
||||
"in a time window and narrowed to a priority and to lines holding a text when asked. A secret a line held is " +
|
||||
"shown as [redacted: <what it was>].",
|
||||
Input: map[string]any{
|
||||
"boot": str("which boot: 0 this boot (the default), -1 the one before, -2 the one before that, and so on (optional)"),
|
||||
"lines": str("how many lines from the end of what matches (default 100, at most 2000)"),
|
||||
"since": str("the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)"),
|
||||
"until": str("the window's end, in the same forms (optional; now when absent)"),
|
||||
"match": str("only the lines holding this text, as written — a fixed string, not a pattern (optional)"),
|
||||
"priority": str("only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
q, err := journalQuery(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.KernelLog(q)
|
||||
}},
|
||||
// **Clearing a failed record** (novox/hq issue 332): a unit whose file is gone keeps its failed state,
|
||||
// and its start-limit count, in its manager until it is reset. A unit's name is required, so the
|
||||
// verb never resets every failed unit at once.
|
||||
|
||||
@@ -274,8 +274,8 @@ func TestAGlobIsRefusedAsAUnitByEveryVerb(t *testing.T) {
|
||||
m.State = t.TempDir()
|
||||
for _, tool := range tools(m) {
|
||||
verb := strings.TrimPrefix(tool.Name, seat+".")
|
||||
if verb == "units" || verb == "failed" {
|
||||
continue // a pattern, and no unit
|
||||
if verb == "units" || verb == "failed" || verb == "kernel-log" {
|
||||
continue // a pattern, or no unit at all
|
||||
}
|
||||
_, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"})
|
||||
if err == nil {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"enable",
|
||||
"disable",
|
||||
"journal",
|
||||
"kernel-log",
|
||||
"failed",
|
||||
"reset-failed",
|
||||
"wanted-by",
|
||||
|
||||
Reference in New Issue
Block a user