Complete the host's vocabulary: package, container, action
The three shapes the substrate bootstrap needs and the host did not have. Until now tier 1 could not be raised at all -- step 0 is a package, step 1 a container, steps 2 and 3 actions -- so every line of the tier 1 and 2 designs was unbuildable. package -- present, never upgraded, never uninstalled. Removal is "forgotten", not "removed": 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 saw it. Reporting it removed would claim an effect the host declined to have. container -- identified by a label carrying a digest of the declaration that made it. Comparing every field the runtime reports cannot be done reliably: a runtime normalises, defaults and reorders what it is given, and that is indistinguishable from real drift. There is no in-place update; a container's configuration is fixed at creation, so any change is a replacement, and saying so beats a partial update that leaves the running thing half-declared. This is the one shape the host removes, because it is the one the host created. action -- bundle-only, per ADR 0047. Verify is mandatory and does double duty: it 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. `in` runs the action inside a named container, which steps 2 and 3 need. Parse now refuses actions; ParseTrusted permits them. The safe path is the default and the permissive one has to be named. The bundle and a local file handed to a root process use ParseTrusted; the link will use Parse. Also replaced the per-type "fields this type ignores" check with a field-set diff stated as what each type USES. The negative form needs every type revisited whenever a field is added, and the one nobody revisits silently accepts a field it will never read. Images must be pinned by digest (ADR 0046). A bundle naming a tag pins nothing. Verified against a real machine, not only fakes: an action ran and was idempotent on the second apply; an action that exits zero and satisfies nothing fails the apply; a real container was created, labelled, replaced when its declaration changed, exec'd into, and removed; a real package query round- tripped. Each new test was also confirmed to fail on an injected fault -- five injections, each breaking exactly its own test. One existing test changed: a vanished unit is now reported "forgotten" rather than "removed", which is what actually happened.
This commit is contained in:
+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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user