declaration: an access is mounted, and the host owns nothing about it
The tenth shape (novox/hq ADR 0051). Shared, pre-existing data — a media library, a download spool several modules use — is the operator's, not the mesh's. A `directory` resource is the host's own: it creates it, chowns it, sets its mode and removes it when empty. An access is the opposite on every axis. Add the `access` type to the vocabulary. Its applier confirms the path is present and changes nothing: it does not create, chown, reconcile or set a mode. Absent is refused clearly — the operator must provide it — rather than created, because a bind mount whose source is missing is made as root by the container runtime with the wrong ownership (04-ISSUES/026). Undeclaring an access forgets the record and never touches the path, which is the data loss ADR 0030 prevents, on a directory the mesh never made. Full hosts speak it (it gates a bind mount, which needs the container runtime); the vocabulary guard test records the decision that made it the tenth shape. Unit tests cover present, absent-refused, and undeclared-left-alone. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,6 +250,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
|
|||||||
return applyAction(ctx, res, run)
|
return applyAction(ctx, res, run)
|
||||||
case *declaration.Network:
|
case *declaration.Network:
|
||||||
return applyNetwork(ctx, res, run)
|
return applyNetwork(ctx, res, run)
|
||||||
|
case *declaration.Access:
|
||||||
|
return applyAccess(res)
|
||||||
default:
|
default:
|
||||||
// Unreachable: the declaration refused this already. Present because "unreachable"
|
// Unreachable: the declaration refused this already. Present because "unreachable"
|
||||||
// stops being true the moment someone adds a kind and forgets this switch.
|
// 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
|
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) {
|
func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outcome, error) {
|
||||||
out := begin(r)
|
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.
|
// to whatever it acted on.
|
||||||
return "forgotten", "an action leaves nothing the host owns", nil
|
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:
|
case declaration.TypeNetwork:
|
||||||
// **The reason this is a shape at all** (novox/hq ADR 0029). Orphans are removed in
|
// **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
|
// reverse declaration order, so a network written before the containers that join it is
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ const (
|
|||||||
// reason it is a shape rather than an action, because an action leaves nothing the host can
|
// 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).
|
// undo and the network would outlive the module (novox/hq ADR 0029).
|
||||||
TypeNetwork Type = "network"
|
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.
|
// 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
|
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) Identity() string { return u.ID }
|
||||||
func (u *User) Kind() Type { return TypeUser }
|
func (u *User) Kind() Type { return TypeUser }
|
||||||
func (u *User) Target() string { return u.Name }
|
func (u *User) Target() string { return u.Name }
|
||||||
@@ -555,6 +606,8 @@ func newOf(t Type) Resource {
|
|||||||
return &User{}
|
return &User{}
|
||||||
case TypeArchive:
|
case TypeArchive:
|
||||||
return &Archive{}
|
return &Archive{}
|
||||||
|
case TypeAccess:
|
||||||
|
return &Access{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -562,7 +615,7 @@ func newOf(t Type) Resource {
|
|||||||
// Vocabulary is every kind this host speaks.
|
// Vocabulary is every kind this host speaks.
|
||||||
func Vocabulary() []Type {
|
func Vocabulary() []Type {
|
||||||
return []Type{
|
return []Type{
|
||||||
TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork,
|
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, TypeNetwork,
|
||||||
TypePackage, TypeService, TypeUser,
|
TypePackage, TypeService, TypeUser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ func TestTheVocabularyIsTheEightShapesTheMeshNeeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []Type{
|
for _, want := range []Type{
|
||||||
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
||||||
TypeUser, TypeArchive, TypeNetwork,
|
TypeUser, TypeArchive, TypeNetwork, TypeAccess,
|
||||||
} {
|
} {
|
||||||
if !speaks[want] {
|
if !speaks[want] {
|
||||||
t.Errorf("the host no longer speaks %q", 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
|
// `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
|
// 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.
|
// 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",
|
"control plane can express, so a change here is a decision: %s",
|
||||||
len(speaks), vocabulary())
|
len(speaks), vocabulary())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// the other. Not in portableShapes for exactly that reason.
|
||||||
declaration.TypeNetwork,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user