Files
mesh-sdk/go/delivery/placement_candidate.go
T

212 lines
8.7 KiB
Go

package delivery
import (
"encoding/json"
"fmt"
"strings"
)
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`.
//
// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node`
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's
// write is the node's event, the `<node>.node` change the check's.
const (
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket.
PlacementCandidatesState = "placement-candidates"
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState
// PlacementCandidatesHistory is the revisions the bucket keeps per key.
PlacementCandidatesHistory = 5
)
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group:
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are
// `<group>.*`.
func PlacementCandidateKey(group, node string) (string, error) {
g, err := groupKey(group)
if err != nil {
return "", err
}
if err := checkName("node", node); err != nil {
return "", err
}
return g + "." + node, nil
}
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
func ParsePlacementCandidateKey(key string) (group, node string, err error) {
g, node, ok := strings.Cut(key, ".")
if !ok || checkName("node", node) != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key)
}
if group, err = parseGroupKey(g); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
}
return group, node, nil
}
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file.
type PlacementCandidate struct {
V int `json:"v"`
Group string `json:"group"`
Pull Head `json:"pull"`
Placement *Placement `json:"placement,omitempty"`
}
func (c PlacementCandidate) check() error {
if c.Group == "" {
return fmt.Errorf("delivery: a placement candidate names no group")
}
if err := checkRepository(c.Pull.Repository); err != nil {
return err
}
if c.Pull.Number <= 0 || c.Pull.Head == "" {
return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head")
}
if c.Placement != nil {
return c.Placement.check()
}
return nil
}
// EncodePlacementCandidate is c as JSON at Version; it refuses what DecodePlacementCandidate refuses.
func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
if err := c.check(); err != nil {
return nil, err
}
c.V = Version
if c.Placement != nil {
p := *c.Placement
p.V = Version
c.Placement = &p
}
return json.Marshal(c)
}
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or
// head, and a placement DecodePlacement refuses.
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
var c PlacementCandidate
if err := decode(data, &c); err != nil {
return PlacementCandidate{}, err
}
if err := c.check(); err != nil {
return PlacementCandidate{}, err
}
return c, nil
}
// The verdicts of a node's answer to a candidate.
const (
// CandidateSame: every composition the node composes with the candidate is the one it runs, and it runs no other.
CandidateSame = "same"
// CandidateDiffers: some composition differs (Differs), is added by the candidate (Added) or removed by it (Removed);
// whether the change intends it is the assigner's to judge (ADR 0559 §5).
CandidateDiffers = "differs"
// CandidateRefused: a value is missing for a module the node runs (Refusals), or the node cannot compose with the
// candidate at all (Refused says why: an unbound requirement, a manifest it cannot read).
CandidateRefused = "refused"
)
// CandidateAnswer is a node's answer to its group's candidate keys, in HaveNode.Candidates (ADR 0559 §4): the head
// and the revision of the node's own key in the group it answered (0 when the group has no key of its own for it: its
// file is unchanged and it answers as placed at the head or on the trunk, judged by Head alone), its verdict, the modules composed the same, those that differ with both digests, those
// the candidate adds (naming the values not yet issued for each, which is no refusal: a module's credential and boxes
// are made only once it is placed, ADR 0549 §5) or removes, and each refusal. The node's own composition is named
// "node" (HaveNodeWord).
type CandidateAnswer struct {
Head string `json:"head"`
Revision uint64 `json:"revision"`
Verdict string `json:"verdict"`
Same []string `json:"same,omitempty"`
Differs []ModuleDiffers `json:"differs,omitempty"`
Added []ModuleAdded `json:"added,omitempty"`
Removed []string `json:"removed,omitempty"`
Refusals []ModuleRefused `json:"refusals,omitempty"`
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
Refused string `json:"refused,omitempty"`
}
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
// when every resource is equal, and why in words.
type ModuleDiffers struct {
Module string `json:"module"`
Composed string `json:"composed,omitempty"`
Running string `json:"running,omitempty"`
Resource string `json:"resource,omitempty"`
Why string `json:"why"`
}
// ModuleAdded is one module the candidate places on the node and the node does not run: its composition's digest when
// it composed, and the values not yet issued for it (its own secrets, its grants, a host port), each in the words the
// composition stood in for it with.
type ModuleAdded struct {
Module string `json:"module"`
Composed string `json:"composed,omitempty"`
Unissued []string `json:"unissued,omitempty"`
}
// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with.
type ModuleRefused struct {
Module string `json:"module"`
Why string `json:"why"`
}
// VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or
// removal, same otherwise.
func VerdictOf(a CandidateAnswer) string {
switch {
case a.Refused != "" || len(a.Refusals) > 0:
return CandidateRefused
case len(a.Differs)+len(a.Added)+len(a.Removed) > 0:
return CandidateDiffers
}
return CandidateSame
}
func (a CandidateAnswer) check() error {
if a.Head == "" {
return fmt.Errorf("delivery: a candidate answer names no head")
}
switch a.Verdict {
case CandidateSame, CandidateDiffers, CandidateRefused:
default:
return fmt.Errorf("delivery: the candidate verdict %q is not same, differs or refused", a.Verdict)
}
if got := VerdictOf(a); got != a.Verdict {
return fmt.Errorf("delivery: a candidate answered %s names what makes it %s", a.Verdict, got)
}
if a.Refused != "" && len(a.Same)+len(a.Differs)+len(a.Added)+len(a.Removed)+len(a.Refusals) > 0 {
return fmt.Errorf("delivery: a candidate the node could not compose names modules")
}
for _, d := range a.Differs {
if d.Module == "" || d.Why == "" {
return fmt.Errorf("delivery: a candidate difference names no module or no why")
}
}
for _, d := range a.Added {
if d.Module == "" {
return fmt.Errorf("delivery: a module the candidate adds is not named")
}
}
for _, d := range a.Refusals {
if d.Module == "" || d.Why == "" {
return fmt.Errorf("delivery: a candidate refusal names no module or no why")
}
}
for _, m := range a.Removed {
if m == "" {
return fmt.Errorf("delivery: a module the candidate removes is not named")
}
}
return nil
}