diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks.go b/modules/mesh-build-image/cmd/mesh-build-image/asks.go index 015c25ab1..ca33045ad 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/asks.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks.go @@ -77,7 +77,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep // SeatTools are the seat's verbs on this node (ADR 0525 §10). func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { - return buildstep.SeatTools(Seat, "image", p) + return append(buildstep.SeatTools(Seat, "image", p), stepsTool()) } // StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go index f3c2cd6e2..8419a7a08 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go @@ -125,7 +125,7 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes if want := []string{"keys", MirrorWaitsState, "pauses", "steps"}; !slices.Equal(states, want) { t.Errorf("state = %v, want %v", states, want) } - if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "drop", "pause", "resume"}) { + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "drop", "pause", "resume", "steps"}) { t.Errorf("claims = %v, want the seat %s alone", m.Claims, Seat) } raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json")) diff --git a/modules/mesh-build-image/cmd/mesh-build-image/steps.go b/modules/mesh-build-image/cmd/mesh-build-image/steps.go new file mode 100644 index 000000000..e62ff9364 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/steps.go @@ -0,0 +1,41 @@ +package main + +// **The verb `steps`** (design 47 G §22, §307, §328): an image has no lint, unit or integration test step, so `steps` +// answers none for it and the seed has those steps `bypass` (*A module's registration*). The answer is the same for +// every fingerprint, so nothing is kept for it. + +import ( + "fmt" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// StepsAnswer is what `steps` answers. +type StepsAnswer struct { + Artifact string `json:"artifact"` + Fingerprint string `json:"fingerprint,omitempty"` + Steps []seat.DetectedStep `json:"steps"` +} + +// stepsTool is the seat's verb `steps`. +func stepsTool() stdio.Tool { + return stdio.Tool{Name: Seat + "." + seat.VerbSteps, + Description: "Which pipeline steps an image artifact can run: none (an image has no lint, unit or integration test step).", + Input: map[string]any{"type": "object", "required": []string{"module", "artifact"}, "properties": map[string]any{ + "module": map[string]any{"type": "string"}, + "artifact": map[string]any{"type": "string", "description": "the artifact reference, :"}, + "fingerprint": map[string]any{"type": "string"}, + "fetch": map[string]any{"type": "object", "description": "not read: an image's steps need no tree"}, + "again": map[string]any{"type": "boolean", "description": "not read: the answer is the same anew"}, + }}, + Run: steps} +} + +func steps(args map[string]any) (any, error) { + str := func(k string) string { s, _ := args[k].(string); return s } + if str("artifact") == "" || str("module") == "" { + return nil, fmt.Errorf("steps: the module and the artifact are asked") + } + return StepsAnswer{Artifact: str("artifact"), Fingerprint: str("fingerprint"), Steps: []seat.DetectedStep{}}, nil +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/steps_test.go b/modules/mesh-build-image/cmd/mesh-build-image/steps_test.go new file mode 100644 index 000000000..b24fea3e4 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/steps_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +// Design 47 G §22, §328 (BLD8): the verb steps is served and answers none for an image. +func TestTheStepsVerbAnswersNoStepForAnImage(t *testing.T) { + served := false + for _, tool := range (&Module{}).SeatTools(nil) { + if tool.Name == Seat+".steps" { + served = true + got, err := tool.Run(map[string]any{"module": "m", "artifact": "m:server", "fingerprint": "fp"}) + a, ok := got.(StepsAnswer) + if err != nil || !ok || a.Artifact != "m:server" || a.Steps == nil || len(a.Steps) != 0 { + t.Errorf("steps = %+v (%v)", got, err) + } + if _, err := tool.Run(map[string]any{"module": "m"}); err == nil { + t.Error("an ask with no artifact was answered") + } + } + } + if !served { + t.Fatal("the module serves no verb steps") + } +} diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index ec0de87d9..be406b135 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -21,7 +21,7 @@ { "name": "node-build-image", "scope": "node", - "serves": ["current", "drop", "pause", "resume"] + "serves": ["current", "drop", "pause", "resume", "steps"] } ], "config": { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/announce.go b/modules/mesh-build-node/cmd/mesh-build-node/announce.go index 2163bca16..642d81767 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/announce.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/announce.go @@ -15,6 +15,7 @@ var Kept = []announce.State{ {Name: "keys", Readable: true}, {Name: "steps", Readable: true}, {Name: "pauses", Readable: true}, + {Name: "detected-steps", Readable: true}, } // keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves. diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks.go b/modules/mesh-build-node/cmd/mesh-build-node/asks.go index 93147db9a..5161802e3 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/asks.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks.go @@ -87,7 +87,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep // SeatTools are the seat's verbs on this node (ADR 0525 §10). func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { - return buildstep.SeatTools(Seat, "Node", p) + return append(buildstep.SeatTools(Seat, "Node", p), m.stepsTool()) } // StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes. diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index 772abe1cb..ad83cc12f 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -48,6 +48,8 @@ func main() { Emit: func(t string, b any) error { return stdio.Emit(t, b) }, Steps: stdio.State(seat.StateSteps).Put, ReadSteps: readOf(stdio.State(seat.StateSteps)), + Detected: stdio.State(seat.StateDetectedSteps).Put, + ReadDetected: readOf(stdio.State(seat.StateDetectedSteps)).Get, Pauses: stdio.State(sdkstep.PausesState).Put, ReadPauses: readOf(stdio.State(sdkstep.PausesState)), ForgetPause: stdio.State(sdkstep.PausesState).Delete, diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index 06b8939d9..131d1c15c 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -117,7 +117,7 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) { t.Errorf("state = %v, want %v", states, want) } - if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "drop", "pause", "resume"}) { + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "drop", "pause", "resume", "steps"}) { t.Errorf("claims = %v, want the seat %s alone", m.Claims, Seat) } if !slices.Contains(m.Tools, HealthTool) { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index a98ca9209..f87c17b98 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" stdio "git.novox.be/novox/mesh-sdk/go" "net/http" @@ -47,6 +48,9 @@ type Module struct { // Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test. Held *buildstep.Puller ReadSteps, ReadToolchainsState StateRead + // Detected and ReadDetected are the Put and the read of the state detected-steps (design 47 G §326, §414). + Detected buildstep.Put + ReadDetected func(key string) (json.RawMessage, error) // Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back. Pauses buildstep.Put ReadPauses StateRead diff --git a/modules/mesh-build-node/cmd/mesh-build-node/steps.go b/modules/mesh-build-node/cmd/mesh-build-node/steps.go new file mode 100644 index 000000000..0f109953f --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/steps.go @@ -0,0 +1,159 @@ +package main + +// **The verb `steps`** (design 47 G §22, §307, §326, §328): which pipeline steps a TypeScript artifact can run, by the +// convention check.go tests by. The type-check is the lint step (§328) and is always one; the unit step is the packages +// holding a package.json script `test`, the integration step those holding `test:integration`. A level with none is not +// answered. The answer is kept in the state detected-steps by the artifact's fingerprint, replaced by a newer one and by +// again, as the Go build module keeps it. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// The pipeline steps `steps` answers (the contract's DetectedStep enum). +const ( + StepLint = "lint" + StepUnit = "unit" + StepIntegration = "integration" +) + +// DetectedStep is the contract's. +type DetectedStep = seat.DetectedStep + +// StepsAnswer is what `steps` answers. +type StepsAnswer struct { + Artifact string `json:"artifact"` + Fingerprint string `json:"fingerprint,omitempty"` + Steps []DetectedStep `json:"steps"` + Kept bool `json:"kept,omitempty"` +} + +// detectSteps finds the steps of the package directories under dir. +func detectSteps(dir string) ([]DetectedStep, error) { + targets, err := testTargets(dir, ".") + if err != nil { + return nil, err + } + unit, integration := []string{}, []string{} + for _, tg := range targets { + scripts, err := packageScripts(tg.dir) + if err != nil { + return nil, err + } + if scripts["test"] != "" { + unit = append(unit, tg.where) + } + if scripts["test:integration"] != "" { + integration = append(integration, tg.where) + } + } + steps := []DetectedStep{{Step: StepLint}} + for _, l := range []struct { + step string + pkgs []string + }{{StepUnit, unit}, {StepIntegration, integration}} { + if len(l.pkgs) > 0 { + sort.Strings(l.pkgs) + steps = append(steps, DetectedStep{Step: l.step, Packages: l.pkgs}) + } + } + return steps, nil +} + +// stepsTool is the seat's verb `steps`. +func (m *Module) stepsTool() stdio.Tool { + return stdio.Tool{Name: Seat + "." + seat.VerbSteps, + Description: "Which pipeline steps a TypeScript artifact can run: the type-check as lint, always; unit tests where a package has a test script; " + + "integration tests where one has a test:integration script. A level with no test is not answered.", + Input: map[string]any{"type": "object", "required": []string{"module", "artifact", "fetch"}, "properties": map[string]any{ + "module": map[string]any{"type": "string"}, + "artifact": map[string]any{"type": "string", "description": "the artifact reference, :"}, + "fingerprint": map[string]any{"type": "string"}, + "fetch": map[string]any{"type": "object", "description": "what the fetch tool is asked: the repository, the full commit and the module's directory"}, + "again": map[string]any{"type": "boolean", "description": "find them anew, not from the kept answer"}, + }}, + Run: m.Short(m.steps)} +} + +func (m *Module) steps(ctx context.Context, args map[string]any) (any, error) { + str := func(k string) string { s, _ := args[k].(string); return s } + fetch, _ := args["fetch"].(map[string]any) + req, err := sdkstep.ParseFetchRequest(fetch) + if err != nil { + return nil, err + } + if str("artifact") == "" || str("module") == "" { + return nil, fmt.Errorf("steps: the module and the artifact are asked") + } + artifact, fp := str("artifact"), str("fingerprint") + name := artifact + if _, n, ok := strings.Cut(artifact, ":"); ok { + name = n + } + key := seat.DetectedStepsByModuleArtifactKey(str("module"), name) + if again, _ := args["again"].(bool); !again && fp != "" && m.ReadDetected != nil { + if raw, err := m.ReadDetected(key); err == nil && len(raw) > 0 { + if kept, err := seat.DecodeDetectedStepsByModuleArtifactValue(raw); err == nil && kept.Fingerprint == fp { + return StepsAnswer{Artifact: artifact, Fingerprint: fp, Steps: kept.Steps, Kept: true}, nil + } + } + } + steps, err := m.stepsOfTree(ctx, req) + if err != nil { + return nil, err + } + if fp != "" && m.Detected != nil { + at := time.Now() + if m.Clock != nil { + at = m.Clock() + } + if _, err := m.Detected(key, seat.DetectedSteps{V: 1, Fingerprint: fp, Steps: steps, At: &at}); err != nil { + return nil, fmt.Errorf("steps: keeping the answer: %w", err) + } + } + return StepsAnswer{Artifact: artifact, Fingerprint: fp, Steps: steps}, nil +} + +// stepsOfTree fetches the commit alone (nothing installed) into a workspace removed at the end. +func (m *Module) stepsOfTree(ctx context.Context, req sdkstep.FetchRequest) ([]DetectedStep, error) { + if err := os.MkdirAll(m.Work, 0o700); err != nil { + return nil, err + } + root, err := os.MkdirTemp(m.Work, "steps-") + if err != nil { + return nil, err + } + defer os.RemoveAll(root) + tree := filepath.Join(root, "tree") + if err := os.MkdirAll(tree, 0o700); err != nil { + return nil, err + } + gitRun := func(ctx context.Context, env []string, name string, a ...string) forgekey.Ran { + return m.Run(ctx, append(m.baseEnv(), env...), name, a...) + } + if err := buildstep.FetchCommit(ctx, gitRun, tree, m.remote(req.Repository), req.Commit, req.Ref, m.FetchOptions...); err != nil { + return nil, err + } + if len(req.Paths) > 0 { + if err := buildstep.Narrow(tree, req); err != nil { + return nil, err + } + } + dir := tree + if req.Dir != "" && req.Dir != "." { + dir = filepath.Join(tree, filepath.FromSlash(req.Dir)) + } + return detectSteps(dir) +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/steps_test.go b/modules/mesh-build-node/cmd/mesh-build-node/steps_test.go new file mode 100644 index 000000000..0e7accaaa --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/steps_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" +) + +// Design 47 G §22, §307, §328 (BLD7): steps answers the type-check as lint always, the packages with a test script as +// unit, those with test:integration as integration, and leaves out a level with none. +func TestTheStepsOfATypeScriptArtifactAreFoundByItsScripts(t *testing.T) { + put := func(dir, name, body string) { + if err := os.MkdirAll(filepath.Join(dir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for name, c := range map[string]struct { + pkg string + want []DetectedStep + }{ + "both levels": {`{"scripts":{"test":"node t.js","test:integration":"node i.js"}}`, + []DetectedStep{{Step: "lint"}, {Step: "unit", Packages: []string{"."}}, {Step: "integration", Packages: []string{"."}}}}, + "unit only": {`{"scripts":{"test":"node t.js"}}`, []DetectedStep{{Step: "lint"}, {Step: "unit", Packages: []string{"."}}}}, + "no script": {`{"scripts":{}}`, []DetectedStep{{Step: "lint"}}}, + } { + dir := t.TempDir() + put(dir, "package.json", c.pkg) + got, err := detectSteps(dir) + if err != nil || !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: steps = %+v (%v), want %+v", name, got, err, c.want) + } + } +} + +// The verb is served beside the seat's others and refuses an ask that names no artifact. +func TestTheStepsVerbIsServedAndRefusesAnAskWithoutAnArtifact(t *testing.T) { + m, _, _ := newMachine(t) + served := false + for _, tool := range m.SeatTools(m.Puller(nil, nil)) { + served = served || tool.Name == Seat+".steps" + } + if !served { + t.Fatal("the module serves no verb steps") + } + if _, err := m.steps(context.Background(), map[string]any{"module": "m", "fetch": map[string]any{"repository": "novox/m", "commit": "0123456789abcdef0123456789abcdef01234567"}}); err == nil { + t.Error("an ask with no artifact was answered") + } +} diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index 0e84060bf..4d9a5936b 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -21,7 +21,7 @@ { "name": "node-build-node", "scope": "node", - "serves": ["current", "drop", "pause", "resume"] + "serves": ["current", "drop", "pause", "resume", "steps"] } ], "config": { @@ -65,6 +65,7 @@ } ], "requires": [ + {"name": "detected-steps", "storage": "key-value", "template": "state", "options": {"history": 1}}, "forge-account", "npm-package-registry", "node-toolchain",