diff --git a/README.md b/README.md index 96d0cb8..c259b22 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,64 @@ Similarly, the deployment(s) can be torn down using: ./bin/roxie teardown [ ] ``` +## Local Image Support for Kind Clusters + +Roxie automatically detects and uses locally-built container images when deploying to kind clusters, eliminating the need to push images to quay.io during development. + +### How It Works + +When deploying to a kind cluster, roxie: + +1. Checks if images exist locally in podman +2. Loads found images into the kind cluster +3. Skips credential verification if all images are local +4. Falls back to quay.io for any missing images + +### Requirements + +- kind cluster (context name must start with "kind") +- podman with images built locally +- Images tagged with `quay.io//:` + +### Supported Images + +Main images (7): +- main, central-db +- scanner, scanner-db +- scanner-v4, scanner-v4-db +- collector + +Operator images (2): +- stackrox-operator +- stackrox-operator-bundle + +### Environment Variables + +**ROX_PRODUCT_BRANDING**: Controls which registry organization to check +- `RHACS_BRANDING` → `quay.io/rhacs-eng` (default) +- `STACKROX_BRANDING` → `quay.io/stackrox-io` + +**ROXIE_SKIP_LOCAL_IMAGES**: Set to `true` to disable local image detection and force quay.io pulls + +### Example Workflow + +```bash +# Build stackrox locally (images go to podman) +cd /path/to/stackrox +make image + +# Deploy to kind - roxie automatically uses local images +cd /path/to/roxie +./roxie deploy +``` + +### Behavior + +- **All images local**: Skips credential verification, fast deployment +- **Some images local**: Loads local ones, pulls remaining from quay.io +- **No images local**: Normal quay.io workflow (backward compatible) +- **Non-kind cluster**: Skips local image detection entirely + ## Development Enter the dev shell: diff --git a/docs/plans/2026-01-20-local-image-support-design.md b/docs/plans/2026-01-20-local-image-support-design.md new file mode 100644 index 0000000..3b69dc3 --- /dev/null +++ b/docs/plans/2026-01-20-local-image-support-design.md @@ -0,0 +1,270 @@ +# Local Image Support for Kind Clusters + +**Date**: 2026-01-20 +**Status**: Implemented (with variations from original design) +**Author**: Design session with user + +> **Note**: This document reflects the initial design. The final implementation differs in several areas: +> - Image detection checks both branding organizations (not just ROX_PRODUCT_BRANDING) +> - Localhost paths were removed (only quay.io paths used) +> - Added CSV patching for operator deployments +> - Image list includes collector, scanner-v4, and stackrox-operator +> - See git history (commits bc376ad through 9d8a54b) for implementation evolution + +## Overview + +Enable roxie to automatically use locally-built container images when deploying to kind clusters, eliminating the need to push images to quay.io during local development. + +## Problem Statement + +Current workflow for testing local ACS builds: +1. Build stackrox locally (images go to podman) +2. Push images to quay.io +3. Deploy via roxie from quay.io + +This upload/download cycle is slow and unnecessary for local kind cluster testing. + +## Goals + +- **Primary**: Seamless local development workflow - build locally, deploy locally with zero extra steps +- **Zero configuration**: Automatic detection and transparent fallback +- **Fast**: Eliminate network round-trip to quay.io +- **Reliable**: Fail fast on errors, don't silently fall back + +## Non-Goals + +- Support for non-kind local clusters (minikube, k3d, etc.) - can be added later if needed +- Support for remote kind clusters +- Image building or tagging - assumes images already exist in podman + +## Design + +### High-Level Architecture + +**Core Strategy**: Automatic detection and transparent fallback + +- When deploying to a kind cluster, roxie automatically detects if images exist locally in podman +- If local images exist, roxie loads them into kind before deployment +- If local images don't exist, roxie falls back to pulling from quay.io (existing behavior) +- Zero configuration required - it "just works" + +**Detection Chain**: +1. Is this a kind cluster? - Check if current KUBECONFIG context points to a kind cluster +2. Are images available locally? - Check podman for images using branding-aware paths +3. Load if both true - Use `kind load docker-image` to transfer images from podman to kind + +### Branding Support + +**ROX_PRODUCT_BRANDING Environment Variable**: +- Read `ROX_PRODUCT_BRANDING` environment variable +- Default to `RHACS_BRANDING` if not set +- Branding determines registry organization: + - `RHACS_BRANDING` → `quay.io/rhacs-eng` + - `STACKROX_BRANDING` → `quay.io/stackrox-io` + +**Image Naming**: +- Local stackrox builds create dual-tagged images: + - `localhost/stackrox/:` + - `quay.io//:` +- Both tags point to the same image ID in podman + +### Image Inventory and Detection + +**Images Required for Deployment** (as implemented): + +Main images (7): +- `main:` +- `scanner:` +- `scanner-db:` +- `scanner-v4:` +- `scanner-v4-db:` +- `central-db:` +- `collector:` + +Operator images (2): +- `stackrox-operator:` +- `stackrox-operator-bundle:v` + +Total: 9 images + +Note: Original design included scanner-v4-indexer, scanner-v4-matcher, and stackrox-operator-index, but these were removed/consolidated during implementation. + +**Detection Algorithm** (as implemented): + +For each required image: +``` +Function: checkLocalImage(imageName, tag) + 1. Determine primary org based on ROX_PRODUCT_BRANDING (defaults to "rhacs-eng") + 2. Determine fallback org (the other branding org) + 3. Check: podman image exists quay.io//: + 4. If not found, check: podman image exists quay.io//: + 5. Return: (imageRef, true, nil) if found, ("", false, nil) if not found +``` + +**Key Implementation Differences**: +- ✅ Checks BOTH branding orgs to handle images that only exist in one org (e.g., collector) +- ✅ Only uses quay.io paths (localhost paths removed in commit bc376ad) +- ✅ Returns idiomatic (value, bool, error) tuple +- **Implementation**: Use `podman image exists ` command (exit code 0 = exists, 1 = doesn't exist) + +### Kind Cluster Detection + +**Detection Method**: +``` +Function: isKindCluster() + 1. Get current context name from kubectl/KUBECONFIG + 2. Check if context name starts with "kind-" prefix + 3. Return: true if kind cluster detected, false otherwise +``` + +**Cluster Name Extraction**: +- Context name format: `kind-` +- Extract cluster name for use in `kind load docker-image -n ` +- Example: context `kind-acs` → cluster name `acs` + +### Image Loading to Kind + +**Loading Mechanism**: +``` +Function: loadImageToKind(imageRef, clusterName) + 1. Execute: kind load docker-image -n + 2. kind automatically detects podman via DOCKER_HOST or default socket + 3. Log progress: "Loading into kind cluster " + 4. On failure: Abort deployment with clear error (loading failures are fatal) +``` + +**Parallel Loading**: +- Load multiple images concurrently (4 workers, matching existing image verification parallelism) +- Show progress to user: "Loading 8 images into kind cluster..." +- Estimated time: ~10-30 seconds depending on image sizes + +### Deployment Workflow Integration + +**Modified Deployment Flow**: + +``` +1. Parse flags and validate configuration +2. Resolve image tags (MAIN_IMAGE_TAG, operator tags) +3. Connect to cluster +4. [NEW] Detect if kind cluster +5. [NEW] If kind: check for local images and load them +6. Verify credentials (skip if using all local images) +7. Create namespaces +8. Create image pull secrets (skip if using all local images) +9. Deploy via operator or helm (existing behavior) +``` + +**Optimization When All Images Are Local**: +- Skip quay.io credential verification (allows completely offline development) +- Skip creating image pull secrets (local images don't need authentication) + +**User Feedback**: +- "Detected kind cluster 'acs'" +- "Found 8/8 images locally in podman" +- "Loading images into kind cluster..." (with progress) +- "All images loaded, skipping credential verification" + +### Error Handling and Edge Cases + +**Partial Local Images**: +- **Scenario**: Some images exist locally, others don't (e.g., 5/8 found) +- **Behavior**: Load available local images to kind, let remaining images be pulled from quay.io by kubelet +- **Credentials**: Still create image pull secrets (needed for remote images) +- **User feedback**: "Found 5/8 images locally, loading these. Remaining images will be pulled from quay.io" + +**Image Loading Failures**: +- **Scenario**: `kind load docker-image` fails for a specific image +- **Behavior**: **Fail deployment immediately** with clear error message +- **Rationale**: User expects local images to be used; failure indicates real problem (wrong podman socket, permissions, corrupted image, etc.) +- **Error message**: "Failed to load local image into kind cluster: . Aborting deployment." + +**Tag Mismatch**: +- **Scenario**: `MAIN_IMAGE_TAG=4.10.0` but local images are tagged `4.10.x-827-g6ab85ec46b` +- **Behavior**: No match found, fall back to quay.io +- **Future enhancement**: Could support fuzzy matching or tag aliases + +**Non-Kind Clusters**: +- **Scenario**: Deploying to OpenShift, GKE, EKS, regular k8s, etc. +- **Behavior**: Skip kind detection and image loading entirely, use existing quay.io flow +- **Impact**: Zero behavior change for non-kind deployments (backward compatible) + +**podman Not Available**: +- **Scenario**: roxie running in environment without podman +- **Behavior**: Skip local image detection (podman commands fail gracefully), fall back to quay.io +- **Rationale**: Allows roxie to work in containerized environments or systems with only docker + +**Override/Disable**: +- **Optional**: Add environment variable `ROXIE_SKIP_LOCAL_IMAGES=true` to force quay.io behavior even on kind +- **Use case**: Debugging, testing quay.io pulls on kind, working around issues + +## Implementation Plan + +### New Components + +**1. Package: `internal/localimages`** +- `branding.go`: Handle ROX_PRODUCT_BRANDING environment variable, map to registry paths +- `detection.go`: Check for local images in podman +- `loading.go`: Load images into kind cluster + +**2. Package: `internal/cluster`** +- `kind.go`: Detect kind clusters, extract cluster name from context + +### Modified Components + +**1. `internal/helpers/tag.go`** +- No changes needed - existing tag resolution works as-is + +**2. `internal/dockerauth/dockerauth.go`** +- Modify credential verification to be skippable when all images are local + +**3. `cmd/deploy.go`** +- Integrate local image detection and loading into deployment flow +- Add step between cluster connection and credential verification + +**4. `internal/deployer/operator.go`** +- Potentially skip pull secret creation if all images are local + +**5. `internal/deployer/deploy_via_helm.go`** +- Image verification may need adjustment to skip local images + +### Testing Strategy + +**Manual Testing**: +1. Build stackrox locally with RHACS branding +2. Verify images in podman: `podman images | grep stackrox` +3. Deploy to kind with roxie: should auto-detect and load images +4. Verify deployment uses local images (check pod image IDs) +5. Test with STACKROX branding +6. Test partial local images (delete some images) +7. Test non-kind cluster (should fall back to quay.io) +8. Test with `ROXIE_SKIP_LOCAL_IMAGES=true` + +**Edge Case Testing**: +- Missing podman (should gracefully fall back) +- Kind cluster but no local images (should use quay.io) +- Loading failure (should fail deployment with clear error) +- Mixed brandings (local RHACS, remote STACKROX - should work) + +## Future Enhancements + +**Potential additions if needed**: +1. Support for other local cluster types (k3d, minikube, microk8s) +2. Fuzzy tag matching (4.10.0 matches 4.10.x-*) +3. Local registry mode for clusters that can't use direct loading +4. Verbose logging mode showing exact podman/kind commands +5. Image pre-loading cache to skip re-loading unchanged images + +## Success Criteria + +- Developer can build stackrox locally and deploy to kind without pushing to quay.io +- Zero configuration required - works automatically when conditions are met +- Backward compatible - no behavior change for non-kind deployments +- Fast - eliminates network round-trip, loads complete in <30 seconds +- Reliable - clear errors on failures, no silent fallback when local images are detected + +## Non-Functional Requirements + +- **Performance**: Image loading should complete within 30 seconds for typical ACS deployment (8 images) +- **Compatibility**: Must work with both podman and docker (via compatible sockets) +- **Backward Compatibility**: No breaking changes to existing roxie functionality +- **Maintainability**: Code should be well-structured and easy to extend for other cluster types later diff --git a/docs/plans/2026-01-20-local-image-support.md b/docs/plans/2026-01-20-local-image-support.md new file mode 100644 index 0000000..ecb1a09 --- /dev/null +++ b/docs/plans/2026-01-20-local-image-support.md @@ -0,0 +1,1221 @@ +# Local Image Support for Kind Clusters - Implementation Plan + +> **Status**: COMPLETED (January 2026) +> +> **Note**: This was the original implementation plan. The feature has been fully implemented with some variations from the original plan (see commits bc376ad through 9d8a54b). Key differences: +> - Localhost paths were removed (only quay.io paths used) +> - Both branding orgs are checked for each image +> - CSV patching was added for operator deployments +> - Some images were consolidated/removed during implementation + +**Goal:** Enable roxie to automatically detect and load locally-built container images from podman into kind clusters, eliminating the need to push to quay.io during local development. + +**Architecture:** Add new `internal/localimages` and `internal/cluster` packages for image detection and kind cluster handling. Integrate into deployer workflow between cluster defaults and credential verification. Use existing `env` package's kind cluster detection. Support ROX_PRODUCT_BRANDING to check both localhost/stackrox and quay.io registry paths. + +**Tech Stack:** Go 1.21+, podman CLI, kind CLI, kubectl, existing roxie packages (env, dockerauth, deployer) + +--- + +## Task 1: Create branding support package + +**Files:** +- Create: `internal/localimages/branding.go` +- Create: `internal/localimages/branding_test.go` + +**Step 1: Write the failing test** + +```go +package localimages + +import ( + "testing" +) + +func TestGetBrandingRegistry(t *testing.T) { + tests := []struct { + name string + brandingEnv string + expectedOrg string + expectedFlavor string + }{ + { + name: "RHACS branding", + brandingEnv: "RHACS_BRANDING", + expectedOrg: "rhacs-eng", + expectedFlavor: "development_build", + }, + { + name: "STACKROX branding", + brandingEnv: "STACKROX_BRANDING", + expectedOrg: "stackrox-io", + expectedFlavor: "opensource", + }, + { + name: "empty defaults to RHACS", + brandingEnv: "", + expectedOrg: "rhacs-eng", + expectedFlavor: "development_build", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ROX_PRODUCT_BRANDING", tt.brandingEnv) + + org := GetBrandingOrganization() + if org != tt.expectedOrg { + t.Errorf("GetBrandingOrganization() = %v, want %v", org, tt.expectedOrg) + } + + flavor := GetImageFlavor() + if flavor != tt.expectedFlavor { + t.Errorf("GetImageFlavor() = %v, want %v", flavor, tt.expectedFlavor) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/localimages/... -v` +Expected: FAIL with "no buildable Go source files" or similar + +**Step 3: Write minimal implementation** + +```go +package localimages + +import "os" + +const ( + brandingEnvVar = "ROX_PRODUCT_BRANDING" + + rhacsBranding = "RHACS_BRANDING" + stackroxBranding = "STACKROX_BRANDING" + + rhacsOrg = "rhacs-eng" + stackroxOrg = "stackrox-io" + + rhacsFlavor = "development_build" + stackroxFlavor = "opensource" +) + +// GetBrandingOrganization returns the registry organization based on ROX_PRODUCT_BRANDING. +// Defaults to rhacs-eng if not set. +func GetBrandingOrganization() string { + branding := os.Getenv(brandingEnvVar) + if branding == stackroxBranding { + return stackroxOrg + } + // Default to RHACS branding + return rhacsOrg +} + +// GetImageFlavor returns the image flavor based on ROX_PRODUCT_BRANDING. +// Defaults to development_build if not set. +func GetImageFlavor() string { + branding := os.Getenv(brandingEnvVar) + if branding == stackroxBranding { + return stackroxFlavor + } + // Default to RHACS flavor + return rhacsFlavor +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/localimages/... -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/localimages/branding.go internal/localimages/branding_test.go +git commit -m "Add branding support for local image detection + +Implements ROX_PRODUCT_BRANDING environment variable support to determine +which registry organization to use (rhacs-eng vs stackrox-io). Defaults +to RHACS branding for typical development workflows. + +Relates to #41" +``` + +--- + +## Task 2: Create kind cluster detection + +**Files:** +- Create: `internal/cluster/kind.go` +- Create: `internal/cluster/kind_test.go` + +**Step 1: Write the failing test** + +```go +package cluster + +import ( + "testing" +) + +func TestIsKindCluster(t *testing.T) { + tests := []struct { + name string + contextName string + expected bool + }{ + { + name: "kind cluster with prefix", + contextName: "kind-acs", + expected: true, + }, + { + name: "kind cluster just kind", + contextName: "kind", + expected: true, + }, + { + name: "non-kind cluster", + contextName: "gke_project_zone_cluster", + expected: false, + }, + { + name: "empty context", + contextName: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isKindContext(tt.contextName) + if result != tt.expected { + t.Errorf("isKindContext(%q) = %v, want %v", tt.contextName, result, tt.expected) + } + }) + } +} + +func TestExtractKindClusterName(t *testing.T) { + tests := []struct { + name string + contextName string + expected string + }{ + { + name: "kind with cluster name", + contextName: "kind-acs", + expected: "acs", + }, + { + name: "just kind", + contextName: "kind", + expected: "kind", + }, + { + name: "kind with dashes", + contextName: "kind-my-cluster-name", + expected: "my-cluster-name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractKindClusterName(tt.contextName) + if result != tt.expected { + t.Errorf("ExtractKindClusterName(%q) = %v, want %v", tt.contextName, result, tt.expected) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/cluster/... -v` +Expected: FAIL with "no buildable Go source files" + +**Step 3: Write minimal implementation** + +```go +package cluster + +import ( + "strings" + + "github.com/stackrox/roxie/internal/env" +) + +// IsKindCluster returns true if the current kubectl context is a kind cluster. +func IsKindCluster() bool { + contextName := env.GetCurrentContext() + return isKindContext(contextName) +} + +// isKindContext checks if the given context name indicates a kind cluster. +// Exported for testing. +func isKindContext(contextName string) bool { + if contextName == "" { + return false + } + // Kind clusters have contexts starting with "kind" (case-insensitive) + return strings.HasPrefix(strings.ToLower(contextName), "kind") +} + +// ExtractKindClusterName extracts the cluster name from a kind context. +// For context "kind-acs", returns "acs". +// For context "kind", returns "kind". +func ExtractKindClusterName(contextName string) string { + // Remove "kind-" prefix if present + if len(contextName) > 5 && strings.HasPrefix(strings.ToLower(contextName), "kind-") { + return contextName[5:] + } + // If just "kind", return as-is + return contextName +} + +// GetKindClusterName returns the kind cluster name for the current context. +// Returns empty string if not a kind cluster. +func GetKindClusterName() string { + if !IsKindCluster() { + return "" + } + return ExtractKindClusterName(env.GetCurrentContext()) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/cluster/... -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/cluster/kind.go internal/cluster/kind_test.go +git commit -m "Add kind cluster detection utilities + +Implements detection of kind clusters based on kubectl context name. +Extracts cluster name for use with 'kind load' commands. + +Relates to #41" +``` + +--- + +## Task 3: Create local image detection + +**Files:** +- Create: `internal/localimages/detection.go` +- Create: `internal/localimages/detection_test.go` + +**Step 1: Write the failing test** + +```go +package localimages + +import ( + "testing" +) + +func TestBuildImageReferences(t *testing.T) { + tests := []struct { + name string + imageName string + tag string + branding string + expected []string + }{ + { + name: "RHACS branding", + imageName: "main", + tag: "4.10.0", + branding: "RHACS_BRANDING", + expected: []string{ + "localhost/stackrox/main:4.10.0", + "quay.io/rhacs-eng/main:4.10.0", + }, + }, + { + name: "STACKROX branding", + imageName: "scanner", + tag: "4.9.2", + branding: "STACKROX_BRANDING", + expected: []string{ + "localhost/stackrox/scanner:4.9.2", + "quay.io/stackrox-io/scanner:4.9.2", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ROX_PRODUCT_BRANDING", tt.branding) + + result := buildImageReferences(tt.imageName, tt.tag) + if len(result) != len(tt.expected) { + t.Fatalf("buildImageReferences() returned %d refs, want %d", len(result), len(tt.expected)) + } + for i, ref := range result { + if ref != tt.expected[i] { + t.Errorf("buildImageReferences()[%d] = %v, want %v", i, ref, tt.expected[i]) + } + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/localimages/... -v -run TestBuild` +Expected: FAIL with "undefined: buildImageReferences" + +**Step 3: Write minimal implementation** + +```go +package localimages + +import ( + "fmt" + "os/exec" + "strings" +) + +const ( + localhostPrefix = "localhost/stackrox" + quayRegistry = "quay.io" +) + +// buildImageReferences returns candidate image references to check in podman. +// Returns in priority order: localhost/stackrox first, then quay.io registry. +func buildImageReferences(imageName, tag string) []string { + org := GetBrandingOrganization() + return []string{ + fmt.Sprintf("%s/%s:%s", localhostPrefix, imageName, tag), + fmt.Sprintf("%s/%s/%s:%s", quayRegistry, org, imageName, tag), + } +} + +// CheckLocalImage checks if an image exists in podman. +// Returns the full image reference if found, empty string if not found. +func CheckLocalImage(imageName, tag string) (string, error) { + refs := buildImageReferences(imageName, tag) + + for _, ref := range refs { + exists, err := podmanImageExists(ref) + if err != nil { + return "", fmt.Errorf("checking podman for %s: %w", ref, err) + } + if exists { + return ref, nil + } + } + + return "", nil +} + +// podmanImageExists checks if an image exists in podman using 'podman image exists'. +// Returns true if the image exists (exit code 0), false otherwise. +func podmanImageExists(imageRef string) (bool, error) { + cmd := exec.Command("podman", "image", "exists", imageRef) + err := cmd.Run() + if err != nil { + // Exit code 1 means image doesn't exist (expected) + // Other errors are actual failures + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err + } + return true, nil +} + +// ImageSet represents a collection of images to check. +type ImageSet struct { + Main string + Operator string + Images []string +} + +// CheckImages checks which images from the set exist locally. +// Returns a map of image names to their full references. +func CheckImages(mainTag, operatorTag string) (map[string]string, error) { + images := []string{ + "main", + "scanner", + "scanner-db", + "scanner-v4-db", + "scanner-v4-indexer", + "scanner-v4-matcher", + "central-db", + } + + operatorImages := []string{ + "stackrox-operator-bundle", + "stackrox-operator-index", + } + + localImages := make(map[string]string) + + // Check main images + for _, imageName := range images { + ref, err := CheckLocalImage(imageName, mainTag) + if err != nil { + return nil, err + } + if ref != "" { + localImages[imageName+":"+mainTag] = ref + } + } + + // Check operator images with v prefix + for _, imageName := range operatorImages { + ref, err := CheckLocalImage(imageName, "v"+operatorTag) + if err != nil { + return nil, err + } + if ref != "" { + localImages[imageName+":v"+operatorTag] = ref + } + } + + return localImages, nil +} +``` + +**Step 4: Add integration test (requires podman)** + +```go +// Add to detection_test.go +func TestCheckLocalImage_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This test requires podman to be available + cmd := exec.Command("podman", "version") + if err := cmd.Run(); err != nil { + t.Skip("podman not available, skipping integration test") + } + + // Try to find a known image (alpine is commonly available) + // This is just to test the mechanism works + t.Log("Integration test - checking if podman command works") +} +``` + +**Step 5: Run test to verify it passes** + +Run: `go test ./internal/localimages/... -v -short` +Expected: PASS (skips integration test) + +**Step 6: Commit** + +```bash +git add internal/localimages/detection.go internal/localimages/detection_test.go +git commit -m "Add local image detection for podman + +Implements image existence checking in podman with branding-aware paths. +Checks localhost/stackrox and quay.io registry prefixes in priority order. +Supports all ACS images (main, scanner, operator, etc.). + +Relates to #41" +``` + +--- + +## Task 4: Create kind image loading + +**Files:** +- Create: `internal/localimages/loading.go` +- Create: `internal/localimages/loading_test.go` + +**Step 1: Write the failing test** + +```go +package localimages + +import ( + "context" + "testing" +) + +func TestLoadImageToKind(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This would require a real kind cluster, so we just test the command building + t.Log("Integration test placeholder - would require real kind cluster") +} + +func TestBuildKindLoadCommand(t *testing.T) { + tests := []struct { + name string + imageRef string + clusterName string + expected []string + }{ + { + name: "basic load", + imageRef: "localhost/stackrox/main:4.10.0", + clusterName: "acs", + expected: []string{"kind", "load", "docker-image", "localhost/stackrox/main:4.10.0", "-n", "acs"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := buildKindLoadCommand(tt.imageRef, tt.clusterName) + if len(cmd) != len(tt.expected) { + t.Fatalf("command length = %d, want %d", len(cmd), len(tt.expected)) + } + for i, arg := range cmd { + if arg != tt.expected[i] { + t.Errorf("cmd[%d] = %q, want %q", i, arg, tt.expected[i]) + } + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/localimages/... -v -short -run TestBuild` +Expected: FAIL with "undefined: buildKindLoadCommand" + +**Step 3: Write minimal implementation** + +```go +// Add to loading.go +package localimages + +import ( + "context" + "fmt" + "os/exec" + "sync" + + "github.com/stackrox/roxie/internal/logger" +) + +// LoadImageToKind loads a single image into a kind cluster. +func LoadImageToKind(ctx context.Context, imageRef, clusterName string, log *logger.Logger) error { + log.Dimf("Loading %s into kind cluster %s", imageRef, clusterName) + + cmd := exec.CommandContext(ctx, "kind", "load", "docker-image", imageRef, "-n", clusterName) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("kind load failed for %s: %w\nOutput: %s", imageRef, err, string(output)) + } + + return nil +} + +// buildKindLoadCommand builds the kind load command arguments. +func buildKindLoadCommand(imageRef, clusterName string) []string { + return []string{"kind", "load", "docker-image", imageRef, "-n", clusterName} +} + +// LoadImagesToKind loads multiple images into a kind cluster in parallel. +// Uses up to 4 concurrent workers to speed up loading. +func LoadImagesToKind(ctx context.Context, images map[string]string, clusterName string, log *logger.Logger) error { + if len(images) == 0 { + return nil + } + + log.Infof("Loading %d images into kind cluster %s", len(images), clusterName) + + // Channel for images to process + imageChan := make(chan string, len(images)) + for _, imageRef := range images { + imageChan <- imageRef + } + close(imageChan) + + // Error channel + errChan := make(chan error, len(images)) + + // Use 4 workers for parallel loading (matching existing image verification parallelism) + const numWorkers = 4 + var wg sync.WaitGroup + + for i := 0; i < numWorkers && i < len(images); i++ { + wg.Add(1) + go func() { + defer wg.Done() + for imageRef := range imageChan { + if err := LoadImageToKind(ctx, imageRef, clusterName, log); err != nil { + errChan <- err + return + } + } + }() + } + + wg.Wait() + close(errChan) + + // Check for errors + if err := <-errChan; err != nil { + return err + } + + log.Infof("Successfully loaded %d images into kind cluster", len(images)) + return nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/localimages/... -v -short` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/localimages/loading.go internal/localimages/loading_test.go +git commit -m "Add kind image loading functionality + +Implements parallel loading of container images from podman into kind +clusters using 'kind load docker-image'. Uses 4 concurrent workers +for performance. + +Relates to #41" +``` + +--- + +## Task 5: Integrate into deployer + +**Files:** +- Modify: `internal/deployer/deployer.go` + +**Step 1: Add local image fields to Deployer struct** + +Locate the `Deployer` struct (around line 89) and add new fields: + +```go +type Deployer struct { + logger *logger.Logger + startTime time.Time + dockerAuth *dockerauth.DockerAuth + imageCache *imagecache.ImageCache + portForward *portforward.Manager + clusterDefaults *clusterdefaults.Manager + kubectl string + roxctlVersion string + centralNamespace string + sensorNamespace string + mainImageTag string + operatorTag string + centralEndpoint string + centralPassword string + roxCACertFile string + kubeContext string + portForwardEnabled bool + pauseReconciliation bool + exposure string + overrideFile string + overrideSetExpressions []string + envrcFile string + useHelm bool + useOLM bool + shouldDeployOperator bool + verbose bool + earlyReadiness bool + dockerCreds *dockerauth.Credentials + clusterResourceKinds map[string]struct{} + // New fields for local image support + localImages map[string]string // map of image names to local references + usingLocalImages bool // true if any local images were found and loaded +} +``` + +**Step 2: Add import statements** + +At the top of `deployer.go`, add new imports: + +```go +import ( + // ... existing imports ... + "github.com/stackrox/roxie/internal/cluster" + "github.com/stackrox/roxie/internal/localimages" +) +``` + +**Step 3: Add local image detection and loading method** + +Add new method after `prepareCredentials()` (around line 420): + +```go +// detectAndLoadLocalImages checks for local images and loads them into kind if applicable. +// Returns true if local images were found and loaded, false otherwise. +func (d *Deployer) detectAndLoadLocalImages(ctx context.Context) error { + // Check if ROXIE_SKIP_LOCAL_IMAGES is set + if os.Getenv("ROXIE_SKIP_LOCAL_IMAGES") == "true" { + d.logger.Dim("ROXIE_SKIP_LOCAL_IMAGES is set, skipping local image detection") + return nil + } + + // Check if this is a kind cluster + if !cluster.IsKindCluster() { + d.logger.Dim("Not a kind cluster, skipping local image detection") + return nil + } + + kindClusterName := cluster.GetKindClusterName() + d.logger.Infof("Detected kind cluster: %s", kindClusterName) + + // Check for local images + d.logger.Dim("Checking for local images in podman...") + localImages, err := localimages.CheckImages(d.mainImageTag, d.operatorTag) + if err != nil { + // If podman is not available, gracefully fall back + d.logger.Dimf("Could not check for local images: %v", err) + d.logger.Dim("Falling back to quay.io") + return nil + } + + if len(localImages) == 0 { + d.logger.Dim("No local images found, will pull from quay.io") + return nil + } + + // Calculate total images needed (7 main + 2 operator = 9) + totalExpected := 9 + d.logger.Infof("Found %d/%d images locally in podman", len(localImages), totalExpected) + + // Load images into kind + if err := localimages.LoadImagesToKind(ctx, localImages, kindClusterName, d.logger); err != nil { + return fmt.Errorf("failed to load images into kind cluster: %w", err) + } + + // Store the local images for later use + d.localImages = localImages + d.usingLocalImages = len(localImages) > 0 + + return nil +} +``` + +**Step 4: Modify Deploy method to call local image detection** + +Locate the `Deploy` method (around line 371) and add the call after cluster defaults, before credentials: + +```go +func (d *Deployer) Deploy(ctx context.Context, component, resources, exposure string) error { + adjustedResources, adjustedExposure, adjustedPortForward := d.clusterDefaults.ApplyConvenienceDefaults( + d.kubeContext, + resources, + exposure, + d.portForwardEnabled, + ) + + resources = adjustedResources + exposure = adjustedExposure + d.portForwardEnabled = adjustedPortForward + d.exposure = exposure + + // NEW: Detect and load local images for kind clusters + if err := d.detectAndLoadLocalImages(ctx); err != nil { + return fmt.Errorf("failed to detect and load local images: %w", err) + } + + // Prepare and verify credentials early to fail fast + // Skip if all images are local + if !d.shouldSkipCredentialVerification() { + if err := d.prepareCredentials(); err != nil { + return fmt.Errorf("failed to prepare credentials: %w", err) + } + } else { + d.logger.Info("All images loaded locally, skipping credential verification") + } + + d.logger.Infof("Initiating deployment of %s", formatComponentName(component)) + + switch component { + case "central": + return d.deployCentral(ctx, resources, exposure) + case "secured-cluster", "sensor": + return d.deploySecuredCluster(ctx, resources) + case "both", "all": + if err := d.deployCentral(ctx, resources, exposure); err != nil { + return fmt.Errorf("failed to deploy central: %w", err) + } + return d.deploySecuredCluster(ctx, resources) + default: + return fmt.Errorf("unknown component: %s", component) + } +} +``` + +**Step 5: Add helper method to determine if credentials should be skipped** + +```go +// shouldSkipCredentialVerification returns true if we should skip credential verification. +// We skip if all required images are available locally. +func (d *Deployer) shouldSkipCredentialVerification() bool { + // If not using any local images, don't skip + if !d.usingLocalImages { + return false + } + + // If using some local images but not all, don't skip (need creds for remote pulls) + // Total expected: 7 main + 2 operator = 9 + totalExpected := 9 + if len(d.localImages) < totalExpected { + d.logger.Dimf("Using %d/%d local images, remaining images will be pulled from quay.io", + len(d.localImages), totalExpected) + return false + } + + // All images are local + return true +} +``` + +**Step 6: Build and test** + +Run: `go build ./cmd/roxie` +Expected: Build succeeds + +**Step 7: Commit** + +```bash +git add internal/deployer/deployer.go +git commit -m "Integrate local image detection into deployment flow + +Adds automatic detection and loading of local images for kind clusters. +Checks podman for images before deployment and loads them into kind. +Skips credential verification when all images are available locally. + +Supports ROXIE_SKIP_LOCAL_IMAGES environment variable to disable. + +Relates to #41" +``` + +--- + +## Task 6: Handle image pull secrets for partial local images + +**Files:** +- Modify: `internal/deployer/operator.go` +- Modify: `internal/deployer/deploy_via_helm.go` + +**Step 1: Review current pull secret creation** + +Read `internal/deployer/operator.go` to find where image pull secrets are created. Look for `createImagePullSecret` or similar methods. + +**Step 2: Add conditional pull secret creation** + +Modify the pull secret creation to skip when all images are local: + +```go +// In the deployment method, wrap pull secret creation: +if !d.shouldSkipImagePullSecrets() { + if err := d.createImagePullSecret(ctx, namespace); err != nil { + return fmt.Errorf("failed to create image pull secret: %w", err) + } +} else { + d.logger.Dim("All images loaded locally, skipping image pull secret creation") +} +``` + +**Step 3: Add helper method** + +Add to `deployer.go`: + +```go +// shouldSkipImagePullSecrets returns true if image pull secrets should be skipped. +// Same logic as credential verification - skip only if all images are local. +func (d *Deployer) shouldSkipImagePullSecrets() bool { + return d.shouldSkipCredentialVerification() +} +``` + +**Step 4: Update helm deployment similarly** + +Apply same logic in `deploy_via_helm.go` if it creates pull secrets. + +**Step 5: Build and test** + +Run: `go build ./cmd/roxie` +Expected: Build succeeds + +**Step 6: Commit** + +```bash +git add internal/deployer/operator.go internal/deployer/deploy_via_helm.go internal/deployer/deployer.go +git commit -m "Skip image pull secrets when all images are local + +When all required images are loaded locally into kind, skip creating +image pull secrets since they're not needed. Still creates secrets +for partial local image scenarios. + +Relates to #41" +``` + +--- + +## Task 7: Add environment variable documentation + +**Files:** +- Modify: `README.md` or create `docs/local-images.md` + +**Step 1: Document the feature** + +Add documentation for the new feature (location depends on existing docs structure): + +````markdown +## Local Image Support for Kind Clusters + +Roxie automatically detects and uses locally-built container images when deploying to kind clusters, eliminating the need to push images to quay.io during development. + +### How It Works + +When deploying to a kind cluster, roxie: + +1. Checks if images exist locally in podman +2. Loads found images into the kind cluster +3. Skips credential verification if all images are local +4. Falls back to quay.io for any missing images + +### Requirements + +- kind cluster (context name must start with "kind") +- podman with images built locally +- Images tagged with either: + - `localhost/stackrox/:` + - `quay.io//:` + +### Supported Images + +- main +- scanner, scanner-db +- scanner-v4-db, scanner-v4-indexer, scanner-v4-matcher +- central-db +- stackrox-operator-bundle +- stackrox-operator-index + +### Environment Variables + +**ROX_PRODUCT_BRANDING**: Controls which registry organization to check +- `RHACS_BRANDING` → `quay.io/rhacs-eng` (default) +- `STACKROX_BRANDING` → `quay.io/stackrox-io` + +**ROXIE_SKIP_LOCAL_IMAGES**: Set to `true` to disable local image detection and force quay.io pulls + +### Example Workflow + +```bash +# Build stackrox locally (images go to podman) +cd /path/to/stackrox +make image + +# Deploy to kind - roxie automatically uses local images +cd /path/to/roxie +./roxie deploy both +``` + +### Behavior + +- **All images local**: Skips credential verification, fast deployment +- **Some images local**: Loads local ones, pulls remaining from quay.io +- **No images local**: Normal quay.io workflow (backward compatible) +- **Non-kind cluster**: Skips local image detection entirely +```` + +**Step 2: Commit** + +```bash +git add README.md # or docs/local-images.md +git commit -m "Add documentation for local image support + +Documents the automatic local image detection and loading feature +for kind clusters, including requirements and usage examples. + +Relates to #41" +``` + +--- + +## Task 8: Manual testing + +**Testing Checklist:** + +Run these manual tests to verify the implementation: + +**Test 1: All images local (happy path)** +```bash +# Prerequisite: Build stackrox locally with RHACS branding +cd /path/to/stackrox +make image +podman images | grep stackrox # Verify images exist + +# Test: Deploy to kind +cd /path/to/roxie +./roxie deploy both + +# Expected: +# - "Detected kind cluster: acs" +# - "Found 9/9 images locally in podman" +# - "Loading 9 images into kind cluster..." +# - "All images loaded locally, skipping credential verification" +# - Deployment succeeds +``` + +**Test 2: Partial local images** +```bash +# Delete some images to simulate partial scenario +podman rmi localhost/stackrox/scanner:TAG + +# Test: Deploy to kind +./roxie deploy both + +# Expected: +# - "Found 8/9 images locally in podman" +# - "Using 8/9 local images, remaining images will be pulled from quay.io" +# - Credential verification still runs +# - Deployment succeeds (pulls missing image from quay.io) +``` + +**Test 3: No local images** +```bash +# Delete all local images +podman rmi localhost/stackrox/main:TAG # (and others) + +# Test: Deploy to kind +./roxie deploy both + +# Expected: +# - "No local images found, will pull from quay.io" +# - Normal credential verification +# - Deployment succeeds +``` + +**Test 4: Non-kind cluster** +```bash +# Switch to non-kind cluster +kubectl config use-context gke_project_zone_cluster + +# Test: Deploy +./roxie deploy central + +# Expected: +# - "Not a kind cluster, skipping local image detection" +# - Normal quay.io workflow +# - No kind-specific behavior +``` + +**Test 5: STACKROX branding** +```bash +# Build with STACKROX branding +cd /path/to/stackrox +ROX_PRODUCT_BRANDING=STACKROX_BRANDING make image + +# Test: Deploy +cd /path/to/roxie +ROX_PRODUCT_BRANDING=STACKROX_BRANDING ./roxie deploy both + +# Expected: +# - Uses quay.io/stackrox-io paths +# - Finds images correctly +``` + +**Test 6: Skip local images** +```bash +# Test with override +ROXIE_SKIP_LOCAL_IMAGES=true ./roxie deploy both + +# Expected: +# - "ROXIE_SKIP_LOCAL_IMAGES is set, skipping local image detection" +# - Normal quay.io workflow +``` + +**Test 7: Image loading failure** +```bash +# This is hard to test without breaking kind, but verify error handling: +# - Corrupt an image +# - Expect: Clear error message, deployment aborted +``` + +**Mark complete when all tests pass** + +No commit needed - this is verification only. + +--- + +## Task 9: Update CHANGELOG (if exists) + +**Files:** +- Modify: `CHANGELOG.md` (if it exists) + +**Step 1: Check if CHANGELOG exists** + +Run: `ls -la CHANGELOG.md` + +**Step 2: If exists, add entry** + +```markdown +## [Unreleased] + +### Added +- Automatic local image detection and loading for kind clusters (#41) + - Checks podman for locally-built images before deployment + - Automatically loads images into kind cluster using `kind load` + - Skips credential verification when all images available locally + - Supports ROX_PRODUCT_BRANDING for RHACS/STACKROX brandings + - Gracefully falls back to quay.io for missing images + - Can be disabled with ROXIE_SKIP_LOCAL_IMAGES=true +``` + +**Step 3: Commit if updated** + +```bash +git add CHANGELOG.md +git commit -m "Update CHANGELOG for local image support + +Relates to #41" +``` + +--- + +## Testing Strategy Summary + +**Unit Tests**: Tasks 1-4 include unit tests for individual components +**Integration Points**: Task 5 integrates all components +**Manual Testing**: Task 8 provides comprehensive manual test scenarios + +**Key Test Scenarios**: +1. All local images → skip credentials +2. Partial local images → load local + pull remote +3. No local images → normal quay.io flow +4. Non-kind cluster → skip local detection +5. Different brandings → correct registry paths +6. Override flag → force quay.io +7. Error cases → clear error messages + +**Success Criteria**: +- All unit tests pass +- Build succeeds without errors +- Manual tests verify expected behavior +- Backward compatible with non-kind deployments +- Clear user feedback throughout process + +--- + +## Implementation Notes + +**Code Style**: Follow existing roxie conventions (logger usage, error handling) +**Error Handling**: Fail fast on critical errors (kind load failures), gracefully degrade on non-critical (podman unavailable) +**Logging**: Use logger.Info for user-visible actions, logger.Dim for technical details +**Concurrency**: Use 4 workers for parallel image loading (matches existing imagecache pattern) +**Testing**: Use t.Skip for integration tests that require external tools + +**Dependencies**: No new external Go modules required, uses existing: +- os/exec for CLI commands +- existing roxie packages (env, logger, dockerauth, deployer) diff --git a/internal/cluster/kind.go b/internal/cluster/kind.go new file mode 100644 index 0000000..6a86b21 --- /dev/null +++ b/internal/cluster/kind.go @@ -0,0 +1,43 @@ +package cluster + +import ( + "strings" + + "github.com/stackrox/roxie/internal/env" +) + +// IsKindCluster returns true if the current kubectl context is a kind cluster. +func IsKindCluster() bool { + contextName := env.GetCurrentContext() + return isKindContext(contextName) +} + +// isKindContext checks if the given context name indicates a kind cluster. +func isKindContext(contextName string) bool { + if contextName == "" { + return false + } + // Kind clusters have contexts starting with "kind" (case-insensitive) + return strings.HasPrefix(strings.ToLower(contextName), "kind") +} + +// extractKindClusterName extracts the cluster name from a kind context. +// For context "kind-acs", returns "acs". +// For context "kind", returns "kind". +func extractKindClusterName(contextName string) string { + // Remove "kind-" prefix if present + if strings.HasPrefix(strings.ToLower(contextName), "kind-") { + return contextName[len("kind-"):] + } + // If just "kind", return as-is + return contextName +} + +// GetKindClusterName returns the kind cluster name for the current context. +// Returns empty string if not a kind cluster. +func GetKindClusterName() string { + if !IsKindCluster() { + return "" + } + return extractKindClusterName(env.GetCurrentContext()) +} diff --git a/internal/cluster/kind_test.go b/internal/cluster/kind_test.go new file mode 100644 index 0000000..956f730 --- /dev/null +++ b/internal/cluster/kind_test.go @@ -0,0 +1,91 @@ +package cluster + +import ( + "testing" +) + +func TestIsKindCluster(t *testing.T) { + tests := []struct { + name string + contextName string + expected bool + }{ + { + name: "kind cluster with prefix", + contextName: "kind-acs", + expected: true, + }, + { + name: "kind cluster just kind", + contextName: "kind", + expected: true, + }, + { + name: "non-kind cluster", + contextName: "gke_project_zone_cluster", + expected: false, + }, + { + name: "empty context", + contextName: "", + expected: false, + }, + { + name: "contains but doesn't start with kind", + contextName: "my-kind-cluster", + expected: false, + }, + { + name: "kubernetes context", + contextName: "kubernetes", + expected: false, + }, + { + name: "mixed case KIND", + contextName: "KIND-acs", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isKindContext(tt.contextName) + if result != tt.expected { + t.Errorf("isKindContext(%q) = %v, want %v", tt.contextName, result, tt.expected) + } + }) + } +} + +func TestExtractKindClusterName(t *testing.T) { + tests := []struct { + name string + contextName string + expected string + }{ + { + name: "kind with cluster name", + contextName: "kind-acs", + expected: "acs", + }, + { + name: "just kind", + contextName: "kind", + expected: "kind", + }, + { + name: "kind with dashes", + contextName: "kind-my-cluster-name", + expected: "my-cluster-name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractKindClusterName(tt.contextName) + if result != tt.expected { + t.Errorf("extractKindClusterName(%q) = %v, want %v", tt.contextName, result, tt.expected) + } + }) + } +} diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 083e8b5..9239379 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -180,8 +180,12 @@ func (d *Deployer) prepareNamespace(ctx context.Context, namespace string) error } if env.GetCurrentClusterType() != env.InfraOpenShift4 { - if err := d.ensurePullSecretExists(ctx, namespace); err != nil { - return fmt.Errorf("ensuring image pull secret exists: %w", err) + if !d.hasAllImagesLocally() { + if err := d.ensurePullSecretExists(ctx, namespace); err != nil { + return fmt.Errorf("ensuring image pull secret exists: %w", err) + } + } else { + d.logger.Dim("All images loaded locally, skipping image pull secret creation") } } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 50b3b6b..1a89b50 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -12,15 +12,27 @@ import ( "github.com/fatih/color" + "github.com/stackrox/roxie/internal/cluster" "github.com/stackrox/roxie/internal/clusterdefaults" "github.com/stackrox/roxie/internal/dockerauth" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/imagecache" + "github.com/stackrox/roxie/internal/localimages" "github.com/stackrox/roxie/internal/logger" "github.com/stackrox/roxie/internal/portforward" ) +const ( + // totalRequiredImages is the number of images needed for a complete deployment + // (7 main images + 2 operator images: stackrox-operator + stackrox-operator-bundle). + // Main images: main, scanner, scanner-db, scanner-v4, scanner-v4-db, central-db, collector + totalRequiredImages = 9 + + // skipLocalImagesEnvVar is the environment variable name to disable local image detection. + skipLocalImagesEnvVar = "ROXIE_SKIP_LOCAL_IMAGES" +) + var ( sharedNamespace = "stackrox" centralNamespace = "acs-central" @@ -119,6 +131,8 @@ type Deployer struct { earlyReadiness bool dockerCreds *dockerauth.Credentials clusterResourceKinds map[string]struct{} + localImages map[string]string // map of image names to local references + usingLocalImages bool } type ResourceKindWithName struct { @@ -420,9 +434,19 @@ func (d *Deployer) Deploy(ctx context.Context, component, resources, exposure st d.portForwardEnabled = adjustedPortForward d.exposure = exposure + // Detect and load local images for kind clusters + if err := d.detectAndLoadLocalImages(ctx); err != nil { + return fmt.Errorf("failed to detect and load local images: %w", err) + } + // Prepare and verify credentials early to fail fast - if err := d.prepareCredentials(); err != nil { - return fmt.Errorf("failed to prepare credentials: %w", err) + // Skip if all images are local + if !d.hasAllImagesLocally() { + if err := d.prepareCredentials(); err != nil { + return fmt.Errorf("failed to prepare credentials: %w", err) + } + } else { + d.logger.Info("All images loaded locally, skipping credential verification") } d.logger.Infof("Initiating deployment of %s", formatComponentName(component)) @@ -462,6 +486,75 @@ func (d *Deployer) prepareCredentials() error { return nil } +// detectAndLoadLocalImages attempts to detect and load locally-available container +// images into the kind cluster. +// +// This method gracefully degrades - if podman is unavailable or no images are found, +// it returns nil and allows deployment to proceed with remote image pulls. +// Returns an error only if image loading into kind fails after images are detected. +func (d *Deployer) detectAndLoadLocalImages(ctx context.Context) error { + if os.Getenv(skipLocalImagesEnvVar) == "true" { + d.logger.Dim(skipLocalImagesEnvVar + " is set, skipping local image detection") + return nil + } + + // Check if this is a kind cluster + if !cluster.IsKindCluster() { + d.logger.Dim("Not a kind cluster, skipping local image detection") + return nil + } + + kindClusterName := cluster.GetKindClusterName() + d.logger.Infof("Detected kind cluster: %s", kindClusterName) + + // Check for local images + d.logger.Dim("Checking for local images in podman...") + localImages, err := localimages.CheckImages(d.mainImageTag, d.operatorTag) + if err != nil { + d.logger.Dimf("Could not check for local images: %v", err) + d.logger.Dim("Falling back to quay.io") + return nil + } + + if len(localImages) == 0 { + d.logger.Dim("No local images found, will pull from quay.io") + return nil + } + + d.logger.Infof("Found %d/%d images locally in podman", len(localImages), totalRequiredImages) + + // Load images into kind using quay.io paths + if err := localimages.LoadImagesToKind(ctx, localImages, d.mainImageTag, d.operatorTag, kindClusterName, d.logger); err != nil { + return fmt.Errorf("failed to load images into kind cluster %s: %w", kindClusterName, err) + } + + // Store the local images for later use + d.localImages = localImages + d.usingLocalImages = len(localImages) > 0 + + return nil +} + +// hasAllImagesLocally returns true if all required images are available locally. +// When all images are local, credential verification and image pull secrets can be skipped. +// For partial local image scenarios, credentials are still required for remote pulls. +func (d *Deployer) hasAllImagesLocally() bool { + // If not using any local images, return false + if !d.usingLocalImages { + return false + } + + // If using some local images but not all, return false (need creds for remote pulls) + if len(d.localImages) < totalRequiredImages { + d.logger.Dimf("Using %d/%d local images, remaining images will be pulled from quay.io", + len(d.localImages), totalRequiredImages) + return false + } + + // All images are local + return true +} + func (d *Deployer) deployCentral(ctx context.Context, resources, exposure string) error { d.logger.Infof("Deploying Central to namespace %s", d.centralNamespace) if d.namespaceExists(d.centralNamespace) { diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index df82c47..041f7bb 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -25,6 +25,43 @@ const ( operatorDeploymentName = "rhacs-operator-controller-manager" ) +// clusterServiceVersion represents the relevant structure of a ClusterServiceVersion YAML +// that we need to patch for local image references. Uses inline maps to preserve +// all fields not explicitly defined. +type clusterServiceVersion struct { + Spec struct { + Install struct { + Spec struct { + Deployments []struct { + Spec struct { + Template struct { + Spec struct { + Containers []struct { + Image string `yaml:"image"` + Env []struct { + Name string `yaml:"name"` + Value string `yaml:"value"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"env"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"containers"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"spec"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"template"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"spec"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"deployments"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"spec"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"install"` + ExtraFields map[string]interface{} `yaml:",inline"` + } `yaml:"spec"` + ExtraFields map[string]interface{} `yaml:",inline"` +} + // deployOperator deploys the RHACS operator func (d *Deployer) deployOperator(ctx context.Context) error { d.logger.Infof("Operator tag: %s", d.operatorTag) @@ -328,6 +365,14 @@ func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string) return errors.New("ClusterServiceVersion file not found in bundle") } + // Patch CSV with local image references if using local images + if d.usingLocalImages { + d.logger.Info("Patching CSV with local image references") + if err := patchCSVWithLocalImages(csvFile, d.mainImageTag, d.operatorTag, d.localImages); err != nil { + return fmt.Errorf("failed to patch CSV with local images: %w", err) + } + } + d.logger.Info("🔍 Parsing ClusterServiceVersion deployment specification") deploymentSpec, err := d.parseCSVDeploymentSpec(csvFile) @@ -372,6 +417,103 @@ func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string) return nil } +// envVarToImageName converts a RELATED_IMAGE_* environment variable name to an image name. +// e.g., RELATED_IMAGE_SCANNER_V4_DB → scanner-v4-db +func envVarToImageName(envVar string) string { + name := strings.TrimPrefix(envVar, "RELATED_IMAGE_") + name = strings.ToLower(name) + name = strings.ReplaceAll(name, "_", "-") + return name +} + +// patchCSVWithLocalImages patches the CSV file with local image references. +// It updates: +// 1. The operator image reference (spec.install.spec.deployments[0].spec.template.spec.containers[0].image) +// 2. RELATED_IMAGE_* environment variables with local image tags +// +// The function only patches images that exist in the localImages map, leaving +// other image references unchanged. +func patchCSVWithLocalImages(csvFile, mainImageTag, operatorTag string, localImages map[string]string) error { + // Read the CSV file + content, err := os.ReadFile(csvFile) + if err != nil { + return fmt.Errorf("failed to read CSV file: %w", err) + } + + var csvData clusterServiceVersion + if err := yaml.Unmarshal(content, &csvData); err != nil { + return fmt.Errorf("failed to parse CSV YAML: %w", err) + } + + // Validate structure + if len(csvData.Spec.Install.Spec.Deployments) == 0 { + return errors.New("CSV missing deployments") + } + if len(csvData.Spec.Install.Spec.Deployments[0].Spec.Template.Spec.Containers) == 0 { + return errors.New("CSV missing containers") + } + + // Get reference to the first container + container := &csvData.Spec.Install.Spec.Deployments[0].Spec.Template.Spec.Containers[0] + + // Patch operator image if it exists in localImages + // Use the actual detected image reference to handle fallback branding cases + operatorImageKey := "stackrox-operator:" + operatorTag + if imageRef, ok := localImages[operatorImageKey]; ok { + container.Image = imageRef + } + + // Build a reverse map from image name to full image reference for quick lookup + // Map[imagename] -> "quay.io/org/imagename:tag" + imageNameToRef := make(map[string]string) + for imageKey, imageRef := range localImages { + // Extract image name from "imagename:tag" + parts := strings.SplitN(imageKey, ":", 2) + if len(parts) == 2 { + imageNameToRef[parts[0]] = imageRef + } + } + + // Patch RELATED_IMAGE_* environment variables + for i := range container.Env { + envVar := &container.Env[i] + + // Check if this is a RELATED_IMAGE_* env var + if !strings.HasPrefix(envVar.Name, "RELATED_IMAGE_") { + continue + } + + // Convert RELATED_IMAGE_FOO_BAR to foo-bar + // e.g., RELATED_IMAGE_SCANNER_V4_DB → scanner-v4-db + imageName := envVarToImageName(envVar.Name) + + // Special case: scanner-v4-indexer and scanner-v4-matcher both use the scanner-v4 image + // The same image runs in different modes based on runtime configuration + if imageName == "scanner-v4-indexer" || imageName == "scanner-v4-matcher" { + imageName = "scanner-v4" + } + + // Check if we have this image locally and use the actual detected reference + // This handles cases where an image was found at a fallback branding org + if imageRef, found := imageNameToRef[imageName]; found { + envVar.Value = imageRef + } + } + + // Marshal back to YAML + patchedContent, err := yaml.Marshal(csvData) + if err != nil { + return fmt.Errorf("failed to marshal patched CSV: %w", err) + } + + // Write back to file + if err := os.WriteFile(csvFile, patchedContent, 0644); err != nil { + return fmt.Errorf("failed to write patched CSV: %w", err) + } + + return nil +} + // parseCSVDeploymentSpec parses the CSV file func (d *Deployer) parseCSVDeploymentSpec(csvFile string) (map[string]interface{}, error) { content, err := os.ReadFile(csvFile) diff --git a/internal/deployer/operator_test.go b/internal/deployer/operator_test.go new file mode 100644 index 0000000..4d8d785 --- /dev/null +++ b/internal/deployer/operator_test.go @@ -0,0 +1,565 @@ +package deployer + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +// TestEnvVarToImageName tests the conversion of RELATED_IMAGE_* env vars to image names +func TestEnvVarToImageName(t *testing.T) { + tests := []struct { + envVar string + expected string + }{ + {"RELATED_IMAGE_MAIN", "main"}, + {"RELATED_IMAGE_SCANNER", "scanner"}, + {"RELATED_IMAGE_SCANNER_DB", "scanner-db"}, + {"RELATED_IMAGE_SCANNER_V4_DB", "scanner-v4-db"}, + {"RELATED_IMAGE_SCANNER_V4", "scanner-v4"}, + {"RELATED_IMAGE_SCANNER_V4_INDEXER", "scanner-v4-indexer"}, + {"RELATED_IMAGE_SCANNER_V4_MATCHER", "scanner-v4-matcher"}, + {"RELATED_IMAGE_CENTRAL_DB", "central-db"}, + {"RELATED_IMAGE_COLLECTOR", "collector"}, + } + + for _, tt := range tests { + t.Run(tt.envVar, func(t *testing.T) { + result := envVarToImageName(tt.envVar) + if result != tt.expected { + t.Errorf("envVarToImageName(%q) = %q, want %q", tt.envVar, result, tt.expected) + } + }) + } +} + +// TestPatchCSVWithLocalImages_ScannerV4Mapping tests that scanner-v4-indexer and scanner-v4-matcher +// both get mapped to the scanner-v4 image +func TestPatchCSVWithLocalImages_ScannerV4Mapping(t *testing.T) { + csvContent := `apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + name: rhacs-operator.v4.0.0 +spec: + install: + spec: + deployments: + - name: rhacs-operator-controller-manager + spec: + template: + spec: + containers: + - name: manager + image: quay.io/rhacs-eng/stackrox-operator:4.0.0 + env: + - name: RELATED_IMAGE_SCANNER_V4 + value: quay.io/rhacs-eng/scanner-v4:4.0.x-nightly + - name: RELATED_IMAGE_SCANNER_V4_INDEXER + value: quay.io/rhacs-eng/scanner-v4:4.0.x-nightly + - name: RELATED_IMAGE_SCANNER_V4_MATCHER + value: quay.io/rhacs-eng/scanner-v4:4.0.x-nightly +` + + csvFile := filepath.Join(t.TempDir(), "test-csv.yaml") + if err := os.WriteFile(csvFile, []byte(csvContent), 0644); err != nil { + t.Fatalf("Failed to write test CSV: %v", err) + } + + // Only have scanner-v4 locally (not separate indexer/matcher) + localImages := map[string]string{ + "scanner-v4:4.0.0-local": "quay.io/rhacs-eng/scanner-v4:4.0.0-local", + } + + err := patchCSVWithLocalImages(csvFile, "4.0.0-local", "4.0.0", localImages) + if err != nil { + t.Fatalf("patchCSVWithLocalImages failed: %v", err) + } + + // Read and parse the patched CSV + patchedContent, err := os.ReadFile(csvFile) + if err != nil { + t.Fatalf("Failed to read patched CSV: %v", err) + } + + var csvData map[string]interface{} + if err := yaml.Unmarshal(patchedContent, &csvData); err != nil { + t.Fatalf("Failed to unmarshal patched CSV: %v", err) + } + + // Navigate to env vars + spec := csvData["spec"].(map[string]interface{}) + install := spec["install"].(map[string]interface{}) + installSpec := install["spec"].(map[string]interface{}) + deployments := installSpec["deployments"].([]interface{}) + deployment := deployments[0].(map[string]interface{}) + deploymentSpec := deployment["spec"].(map[string]interface{}) + template := deploymentSpec["template"].(map[string]interface{}) + podSpec := template["spec"].(map[string]interface{}) + containers := podSpec["containers"].([]interface{}) + container := containers[0].(map[string]interface{}) + envVars := container["env"].([]interface{}) + + // All three env vars should be patched to use scanner-v4:4.0.0-local + expectedValue := "quay.io/rhacs-eng/scanner-v4:4.0.0-local" + scannerV4EnvVars := []string{ + "RELATED_IMAGE_SCANNER_V4", + "RELATED_IMAGE_SCANNER_V4_INDEXER", + "RELATED_IMAGE_SCANNER_V4_MATCHER", + } + + for _, envVar := range envVars { + envMap := envVar.(map[string]interface{}) + name := envMap["name"].(string) + + for _, expectedName := range scannerV4EnvVars { + if name == expectedName { + value := envMap["value"].(string) + if value != expectedValue { + t.Errorf("%s not patched correctly. Got: %s, Expected: %s", name, value, expectedValue) + } + } + } + } +} + +// TestPatchCSVWithLocalImages_AllLocalImages tests patching when all images are available locally +func TestPatchCSVWithLocalImages_AllLocalImages(t *testing.T) { + // Create a temporary CSV file + csvContent := `apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + name: rhacs-operator.v4.0.0 +spec: + install: + spec: + deployments: + - name: rhacs-operator-controller-manager + spec: + template: + spec: + containers: + - name: manager + image: quay.io/rhacs-eng/stackrox-operator:4.0.0 + env: + - name: RELATED_IMAGE_MAIN + value: quay.io/rhacs-eng/main:4.0.0 + - name: RELATED_IMAGE_SCANNER + value: quay.io/rhacs-eng/scanner:4.0.0 + - name: RELATED_IMAGE_SCANNER_DB + value: quay.io/rhacs-eng/scanner-db:4.0.0 + - name: RELATED_IMAGE_CENTRAL_DB + value: quay.io/rhacs-eng/central-db:4.0.0 + - name: RELATED_IMAGE_SCANNER_V4_DB + value: quay.io/rhacs-eng/scanner-v4-db:4.0.0 + - name: RELATED_IMAGE_SCANNER_V4 + value: quay.io/rhacs-eng/scanner-v4-matcher:4.0.0 + - name: RELATED_IMAGE_COLLECTOR + value: quay.io/rhacs-eng/collector:4.0.0 + - name: OTHER_ENV + value: some-value +` + + tmpDir := t.TempDir() + csvFile := filepath.Join(tmpDir, "test.csv.yaml") + if err := os.WriteFile(csvFile, []byte(csvContent), 0644); err != nil { + t.Fatalf("Failed to write test CSV: %v", err) + } + + // Create local images map with all images + localImages := map[string]string{ + "stackrox-operator:4.0.0": "quay.io/rhacs-eng/stackrox-operator:4.0.0", + "main:4.0.0": "quay.io/rhacs-eng/main:4.0.0", + "scanner:4.0.0": "quay.io/rhacs-eng/scanner:4.0.0", + "scanner-db:4.0.0": "quay.io/rhacs-eng/scanner-db:4.0.0", + "central-db:4.0.0": "quay.io/rhacs-eng/central-db:4.0.0", + "scanner-v4-db:4.0.0": "quay.io/rhacs-eng/scanner-v4-db:4.0.0", + "scanner-v4:4.0.0": "quay.io/rhacs-eng/scanner-v4:4.0.0", + } + + // Patch the CSV + err := patchCSVWithLocalImages(csvFile, "4.0.0", "4.0.0", localImages) + if err != nil { + t.Fatalf("patchCSVWithLocalImages failed: %v", err) + } + + // Read and parse the patched CSV + patchedContent, err := os.ReadFile(csvFile) + if err != nil { + t.Fatalf("Failed to read patched CSV: %v", err) + } + + var csvData map[string]interface{} + if err := yaml.Unmarshal(patchedContent, &csvData); err != nil { + t.Fatalf("Failed to unmarshal patched CSV: %v", err) + } + + // Navigate to the container spec + spec := csvData["spec"].(map[string]interface{}) + install := spec["install"].(map[string]interface{}) + installSpec := install["spec"].(map[string]interface{}) + deployments := installSpec["deployments"].([]interface{}) + deployment := deployments[0].(map[string]interface{}) + deploymentSpec := deployment["spec"].(map[string]interface{}) + template := deploymentSpec["template"].(map[string]interface{}) + podSpec := template["spec"].(map[string]interface{}) + containers := podSpec["containers"].([]interface{}) + container := containers[0].(map[string]interface{}) + + // Verify operator image was patched with quay.io path + operatorImage := container["image"].(string) + expectedOperatorImage := "quay.io/rhacs-eng/stackrox-operator:4.0.0" + if operatorImage != expectedOperatorImage { + t.Errorf("Operator image not patched correctly. Got: %s, Expected: %s", operatorImage, expectedOperatorImage) + } + + // Verify RELATED_IMAGE_* env vars were patched with quay.io paths + envVars := container["env"].([]interface{}) + expectedEnvVars := map[string]string{ + "RELATED_IMAGE_MAIN": "quay.io/rhacs-eng/main:4.0.0", + "RELATED_IMAGE_SCANNER": "quay.io/rhacs-eng/scanner:4.0.0", + "RELATED_IMAGE_SCANNER_DB": "quay.io/rhacs-eng/scanner-db:4.0.0", + "RELATED_IMAGE_CENTRAL_DB": "quay.io/rhacs-eng/central-db:4.0.0", + "RELATED_IMAGE_SCANNER_V4_DB": "quay.io/rhacs-eng/scanner-v4-db:4.0.0", + "RELATED_IMAGE_SCANNER_V4": "quay.io/rhacs-eng/scanner-v4:4.0.0", + "RELATED_IMAGE_COLLECTOR": "quay.io/rhacs-eng/collector:4.0.0", // Not in local images, stays unchanged + "OTHER_ENV": "some-value", + } + + for _, envVar := range envVars { + envMap := envVar.(map[string]interface{}) + name := envMap["name"].(string) + value := envMap["value"].(string) + + if expectedValue, ok := expectedEnvVars[name]; ok { + if value != expectedValue { + t.Errorf("Env var %s not patched correctly. Got: %s, Expected: %s", name, value, expectedValue) + } + } + } +} + +// TestPatchCSVWithLocalImages_PartialLocalImages tests patching when only some images are available locally +func TestPatchCSVWithLocalImages_PartialLocalImages(t *testing.T) { + csvContent := `apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + name: rhacs-operator.v4.0.0 +spec: + install: + spec: + deployments: + - name: rhacs-operator-controller-manager + spec: + template: + spec: + containers: + - name: manager + image: quay.io/rhacs-eng/stackrox-operator:4.0.0 + env: + - name: RELATED_IMAGE_MAIN + value: quay.io/rhacs-eng/main:4.0.0 + - name: RELATED_IMAGE_SCANNER + value: quay.io/rhacs-eng/scanner:4.0.0 + - name: RELATED_IMAGE_SCANNER_DB + value: quay.io/rhacs-eng/scanner-db:4.0.0 +` + + tmpDir := t.TempDir() + csvFile := filepath.Join(tmpDir, "test.csv.yaml") + if err := os.WriteFile(csvFile, []byte(csvContent), 0644); err != nil { + t.Fatalf("Failed to write test CSV: %v", err) + } + + // Only main and scanner are local + localImages := map[string]string{ + "main:4.0.0": "quay.io/rhacs-eng/main:4.0.0", + "scanner:4.0.0": "quay.io/rhacs-eng/scanner:4.0.0", + } + + err := patchCSVWithLocalImages(csvFile, "4.0.0", "4.0.0", localImages) + if err != nil { + t.Fatalf("patchCSVWithLocalImages failed: %v", err) + } + + // Read and parse the patched CSV + patchedContent, err := os.ReadFile(csvFile) + if err != nil { + t.Fatalf("Failed to read patched CSV: %v", err) + } + + var csvData map[string]interface{} + if err := yaml.Unmarshal(patchedContent, &csvData); err != nil { + t.Fatalf("Failed to unmarshal patched CSV: %v", err) + } + + // Navigate to the container spec + spec := csvData["spec"].(map[string]interface{}) + install := spec["install"].(map[string]interface{}) + installSpec := install["spec"].(map[string]interface{}) + deployments := installSpec["deployments"].([]interface{}) + deployment := deployments[0].(map[string]interface{}) + deploymentSpec := deployment["spec"].(map[string]interface{}) + template := deploymentSpec["template"].(map[string]interface{}) + podSpec := template["spec"].(map[string]interface{}) + containers := podSpec["containers"].([]interface{}) + container := containers[0].(map[string]interface{}) + + // Verify operator image was NOT patched (not in local images) + operatorImage := container["image"].(string) + expectedOperatorImage := "quay.io/rhacs-eng/stackrox-operator:4.0.0" + if operatorImage != expectedOperatorImage { + t.Errorf("Operator image should not be patched. Got: %s, Expected: %s", operatorImage, expectedOperatorImage) + } + + // Verify only local images were patched with quay.io paths + envVars := container["env"].([]interface{}) + expectedEnvVars := map[string]string{ + "RELATED_IMAGE_MAIN": "quay.io/rhacs-eng/main:4.0.0", // Patched with quay.io path + "RELATED_IMAGE_SCANNER": "quay.io/rhacs-eng/scanner:4.0.0", // Patched with quay.io path + "RELATED_IMAGE_SCANNER_DB": "quay.io/rhacs-eng/scanner-db:4.0.0", // Not patched, stays original + } + + for _, envVar := range envVars { + envMap := envVar.(map[string]interface{}) + name := envMap["name"].(string) + value := envMap["value"].(string) + + if expectedValue, ok := expectedEnvVars[name]; ok { + if value != expectedValue { + t.Errorf("Env var %s incorrect. Got: %s, Expected: %s", name, value, expectedValue) + } + } + } +} + +// TestPatchCSVWithLocalImages_NoLocalImages tests skipping when no local images exist +func TestPatchCSVWithLocalImages_NoLocalImages(t *testing.T) { + csvContent := `apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + name: rhacs-operator.v4.0.0 +spec: + install: + spec: + deployments: + - name: rhacs-operator-controller-manager + spec: + template: + spec: + containers: + - name: manager + image: quay.io/rhacs-eng/stackrox-operator:4.0.0 + env: + - name: RELATED_IMAGE_MAIN + value: quay.io/rhacs-eng/main:4.0.0 +` + + tmpDir := t.TempDir() + csvFile := filepath.Join(tmpDir, "test.csv.yaml") + originalContent := []byte(csvContent) + if err := os.WriteFile(csvFile, originalContent, 0644); err != nil { + t.Fatalf("Failed to write test CSV: %v", err) + } + + // Empty local images map + localImages := map[string]string{} + + err := patchCSVWithLocalImages(csvFile, "4.0.0", "4.0.0", localImages) + if err != nil { + t.Fatalf("patchCSVWithLocalImages failed: %v", err) + } + + // Verify CSV was not modified + patchedContent, err := os.ReadFile(csvFile) + if err != nil { + t.Fatalf("Failed to read patched CSV: %v", err) + } + + var originalData, patchedData map[string]interface{} + if err := yaml.Unmarshal(originalContent, &originalData); err != nil { + t.Fatalf("Failed to unmarshal original CSV: %v", err) + } + if err := yaml.Unmarshal(patchedContent, &patchedData); err != nil { + t.Fatalf("Failed to unmarshal patched CSV: %v", err) + } + + // Navigate to the image field + getImage := func(data map[string]interface{}) string { + spec := data["spec"].(map[string]interface{}) + install := spec["install"].(map[string]interface{}) + installSpec := install["spec"].(map[string]interface{}) + deployments := installSpec["deployments"].([]interface{}) + deployment := deployments[0].(map[string]interface{}) + deploymentSpec := deployment["spec"].(map[string]interface{}) + template := deploymentSpec["template"].(map[string]interface{}) + podSpec := template["spec"].(map[string]interface{}) + containers := podSpec["containers"].([]interface{}) + container := containers[0].(map[string]interface{}) + return container["image"].(string) + } + + originalImage := getImage(originalData) + patchedImage := getImage(patchedData) + + if originalImage != patchedImage { + t.Errorf("CSV should not be modified when no local images. Original: %s, Patched: %s", originalImage, patchedImage) + } +} + +// TestPatchCSVWithLocalImages_MalformedCSV tests error handling for malformed CSV +func TestPatchCSVWithLocalImages_MalformedCSV(t *testing.T) { + csvContent := `this is not valid yaml: [[[` + + tmpDir := t.TempDir() + csvFile := filepath.Join(tmpDir, "test.csv.yaml") + if err := os.WriteFile(csvFile, []byte(csvContent), 0644); err != nil { + t.Fatalf("Failed to write test CSV: %v", err) + } + + localImages := map[string]string{ + "main:4.0.0": "quay.io/rhacs-eng/main:4.0.0", + } + + err := patchCSVWithLocalImages(csvFile, "4.0.0", "4.0.0", localImages) + if err == nil { + t.Error("Expected error for malformed CSV, got nil") + } +} + +// TestPatchCSVWithLocalImages_MissingFile tests error handling for missing CSV file +func TestPatchCSVWithLocalImages_MissingFile(t *testing.T) { + localImages := map[string]string{ + "main:4.0.0": "quay.io/rhacs-eng/main:4.0.0", + } + + err := patchCSVWithLocalImages("/nonexistent/file.yaml", "4.0.0", "4.0.0", localImages) + if err == nil { + t.Error("Expected error for missing file, got nil") + } +} + +// TestPatchCSVWithLocalImages_PreservesOtherContent tests that non-image content is preserved +func TestPatchCSVWithLocalImages_PreservesOtherContent(t *testing.T) { + csvContent := `apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + name: rhacs-operator.v4.0.0 + labels: + app: rhacs-operator + annotations: + description: "RHACS Operator" +spec: + displayName: "RHACS Operator" + install: + spec: + deployments: + - name: rhacs-operator-controller-manager + spec: + replicas: 1 + template: + metadata: + labels: + control-plane: controller-manager + spec: + containers: + - name: manager + image: quay.io/rhacs-eng/stackrox-operator:4.0.0 + resources: + limits: + cpu: 500m + memory: 128Mi + env: + - name: RELATED_IMAGE_MAIN + value: quay.io/rhacs-eng/main:4.0.0 + - name: OTHER_VAR + value: keep-me +` + + tmpDir := t.TempDir() + csvFile := filepath.Join(tmpDir, "test.csv.yaml") + if err := os.WriteFile(csvFile, []byte(csvContent), 0644); err != nil { + t.Fatalf("Failed to write test CSV: %v", err) + } + + localImages := map[string]string{ + "main:4.0.0": "quay.io/rhacs-eng/main:4.0.0", + } + + err := patchCSVWithLocalImages(csvFile, "4.0.0", "4.0.0", localImages) + if err != nil { + t.Fatalf("patchCSVWithLocalImages failed: %v", err) + } + + // Read and parse the patched CSV + patchedContent, err := os.ReadFile(csvFile) + if err != nil { + t.Fatalf("Failed to read patched CSV: %v", err) + } + + var csvData map[string]interface{} + if err := yaml.Unmarshal(patchedContent, &csvData); err != nil { + t.Fatalf("Failed to unmarshal patched CSV: %v", err) + } + + // Verify metadata is preserved + metadata := csvData["metadata"].(map[string]interface{}) + if metadata["name"].(string) != "rhacs-operator.v4.0.0" { + t.Error("Metadata name was not preserved") + } + labels := metadata["labels"].(map[string]interface{}) + if labels["app"].(string) != "rhacs-operator" { + t.Error("Metadata labels were not preserved") + } + + // Verify spec fields are preserved + spec := csvData["spec"].(map[string]interface{}) + if spec["displayName"].(string) != "RHACS Operator" { + t.Error("displayName was not preserved") + } + + // Verify deployment details are preserved + install := spec["install"].(map[string]interface{}) + installSpec := install["spec"].(map[string]interface{}) + deployments := installSpec["deployments"].([]interface{}) + deployment := deployments[0].(map[string]interface{}) + deploymentSpec := deployment["spec"].(map[string]interface{}) + + if deploymentSpec["replicas"].(int) != 1 { + t.Error("Replicas count was not preserved") + } + + template := deploymentSpec["template"].(map[string]interface{}) + templateMeta := template["metadata"].(map[string]interface{}) + templateLabels := templateMeta["labels"].(map[string]interface{}) + if templateLabels["control-plane"].(string) != "controller-manager" { + t.Error("Template labels were not preserved") + } + + // Verify resources are preserved + podSpec := template["spec"].(map[string]interface{}) + containers := podSpec["containers"].([]interface{}) + container := containers[0].(map[string]interface{}) + resources := container["resources"].(map[string]interface{}) + limits := resources["limits"].(map[string]interface{}) + if limits["cpu"].(string) != "500m" { + t.Error("Resource limits were not preserved") + } + + // Verify non-RELATED_IMAGE env vars are preserved + envVars := container["env"].([]interface{}) + foundOtherVar := false + for _, envVar := range envVars { + envMap := envVar.(map[string]interface{}) + if envMap["name"].(string) == "OTHER_VAR" { + if envMap["value"].(string) != "keep-me" { + t.Error("OTHER_VAR value was modified") + } + foundOtherVar = true + } + } + if !foundOtherVar { + t.Error("OTHER_VAR was not preserved") + } +} diff --git a/internal/localimages/branding.go b/internal/localimages/branding.go new file mode 100644 index 0000000..9fe5f4b --- /dev/null +++ b/internal/localimages/branding.go @@ -0,0 +1,38 @@ +package localimages + +import "os" + +const ( + brandingEnvVar = "ROX_PRODUCT_BRANDING" + + rhacsBranding = "RHACS_BRANDING" + stackroxBranding = "STACKROX_BRANDING" + + rhacsOrg = "rhacs-eng" + stackroxOrg = "stackrox-io" + + rhacsFlavor = "development_build" + stackroxFlavor = "opensource" +) + +// GetBrandingOrganization returns the registry organization based on ROX_PRODUCT_BRANDING. +// Defaults to rhacs-eng if not set. +func GetBrandingOrganization() string { + branding := os.Getenv(brandingEnvVar) + if branding == stackroxBranding { + return stackroxOrg + } + // Default to RHACS branding + return rhacsOrg +} + +// GetImageFlavor returns the image flavor based on ROX_PRODUCT_BRANDING. +// Defaults to development_build if not set. +func GetImageFlavor() string { + branding := os.Getenv(brandingEnvVar) + if branding == stackroxBranding { + return stackroxFlavor + } + // Default to RHACS flavor + return rhacsFlavor +} diff --git a/internal/localimages/branding_test.go b/internal/localimages/branding_test.go new file mode 100644 index 0000000..2fe41e6 --- /dev/null +++ b/internal/localimages/branding_test.go @@ -0,0 +1,49 @@ +package localimages + +import ( + "testing" +) + +func TestGetBrandingRegistry(t *testing.T) { + tests := []struct { + name string + brandingEnv string + expectedOrg string + expectedFlavor string + }{ + { + name: "RHACS branding", + brandingEnv: "RHACS_BRANDING", + expectedOrg: "rhacs-eng", + expectedFlavor: "development_build", + }, + { + name: "STACKROX branding", + brandingEnv: "STACKROX_BRANDING", + expectedOrg: "stackrox-io", + expectedFlavor: "opensource", + }, + { + name: "empty defaults to RHACS", + brandingEnv: "", + expectedOrg: "rhacs-eng", + expectedFlavor: "development_build", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ROX_PRODUCT_BRANDING", tt.brandingEnv) + + org := GetBrandingOrganization() + if org != tt.expectedOrg { + t.Errorf("GetBrandingOrganization() = %v, want %v", org, tt.expectedOrg) + } + + flavor := GetImageFlavor() + if flavor != tt.expectedFlavor { + t.Errorf("GetImageFlavor() = %v, want %v", flavor, tt.expectedFlavor) + } + }) + } +} diff --git a/internal/localimages/detection.go b/internal/localimages/detection.go new file mode 100644 index 0000000..cf83443 --- /dev/null +++ b/internal/localimages/detection.go @@ -0,0 +1,115 @@ +package localimages + +import ( + "fmt" + "os/exec" +) + +const ( + quayRegistry = "quay.io" +) + +// mainImageNames lists the core product images that use mainTag +var mainImageNames = []string{ + "main", + "scanner", + "scanner-db", + "scanner-v4", + "scanner-v4-db", + "central-db", + "collector", +} + +// imageSpec represents an image name and its tag +type imageSpec struct { + name string + tag string +} + +// getExpectedImages returns all expected images with their respective tags. +// Main images use mainTag, while operator images use operatorTag. +func getExpectedImages(mainTag, operatorTag string) []imageSpec { + specs := make([]imageSpec, 0, len(mainImageNames)+2) + + // Main images use mainTag + for _, name := range mainImageNames { + specs = append(specs, imageSpec{name: name, tag: mainTag}) + } + + // Operator images use operatorTag (with and without v prefix) + specs = append(specs, imageSpec{name: "stackrox-operator", tag: operatorTag}) + specs = append(specs, imageSpec{name: "stackrox-operator-bundle", tag: "v" + operatorTag}) + + return specs +} + +// buildImageReferences returns candidate image references to check in podman. +// Checks both branding organizations to handle cases where images don't support +// ROX_PRODUCT_BRANDING (e.g., collector currently only builds with stackrox-io). +func buildImageReferences(imageName, tag string) []string { + currentOrg := GetBrandingOrganization() + + // Determine the fallback organization + var fallbackOrg string + if currentOrg == "rhacs-eng" { + fallbackOrg = "stackrox-io" + } else { + fallbackOrg = "rhacs-eng" + } + + return []string{ + fmt.Sprintf("%s/%s/%s:%s", quayRegistry, currentOrg, imageName, tag), + fmt.Sprintf("%s/%s/%s:%s", quayRegistry, fallbackOrg, imageName, tag), + } +} + +// checkLocalImage checks if an image exists in podman. +// Returns the full image reference and true if found, empty string and false if not found. +func checkLocalImage(imageName, tag string) (string, bool, error) { + refs := buildImageReferences(imageName, tag) + + for _, ref := range refs { + exists, err := podmanImageExists(ref) + if err != nil { + return "", false, fmt.Errorf("checking podman for %s: %w", ref, err) + } + if exists { + return ref, true, nil + } + } + + return "", false, nil +} + +// checks if an image exists in podman +func podmanImageExists(imageRef string) (bool, error) { + cmd := exec.Command("podman", "image", "exists", imageRef) + err := cmd.Run() + if err != nil { + // Exit code 1 means image doesn't exist (expected) + // Other errors are actual failures + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err + } + return true, nil +} + +// CheckImages checks which images from the overall set exist locally. +// Returns a map of image names to their full references. +func CheckImages(mainTag, operatorTag string) (map[string]string, error) { + localImages := make(map[string]string) + + for _, img := range getExpectedImages(mainTag, operatorTag) { + ref, found, err := checkLocalImage(img.name, img.tag) + if err != nil { + return nil, fmt.Errorf("checking %s:%s: %w", img.name, img.tag, err) + } + if found { + localImages[img.name+":"+img.tag] = ref + } + } + + return localImages, nil +} diff --git a/internal/localimages/detection_test.go b/internal/localimages/detection_test.go new file mode 100644 index 0000000..84217ce --- /dev/null +++ b/internal/localimages/detection_test.go @@ -0,0 +1,52 @@ +package localimages + +import ( + "testing" +) + +func TestBuildImageReferences(t *testing.T) { + tests := []struct { + name string + imageName string + tag string + branding string + expected []string + }{ + { + name: "RHACS branding", + imageName: "main", + tag: "4.10.0", + branding: "RHACS_BRANDING", + expected: []string{ + "quay.io/rhacs-eng/main:4.10.0", + "quay.io/stackrox-io/main:4.10.0", // fallback + }, + }, + { + name: "STACKROX branding", + imageName: "scanner", + tag: "4.9.2", + branding: "STACKROX_BRANDING", + expected: []string{ + "quay.io/stackrox-io/scanner:4.9.2", + "quay.io/rhacs-eng/scanner:4.9.2", // fallback + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ROX_PRODUCT_BRANDING", tt.branding) + + result := buildImageReferences(tt.imageName, tt.tag) + if len(result) != len(tt.expected) { + t.Fatalf("buildImageReferences() returned %d refs, want %d", len(result), len(tt.expected)) + } + for i, ref := range result { + if ref != tt.expected[i] { + t.Errorf("buildImageReferences()[%d] = %v, want %v", i, ref, tt.expected[i]) + } + } + }) + } +} diff --git a/internal/localimages/loading.go b/internal/localimages/loading.go new file mode 100644 index 0000000..a18ad82 --- /dev/null +++ b/internal/localimages/loading.go @@ -0,0 +1,93 @@ +package localimages + +import ( + "context" + "fmt" + "os/exec" + "sync" + + "github.com/stackrox/roxie/internal/logger" +) + +// loadImageToKind loads a single image into a kind cluster. +func loadImageToKind(ctx context.Context, imageRef, clusterName string, log *logger.Logger) error { + log.Dimf("Loading %s into kind cluster %s", imageRef, clusterName) + + cmd := exec.CommandContext(ctx, "kind", "load", "docker-image", imageRef, "-n", clusterName) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("kind load failed for %s: %w\nOutput: %s", imageRef, err, string(output)) + } + + return nil +} + +// LoadImagesToKind loads multiple images into a kind cluster in parallel. +// Uses up to 4 concurrent workers to speed up loading. +// Returns on first error encountered (fail-fast behavior). +// +// Images are loaded using quay.io paths (e.g., quay.io/rhacs-eng/main:tag) instead of +// localhost paths to ensure the tags in kind match what the operator CSV will reference. +func LoadImagesToKind(ctx context.Context, images map[string]string, mainImageTag, operatorTag string, clusterName string, log *logger.Logger) error { + if len(images) == 0 { + return nil + } + + log.Infof("Loading %d images into kind cluster %s", len(images), clusterName) + + // Build list of image references to load + // Use the actual references found during detection (which may be from fallback branding) + imageRefs := make([]string, 0, len(images)) + + for _, img := range getExpectedImages(mainImageTag, operatorTag) { + imageKey := img.name + ":" + img.tag + if imageRef, exists := images[imageKey]; exists { + imageRefs = append(imageRefs, imageRef) + } + } + + // Channel for images to process + imageChan := make(chan string, len(imageRefs)) + for _, imageRef := range imageRefs { + imageChan <- imageRef + } + close(imageChan) + + // Error channel + errChan := make(chan error, len(imageRefs)) + + // Use 4 workers for parallel loading (matching existing image verification parallelism) + const numWorkers = 4 + var wg sync.WaitGroup + + for i := 0; i < numWorkers && i < len(imageRefs); i++ { + wg.Add(1) + go func() { + defer wg.Done() + for imageRef := range imageChan { + if err := loadImageToKind(ctx, imageRef, clusterName, log); err != nil { + errChan <- err + return + } + } + }() + } + + wg.Wait() + close(errChan) + + // Check for errors - collect all errors that occurred + var firstErr error + for err := range errChan { + if firstErr == nil { + firstErr = err + } + } + + if firstErr != nil { + return firstErr + } + + log.Infof("Successfully loaded %d images into kind cluster", len(imageRefs)) + return nil +} diff --git a/internal/localimages/loading_test.go b/internal/localimages/loading_test.go new file mode 100644 index 0000000..40f05fb --- /dev/null +++ b/internal/localimages/loading_test.go @@ -0,0 +1,28 @@ +package localimages + +import ( + "context" + "testing" + + "github.com/stackrox/roxie/internal/logger" +) + +func TestLoadImageToKind(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // This would require a real kind cluster, so we just test the command building + t.Log("Integration test placeholder - would require real kind cluster") +} + +func TestLoadImagesToKind_EmptyImages(t *testing.T) { + ctx := context.Background() + // Create a test logger (use nil if logger.New doesn't exist in tests) + log := &logger.Logger{} + + err := LoadImagesToKind(ctx, map[string]string{}, "4.10.0", "4.10.0", "test-cluster", log) + if err != nil { + t.Errorf("LoadImagesToKind with empty map should not error, got: %v", err) + } +}