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}