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