A step's key keeps its answer only within what the steps state holds per value (kvlimit.ModuleStateValueBytes less the headers' room). Over it, finish keeps the answer with the holder's KeepOutput as the object output.<ask> in its outputs store and the key names it in output-object: a check's whole answer, its result emptied; a run's output, the key keeping the run answer without it. An answer that cannot be kept ends the key failed, saying why. The Puller says step-ended from the end finish wrote, so the key and the event say one outcome: a run whose program exited other than 0 ends its key failed as well, its answer kept; step-ended names the object finish kept instead of putting it twice. Step.Whole reads a key's object back through the seat's verb output on the step's node. The part build-step's Step gains output-object, the verb output says it answers a step's answer; fixtures and the generated contracts follow. Builds: design 47 G A-290, A-254, §467 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
235 lines
8.6 KiB
Go
235 lines
8.6 KiB
Go
package steprun
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
sdkstep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
|
|
)
|
|
|
|
// 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][]sdkstep.Step
|
|
refused int
|
|
}
|
|
|
|
func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]sdkstep.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 := sdkstep.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) (sdkstep.Step, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t := s.took[key]
|
|
if len(t) == 0 {
|
|
return sdkstep.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(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit},
|
|
Fetch: sdkstep.Fetch{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, nil, "shanks.a1", sdkstep.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: sdkstep.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, ok := st.last("shanks.a1")
|
|
if !ok || s.State != sdkstep.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 sdkstep.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 := []sdkstep.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 []sdkstep.Step
|
|
put := func(key string, value any) (uint64, error) {
|
|
st, _ := sdkstep.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, nil, "shanks.a2", sdkstep.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: sdkstep.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 != sdkstep.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, nil, "shanks.a3", sdkstep.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(sdkstep.AskCheck)
|
|
_ = src.PublishAsk(a)
|
|
var mu sync.Mutex
|
|
var ends []sdkstep.Step
|
|
put := func(key string, value any) (uint64, error) {
|
|
st, err := sdkstep.DecodeStep(value.(json.RawMessage))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if st.State != sdkstep.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: sdkstep.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 },
|
|
PullWait: 10 * time.Millisecond, Logf: t.Logf,
|
|
Run: func(ctx context.Context, a sdkstep.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 != sdkstep.StepFailed || !strings.Contains(last.Reason, "refused") {
|
|
t.Errorf("the end %+v, want the small failed end", last)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|