// Package declaration is what the host is told a machine should be. // // Data, never instructions. The vocabulary is finite, versioned, and anything outside it // refuses the whole declaration rather than being skipped — a host that applied most of what // it was sent and reported success is a node that looks configured and is not // (novox/hq ADR 0043). package declaration import ( "bytes" "encoding/json" "fmt" "sort" "strings" ) // Version is the vocabulary this host speaks. A declaration naming any other version is // refused: an older host handed a newer vocabulary must not quietly do half of it. const Version = 1 // Type names a kind of resource. Every addition widens what a compromised control plane can // express, so the list is a security artefact and grows deliberately. type Type string const ( TypeDirectory Type = "directory" TypeFile Type = "file" TypeService Type = "service" ) // known is the whole vocabulary. Anything else is refused. var known = map[Type]bool{ TypeDirectory: true, TypeFile: true, TypeService: true, } // Resource is one thing that should be true of the machine. // // Identity is a name the control plane keeps stable across declarations, not a position and // not a hash of the content. It is what lets the store say *this is the same resource I // applied last time*, which is what makes removal possible at all. type Resource struct { ID string `json:"id"` Type Type `json:"type"` // Path, for a file or directory. Path string `json:"path,omitempty"` // Content, for a file. Literal; the host renders nothing. Content string `json:"content,omitempty"` // Mode, for a file or directory, as an octal string such as "0644". Mode string `json:"mode,omitempty"` // Unit and State, for a service. State is "running" or "stopped". Unit string `json:"unit,omitempty"` State string `json:"state,omitempty"` } // Declaration is what a machine should be, in the order it should be made so. type Declaration struct { Version int `json:"declaration"` // For names the node this is meant for. A host with an identity refuses one addressed // elsewhere; a host without one — the first node, applying the bundle it carries — has // nothing to check against. For string `json:"for,omitempty"` // Resources, in the order they are applied. The host does not sort them: ordering is a // decision, and deciding is not what the host does (novox/hq ADR 0037). Resources []Resource `json:"resources"` } // RefusalError refuses a whole declaration, naming every problem at once. // // Every problem rather than the first: a caller fixing one at a time learns the next only by // running again, and a declaration is generated, so a person reading this is debugging the // generator. type RefusalError struct { Problems []string } func (e *RefusalError) Error() string { return fmt.Sprintf( "this declaration is refused, and none of it was applied:\n - %s\n\n"+ "A host that applied the parts it understood would leave a machine that looks "+ "configured and is not.", strings.Join(e.Problems, "\n - ")) } // Parse reads a declaration and refuses anything it does not fully understand. func Parse(raw []byte) (*Declaration, error) { // DisallowUnknownFields is the whole point rather than strictness for its own sake: a // field the host does not know is a thing the control plane believes it asked for. dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() var d Declaration if err := dec.Decode(&d); err != nil { return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}} } if problems := validate(&d); len(problems) > 0 { return nil, &RefusalError{Problems: problems} } return &d, nil } func validate(d *Declaration) []string { var problems []string if d.Version != Version { problems = append(problems, fmt.Sprintf( "declaration version %d; this host speaks version %d. Refused whole rather than "+ "partly, so a newer vocabulary is never half-applied by an older host", d.Version, Version)) // Everything below assumes the vocabulary, so there is nothing further to say. return problems } if len(d.Resources) == 0 { problems = append(problems, "no resources. An empty declaration is a mistake, not a "+ "machine with nothing on it — say so with an explicit empty list if that is meant") } seen := map[string]int{} for i, r := range d.Resources { where := fmt.Sprintf("resource %d", i) if r.ID != "" { where = fmt.Sprintf("resource %q", r.ID) } if r.ID == "" { problems = append(problems, where+": no id. Identity is what lets the host know "+ "this is the same resource it applied last time") } else if first, ok := seen[r.ID]; ok { problems = append(problems, fmt.Sprintf( "%s: id already used by resource %d. Two resources with one identity cannot "+ "both be tracked", where, first)) } else { seen[r.ID] = i } if !known[r.Type] { problems = append(problems, fmt.Sprintf( "%s: unknown type %q. This host understands %s", where, r.Type, vocabulary())) continue } problems = append(problems, validateResource(where, r)...) } return problems } func validateResource(where string, r Resource) []string { var problems []string switch r.Type { case TypeDirectory: if r.Path == "" { problems = append(problems, where+": a directory needs a path") } problems = append(problems, checkMode(where, r.Mode)...) problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State, "content", r.Content)...) case TypeFile: if r.Path == "" { problems = append(problems, where+": a file needs a path") } problems = append(problems, checkMode(where, r.Mode)...) problems = append(problems, unusedBy(where, r, "unit", r.Unit, "state", r.State)...) case TypeService: if r.Unit == "" { problems = append(problems, where+": a service needs a unit") } if r.State != "running" && r.State != "stopped" { problems = append(problems, fmt.Sprintf( "%s: state %q; a service is \"running\" or \"stopped\"", where, r.State)) } problems = append(problems, unusedBy(where, r, "path", r.Path, "content", r.Content, "mode", r.Mode)...) } return problems } // unusedBy refuses a field this type does not use. // // A field set and ignored is the fault this package exists to prevent, in miniature: the // control plane believes it asked for something the host will never do. func unusedBy(where string, r Resource, pairs ...string) []string { var problems []string for i := 0; i+1 < len(pairs); i += 2 { if pairs[i+1] != "" { problems = append(problems, fmt.Sprintf( "%s: a %s does not use %q, and it is set. Refused rather than ignored", where, r.Type, pairs[i])) } } return problems } func checkMode(where, mode string) []string { if mode == "" { return nil } if len(mode) != 4 || mode[0] != '0' { return []string{fmt.Sprintf( "%s: mode %q; write it as four octal digits such as \"0644\", so it means the "+ "same thing here as it does in the manifest it came from", where, mode)} } for _, c := range mode[1:] { if c < '0' || c > '7' { return []string{fmt.Sprintf("%s: mode %q is not octal", where, mode)} } } return nil } func vocabulary() string { var names []string for t := range known { names = append(names, string(t)) } sort.Strings(names) return strings.Join(names, ", ") }