The runner, the toolchain guard, the fetch, the parsers, buildcpu, the conformance kit and mesh-pack move from mesh-sdk's buildstep into modules/mesh-build-go/buildstep (own go.mod). mesh-build-go, mesh-build-node and mesh-build-image require it by a same-repository replace; what the delivery parts read (the failure contract, the fetch request, the stale sweep, the module tree shape, NewID, CapacitySetting) stays imported from mesh-sdk as sdkstep. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
235 lines
8.6 KiB
Go
235 lines
8.6 KiB
Go
package buildstep
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq
|
|
// ADR 0201), copied whole: the first field anywhere in the value whose name, lower-cased and without - _ and spaces, ends
|
|
// in a credential word, or "". A step's end is refused when it answers a field.
|
|
func credentialField(value json.RawMessage) string {
|
|
endings := []string{"password", "passwd", "secret", "token", "credential", "credentials",
|
|
"authorization", "apikey", "privatekey", "accesskey", "cookie"}
|
|
var v any
|
|
if json.Unmarshal(value, &v) != nil {
|
|
return ""
|
|
}
|
|
var walk func(any) string
|
|
walk = func(v any) string {
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(t))
|
|
for k := range t {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k))
|
|
for _, end := range endings {
|
|
if strings.HasSuffix(norm, end) {
|
|
return k
|
|
}
|
|
}
|
|
if found := walk(t[k]); found != "" {
|
|
return found
|
|
}
|
|
}
|
|
case []any:
|
|
for _, x := range t {
|
|
if found := walk(x); found != "" {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
return walk(v)
|
|
}
|
|
|
|
// runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took.
|
|
type runtimeState struct {
|
|
mu sync.Mutex
|
|
took map[string][]delivery.Step
|
|
refused int
|
|
}
|
|
|
|
func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]delivery.Step{}} }
|
|
|
|
func (s *runtimeState) put(key string, value any) (uint64, error) {
|
|
raw, _ := value.(json.RawMessage)
|
|
if f := credentialField(raw); f != "" {
|
|
s.mu.Lock()
|
|
s.refused++
|
|
s.mu.Unlock()
|
|
return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f)
|
|
}
|
|
st, err := delivery.DecodeStep(raw)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.took[key] = append(s.took[key], st)
|
|
return uint64(len(s.took[key])), nil
|
|
}
|
|
|
|
func (s *runtimeState) last(key string) (delivery.Step, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t := s.took[key]
|
|
if len(t) == 0 {
|
|
return delivery.Step{}, false
|
|
}
|
|
return t[len(t)-1], true
|
|
}
|
|
|
|
func fastEnds(t *testing.T) {
|
|
attempts, retry := EndAttempts, EndRetry
|
|
EndAttempts, EndRetry = 2, time.Millisecond
|
|
t.Cleanup(func() { EndAttempts, EndRetry = attempts, retry })
|
|
}
|
|
|
|
// 1930/AC1: given a check of a repository with the package .../internal/token, when its ask's answer is stored as the
|
|
// step's end, then no field is keyed by a package path: the runtime's credential rule takes the value, and the package
|
|
// and its digest read back from the stored end.
|
|
func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) {
|
|
fastEnds(t)
|
|
r := &recorder{
|
|
fetch: map[string]any{"workspace": "/w"},
|
|
digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"},
|
|
tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true},
|
|
}
|
|
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit},
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old, _ := json.Marshal(map[string]any{"levels": []any{map[string]any{"level": "unit", "digest": map[string]any{"packages": r.digests}}}})
|
|
if credentialField(old) != "x/internal/token" {
|
|
t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old)
|
|
}
|
|
st := newRuntimeState()
|
|
if err := finish(st.put, "shanks.a1", delivery.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, ok := st.last("shanks.a1")
|
|
if !ok || s.State != delivery.StepPassed || st.refused != 0 {
|
|
t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused)
|
|
}
|
|
var a delivery.CheckAnswer
|
|
if err := json.Unmarshal(s.Result, &a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(a.Levels) != 1 || a.Levels[0].Digest == nil {
|
|
t.Fatalf("levels %+v, want the unit level with its digest", a.Levels)
|
|
}
|
|
want := []delivery.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}}
|
|
if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Errorf("packages %v, want %v", got, want)
|
|
}
|
|
if !strings.Contains(string(s.Result), `"package":"x/internal/token"`) {
|
|
t.Errorf("the stored end does not name the package as a value: %s", s.Result)
|
|
}
|
|
}
|
|
|
|
// 1930/AC2: given an end the state refuses every time, when finish writes it, then it logs the refusal and writes a small
|
|
// failed end without the result that names the write's error; and when no end can be written at all it answers an error.
|
|
func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) {
|
|
fastEnds(t)
|
|
big := errors.New("the value carries a field \"x/internal/token\", which names a credential")
|
|
var refused int
|
|
var stored []delivery.Step
|
|
put := func(key string, value any) (uint64, error) {
|
|
st, _ := delivery.DecodeStep(value.(json.RawMessage))
|
|
if len(st.Result) > 0 {
|
|
refused++
|
|
return 0, big
|
|
}
|
|
stored = append(stored, st)
|
|
return 1, nil
|
|
}
|
|
var logged []string
|
|
logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }
|
|
err := finish(put, "shanks.a2", delivery.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf)
|
|
if err != nil {
|
|
t.Fatalf("finish answered %v, want the small end written", err)
|
|
}
|
|
if refused != EndAttempts || len(stored) != 1 {
|
|
t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts)
|
|
}
|
|
s := stored[0]
|
|
if s.State != delivery.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") ||
|
|
!strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") {
|
|
t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s)
|
|
}
|
|
if len(logged) == 0 || !strings.Contains(logged[0], big.Error()) {
|
|
t.Errorf("logged %q, want the refusal said", logged)
|
|
}
|
|
none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") }
|
|
if err := finish(none, "shanks.a3", delivery.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") {
|
|
t.Errorf("finish with no write possible answered %v, want the error", err)
|
|
}
|
|
}
|
|
|
|
// 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small
|
|
// failed end is written; when no end can be written at all, it is given back, never acknowledged as done.
|
|
func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) {
|
|
fastEnds(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
smallOK bool
|
|
wantAcked bool
|
|
}{{"the small end written", true, true}, {"no end written", false, false}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
src := NewFakeAsks()
|
|
a := testAsk(delivery.AskCheck)
|
|
_ = src.PublishAsk(a)
|
|
var mu sync.Mutex
|
|
var ends []delivery.Step
|
|
put := func(key string, value any) (uint64, error) {
|
|
st, err := delivery.DecodeStep(value.(json.RawMessage))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if st.State != delivery.StepRunning && (len(st.Result) > 0 || !tc.smallOK) {
|
|
return 0, errors.New("refused")
|
|
}
|
|
mu.Lock()
|
|
ends = append(ends, st)
|
|
mu.Unlock()
|
|
return 1, nil
|
|
}
|
|
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 },
|
|
PullWait: 10 * time.Millisecond, Logf: t.Logf,
|
|
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go p.Pull(ctx)
|
|
waitFor(t, "the ask's fate", func() bool { ac, tm, nk := src.Fates(); return len(ac)+len(tm)+len(nk) > 0 })
|
|
cancel()
|
|
acked, termed, naked := src.Fates()
|
|
if tc.wantAcked && (len(acked) != 1 || len(naked) != 0) || !tc.wantAcked && (len(acked) != 0 || len(naked) == 0) || len(termed) != 0 {
|
|
t.Fatalf("acked %v naked %v termed %v", acked, naked, termed)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if tc.smallOK {
|
|
last := ends[len(ends)-1]
|
|
if last.State != delivery.StepFailed || !strings.Contains(last.Reason, "refused") {
|
|
t.Errorf("the end %+v, want the small failed end", last)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|