A module accesses operator-owned data, it does not own it (ADR 0051) #8
@@ -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,
|
||||
})
|
||||
}
|
||||
// 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) {
|
||||
var found *Needed
|
||||
for i, n := range r.Needs {
|
||||
|
||||
@@ -57,6 +57,49 @@ func (c Claim) At() string {
|
||||
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.
|
||||
//
|
||||
// **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 []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
|
||||
// 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.
|
||||
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
|
||||
// it contributes to.
|
||||
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))
|
||||
}
|
||||
}
|
||||
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 {
|
||||
id, _ := r["id"].(string)
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// **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 {
|
||||
var problems []string
|
||||
owner := map[string]string{}
|
||||
ownedPath := map[string]string{} // path → owning module, for the access check below
|
||||
|
||||
for _, m := range modules {
|
||||
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))
|
||||
}
|
||||
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