Unify trunk on main: initialization → main #3
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user