diff --git a/go/delivery/composed.go b/go/delivery/composed.go new file mode 100644 index 0000000..c5d30c7 --- /dev/null +++ b/go/delivery/composed.go @@ -0,0 +1,196 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// The controller's compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources +// composed for one node at one fingerprint, written by the controller, which keeps the inputs. It lives in a bucket of +// each node, `composed-`, which only that node's node-engine reads (a composition can carry a sealed value), under +// `..`, and the node's own resources (adoption, firewall openings, accounts, the +// resolver, identities and groups) under `.node`. Beside it, under the same key in `mesh-controller_composed`, +// which the assigner reads, the controller writes the composition's digest and its judgement of the move from the +// composition the node runs now (ComposedDigest). The controller writes both by compare-and-set while it holds the +// lease; the operator's credential is a second writer on the hand path alone (clause 10, mesh-compose). +// +// A composition is derived state (clause 4): its key and its digest are deleted by the controller when its fingerprint +// is no longer among the module's five newest and no want names it, and each bucket keeps five revisions of a key. +const ( + // ComposedBucketPrefix starts the name of a node's bucket of compositions, ComposedBucketFor(node). + ComposedBucketPrefix = "composed-" + // ComposedHistory is the revisions a node's composition bucket keeps per key (ADR 0539 clause 4). + ComposedHistory = 5 + // ControllerModule is the module of the controller, the writer of the compositions. + ControllerModule = "mesh-controller" + // ControllerComposedBucket is the controller's bucket of each composition's digest and judgement, `_`, read by the assigner (ADR 0539 clauses 2 and 6). + ControllerComposedBucket = ControllerModule + "_composed" + // ControllerComposedHistory is the revisions that bucket keeps per key. + ControllerComposedHistory = 5 + // ComposedKeep is how many of a module's newest fingerprints keep their compositions though no want names them + // (ADR 0539 clause 4). + ComposedKeep = 5 +) + +// nodeWord is the last part of a node's own key, in the have bucket and in a node's composition bucket alike. +const nodeWord = "node" + +var bucketNode = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// ComposedBucketFor is the name of a node's bucket of compositions, "composed-", as declaration.FileBucketFor +// names a node's object bucket (ADR 0488 §4). The node name must be one a bucket and a subject token accept: letters, +// digits, '_' and '-'. +func ComposedBucketFor(node string) (string, error) { + if node == "" { + return "", fmt.Errorf("delivery: the node name is empty; a node's composition bucket is named for its node") + } + if !bucketNode.MatchString(node) { + return "", fmt.Errorf("delivery: the node name %q is not one a bucket accepts: letters, digits, '_' and '-' only", node) + } + return ComposedBucketPrefix + node, nil +} + +// ComposedKey is the key, in ComposedBucketFor(node) and in ControllerComposedBucket, of a module's composition for a +// node at a fingerprint: `..`, the hex being the fingerprint's key part (FingerprintKeyPart). A +// module named `node` is refused, since `.node` is the node's own key. +func ComposedKey(node, module, fingerprint string) (string, error) { + have, err := HaveKey(node, module) + if err != nil { + return "", err + } + part, err := FingerprintKeyPart(fingerprint) + if err != nil { + return "", err + } + return have + "." + part, nil +} + +// ComposedNodeKey is the key of a node's own composition: `.node`. +func ComposedNodeKey(node string) (string, error) { + return HaveNodeKey(node) +} + +// ParseComposedKey is the inverse of ComposedKey and ComposedNodeKey: isNode is true for `.node`, with module +// and fingerprint empty. It refuses any other shape, naming the key. +func ParseComposedKey(key string) (node, module, fingerprint string, isNode bool, err error) { + refused := func(why error) (string, string, string, bool, error) { + return "", "", "", false, fmt.Errorf("delivery: %q is not a composition key, .. or .node: %w", key, why) + } + parts := strings.Split(key, ".") + switch len(parts) { + case 2: + if parts[1] != nodeWord { + return refused(fmt.Errorf("a module's composition names its fingerprint")) + } + if err := checkName("node", parts[0]); err != nil { + return refused(err) + } + return parts[0], "", "", true, nil + case 3: + node, module, isNode, err := ParseHaveKey(parts[0] + "." + parts[1]) + if err != nil { + return refused(err) + } + if isNode { + return refused(fmt.Errorf("the node's own key names no fingerprint")) + } + fp, err := ParseFingerprintKeyPart(parts[2]) + if err != nil { + return refused(err) + } + return node, module, fp, false, nil + } + return refused(fmt.Errorf("it has %d parts", len(parts))) +} + +// Judgement is the controller's judgement of a move from the composition a node runs, as its have names it, to a new +// one (ADR 0539 clauses 2 and 14, by ADR 0377 §3's tests). +type Judgement string + +// The judgements of a move. +const ( + // JudgedRecreates is a move that recreates a container. + JudgedRecreates Judgement = "recreates" + // JudgedMovesData is a move that gives the module's data another place. + JudgedMovesData Judgement = "moves-data" + // JudgedNeither is a move that does neither, and the judgement of a composition the node never ran. + JudgedNeither Judgement = "neither" + // JudgedCannot is a move that cannot be judged, because the composition the node runs is gone or cannot be read. + JudgedCannot Judgement = "cannot-judge" +) + +var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func checkDigest(digest, of string) error { + if !digestForm.MatchString(digest) { + return fmt.Errorf("delivery: %s %q is not sha256:<64 lowercase hex digits>", of, digest) + } + return nil +} + +// ComposedDigest is the value, in ControllerComposedBucket, beside a composition: its Digest (ComposedDigestOf), and +// the Judgement of the move to it from the composition the node runs now, whose digest is Against (empty when the node +// runs no composition of the module). Words say why, and are required for a move that cannot be judged. Rewritten when +// the node's have changes. +type ComposedDigest struct { + V int `json:"v"` + Digest string `json:"digest"` + Judgement Judgement `json:"judgement"` + Against string `json:"against,omitempty"` + Words string `json:"words,omitempty"` +} + +func (c ComposedDigest) check() error { + if err := checkDigest(c.Digest, "the composition's digest"); err != nil { + return err + } + if c.Against != "" { + if err := checkDigest(c.Against, "the digest a move is judged against"); err != nil { + return err + } + } + switch c.Judgement { + case JudgedRecreates, JudgedMovesData: + if c.Against == "" { + return fmt.Errorf("delivery: a move judged %s names the composition it is judged against", c.Judgement) + } + case JudgedNeither: + case JudgedCannot: + if c.Words == "" { + return fmt.Errorf("delivery: a move that cannot be judged says why") + } + default: + return fmt.Errorf("delivery: the judgement %q is not %s, %s, %s or %s", c.Judgement, JudgedRecreates, + JudgedMovesData, JudgedNeither, JudgedCannot) + } + return nil +} + +// EncodeComposedDigest is c as JSON at Version; it refuses an unknown judgement, a digest not of the sha256 form, a +// move judged to recreate or move data against nothing, and a move that cannot be judged without its reason. +func EncodeComposedDigest(c ComposedDigest) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = Version + return json.Marshal(c) +} + +// DecodeComposedDigest reads the value beside a composition; it refuses an unknown version (ErrUnknownVersion) and +// what EncodeComposedDigest refuses. +func DecodeComposedDigest(data []byte) (ComposedDigest, error) { + var c ComposedDigest + if err := decode(data, &c); err != nil { + return ComposedDigest{}, err + } + if err := c.check(); err != nil { + return ComposedDigest{}, err + } + return c, nil +} + +// SameComposedDigest says whether two values are the same, so the controller writes only when they differ. +func SameComposedDigest(a, b ComposedDigest) bool { return a == b } diff --git a/go/delivery/composed_keys_test.go b/go/delivery/composed_keys_test.go new file mode 100644 index 0000000..dec6987 --- /dev/null +++ b/go/delivery/composed_keys_test.go @@ -0,0 +1,117 @@ +package delivery + +import ( + "errors" + "strings" + "testing" +) + +// 1943/AC1: a node's composition bucket is named for the node, and a node with no name, or one a bucket refuses, +// is refused, naming why. +func TestComposedBucketFor(t *testing.T) { + b, err := ComposedBucketFor("ace") + if err != nil || b != "composed-ace" { + t.Fatalf("ComposedBucketFor(ace) = %q, %v", b, err) + } + for _, bad := range []string{"", "a.b", "a*", "a b", "a>"} { + if _, err := ComposedBucketFor(bad); err == nil || !strings.Contains(err.Error(), "node") { + t.Errorf("ComposedBucketFor(%q) = %v, want a refusal naming the node", bad, err) + } + } +} + +// 1943/AC1: a module's composition key and the node's own key round-trip through one parser; an empty node, a +// module named node, a bad fingerprint and a key of another shape are refused, naming why. +func TestComposedKeys(t *testing.T) { + fp := fp2of('c') + key, err := ComposedKey("ace", "mesh-mail", fp) + if err != nil || key != "ace.mesh-mail."+strings.Repeat("c", 64) { + t.Fatalf("ComposedKey = %q, %v", key, err) + } + node, module, back, isNode, err := ParseComposedKey(key) + if err != nil || node != "ace" || module != "mesh-mail" || back != fp || isNode { + t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v, %v", key, node, module, back, isNode, err) + } + own, err := ComposedNodeKey("ace") + if err != nil || own != "ace.node" { + t.Fatalf("ComposedNodeKey = %q, %v", own, err) + } + node, module, back, isNode, err = ParseComposedKey(own) + if err != nil || node != "ace" || module != "" || back != "" || !isNode { + t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v, %v", own, node, module, back, isNode, err) + } + + refusals := []struct { + node, module, fp, why string + }{ + {"", "mesh-mail", fp, "node name is empty"}, + {"ace", "node", fp, `"node" is refused`}, + {"ace", "", fp, "module name is empty"}, + {"ace", "mesh-mail", "src2:abc", "not a fingerprint of the new form"}, + {"ace", "mesh-mail", "src1:" + strings.Repeat("c", 64), "not a fingerprint of the new form"}, + {"a.b", "mesh-mail", fp, "node name"}, + } + for _, r := range refusals { + if _, err := ComposedKey(r.node, r.module, r.fp); err == nil || !strings.Contains(err.Error(), r.why) { + t.Errorf("ComposedKey(%q, %q, %q) = %v, want a refusal saying %q", r.node, r.module, r.fp, err, r.why) + } + } + if _, err := ComposedNodeKey(""); err == nil { + t.Errorf("ComposedNodeKey(\"\") accepted") + } + for _, bad := range []string{"", "ace", "ace.mesh-mail", "ace.node.x", "ace.mesh-mail.abc", ".mesh-mail." + strings.Repeat("c", 64), + "ace.node." + strings.Repeat("c", 64), "ace.mesh-mail." + strings.Repeat("c", 64) + ".x", "want.ace.mesh-mail"} { + if _, _, _, _, err := ParseComposedKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseComposedKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1943/AC1: the controller's value beside a composition, its digest and the judgement of the move, round-trips; an +// unknown judgement, a digest not of the sha256 form, a move judged against nothing, and a cannot-judge without its +// reason are refused, naming why; an unknown version is refused as one. +func TestComposedDigestValue(t *testing.T) { + digest := "sha256:" + strings.Repeat("d", 64) + against := "sha256:" + strings.Repeat("e", 64) + for _, v := range []ComposedDigest{ + {Digest: digest, Judgement: JudgedNeither}, + {Digest: digest, Judgement: JudgedNeither, Against: against}, + {Digest: digest, Judgement: JudgedRecreates, Against: against, Words: "the container's image changes"}, + {Digest: digest, Judgement: JudgedMovesData, Against: against}, + {Digest: digest, Judgement: JudgedCannot, Words: "the composition the node runs is gone"}, + } { + raw, err := EncodeComposedDigest(v) + if err != nil { + t.Fatalf("EncodeComposedDigest(%+v): %v", v, err) + } + back, err := DecodeComposedDigest(raw) + v.V = Version + if err != nil || !SameComposedDigest(back, v) { + t.Errorf("round trip of %+v = %+v, %v", v, back, err) + } + } + refusals := []struct { + v ComposedDigest + why string + }{ + {ComposedDigest{Digest: digest, Judgement: "maybe"}, `judgement "maybe"`}, + {ComposedDigest{Digest: digest}, `judgement ""`}, + {ComposedDigest{Digest: "sha256:abc", Judgement: JudgedNeither}, "not sha256"}, + {ComposedDigest{Judgement: JudgedNeither}, "not sha256"}, + {ComposedDigest{Digest: digest, Judgement: JudgedRecreates}, "against"}, + {ComposedDigest{Digest: digest, Judgement: JudgedMovesData}, "against"}, + {ComposedDigest{Digest: digest, Judgement: JudgedCannot}, "why"}, + {ComposedDigest{Digest: digest, Judgement: JudgedNeither, Against: "sha256:zz"}, "not sha256"}, + } + for _, r := range refusals { + if _, err := EncodeComposedDigest(r.v); err == nil || !strings.Contains(err.Error(), r.why) { + t.Errorf("EncodeComposedDigest(%+v) = %v, want a refusal saying %q", r.v, err, r.why) + } + } + if _, err := DecodeComposedDigest([]byte(`{"v":2,"digest":"` + digest + `","judgement":"neither"}`)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("version 2 = %v, want ErrUnknownVersion", err) + } + if _, err := DecodeComposedDigest([]byte(`{"v":1,"digest":"` + digest + `","judgement":"perhaps"}`)); err == nil { + t.Errorf("an unknown judgement was read") + } +}