diff --git a/internal/apply/access_test.go b/internal/apply/access_test.go new file mode 100644 index 0000000..f4f6187 --- /dev/null +++ b/internal/apply/access_test.go @@ -0,0 +1,98 @@ +package apply + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// An operator-owned path the module reaches but does not own (novox/hq ADR 0051). +// +// The host confirms it is present and changes nothing: it does not create it, chown it or set its +// mode, because the media library and the download spool are the operator's and several modules +// share them. This is the opposite of a directory on every axis, and the whole reason the two are +// different shapes. +func TestAnAccessPresentIsConfirmedAndNothingIsChanged(t *testing.T) { + dir := t.TempDir() // the operator's directory, already there + d := declare(t, `{"id":"lib","type":"access","path":"`+dir+`","mode":"read-write"}`) + + report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatalf("an operator-owned path that is present was refused: %v", err) + } + if report.Changed() { + t.Fatalf("confirming an access reported a change; the host owns nothing about it") + } + if _, statErr := os.Stat(dir); statErr != nil { + t.Fatalf("the operator's directory was disturbed: %v", statErr) + } +} + +// Absent is refused, not created. A bind mount whose source does not exist is made by the +// container runtime as root, with whatever mode it picks — the silent wrong-ownership +// 04-ISSUES/026 records. So the host says plainly that the operator must provide the path, rather +// than conjuring a directory it does not own. +func TestAnAccessThatIsAbsentIsRefusedClearlyAndNotCreated(t *testing.T) { + missing := t.TempDir() + "/media/library" // named, never created + d := declare(t, `{"id":"lib","type":"access","path":"`+missing+`"}`) + + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, noServices, nil, nil) + if err == nil { + t.Fatal("an absent operator-owned path was accepted, and would be created as root by the runtime") + } + if !strings.Contains(err.Error(), "the operator must provide") || + !strings.Contains(err.Error(), "does not own") { + t.Fatalf("the refusal does not say whose the path is: %v", err) + } + if _, statErr := os.Stat(missing); statErr == nil { + t.Fatal("the host created the path it does not own") + } +} + +// Undeclaring an access never removes the path. Unassigning the module that reached the media +// library must not delete the library — that is the data loss ADR 0030 exists to prevent, on a +// directory the mesh never made. The record is dropped; the operator's data is left exactly as it +// is. +func TestAnUndeclaredAccessLeavesTheOperatorsPathAlone(t *testing.T) { + lib := t.TempDir() // the operator's library + keep := t.TempDir() + if err := os.WriteFile(lib+"/a-real-file", []byte("the operator's data"), 0o644); err != nil { + t.Fatal(err) + } + + d := declare(t, `{"id":"lib","type":"access","path":"`+lib+`","mode":"read"}`) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + + // The module is unassigned: the mesh no longer declares the access. + empty := declare(t, `{"id":"unrelated","type":"directory","path":"`+keep+`"}`) + report, _, err := Apply(context.Background(), archHost(t), empty, state, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + + if _, statErr := os.Stat(lib); statErr != nil { + t.Fatalf("the operator's library was removed when the module stopped reaching it: %v", statErr) + } + if _, statErr := os.Stat(lib + "/a-real-file"); statErr != nil { + t.Fatalf("the operator's data was removed: %v", statErr) + } + var forgot bool + for _, o := range report.Outcomes { + if o.Type == "access" && o.Action == "forgotten" { + forgot = true + } + } + if !forgot { + t.Errorf("dropping an access was not reported as forgotten: %+v", report.Outcomes) + } +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 9905485..8cba0e3 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -250,6 +250,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return applyAction(ctx, res, run) case *declaration.Network: return applyNetwork(ctx, res, run) + case *declaration.Access: + return applyAccess(res) default: // Unreachable: the declaration refused this already. Present because "unreachable" // stops being true the moment someone adds a kind and forgets this switch. @@ -336,6 +338,46 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) { return out, nil } +// applyAccess confirms an operator-owned path is present, and owns nothing about it. +// +// **The mirror image of applyDirectory** (novox/hq ADR 0051). A directory the host makes, chmods, +// chowns and removes when empty. An access it does none of: the path is the operator's — a media +// library, a download spool that several modules share — and the host's only job is to be sure it +// is there before anything mounts it. +// +// **Absent is refused, not created.** A bind mount whose source does not exist is made for you by +// the container runtime, as root, with whatever mode it picks — which is exactly the silent +// wrong-ownership 04-ISSUES/026 records. So the host checks first and says plainly that the +// operator must provide the path, rather than conjuring a directory it does not own and cannot +// give the right owner. Nothing is written, so this never reports a change: the machine did not +// move, the host merely confirmed a fact about it. +func applyAccess(r *declaration.Access) (Outcome, error) { + out := begin(r) + info, err := os.Stat(r.Path) + if errors.Is(err, os.ErrNotExist) { + return out, fmt.Errorf( + "%s is not there, and the mesh does not own it — the operator must provide it. It is "+ + "shared, pre-existing data (novox/hq ADR 0051): the host mounts it and creates "+ + "nothing, so a missing one is said here rather than made as root by the container "+ + "runtime", r.Path) + } + if err != nil { + return out, err + } + if !info.IsDir() { + return out, fmt.Errorf( + "%s is not a directory, and an access is a shared directory the operator provides", + r.Path) + } + mode := declaration.AccessRead + if r.Mode != "" { + mode = r.Mode + } + out.Action = "unchanged" + out.Detail = "operator-owned; present, " + mode + ", nothing managed" + return out, nil +} + func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outcome, error) { out := begin(r) @@ -705,6 +747,13 @@ 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.TypeAccess: + // The path is the operator's and the host never owned it (novox/hq ADR 0051). Undeclaring + // it says only that this module no longer reaches it — not that the media library should + // be touched. So the record is dropped and the path left exactly as it is; removing it + // would be the data loss ADR 0030 exists to prevent, on a directory the mesh never made. + return "forgotten", "an operator-owned path is never the host's to remove", 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 diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index ed25882..0ce7338 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -50,6 +50,15 @@ const ( // 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" + + // TypeAccess is a pre-existing, operator-owned path a module is granted use of but does not + // own (novox/hq ADR 0051). The opposite of a directory on every axis the host acts on: the + // host creates, chowns and reconciles a directory, and removes it when it is empty; it does + // none of that to an access. It confirms the path is present — refusing clearly if the + // operator has not provided it, rather than creating it as a bind mount source would + // (04-ISSUES/026) — and leaves everything about it alone. Several modules declaring one + // access is ordinary, because none of them owns it. + TypeAccess Type = "access" ) // Resource is one thing that should be true of the machine. @@ -281,6 +290,48 @@ func (n *Network) validate(where string, _ bool) []string { return problems } +// Modes an access may be granted at. Plain words, not the octal a directory's mode is: an access +// is not a thing the host chmods, it is a statement of how this module reaches what the operator +// owns. +const ( + AccessRead = "read" + AccessReadWrite = "read-write" +) + +// Access is a pre-existing, operator-owned path this module is granted use of but does not own. +// +// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own — +// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A +// media library, a download spool is the operator's: it existed before the mesh, several modules +// read and write it at once, and the mesh must not create, chown, reconcile or remove it. The +// host confirms it is there and mounts it; nothing else. +type Access struct { + ID string `json:"id"` + Type Type `json:"type"` + Path string `json:"path"` + // Mode is how this module reaches the path: read or read-write. Absent narrows to read. + Mode string `json:"mode,omitempty"` +} + +func (a *Access) Identity() string { return a.ID } +func (a *Access) Kind() Type { return TypeAccess } +func (a *Access) Target() string { return a.Path } + +func (a *Access) validate(where string, _ bool) []string { + var problems []string + if !strings.HasPrefix(a.Path, "/") { + problems = append(problems, where+": an access needs an absolute path, and "+ + a.Path+" is not one") + } + switch a.Mode { + case "", AccessRead, AccessReadWrite: + default: + problems = append(problems, fmt.Sprintf( + "%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode)) + } + 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 } @@ -555,6 +606,8 @@ func newOf(t Type) Resource { return &User{} case TypeArchive: return &Archive{} + case TypeAccess: + return &Access{} } return nil } @@ -562,7 +615,7 @@ func newOf(t Type) Resource { // Vocabulary is every kind this host speaks. func Vocabulary() []Type { return []Type{ - TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork, + TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork, TypePackage, TypeService, TypeUser, } } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index f738002..46734b3 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, TypeNetwork, + TypeUser, TypeArchive, TypeNetwork, TypeAccess, } { if !speaks[want] { t.Errorf("the host no longer speaks %q", want) @@ -268,8 +268,12 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) { // `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 "+ + // + // `access` is the tenth, and novox/hq ADR 0051 is its decision: shared, pre-existing data is + // the operator's, and a module is granted use of it without owning it — a shape the host must + // tell apart from a directory precisely because it must NOT create, chown or remove it. + if len(speaks) != 10 { + t.Errorf("the vocabulary is %d shapes rather than 10; every addition widens what a compromised "+ "control plane can express, so a change here is a decision: %s", len(speaks), vocabulary()) } diff --git a/internal/system/system.go b/internal/system/system.go index 9508982..0c2cb96 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -169,6 +169,10 @@ func everyShape() []declaration.Type { // A network needs the same runtime a container does, so a host that can run one can make // the other. Not in portableShapes for exactly that reason. declaration.TypeNetwork, + // An access is confirmed by a stat and needs only a filesystem — but it exists to gate a + // bind mount, and a bind mount needs the container runtime a full host has. So it sits + // here with the container it guards, not at the portable floor (novox/hq ADR 0051). + declaration.TypeAccess, } }