catalogue: a module accesses operator-owned data, and does not own it
04-ISSUES/036: the media stack is several modules that must share the library and download directories on one machine, but the manifest could only say "a directory I own". Six modules each declared the same paths as their own resources, and the resolver's duplicate-owner refusal — right in general — would refuse the stack's only sensible assignment the first time two of them landed on one node. Add an `accesses` field: a pre-existing, operator-owned path a module is granted use of but does not own (novox/hq ADR 0051). Distinct from a `directory` resource on every axis the host acts on — the mesh creates, chowns and reconciles a directory; it mounts an access and owns nothing. An access is not a resource, so it never enters the duplicate-owner map and several modules may name one path with no conflict. What is refused is the contradiction: a path one module owns and another accesses. Rendered into the declaration as an `access` resource, before the container that mounts it, so the host can find it present or refuse clearly. Unit tests cover co-resolution (the exact 036 case), the unchanged owner-vs-owner refusal, the owner-vs-accessor refusal, and access validation. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the
|
||||||
|
// acquisition managers, a download client — that must share directories on one machine. Written
|
||||||
|
// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which
|
||||||
|
// is right in general and wrong here: sharing those directories is the whole point of the stack.
|
||||||
|
//
|
||||||
|
// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access
|
||||||
|
// is not a resource and never enters the duplicate-owner map, so this is the exact case the
|
||||||
|
// resolver refuses above when the same path is owned — and does not, when it is merely accessed.
|
||||||
|
func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) {
|
||||||
|
a := mod("a", nil, nil, nil)
|
||||||
|
a.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessReadWrite}}
|
||||||
|
b := mod("b", nil, nil, nil)
|
||||||
|
b.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessRead}}
|
||||||
|
|
||||||
|
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("two modules accessing one operator-owned path were refused: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And each accessing module's grant reaches the machine as its own `access` resource, so the
|
||||||
|
// host can find the path present or refuse — both naming the one operator-owned path.
|
||||||
|
var accesses int
|
||||||
|
for _, r := range mustDeclare(t, got) {
|
||||||
|
if r["type"] == "access" && r["path"] == "/services/media/downloads" {
|
||||||
|
accesses++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accesses != 2 {
|
||||||
|
t.Errorf("expected both modules' accesses in the declaration, got %d", accesses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside:
|
||||||
|
// two modules OWNING one path is still refused, exactly as before accesses existed.
|
||||||
|
func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) {
|
||||||
|
a := mod("a", nil, nil, nil)
|
||||||
|
a.Resources = []map[string]any{
|
||||||
|
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
||||||
|
b := mod("b", nil, nil, nil)
|
||||||
|
b.Resources = []map[string]any{
|
||||||
|
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
||||||
|
|
||||||
|
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("two modules owning the same directory were both assigned")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/services/media/movies") {
|
||||||
|
t.Errorf("the refusal does not name the path: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path
|
||||||
|
// another module was told to expect the operator to provide would create and chown it — and the
|
||||||
|
// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction
|
||||||
|
// machine-checkable rather than a convention nobody enforces.
|
||||||
|
func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
|
||||||
|
owns := mod("owns", nil, nil, nil)
|
||||||
|
owns.Resources = []map[string]any{
|
||||||
|
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
||||||
|
uses := mod("uses", nil, nil, nil)
|
||||||
|
uses.Accesses = []Access{{Path: "/services/media/movies", Mode: AccessRead}}
|
||||||
|
|
||||||
|
_, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), World{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a module owning a path another accesses was allowed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/services/media/movies") ||
|
||||||
|
!strings.Contains(err.Error(), "the operator's") {
|
||||||
|
t.Errorf("the refusal does not explain the ownership conflict: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manifest states an access's extent, the way a listening port states its source. An absolute
|
||||||
|
// path and a mode of read or read-write; anything else is refused rather than acted on wrongly.
|
||||||
|
func TestAnAccessIsValidated(t *testing.T) {
|
||||||
|
good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`)
|
||||||
|
if _, err := ParseManifest(good); err != nil {
|
||||||
|
t.Fatalf("a valid access was refused: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode is optional and narrows to read — the safe default, because the danger with an access
|
||||||
|
// is being given more than was meant, not less.
|
||||||
|
bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`)
|
||||||
|
m, err := ParseManifest(bare)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an access with no mode was refused: %v", err)
|
||||||
|
}
|
||||||
|
if m.Accesses[0].At() != AccessRead {
|
||||||
|
t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`)
|
||||||
|
if _, err := ParseManifest(relative); err == nil {
|
||||||
|
t.Error("a relative access path was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`)
|
||||||
|
if _, err := ParseManifest(wrong); err == nil {
|
||||||
|
t.Error("an access with an unknown mode was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,6 +215,16 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name], "sealed": sealed,
|
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name], "sealed": sealed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
|
||||||
|
// the module's own resources, and so before the container that mounts them: the host must
|
||||||
|
// find each present — refusing clearly if the operator has not provided it — before it
|
||||||
|
// starts anything that depends on it. The mesh creates, chowns and reconciles none of it;
|
||||||
|
// an `access` resource says only *this path must exist, and this module reaches it*.
|
||||||
|
for _, a := range m.Accesses {
|
||||||
|
first = append(first, map[string]any{
|
||||||
|
"id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.At(),
|
||||||
|
})
|
||||||
|
}
|
||||||
for _, to := range sortedKeys(m.Secrets) {
|
for _, to := range sortedKeys(m.Secrets) {
|
||||||
var found *Needed
|
var found *Needed
|
||||||
for i, n := range r.Needs {
|
for i, n := range r.Needs {
|
||||||
|
|||||||
@@ -57,6 +57,49 @@ func (c Claim) At() string {
|
|||||||
return c.Scope
|
return c.Scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modes an access may be granted at.
|
||||||
|
//
|
||||||
|
// **The grant states its extent, the way a listening port states its source** (the rule the
|
||||||
|
// manifest already keeps for `listens.from`). Absent narrows to read — the safe default, because
|
||||||
|
// the danger with an access is being given more than was meant, not less.
|
||||||
|
const (
|
||||||
|
AccessRead = "read"
|
||||||
|
AccessReadWrite = "read-write"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Access is a pre-existing path on the machine that this module is GRANTED USE OF, and does not
|
||||||
|
// own.
|
||||||
|
//
|
||||||
|
// **The distinction this exists for** (novox/hq ADR 0051, 04-ISSUES/036): a `directory` resource
|
||||||
|
// is a thing the mesh owns — it creates it, sets its owner and mode, and removes it when it is
|
||||||
|
// empty and no longer declared ([ADR 0030](novox/hq)). Shared, pre-existing data is none of that.
|
||||||
|
// A media library, a download spool, an ingest folder 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. It mounts it and owns nothing about it.
|
||||||
|
//
|
||||||
|
// Written as its own field rather than a flag on a directory because the two are opposite on every
|
||||||
|
// axis the host acts on, and 04-ISSUES/026 records what happens when *the directory my data lives
|
||||||
|
// in* and *a facility I was granted* are spelled the same: the second gets created as root and the
|
||||||
|
// ownership fields silently do not apply. Two modules may name the same access with no conflict —
|
||||||
|
// that is the whole point of it — whereas two owning one path is the fault the resolver refuses.
|
||||||
|
//
|
||||||
|
// If the path is absent when a machine applies, the host refuses clearly rather than creating it:
|
||||||
|
// the mesh does not own it, so conjuring it would be a lie the host then acts on.
|
||||||
|
type Access struct {
|
||||||
|
// Path is the absolute path on the machine, as the operator provides it.
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Mode is "read" or "read-write". Absent narrows to read.
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// At is this access's mode, with the default applied.
|
||||||
|
func (a Access) At() string {
|
||||||
|
if a.Mode == "" {
|
||||||
|
return AccessRead
|
||||||
|
}
|
||||||
|
return a.Mode
|
||||||
|
}
|
||||||
|
|
||||||
// Offer is something a module provides, and where the answer to it may live.
|
// Offer is something a module provides, and where the answer to it may live.
|
||||||
//
|
//
|
||||||
// **The distinction this exists for:** a shell, a display server and a private network have to be
|
// **The distinction this exists for:** a shell, a display server and a private network have to be
|
||||||
@@ -157,6 +200,12 @@ type Manifest struct {
|
|||||||
// Resources are what this module puts on a node, in the host's own vocabulary.
|
// Resources are what this module puts on a node, in the host's own vocabulary.
|
||||||
Resources []map[string]any `json:"resources,omitempty"`
|
Resources []map[string]any `json:"resources,omitempty"`
|
||||||
|
|
||||||
|
// Accesses are pre-existing, operator-owned paths this module is granted use of but does not
|
||||||
|
// own — a shared media library, a download spool (novox/hq ADR 0051). Distinct from a
|
||||||
|
// `directory` resource, which the mesh creates and owns: an access is mounted and nothing
|
||||||
|
// about it is reconciled, and several modules may name the same one without conflict.
|
||||||
|
Accesses []Access `json:"accesses,omitempty"`
|
||||||
|
|
||||||
// Computed names something in the control plane that works this module's resources out per
|
// Computed names something in the control plane that works this module's resources out per
|
||||||
// node, instead of them being fixed here.
|
// node, instead of them being fixed here.
|
||||||
//
|
//
|
||||||
@@ -415,6 +464,13 @@ func GrantID(provision, consumer string) string { return "grant-" + provision +
|
|||||||
// BoundID is the resource identity of the file a module is told about a provision in.
|
// BoundID is the resource identity of the file a module is told about a provision in.
|
||||||
func BoundID(requirement string) string { return "bound-" + requirement }
|
func BoundID(requirement string) string { return "bound-" + requirement }
|
||||||
|
|
||||||
|
// AccessID is the resource identity of an operator-owned path this module is granted use of.
|
||||||
|
//
|
||||||
|
// Derived from the path rather than a name the module chose, so two modules granted the same
|
||||||
|
// access name the same identity within their own qualification — and neither has to invent a
|
||||||
|
// label for something that is not theirs.
|
||||||
|
func AccessID(path string) string { return "access-" + strings.TrimPrefix(path, "/") }
|
||||||
|
|
||||||
// Wants is everything that must be provided on the same node: what this module requires, and what
|
// Wants is everything that must be provided on the same node: what this module requires, and what
|
||||||
// it contributes to.
|
// it contributes to.
|
||||||
func (m Manifest) Wants() []string {
|
func (m Manifest) Wants() []string {
|
||||||
@@ -730,6 +786,20 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s receives contributions to %q and does not provide it", m.Module, to))
|
"%s receives contributions to %q and does not provide it", m.Module, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, a := range m.Accesses {
|
||||||
|
if !strings.HasPrefix(a.Path, "/") {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s accesses %q, which is not an absolute path", m.Module, a.Path))
|
||||||
|
}
|
||||||
|
switch a.At() {
|
||||||
|
case AccessRead, AccessReadWrite:
|
||||||
|
default:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s accesses %q at mode %q; an access is %q or %q",
|
||||||
|
m.Module, a.Path, a.Mode, AccessRead, AccessReadWrite))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, r := range m.Resources {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
|
|||||||
@@ -566,9 +566,18 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held) ([]Held, []str
|
|||||||
// This costs no manifest field: the mesh already holds every resource of every module, so two
|
// This costs no manifest field: the mesh already holds every resource of every module, so two
|
||||||
// declaring one path or one unit are visible without either having to know about the other. A
|
// declaring one path or one unit are visible without either having to know about the other. A
|
||||||
// declared claim is only for the abstract conflicts nothing in the resources reveals.
|
// declared claim is only for the abstract conflicts nothing in the resources reveals.
|
||||||
|
//
|
||||||
|
// **The refusal is about ownership, not use** (novox/hq ADR 0051, 04-ISSUES/036). Two modules
|
||||||
|
// owning one path is the fault this catches — the class of collision this repository keeps
|
||||||
|
// recording. Two modules *accessing* one operator-owned path is not a collision: it is the whole
|
||||||
|
// point of a media stack, where the library server, the managers and the download client must see
|
||||||
|
// the same directories. An access is not a resource and never enters the owner map, so co-access
|
||||||
|
// resolves with no refusal. What is refused is the contradiction — a path one module owns and
|
||||||
|
// another merely accesses — because shared data is the operator's and nobody's to own.
|
||||||
func checkResources(modules []Manifest) []string {
|
func checkResources(modules []Manifest) []string {
|
||||||
var problems []string
|
var problems []string
|
||||||
owner := map[string]string{}
|
owner := map[string]string{}
|
||||||
|
ownedPath := map[string]string{} // path → owning module, for the access check below
|
||||||
|
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
@@ -583,6 +592,30 @@ func checkResources(modules []Manifest) []string {
|
|||||||
"%s and %s both declare the %s %q", other, m.Module, field, value))
|
"%s and %s both declare the %s %q", other, m.Module, field, value))
|
||||||
}
|
}
|
||||||
owner[key] = m.Module
|
owner[key] = m.Module
|
||||||
|
if field == "path" {
|
||||||
|
ownedPath[value] = m.Module
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accessed path is the operator's, so no module may declare it as one of its own
|
||||||
|
// (novox/hq ADR 0051). Refused here rather than silently tolerated: an owner would create and
|
||||||
|
// chown the very directory another module was told to expect the operator to provide, and the
|
||||||
|
// two intentions cannot both hold. Two modules *accessing* it, by contrast, is never checked,
|
||||||
|
// which is what lets the stack in 04-ISSUES/036 co-resolve.
|
||||||
|
for _, m := range modules {
|
||||||
|
for _, a := range m.Accesses {
|
||||||
|
switch other := ownedPath[a.Path]; other {
|
||||||
|
case "":
|
||||||
|
// Nobody owns it — the ordinary, correct case for shared data.
|
||||||
|
case m.Module:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s both owns and accesses %q — it is one or the other", m.Module, a.Path))
|
||||||
|
default:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s accesses %q, which %s declares it owns — shared data is the operator's, "+
|
||||||
|
"owned by no module (novox/hq ADR 0051)", m.Module, a.Path, other))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user