Files
mesh-host/internal/apply/grants_test.go
T
jschoubben c433e76de2
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Judge a granted group by number, grant nothing a group cannot write, and keep the look bounded (hq ADR 0312, issue 537 review)
The engine is built without cgo, so a check by name missed a root group a name
service holds or one number carrying another name: the root groups are resolved
to numbers (now with kmem and shadow) and a number named nothing is refused for
containers too. A group without write is granted nothing (§2). The look reads
each path off the lock within five seconds, so a dead network mount cannot stop
every health statement. A grant's account may not start with a dash. A group a
record leaves for another asker is handed to it, so it is still taken back when
the last asker goes (§5). The scheduler skips paths issue 339's guard refuses.
2026-10-12 12:53:42 +02:00

560 lines
23 KiB
Go

package apply
import (
"context"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0312 and issue 537: a read-write access grants its path's own group to the containers and
// the account the controller names, and the path is never changed; a read access grants nothing; the root group,
// and a group whose members act as root, are refused by name; what was granted is taken back as ADR 0252 takes
// back; a container is recreated with the group, and a unit of the machine's own manager running as the account
// is restarted once the account holds it.
// grantMachine is the group database of groupDB, a container runtime that keeps each container's spec label and
// the arguments it was run with, and a service manager that notes, at each restart, the groups the account holds
// in the database then — which is what the machine's own manager gives the unit's new process.
type grantMachine struct {
*groupDB
containers map[string]string
runs map[string][]string
restarts []string
}
func newGrantMachine(account string, in []string, exists ...string) *grantMachine {
g := newGroupDB(in, exists...)
g.account = account
return &grantMachine{groupDB: g, containers: map[string]string{}, runs: map[string][]string{}}
}
func (m *grantMachine) run(ctx context.Context, name string, args ...string) (string, error) {
a := oldShape(name, args)
switch name {
case "docker":
m.asked = append(m.asked, name+" "+strings.Join(a, " "))
switch a[0] {
case "info":
return "27.0\n", nil
case "container":
spec, there := m.containers[a[len(a)-1]]
if !there {
return "", errors.New("no such container")
}
return "true\t" + spec, nil
case "rm":
delete(m.containers, a[len(a)-1])
case "run":
var named, spec string
for i := 0; i+1 < len(a); i++ {
switch {
case a[i] == "--name":
named = a[i+1]
case a[i] == "--label" && strings.HasPrefix(a[i+1], specLabel+"="):
spec = strings.TrimPrefix(a[i+1], specLabel+"=")
}
}
m.containers[named] = spec
m.runs[named] = a
return "deadbeef\n", nil
}
return "", nil
case "systemctl":
m.asked = append(m.asked, name+" "+strings.Join(a, " "))
if a[0] == "restart" {
m.restarts = append(m.restarts, a[1]+": "+m.groups())
}
return "", nil
}
return m.groupDB.run(ctx, name, args...)
}
// groupOfPaths stands in for the machine's group database and the paths' groups for the length of a test.
func groupOfPaths(t *testing.T, groups map[string]PathGroup) {
t.Helper()
was := GroupOfPath
GroupOfPath = func(path string) (PathGroup, error) {
if _, err := os.Stat(path); err != nil {
return PathGroup{}, err
}
g, ok := groups[path]
if !ok {
return PathGroup{}, errors.New("no group in this test for " + path)
}
return g, nil
}
t.Cleanup(func() { GroupOfPath = was })
rootGroupsAre(t, map[int]string{0: "root", 10: "wheel", 968: "docker"})
}
// rootGroupsAre stands in for the numbers this machine gives the groups whose members act as root.
func rootGroupsAre(t *testing.T, ids map[int]string) {
t.Helper()
was := RootGroupIDs
RootGroupIDs = func() map[int]string { return ids }
t.Cleanup(func() { RootGroupIDs = was })
}
func accessGranting(id, path, account string, containers ...string) string {
grant := `"grant":{"account":"` + account + `"`
if len(containers) > 0 {
grant += `,"containers":["` + strings.Join(containers, `","`) + `"]`
}
return `{"id":"` + id + `","type":"access","path":"` + path + `","mode":"read-write",` + grant + `}}`
}
func mounting(id, path string) string {
return `{"id":"` + id + `","type":"container","name":"` + strings.ReplaceAll(id, ".", "-") + `","image":"` + pinned +
`","volumes":["` + path + `:/library"]}`
}
func applyGrants(t *testing.T, m *grantMachine, known store.State, resources ...string) (Report, store.State, error) {
t.Helper()
return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+
strings.Join(resources, ",")+`]}`), known, store.OriginDeclared, m.run, nil, nil)
}
func statOf(t *testing.T, path string) string {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
uid, gid, _ := OwnerOf(info)
return fmt.Sprintf("%s %d:%d %s", info.Mode(), uid, gid, info.ModTime())
}
func TestAReadWriteAccessGrantsItsPathsGroupToTheContainerAndTheAccountAndLeavesThePath(t *testing.T) {
library := t.TempDir()
if err := os.WriteFile(filepath.Join(library, "album"), []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
before, beforeInside := statOf(t, library), statOf(t, filepath.Join(library, "album"))
m := newGrantMachine("operator", []string{"operator"}, "media")
report, state, err := applyGrants(t, m, store.State{},
accessGranting("importer.music", library, "operator", "importer.app"), mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(" "+m.groups()+" ", " media ") {
t.Fatalf("the account was not put in the path's group: %q", m.groups())
}
if run := strings.Join(m.runs["importer-app"], " "); !strings.Contains(run, "--group-add 1001") {
t.Fatalf("the container was not created with the path's group: %s", run)
}
o := outcomeOf(report, "importer.music")
if o.Action != "updated" || !strings.Contains(o.Detail, "grants media (gid 1001)") ||
!strings.Contains(o.Detail, "put operator in media") {
t.Errorf("the access did not say what it granted: %+v", o)
}
if rec, _ := state.Find("importer.music"); rec.User != "operator" || strings.Join(rec.Groups, " ") != "media" {
t.Errorf("the access did not record the group it put the account in: %+v", rec)
}
// The path, and what is under it, exactly as they were (ADR 0312 §2).
if after := statOf(t, library); after != before {
t.Errorf("the accessed path changed: %s, was %s", after, before)
}
if after := statOf(t, filepath.Join(library, "album")); after != beforeInside {
t.Errorf("a file under the accessed path changed: %s, was %s", after, beforeInside)
}
for _, asked := range m.asked {
for _, verb := range []string{"chown", "chmod", "chgrp", "setfacl"} {
if strings.HasPrefix(asked, verb) || strings.Contains(asked, " "+verb+" ") {
t.Errorf("the engine asked the machine to change something: %q", asked)
}
}
}
// Applied again: nothing moves, and nothing is recreated.
m.runs = map[string][]string{}
report, _, err = applyGrants(t, m, state,
accessGranting("importer.music", library, "operator", "importer.app"), mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "importer.music"); o.Action != "unchanged" {
t.Errorf("a second apply moved the access: %+v", o)
}
if len(m.runs) != 0 {
t.Errorf("a second apply recreated the container: %v", m.runs)
}
}
func TestAReadAccessGrantsNothing(t *testing.T) {
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
// A grant on a read access is refused whole: the controller asked for something the decision forbids.
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"player.music","type":"access","path":"` +
library + `","mode":"read","grant":{"account":"operator"}}]}`))
if err == nil || !strings.Contains(err.Error(), "a read access grants nothing") {
t.Fatalf("a grant on a read access was not refused by name: %v", err)
}
m := newGrantMachine("operator", []string{"operator"}, "media")
report, state, err := applyGrants(t, m, store.State{},
`{"id":"player.music","type":"access","path":"`+library+`","mode":"read"}`, mounting("player.app", library))
if err != nil {
t.Fatal(err)
}
if strings.Contains(m.groups(), "media") {
t.Errorf("a read access put the account in a group: %q", m.groups())
}
if run := strings.Join(m.runs["player-app"], " "); strings.Contains(run, "--group-add") {
t.Errorf("a read access gave its container a group: %s", run)
}
if o := outcomeOf(report, "player.music"); o.Action != "unchanged" {
t.Errorf("a read access moved something: %+v", o)
}
if rec, _ := state.Find("player.music"); len(rec.Groups) != 0 || rec.User != "" {
t.Errorf("a read access recorded a grant: %+v", rec)
}
}
func TestAnAccessWhoseGroupCannotWriteGrantsNothingAndSaysTheOperatorDecides(t *testing.T) {
spool := t.TempDir()
groupOfPaths(t, map[string]PathGroup{spool: {GID: 1002, Name: "downloads", Writable: false}})
before := statOf(t, spool)
m := newGrantMachine("operator", nil, "downloads")
report, _, err := applyGrants(t, m, store.State{},
accessGranting("nzbget.downloads", spool, "operator", "nzbget.app"), mounting("nzbget.app", spool))
if err != nil {
t.Fatal(err)
}
o := outcomeOf(report, "nzbget.downloads")
if !strings.Contains(o.Detail, "cannot write it, so nothing is granted") || !strings.Contains(o.Detail, "the operator decides") ||
!strings.Contains(o.Detail, "nothing was changed") {
t.Errorf("the access did not say its group cannot write and that the operator decides: %+v", o)
}
// Nothing is given (ADR 0312 §2): no account group, no container group.
if strings.Contains(m.groups(), "downloads") {
t.Errorf("a group that cannot write was granted to the account: %q", m.groups())
}
if run := strings.Join(m.runs["nzbget-app"], " "); strings.Contains(run, "--group-add") {
t.Errorf("a group that cannot write was granted to the container: %s", run)
}
if after := statOf(t, spool); after != before {
t.Errorf("the engine changed a path whose group cannot write: %s, was %s", after, before)
}
// The words the look states it unhealthy with: the remedy is the operator's, and the path is never in them.
words := CannotWriteWords(PathGroup{GID: 1002, Name: "downloads"})
if !strings.Contains(words, "the operator's to decide") || strings.Contains(words, spool) {
t.Errorf("the unhealthy reason is %q", words)
}
}
func TestTheRootGroupIsNeverGranted(t *testing.T) {
for _, g := range []PathGroup{{GID: 0, Name: "root", Writable: true}, {GID: 968, Name: "docker", Writable: true},
{GID: 10, Name: "wheel", Writable: true}, {GID: 42, Name: "shadow", Writable: true},
// A number the root groups have here under another name — judged by the number, never the name.
{GID: 968, Name: "containers", Writable: true},
// A number the machine names nothing for: unjudged, so never granted, to a container either.
{GID: 4242, Name: "", Writable: true}} {
want := g.Name
if want == "" || want == "containers" {
want = fmt.Sprintf("gid %d", g.GID)
}
t.Run(fmt.Sprintf("%d %s", g.GID, g.Name), func(t *testing.T) {
shared := t.TempDir()
groupOfPaths(t, map[string]PathGroup{shared: g})
m := newGrantMachine("operator", nil, g.Name)
report, _, err := applyGrants(t, m, store.State{},
accessGranting("importer.shared", shared, "operator", "importer.app"), mounting("importer.app", shared))
if err == nil {
t.Fatal("a read-write access to a path of a root-granting group applied")
}
var failed *Error
if !errors.As(err, &failed) || failed.Resource != "importer.shared" ||
!strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "never") {
t.Errorf("the refusal does not name the access and the group: %v", err)
}
if g.Name != "" && strings.Contains(m.groups(), g.Name) {
t.Errorf("the account was put in %s", g.Name)
}
if run := strings.Join(m.runs["importer-app"], " "); strings.Contains(run, "--group-add") {
t.Errorf("the container was given a root-granting group: %s", run)
}
_ = report
})
}
}
func TestAGrantedGroupIsTakenBackUnlessStillAskedForOrHeldBefore(t *testing.T) {
music, movies := t.TempDir(), t.TempDir()
groupOfPaths(t, map[string]PathGroup{music: {GID: 1001, Name: "media", Writable: true},
movies: {GID: 1001, Name: "media", Writable: true}})
t.Run("no longer asked", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator"}, "media")
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"))
if err != nil {
t.Fatal(err)
}
report, state, err := applyGrants(t, m, state, mounting("other.app", t.TempDir()))
if err != nil {
t.Fatal(err)
}
if strings.Contains(m.groups(), "media") {
t.Errorf("the group the mesh granted was not taken back: %q", m.groups())
}
if o := outcomeOf(report, "importer.music"); o.Action != "restored" || !strings.Contains(o.Detail, "taken out of media") {
t.Errorf("the removal did not say it took the group back: %+v", o)
}
if _, still := state.Find("importer.music"); still {
t.Error("the access is still recorded")
}
})
t.Run("still asked by another access", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator"}, "media")
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"),
accessGranting("sonarr.movies", movies, "operator"))
if err != nil {
t.Fatal(err)
}
report, _, err := applyGrants(t, m, state, accessGranting("sonarr.movies", movies, "operator"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(m.groups(), "media") {
t.Errorf("a group another access still grants was taken back: %q", m.groups())
}
if o := outcomeOf(report, "importer.music"); !strings.Contains(o.Detail, "still asks for") {
t.Errorf("the removal did not say why the group stayed: %+v", o)
}
})
t.Run("still asked by a user resource", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator"}, "media")
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"))
if err != nil {
t.Fatal(err)
}
if _, _, err := applyGrants(t, m, state,
`{"id":"player.account","type":"user","name":"operator","groups":["media"]}`); err != nil {
t.Fatal(err)
}
if !strings.Contains(m.groups(), "media") {
t.Errorf("a group a user resource asks for was taken back: %q", m.groups())
}
})
t.Run("held before", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator", "media"})
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"))
if err != nil {
t.Fatal(err)
}
if rec, _ := state.Find("importer.music"); len(rec.Groups) != 0 {
t.Fatalf("a group the account held before was recorded as the mesh's: %+v", rec)
}
if _, _, err := applyGrants(t, m, state, mounting("other.app", t.TempDir())); err != nil {
t.Fatal(err)
}
if !strings.Contains(m.groups(), "media") {
t.Errorf("a group the account held before the mesh was taken back: %q", m.groups())
}
})
}
func TestAContainerIsRecreatedWithTheGroupItIsGranted(t *testing.T) {
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
m := newGrantMachine("operator", nil, "media")
// Running before its access granted anything: a controller older than the grant.
_, state, err := applyGrants(t, m, store.State{},
`{"id":"importer.music","type":"access","path":"`+library+`","mode":"read-write"}`, mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
if run := strings.Join(m.runs["importer-app"], " "); strings.Contains(run, "--group-add") {
t.Fatalf("a container granted nothing was given a group: %s", run)
}
report, _, err := applyGrants(t, m, state,
accessGranting("importer.music", library, "", "importer.app"), mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
o := outcomeOf(report, "importer.app")
if o.Action != "updated" || !strings.Contains(o.Detail, "recreated to hold the groups") {
t.Errorf("the container was not recreated for its group: %+v", o)
}
if run := strings.Join(m.runs["importer-app"], " "); !strings.Contains(run, "--group-add 1001") {
t.Errorf("the recreated container does not hold the group: %s", run)
}
}
func TestTheToolRunnerIsRestartedOnceItsAccountHoldsTheGroup(t *testing.T) {
me, err := user.Current()
if err != nil {
t.Skip("no current account to run the tool runner as")
}
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
m := newGrantMachine(me.Username, nil, "media")
body, digest := anArchive(t, map[string]string{"main.js": "console.log(1)\n"})
runner := `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"` + serving(t, body) +
`","digest":"` + digest + `","run":["node","main.js"],"user":"` + me.Username +
`","restart-on":["importer.music"]}`
// Running before the grant.
_, state, err := applyGrants(t, m, store.State{}, runner)
if err != nil {
t.Fatal(err)
}
m.restarts = nil
report, state, err := applyGrants(t, m, state, accessGranting("importer.music", library, me.Username), runner)
if err != nil {
t.Fatal(err)
}
if len(m.restarts) != 1 || !strings.HasPrefix(m.restarts[0], "node-tools.service: ") ||
!strings.Contains(m.restarts[0], "media") {
t.Fatalf("the tool runner was not restarted once its account held the group: %v", m.restarts)
}
if o := outcomeOf(report, "node-tools.runtime"); !strings.Contains(o.Detail, "importer.music") {
t.Errorf("the restart does not say what it was for: %+v", o)
}
// And not again while nothing moves.
m.restarts = nil
if _, _, err := applyGrants(t, m, state, accessGranting("importer.music", library, me.Username), runner); err != nil {
t.Fatal(err)
}
if len(m.restarts) != 0 {
t.Errorf("the tool runner was restarted with nothing moved: %v", m.restarts)
}
}
// sessionReader is an account whose own service manager runs, holding the groups it began with.
type sessionReader struct{ database, session []string }
func (s sessionReader) InDatabase(context.Context, string) ([]string, error) { return s.database, nil }
func (s sessionReader) Session(_ context.Context, _ string, groups []string) (accounts.Session, error) {
has := map[string]bool{}
for _, g := range s.session {
has[g] = true
}
return accounts.Session{Running: true, Has: has}, nil
}
func (s sessionReader) Escalation(context.Context, string, []string) ([]string, error) {
return nil, nil
}
func TestAnAccountsOwnSessionIsSaidReloginNeededForAGrantedGroup(t *testing.T) {
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
d := parse(t, `{"declaration":1,"resources":[`+accessGranting("importer.music", library, "operator")+`]}`)
as := GrantedAccounts(d, nil)
if len(as) != 1 || as[0].Module != "importer" || as[0].Name != "operator" || strings.Join(as[0].Groups, " ") != "media" {
t.Fatalf("the account the access grants is not judged: %+v", as)
}
j := accounts.New(sessionReader{database: []string{"operator", "media"}, session: []string{"operator"}})
j.Set(as)
st, _ := j.Look(context.Background())
if len(st.Accounts) != 1 || !strings.HasPrefix(st.Accounts[0].Reason, accounts.ReasonRelogin) {
t.Errorf("a session of the account's own manager that began before the grant is not said relogin needed: %+v",
st.Accounts)
}
// A read access, or one refused, asks nothing of the judge.
groupOfPaths(t, map[string]PathGroup{library: {GID: 0, Name: "root", Writable: true}})
if as := GrantedAccounts(d, nil); len(as) != 0 {
t.Errorf("a refused grant is judged: %+v", as)
}
}
// An access going while a user resource still asks for its group hands the group over: the user resource records it
// as the mesh's, and takes it back when it stops asking too (novox/hq ADR 0312 §5, ADR 0252 §3).
func TestAGroupStillAskedForIsHandedOverAndTakenBackWhenTheLastAskerGoes(t *testing.T) {
music := t.TempDir()
groupOfPaths(t, map[string]PathGroup{music: {GID: 1001, Name: "media", Writable: true}})
player := `{"id":"player.account","type":"user","name":"operator","groups":["media"]}`
m := newGrantMachine("operator", []string{"operator"}, "media")
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"), player)
if err != nil {
t.Fatal(err)
}
if rec, _ := state.Find("player.account"); len(rec.Groups) != 0 {
t.Fatalf("the user resource recorded a group the access put the account in: %+v", rec)
}
_, state, err = applyGrants(t, m, state, player)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(m.groups(), "media") {
t.Fatalf("a group the user resource still asks for was taken back: %q", m.groups())
}
if rec, _ := state.Find("player.account"); strings.Join(rec.Groups, " ") != "media" {
t.Fatalf("the group was not handed to the user resource that still asks for it: %+v", rec)
}
if _, _, err := applyGrants(t, m, state, mounting("other.app", t.TempDir())); err != nil {
t.Fatal(err)
}
if strings.Contains(m.groups(), "media") {
t.Errorf("the group stayed after the last resource asking for it went: %q", m.groups())
}
}
// A grant's account is an argument to id, usermod and gpasswd, and a name that starts with a dash would be read as
// an option by each.
func TestAGrantsAccountMayNotReadAsAnOption(t *testing.T) {
for _, name := range []string{"-ogroups", "--help", "a b", "a:b"} {
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"m.lib","type":"access","path":"/srv/lib",` +
`"mode":"read-write","grant":{"account":"` + name + `"}}]}`))
if err == nil || !strings.Contains(err.Error(), "not starting with a dash") {
t.Errorf("the account %q was accepted: %v", name, err)
}
}
if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"m.lib","type":"access","path":"/srv/lib",` +
`"mode":"read-write","grant":{"account":"jochen"}}]}`)); err != nil {
t.Errorf("an ordinary account was refused: %v", err)
}
}
// The scheduler reads no path issue 339's guard refuses, as the apply reads none.
func TestAScheduledStepIsArmedWithoutReadingARefusedPath(t *testing.T) {
var read []string
was := GroupOfPath
GroupOfPath = func(path string) (PathGroup, error) {
read = append(read, path)
return PathGroup{GID: 1001, Name: "media", Writable: true}, nil
}
t.Cleanup(func() { GroupOfPath = was })
rootGroupsAre(t, map[int]string{0: "root"})
d := parse(t, `{"declaration":1,"resources":[`+accessGranting("m.etc", "/etc", "", "m.step")+`,`+
`{"id":"m.step","type":"container","name":"step","image":"`+pinned+`","schedule":"0 3 * * *","volumes":["/etc:/x"]}]}`)
if got := ContainerGroupsOf(d); len(got["m.step"]) != 0 {
t.Errorf("a step was armed with the group of a refused path: %v", got)
}
if len(read) != 0 {
t.Errorf("the scheduler read a path the guard refuses: %v", read)
}
}
// The group database is read as name to number, so a number carrying a root group's name under any other name
// is still that number.
func TestGroupEntriesAreReadByNumber(t *testing.T) {
got := groupEntries("root:x:0:\n# a comment\ndocker:x:968:operator\ncontainers:x:968:\nbroken\n")
if got["root"] != 0 || got["docker"] != 968 || got["containers"] != 968 || len(got) != 3 {
t.Errorf("read %v", got)
}
}