diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 5936b66..2dba033 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -247,6 +247,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return applyArchive(ctx, res, previous) case *declaration.Action: return applyAction(ctx, res, run) + case *declaration.Network: + return applyNetwork(ctx, res, run) default: // Unreachable: the declaration refused this already. Present because "unreachable" // stops being true the moment someone adds a kind and forgets this switch. @@ -657,6 +659,24 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner) // to whatever it acted on. return "forgotten", "an action leaves nothing the host owns", nil + case declaration.TypeNetwork: + // **The reason this is a shape at all** (novox/hq ADR 0029). Orphans are removed in + // reverse declaration order, so a network written before the containers that join it is + // removed after they are gone — and a runtime refusing to remove one still in use is + // reported rather than swallowed, because that means something the mesh did not declare + // is holding it. + cri, err := containerRuntime(ctx, run) + if err != nil { + return "", "", fmt.Errorf("%w, so the network %q cannot be removed", err, a.Target) + } + if _, err := run(ctx, cri, "network", "inspect", a.Target); err != nil { + return "forgotten", "no longer there", nil + } + if _, err := run(ctx, cri, "network", "rm", a.Target); err != nil { + return "", "", fmt.Errorf("cannot remove the network %q: %w", a.Target, err) + } + return "removed", "no longer declared", nil + default: return "", "", fmt.Errorf("no way to remove a %q", a.Type) } @@ -775,6 +795,45 @@ func containerState(ctx context.Context, name string, run Runner) (state struct // 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. +// applyNetwork creates a named network if the machine does not already have one. +// +// **Existence is the whole of the state.** A network the mesh declared and a network somebody +// made by hand are indistinguishable by name, and that is deliberate: the mesh owns the name, not +// the thing, so it will not tear down and rebuild one that is already there and working. What it +// records is that this resource is now present, which is what lets it be removed later. +// +// Nothing is reconciled beyond presence. A driver or a subnet changed underneath would not be +// noticed — and is not declarable either (novox/hq ADR 0029), so there is nothing to disagree +// with. +func applyNetwork(ctx context.Context, r *declaration.Network, run Runner) (Outcome, error) { + out := begin(r) + + cri, err := containerRuntime(ctx, run) + if err != nil { + return out, fmt.Errorf("%w, so nothing can be said about the network %q", err, r.Name) + } + + if _, err := run(ctx, cri, "network", "inspect", r.Name); err == nil { + out.Action = "unchanged" + out.Detail = "already there" + return out, nil + } + + if _, err := run(ctx, cri, "network", "create", r.Name); err != nil { + return out, fmt.Errorf("cannot create the network %q: %w", r.Name, err) + } + // Read back rather than trusting the exit status (novox/hq ADR 0018). A runtime that reports + // success and made nothing leaves every container that joins it failing to start, with the + // cause one step away. + if _, err := run(ctx, cri, "network", "inspect", r.Name); err != nil { + return out, fmt.Errorf( + "the network %q was created and is not there afterwards: %w", r.Name, err) + } + out.Action = "created" + out.Detail = "a network for this module's own containers" + return out, nil +} + func applyContainer(ctx context.Context, r *declaration.Container, run Runner) (Outcome, error) { out := begin(r) want := containerSpec(r) diff --git a/internal/apply/vocabulary_test.go b/internal/apply/vocabulary_test.go index d654093..aacf596 100644 --- a/internal/apply/vocabulary_test.go +++ b/internal/apply/vocabulary_test.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -248,3 +249,71 @@ func TestAnArchiveMustBePinned(t *testing.T) { t.Fatalf("unhelpful refusal: %v", err) } } + +// Defends novox/hq ADR 0029: a network is a shape so that it can be removed. +// +// The whole argument for widening the vocabulary is lifecycle — an action could create one and +// nothing could ever take it away — so removal is the assertion that matters, not creation. +func TestANetworkIsCreatedAndThenRemovedWhenNoLongerDeclared(t *testing.T) { + var calls []string + there := map[string]bool{} + run := func(_ context.Context, name string, args ...string) (string, error) { + calls = append(calls, name+" "+strings.Join(args, " ")) + if name != "docker" || len(args) < 2 || args[0] != "network" { + return "", nil // the runtime probe + } + switch args[1] { + case "inspect": + if !there[args[2]] { + return "", fmt.Errorf("no such network") + } + case "create": + there[args[2]] = true + case "rm": + delete(there, args[2]) + } + return "", nil + } + + d := declare(t, `{"id":"private","type":"network","name":"mail"}`) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatal(err) + } + if !there["mail"] { + t.Fatal("the network was not created") + } + + // The module is unassigned: the mesh now declares nothing. + empty := declare(t, `{"id":"unrelated","type":"directory","path":"`+t.TempDir()+`"}`) + if _, _, err := Apply(context.Background(), archHost(t), empty, state, + store.OriginDeclared, run, nil, nil); err != nil { + t.Fatal(err) + } + if there["mail"] { + t.Fatal("the network outlived the module that declared it, which is the entire reason " + + "this is a shape rather than an action") + } +} + +// A network is created once and left alone when it is already there. +func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) { + var created int + run := func(_ context.Context, name string, args ...string) (string, error) { + if name == "docker" && len(args) > 1 && args[0] == "network" && args[1] == "create" { + created++ + } + return "", nil // inspect succeeds: it is already there + } + + d := declare(t, `{"id":"private","type":"network","name":"mail"}`) + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, run, nil, nil); err != nil { + t.Fatal(err) + } + if created != 0 { + t.Fatalf("a network that was already there was created %d time(s); the mesh owns the "+ + "name and not the thing, so it does not tear one down and rebuild it", created) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 5b235ad..0d4041e 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -42,6 +42,12 @@ const ( // of files; inlining them would make every declaration enormous and rewrite the lot whenever // one changed. TypeArchive Type = "archive" + + // TypeNetwork is a named network on this machine, for a module whose containers must reach + // each other by name. Created if absent, removed when no longer declared — which is the whole + // reason it is a shape rather than an action, because an action leaves nothing the host can + // undo and the network would outlive the module (novox/hq ADR 0029). + TypeNetwork Type = "network" ) // Resource is one thing that should be true of the machine. @@ -182,6 +188,41 @@ type User struct { Home string `json:"home,omitempty"` } +// Network is a named network on this machine. +// +// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a +// module would have to know about the machine it lands on, and a module naming a subnet is a +// module that collides with whatever else chose the same one. The runtime picks; the mesh names +// (novox/hq ADR 0029). +type Network struct { + ID string `json:"id"` + Type Type `json:"type"` + Name string `json:"name"` +} + +func (n *Network) Identity() string { return n.ID } +func (n *Network) Kind() Type { return TypeNetwork } +func (n *Network) Target() string { return n.Name } + +func (n *Network) validate(where string, _ bool) []string { + var problems []string + if n.Name == "" { + problems = append(problems, where+": a network needs a name") + } + // The runtimes accept more than this, and the mesh does not: a name with a slash or a colon + // in it reads as a reference to something else entirely wherever it is later printed. + for _, r := range n.Name { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && + r != '-' && r != '_' && r != '.' { + problems = append(problems, where+ + ": a network name is letters, digits, dashes, underscores and dots, and "+ + n.Name+" is not") + break + } + } + return problems +} + func (u *User) Identity() string { return u.ID } func (u *User) Kind() Type { return TypeUser } func (u *User) Target() string { return u.Name } @@ -429,6 +470,8 @@ func newOf(t Type) Resource { return &Container{} case TypeAction: return &Action{} + case TypeNetwork: + return &Network{} case TypeUser: return &User{} case TypeArchive: @@ -440,8 +483,8 @@ func newOf(t Type) Resource { // Vocabulary is every kind this host speaks. func Vocabulary() []Type { return []Type{ - TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypePackage, - TypeService, TypeUser, + TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork, + TypePackage, TypeService, TypeUser, } } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 3196ecf..a09df0f 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -256,7 +256,7 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { } for _, want := range []Type{ TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, - TypeUser, TypeArchive, + TypeUser, TypeArchive, TypeNetwork, } { if !speaks[want] { t.Errorf("the host no longer speaks %q", want) @@ -265,8 +265,11 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { t.Errorf("%q is in the vocabulary and cannot be constructed", want) } } - if len(speaks) != 8 { - t.Errorf("the vocabulary is %d shapes rather than 8; every addition widens what a compromised "+ + // `network` is the ninth, and novox/hq ADR 0029 is the decision that made it one: an action + // could create a network and nothing could remove it, because an action leaves no footprint + // the host can undo — so the network would outlive every module that was ever unassigned. + if len(speaks) != 9 { + t.Errorf("the vocabulary is %d shapes rather than 9; every addition widens what a compromised "+ "control plane can express, so a change here is a decision: %s", len(speaks), vocabulary()) } @@ -344,3 +347,15 @@ func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) { t.Fatalf("a declaration with a trailing newline was refused: %v", err) } } + +// A name that would read as a reference to something else is refused before it reaches a runtime. +func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) { + for _, name := range []string{"", "mail/private", "host:mail", "a b"} { + refusal := refusalFor(t, `{"declaration":1,"resources":[ + {"id":"private","type":"network","name":"`+name+`"} + ]}`) + if len(refusal.Problems) == 0 { + t.Errorf("a network named %q was accepted", name) + } + } +}