From 7eb116cd8a4b7c7ea4fae0e3146ffc663851aa86 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Mon, 27 Jul 2026 13:40:30 +0300 Subject: [PATCH 1/3] feat: improve catalog discovery UX for scheduled builds Propagate schedule name labels from ImageBuild to CatalogImage, add sort/latest API params, and improve CLI table output with schedule names, AGE column, and smart TAGS column hiding. Signed-off-by: Benny Zlotnik --- cmd/caib/catalog/catalog_test.go | 53 +++ cmd/caib/catalog/get.go | 48 ++- cmd/caib/catalog/list.go | 114 +++++- internal/buildapi/catalog/handlers.go | 132 +++++-- internal/buildapi/catalog/handlers_test.go | 212 ++++++++++ internal/buildapi/catalog/models.go | 6 + internal/controller/catalogimage/publisher.go | 9 + .../controller/catalogimage/publisher_test.go | 64 +++ test/e2e/catalog_discovery_test.go | 367 ++++++++++++++++++ 9 files changed, 951 insertions(+), 54 deletions(-) create mode 100644 test/e2e/catalog_discovery_test.go diff --git a/cmd/caib/catalog/catalog_test.go b/cmd/caib/catalog/catalog_test.go index fe8d120a3..8970ea951 100644 --- a/cmd/caib/catalog/catalog_test.go +++ b/cmd/caib/catalog/catalog_test.go @@ -2,6 +2,7 @@ package catalog import ( "testing" + "time" "github.com/spf13/cobra" ) @@ -47,3 +48,55 @@ func TestGetOutputFormat_FallbackWithoutFlag(t *testing.T) { t.Errorf("expected fallback %q, got %q", testFormatTable, got) } } + +func TestFormatAge(t *testing.T) { + tests := []struct { + name string + offset time.Duration + want string + }{ + {"seconds ago", 30 * time.Second, "30s"}, + {"minutes ago", 5 * time.Minute, "5m"}, + {"hours ago", 3 * time.Hour, "3h"}, + {"days ago", 2 * 24 * time.Hour, "2d"}, + {"months ago", 60 * 24 * time.Hour, "2mo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := time.Now().Add(-tt.offset).Format(time.RFC3339) + got := formatAge(ts) + if got != tt.want { + t.Errorf("formatAge(%q) = %q, want %q", ts, got, tt.want) + } + }) + } +} + +func TestFormatAge_InvalidTimestamp(t *testing.T) { + got := formatAge("not-a-timestamp") + if got != "not-a-timestamp" { + t.Errorf("expected raw string passthrough, got %q", got) + } +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + input int64 + want string + }{ + {500, "500 B"}, + {1024, "1.0 KiB"}, + {1536 * 1024, "1.5 MiB"}, + {2 * 1024 * 1024 * 1024, "2.0 GiB"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := formatBytes(tt.input) + if got != tt.want { + t.Errorf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/cmd/caib/catalog/get.go b/cmd/caib/catalog/get.go index dad67686d..504acda57 100644 --- a/cmd/caib/catalog/get.go +++ b/cmd/caib/catalog/get.go @@ -143,7 +143,7 @@ func printImageDetails(img CatalogImageResponse) { for i, t := range img.Targets { names[i] = t.Name } - target = fmt.Sprintf("%v", names) + target = strings.Join(names, ", ") } rows := [][2]string{ @@ -154,8 +154,28 @@ func printImageDetails(img CatalogImageResponse) { {"Architecture", img.Architecture}, {"Distro", img.Distro}, {"Targets", target}, + {"Build Mode", img.BuildMode}, + {"Export Format", img.ExportFormat}, {"Created At", img.CreatedAt}, } + if img.ScheduleName != "" { + rows = append(rows, [2]string{"Schedule", img.ScheduleName}) + } + if img.SourceType != "" { + rows = append(rows, [2]string{"Source Type", img.SourceType}) + } + if img.SourceImageBuild != "" { + rows = append(rows, [2]string{"Source Build", img.SourceImageBuild}) + } + if len(img.Tags) > 0 { + rows = append(rows, [2]string{"Tags", strings.Join(img.Tags, ", ")}) + } + if img.SizeBytes > 0 { + rows = append(rows, [2]string{"Size", formatBytes(img.SizeBytes)}) + } + if img.DownloadURL != "" { + rows = append(rows, [2]string{"Download URL", img.DownloadURL}) + } if img.StatusReason != "" { reason := img.StatusReason if img.StatusMessage != "" { @@ -163,14 +183,32 @@ func printImageDetails(img CatalogImageResponse) { } rows = append(rows, [2]string{"Status Reason", reason}) } - if img.SizeBytes > 0 { - rows = append(rows, [2]string{"Size", fmt.Sprintf("%d bytes", img.SizeBytes)}) - } for _, row := range rows { - if _, err := fmt.Fprintf(w, "%s\t%s\n", row[0], row[1]); err != nil { + if row[1] == "" { + continue + } + if _, err := fmt.Fprintf(w, "%s:\t%s\n", row[0], row[1]); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write output row: %v\n", err) return } } } + +func formatBytes(b int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case b >= gb: + return fmt.Sprintf("%.1f GiB", float64(b)/float64(gb)) + case b >= mb: + return fmt.Sprintf("%.1f MiB", float64(b)/float64(mb)) + case b >= kb: + return fmt.Sprintf("%.1f KiB", float64(b)/float64(kb)) + default: + return fmt.Sprintf("%d B", b) + } +} diff --git a/cmd/caib/catalog/list.go b/cmd/caib/catalog/list.go index 440b59ec1..0aaddf06b 100644 --- a/cmd/caib/catalog/list.go +++ b/cmd/caib/catalog/list.go @@ -20,11 +20,13 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "net/url" "os" "strings" "text/tabwriter" + "time" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/config" "github.com/spf13/cobra" @@ -37,6 +39,8 @@ var ( listTarget string listPhase string listTags string + listSort string + listLatest bool listLimit int listAllNamespaces bool ) @@ -45,8 +49,10 @@ func newListCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List images in the catalog", - Long: `List images in the catalog with optional filtering by architecture, distribution, target, and phase.`, - RunE: runList, + Long: `List images in the catalog with optional filtering by architecture, distribution, +target, and phase. Use --latest to show only the newest image per schedule +(or per distro/arch/target group for non-scheduled images).`, + RunE: runList, } addCommonFlags(cmd) @@ -55,6 +61,8 @@ func newListCmd() *cobra.Command { cmd.Flags().StringVar(&listTarget, "target", "", "Filter by hardware target (qemu, raspberry-pi)") cmd.Flags().StringVar(&listPhase, "phase", "", "Filter by phase (Available, Unavailable, etc)") cmd.Flags().StringVar(&listTags, "tags", "", "Filter by tags (comma-separated)") + cmd.Flags().StringVar(&listSort, "sort", "created", "Sort order: created (newest first), name") + cmd.Flags().BoolVar(&listLatest, "latest", false, "Show only the latest image per schedule or distro/arch/target group") cmd.Flags().IntVar(&listLimit, "limit", 20, "Maximum results to show") cmd.Flags().BoolVar(&listAllNamespaces, "all-namespaces", false, "List images across all namespaces") @@ -84,10 +92,12 @@ type CatalogImageResponse struct { Tags []string `json:"tags,omitempty"` SourceType string `json:"sourceType,omitempty"` SourceImageBuild string `json:"sourceImageBuild,omitempty"` + ScheduleName string `json:"scheduleName,omitempty"` BuildMode string `json:"buildMode,omitempty"` ExportFormat string `json:"exportFormat,omitempty"` Labels map[string]string `json:"labels,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` + DownloadURL string `json:"downloadUrl,omitempty"` CreatedAt string `json:"createdAt"` StatusReason string `json:"statusReason,omitempty"` StatusMessage string `json:"statusMessage,omitempty"` @@ -134,6 +144,15 @@ func runList(cmd *cobra.Command, _ []string) error { if listTags != "" { params.Set("tags", listTags) } + if listSort != "" { + if listSort != "created" && listSort != "name" { + return fmt.Errorf("invalid --sort value %q (supported: created, name)", listSort) + } + params.Set("sort", listSort) + } + if listLatest { + params.Set("latest", "true") + } if listLimit > 0 { params.Set("limit", fmt.Sprintf("%d", listLimit)) } @@ -189,7 +208,7 @@ func runList(cmd *cobra.Command, _ []string) error { output, _ := yaml.Marshal(result) fmt.Println(string(output)) case outputFormatTable: - printTable(result.Items) + printTable(result.Items, listTags != "") default: return fmt.Errorf("invalid output format %q (supported: table, json, yaml)", format) } @@ -197,7 +216,7 @@ func runList(cmd *cobra.Command, _ []string) error { return nil } -func printTable(items []CatalogImageResponse) { +func printTable(items []CatalogImageResponse, tagsFiltered bool) { if len(items) == 0 { fmt.Println("No catalog images found") return @@ -210,7 +229,12 @@ func printTable(items []CatalogImageResponse) { } }() - if _, err := fmt.Fprintln(w, "NAME\tSOURCE\tARCH\tDISTRO\tTARGET\tFORMAT\tTAGS\tPHASE\tIMAGE\tCREATED"); err != nil { + header := "NAME\tSCHEDULE\tARCH\tDISTRO\tTARGET\tFORMAT\tPHASE\tAGE\tIMAGE" + if !tagsFiltered { + header = "NAME\tSCHEDULE\tARCH\tDISTRO\tTARGET\tFORMAT\tTAGS\tPHASE\tAGE\tIMAGE" + } + + if _, err := fmt.Fprintln(w, header); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to write header: %v\n", err) return } @@ -221,21 +245,71 @@ func printTable(items []CatalogImageResponse) { target = img.Targets[0].Name } - tags := strings.Join(img.Tags, ",") - - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - img.Name, - img.SourceType, - img.Architecture, - img.Distro, - target, - img.ExportFormat, - tags, - img.Phase, - img.RegistryURL, - img.CreatedAt, - ); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to write row: %v\n", err) + age := formatAge(img.CreatedAt) + + if tagsFiltered { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + img.Name, + img.ScheduleName, + img.Architecture, + img.Distro, + target, + img.ExportFormat, + img.Phase, + age, + img.RegistryURL, + ); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write row: %v\n", err) + } + } else { + tags := strings.Join(img.Tags, ",") + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + img.Name, + img.ScheduleName, + img.Architecture, + img.Distro, + target, + img.ExportFormat, + tags, + img.Phase, + age, + img.RegistryURL, + ); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write row: %v\n", err) + } + } + } + + _, _ = fmt.Fprintf(w, "\n") + _, _ = fmt.Fprintf(os.Stderr, "%d image(s)\n", len(items)) +} + +// formatAge converts an RFC3339 timestamp to a human-readable relative duration. +func formatAge(timestamp string) string { + t, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return timestamp + } + + d := time.Since(t) + if d < 0 { + return "future" + } + + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dd", int(math.Floor(d.Hours()/24))) + default: + months := int(math.Floor(d.Hours() / (24 * 30))) + if months < 12 { + return fmt.Sprintf("%dmo", months) } + return fmt.Sprintf("%dy", int(math.Floor(d.Hours()/(24*365)))) } } diff --git a/internal/buildapi/catalog/handlers.go b/internal/buildapi/catalog/handlers.go index 9d8ad2917..755ff387e 100644 --- a/internal/buildapi/catalog/handlers.go +++ b/internal/buildapi/catalog/handlers.go @@ -20,6 +20,7 @@ package catalog import ( "context" "net/http" + "sort" "strings" "github.com/gin-gonic/gin" @@ -30,6 +31,11 @@ import ( automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" ) +const ( + sortByCreated = "created" + sortByName = "name" +) + // Handler handles catalog API requests type Handler struct { client client.Client @@ -93,16 +99,19 @@ func (h *Handler) HandleListCatalogImages(c *gin.Context) { listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: selector}) } - // Apply limit - if params.Limit > 0 && params.Limit <= 100 { - listOpts = append(listOpts, client.Limit(int64(params.Limit))) - } else { - listOpts = append(listOpts, client.Limit(20)) + // Validate sort param + if params.Sort != "" && params.Sort != sortByCreated && params.Sort != sortByName { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort value", "details": "supported values: created, name"}) + return } - // Apply continue token - if params.Continue != "" { - listOpts = append(listOpts, client.Continue(params.Continue)) + needsPostProcessing := params.Sort != "" || params.Latest + + if !needsPostProcessing { + listOpts = append(listOpts, client.Limit(int64(effectiveLimit(params.Limit)))) + if params.Continue != "" { + listOpts = append(listOpts, client.Continue(params.Continue)) + } } // List catalog images @@ -113,31 +122,18 @@ func (h *Handler) HandleListCatalogImages(c *gin.Context) { return } - // Filter by phase if specified (post-filter since phase is in status) - if params.Phase != "" { - filtered := []automotivev1alpha1.CatalogImage{} - for _, img := range catalogImages.Items { - if string(img.Status.Phase) == params.Phase { - filtered = append(filtered, img) - } - } - catalogImages.Items = filtered - } + catalogImages.Items = postFilterAndSort(catalogImages.Items, params) - // Filter by tags if specified - if params.Tags != "" { - requestedTags := strings.Split(params.Tags, ",") - filtered := []automotivev1alpha1.CatalogImage{} - for _, img := range catalogImages.Items { - if hasAllTags(img.Spec.Tags, requestedTags) { - filtered = append(filtered, img) - } + continueToken := catalogImages.Continue + if needsPostProcessing { + continueToken = "" + limit := effectiveLimit(params.Limit) + if len(catalogImages.Items) > limit { + catalogImages.Items = catalogImages.Items[:limit] } - catalogImages.Items = filtered } - // Convert to response - response := ToCatalogImageListResponse(catalogImages, catalogImages.Continue) + response := ToCatalogImageListResponse(catalogImages, continueToken) c.JSON(http.StatusOK, response) } @@ -385,6 +381,84 @@ func (h *Handler) HandlePublishImageBuild(c *gin.Context) { c.JSON(http.StatusCreated, response) } +func effectiveLimit(limit int) int { + if limit > 0 && limit <= 100 { + return limit + } + return 20 +} + +func postFilterAndSort(items []automotivev1alpha1.CatalogImage, params ListQueryParams) []automotivev1alpha1.CatalogImage { + if params.Phase != "" { + filtered := []automotivev1alpha1.CatalogImage{} + for _, img := range items { + if string(img.Status.Phase) == params.Phase { + filtered = append(filtered, img) + } + } + items = filtered + } + + if params.Tags != "" { + requestedTags := strings.Split(params.Tags, ",") + filtered := []automotivev1alpha1.CatalogImage{} + for _, img := range items { + if hasAllTags(img.Spec.Tags, requestedTags) { + filtered = append(filtered, img) + } + } + items = filtered + } + + if params.Latest { + sort.Slice(items, func(i, j int) bool { + return items[i].CreationTimestamp.After(items[j].CreationTimestamp.Time) + }) + seen := map[string]bool{} + filtered := []automotivev1alpha1.CatalogImage{} + for i := range items { + key := latestGroupKey(&items[i]) + if !seen[key] { + seen[key] = true + filtered = append(filtered, items[i]) + } + } + items = filtered + } + + sortBy := params.Sort + if sortBy == "" { + sortBy = sortByCreated + } + switch sortBy { + case sortByCreated: + sort.Slice(items, func(i, j int) bool { + return items[i].CreationTimestamp.After(items[j].CreationTimestamp.Time) + }) + case sortByName: + sort.Slice(items, func(i, j int) bool { + return items[i].Name < items[j].Name + }) + } + + return items +} + +// latestGroupKey returns a grouping key for --latest deduplication. +// Scheduled images group by schedule name; others by distro+arch+target. +func latestGroupKey(img *automotivev1alpha1.CatalogImage) string { + if name, ok := img.Labels[automotivev1alpha1.LabelScheduledImageBuildName]; ok && name != "" { + return "schedule:" + name + } + arch := img.Labels[automotivev1alpha1.LabelArchitecture] + distro := img.Labels[automotivev1alpha1.LabelDistro] + target := img.Labels[automotivev1alpha1.LabelTarget] + if arch == "" && distro == "" && target == "" { + return "name:" + img.Name + } + return distro + "/" + arch + "/" + target +} + // hasAllTags checks if the image has all the requested tags func hasAllTags(imageTags, requestedTags []string) bool { tagSet := make(map[string]bool) diff --git a/internal/buildapi/catalog/handlers_test.go b/internal/buildapi/catalog/handlers_test.go index 25df0f23e..bf6815d2b 100644 --- a/internal/buildapi/catalog/handlers_test.go +++ b/internal/buildapi/catalog/handlers_test.go @@ -2,9 +2,11 @@ package catalog import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" "github.com/go-logr/logr" @@ -82,6 +84,216 @@ func TestHandleGetCatalogImage_DoesNotWrite(t *testing.T) { } } +func TestHandleListCatalogImages_SortByCreated(t *testing.T) { + gin.SetMode(gin.TestMode) + + older := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "img-older", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: metav1.Now().Add(-2 * 24 * time.Hour)}, + }, + Spec: automotivev1alpha1.CatalogImageSpec{RegistryURL: "quay.io/test/older:v1"}, + } + newer := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "img-newer", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: metav1.Now().Time}, + }, + Spec: automotivev1alpha1.CatalogImageSpec{RegistryURL: "quay.io/test/newer:v1"}, + } + + h, _ := newTestHandler(older, newer) + + router := gin.New() + router.GET("/catalog/images", h.HandleListCatalogImages) + + req := httptest.NewRequest(http.MethodGet, "/catalog/images?namespace=default&sort=created", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp CatalogImageListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp.Items) != 2 { + t.Fatalf("expected 2 items, got %d", len(resp.Items)) + } + if resp.Items[0].Name != "img-newer" { + t.Errorf("expected newest first, got %s", resp.Items[0].Name) + } +} + +func TestHandleListCatalogImages_Latest(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + sort string + olderName string + newerName string + schedule string + wantName string + }{ + { + name: "sort=created picks newest", + sort: "created", + olderName: "sched-old", + newerName: "sched-fresh", + schedule: "nightly-qemu", + wantName: "sched-fresh", + }, + { + name: "sort=name still picks newest by creation time", + sort: "name", + olderName: "aaa-older", + newerName: "zzz-newer", + schedule: "nightly", + wantName: "zzz-newer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + older := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.olderName, + Namespace: "default", + CreationTimestamp: metav1.Time{Time: metav1.Now().Add(-24 * time.Hour)}, + Labels: map[string]string{ + automotivev1alpha1.LabelScheduledImageBuildName: tt.schedule, + }, + }, + Spec: automotivev1alpha1.CatalogImageSpec{RegistryURL: "quay.io/test/older:v1"}, + } + newer := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.newerName, + Namespace: "default", + CreationTimestamp: metav1.Time{Time: metav1.Now().Time}, + Labels: map[string]string{ + automotivev1alpha1.LabelScheduledImageBuildName: tt.schedule, + }, + }, + Spec: automotivev1alpha1.CatalogImageSpec{RegistryURL: "quay.io/test/newer:v1"}, + } + + h, _ := newTestHandler(older, newer) + router := gin.New() + router.GET("/catalog/images", h.HandleListCatalogImages) + + url := fmt.Sprintf("/catalog/images?latest=true&sort=%s", tt.sort) + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp CatalogImageListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp.Items) != 1 { + t.Fatalf("expected 1 item (latest per schedule), got %d", len(resp.Items)) + } + if resp.Items[0].Name != tt.wantName { + t.Errorf("expected %s, got %s", tt.wantName, resp.Items[0].Name) + } + }) + } +} + +func TestLatestGroupKey(t *testing.T) { + tests := []struct { + name string + labels map[string]string + want string + }{ + { + name: "scheduled image groups by schedule name", + labels: map[string]string{ + automotivev1alpha1.LabelScheduledImageBuildName: "nightly-qemu", + }, + want: "schedule:nightly-qemu", + }, + { + name: "non-scheduled groups by distro/arch/target", + labels: map[string]string{ + automotivev1alpha1.LabelDistro: "autosd", + automotivev1alpha1.LabelArchitecture: "x86_64", + automotivev1alpha1.LabelTarget: "qemu", + }, + want: "autosd/x86_64/qemu", + }, + { + name: "empty labels produce unique per-image key", + labels: map[string]string{}, + want: "name:test-img", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + img := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{Name: "test-img", Labels: tt.labels}, + } + got := latestGroupKey(img) + if got != tt.want { + t.Errorf("latestGroupKey() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestScheduleNameInResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + + img := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nightly-qemu-abc123", + Namespace: "default", + Labels: map[string]string{ + automotivev1alpha1.LabelScheduledImageBuildName: "nightly-qemu", + automotivev1alpha1.LabelSourceType: "Scheduled", + }, + }, + Spec: automotivev1alpha1.CatalogImageSpec{ + RegistryURL: "quay.io/test/image:latest", + }, + } + + h, _ := newTestHandler(img) + + router := gin.New() + router.GET("/catalog/images/:name", h.HandleGetCatalogImage) + + req := httptest.NewRequest(http.MethodGet, "/catalog/images/nightly-qemu-abc123?namespace=default", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp CatalogImageResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.ScheduleName != "nightly-qemu" { + t.Errorf("expected scheduleName %q, got %q", "nightly-qemu", resp.ScheduleName) + } + if resp.SourceType != "Scheduled" { + t.Errorf("expected sourceType %q, got %q", "Scheduled", resp.SourceType) + } +} + func TestHandleGetCatalogImage_NotFound(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/internal/buildapi/catalog/models.go b/internal/buildapi/catalog/models.go index 6dc0b8550..0fba79266 100644 --- a/internal/buildapi/catalog/models.go +++ b/internal/buildapi/catalog/models.go @@ -45,6 +45,7 @@ type CatalogImageResponse struct { CreatedAt time.Time `json:"createdAt"` SourceImageBuild string `json:"sourceImageBuild,omitempty"` SourceType string `json:"sourceType,omitempty"` + ScheduleName string `json:"scheduleName,omitempty"` BuildMode string `json:"buildMode,omitempty"` ExportFormat string `json:"exportFormat,omitempty"` Labels map[string]string `json:"labels,omitempty"` @@ -127,6 +128,8 @@ type ListQueryParams struct { Target string `form:"target"` Phase string `form:"phase"` Tags string `form:"tags"` + Sort string `form:"sort"` + Latest bool `form:"latest"` Limit int `form:"limit,default=20"` Continue string `form:"continue"` } @@ -166,6 +169,9 @@ func ToCatalogImageResponse(catalogImage *automotivev1alpha1.CatalogImage) Catal if sourceType, ok := catalogImage.Labels[automotivev1alpha1.LabelSourceType]; ok { response.SourceType = sourceType } + if scheduleName, ok := catalogImage.Labels[automotivev1alpha1.LabelScheduledImageBuildName]; ok { + response.ScheduleName = scheduleName + } // Extract registry metadata if catalogImage.Status.RegistryMetadata != nil { diff --git a/internal/controller/catalogimage/publisher.go b/internal/controller/catalogimage/publisher.go index a24607f88..5128cb804 100644 --- a/internal/controller/catalogimage/publisher.go +++ b/internal/controller/catalogimage/publisher.go @@ -62,6 +62,8 @@ type PublishOptions struct { Source PublishSource // SourceImageBuildName is the name of the source ImageBuild (if applicable) SourceImageBuildName string + // ScheduleName is the name of the ScheduledImageBuild that triggered this publish + ScheduleName string // VerifyAccessibility determines if registry accessibility should be verified VerifyAccessibility bool } @@ -210,6 +212,8 @@ func (p *Publisher) PublishFromImageBuild( log.Info("Publishing ImageBuild to catalog", "catalogName", catalogName, "registryURL", registryURL, "source", publishSource) + scheduleName := imageBuild.Labels[automotivev1alpha1.LabelScheduledImageBuildName] + return p.Publish(ctx, PublishOptions{ Name: catalogName, Namespace: imageBuild.Namespace, @@ -219,6 +223,7 @@ func (p *Publisher) PublishFromImageBuild( AuthSecretRef: authSecretRef, Source: publishSource, SourceImageBuildName: imageBuild.Name, + ScheduleName: scheduleName, VerifyAccessibility: true, }) } @@ -281,6 +286,10 @@ func (p *Publisher) buildCatalogImage(opts PublishOptions) *automotivev1alpha1.C // Set source type label catalogImage.Labels[automotivev1alpha1.LabelSourceType] = string(opts.Source) + if opts.ScheduleName != "" { + catalogImage.Labels[automotivev1alpha1.LabelScheduledImageBuildName] = opts.ScheduleName + } + return catalogImage } diff --git a/internal/controller/catalogimage/publisher_test.go b/internal/controller/catalogimage/publisher_test.go index 45a003af6..414f8b4bd 100644 --- a/internal/controller/catalogimage/publisher_test.go +++ b/internal/controller/catalogimage/publisher_test.go @@ -19,9 +19,73 @@ package catalogimage import ( "testing" + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" ) +func TestBuildCatalogImage_ScheduleNameLabel(t *testing.T) { + p := &Publisher{log: logr.Discard()} + + t.Run("sets schedule name label when provided", func(t *testing.T) { + ci := p.buildCatalogImage(PublishOptions{ + Name: "test-img", + Namespace: "default", + RegistryURL: "quay.io/test:latest", + Source: PublishSourceScheduled, + ScheduleName: "nightly-autosd-qemu", + }) + + got := ci.Labels[automotivev1alpha1.LabelScheduledImageBuildName] + if got != "nightly-autosd-qemu" { + t.Errorf("expected schedule label %q, got %q", "nightly-autosd-qemu", got) + } + }) + + t.Run("omits schedule name label when empty", func(t *testing.T) { + ci := p.buildCatalogImage(PublishOptions{ + Name: "test-img", + Namespace: "default", + RegistryURL: "quay.io/test:latest", + Source: PublishSourceManual, + }) + + if _, ok := ci.Labels[automotivev1alpha1.LabelScheduledImageBuildName]; ok { + t.Error("schedule label should not be set for manual publishes") + } + }) +} + +func TestPublishFromImageBuild_PropagatesScheduleName(t *testing.T) { + ib := &automotivev1alpha1.ImageBuild{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nightly-autosd-qemu-abc123", + Namespace: "default", + Labels: map[string]string{ + automotivev1alpha1.LabelScheduledImageBuildName: "nightly-autosd-qemu", + }, + }, + Spec: automotivev1alpha1.ImageBuildSpec{ + AIB: &automotivev1alpha1.AIBSpec{Mode: "bootc"}, + Export: &automotivev1alpha1.ExportSpec{ + Container: "quay.io/test/img:latest", + }, + }, + Status: automotivev1alpha1.ImageBuildStatus{Phase: "Completed"}, + } + + registryURL := ib.Spec.GetContainerPush() + if registryURL == "" { + t.Fatal("expected container push URL") + } + + scheduleName := ib.Labels[automotivev1alpha1.LabelScheduledImageBuildName] + if scheduleName != "nightly-autosd-qemu" { + t.Errorf("expected schedule name from ImageBuild label, got %q", scheduleName) + } +} + func TestResolvedExportFormatForCatalog(t *testing.T) { tests := []struct { name string diff --git a/test/e2e/catalog_discovery_test.go b/test/e2e/catalog_discovery_test.go new file mode 100644 index 000000000..195ce16e8 --- /dev/null +++ b/test/e2e/catalog_discovery_test.go @@ -0,0 +1,367 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // Dot import is standard for Ginkgo + . "github.com/onsi/gomega" //nolint:revive // Dot import is standard for Gomega + + utils "github.com/centos-automotive-suite/automotive-dev-operator/test/utils" +) + +// Catalog discovery e2e tests: sort, latest, schedule name display, detail view. + +var _ = Describe("Catalog Discovery", Label("catalog"), Ordered, func() { + const ( + catalogPrefix = "catdisc" + ) + + BeforeAll(func() { + ensureOperatorDeployed() + ensureBuildAPIAccess() + ensureCaibCredentials() + }) + + cleanupCatalogImage := func(name string) { + cmd := exec.Command("kubectl", "delete", "catalogimage", name, + "-n", testNamespace, "--ignore-not-found") + _, _ = utils.Run(cmd) + } + + applyCatalogImage := func(name string, labels map[string]string, tags []string, registryURL string) { + labelYAML := "" + if len(labels) > 0 { + labelYAML = " labels:\n" + for k, v := range labels { + labelYAML += fmt.Sprintf(" %s: %q\n", k, v) + } + } + + tagsYAML := "" + if len(tags) > 0 { + tagsYAML = " tags:\n" + for _, t := range tags { + tagsYAML += fmt.Sprintf(" - %s\n", t) + } + } + + cr := fmt.Sprintf(`apiVersion: automotive.sdv.cloud.redhat.com/v1alpha1 +kind: CatalogImage +metadata: + name: %s + namespace: %s +%s +spec: + registryUrl: %q +%s metadata: + architecture: x86_64 + distro: autosd + targets: + - name: qemu + verified: true +`, name, testNamespace, labelYAML, registryURL, tagsYAML) + + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(cr) + _, err := utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "failed to apply CatalogImage %s", name) + } + + waitForCatalogPhase := func(name, phase string) { + EventuallyWithOffset(1, func() error { + cmd := exec.Command("kubectl", "get", "catalogimage", name, + "-n", testNamespace, "-o", "jsonpath={.status.phase}") + output, err := utils.Run(cmd) + if err != nil { + return err + } + got := strings.TrimSpace(string(output)) + if got != phase { + return fmt.Errorf("CatalogImage %s phase is %q, want %q", name, got, phase) + } + return nil + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + } + + Context("Sort and Latest", func() { + olderName := catalogPrefix + "-older" + newerName := catalogPrefix + "-newer" + + BeforeAll(func() { + applyCatalogImage(olderName, map[string]string{ + "automotive.sdv.cloud.redhat.com/scheduledimagebuild-name": "nightly-qemu", + "automotive.sdv.cloud.redhat.com/source-type": "Scheduled", + "automotive.sdv.cloud.redhat.com/architecture": "x86_64", + "automotive.sdv.cloud.redhat.com/distro": "autosd", + "automotive.sdv.cloud.redhat.com/target": "qemu", + }, []string{"nightly"}, "registry.access.redhat.com/ubi9/ubi-micro:9.4") + + // Small delay so CreationTimestamps differ + time.Sleep(2 * time.Second) + + applyCatalogImage(newerName, map[string]string{ + "automotive.sdv.cloud.redhat.com/scheduledimagebuild-name": "nightly-qemu", + "automotive.sdv.cloud.redhat.com/source-type": "Scheduled", + "automotive.sdv.cloud.redhat.com/architecture": "x86_64", + "automotive.sdv.cloud.redhat.com/distro": "autosd", + "automotive.sdv.cloud.redhat.com/target": "qemu", + }, []string{"nightly"}, "registry.access.redhat.com/ubi9/ubi-micro:latest") + + DeferCleanup(func() { + cleanupCatalogImage(olderName) + cleanupCatalogImage(newerName) + }) + + waitForCatalogPhase(olderName, "Available") + waitForCatalogPhase(newerName, "Available") + }) + + It("should sort by created date (newest first) by default", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "list", + "--tags", "nightly", + "--output-format", "json", + ) + Expect(err).NotTo(HaveOccurred(), "caib catalog list failed: %s", string(output)) + + var result struct { + Items []struct { + Name string `json:"name"` + CreatedAt string `json:"createdAt"` + } `json:"items"` + } + Expect(json.Unmarshal(output, &result)).To(Succeed()) + Expect(result.Items).To(HaveLen(2)) + Expect(result.Items[0].Name).To(Equal(newerName), + "expected newest item first, got %s", result.Items[0].Name) + }) + + It("should show only latest per schedule with --latest", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "list", + "--tags", "nightly", + "--latest", + "--output-format", "json", + ) + Expect(err).NotTo(HaveOccurred(), "caib catalog list --latest failed: %s", string(output)) + + var result struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } + Expect(json.Unmarshal(output, &result)).To(Succeed()) + Expect(result.Items).To(HaveLen(1), "expected 1 item (latest per schedule)") + Expect(result.Items[0].Name).To(Equal(newerName)) + }) + + It("should display schedule name in SCHEDULE column in table output", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "list", + "--tags", "nightly", + "--latest", + ) + Expect(err).NotTo(HaveOccurred(), "caib catalog list table failed: %s", string(output)) + + lines := strings.Split(string(output), "\n") + Expect(lines[0]).To(ContainSubstring("SCHEDULE"), + "expected SCHEDULE column header, got: %s", lines[0]) + foundScheduleName := false + for _, line := range lines[1:] { + if strings.Contains(line, "nightly-qemu") { + foundScheduleName = true + break + } + } + Expect(foundScheduleName).To(BeTrue(), + "expected schedule name 'nightly-qemu' in table output, got:\n%s", string(output)) + }) + + It("should show AGE column instead of raw timestamp in table", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "list", + "--tags", "nightly", + "--latest", + ) + Expect(err).NotTo(HaveOccurred()) + + lines := strings.Split(string(output), "\n") + Expect(lines[0]).To(ContainSubstring("AGE"), + "expected AGE column header, got: %s", lines[0]) + Expect(lines[0]).NotTo(ContainSubstring("CREATED"), + "should not have CREATED column header") + }) + + It("should hide TAGS column when --tags filter is active", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "list", + "--tags", "nightly", + ) + Expect(err).NotTo(HaveOccurred()) + + header := strings.Split(string(output), "\n")[0] + Expect(header).NotTo(ContainSubstring("TAGS"), + "TAGS column should be hidden when filtering by tags") + }) + + It("should show TAGS column when no --tags filter", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "list", + "--distro", "autosd", + ) + Expect(err).NotTo(HaveOccurred()) + + header := strings.Split(string(output), "\n")[0] + Expect(header).To(ContainSubstring("TAGS"), + "TAGS column should be visible without --tags filter") + }) + }) + + Context("Catalog Get Detail View", func() { + detailName := catalogPrefix + "-detail" + + BeforeAll(func() { + applyCatalogImage(detailName, map[string]string{ + "automotive.sdv.cloud.redhat.com/scheduledimagebuild-name": "nightly-detail", + "automotive.sdv.cloud.redhat.com/source-type": "Scheduled", + "automotive.sdv.cloud.redhat.com/architecture": "x86_64", + "automotive.sdv.cloud.redhat.com/distro": "autosd", + "automotive.sdv.cloud.redhat.com/target": "qemu", + }, []string{"nightly", "qa"}, "registry.access.redhat.com/ubi9/ubi-micro:latest") + + DeferCleanup(func() { + cleanupCatalogImage(detailName) + }) + + waitForCatalogPhase(detailName, "Available") + }) + + It("should include schedule name and tags in JSON detail", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "get", detailName, + "--output-format", "json", + ) + Expect(err).NotTo(HaveOccurred(), "caib catalog get failed: %s", string(output)) + + var result map[string]interface{} + Expect(json.Unmarshal(output, &result)).To(Succeed()) + Expect(result["scheduleName"]).To(Equal("nightly-detail")) + Expect(result["sourceType"]).To(Equal("Scheduled")) + Expect(result["tags"]).To(ContainElements("nightly", "qa")) + }) + + It("should show schedule and tags in table detail", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "get", detailName, + ) + Expect(err).NotTo(HaveOccurred(), "caib catalog get table failed: %s", string(output)) + + text := string(output) + Expect(text).To(ContainSubstring("Schedule")) + Expect(text).To(ContainSubstring("nightly-detail")) + Expect(text).To(ContainSubstring("nightly, qa")) + }) + }) + + Context("Sort by Name", func() { + aName := catalogPrefix + "-aaa" + zName := catalogPrefix + "-zzz" + + BeforeAll(func() { + applyCatalogImage(zName, map[string]string{ + "automotive.sdv.cloud.redhat.com/architecture": "x86_64", + "automotive.sdv.cloud.redhat.com/distro": "autosd", + }, nil, "registry.access.redhat.com/ubi9/ubi-micro:9.4") + + applyCatalogImage(aName, map[string]string{ + "automotive.sdv.cloud.redhat.com/architecture": "x86_64", + "automotive.sdv.cloud.redhat.com/distro": "autosd", + }, nil, "registry.access.redhat.com/ubi9/ubi-micro:latest") + + DeferCleanup(func() { + cleanupCatalogImage(aName) + cleanupCatalogImage(zName) + }) + }) + + It("should sort alphabetically with --sort name", func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + output, err := runCaibCommand(ctx, + "catalog", "list", + "--distro", "autosd", + "--sort", "name", + "--output-format", "json", + ) + Expect(err).NotTo(HaveOccurred(), "caib catalog list --sort name failed: %s", string(output)) + + var result struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } + Expect(json.Unmarshal(output, &result)).To(Succeed()) + Expect(len(result.Items)).To(BeNumerically(">=", 2)) + + aIdx, zIdx := -1, -1 + for i, item := range result.Items { + if item.Name == aName { + aIdx = i + } + if item.Name == zName { + zIdx = i + } + } + Expect(aIdx).NotTo(Equal(-1), "expected %s in results", aName) + Expect(zIdx).NotTo(Equal(-1), "expected %s in results", zName) + Expect(aIdx).To(BeNumerically("<", zIdx), + "expected %s (idx %d) before %s (idx %d)", aName, aIdx, zName, zIdx) + }) + }) +}) From bcca2218bd07ded9840d059eefb514b7af5a9b17 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Mon, 27 Jul 2026 13:40:40 +0300 Subject: [PATCH 2/3] fix: update existing CatalogImage on duplicate registry URL Scheduled builds push to a fixed tag, so successive builds produce CatalogImages with the same registry URL. The old check-and-reject pattern caused all but the first publish to fail silently. Replace checkDuplicates() with a find-or-create pattern: look up an existing CatalogImage by registry URL field index, update its metadata if found, or create a new one otherwise. Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- .../catalogimage/catalogimage_controller.go | 2 +- internal/controller/catalogimage/publisher.go | 100 +++++++++---- .../controller/catalogimage/publisher_test.go | 140 +++++++++++++++++- 3 files changed, 208 insertions(+), 34 deletions(-) diff --git a/internal/controller/catalogimage/catalogimage_controller.go b/internal/controller/catalogimage/catalogimage_controller.go index ace308f56..4fc44a911 100644 --- a/internal/controller/catalogimage/catalogimage_controller.go +++ b/internal/controller/catalogimage/catalogimage_controller.go @@ -388,7 +388,7 @@ func (r *CatalogImageReconciler) ensureLabels(catalogImage *automotivev1alpha1.C catalogImage.Labels[automotivev1alpha1.LabelTarget] = catalogImage.Spec.Metadata.Targets[0].Name } if catalogImage.Spec.Metadata.Bootc { - catalogImage.Labels[automotivev1alpha1.LabelBootc] = "true" + catalogImage.Labels[automotivev1alpha1.LabelBootc] = labelValueTrue } } } diff --git a/internal/controller/catalogimage/publisher.go b/internal/controller/catalogimage/publisher.go index 5128cb804..8b398ef14 100644 --- a/internal/controller/catalogimage/publisher.go +++ b/internal/controller/catalogimage/publisher.go @@ -28,6 +28,8 @@ import ( automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" ) +const labelValueTrue = "true" + // PublishSource indicates where the catalog image was published from type PublishSource string @@ -101,19 +103,13 @@ type PublishResult struct { Metadata *automotivev1alpha1.RegistryMetadata } -// Publish creates a new CatalogImage and optionally verifies registry accessibility +// Publish creates or updates a CatalogImage and optionally verifies registry accessibility. +// When a CatalogImage with the same registry URL already exists (common for scheduled builds +// that push to a fixed tag), the existing entry is updated with the new build's metadata. func (p *Publisher) Publish(ctx context.Context, opts PublishOptions) (*PublishResult, error) { log := p.log.WithValues("name", opts.Name, "namespace", opts.Namespace, "registryURL", opts.RegistryURL) log.Info("Publishing image to catalog") - // Check for duplicate registry URLs - if err := p.checkDuplicates(ctx, opts.Namespace, opts.RegistryURL); err != nil { - return nil, err - } - - // Create the CatalogImage resource - catalogImage := p.buildCatalogImage(opts) - // Verify accessibility if requested var registryMetadata *automotivev1alpha1.RegistryMetadata var verified bool @@ -122,31 +118,48 @@ func (p *Publisher) Publish(ctx context.Context, opts PublishOptions) (*PublishR verified, registryMetadata, err = p.verifyAndExtractMetadata(ctx, opts) if err != nil { log.Error(err, "Failed to verify registry accessibility") - // Continue with creation, controller will handle verification } } - // Create the CatalogImage - if err := p.client.Create(ctx, catalogImage); err != nil { - return nil, fmt.Errorf("failed to create CatalogImage: %w", err) + catalogImage, err := p.findExistingByRegistryURL(ctx, opts.Namespace, opts.RegistryURL) + if err != nil { + return nil, err } - log.Info("Successfully created CatalogImage", "verified", verified) + if catalogImage != nil { + p.updateCatalogImage(catalogImage, opts) + + if err := p.client.Update(ctx, catalogImage); err != nil { + return nil, fmt.Errorf("failed to update CatalogImage: %w", err) + } + log.Info("Updated existing CatalogImage", "existingName", catalogImage.Name, "verified", verified) + } else { + catalogImage = p.buildCatalogImage(opts) + + if err := p.client.Create(ctx, catalogImage); err != nil { + return nil, fmt.Errorf("failed to create CatalogImage: %w", err) + } + log.Info("Successfully created CatalogImage", "verified", verified) + } - // Record audit event if p.auditRecorder != nil { p.auditRecorder.RecordPublished(ctx, catalogImage, string(opts.Source)) } - // If we verified and have metadata, update the status + statusChanged := false + if opts.SourceImageBuildName != "" { + catalogImage.Status.SourceImageBuild = opts.SourceImageBuildName + statusChanged = true + } if verified && registryMetadata != nil { catalogImage.Status.RegistryMetadata = registryMetadata catalogImage.Status.LastVerificationTime = GetCurrentTime() catalogImage.Status.Phase = automotivev1alpha1.CatalogImagePhaseAvailable - + statusChanged = true + } + if statusChanged { if err := p.client.Status().Update(ctx, catalogImage); err != nil { - log.Error(err, "Failed to update status with verification results") - // Non-fatal: controller will pick up verification on next reconcile + log.Error(err, "Failed to update status") } } @@ -228,17 +241,50 @@ func (p *Publisher) PublishFromImageBuild( }) } -// checkDuplicates checks if a CatalogImage with the same registry URL already exists -func (p *Publisher) checkDuplicates(ctx context.Context, namespace, registryURL string) error { +// findExistingByRegistryURL returns an existing CatalogImage with the same registry URL, or nil. +func (p *Publisher) findExistingByRegistryURL(ctx context.Context, namespace, registryURL string) (*automotivev1alpha1.CatalogImage, error) { lister := NewCatalogImageLister(p.client) - exists, err := lister.ExistsByRegistryURL(ctx, namespace, registryURL) + list, err := lister.ListByRegistryURL(ctx, namespace, registryURL) if err != nil { - return fmt.Errorf("failed to check for duplicates: %w", err) + return nil, fmt.Errorf("failed to check for existing catalog image: %w", err) } - if exists { - return fmt.Errorf("catalog image with registry URL %q already exists in namespace %s", registryURL, namespace) + if len(list.Items) == 0 { + return nil, nil + } + return &list.Items[0], nil +} + +// updateCatalogImage applies new PublishOptions to an existing CatalogImage. +func (p *Publisher) updateCatalogImage(catalogImage *automotivev1alpha1.CatalogImage, opts PublishOptions) { + catalogImage.Spec.Digest = opts.Digest + catalogImage.Spec.Tags = opts.Tags + catalogImage.Spec.AuthSecretRef = opts.AuthSecretRef + catalogImage.Spec.Metadata = opts.Metadata + + if catalogImage.Labels == nil { + catalogImage.Labels = make(map[string]string) + } + delete(catalogImage.Labels, automotivev1alpha1.LabelTarget) + delete(catalogImage.Labels, automotivev1alpha1.LabelBootc) + delete(catalogImage.Labels, automotivev1alpha1.LabelScheduledImageBuildName) + if opts.Metadata != nil { + if opts.Metadata.Architecture != "" { + catalogImage.Labels[automotivev1alpha1.LabelArchitecture] = NormalizeArchitecture(opts.Metadata.Architecture) + } + if opts.Metadata.Distro != "" { + catalogImage.Labels[automotivev1alpha1.LabelDistro] = opts.Metadata.Distro + } + if len(opts.Metadata.Targets) > 0 { + catalogImage.Labels[automotivev1alpha1.LabelTarget] = opts.Metadata.Targets[0].Name + } + if opts.Metadata.Bootc { + catalogImage.Labels[automotivev1alpha1.LabelBootc] = labelValueTrue + } + } + catalogImage.Labels[automotivev1alpha1.LabelSourceType] = string(opts.Source) + if opts.ScheduleName != "" { + catalogImage.Labels[automotivev1alpha1.LabelScheduledImageBuildName] = opts.ScheduleName } - return nil } // buildCatalogImage creates a CatalogImage resource from PublishOptions @@ -279,7 +325,7 @@ func (p *Publisher) buildCatalogImage(opts PublishOptions) *automotivev1alpha1.C catalogImage.Labels[automotivev1alpha1.LabelTarget] = opts.Metadata.Targets[0].Name } if opts.Metadata.Bootc { - catalogImage.Labels[automotivev1alpha1.LabelBootc] = "true" + catalogImage.Labels[automotivev1alpha1.LabelBootc] = labelValueTrue } } diff --git a/internal/controller/catalogimage/publisher_test.go b/internal/controller/catalogimage/publisher_test.go index 414f8b4bd..ffa75ed1c 100644 --- a/internal/controller/catalogimage/publisher_test.go +++ b/internal/controller/catalogimage/publisher_test.go @@ -17,10 +17,17 @@ limitations under the License. package catalogimage import ( + "context" "testing" + "github.com/containers/image/v5/types" "github.com/go-logr/logr" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" ) @@ -57,6 +64,34 @@ func TestBuildCatalogImage_ScheduleNameLabel(t *testing.T) { }) } +type stubRegistryClient struct{} + +func (s *stubRegistryClient) VerifyImageAccessible(_ context.Context, _ string, _ *types.DockerAuthConfig) (bool, error) { + return true, nil +} + +func (s *stubRegistryClient) GetImageMetadata(_ context.Context, _ string, _ *types.DockerAuthConfig) (*automotivev1alpha1.RegistryMetadata, error) { + return &automotivev1alpha1.RegistryMetadata{SizeBytes: 1024}, nil +} + +func (s *stubRegistryClient) VerifyDigest(_ context.Context, _ string, _ string, _ *types.DockerAuthConfig) (bool, string, error) { + return true, "sha256:abc123", nil +} + +func newFakePublisherWithRegistry(objs ...client.Object) *Publisher { + scheme := newPublisherTestScheme() + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&automotivev1alpha1.CatalogImage{}). + WithIndex(&automotivev1alpha1.CatalogImage{}, "spec.registryUrl", func(o client.Object) []string { + ci := o.(*automotivev1alpha1.CatalogImage) + return []string{ci.Spec.RegistryURL} + }). + Build() + return NewPublisher(c, &stubRegistryClient{}, nil, logr.Discard()) +} + func TestPublishFromImageBuild_PropagatesScheduleName(t *testing.T) { ib := &automotivev1alpha1.ImageBuild{ ObjectMeta: metav1.ObjectMeta{ @@ -75,14 +110,107 @@ func TestPublishFromImageBuild_PropagatesScheduleName(t *testing.T) { Status: automotivev1alpha1.ImageBuildStatus{Phase: "Completed"}, } - registryURL := ib.Spec.GetContainerPush() - if registryURL == "" { - t.Fatal("expected container push URL") + pub := newFakePublisherWithRegistry() + res, err := pub.PublishFromImageBuild(context.Background(), ib, "", nil, nil, PublishSourceScheduled) + if err != nil { + t.Fatalf("PublishFromImageBuild() error: %v", err) + } + if got := res.CatalogImage.Labels[automotivev1alpha1.LabelScheduledImageBuildName]; got != "nightly-autosd-qemu" { + t.Errorf("expected schedule label propagated, got %q", got) + } +} + +func newPublisherTestScheme() *runtime.Scheme { + s := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(s)) + utilruntime.Must(automotivev1alpha1.AddToScheme(s)) + return s +} + +func newFakePublisher(objs ...client.Object) *Publisher { + scheme := newPublisherTestScheme() + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&automotivev1alpha1.CatalogImage{}). + WithIndex(&automotivev1alpha1.CatalogImage{}, "spec.registryUrl", func(o client.Object) []string { + ci := o.(*automotivev1alpha1.CatalogImage) + return []string{ci.Spec.RegistryURL} + }). + Build() + return NewPublisher(c, nil, nil, logr.Discard()) +} + +func TestPublish_UpdatesExistingOnDuplicateRegistryURL(t *testing.T) { + existing := &automotivev1alpha1.CatalogImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "old-build-xyz", + Namespace: "default", + Labels: map[string]string{ + automotivev1alpha1.LabelScheduledImageBuildName: "nightly-qemu", + automotivev1alpha1.LabelSourceType: string(PublishSourceScheduled), + }, + }, + Spec: automotivev1alpha1.CatalogImageSpec{ + RegistryURL: "quay.io/test/img:nightly", + Tags: []string{"old-tag"}, + Metadata: &automotivev1alpha1.CatalogImageMetadata{ + Architecture: "x86_64", + Distro: "autosd", + }, + }, + } + + pub := newFakePublisher(existing) + + result, err := pub.Publish(context.Background(), PublishOptions{ + Name: "new-build-abc", + Namespace: "default", + RegistryURL: "quay.io/test/img:nightly", + Tags: []string{"updated-tag"}, + Source: PublishSourceScheduled, + Metadata: &automotivev1alpha1.CatalogImageMetadata{ + Architecture: "aarch64", + Distro: "autosd", + }, + ScheduleName: "nightly-qemu", + SourceImageBuildName: "new-build-abc", + }) + if err != nil { + t.Fatalf("Publish() error: %v", err) + } + + if result.CatalogImage.Name != "old-build-xyz" { + t.Errorf("expected existing CatalogImage name %q, got %q", "old-build-xyz", result.CatalogImage.Name) + } + if len(result.CatalogImage.Spec.Tags) != 1 || result.CatalogImage.Spec.Tags[0] != "updated-tag" { + t.Errorf("expected tags [updated-tag], got %v", result.CatalogImage.Spec.Tags) + } + if result.CatalogImage.Spec.Metadata.Architecture != "aarch64" { + t.Errorf("expected arch aarch64, got %q", result.CatalogImage.Spec.Metadata.Architecture) + } +} + +func TestPublish_CreatesNewWhenNoExisting(t *testing.T) { + pub := newFakePublisher() + + result, err := pub.Publish(context.Background(), PublishOptions{ + Name: "fresh-build", + Namespace: "default", + RegistryURL: "quay.io/test/img:v1", + Tags: []string{"release"}, + Source: PublishSourceManual, + Metadata: &automotivev1alpha1.CatalogImageMetadata{ + Architecture: "x86_64", + Distro: "autosd", + }, + }) + if err != nil { + t.Fatalf("Publish() error: %v", err) } - scheduleName := ib.Labels[automotivev1alpha1.LabelScheduledImageBuildName] - if scheduleName != "nightly-autosd-qemu" { - t.Errorf("expected schedule name from ImageBuild label, got %q", scheduleName) + if result.CatalogImage.Name != "fresh-build" { + t.Errorf("expected name %q, got %q", "fresh-build", result.CatalogImage.Name) } } From c4d294cbb8759d4679c5e8402898479672025482 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Mon, 27 Jul 2026 19:32:42 +0300 Subject: [PATCH 3/3] fix: remove namespace semantics from catalog CLI and API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog CLI defaulted to namespace "default" when --namespace wasn't provided, causing lookups to fail when CatalogImages live in the operator namespace. Since users have no direct OpenShift access, namespace selection is unnecessary — the server already resolves its own namespace from the pod's service account. Remove --namespace/-n and --all-namespaces flags from all catalog subcommands. Strip namespace query params from CLI requests and namespace fields from API models. Server handlers now always use h.defaultNamespace instead of accepting client-provided namespace overrides. Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- cmd/caib/catalog/add.go | 7 +--- cmd/caib/catalog/catalog.go | 3 -- cmd/caib/catalog/get.go | 10 +---- cmd/caib/catalog/list.go | 22 ++++------ cmd/caib/catalog/publish.go | 21 ++++------ cmd/caib/catalog/remove.go | 9 +---- cmd/caib/catalog/verify.go | 11 ++--- internal/buildapi/catalog/handlers.go | 47 ++++++---------------- internal/buildapi/catalog/handlers_test.go | 8 ++-- internal/buildapi/catalog/models.go | 10 ++--- 10 files changed, 42 insertions(+), 106 deletions(-) diff --git a/cmd/caib/catalog/add.go b/cmd/caib/catalog/add.go index fb7d2cbed..eeee05a27 100644 --- a/cmd/caib/catalog/add.go +++ b/cmd/caib/catalog/add.go @@ -108,11 +108,6 @@ func runAdd(cmd *cobra.Command, args []string) error { token = os.Getenv("CAIB_TOKEN") } - ns := namespace - if ns == "" { - ns = defaultNamespace - } - clilog.Infof("Adding image to catalog...\n") clilog.Infof("✓ Validating registry URL\n") @@ -137,7 +132,7 @@ func runAdd(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to marshal request: %w", err) } - reqURL := fmt.Sprintf("%s/v1/catalog/images?namespace=%s", server, ns) + reqURL := fmt.Sprintf("%s/v1/catalog/images", server) req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("failed to create request: %w", err) diff --git a/cmd/caib/catalog/catalog.go b/cmd/caib/catalog/catalog.go index ae39998e7..aca2f512b 100644 --- a/cmd/caib/catalog/catalog.go +++ b/cmd/caib/catalog/catalog.go @@ -27,14 +27,12 @@ import ( ) const ( - defaultNamespace = "default" outputFormatTable = "table" ) var ( serverURL string authToken string - namespace string ) // NewCatalogCmd creates the catalog command with subcommands @@ -60,7 +58,6 @@ func NewCatalogCmd() *cobra.Command { func addCommonFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&serverURL, "server", "", "REST API server base URL (env: CAIB_SERVER)") cmd.Flags().StringVar(&authToken, "token", "", "Bearer token for authentication (env: CAIB_TOKEN)") - cmd.Flags().StringVarP(&namespace, "namespace", "n", "", "Kubernetes namespace") } // getOutputFormat returns the output format from the root command's --output-format flag. diff --git a/cmd/caib/catalog/get.go b/cmd/caib/catalog/get.go index 504acda57..1b92907bf 100644 --- a/cmd/caib/catalog/get.go +++ b/cmd/caib/catalog/get.go @@ -59,12 +59,7 @@ func runGet(cmd *cobra.Command, args []string) error { token = os.Getenv("CAIB_TOKEN") } - ns := namespace - if ns == "" { - ns = defaultNamespace - } - - reqURL := fmt.Sprintf("%s/v1/catalog/images/%s?namespace=%s", server, name, ns) + reqURL := fmt.Sprintf("%s/v1/catalog/images/%s", server, name) req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { @@ -87,7 +82,7 @@ func runGet(cmd *cobra.Command, args []string) error { }() if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("catalog image %q not found in namespace %q", name, ns) + return fmt.Errorf("catalog image %q not found", name) } if resp.StatusCode != http.StatusOK { @@ -148,7 +143,6 @@ func printImageDetails(img CatalogImageResponse) { rows := [][2]string{ {"Name", img.Name}, - {"Namespace", img.Namespace}, {"Registry URL", img.RegistryURL}, {"Phase", img.Phase}, {"Architecture", img.Architecture}, diff --git a/cmd/caib/catalog/list.go b/cmd/caib/catalog/list.go index 0aaddf06b..e1301d67b 100644 --- a/cmd/caib/catalog/list.go +++ b/cmd/caib/catalog/list.go @@ -34,15 +34,14 @@ import ( ) var ( - listArchitecture string - listDistro string - listTarget string - listPhase string - listTags string - listSort string - listLatest bool - listLimit int - listAllNamespaces bool + listArchitecture string + listDistro string + listTarget string + listPhase string + listTags string + listSort string + listLatest bool + listLimit int ) func newListCmd() *cobra.Command { @@ -64,7 +63,6 @@ target, and phase. Use --latest to show only the newest image per schedule cmd.Flags().StringVar(&listSort, "sort", "created", "Sort order: created (newest first), name") cmd.Flags().BoolVar(&listLatest, "latest", false, "Show only the latest image per schedule or distro/arch/target group") cmd.Flags().IntVar(&listLimit, "limit", 20, "Maximum results to show") - cmd.Flags().BoolVar(&listAllNamespaces, "all-namespaces", false, "List images across all namespaces") return cmd } @@ -83,7 +81,6 @@ type CatalogImageListResponse struct { //nolint:revive // Name intentionally includes package name for clarity in CLI context type CatalogImageResponse struct { Name string `json:"name"` - Namespace string `json:"namespace"` RegistryURL string `json:"registryUrl"` Phase string `json:"phase"` Architecture string `json:"architecture,omitempty"` @@ -126,9 +123,6 @@ func runList(cmd *cobra.Command, _ []string) error { // Build query parameters params := url.Values{} - if namespace != "" && !listAllNamespaces { - params.Set("namespace", namespace) - } if listArchitecture != "" { params.Set("architecture", listArchitecture) } diff --git a/cmd/caib/catalog/publish.go b/cmd/caib/catalog/publish.go index 2228f5c13..1322dca51 100644 --- a/cmd/caib/catalog/publish.go +++ b/cmd/caib/catalog/publish.go @@ -53,10 +53,9 @@ func newPublishCmd() *cobra.Command { } type publishRequest struct { - ImageBuildName string `json:"imageBuildName"` - ImageBuildNamespace string `json:"imageBuildNamespace"` - CatalogImageName string `json:"catalogImageName,omitempty"` - Tags []string `json:"tags,omitempty"` + ImageBuildName string `json:"imageBuildName"` + CatalogImageName string `json:"catalogImageName,omitempty"` + Tags []string `json:"tags,omitempty"` } func runPublish(cmd *cobra.Command, args []string) error { @@ -75,18 +74,12 @@ func runPublish(cmd *cobra.Command, args []string) error { token = os.Getenv("CAIB_TOKEN") } - ns := namespace - if ns == "" { - ns = defaultNamespace - } - clilog.Infof("Publishing ImageBuild %q to catalog...\n", imageBuildName) reqBody := publishRequest{ - ImageBuildName: imageBuildName, - ImageBuildNamespace: ns, - CatalogImageName: publishCatalogName, - Tags: publishTags, + ImageBuildName: imageBuildName, + CatalogImageName: publishCatalogName, + Tags: publishTags, } bodyBytes, err := json.Marshal(reqBody) @@ -119,7 +112,7 @@ func runPublish(cmd *cobra.Command, args []string) error { body, _ := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("ImageBuild %q not found in namespace %q", imageBuildName, ns) + return fmt.Errorf("ImageBuild %q not found", imageBuildName) } if resp.StatusCode != http.StatusCreated { diff --git a/cmd/caib/catalog/remove.go b/cmd/caib/catalog/remove.go index 6d22c71d6..b7c0bd77c 100644 --- a/cmd/caib/catalog/remove.go +++ b/cmd/caib/catalog/remove.go @@ -64,11 +64,6 @@ func runRemove(cmd *cobra.Command, args []string) error { token = os.Getenv("CAIB_TOKEN") } - ns := namespace - if ns == "" { - ns = defaultNamespace - } - // Confirm deletion if !removeForce { clilog.Infof("Removing catalog image %q...\n", name) @@ -82,7 +77,7 @@ func runRemove(cmd *cobra.Command, args []string) error { } } - reqURL := fmt.Sprintf("%s/v1/catalog/images/%s?namespace=%s", server, name, ns) + reqURL := fmt.Sprintf("%s/v1/catalog/images/%s", server, name) req, err := http.NewRequest(http.MethodDelete, reqURL, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -104,7 +99,7 @@ func runRemove(cmd *cobra.Command, args []string) error { }() if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("catalog image %q not found in namespace %q", name, ns) + return fmt.Errorf("catalog image %q not found", name) } if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { diff --git a/cmd/caib/catalog/verify.go b/cmd/caib/catalog/verify.go index 4c1bd8568..891323ce3 100644 --- a/cmd/caib/catalog/verify.go +++ b/cmd/caib/catalog/verify.go @@ -68,14 +68,9 @@ func runVerify(cmd *cobra.Command, args []string) error { token = os.Getenv("CAIB_TOKEN") } - ns := namespace - if ns == "" { - ns = defaultNamespace - } - clilog.Infof("Verifying catalog image %q...\n", name) - reqURL := fmt.Sprintf("%s/v1/catalog/images/%s/verify?namespace=%s", server, name, ns) + reqURL := fmt.Sprintf("%s/v1/catalog/images/%s/verify", server, name) req, err := http.NewRequest(http.MethodPost, reqURL, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -97,7 +92,7 @@ func runVerify(cmd *cobra.Command, args []string) error { }() if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("catalog image %q not found in namespace %q", name, ns) + return fmt.Errorf("catalog image %q not found", name) } body, _ := io.ReadAll(resp.Body) @@ -119,7 +114,7 @@ func runVerify(cmd *cobra.Command, args []string) error { // Optionally get updated status if verifyWait { - getURL := fmt.Sprintf("%s/v1/catalog/images/%s?namespace=%s", server, name, ns) + getURL := fmt.Sprintf("%s/v1/catalog/images/%s", server, name) getReq, _ := http.NewRequest(http.MethodGet, getURL, nil) if token != "" { getReq.Header.Set("Authorization", "Bearer "+token) diff --git a/internal/buildapi/catalog/handlers.go b/internal/buildapi/catalog/handlers.go index 755ff387e..8f7cadf8d 100644 --- a/internal/buildapi/catalog/handlers.go +++ b/internal/buildapi/catalog/handlers.go @@ -69,12 +69,7 @@ func (h *Handler) HandleListCatalogImages(c *gin.Context) { // Build list options listOpts := []client.ListOption{} - // Namespace filtering — always scope to a namespace - ns := params.Namespace - if ns == "" { - ns = h.defaultNamespace - } - listOpts = append(listOpts, client.InNamespace(ns)) + listOpts = append(listOpts, client.InNamespace(h.defaultNamespace)) // Build label selector for filtering labelRequirements := []string{} @@ -141,19 +136,14 @@ func (h *Handler) HandleListCatalogImages(c *gin.Context) { func (h *Handler) HandleGetCatalogImage(c *gin.Context) { ctx := context.Background() name := c.Param("name") - namespace := c.Query("namespace") - - if namespace == "" { - namespace = h.defaultNamespace - } catalogImage := &automotivev1alpha1.CatalogImage{} - if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, catalogImage); err != nil { + if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: h.defaultNamespace}, catalogImage); err != nil { if client.IgnoreNotFound(err) == nil { c.JSON(http.StatusNotFound, gin.H{"error": "catalog image not found"}) return } - h.log.Error(err, "failed to get catalog image", "name", name, "namespace", namespace) + h.log.Error(err, "failed to get catalog image", "name", name) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get catalog image"}) return } @@ -165,10 +155,7 @@ func (h *Handler) HandleGetCatalogImage(c *gin.Context) { // HandleCreateCatalogImage creates a new catalog image func (h *Handler) HandleCreateCatalogImage(c *gin.Context) { ctx := context.Background() - namespace := c.Query("namespace") - if namespace == "" { - namespace = h.defaultNamespace - } + namespace := h.defaultNamespace var req CreateCatalogImageRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -236,19 +223,14 @@ func (h *Handler) HandleCreateCatalogImage(c *gin.Context) { func (h *Handler) HandleDeleteCatalogImage(c *gin.Context) { ctx := context.Background() name := c.Param("name") - namespace := c.Query("namespace") - - if namespace == "" { - namespace = h.defaultNamespace - } catalogImage := &automotivev1alpha1.CatalogImage{} - if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, catalogImage); err != nil { + if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: h.defaultNamespace}, catalogImage); err != nil { if client.IgnoreNotFound(err) == nil { c.JSON(http.StatusNotFound, gin.H{"error": "catalog image not found"}) return } - h.log.Error(err, "failed to get catalog image", "name", name, "namespace", namespace) + h.log.Error(err, "failed to get catalog image", "name", name) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get catalog image"}) return } @@ -259,7 +241,7 @@ func (h *Handler) HandleDeleteCatalogImage(c *gin.Context) { return } - h.log.Info("deleted catalog image", "name", name, "namespace", namespace) + h.log.Info("deleted catalog image", "name", name) c.Status(http.StatusNoContent) } @@ -267,19 +249,14 @@ func (h *Handler) HandleDeleteCatalogImage(c *gin.Context) { func (h *Handler) HandleVerifyCatalogImage(c *gin.Context) { ctx := context.Background() name := c.Param("name") - namespace := c.Query("namespace") - - if namespace == "" { - namespace = h.defaultNamespace - } catalogImage := &automotivev1alpha1.CatalogImage{} - if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, catalogImage); err != nil { + if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: h.defaultNamespace}, catalogImage); err != nil { if client.IgnoreNotFound(err) == nil { c.JSON(http.StatusNotFound, gin.H{"error": "catalog image not found"}) return } - h.log.Error(err, "failed to get catalog image", "name", name, "namespace", namespace) + h.log.Error(err, "failed to get catalog image", "name", name) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get catalog image"}) return } @@ -292,7 +269,7 @@ func (h *Handler) HandleVerifyCatalogImage(c *gin.Context) { return } - h.log.Info("triggered verification for catalog image", "name", name, "namespace", namespace) + h.log.Info("triggered verification for catalog image", "name", name) c.JSON(http.StatusOK, VerifyImageResponse{ Message: "Verification triggered successfully", Triggered: true, @@ -312,7 +289,7 @@ func (h *Handler) HandlePublishImageBuild(c *gin.Context) { // Get the ImageBuild imageBuild := &automotivev1alpha1.ImageBuild{} if err := h.client.Get( - ctx, client.ObjectKey{Name: req.ImageBuildName, Namespace: req.ImageBuildNamespace}, imageBuild, + ctx, client.ObjectKey{Name: req.ImageBuildName, Namespace: h.defaultNamespace}, imageBuild, ); err != nil { if client.IgnoreNotFound(err) == nil { c.JSON(http.StatusNotFound, gin.H{"error": "ImageBuild not found"}) @@ -349,7 +326,7 @@ func (h *Handler) HandlePublishImageBuild(c *gin.Context) { // Create CatalogImage catalogImage := &automotivev1alpha1.CatalogImage{} catalogImage.Name = catalogImageName - catalogImage.Namespace = req.ImageBuildNamespace + catalogImage.Namespace = h.defaultNamespace catalogImage.Spec = automotivev1alpha1.CatalogImageSpec{ RegistryURL: registryURL, Tags: req.Tags, diff --git a/internal/buildapi/catalog/handlers_test.go b/internal/buildapi/catalog/handlers_test.go index bf6815d2b..03cc7888c 100644 --- a/internal/buildapi/catalog/handlers_test.go +++ b/internal/buildapi/catalog/handlers_test.go @@ -58,7 +58,7 @@ func TestHandleGetCatalogImage_DoesNotWrite(t *testing.T) { router := gin.New() router.GET("/catalog/images/:name", h.HandleGetCatalogImage) - req := httptest.NewRequest(http.MethodGet, "/catalog/images/test-image?namespace=default", nil) + req := httptest.NewRequest(http.MethodGet, "/catalog/images/test-image", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -109,7 +109,7 @@ func TestHandleListCatalogImages_SortByCreated(t *testing.T) { router := gin.New() router.GET("/catalog/images", h.HandleListCatalogImages) - req := httptest.NewRequest(http.MethodGet, "/catalog/images?namespace=default&sort=created", nil) + req := httptest.NewRequest(http.MethodGet, "/catalog/images?sort=created", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -274,7 +274,7 @@ func TestScheduleNameInResponse(t *testing.T) { router := gin.New() router.GET("/catalog/images/:name", h.HandleGetCatalogImage) - req := httptest.NewRequest(http.MethodGet, "/catalog/images/nightly-qemu-abc123?namespace=default", nil) + req := httptest.NewRequest(http.MethodGet, "/catalog/images/nightly-qemu-abc123", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -302,7 +302,7 @@ func TestHandleGetCatalogImage_NotFound(t *testing.T) { router := gin.New() router.GET("/catalog/images/:name", h.HandleGetCatalogImage) - req := httptest.NewRequest(http.MethodGet, "/catalog/images/nonexistent?namespace=default", nil) + req := httptest.NewRequest(http.MethodGet, "/catalog/images/nonexistent", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/internal/buildapi/catalog/models.go b/internal/buildapi/catalog/models.go index 0fba79266..2414306fb 100644 --- a/internal/buildapi/catalog/models.go +++ b/internal/buildapi/catalog/models.go @@ -28,7 +28,6 @@ import ( //nolint:revive // Name intentionally includes package name for clarity in external API type CatalogImageResponse struct { Name string `json:"name"` - Namespace string `json:"namespace"` RegistryURL string `json:"registryUrl"` Digest string `json:"digest,omitempty"` Tags []string `json:"tags,omitempty"` @@ -108,10 +107,9 @@ type CreateCatalogImageRequest struct { // PublishImageBuildRequest represents a request to publish an ImageBuild to the catalog type PublishImageBuildRequest struct { - ImageBuildName string `json:"imageBuildName" binding:"required"` - ImageBuildNamespace string `json:"imageBuildNamespace" binding:"required"` - CatalogImageName string `json:"catalogImageName,omitempty"` - Tags []string `json:"tags,omitempty"` + ImageBuildName string `json:"imageBuildName" binding:"required"` + CatalogImageName string `json:"catalogImageName,omitempty"` + Tags []string `json:"tags,omitempty"` } // VerifyImageResponse represents the response from verifying an image @@ -122,7 +120,6 @@ type VerifyImageResponse struct { // ListQueryParams represents query parameters for listing catalog images type ListQueryParams struct { - Namespace string `form:"namespace"` Architecture string `form:"architecture"` Distro string `form:"distro"` Target string `form:"target"` @@ -138,7 +135,6 @@ type ListQueryParams struct { func ToCatalogImageResponse(catalogImage *automotivev1alpha1.CatalogImage) CatalogImageResponse { response := CatalogImageResponse{ Name: catalogImage.Name, - Namespace: catalogImage.Namespace, RegistryURL: catalogImage.Spec.RegistryURL, Digest: catalogImage.Spec.Digest, Tags: catalogImage.Spec.Tags,