326 lines
13 KiB
Go
326 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
dynamicfake "k8s.io/client-go/dynamic/fake"
|
|
k8stesting "k8s.io/client-go/testing"
|
|
)
|
|
|
|
func TestQualManual(t *testing.T) {
|
|
c, now := qualFixture(t, "")
|
|
build := publishQual(t, c, "point", strings.Repeat("1", 40))
|
|
stepQual(t, c)
|
|
if len(readQual(t, c, "point").Attempts) != 0 {
|
|
t.Fatal("omitted mode started automatic qualification")
|
|
}
|
|
requestQual(t, c, "point", build+"/first")
|
|
stepQual(t, c)
|
|
first := readQual(t, c, "point").Attempts[0]
|
|
if first.Phase != "Reserved" || countWorkflows(t, c, "qualification") != 0 {
|
|
t.Fatal("reservation was not persisted before submission")
|
|
}
|
|
stepQual(t, c)
|
|
markQual(t, c, "delivery", first.WorkflowName, "Running", now, nil)
|
|
stepQual(t, c)
|
|
for _, id := range []string{"second", "third"} {
|
|
requestQual(t, c, "point", build+"/"+id)
|
|
stepQual(t, c)
|
|
}
|
|
q := readQual(t, c, "point")
|
|
if len(q.Attempts) != 3 || q.Attempts[1].Phase != "Pending" || q.Attempts[2].Phase != "Pending" {
|
|
t.Fatalf("accepted requests = %#v", q.Attempts)
|
|
}
|
|
requestQual(t, c, "point", build+"/first")
|
|
stepQual(t, c)
|
|
if len(readQual(t, c, "point").Attempts) != 3 || countWorkflows(t, c, "qualification") != 1 {
|
|
t.Fatal("reapplying a consumed request created another run")
|
|
}
|
|
markQual(t, c, "delivery", first.WorkflowName, "Succeeded", now, map[string]string{"qualification-result": `{"report":"synthetic://reports/first"}`})
|
|
stepQual(t, c)
|
|
stepQual(t, c)
|
|
stepQual(t, c)
|
|
if countWorkflows(t, c, "qualification") != 2 {
|
|
t.Fatal("pending manual request did not advance after completion")
|
|
}
|
|
q = readQual(t, c, "point")
|
|
if string(q.Attempts[0].Result) != `{"report":"synthetic://reports/first"}` {
|
|
t.Fatalf("qualification result = %s", q.Attempts[0].Result)
|
|
}
|
|
}
|
|
|
|
func TestQualInterval(t *testing.T) {
|
|
c, now := qualFixture(t, "OnBuild")
|
|
firstBuild := publishQual(t, c, "point", strings.Repeat("1", 40))
|
|
stepQual(t, c)
|
|
stepQual(t, c)
|
|
first := readQual(t, c, "point").Attempts[0]
|
|
markQual(t, c, "delivery", first.WorkflowName, "Running", now, nil)
|
|
stepQual(t, c)
|
|
publishQual(t, c, "point", strings.Repeat("2", 40))
|
|
thirdBuild := publishQual(t, c, "point", strings.Repeat("3", 40))
|
|
markQual(t, c, "delivery", first.WorkflowName, "Failed", now, nil)
|
|
stepQual(t, c)
|
|
*now = now.Add(167 * time.Hour)
|
|
stepQual(t, c)
|
|
if len(readQual(t, c, "point").Attempts) != 1 {
|
|
t.Fatal("automatic qualification ignored the interval after failure")
|
|
}
|
|
*now = now.Add(time.Hour)
|
|
stepQual(t, c)
|
|
q := readQual(t, c, "point")
|
|
if len(q.Attempts) != 2 || q.Builds[q.Attempts[1].Build-1].WorkflowName != thirdBuild {
|
|
t.Fatalf("expiry did not select latest build: %#v", q.Attempts)
|
|
}
|
|
stepQual(t, c)
|
|
second := q.Attempts[1]
|
|
markQual(t, c, "delivery", second.WorkflowName, "Succeeded", now, nil)
|
|
stepQual(t, c)
|
|
requestQual(t, c, "point", firstBuild+"/old-build")
|
|
stepQual(t, c)
|
|
stepQual(t, c)
|
|
q = readQual(t, c, "point")
|
|
manual := q.Attempts[2]
|
|
if manual.Kind != "Manual" || manual.Build != 1 {
|
|
t.Fatalf("manual selection = %#v", manual)
|
|
}
|
|
*now = now.Add(time.Hour)
|
|
markQual(t, c, "delivery", manual.WorkflowName, "Succeeded", now, nil)
|
|
stepQual(t, c)
|
|
q = readQual(t, c, "point")
|
|
if q.Covered != 3 || !q.LastStart.Equal(*now) {
|
|
t.Fatalf("manual start accounting = %#v", q)
|
|
}
|
|
*now = now.Add(168 * time.Hour)
|
|
stepQual(t, c)
|
|
if len(readQual(t, c, "point").Attempts) != 3 {
|
|
t.Fatal("expiry without a new build repeated qualification")
|
|
}
|
|
}
|
|
|
|
func TestQualSnapshot(t *testing.T) {
|
|
c, _ := qualFixture(t, "OnBuild")
|
|
build := publishQual(t, c, "point", strings.Repeat("1", 40))
|
|
original := readQual(t, c, "point").Builds[0].Input
|
|
object := getDelivery(t, c, "point")
|
|
spec := object.Object["spec"].(map[string]any)
|
|
spec["source"].(map[string]any)["repository"] = "https://example.test/other/repo"
|
|
spec["targets"] = []any{map[string]any{"kind": "Other", "repository": "changed"}}
|
|
if _, err := c.client.Resource(projectDeliveryGVR).Namespace("delivery").Update(context.Background(), object, metav1.UpdateOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.client.Resource(workflowGVR).Namespace("delivery").Delete(context.Background(), build, metav1.DeleteOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stepQual(t, c)
|
|
object = getDelivery(t, c, "point")
|
|
object.Object["spec"].(map[string]any)["qualification"].(map[string]any)["workflowTemplateRef"] = map[string]any{"name": "other-template"}
|
|
if _, err := c.client.Resource(projectDeliveryGVR).Namespace("delivery").Update(context.Background(), object, metav1.UpdateOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stepQual(t, c)
|
|
attempt := readQual(t, c, "point").Attempts[0]
|
|
workflow, err := c.client.Resource(workflowGVR).Namespace("delivery").Get(context.Background(), attempt.WorkflowName, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
template := workflow.GetAnnotations()[templateKey]
|
|
if template != "qual-tests" {
|
|
t.Fatalf("reserved template changed to %q", template)
|
|
}
|
|
for key, value := range original {
|
|
if got := workflowParameter(t, workflow, key); got != value {
|
|
t.Fatalf("parameter %s = %q, want %q", key, got, value)
|
|
}
|
|
}
|
|
if workflowHasParameter(t, workflow, "image-digest") {
|
|
t.Fatal("controller interpreted the publication result")
|
|
}
|
|
}
|
|
|
|
func TestQualReservation(t *testing.T) {
|
|
c, _ := qualFixture(t, "OnBuild")
|
|
publishQual(t, c, "point", strings.Repeat("1", 40))
|
|
fail := true
|
|
c.client.(*dynamicfake.FakeDynamicClient).PrependReactor("update", "projectdeliveries", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
if fail {
|
|
fail = false
|
|
return true, nil, errors.New("synthetic status write failure")
|
|
}
|
|
return false, nil, nil
|
|
})
|
|
if err := c.qualifyAll(context.Background()); err == nil {
|
|
t.Fatal("expected reservation write failure")
|
|
}
|
|
if countWorkflows(t, c, "qualification") != 0 || len(readQual(t, c, "point").Attempts) != 0 {
|
|
t.Fatal("unpersisted reservation created a Workflow")
|
|
}
|
|
stepQual(t, c)
|
|
restarted := *c
|
|
restarted.qualCursor = ""
|
|
conflicts := 0
|
|
c.client.(*dynamicfake.FakeDynamicClient).PrependReactor("update", "projectdeliveries", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
object := action.(k8stesting.UpdateAction).GetObject().(*unstructured.Unstructured)
|
|
attempts, _, _ := unstructured.NestedSlice(object.Object, "status", "qualification", "attempts")
|
|
queued := len(attempts) != 0 && attempts[0].(map[string]any)["phase"] == "Queued"
|
|
if conflicts == 0 && queued {
|
|
conflicts++
|
|
return true, nil, apierrors.NewConflict(projectDeliveryGVR.GroupResource(), "point", errors.New("synthetic conflict"))
|
|
}
|
|
return false, nil, nil
|
|
})
|
|
stepQual(t, &restarted)
|
|
stepQual(t, &restarted)
|
|
if conflicts != 1 || countWorkflows(t, c, "qualification") != 1 {
|
|
t.Fatalf("reservation recovery: conflicts=%d workflows=%d", conflicts, countWorkflows(t, c, "qualification"))
|
|
}
|
|
}
|
|
|
|
func TestQualGlobalCap(t *testing.T) {
|
|
c, now := qualFixture(t, "OnBuild")
|
|
second := getDelivery(t, c, "point").DeepCopy()
|
|
second.SetNamespace("other")
|
|
second.SetUID("other-delivery")
|
|
second.SetResourceVersion("")
|
|
second.Object["spec"].(map[string]any)["qualification"].(map[string]any)["workflowTemplateRef"] = map[string]any{"name": "other-qual"}
|
|
if _, err := c.client.Resource(projectDeliveryGVR).Namespace("other").Create(context.Background(), second, metav1.CreateOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publishQual(t, c, "point", strings.Repeat("1", 40))
|
|
other, err := decodeProjectDelivery(second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
input := map[string]string{"source-repository": "https://example.test/repo/other", "source-ref": "", "source-revision": strings.Repeat("2", 40), "targets": "[{}]"}
|
|
observeQualBuild(&other, &attemptStatus{WorkflowName: "other-build", Phase: "Succeeded", Input: input, Result: json.RawMessage(`{"image":"other"}`)})
|
|
if err := c.updateStatus(context.Background(), second, &other); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stepQual(t, c)
|
|
stepQual(t, c)
|
|
first := readQual(t, c, "point").Attempts[0]
|
|
markQual(t, c, "delivery", first.WorkflowName, "Running", now, nil)
|
|
stepQual(t, c)
|
|
objects, err := c.client.Resource(workflowGVR).Namespace(metav1.NamespaceAll).List(context.Background(), metav1.ListOptions{LabelSelector: purposeAnnotation + "=qualification"})
|
|
if err != nil || len(objects.Items) != 1 {
|
|
t.Fatalf("global Workflows = %d, error=%v", len(objects.Items), err)
|
|
}
|
|
publishQual(t, c, "point", strings.Repeat("3", 40))
|
|
markQual(t, c, "delivery", first.WorkflowName, "Succeeded", now, nil)
|
|
stepQual(t, c)
|
|
*now = now.Add(168 * time.Hour)
|
|
stepQual(t, c)
|
|
stepQual(t, c)
|
|
objects, err = c.client.Resource(workflowGVR).Namespace("other").List(context.Background(), metav1.ListOptions{LabelSelector: purposeAnnotation + "=qualification"})
|
|
if err != nil || len(objects.Items) != 1 {
|
|
t.Fatalf("waiting namespace did not advance: count=%d error=%v", len(objects.Items), err)
|
|
}
|
|
}
|
|
|
|
func TestQualPolicyChange(t *testing.T) {
|
|
c, _ := qualFixture(t, "OnBuild")
|
|
publishQual(t, c, "point", strings.Repeat("1", 40))
|
|
object := getDelivery(t, c, "point")
|
|
spec := object.Object["spec"].(map[string]any)["qualification"].(map[string]any)
|
|
spec["mode"] = "ManualOnly"
|
|
if _, err := c.client.Resource(projectDeliveryGVR).Namespace("delivery").Update(context.Background(), object, metav1.UpdateOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stepQual(t, c)
|
|
if len(readQual(t, c, "point").Attempts) != 0 {
|
|
t.Fatal("ManualOnly reserved an automatic candidate")
|
|
}
|
|
object = getDelivery(t, c, "point")
|
|
object.Object["spec"].(map[string]any)["qualification"].(map[string]any)["mode"] = "OnBuild"
|
|
if _, err := c.client.Resource(projectDeliveryGVR).Namespace("delivery").Update(context.Background(), object, metav1.UpdateOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stepQual(t, c)
|
|
if readQual(t, c, "point").Attempts[0].Phase != "Reserved" {
|
|
t.Fatal("OnBuild did not reconsider the waiting candidate")
|
|
}
|
|
}
|
|
|
|
func qualFixture(t *testing.T, mode string) (*controller, *time.Time) {
|
|
t.Helper()
|
|
now := time.Date(2026, time.September, 11, 0, 0, 0, 0, time.UTC)
|
|
object := pointDelivery(strings.Repeat("1", 40))
|
|
object.Object["spec"].(map[string]any)["qualification"] = map[string]any{
|
|
"workflowTemplateRef": map[string]any{"name": "qual-tests"}, "mode": mode, "interval": "168h",
|
|
}
|
|
return testController(t, object, &mutableRefs{}, &now), &now
|
|
}
|
|
|
|
func publishQual(t *testing.T, c *controller, name, revision string) string {
|
|
t.Helper()
|
|
object := getDelivery(t, c, name)
|
|
d, err := decodeProjectDelivery(object)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
attempt := attemptStatus{Revision: revision, Phase: "Pending"}
|
|
if err := c.advanceAttempt(context.Background(), &d, &attempt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := fmt.Sprintf(`{"sourceRevision":%q,"targets":[{"reference":%q}]}`, revision, "synthetic/image@sha256:"+strings.Repeat("a", 64))
|
|
setNamedWorkflowStatus(t, c, attempt.WorkflowName, "Succeeded", map[string]string{"delivery-result": result})
|
|
if err := c.advanceAttempt(context.Background(), &d, &attempt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.Status.Points = append(d.Status.Points, attempt)
|
|
if err := c.updateStatus(context.Background(), object, &d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return attempt.WorkflowName
|
|
}
|
|
|
|
func stepQual(t *testing.T, c *controller) {
|
|
t.Helper()
|
|
if err := c.qualifyAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func readQual(t *testing.T, c *controller, name string) *qualStatus {
|
|
t.Helper()
|
|
d, err := decodeProjectDelivery(getDelivery(t, c, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d.Status.Qualification
|
|
}
|
|
|
|
func requestQual(t *testing.T, c *controller, name, request string) {
|
|
t.Helper()
|
|
object := getDelivery(t, c, name)
|
|
object.SetAnnotations(map[string]string{qualAnnotation: request})
|
|
if _, err := c.client.Resource(projectDeliveryGVR).Namespace("delivery").Update(context.Background(), object, metav1.UpdateOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func markQual(t *testing.T, c *controller, namespace, name, phase string, started *time.Time, outputs map[string]string) {
|
|
t.Helper()
|
|
object, err := c.client.Resource(workflowGVR).Namespace(namespace).Get(context.Background(), name, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
params := []any{}
|
|
for name, value := range outputs {
|
|
params = append(params, map[string]any{"name": name, "value": value})
|
|
}
|
|
object.Object["status"] = map[string]any{"phase": phase, "startedAt": started.Format(time.RFC3339), "outputs": map[string]any{"parameters": params}}
|
|
if _, err := c.client.Resource(workflowGVR).Namespace(namespace).UpdateStatus(context.Background(), object, metav1.UpdateOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|