diff --git a/Dockerfile b/Dockerfile index 56cde05..d75852e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,8 +104,8 @@ RUN microdnf install -y \ gzip \ unzip \ ca-certificates \ - # Container tools - podman \ + # Container tools (skopeo for OCI image operations, no daemon required) + skopeo \ # Python (required for gcloud SDK) python3 \ # Clean up @@ -155,10 +155,6 @@ RUN ARCH=${TARGETARCH:-amd64} && \ echo "${ROXCTL_SHA256} /usr/local/bin/roxctl" | sha256sum -c - && \ chmod +x /usr/local/bin/roxctl -# Install podman (required for extracting operator bundles) -# fuse-overlayfs provides better performance but vfs driver is more compatible -RUN microdnf install -y podman fuse-overlayfs \ - && microdnf clean all # Install common kubectl credential plugins for cloud provider authentication # This enables kubectl to work with GKE, EKS, AKS, and OpenShift clusters @@ -199,19 +195,8 @@ RUN chmod +x /usr/local/bin/roxcurl # This allows users to mount credentials directly at their standard paths: # -v ~/.kube:/.kube:ro instead of -v ~/.kube:/home/roxie/.kube:ro RUN useradd -r -u 1000 -d / -s /bin/bash roxie \ - && mkdir -p /.kube /.roxie /.local/share/containers /.config /.aws /.azure \ - && chown -R roxie:roxie /.kube /.roxie /.local /.config /.aws /.azure - -# Configure podman for rootless operation inside container -# This is critical for roxie's operator bundle extraction functionality -# Using VFS storage driver for maximum compatibility in containerized environments -RUN mkdir -p /etc/containers && \ - echo 'unqualified-search-registries = ["docker.io", "quay.io"]' > /etc/containers/registries.conf && \ - echo '[storage]' > /etc/containers/storage.conf && \ - echo 'driver = "vfs"' >> /etc/containers/storage.conf && \ - echo 'runroot = "/tmp/containers/storage"' >> /etc/containers/storage.conf && \ - echo 'graphroot = "/.local/share/containers/storage"' >> /etc/containers/storage.conf && \ - chmod 644 /etc/containers/storage.conf /etc/containers/registries.conf + && mkdir -p /.kube /.roxie /.config /.aws /.azure \ + && chown -R roxie:roxie /.kube /.roxie /.config /.aws /.azure # Set working directory WORKDIR /workspace diff --git a/Makefile b/Makefile index 916e8c6..a11bcc3 100644 --- a/Makefile +++ b/Makefile @@ -121,10 +121,6 @@ test-integration: build ## Run integration tests (requires kubectl context and c echo "❌ No kubectl context found. Please configure kubectl first."; \ exit 1; \ fi - @if ! command -v podman >/dev/null 2>&1; then \ - echo "❌ podman not found. Please install podman for integration tests."; \ - exit 1; \ - fi $(GOTEST) -v -tags=integration -run=_Integration$$ -timeout=120m -parallel=1 ./... .PHONY: test-all diff --git a/README.md b/README.md index ab71e11..a968974 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ the cluster to succeed. Prerequisites: - `kubectl` configured to point at your target cluster -- `podman` is set up and available +- `skopeo` is available (for OCI image operations) - The `roxctl` CLI - The `roxie` branch forked and cloned to your local machine diff --git a/go.mod b/go.mod index 66adbaf..732f4dd 100644 --- a/go.mod +++ b/go.mod @@ -23,9 +23,9 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect diff --git a/go.sum b/go.sum index 8a417fb..3c7a2d3 100644 --- a/go.sum +++ b/go.sum @@ -44,16 +44,16 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= diff --git a/internal/deployer/deploy_via_helm.go b/internal/deployer/deploy_via_helm.go index 3d4fff7..a7c429d 100644 --- a/internal/deployer/deploy_via_helm.go +++ b/internal/deployer/deploy_via_helm.go @@ -443,7 +443,7 @@ func (d *Deployer) verifyHelmChartImages(ctx context.Context, chartDir, valuesFi d.logger.Dim(fmt.Sprintf(" - %s", img)) } - if !d.imageCache.VerifyImagesPullable(imageRefs...) { + if !d.imageCache.VerifyImagesPullable(ctx, imageRefs...) { return errors.New("one or more images not found or not pullable") } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 0643366..34b6f93 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -797,7 +797,7 @@ func (d *Deployer) waitForNamespaceDeletion(namespace string) error { // checkRequiredTools verifies that required CLI tools are available func checkRequiredTools() error { - requiredTools := []string{"kubectl", "roxctl"} + requiredTools := []string{"kubectl", "roxctl", "skopeo"} var missing []string for _, tool := range requiredTools { @@ -806,18 +806,6 @@ func checkRequiredTools() error { } } - // Check for container tool (podman or docker) - containerTool := "" - if _, err := exec.LookPath("podman"); err == nil { - containerTool = "podman" - } else if _, err := exec.LookPath("docker"); err == nil { - containerTool = "docker" - } - - if containerTool == "" { - missing = append(missing, "podman or docker") - } - if len(missing) > 0 { return fmt.Errorf("required tools not found in PATH: %s\nPlease install these tools and ensure they are available in your PATH", strings.Join(missing, ", ")) } diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index 0d5d584..96dd4ed 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -8,7 +8,6 @@ import ( "fmt" "math/big" "os" - "os/exec" "path/filepath" "strings" "time" @@ -16,7 +15,7 @@ import ( "gopkg.in/yaml.v3" "github.com/stackrox/roxie/internal/env" - "github.com/stackrox/roxie/internal/helpers" + "github.com/stackrox/roxie/internal/skopeohelper" ) const ( @@ -70,46 +69,11 @@ func (d *Deployer) downloadAndExtractOperatorBundle(ctx context.Context, bundleI return "", fmt.Errorf("failed to create temp dir: %w", err) } - d.logger.Dim(fmt.Sprintf("Created temporary directory: %s", bundleDir)) - - containerTool := helpers.GetContainerTool() - d.logger.Dim(fmt.Sprintf("Using %s to extract bundle", containerTool)) - - // Check if image exists locally - inspectCmd := exec.CommandContext(ctx, containerTool, "inspect", bundleImage) - if err := inspectCmd.Run(); err != nil { - // Image doesn't exist locally, pull it - d.logger.Info("Pulling operator bundle image...") - // Force amd64 platform for pulling the operator bundle image. - // This is - // 1. fine because bundle images only contain platform-agnostic YAML files and - // 2. required to pull the image after the recent changes to the operator bundle image build process. - pullCmd := exec.CommandContext(ctx, containerTool, "pull", "--platform", "linux/amd64", bundleImage) - if output, err := pullCmd.CombinedOutput(); err != nil { - os.RemoveAll(bundleDir) - d.logger.Dim("Command output:") - d.logger.Dim(string(output)) - return "", fmt.Errorf("failed to pull bundle image: %w", err) - } - } else { - d.logger.Dim("Bundle image already available locally, skipping pull") - } - - containerID := fmt.Sprintf("stackrox-bundle-extract-%d", time.Now().Unix()) - - createCmd := exec.CommandContext(ctx, containerTool, "create", "--name", containerID, bundleImage) - if err := createCmd.Run(); err != nil { - os.RemoveAll(bundleDir) - return "", fmt.Errorf("failed to create container: %w", err) - } - - defer func() { - rmCmd := exec.Command(containerTool, "rm", containerID) - rmCmd.Run() - }() + d.logger.Dimf("Created temporary directory: %s", bundleDir) + d.logger.Info("Pulling and extracting operator bundle image...") - cpCmd := exec.CommandContext(ctx, containerTool, "cp", fmt.Sprintf("%s:/manifests/.", containerID), bundleDir) - if err := cpCmd.Run(); err != nil { + // Force amd64 platform - the bundle images only contain platform-agnostic YAML files. + if err := skopeohelper.ExtractManifestsFromImage(ctx, d.logger, bundleImage, bundleDir); err != nil { os.RemoveAll(bundleDir) return "", fmt.Errorf("failed to copy bundle contents: %w", err) } diff --git a/internal/imagecache/imagecache.go b/internal/imagecache/imagecache.go index 123e494..e072389 100644 --- a/internal/imagecache/imagecache.go +++ b/internal/imagecache/imagecache.go @@ -1,14 +1,15 @@ package imagecache import ( + "context" "encoding/json" "fmt" "os" "path/filepath" "sync" - "github.com/stackrox/roxie/internal/helpers" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/skopeohelper" ) // ImageCache manages cache of verified pullable Docker images @@ -127,31 +128,24 @@ func (ic *ImageCache) AddToCache(imageRef string) { } // VerifyImagePullable verifies if image is pullable using cache when possible -func (ic *ImageCache) VerifyImagePullable(imageRef string) bool { +func (ic *ImageCache) VerifyImagePullable(ctx context.Context, imageRef string) bool { if ic.IsCached(imageRef) { return true } - _, stderr, err := helpers.RunCommandWithOutput( - "Verifying image pullability", - "podman", - []string{"manifest", "inspect", imageRef}, - ) - + // Use skopeo inspect to verify image accessibility. + err := skopeohelper.InspectImage(ctx, ic.logger, imageRef) if err == nil { ic.AddToCache(imageRef) return true } - if stderr != "" { - fmt.Fprintln(os.Stderr, stderr) - } - + fmt.Fprintf(os.Stderr, "Failed to verify image %s: %v\n", imageRef, err) return false } // VerifyImagesPullable verifies that Docker images are pullable using cached results -func (ic *ImageCache) VerifyImagesPullable(images ...string) bool { +func (ic *ImageCache) VerifyImagesPullable(ctx context.Context, images ...string) bool { if len(images) == 0 { return true } @@ -207,7 +201,7 @@ func (ic *ImageCache) VerifyImagesPullable(images ...string) bool { sem <- struct{}{} // Acquire semaphore defer func() { <-sem }() // Release semaphore - success := ic.VerifyImagePullable(image) + success := ic.VerifyImagePullable(ctx, image) if success { results <- result{img: image, success: true} } else { diff --git a/internal/skopeohelper/skopeohelper.go b/internal/skopeohelper/skopeohelper.go new file mode 100644 index 0000000..6431f32 --- /dev/null +++ b/internal/skopeohelper/skopeohelper.go @@ -0,0 +1,212 @@ +package skopeohelper + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/stackrox/roxie/internal/logger" +) + +// ExtractManifestsFromImage extracts the /manifests/ directory from an operator bundle image. +// Authentication is handled automatically by skopeo from ~/.docker/config.json or $REGISTRY_AUTH_FILE. +func ExtractManifestsFromImage(ctx context.Context, log *logger.Logger, imageRef, destDir string) error { + tempDir, err := os.MkdirTemp("", "skopeo-image-") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + log.Dimf("Using temporary directory: %s", tempDir) + + if err := copyImageToDir(ctx, log, imageRef, tempDir); err != nil { + return err + } + + log.Dim("Extracting /manifests/ directory from image layers...") + if err := extractManifestsFromDir(log, tempDir, destDir); err != nil { + return err + } + + log.Dimf("✓ Manifests extracted to: %s", destDir) + return nil +} + +// InspectImage verifies that an OCI image is accessible. +// Authentication is handled automatically by skopeo from ~/.docker/config.json or $REGISTRY_AUTH_FILE. +func InspectImage(ctx context.Context, log *logger.Logger, imageRef string) error { + log.Dimf("Inspecting image %s", imageRef) + + cmd := exec.CommandContext(ctx, "skopeo", "inspect", "--raw", withDockerTransport(imageRef)) + output, err := cmd.CombinedOutput() + if err != nil { + log.Dimf("skopeo command output: %s", string(output)) + return fmt.Errorf("skopeo inspect failed: %w", err) + } + + log.Dim("✓ Image is accessible") + return nil +} + +// copyImageToDir downloads an OCI image to a local directory. +func copyImageToDir(ctx context.Context, log *logger.Logger, imageRef, destDir string) error { + log.Dimf("Copying image %s to %s", imageRef, destDir) + + args := []string{ + "copy", + "--override-os", "linux", + "--override-arch", "amd64", + withDockerTransport(imageRef), + fmt.Sprintf("dir:%s", destDir), + } + + cmd := exec.CommandContext(ctx, "skopeo", args...) + output, err := cmd.CombinedOutput() + if err != nil { + log.Dimf("skopeo command output: %s", string(output)) + return fmt.Errorf("skopeo copy failed: %w", err) + } + + log.Dim("✓ Image copied successfully") + return nil +} + +type imageManifest struct { + Layers []imageLayer `json:"layers"` +} + +type imageLayer struct { + Digest string `json:"digest"` +} + +// extractManifestsFromDir extracts /manifests/ from a skopeo dir-format image. +func extractManifestsFromDir(log *logger.Logger, skopeoDir, destDir string) error { + manifestPath := filepath.Join(skopeoDir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("failed to read manifest: %w", err) + } + + var manifest imageManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return fmt.Errorf("failed to parse manifest: %w", err) + } + + // Extract all image layers into tempExtractDir. + log.Dimf("Found %d layer(s) in image", len(manifest.Layers)) + tempExtractDir, err := os.MkdirTemp("", "layer-extract-") + if err != nil { + return fmt.Errorf("failed to create temp extract dir: %w", err) + } + defer os.RemoveAll(tempExtractDir) + + if err := extractLayers(log, manifest.Layers, skopeoDir, tempExtractDir); err != nil { + return err + } + + // From the directory into which all layers have been extracted, copy the + // /manifests/ directory to the destination. + log.Dim("Copying manifests to destination...") + manifestsDir := filepath.Join(tempExtractDir, "manifests") + if _, err := os.Stat(manifestsDir); err != nil { + return fmt.Errorf("no /manifests directory found in image: %w", err) + } + + return os.CopyFS(destDir, os.DirFS(manifestsDir)) +} + +// extractLayers extracts all image layers to a destination directory. +func extractLayers(log *logger.Logger, layers []imageLayer, skopeoDir, destDir string) error { + for i, layer := range layers { + log.Dimf("Extracting layer %d/%d...", i+1, len(layers)) + // Strip algorithm prefix (sha256:, sha512:, blake3:, etc.) from digest to obtain the filename. + _, encoded, ok := strings.Cut(layer.Digest, ":") + if !ok { + return fmt.Errorf("invalid digest format (missing ':' separator): %s", layer.Digest) + } + layerFile := filepath.Join(skopeoDir, encoded) + if err := extractTarToDir(layerFile, destDir); err != nil { + return fmt.Errorf("failed to extract layer %s: %w", layer.Digest, err) + } + } + return nil +} + +// extractTarToDir extracts a gzip-compressed tar file to a directory. +func extractTarToDir(tarPath, destDir string) error { + f, err := os.Open(tarPath) + if err != nil { + return fmt.Errorf("failed to open tar file: %w", err) + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + dirPermissions := make(map[string]os.FileMode) // Map to track directory permissions. + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + + // Prevent path traversal attacks. + target := filepath.Join(destDir, header.Name) + relPath, err := filepath.Rel(destDir, target) + if err != nil || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) || relPath == ".." { + continue + } + + switch header.Typeflag { + case tar.TypeSymlink, tar.TypeLink: + // Skip symlinks and hardlinks for security. + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + dirPermissions[target] = os.FileMode(header.Mode) + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", target, err) + } + + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return fmt.Errorf("failed to write file %s: %w", target, err) + } + outFile.Close() + } + } + + for dir, perm := range dirPermissions { + if err := os.Chmod(dir, perm); err != nil { + return fmt.Errorf("failed to set directory permissions for %s: %w", dir, err) + } + } + + return nil +} + +func withDockerTransport(imageRef string) string { + return "docker://" + imageRef +} diff --git a/internal/skopeohelper/skopeohelper_integration_test.go b/internal/skopeohelper/skopeohelper_integration_test.go new file mode 100644 index 0000000..869dc19 --- /dev/null +++ b/internal/skopeohelper/skopeohelper_integration_test.go @@ -0,0 +1,115 @@ +//go:build integration + +package skopeohelper + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stackrox/roxie/internal/logger" +) + +func TestExtractManifestsFromImage_Integration(t *testing.T) { + // Use a known stable operator bundle image. + bundleImage := "quay.io/rhacs-eng/stackrox-operator-bundle:v4.10.0" + + destDir, err := os.MkdirTemp("", "test-bundle-extract-") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(destDir) + + log := logger.New() + ctx := context.Background() + + t.Logf("Extracting manifests from %s", bundleImage) + err = ExtractManifestsFromImage(ctx, log, bundleImage, destDir) + if err != nil { + t.Fatalf("ExtractManifestsFromImage failed: %v", err) + } + + // Verify expected files exist. + expectedFiles := []string{ + "rhacs-operator.clusterserviceversion.yaml", + "rhacs-operator-controller-manager-metrics-service_v1_service.yaml", + "rhacs-operator-metrics-reader_rbac.authorization.k8s.io_v1_clusterrole.yaml", + } + + for _, expectedFile := range expectedFiles { + filePath := filepath.Join(destDir, expectedFile) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Expected file %s not found in extracted manifests", expectedFile) + } else { + t.Logf("✓ Found expected file: %s", expectedFile) + } + } + + // Verify that extracted files are non-empty. + entries, err := os.ReadDir(destDir) + if err != nil { + t.Fatalf("Failed to read destination directory: %v", err) + } + + if len(entries) == 0 { + t.Fatal("No files were extracted") + } + + t.Logf("Successfully extracted %d files", len(entries)) + + // Verify CSV file contents and size. + csvPath := filepath.Join(destDir, "rhacs-operator.clusterserviceversion.yaml") + csvContent, err := os.ReadFile(csvPath) + if err != nil { + t.Fatalf("Failed to read CSV file: %v", err) + } + + // Verify exact size for pinned image version. + expectedCSVSize := 105398 + if len(csvContent) != expectedCSVSize { + t.Errorf("CSV file size mismatch: expected %d bytes, got %d bytes", expectedCSVSize, len(csvContent)) + } + + // Basic YAML structure check. + csvStr := string(csvContent) + if !strings.Contains(csvStr, "apiVersion") { + t.Error("CSV file does not appear to be valid Kubernetes YAML (missing apiVersion)") + } + + t.Logf("✓ CSV file verified (%d bytes)", len(csvContent)) +} + +func TestInspectImage_Integration(t *testing.T) { + bundleImage := "quay.io/rhacs-eng/stackrox-operator-bundle:v4.10.0" + + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Logf("Inspecting image %s", bundleImage) + err := InspectImage(ctx, log, bundleImage) + if err != nil { + t.Fatalf("InspectImage failed: %v", err) + } + + t.Log("✓ Image inspection successful") +} + +func TestInspectImage_NonExistent_Integration(t *testing.T) { + nonExistentImage := "quay.io/rhacs-eng/this-image-does-not-exist:v999.999.999" + + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Logf("Inspecting non-existent image %s", nonExistentImage) + err := InspectImage(ctx, log, nonExistentImage) + if err == nil { + t.Fatal("Expected InspectImage to fail for non-existent image, but it succeeded") + } + + t.Logf("✓ InspectImage correctly failed for non-existent image: %v", err) +}