result.go

  1package engine
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"maps"
  9	"os"
 10	"path/filepath"
 11	"strings"
 12
 13	"git.kilimanjaro.io/ci/internal/lex"
 14	"git.kilimanjaro.io/ci/internal/manifest"
 15)
 16
 17// ReadResult reads the result an action left at path and checks it against
 18// the contract (DESIGN.md ยง7). A missing or empty file falls back to the exit
 19// code. Any contract violation is returned as an error; the caller turns it
 20// into a failure.
 21func ReadResult(m *manifest.Manifest, path string, exitCode int, artifacts string) (lex.Result, error) {
 22	b, err := os.ReadFile(path)
 23	if errors.Is(err, os.ErrNotExist) || (err == nil && len(bytes.TrimSpace(b)) == 0) {
 24		if exitCode == 0 {
 25			return lex.Result{Version: lex.ResultVersion, Status: lex.Success}, nil
 26		}
 27		return lex.Result{Version: lex.ResultVersion, Status: lex.Failure,
 28			Summary: fmt.Sprintf("exited with status %d", exitCode)}, nil
 29	}
 30	if err != nil {
 31		return lex.Result{}, err
 32	}
 33	var r lex.Result
 34	if err := lex.StrictUnmarshal(b, &r); err != nil {
 35		return lex.Result{}, fmt.Errorf("invalid result: %w", err)
 36	}
 37	return r, CheckResult(m, r, exitCode, artifacts)
 38}
 39
 40// CheckResult validates a decoded result.
 41func CheckResult(m *manifest.Manifest, r lex.Result, exitCode int, artifacts string) error {
 42	var errs []error
 43	bad := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
 44	if r.Version != lex.ResultVersion {
 45		bad("version %d (this engine speaks %d)", r.Version, lex.ResultVersion)
 46	}
 47	switch r.Status {
 48	case lex.Success, lex.Warning:
 49		if exitCode != 0 {
 50			bad("exited with status %d but reported %s", exitCode, r.Status)
 51		}
 52	case lex.Failure:
 53	default:
 54		bad("status %q (want success, warning or failure)", r.Status)
 55	}
 56	if r.Next != nil {
 57		for i, o := range r.Next.Options {
 58			if _, ok := m.Actions[o.Action]; !ok {
 59				bad("next[%d]: %q is not a declared action", i, o.Action)
 60			}
 61			if o.Then != "" && o.Then != lex.ThenContinue && o.Then != lex.ThenStop {
 62				bad("next[%d].then: %q (want continue or stop)", i, o.Then)
 63			}
 64			if len(o.Inputs) > 0 && !isObject(o.Inputs) {
 65				bad("next[%d].inputs: must be an object", i)
 66			}
 67		}
 68	}
 69	for i, a := range r.Attachments {
 70		if err := attachmentPath(artifacts, a.Path); err != nil {
 71			bad("attachments[%d]: %v", i, err)
 72		}
 73		switch a.Type {
 74		case "", "text", "markdown", "ndjson", "json":
 75		default:
 76			bad("attachments[%d].type: %q", i, a.Type)
 77		}
 78	}
 79	if len(r.Outputs) > 0 && !isObject(r.Outputs) {
 80		bad("outputs: must be an object")
 81	}
 82	return errors.Join(errs...)
 83}
 84
 85// attachmentPath checks that p names an existing file inside artifacts.
 86func attachmentPath(artifacts, p string) error {
 87	if p == "" || filepath.IsAbs(p) {
 88		return fmt.Errorf("path %q must be relative to $CI_ARTIFACTS", p)
 89	}
 90	c := filepath.Clean(p)
 91	if c == ".." || strings.HasPrefix(c, "../") {
 92		return fmt.Errorf("path %q leaves $CI_ARTIFACTS", p)
 93	}
 94	full := filepath.Join(artifacts, c)
 95	real, err := filepath.EvalSymlinks(full)
 96	if err != nil {
 97		return fmt.Errorf("path %q: %w", p, err)
 98	}
 99	root, err := filepath.EvalSymlinks(artifacts)
100	if err != nil {
101		return err
102	}
103	if rel, err := filepath.Rel(root, real); err != nil || rel == ".." || strings.HasPrefix(rel, "../") {
104		return fmt.Errorf("path %q resolves outside $CI_ARTIFACTS", p)
105	}
106	if fi, err := os.Stat(real); err != nil || !fi.Mode().IsRegular() {
107		return fmt.Errorf("path %q is not a regular file", p)
108	}
109	return nil
110}
111
112func isObject(b json.RawMessage) bool {
113	b = bytes.TrimSpace(b)
114	return len(b) > 0 && b[0] == '{'
115}
116
117// ContractFailure turns a contract violation into the failure result the
118// router sees.
119func ContractFailure(err error) lex.Result {
120	return lex.Result{Version: lex.ResultVersion, Status: lex.Failure, Summary: "contract error: " + err.Error()}
121}
122
123// mergeOutputs merges a result's outputs into the run's outputs.json.
124func mergeOutputs(path string, outputs json.RawMessage) error {
125	if len(outputs) == 0 {
126		return nil
127	}
128	cur := map[string]json.RawMessage{}
129	if b, err := os.ReadFile(path); err == nil {
130		if err := json.Unmarshal(b, &cur); err != nil {
131			return fmt.Errorf("%s: %w", path, err)
132		}
133	} else if !errors.Is(err, os.ErrNotExist) {
134		return err
135	}
136	var add map[string]json.RawMessage
137	if err := json.Unmarshal(outputs, &add); err != nil {
138		return err
139	}
140	maps.Copy(cur, add)
141	b, err := json.MarshalIndent(cur, "", "  ")
142	if err != nil {
143		return err
144	}
145	return os.WriteFile(path, append(b, '\n'), 0o644)
146}