Answer the what-if as data, let the build seat read the gate's pull keys, and prove the parts' and the gate's grants (design 47 R3d, ADR 0450) #401
@@ -784,6 +784,27 @@ func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[s
|
||||
return out
|
||||
}
|
||||
|
||||
// changeMember is one change as the planner is asked about it, the same for `delivery plan` and `plans --what-if`.
|
||||
func changeMember(repository, base, head string, paths, removed []string, moduleDirs *[]string) orderMember {
|
||||
m := orderMember{ID: repository + "@" + head, Repository: repository, Base: base, Head: head,
|
||||
Paths: paths, Removed: removed}
|
||||
if moduleDirs != nil {
|
||||
m.ModuleDirs, m.ModuleDirsSaid = *moduleDirs, true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// deliveryPlanDocument is the answer of `delivery plan` and of `plans --what-if … --data`: one change's
|
||||
// change plan with its reach, the mesh's own part in it and whether the gate would run. One function, so
|
||||
// the two verbs cannot differ (novox/hq ADR 0450 §2, issue 1324).
|
||||
func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
||||
edges []inventory.Edge, policy policyOf) map[string]any {
|
||||
s := pullScope(m.pull(), entries, read, edges)
|
||||
plan := changePlanOf(m.Repository, m.Base, m.Head, s.Reach, entries, policy)
|
||||
return map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
|
||||
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()}
|
||||
}
|
||||
|
||||
// deliveryCommand is `delivery`, the controller's verbs for the delivery's owner:
|
||||
//
|
||||
// delivery plan --repository owner/repo --head <commit> [--base main] --paths a,b [--module-dirs …] [--removed …]
|
||||
@@ -855,22 +876,15 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
if *repository == "" || *head == "" && *paths == "" {
|
||||
return errors.New("delivery plan --repository owner/repo --head <commit> --paths a,b")
|
||||
}
|
||||
m := orderMember{ID: *repository + "@" + *head, Repository: *repository, Base: *base, Head: *head,
|
||||
Paths: splitList(*paths), Removed: splitList(*removed)}
|
||||
if d := moduleDirsOf(*moduleDirs); d != nil {
|
||||
m.ModuleDirs, m.ModuleDirsSaid = *d, true
|
||||
}
|
||||
m := changeMember(*repository, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
|
||||
entries, read, edges, err := theGraph(ctx, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := pullScope(m.pull(), entries, read, edges)
|
||||
plan := changePlanOf(*repository, *base, *head, s.Reach, entries, func(module string) (inventory.Upgrade, bool) {
|
||||
return answer(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
|
||||
u, err := inv.UpgradeOf(ctx, module)
|
||||
return u, err == nil
|
||||
})
|
||||
return answer(map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
|
||||
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()})
|
||||
}))
|
||||
case "order":
|
||||
if len(members) == 0 {
|
||||
return errors.New("delivery order --members <json>: a group has members")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// flagsOf reads a command line's flags (and its first positional after --what-if) into a map.
|
||||
func flagsOf(argv []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for i := 0; i < len(argv); i++ {
|
||||
if !strings.HasPrefix(argv[i], "--") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(argv[i], "--")
|
||||
if i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "--") {
|
||||
out[name] = argv[i+1]
|
||||
i++
|
||||
} else {
|
||||
out[name] = "true"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// **The plan of a change as data from `plans`** (novox/hq ADR 0450 §2, issue 1324 AC1): asked for one change
|
||||
// both ways, `plans` with data and `delivery-plan` answer the same plan in every field.
|
||||
func TestPlansWhatIfAsDataAnswersWhatDeliveryPlanAnswers(t *testing.T) {
|
||||
change := map[string]any{"repository": "novox/mesh-catalog", "paths": "modules/gitea/index.ts,modules/gitea/old.txt",
|
||||
"head": "abc123", "base": "main", "module-dirs": "modules/gitea", "removed": "modules/gitea/old.txt"}
|
||||
with := func(extra map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range change {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
plansArgv, err := argvFor("plans", with(map[string]any{"data": "true"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deliveryArgv, err := argvFor("delivery-plan", change)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plansArgv[0] != "plans" || !slicesHas(plansArgv, "--data") || !slicesHas(plansArgv, "--what-if") {
|
||||
t.Fatalf("plans with data became %q", strings.Join(plansArgv, " "))
|
||||
}
|
||||
p, d := flagsOf(plansArgv), flagsOf(deliveryArgv)
|
||||
for _, name := range []string{"paths", "head", "base", "module-dirs", "removed"} {
|
||||
if p[name] == "" || p[name] != d[name] {
|
||||
t.Errorf("--%s: plans says %q, delivery-plan %q", name, p[name], d[name])
|
||||
}
|
||||
}
|
||||
if p["what-if"] != d["repository"] {
|
||||
t.Errorf("repository: plans says %q, delivery-plan %q", p["what-if"], d["repository"])
|
||||
}
|
||||
|
||||
// Both commands build their document from the one function, from the same member.
|
||||
const src = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||
gitea := fromRepo("gitea", src, "modules/gitea")
|
||||
gitea.Source.BuiltFrom = "old"
|
||||
gitea.On = []string{"anchor", "laptop"}
|
||||
entries := []inventory.Entry{gitea}
|
||||
policy := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true }
|
||||
member := func(f map[string]string, repository string) orderMember {
|
||||
return changeMember(repository, f["base"], f["head"], splitList(f["paths"]), splitList(f["removed"]),
|
||||
moduleDirsOf(f["module-dirs"]))
|
||||
}
|
||||
a := deliveryPlanDocument(member(p, p["what-if"]), entries, nil, nil, policy)
|
||||
b := deliveryPlanDocument(member(d, d["repository"]), entries, nil, nil, policy)
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
if !reflect.DeepEqual(ja, jb) {
|
||||
t.Errorf("the two plans differ:\n%s\n%s", ja, jb)
|
||||
}
|
||||
plan := a["plan"].(interface{})
|
||||
raw, _ := json.Marshal(plan)
|
||||
var fields map[string]any
|
||||
_ = json.Unmarshal(raw, &fields)
|
||||
for _, want := range []string{"moved", "tiers", "machines", "summary", "class", "head", "base", "repository"} {
|
||||
if _, ok := fields[want]; !ok {
|
||||
t.Errorf("the plan has no %q: %s", want, raw)
|
||||
}
|
||||
}
|
||||
if fields["head"] != "abc123" || fields["base"] != "main" {
|
||||
t.Errorf("head and base are %v and %v", fields["head"], fields["base"])
|
||||
}
|
||||
}
|
||||
|
||||
// Without the data form the text answer is unchanged (AC2): no data flag, and the data-only arguments are not passed.
|
||||
func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) {
|
||||
argv, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a,b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a,b" {
|
||||
t.Errorf("the text what-if reads %q", got)
|
||||
}
|
||||
if _, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"}); err == nil {
|
||||
t.Error("a head without data was passed over, not refused")
|
||||
}
|
||||
argv, _ = argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "data": "false"})
|
||||
if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a" {
|
||||
t.Errorf("data false reads %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// delivery-plan stays and answers as before (AC3, ADR 0450 §8).
|
||||
func TestDeliveryPlanStaysAsItWas(t *testing.T) {
|
||||
argv, err := argvFor("delivery-plan", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(argv, " "); got != "delivery plan --repository novox/x --paths a --head h" {
|
||||
t.Errorf("delivery-plan reads %q", got)
|
||||
}
|
||||
if _, ok := catalogueVerb("delivery-plan"); !ok {
|
||||
t.Error("delivery-plan is gone")
|
||||
}
|
||||
}
|
||||
|
||||
func catalogueVerb(name string) (catalogue.Verb, bool) { return controllerVerb(name) }
|
||||
|
||||
func slicesHas(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -2037,6 +2038,12 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
modules := set.String("modules", "", "or the modules it would change, comma-separated")
|
||||
moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+
|
||||
"comma-separated, as the forge's announcer says them (novox/hq issue 278)")
|
||||
// The what-if as data, the document `delivery plan` answers (novox/hq ADR 0450 §2, issue 1324).
|
||||
data := set.Bool("data", false, "with --what-if: answer the change plan as data, the document `delivery plan` answers; "+
|
||||
"without it the answer is the text a person reads")
|
||||
head := set.String("head", "", "with --data: the commit at hand")
|
||||
base := set.String("base", "main", "with --data: the branch it merges into")
|
||||
removed := set.String("removed", "", "with --data: the files among --paths it deletes, comma-separated")
|
||||
// Following a plan (novox/hq issue 229): `plans wait <id>`.
|
||||
until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)")
|
||||
bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+
|
||||
@@ -2152,6 +2159,25 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if *data && *whatIf == "" {
|
||||
return errors.New("--data answers a what-if: give --what-if owner/repository")
|
||||
}
|
||||
if *whatIf != "" && *data {
|
||||
if *paths == "" {
|
||||
return errors.New("plans --what-if --data: the change plan is of the files a change touches; give --paths")
|
||||
}
|
||||
entries, read, edges, err := theGraph(ctx, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := changeMember(*whatIf, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
|
||||
u, err := inv.UpgradeOf(ctx, module)
|
||||
return u, err == nil
|
||||
}))
|
||||
}
|
||||
if *whatIf != "" {
|
||||
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules), moduleDirsOf(*moduleDirs))
|
||||
}
|
||||
|
||||
@@ -352,6 +352,15 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
if d := str("module-dirs"); d != "" {
|
||||
argv = append(argv, "--module-dirs", d)
|
||||
}
|
||||
// The plan as data, the document delivery-plan answers (novox/hq ADR 0450 §2, issue 1324).
|
||||
if str("data") == "true" {
|
||||
argv = append(argv, "--data")
|
||||
for _, flag := range []string{"head", "base", "removed"} {
|
||||
if v := str(flag); v != "" {
|
||||
argv = append(argv, "--"+flag, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
for _, act := range []string{"stop", "close", "retry", "go"} {
|
||||
|
||||
@@ -135,10 +135,16 @@ var ControllerVerbs = []Verb{
|
||||
"modules": "with repository: or the modules it would change, comma-separated",
|
||||
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
|
||||
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
|
||||
"limit": "how many plans to list (default 10); only when listing",
|
||||
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
|
||||
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
|
||||
}, nil)},
|
||||
"data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " +
|
||||
"(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " +
|
||||
"without it the answer is text (novox/hq ADR 0450 §2)",
|
||||
"head": "with data: the commit at hand",
|
||||
"base": "with data: the branch it merges into (default main)",
|
||||
"removed": "with data: the files among paths the change deletes, comma-separated",
|
||||
"limit": "how many plans to list (default 10); only when listing",
|
||||
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
|
||||
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
|
||||
}, nil, "data")},
|
||||
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
|
||||
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
|
||||
{Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +
|
||||
|
||||
Reference in New Issue
Block a user