Stitch any CI into Tangled
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "log/slog"
8 "testing"
9 "time"
10
11 "tangled.org/core/api/tangled"
12
13 "go.mitchellh.com/tack/internal/k8s"
14)
15
16func newTektonTestProvider(t *testing.T) (*tektonProvider, *store, *broker, *k8s.FakeClient) {
17 t.Helper()
18 st := newTestStore(t)
19 br := newBroker(st)
20 client := k8s.NewFakeClient()
21 p := newTektonProvider(br, st, client, "ci", slog.Default())
22 return p, st, br, client
23}
24
25func TestTektonWorkflowConfig(t *testing.T) {
26 raw := "tack:\n tekton:\n pipeline: repo-ci\n service_account: runner\n params:\n image: example/app\n"
27 cfg, err := parseTektonWorkflowConfig(raw)
28 if err != nil {
29 t.Fatalf("parse: %v", err)
30 }
31 if cfg.Pipeline != "repo-ci" || cfg.ServiceAccount != "runner" {
32 t.Fatalf("cfg mismatch: %+v", cfg)
33 }
34 if got := cfg.Params["image"]; got != "example/app" {
35 t.Fatalf("params[image] = %q", got)
36 }
37
38 if _, err := parseTektonWorkflowConfig("tack:\n tekton: {}\n"); err == nil {
39 t.Fatal("missing pipeline should fail")
40 }
41}
42
43func TestTektonBuildPipelineRun(t *testing.T) {
44 cfg := &tektonWorkflowConfig{
45 Pipeline: "repo-ci",
46 ServiceAccount: "runner",
47 Params: map[string]string{
48 "image": "example/app",
49 },
50 }
51 name := tektonPipelineRunName("knot.example.com", "rkey-1", "ci.yml", "abcdef", "main")
52 if len(name) > 63 || name == "" {
53 t.Fatalf("bad generated name: %q", name)
54 }
55
56 obj := buildTektonPipelineRun("ci", name, cfg,
57 "knot.example.com", "rkey-1", "did:plc:actor", "abcdef", "main",
58 &tangled.Pipeline_Workflow{Name: "ci.yml"},
59 )
60 if obj.GetAPIVersion() != tektonAPIVersion || obj.GetKind() != tektonRunKind {
61 t.Fatalf("type meta mismatch: %s %s", obj.GetAPIVersion(), obj.GetKind())
62 }
63 pipeline, ok := obj.NestedString("spec", "pipelineRef", "name")
64 if !ok || pipeline != "repo-ci" {
65 t.Fatalf("pipelineRef.name = %q", pipeline)
66 }
67 sa, ok := obj.NestedString("spec", "serviceAccountName")
68 if !ok || sa != "runner" {
69 t.Fatalf("serviceAccountName = %q", sa)
70 }
71 params, ok := obj.NestedSlice("spec", "params")
72 if !ok || len(params) != 1 {
73 t.Fatalf("params = %+v", params)
74 }
75 if obj.GetAnnotations()[tektonAnnotationActor] != "did:plc:actor" ||
76 obj.GetAnnotations()[tektonAnnotationCommit] != "abcdef" {
77 t.Fatalf("annotations missing identity: %+v", obj.GetAnnotations())
78 }
79}
80
81func TestTektonStatusMapping(t *testing.T) {
82 tests := []struct {
83 name string
84 cond string
85 reason string
86 status string
87 terminal bool
88 ok bool
89 }{
90 {name: "unknown", cond: "Unknown", status: "running", ok: true},
91 {name: "success", cond: "True", status: "success", terminal: true, ok: true},
92 {name: "failed", cond: "False", reason: "Failed", status: "failed", terminal: true, ok: true},
93 {name: "cancelled", cond: "False", reason: "PipelineRunCancelled", status: "cancelled", terminal: true, ok: true},
94 {name: "stopped", cond: "False", reason: "PipelineRunStopped", status: "cancelled", terminal: true, ok: true},
95 }
96 for _, tt := range tests {
97 t.Run(tt.name, func(t *testing.T) {
98 obj := tektonStatusObject(tt.cond, tt.reason)
99 status, terminal, ok := mapTektonPipelineRunStatus(obj)
100 if status != tt.status || terminal != tt.terminal || ok != tt.ok {
101 t.Fatalf("got %q/%v/%v; want %q/%v/%v",
102 status, terminal, ok, tt.status, tt.terminal, tt.ok)
103 }
104 })
105 }
106}
107
108func TestTektonSpawnCreatesPipelineRun(t *testing.T) {
109 p, st, _, client := newTektonTestProvider(t)
110 ctx, cancel := context.WithCancel(context.Background())
111 defer cancel()
112
113 trigger := &tangled.Pipeline_TriggerMetadata{
114 Push: &tangled.Pipeline_PushTriggerData{
115 NewSha: "abcdef0123",
116 Ref: "refs/heads/main",
117 },
118 }
119 p.Spawn(ctx, "knot.example.com", "rkey-1", "did:plc:actor", trigger,
120 []*tangled.Pipeline_Workflow{{Name: "ci.yml",
121 Raw: "tack:\n tekton:\n pipeline: repo-ci\n"}},
122 )
123
124 ref := waitTektonRef(t, st, "knot.example.com", "rkey-1", "ci.yml")
125 if ref.Namespace != "ci" || ref.PipelineName != "repo-ci" {
126 t.Fatalf("ref mismatch: %+v", ref)
127 }
128
129 obj, err := client.GetObject(context.Background(), pipelineRunsGVR, "ci", ref.PipelineRunName)
130 if err != nil {
131 t.Fatalf("get PipelineRun: %v", err)
132 }
133 pipeline, ok := obj.NestedString("spec", "pipelineRef", "name")
134 if !ok || pipeline != "repo-ci" {
135 t.Fatalf("pipelineRef.name = %q", pipeline)
136 }
137
138 rows, err := st.EventsAfter(context.Background(), 0)
139 if err != nil {
140 t.Fatalf("EventsAfter: %v", err)
141 }
142 if len(rows) != 1 {
143 t.Fatalf("got %d events, want 1", len(rows))
144 }
145 var rec tangled.PipelineStatus
146 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil {
147 t.Fatalf("decode status: %v", err)
148 }
149 if rec.Status != "pending" || rec.Workflow != "ci.yml" {
150 t.Fatalf("bad pending status: %+v", rec)
151 }
152}
153
154func TestTektonSpawnAlreadyExists(t *testing.T) {
155 p, st, _, client := newTektonTestProvider(t)
156 name := tektonPipelineRunName("knot.example.com", "rkey-1", "ci.yml", "abcdef0123", "main")
157 existing := buildTektonPipelineRun("ci", name,
158 &tektonWorkflowConfig{Pipeline: "repo-ci"},
159 "knot.example.com", "rkey-1", "did:plc:actor", "abcdef0123", "main",
160 &tangled.Pipeline_Workflow{Name: "ci.yml"},
161 )
162 existing.SetUID("uid-1")
163 client.SeedObject(pipelineRunsGVR, "ci", existing)
164
165 ctx, cancel := context.WithCancel(context.Background())
166 defer cancel()
167 p.Spawn(ctx, "knot.example.com", "rkey-1", "did:plc:actor",
168 &tangled.Pipeline_TriggerMetadata{Push: &tangled.Pipeline_PushTriggerData{
169 NewSha: "abcdef0123",
170 Ref: "refs/heads/main",
171 }},
172 []*tangled.Pipeline_Workflow{{Name: "ci.yml",
173 Raw: "tack:\n tekton:\n pipeline: repo-ci\n"}},
174 )
175
176 ref := waitTektonRef(t, st, "knot.example.com", "rkey-1", "ci.yml")
177 if ref.PipelineRunName != name || ref.PipelineRunUID != "uid-1" {
178 t.Fatalf("ref mismatch: %+v", ref)
179 }
180}
181
182func TestTektonLogsLookup(t *testing.T) {
183 p, st, _, client := newTektonTestProvider(t)
184 ctx := context.Background()
185 if _, err := p.Logs(ctx, "knot.example.com", "rkey-1", "ci.yml"); !errors.Is(err, ErrLogsNotFound) {
186 t.Fatalf("logs before mapping err = %v; want ErrLogsNotFound", err)
187 }
188 ref := TektonRunRef{
189 Knot: "knot.example.com",
190 PipelineRkey: "rkey-1",
191 Workflow: "ci.yml",
192 Namespace: "ci",
193 PipelineRunName: "run-1",
194 PipelineRunUID: "uid-1",
195 PipelineName: "repo-ci",
196 PipelineURI: pipelineATURI("knot.example.com", "rkey-1"),
197 }
198 if err := st.InsertTektonRun(ctx, ref); err != nil {
199 t.Fatalf("insert ref: %v", err)
200 }
201 if _, err := p.Logs(ctx, "knot.example.com", "rkey-1", "ci.yml"); !errors.Is(err, ErrLogsNotFound) {
202 t.Fatalf("logs before TaskRuns err = %v; want ErrLogsNotFound", err)
203 }
204
205 client.SeedObject(taskRunsGVR, "ci", k8s.Object{
206 "apiVersion": "tekton.dev/v1",
207 "kind": "TaskRun",
208 "metadata": map[string]any{
209 "name": "task-1",
210 "namespace": "ci",
211 "labels": map[string]any{
212 "tekton.dev/pipelineRun": "run-1",
213 },
214 },
215 })
216 client.SeedPod("ci", k8s.Pod{
217 Name: "pod-1",
218 Namespace: "ci",
219 Labels: map[string]string{
220 "tekton.dev/taskRun": "task-1",
221 },
222 Containers: []k8s.Container{{Name: "step-test"}},
223 })
224 client.SetPodLog("ci", "pod-1", "step-test", "hello\n")
225
226 ch, err := p.Logs(ctx, "knot.example.com", "rkey-1", "ci.yml")
227 if err != nil {
228 t.Fatalf("Logs after pods: %v", err)
229 }
230 var got []LogLine
231 for line := range ch {
232 got = append(got, line)
233 }
234 if len(got) < 2 || got[0].StepStatus != StepStatusStart ||
235 got[len(got)-1].StepStatus != StepStatusEnd {
236 t.Fatalf("log frames = %+v", got)
237 }
238}
239
240func tektonStatusObject(condStatus, reason string) k8s.Object {
241 return k8s.Object{
242 "status": map[string]any{
243 "conditions": []any{map[string]any{
244 "type": "Succeeded",
245 "status": condStatus,
246 "reason": reason,
247 }},
248 },
249 }
250}
251
252func waitTektonRef(t *testing.T, st *store, knot, rkey, workflow string) *TektonRunRef {
253 t.Helper()
254 deadline := time.Now().Add(2 * time.Second)
255 for time.Now().Before(deadline) {
256 ref, err := st.LookupTektonRunByTuple(context.Background(), knot, rkey, workflow)
257 if err != nil {
258 t.Fatalf("lookup: %v", err)
259 }
260 if ref != nil {
261 return ref
262 }
263 time.Sleep(20 * time.Millisecond)
264 }
265 t.Fatal("tekton run row not persisted within deadline")
266 return nil
267}