Unify trunk on main: initialization → main #3
@@ -153,7 +153,12 @@ func run(ctx context.Context, command string, opts options) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the declaration: %w", err)
|
||||
}
|
||||
d, err := declaration.Parse(raw)
|
||||
// ParseTrusted: a file handed to the host by someone already running it as root is
|
||||
// not the link. novox/hq ADR 0047 bounds what a REMOTE party may push; someone who
|
||||
// can write this file and run this binary can do anything the binary can, so refusing
|
||||
// them an action would buy nothing and would make an action untestable except by
|
||||
// rebuilding the bundle.
|
||||
d, err := declaration.ParseTrusted(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+292
-14
@@ -12,11 +12,13 @@ package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -95,15 +97,16 @@ func Apply(
|
||||
}
|
||||
|
||||
for _, orphan := range known.Orphans(declared) {
|
||||
if err := remove(ctx, orphan, run); err != nil {
|
||||
action, detail, err := remove(ctx, orphan, run)
|
||||
if err != nil {
|
||||
return report, known, &Error{Resource: orphan.ID, Err: err, Done: report}
|
||||
}
|
||||
known.Forget(orphan.ID)
|
||||
report.Outcomes = append(report.Outcomes, Outcome{
|
||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, Action: "removed",
|
||||
Detail: "no longer declared",
|
||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
||||
Action: action, Detail: detail,
|
||||
})
|
||||
log(fmt.Sprintf(" removed %s (%s)", orphan.ID, orphan.Target))
|
||||
log(fmt.Sprintf(" %s %s (%s)", action, orphan.ID, orphan.Target))
|
||||
}
|
||||
|
||||
for _, resource := range d.Resources {
|
||||
@@ -133,6 +136,12 @@ func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome,
|
||||
return applyFile(r)
|
||||
case declaration.TypeService:
|
||||
return applyService(ctx, r, run)
|
||||
case declaration.TypePackage:
|
||||
return applyPackage(ctx, r, run)
|
||||
case declaration.TypeContainer:
|
||||
return applyContainer(ctx, r, run)
|
||||
case declaration.TypeAction:
|
||||
return applyAction(ctx, r, run)
|
||||
default:
|
||||
// Unreachable: the declaration refused this already. Present because "unreachable"
|
||||
// stops being true the moment someone adds a type and forgets this switch.
|
||||
@@ -390,20 +399,25 @@ func serviceState(ctx context.Context, unit string, run Runner) (string, error)
|
||||
}
|
||||
}
|
||||
|
||||
// remove undoes one resource the host applied and the declaration no longer names.
|
||||
// remove undoes one resource the host applied and the declaration no longer names, and reports
|
||||
// what it actually did.
|
||||
//
|
||||
// Only ever called for something in the store, which is what bounds it: the host is
|
||||
// authoritative over its own footprint and inert everywhere else (novox/hq ADR 0043).
|
||||
func remove(ctx context.Context, a store.Applied, run Runner) error {
|
||||
//
|
||||
// It returns the action rather than assuming "removed", because for half the vocabulary the
|
||||
// honest word is "forgotten". A host that reported a package removed when it left the package
|
||||
// installed would be describing an effect it declined to have.
|
||||
func remove(ctx context.Context, a store.Applied, run Runner) (string, string, error) {
|
||||
switch declaration.Type(a.Type) {
|
||||
case declaration.TypeFile, declaration.TypeDirectory:
|
||||
if err := os.RemoveAll(a.Target); err != nil {
|
||||
return err
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := os.Stat(a.Target); !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%s is still there after removing it", a.Target)
|
||||
return "", "", fmt.Errorf("%s is still there after removing it", a.Target)
|
||||
}
|
||||
return nil
|
||||
return "removed", "no longer declared", nil
|
||||
|
||||
case declaration.TypeService:
|
||||
// A unit that is no longer declared is stopped, not deleted. The host did not install
|
||||
@@ -416,17 +430,46 @@ func remove(ctx context.Context, a store.Applied, run Runner) error {
|
||||
// same reason `os.RemoveAll` is.
|
||||
if _, err := serviceState(ctx, a.Target, run); err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist on this machine") {
|
||||
return nil
|
||||
return "forgotten", "the unit no longer exists", nil
|
||||
}
|
||||
return err
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", "stop", a.Target); err != nil {
|
||||
return fmt.Errorf("stopping %s: %w", a.Target, err)
|
||||
return "", "", fmt.Errorf("stopping %s: %w", a.Target, err)
|
||||
}
|
||||
return nil
|
||||
return "removed", "stopped; the unit file is not the host's to delete", nil
|
||||
|
||||
case declaration.TypeContainer:
|
||||
// The host CREATED this one, so the host removes it. That is the line: it removes what
|
||||
// it made and leaves what it merely configured.
|
||||
if _, err := run(ctx, "docker", "rm", "-f", a.Target); err != nil {
|
||||
// Already gone is the state removal wants. Anything else is a real failure.
|
||||
if _, alive := containerState(ctx, a.Target, run); alive == nil {
|
||||
return "", "", fmt.Errorf("removing container %s: %w", a.Target, err)
|
||||
}
|
||||
}
|
||||
if _, err := containerState(ctx, a.Target, run); err == nil {
|
||||
return "", "", fmt.Errorf("container %s is still there after removing it", a.Target)
|
||||
}
|
||||
return "removed", "no longer declared", nil
|
||||
|
||||
case declaration.TypePackage:
|
||||
// Deliberately not uninstalled, and this is a decision rather than an omission.
|
||||
//
|
||||
// The host cannot know what else on this machine needs the package. Uninstalling a
|
||||
// container runtime because a declaration changed would stop every container on the
|
||||
// node, and the machine may have had the package before the mesh ever saw it
|
||||
// (novox/hq research 012: adopted, not installed). Undeclaring says "the mesh no
|
||||
// longer requires this", which is not the same as "remove it".
|
||||
return "forgotten", "left installed; the host does not uninstall what it cannot know is unused", nil
|
||||
|
||||
case declaration.TypeAction:
|
||||
// An action has no footprint the host can undo — it ran, and whatever it did belongs
|
||||
// to whatever it acted on.
|
||||
return "forgotten", "an action leaves nothing the host owns", nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("no way to remove a %q", a.Type)
|
||||
return "", "", fmt.Errorf("no way to remove a %q", a.Type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,3 +488,238 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// applyPackage installs a package the machine does not have.
|
||||
//
|
||||
// It never upgrades and never removes. "Present" is the whole of what a package resource
|
||||
// asserts, because version is the package manager's business and the mesh does not have a
|
||||
// second opinion about it (novox/hq ADR 0041 — the host depends on nothing, and that includes
|
||||
// not becoming a second package manager).
|
||||
func applyPackage(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Package}
|
||||
|
||||
installed, err := packageInstalled(ctx, r.Package, run)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if installed {
|
||||
out.Action = "unchanged"
|
||||
out.Detail = "already installed"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if _, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", r.Package); err != nil {
|
||||
return out, fmt.Errorf("installing %s: %w", r.Package, err)
|
||||
}
|
||||
|
||||
// Read back. A package manager exiting zero says the transaction was accepted.
|
||||
installed, err = packageInstalled(ctx, r.Package, run)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if !installed {
|
||||
return out, fmt.Errorf(
|
||||
"%s was installed without error and the package database does not have it", r.Package)
|
||||
}
|
||||
|
||||
out.Action = "created"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// packageInstalled asks the package database, having first established that it answers.
|
||||
//
|
||||
// The two-step is the same trap `serviceState` documents. `pacman -Q name` exits non-zero for
|
||||
// a package that is not installed AND for a package database that cannot be read, so believing
|
||||
// the first answer would report a broken package manager as "nothing is installed" — absence
|
||||
// read as fact. Proving the tool answers about something that certainly exists separates them.
|
||||
func packageInstalled(ctx context.Context, name string, run Runner) (bool, error) {
|
||||
if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"the package database does not answer on this machine, so nothing can be said "+
|
||||
"about %q: %w", name, err)
|
||||
}
|
||||
if _, err := run(ctx, "pacman", "-Q", name); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Labels the host puts on every container it creates.
|
||||
//
|
||||
// specLabel carries a digest of the declaration that made the container. It is what lets a
|
||||
// reconcile answer "is this container the one the current declaration describes" without
|
||||
// comparing every field the runtime reports — which cannot be done reliably, because a runtime
|
||||
// normalises, defaults and reorders what it is given, and the differences that produces are
|
||||
// indistinguishable from real drift.
|
||||
const (
|
||||
specLabel = "mesh-host.spec"
|
||||
idLabel = "mesh-host.id"
|
||||
)
|
||||
|
||||
// containerSpec is the identity of a declared container: everything that, if changed, means
|
||||
// the running container is no longer what was asked for.
|
||||
func containerSpec(r declaration.Resource) string {
|
||||
keys := make([]string, 0, len(r.Env))
|
||||
for k := range r.Env {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(r.Image + "\n" + r.Name + "\n")
|
||||
for _, k := range keys {
|
||||
b.WriteString("env " + k + "=" + r.Env[k] + "\n")
|
||||
}
|
||||
for _, p := range r.Ports {
|
||||
b.WriteString("port " + p + "\n")
|
||||
}
|
||||
for _, v := range r.Volumes {
|
||||
b.WriteString("volume " + v + "\n")
|
||||
}
|
||||
for _, a := range r.Args {
|
||||
b.WriteString("arg " + a + "\n")
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(b.String())))
|
||||
}
|
||||
|
||||
// containerState reports whether a container is running and which spec made it.
|
||||
// The error means the container does not exist.
|
||||
func containerState(ctx context.Context, name string, run Runner) (state struct {
|
||||
Running bool
|
||||
Spec string
|
||||
}, err error) {
|
||||
out, err := run(ctx, "docker", "inspect", "--format",
|
||||
"{{.State.Running}}\t{{index .Config.Labels \""+specLabel+"\"}}", name)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("no container named %s", name)
|
||||
}
|
||||
running, spec, _ := strings.Cut(strings.TrimSpace(out), "\t")
|
||||
state.Running = running == "true"
|
||||
state.Spec = strings.TrimSpace(spec)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// applyContainer makes the declared container the one that is running.
|
||||
//
|
||||
// There is no "update" for a container: a container's configuration is fixed when it is
|
||||
// created, so any change is a replacement. Saying that plainly is better than a partial
|
||||
// in-place update that leaves the running thing half-declared.
|
||||
func applyContainer(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Name}
|
||||
want := containerSpec(r)
|
||||
|
||||
if _, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err != nil {
|
||||
return out, fmt.Errorf(
|
||||
"the container runtime does not answer on this machine, so nothing can be said "+
|
||||
"about %q: %w", r.Name, err)
|
||||
}
|
||||
|
||||
before, err := containerState(ctx, r.Name, run)
|
||||
existed := err == nil
|
||||
|
||||
switch {
|
||||
case existed && before.Spec == want && before.Running:
|
||||
out.Action = "unchanged"
|
||||
return out, nil
|
||||
case existed:
|
||||
if _, err := run(ctx, "docker", "rm", "-f", r.Name); err != nil {
|
||||
return out, fmt.Errorf("replacing container %s: %w", r.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"run", "--detach", "--name", r.Name, "--restart", "unless-stopped",
|
||||
"--label", specLabel + "=" + want, "--label", idLabel + "=" + r.ID}
|
||||
for _, k := range sortedKeys(r.Env) {
|
||||
args = append(args, "--env", k+"="+r.Env[k])
|
||||
}
|
||||
for _, p := range r.Ports {
|
||||
args = append(args, "--publish", p)
|
||||
}
|
||||
for _, v := range r.Volumes {
|
||||
args = append(args, "--volume", v)
|
||||
}
|
||||
args = append(args, r.Image)
|
||||
args = append(args, r.Args...)
|
||||
|
||||
if _, err := run(ctx, "docker", args...); err != nil {
|
||||
return out, fmt.Errorf("starting container %s: %w", r.Name, err)
|
||||
}
|
||||
|
||||
// Read back. `docker run --detach` returning an id says the container was created, not
|
||||
// that it is still running — a container whose entrypoint exits immediately satisfies the
|
||||
// command exactly as one that came up does.
|
||||
after, err := containerState(ctx, r.Name, run)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("started container %s and it is not there: %w", r.Name, err)
|
||||
}
|
||||
if !after.Running {
|
||||
return out, fmt.Errorf(
|
||||
"container %s was started and is not running. It exited; ask the runtime for its "+
|
||||
"logs", r.Name)
|
||||
}
|
||||
if after.Spec != want {
|
||||
return out, fmt.Errorf("container %s is not the one that was declared after creating it", r.Name)
|
||||
}
|
||||
|
||||
out.Action = "created"
|
||||
if existed {
|
||||
out.Action = "updated"
|
||||
out.Detail = "replaced; a container's configuration is fixed when it is created"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// applyAction runs something the bundle declared, and never learns what it means.
|
||||
//
|
||||
// Verify does double duty, and that is the design rather than a convenience: it is both the
|
||||
// idempotency check and the read-back. Running it first is how the host knows whether there is
|
||||
// anything to do — it does not know what a database is, so "is the database there" is a
|
||||
// question only the declaration can ask. Running it again afterwards is how the host knows the
|
||||
// command had the effect it claimed (novox/hq ADR 0047).
|
||||
func applyAction(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
|
||||
out := Outcome{ID: r.ID, Type: string(r.Type), Target: strings.Join(r.Command, " ")}
|
||||
if r.In != "" {
|
||||
out.Target = "in " + r.In + ": " + out.Target
|
||||
}
|
||||
|
||||
if _, err := runAction(ctx, r, r.Verify, run); err == nil {
|
||||
out.Action = "unchanged"
|
||||
out.Detail = "already true"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if _, err := runAction(ctx, r, r.Command, run); err != nil {
|
||||
return out, fmt.Errorf("running the action: %w", err)
|
||||
}
|
||||
|
||||
if _, err := runAction(ctx, r, r.Verify, run); err != nil {
|
||||
return out, fmt.Errorf(
|
||||
"the action ran without error and its own verify still fails: %w\n\n"+
|
||||
"The command reported success and the thing it was for did not happen, which "+
|
||||
"is exactly what verify exists to catch", err)
|
||||
}
|
||||
|
||||
out.Action = "created"
|
||||
out.Detail = "verify was false and is now true"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runAction runs one of an action's command lines, on the machine or inside a container.
|
||||
func runAction(ctx context.Context, r declaration.Resource, argv []string, run Runner) (string, error) {
|
||||
if len(argv) == 0 {
|
||||
return "", errors.New("no command")
|
||||
}
|
||||
if r.In != "" {
|
||||
return run(ctx, "docker", append([]string{"exec", r.In}, argv...)...)
|
||||
}
|
||||
return run(ctx, argv[0], argv[1:]...)
|
||||
}
|
||||
|
||||
@@ -407,7 +407,281 @@ func TestForgettingAUnitThatIsGoneDoesNotStrandTheNode(t *testing.T) {
|
||||
if _, still := state.Find("gone"); still {
|
||||
t.Error("the host still believes it owns a unit that is gone")
|
||||
}
|
||||
if report.Outcomes[0].Action != "removed" {
|
||||
t.Errorf("the vanished unit was not reported as removed: %+v", report.Outcomes)
|
||||
// "forgotten", not "removed": the host stopped believing it owns the unit, and did not
|
||||
// remove anything, because there was nothing there to remove. Reporting an effect it did
|
||||
// not have would be the same class of untruth as reporting a package uninstalled.
|
||||
if report.Outcomes[0].Action != "forgotten" {
|
||||
t.Errorf("the vanished unit was not reported as forgotten: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
// --- package, container and action (novox/hq 07-the-substrate.md, ADR 0046, ADR 0047) ---
|
||||
|
||||
func parseTrusted(t *testing.T, raw string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
d, err := declaration.ParseTrusted([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("fixture is not a valid declaration: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const pinned = "docker.io/library/postgres@sha256:" +
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func TestABrokenPackageDatabaseIsNotReadAsNotInstalled(t *testing.T) {
|
||||
// The same trap serviceState documents. `pacman -Q x` exits non-zero both for a package
|
||||
// that is not installed and for a database that cannot be read — so believing the first
|
||||
// answer would silently reinstall on a machine whose package manager is broken, or report
|
||||
// "installed nothing" as success. The apply must fail instead.
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
return "", errors.New("pacman: error: could not lock database")
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"rt","type":"package","package":"docker"}
|
||||
]}`)
|
||||
|
||||
_, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a broken package database was read as 'not installed'")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not answer") {
|
||||
t.Errorf("failed for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInstalledPackageIsNotReinstalled(t *testing.T) {
|
||||
var installed bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if args[0] == "-S" {
|
||||
installed = true
|
||||
}
|
||||
return "docker 27.0-1\n", nil // -Q succeeds for everything
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"rt","type":"package","package":"docker"}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
if installed {
|
||||
t.Error("a package that was already present was installed again")
|
||||
}
|
||||
if report.Changed() {
|
||||
t.Errorf("an already-installed package reported a change: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageIsNeverUninstalled(t *testing.T) {
|
||||
// Deliberate: the host cannot know what else needs the package. Uninstalling a container
|
||||
// runtime because a declaration changed would stop every container on the node, and the
|
||||
// machine may have had it before the mesh ever saw it. Undeclaring is not "remove it".
|
||||
var uninstalled bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if len(args) > 0 && (args[0] == "-R" || args[0] == "-Rs") {
|
||||
uninstalled = true
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
known := store.State{Resources: []store.Applied{
|
||||
{ID: "rt", Type: "package", Target: "docker"},
|
||||
}}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
|
||||
report, state, err := Apply(context.Background(), d, known, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dropping a package stranded the apply: %v", err)
|
||||
}
|
||||
if uninstalled {
|
||||
t.Fatal("the host uninstalled a package")
|
||||
}
|
||||
if _, still := state.Find("rt"); still {
|
||||
t.Error("the host still believes it owns the package")
|
||||
}
|
||||
// "forgotten", not "removed" — the host must not claim an effect it declined to have.
|
||||
if report.Outcomes[0].Action != "forgotten" {
|
||||
t.Errorf("dropping a package was not reported as forgotten: %+v", report.Outcomes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActionThatIsAlreadyTrueDoesNotRun(t *testing.T) {
|
||||
// Verify is the idempotency check as well as the read-back. The host does not know what a
|
||||
// database is, so "is it already there" is a question only the declaration can ask.
|
||||
var ran bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "create-db" {
|
||||
ran = true
|
||||
}
|
||||
return "", nil // verify passes
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
if ran {
|
||||
t.Error("an action whose verify already passed was run anyway")
|
||||
}
|
||||
if report.Changed() {
|
||||
t.Errorf("an already-satisfied action reported a change: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActionThatSucceedsAndDoesNothingFails(t *testing.T) {
|
||||
// The whole reason verify is mandatory: a command that exits zero and has no effect is
|
||||
// this repository's most expensive failure shape. Here the command "succeeds" every time
|
||||
// and verify never passes.
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "has-db" {
|
||||
return "", errors.New("no such database")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"db","type":"action","command":["create-db","mesh"],"verify":["has-db","mesh"]}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("an action that reported success and did nothing was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "verify still fails") {
|
||||
t.Errorf("failed for the wrong reason: %v", err)
|
||||
}
|
||||
if _, recorded := state.Find("db"); recorded {
|
||||
t.Error("an action that did not work was recorded as applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActionRunsInsideTheContainerItNames(t *testing.T) {
|
||||
// Steps 2 and 3 of the bootstrap act on something inside the store's container, before
|
||||
// there is any mesh to ask.
|
||||
var sawExec bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "docker" && args[0] == "exec" && args[1] == "store" {
|
||||
sawExec = true
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.New("not run in the container")
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"db","type":"action","in":"store","command":["createdb","mesh"],"verify":["psql","-lqt"]}
|
||||
]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), d, store.State{}, run, nil); err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
if !sawExec {
|
||||
t.Error("an action naming a container did not run inside it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) {
|
||||
// `docker run --detach` returning an id says the container was created, not that it is
|
||||
// still running. A container whose entrypoint dies satisfies the command exactly as one
|
||||
// that came up does — which is the read-back rule, in the place it matters most.
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch {
|
||||
case args[0] == "version":
|
||||
return "27.0\n", nil
|
||||
case args[0] == "inspect":
|
||||
return "false\t" + "", nil // exists, not running
|
||||
case args[0] == "run":
|
||||
return "deadbeef\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`"}
|
||||
]}`)
|
||||
|
||||
_, state, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a container that exited immediately was reported as applied")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not running") {
|
||||
t.Errorf("failed for the wrong reason: %v", err)
|
||||
}
|
||||
if _, recorded := state.Find("store"); recorded {
|
||||
t.Error("a container that is not running was recorded as applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
|
||||
// A container's configuration is fixed when it is created, so any change is a replacement.
|
||||
// The spec label is what makes the difference visible without diffing everything the
|
||||
// runtime reports — which cannot be done reliably, because a runtime normalises what it is
|
||||
// given and that is indistinguishable from drift.
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0])
|
||||
|
||||
var removed, created bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "version":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
if created {
|
||||
return "true\t" + want, nil
|
||||
}
|
||||
return "true\tsome-older-spec", nil
|
||||
case "rm":
|
||||
removed = true
|
||||
return "", nil
|
||||
case "run":
|
||||
created = true
|
||||
return "deadbeef\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
if !removed || !created {
|
||||
t.Fatalf("a changed container was not replaced (removed=%v created=%v)", removed, created)
|
||||
}
|
||||
if report.Outcomes[0].Action != "updated" {
|
||||
t.Errorf("a replacement was not reported as an update: %+v", report.Outcomes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
spec := containerSpec(d.Resources[0])
|
||||
|
||||
var touched bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "version":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
return "true\t" + spec, nil
|
||||
}
|
||||
touched = true
|
||||
return "", nil
|
||||
}
|
||||
|
||||
report, _, err := Apply(context.Background(), d, store.State{}, run, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
if touched {
|
||||
t.Error("a container that already matched was restarted")
|
||||
}
|
||||
if report.Changed() {
|
||||
t.Errorf("a matching container reported a change: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ func Load() (*declaration.Declaration, error) {
|
||||
if IsEmpty() {
|
||||
return nil, ErrEmpty
|
||||
}
|
||||
return declaration.Parse(stripComments(substrate))
|
||||
// ParseTrusted: the bundle arrives with the binary, so it may carry actions the link may
|
||||
// not (novox/hq ADR 0047). The bootstrap needs them — creating the control plane's database
|
||||
// happens before there is any mesh to ask for one.
|
||||
return declaration.ParseTrusted(stripComments(substrate))
|
||||
}
|
||||
|
||||
// stripComments removes whole-line `//` comments so a bundle can be annotated.
|
||||
|
||||
@@ -26,13 +26,25 @@ const (
|
||||
TypeDirectory Type = "directory"
|
||||
TypeFile Type = "file"
|
||||
TypeService Type = "service"
|
||||
TypePackage Type = "package"
|
||||
TypeContainer Type = "container"
|
||||
TypeAction Type = "action"
|
||||
)
|
||||
|
||||
// known is the whole vocabulary. Anything else is refused.
|
||||
var known = map[Type]bool{
|
||||
TypeDirectory: true,
|
||||
TypeFile: true,
|
||||
TypeService: true,
|
||||
// uses names the fields each type consumes. A field set on a type that is not listed here as
|
||||
// using it is refused.
|
||||
//
|
||||
// Stated as what each type USES rather than as what it ignores. The negative form needs every
|
||||
// type revisited whenever a field is added, and the one nobody revisits is the one that
|
||||
// silently accepts a field it will never read — which is the whole fault this package exists
|
||||
// to prevent.
|
||||
var uses = map[Type]map[string]bool{
|
||||
TypeDirectory: {"path": true, "mode": true},
|
||||
TypeFile: {"path": true, "content": true, "mode": true},
|
||||
TypeService: {"unit": true, "state": true},
|
||||
TypePackage: {"package": true},
|
||||
TypeContainer: {"image": true, "name": true, "env": true, "ports": true, "volumes": true, "args": true},
|
||||
TypeAction: {"command": true, "verify": true, "in": true},
|
||||
}
|
||||
|
||||
// Resource is one thing that should be true of the machine.
|
||||
@@ -54,6 +66,30 @@ type Resource struct {
|
||||
// Unit and State, for a service. State is "running" or "stopped".
|
||||
Unit string `json:"unit,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
|
||||
// Package, for a package: the name this machine's own package manager knows it by.
|
||||
Package string `json:"package,omitempty"`
|
||||
|
||||
// Image and Name, for a container. Image is pinned by digest (novox/hq ADR 0046) — a tag
|
||||
// moves and a digest does not, and a bundle that pinned a tag would not be pinned.
|
||||
Image string `json:"image,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// Env, Ports, Volumes and Args, for a container. Literal; the host renders nothing.
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Volumes []string `json:"volumes,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
|
||||
// Command, Verify and In, for an action.
|
||||
//
|
||||
// Verify is not optional and is not a courtesy. An action that runs and reports success
|
||||
// without reading anything back is the fault this repository exists to name, and an action
|
||||
// is the easiest place in the vocabulary to reintroduce it (novox/hq ADR 0047).
|
||||
Command []string `json:"command,omitempty"`
|
||||
Verify []string `json:"verify,omitempty"`
|
||||
// In names a container to run the action inside, when the thing being acted on lives
|
||||
// there. Empty means the machine itself.
|
||||
In string `json:"in,omitempty"`
|
||||
}
|
||||
|
||||
// Declaration is what a machine should be, in the order it should be made so.
|
||||
@@ -85,8 +121,20 @@ func (e *RefusalError) Error() string {
|
||||
strings.Join(e.Problems, "\n - "))
|
||||
}
|
||||
|
||||
// Parse reads a declaration and refuses anything it does not fully understand.
|
||||
func Parse(raw []byte) (*Declaration, error) {
|
||||
// Parse reads a declaration that arrived over the link, and refuses anything it does not fully
|
||||
// understand — including any action, which the link may not carry (novox/hq ADR 0047).
|
||||
func Parse(raw []byte) (*Declaration, error) { return parse(raw, false) }
|
||||
|
||||
// ParseTrusted reads a declaration from a source already as privileged as the host itself: the
|
||||
// bundle it carries, or a file handed to it by someone who is running it as root.
|
||||
//
|
||||
// Actions are permitted here and nowhere else. The asymmetry is deliberate and is the entire
|
||||
// content of ADR 0047: refusing actions from the bundle buys nothing, because whoever built the
|
||||
// bundle built the binary; refusing them from the link buys the bound on what a compromised
|
||||
// control plane can express.
|
||||
func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) }
|
||||
|
||||
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
|
||||
// field the host does not know is a thing the control plane believes it asked for.
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
@@ -97,13 +145,13 @@ func Parse(raw []byte) (*Declaration, error) {
|
||||
return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}}
|
||||
}
|
||||
|
||||
if problems := validate(&d); len(problems) > 0 {
|
||||
if problems := validate(&d, allowActions); len(problems) > 0 {
|
||||
return nil, &RefusalError{Problems: problems}
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func validate(d *Declaration) []string {
|
||||
func validate(d *Declaration, allowActions bool) []string {
|
||||
var problems []string
|
||||
|
||||
if d.Version != Version {
|
||||
@@ -138,32 +186,31 @@ func validate(d *Declaration) []string {
|
||||
seen[r.ID] = i
|
||||
}
|
||||
|
||||
if !known[r.Type] {
|
||||
if _, ok := uses[r.Type]; !ok {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: unknown type %q. This host understands %s", where, r.Type, vocabulary()))
|
||||
continue
|
||||
}
|
||||
problems = append(problems, validateResource(where, r)...)
|
||||
problems = append(problems, validateResource(where, r, allowActions)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func validateResource(where string, r Resource) []string {
|
||||
var problems []string
|
||||
func validateResource(where string, r Resource, allowActions bool) []string {
|
||||
problems := unusedBy(where, r)
|
||||
|
||||
switch r.Type {
|
||||
case TypeDirectory:
|
||||
if r.Path == "" {
|
||||
problems = append(problems, where+": a directory needs a path")
|
||||
}
|
||||
problems = append(problems, checkMode(where, r.Mode)...)
|
||||
problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State, "content", r.Content)...)
|
||||
|
||||
case TypeFile:
|
||||
if r.Path == "" {
|
||||
problems = append(problems, where+": a file needs a path")
|
||||
}
|
||||
problems = append(problems, checkMode(where, r.Mode)...)
|
||||
problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State)...)
|
||||
|
||||
case TypeService:
|
||||
if r.Unit == "" {
|
||||
@@ -173,22 +220,101 @@ func validateResource(where string, r Resource) []string {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: state %q; a service is \"running\" or \"stopped\"", where, r.State))
|
||||
}
|
||||
problems = append(problems, unusedBy(where, r, "path", r.Path, "content", r.Content, "mode", r.Mode)...)
|
||||
|
||||
case TypePackage:
|
||||
if r.Package == "" {
|
||||
problems = append(problems, where+": a package needs a package name")
|
||||
}
|
||||
|
||||
case TypeContainer:
|
||||
if r.Name == "" {
|
||||
problems = append(problems, where+": a container needs a name")
|
||||
}
|
||||
problems = append(problems, checkImage(where, r.Image)...)
|
||||
|
||||
case TypeAction:
|
||||
// The whole reason an action is bounded rather than forbidden (novox/hq ADR 0047).
|
||||
if !allowActions {
|
||||
problems = append(problems, where+
|
||||
": an action arrived over the link, and the link may not carry one. The host "+
|
||||
"applies declarations of known shape; a command to run is not one. A bundle "+
|
||||
"may carry an action because it arrives with the binary — anyone able to put "+
|
||||
"a hostile action there could have put it in the host itself")
|
||||
break
|
||||
}
|
||||
if len(r.Command) == 0 {
|
||||
problems = append(problems, where+": an action needs a command")
|
||||
}
|
||||
if len(r.Verify) == 0 {
|
||||
problems = append(problems, where+
|
||||
": an action needs a verify. An action that runs and reports success without "+
|
||||
"reading anything back is the fault this host exists to prevent, and verify is "+
|
||||
"also how the host knows whether the action is already done")
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// checkImage insists on a digest.
|
||||
//
|
||||
// A tag moves and a digest does not. The bundle's whole claim is that what it names is exact
|
||||
// (novox/hq ADR 0046), and a bundle pinning `postgres:17` pins nothing — it names whatever
|
||||
// that tag points at on the day the host happens to run.
|
||||
func checkImage(where, image string) []string {
|
||||
if image == "" {
|
||||
return []string{where + ": a container needs an image"}
|
||||
}
|
||||
name, digest, found := strings.Cut(image, "@")
|
||||
if !found || name == "" {
|
||||
return []string{fmt.Sprintf(
|
||||
"%s: image %q is not pinned. Write it as name@sha256:... — a tag moves, and a "+
|
||||
"bundle that pinned a tag would not be pinned", where, image)}
|
||||
}
|
||||
if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 {
|
||||
return []string{fmt.Sprintf(
|
||||
"%s: image digest %q is not a sha256 digest", where, digest)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setFields names every field carried on this resource, other than its identity and type.
|
||||
func setFields(r Resource) []string {
|
||||
var set []string
|
||||
add := func(name string, populated bool) {
|
||||
if populated {
|
||||
set = append(set, name)
|
||||
}
|
||||
}
|
||||
add("path", r.Path != "")
|
||||
add("content", r.Content != "")
|
||||
add("mode", r.Mode != "")
|
||||
add("unit", r.Unit != "")
|
||||
add("state", r.State != "")
|
||||
add("package", r.Package != "")
|
||||
add("image", r.Image != "")
|
||||
add("name", r.Name != "")
|
||||
add("env", len(r.Env) > 0)
|
||||
add("ports", len(r.Ports) > 0)
|
||||
add("volumes", len(r.Volumes) > 0)
|
||||
add("args", len(r.Args) > 0)
|
||||
add("command", len(r.Command) > 0)
|
||||
add("verify", len(r.Verify) > 0)
|
||||
add("in", r.In != "")
|
||||
sort.Strings(set)
|
||||
return set
|
||||
}
|
||||
|
||||
// unusedBy refuses a field this type does not use.
|
||||
//
|
||||
// A field set and ignored is the fault this package exists to prevent, in miniature: the
|
||||
// control plane believes it asked for something the host will never do.
|
||||
func unusedBy(where string, r Resource, pairs ...string) []string {
|
||||
func unusedBy(where string, r Resource) []string {
|
||||
var problems []string
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
if pairs[i+1] != "" {
|
||||
for _, name := range setFields(r) {
|
||||
if !uses[r.Type][name] {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: a %s does not use %q, and it is set. Refused rather than ignored",
|
||||
where, r.Type, pairs[i]))
|
||||
where, r.Type, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
@@ -213,7 +339,7 @@ func checkMode(where, mode string) []string {
|
||||
|
||||
func vocabulary() string {
|
||||
var names []string
|
||||
for t := range known {
|
||||
for t := range uses {
|
||||
names = append(names, string(t))
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
@@ -156,3 +156,103 @@ func TestARefusalSaysNothingWasApplied(t *testing.T) {
|
||||
func TestAnEmptyDeclarationIsAMistake(t *testing.T) {
|
||||
refusalFor(t, `{"declaration":1,"resources":[]}`)
|
||||
}
|
||||
|
||||
// --- the vocabulary the substrate bootstrap needs (novox/hq 07-the-substrate.md) ---
|
||||
|
||||
func TestAnActionOverTheLinkIsRefused(t *testing.T) {
|
||||
// novox/hq ADR 0047. The link may push declarations of known shape and never a command to
|
||||
// run. This is the boundary the whole security argument rests on, so it is asserted
|
||||
// directly rather than inferred from the type list.
|
||||
raw := []byte(`{"declaration":1,"resources":[
|
||||
{"id":"schema","type":"action","command":["psql","-f","x.sql"],"verify":["psql","-c","select 1"]}
|
||||
]}`)
|
||||
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("an action arriving over the link was accepted")
|
||||
} else if !strings.Contains(err.Error(), "the link may not carry one") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
|
||||
// And the same bytes from the bundle are fine — the asymmetry IS the decision.
|
||||
if _, err := ParseTrusted(raw); err != nil {
|
||||
t.Errorf("the bundle may carry an action, and this one was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
|
||||
// An action that runs and reports success without reading anything back is the fault this
|
||||
// host exists to prevent. Verify is also the idempotency check, so an action without one
|
||||
// cannot be applied twice safely either.
|
||||
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"schema","type":"action","command":["psql","-f","x.sql"]}
|
||||
]}`))
|
||||
if err == nil {
|
||||
t.Fatal("an action with no verify was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "needs a verify") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageMustBePinnedByDigest(t *testing.T) {
|
||||
// novox/hq ADR 0046: reproducibility comes from pinning the identity of a thing. A bundle
|
||||
// naming a tag pins nothing — it names whatever that tag points at on the day it runs.
|
||||
for _, image := range []string{
|
||||
"postgres:17",
|
||||
"postgres",
|
||||
"postgres@sha256:short",
|
||||
"@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
} {
|
||||
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"` + image + `"}
|
||||
]}`))
|
||||
if err == nil {
|
||||
t.Errorf("image %q was accepted and is not pinned", image)
|
||||
}
|
||||
}
|
||||
|
||||
good := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"` + good + `"}
|
||||
]}`)); err != nil {
|
||||
t.Errorf("a properly pinned image was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
||||
// The field-set check must cover the types added last, not only the three it was written
|
||||
// for. A package that carries a `content` is a control plane believing it asked for
|
||||
// something that will never happen.
|
||||
for _, body := range []string{
|
||||
`{"id":"p","type":"package","package":"docker","content":"x"}`,
|
||||
`{"id":"p","type":"package","package":"docker","image":"x"}`,
|
||||
`{"id":"c","type":"container","name":"n","image":"i@sha256:` + strings.Repeat("a", 64) + `","unit":"x.service"}`,
|
||||
`{"id":"a","type":"action","command":["x"],"verify":["y"],"path":"/tmp/x"}`,
|
||||
} {
|
||||
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[` + body + `]}`))
|
||||
if err == nil {
|
||||
t.Errorf("a resource carrying a field its type does not use was accepted: %s", body)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Refused rather than ignored") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) {
|
||||
// novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them.
|
||||
// Asserted so that removing one is a failing test rather than a discovery during a
|
||||
// first-node install.
|
||||
for _, want := range []Type{
|
||||
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
||||
} {
|
||||
if _, ok := uses[want]; !ok {
|
||||
t.Errorf("the host no longer speaks %q", want)
|
||||
}
|
||||
}
|
||||
if len(uses) != 6 {
|
||||
t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+
|
||||
"control plane can express, so a change here is a decision: %s", len(uses), vocabulary())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user