From e3e7175690c2e0e557c2f4cdb8ed750baa308f99 Mon Sep 17 00:00:00 2001 From: aptmac Date: Wed, 6 May 2026 11:25:31 -0400 Subject: [PATCH] feat(identifier): identify s2i built application and eap operator-managed pods - Use image metadata to identify s2i built applications - Use 'managed-by: eap-operator' to identify EAP operator managed pods --- .gitignore | 3 + README.md | 16 +- cmd/main.go | 7 + config/rbac/role.yaml | 3 + go.mod | 1 + go.sum | 2 + internal/controller/discovery_controller.go | 10 +- .../controller/discovery_controller_test.go | 32 ++-- internal/identifier/identifier.go | 94 ++++++++++- internal/identifier/identifier_test.go | 45 ++--- internal/identifier/image_inspector.go | 156 ++++++++++++++++++ 11 files changed, 316 insertions(+), 53 deletions(-) create mode 100644 internal/identifier/image_inspector.go diff --git a/.gitignore b/.gitignore index bbed703..99bffca 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist/ *.swp *.swo *~ + +# Local, test specific folder +test-eap-builder diff --git a/README.md b/README.md index 7c0ecd5..c6ce117 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,19 @@ OpenShift Operator for automatic detection and labeling of Red Hat application p ## Description -This is currently in a Proof of Concept state. The only pods that will be identified and labelled are EAP at the moment, there's a map in `identifier.go` that can be expanded upon to include other images. +This is currently in a Proof of Concept state. The operator identifies and labels JBoss EAP pods from multiple deployment methods: + +- **Direct deployments**: Pods using Red Hat EAP images (e.g., `registry.redhat.io/jboss-eap-7/...`) +- **S2I builds**: Source-to-Image built applications with EAP base images +- **EAP Operator-managed pods**: Pods deployed via the EAP Operator (identified by `app.kubernetes.io/managed-by: eap-operator` label) + +The operator adds the following labels to identified pods: +- `rht.comp`: Red Hat component/product name ("EAP") +- `rht.pod_image`: The pod's container image name +- `rht.pod_image_ver`: Version extracted from the pod's container image tag +- `rht.comp_discovered`: Unix timestamp of when the pod was first discovered + +The product detection map in `identifier.go` can be expanded to include other Red Hat middleware products. ## Getting Started @@ -18,6 +30,8 @@ Your operator will need to be run with the following permissions: Get, List, Watch, Patch, Update on pods. +Get, List, Watch on images. + ### Running on the cluster You’ll need an OpenShift cluster to run against. You can use CRC to get a local cluster for testing, or run against a remote cluster. Note: Your controller will automatically use the current context in your kubeconfig file (i.e. whatever cluster oc cluster-info shows). diff --git a/cmd/main.go b/cmd/main.go index 3ed4228..1d1aa59 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -26,6 +26,7 @@ import ( // to ensure that exec-entrypoint and run can make use of them. "github.com/aptmac/app-discovery-operator/internal/controller" "github.com/aptmac/app-discovery-operator/internal/identifier" + imagev1 "github.com/openshift/api/image/v1" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/apimachinery/pkg/runtime" @@ -48,6 +49,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(imagev1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -203,6 +205,11 @@ func main() { // Create identifier productIdentifier := identifier.NewIdentifier() + // Try to set up OpenShift Image API support (will be nil in vanilla Kubernetes) + imageInspector := identifier.NewImageInspector(mgr.GetClient()) + productIdentifier.SetImageInspector(imageInspector) + setupLog.Info("Image inspector configured for S2I detection") + // Setup App Discovery controller if err = (&controller.AppDiscoveryReconciler{ Client: mgr.GetClient(), diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d573ef5..31bdca0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -9,3 +9,6 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch", "patch", "update"] +- apiGroups: ["image.openshift.io"] + resources: ["images"] + verbs: ["get", "list", "watch"] diff --git a/go.mod b/go.mod index c5e2d26..6167517 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openshift/api v0.0.0-20240124164020-e2ce40831f2e // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect diff --git a/go.sum b/go.sum index 14ef049..51d0ae1 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/openshift/api v0.0.0-20240124164020-e2ce40831f2e h1:cxgCNo/R769CO23AK5TCh45H9SMUGZ8RukiF2/Qif3o= +github.com/openshift/api v0.0.0-20240124164020-e2ce40831f2e/go.mod h1:CxgbWAlvu2iQB0UmKTtRu1YfepRg1/vJ64n2DlIEVz4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/controller/discovery_controller.go b/internal/controller/discovery_controller.go index 2d4ed9f..5307c61 100644 --- a/internal/controller/discovery_controller.go +++ b/internal/controller/discovery_controller.go @@ -50,7 +50,7 @@ func (r *AppDiscoveryReconciler) Reconcile(ctx context.Context, req ctrl.Request } // Identify if this is a Red Hat product - match := r.Identifier.IdentifyPod(pod) + match := r.Identifier.IdentifyPod(ctx, pod) if match == nil { // Not a Red Hat product, nothing to do log.V(1).Info("Pod is not a Red Hat product", "pod", pod.Name) @@ -97,8 +97,8 @@ func (r *AppDiscoveryReconciler) labelPod(ctx context.Context, pod *corev1.Pod, pod.Labels["rht.comp"] = match.ProductName } - if _, exists := pod.Labels["rht.comp_ver"]; !exists { - pod.Labels["rht.comp_ver"] = match.Version + if _, exists := pod.Labels["rht.pod_image_ver"]; !exists { + pod.Labels["rht.pod_image_ver"] = match.Version } // Only set discovered timestamp if it doesn't exist (first seen time, not last modified) @@ -106,9 +106,9 @@ func (r *AppDiscoveryReconciler) labelPod(ctx context.Context, pod *corev1.Pod, pod.Labels["rht.comp_discovered"] = fmt.Sprintf("%d", match.Discovered.Unix()) } - if _, exists := pod.Labels["rht.comp_image"]; !exists { + if _, exists := pod.Labels["rht.pod_image"]; !exists { // Store the full image name (sanitized for label format) - pod.Labels["rht.comp_image"] = sanitizeLabelValue(match.Image) + pod.Labels["rht.pod_image"] = sanitizeLabelValue(match.Image) } // Update the pod diff --git a/internal/controller/discovery_controller_test.go b/internal/controller/discovery_controller_test.go index f1cc121..e8a0a11 100644 --- a/internal/controller/discovery_controller_test.go +++ b/internal/controller/discovery_controller_test.go @@ -256,12 +256,12 @@ func TestReconcile_RedHatPodLabeling(t *testing.T) { } // Check product and version labels - if updatedPod.Labels["rht.comp"] != "jboss-eap" { - t.Errorf("Expected rht.app to be 'jboss-eap', got '%s'", updatedPod.Labels["rht.comp"]) + if updatedPod.Labels["rht.comp"] != "EAP" { + t.Errorf("Expected rht.comp to be 'EAP', got '%s'", updatedPod.Labels["rht.comp"]) } - if updatedPod.Labels["rht.comp_ver"] != "7.4.0" { - t.Errorf("Expected rht.comp_ver to be '7.4.0', got '%s'", updatedPod.Labels["rht.comp_ver"]) + if updatedPod.Labels["rht.pod_image_ver"] != "7.4.0" { + t.Errorf("Expected rht.pod_image_ver to be '7.4.0', got '%s'", updatedPod.Labels["rht.pod_image_ver"]) } // Check discovered label exists (it's a timestamp, so just verify it exists) @@ -270,8 +270,8 @@ func TestReconcile_RedHatPodLabeling(t *testing.T) { } // Verify image label exists and is sanitized - if _, exists := updatedPod.Labels["rht.comp_image"]; !exists { - t.Error("Expected rht.comp_image label to exist") + if _, exists := updatedPod.Labels["rht.pod_image"]; !exists { + t.Error("Expected rht.pod_image label to exist") } } @@ -284,10 +284,10 @@ func TestReconcile_AlreadyLabeledPod(t *testing.T) { Name: "eap-pod", Namespace: "default", Labels: map[string]string{ - "rht.comp": "jboss-eap", - "rht.comp_ver": "7.4.0", + "rht.comp": "EAP", + "rht.pod_image_ver": "7.4.0", "rht.comp_discovered": "1776437286", // Timestamp - "rht.comp_image": "registry.redhat.io-jboss-eap-7-eap74-openjdk11-openshift-rhel8-7.4.0", + "rht.pod_image": "registry.redhat.io-jboss-eap-7-eap74-openjdk11-openshift-rhel8-7.4.0", }, }, Spec: corev1.PodSpec{ @@ -344,7 +344,7 @@ func TestReconcile_AlreadyLabeledPod(t *testing.T) { // In a real cluster, ResourceVersion would change if the pod was updated // With fake client, we just verify labels are still correct - if afterPod.Labels["rht.comp"] != "jboss-eap" { + if afterPod.Labels["rht.comp"] != "EAP" { t.Error("Labels should remain unchanged for already labeled pod") } } @@ -358,7 +358,7 @@ func TestReconcile_MissingLabels(t *testing.T) { Name: "eap-pod", Namespace: "default", Labels: map[string]string{ - "rht.comp": "jboss-eap", + "rht.comp": "EAP", // Missing version and discovered labels }, }, @@ -407,7 +407,7 @@ func TestReconcile_MissingLabels(t *testing.T) { t.Fatalf("Failed to get updated pod: %v", err) } - if updatedPod.Labels["rht.comp_ver"] != "7.4.0" { + if updatedPod.Labels["rht.pod_image_ver"] != "7.4.0" { t.Error("Expected version label to be added") } @@ -536,16 +536,16 @@ func TestReconcile_UserProvidedLabelsNotOverwritten(t *testing.T) { } // Missing labels should be added - if _, exists := updatedPod.Labels["rht.comp_ver"]; !exists { - t.Error("Expected rht.comp_ver label to be added") + if _, exists := updatedPod.Labels["rht.pod_image_ver"]; !exists { + t.Error("Expected rht.pod_image_ver label to be added") } if _, exists := updatedPod.Labels["rht.comp_discovered"]; !exists { t.Error("Expected rht.comp_discovered label to be added") } - if _, exists := updatedPod.Labels["rht.comp_image"]; !exists { - t.Error("Expected rht.comp_image label to be added") + if _, exists := updatedPod.Labels["rht.pod_image"]; !exists { + t.Error("Expected rht.pod_image label to be added") } } diff --git a/internal/identifier/identifier.go b/internal/identifier/identifier.go index 8dcb41d..50bb685 100644 --- a/internal/identifier/identifier.go +++ b/internal/identifier/identifier.go @@ -1,6 +1,7 @@ package identifier import ( + "context" "strings" "time" @@ -17,7 +18,8 @@ type ProductMatch struct { // Identifier detects Red Hat products from pod specifications type Identifier struct { - patterns map[string]string + patterns map[string]string + imageInspector *ImageInspector } // NewIdentifier creates a new product identifier with hardcoded patterns @@ -25,16 +27,29 @@ func NewIdentifier() *Identifier { return &Identifier{ patterns: map[string]string{ // JBoss EAP (Enterprise Application Platform) - "registry.redhat.io/jboss-eap-7": "jboss-eap", - "registry.redhat.io/jboss-eap-8": "jboss-eap", - "registry.redhat.io/jboss-eap/jboss-eap": "jboss-eap", + "jboss-eap-7": "EAP", + "jboss-eap-8": "EAP", }, + imageInspector: nil, // Will be set if running in OpenShift } } +// SetImageInspector sets the image inspector for OpenShift Image API access +func (i *Identifier) SetImageInspector(inspector *ImageInspector) { + i.imageInspector = inspector +} + // IdentifyPod analyzes a pod and returns product information if it's a Red Hat product -func (i *Identifier) IdentifyPod(pod *corev1.Pod) *ProductMatch { - // Check all containers in the pod +func (i *Identifier) IdentifyPod(ctx context.Context, pod *corev1.Pod) *ProductMatch { + // First, check if this is an EAP Operator-managed pod + // The EAP Operator already adds rht.comp label, but we want to add our additional labels + if pod.Labels != nil { + if managedBy, exists := pod.Labels["app.kubernetes.io/managed-by"]; exists && managedBy == "eap-operator" { + return i.identifyOperatorManagedPod(pod) + } + } + + // Second, try to identify by image name (direct deployments) for _, container := range pod.Spec.Containers { if match := i.identifyImage(container.Image); match != nil { return match @@ -48,9 +63,44 @@ func (i *Identifier) IdentifyPod(pod *corev1.Pod) *ProductMatch { } } + // Fallback: Try OpenShift Image API (for S2I-built applications) + // S2I-built apps have env vars in the image metadata + if i.imageInspector != nil { + if match := i.imageInspector.InspectPodImages(ctx, pod); match != nil { + return match + } + } + return nil } +// identifyOperatorManagedPod handles pods managed by the EAP Operator +// These pods already have rht.comp label, but we add version, image, and discovered timestamp +func (i *Identifier) identifyOperatorManagedPod(pod *corev1.Pod) *ProductMatch { + // Get the product name from existing label (EAP Operator sets "EAP") + productName := pod.Labels["rht.comp"] + if productName == "" { + productName = "EAP" // Default if not set + } + + // Extract version and image from the first container + var version, image string + if len(pod.Spec.Containers) > 0 { + image = pod.Spec.Containers[0].Image + version = extractVersion(image) + } else { + version = "unknown" + image = "unknown" + } + + return &ProductMatch{ + ProductName: productName, + Version: version, + Image: image, + Discovered: pod.CreationTimestamp.Time, + } +} + // identifyImage checks if an image matches any Red Hat product pattern func (i *Identifier) identifyImage(image string) *ProductMatch { for pattern, productName := range i.patterns { @@ -99,17 +149,43 @@ func extractVersion(image string) string { // ShouldLabel determines if a pod should be labeled // Returns true if any of the required labels are missing // Does not check label values to respect user-provided labels +// For operator-managed pods, we only add our additional labels (version, image, discovered) func (i *Identifier) ShouldLabel(pod *corev1.Pod, match *ProductMatch) bool { if pod.Labels == nil { return true } - // Check if any required labels are missing (don't check values) + // Check if this is an operator-managed pod (already has rht.comp) + isOperatorManaged := false + if managedBy, exists := pod.Labels["app.kubernetes.io/managed-by"]; exists && managedBy == "eap-operator" { + isOperatorManaged = true + } + + if isOperatorManaged { + // For operator-managed pods, check rht.comp and our additional labels + // The EAP Operator sets rht.comp, but if it's deleted we should restore it + labelsToCheck := []string{ + "rht.comp", // Product name (may be deleted by user) + "rht.pod_image_ver", // Version of the pod's container image + "rht.comp_discovered", // Discovery timestamp + "rht.pod_image", // Pod's container image name + } + + for _, label := range labelsToCheck { + if _, exists := pod.Labels[label]; !exists { + return true // Missing label + } + } + + return false // All labels are present + } + + // For non-operator-managed pods, check all required labels requiredLabels := []string{ "rht.comp", - "rht.comp_ver", + "rht.pod_image_ver", "rht.comp_discovered", - "rht.comp_image", + "rht.pod_image", } for _, label := range requiredLabels { diff --git a/internal/identifier/identifier_test.go b/internal/identifier/identifier_test.go index 0ce4faf..6faa821 100644 --- a/internal/identifier/identifier_test.go +++ b/internal/identifier/identifier_test.go @@ -1,6 +1,7 @@ package identifier import ( + "context" "testing" corev1 "k8s.io/api/core/v1" @@ -25,14 +26,14 @@ func TestIdentifyPod_JBossEAP(t *testing.T) { }, } - match := identifier.IdentifyPod(pod) + match := identifier.IdentifyPod(context.Background(), pod) if match == nil { t.Fatal("Expected to identify JBoss EAP, got nil") } - if match.ProductName != "jboss-eap" { - t.Errorf("Expected product name 'jboss-eap', got '%s'", match.ProductName) + if match.ProductName != "EAP" { + t.Errorf("Expected product name 'EAP', got '%s'", match.ProductName) } if match.Version != "7.4.0" { @@ -58,7 +59,7 @@ func TestIdentifyPod_NonRedHatImage(t *testing.T) { }, } - match := identifier.IdentifyPod(pod) + match := identifier.IdentifyPod(context.Background(), pod) if match != nil { t.Errorf("Expected nil for non-Red Hat image, got product '%s'", match.ProductName) @@ -87,14 +88,14 @@ func TestIdentifyPod_MultipleContainers(t *testing.T) { }, } - match := identifier.IdentifyPod(pod) + match := identifier.IdentifyPod(context.Background(), pod) if match == nil { t.Fatal("Expected to identify JBoss EAP, got nil") } - if match.ProductName != "jboss-eap" { - t.Errorf("Expected product name 'jboss-eap', got '%s'", match.ProductName) + if match.ProductName != "EAP" { + t.Errorf("Expected product name 'EAP', got '%s'", match.ProductName) } } @@ -122,14 +123,14 @@ func TestIdentifyPod_InitContainer(t *testing.T) { }, } - match := identifier.IdentifyPod(pod) + match := identifier.IdentifyPod(context.Background(), pod) if match == nil { t.Fatal("Expected to identify JBoss EAP, got nil") } - if match.ProductName != "jboss-eap" { - t.Errorf("Expected product name 'jboss-eap', got '%s'", match.ProductName) + if match.ProductName != "EAP" { + t.Errorf("Expected product name 'EAP', got '%s'", match.ProductName) } } @@ -185,7 +186,7 @@ func TestShouldLabel_NoExistingLabels(t *testing.T) { } match := &ProductMatch{ - ProductName: "jboss-eap", + ProductName: "EAP", Version: "7.4", } @@ -202,16 +203,16 @@ func TestShouldLabel_AllCorrectLabelsExist(t *testing.T) { Name: "test-pod", Namespace: "default", Labels: map[string]string{ - "rht.comp": "jboss-eap", - "rht.comp_ver": "7.4", + "rht.comp": "EAP", + "rht.pod_image_ver": "7.4", "rht.comp_discovered": "1776437286", // Timestamp (first seen, not modified) - "rht.comp_image": "registry.redhat.io-jboss-eap-7-eap74-7.4", + "rht.pod_image": "registry.redhat.io-jboss-eap-7-eap74-7.4", }, }, } match := &ProductMatch{ - ProductName: "jboss-eap", + ProductName: "EAP", Version: "7.4", } @@ -229,15 +230,15 @@ func TestShouldLabel_MissingVersionLabel(t *testing.T) { Name: "test-pod", Namespace: "default", Labels: map[string]string{ - "rht.comp": "jboss-eap", + "rht.comp": "EAP", "rht.comp_discovered": "true", - // Missing rht.comp_ver + // Missing rht.pod_image_ver }, }, } match := &ProductMatch{ - ProductName: "jboss-eap", + ProductName: "EAP", Version: "7.4", } @@ -254,15 +255,15 @@ func TestShouldLabel_MissingDiscoveredLabel(t *testing.T) { Name: "test-pod", Namespace: "default", Labels: map[string]string{ - "rht.comp": "jboss-eap", - "rht.comp_ver": "7.4", + "rht.comp": "EAP", + "rht.pod_image_ver": "7.4", // Missing rht.comp_discovered }, }, } match := &ProductMatch{ - ProductName: "jboss-eap", + ProductName: "EAP", Version: "7.4", } @@ -285,7 +286,7 @@ func TestShouldLabel_IncorrectLabelExists(t *testing.T) { } match := &ProductMatch{ - ProductName: "jboss-eap", + ProductName: "EAP", Version: "7.4", } diff --git a/internal/identifier/image_inspector.go b/internal/identifier/image_inspector.go new file mode 100644 index 0000000..28ffdc6 --- /dev/null +++ b/internal/identifier/image_inspector.go @@ -0,0 +1,156 @@ +package identifier + +import ( + "context" + "encoding/json" + "strings" + + imagev1 "github.com/openshift/api/image/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// DockerImageConfig represents the Config section of a Docker image +type DockerImageConfig struct { + Env []string `json:"Env,omitempty"` +} + +// DockerImageMetadata represents the metadata of a Docker image +type DockerImageMetadata struct { + Config *DockerImageConfig `json:"Config,omitempty"` +} + +// ImageInspector provides OpenShift Image API inspection capabilities +type ImageInspector struct { + client client.Client +} + +// NewImageInspector creates a new image inspector with the given client +func NewImageInspector(c client.Client) *ImageInspector { + return &ImageInspector{ + client: c, + } +} + +// InspectPodImages checks pod container images using OpenShift Image API +// This is used to detect S2I-built applications where env vars are in the image +func (ii *ImageInspector) InspectPodImages(ctx context.Context, pod *corev1.Pod) *ProductMatch { + log := log.FromContext(ctx) + + // Check if we have a client (might be nil in vanilla Kubernetes) + if ii.client == nil { + return nil + } + + // Inspect each container's image + for _, containerStatus := range pod.Status.ContainerStatuses { + if match := ii.inspectImage(ctx, containerStatus.ImageID); match != nil { + log.V(1).Info("Detected product from image metadata", + "container", containerStatus.Name, + "imageID", containerStatus.ImageID, + "product", match.ProductName) + return match + } + } + + return nil +} + +// inspectImage queries the OpenShift Image API to get image metadata +func (ii *ImageInspector) inspectImage(ctx context.Context, imageID string) *ProductMatch { + log := log.FromContext(ctx) + + // Extract SHA from imageID + // Format: registry.example.com/namespace/image@sha256:abc123... + sha := extractSHA(imageID) + if sha == "" { + log.V(1).Info("Could not extract SHA from imageID", "imageID", imageID) + return nil + } + + // Query OpenShift Image API + image := &imagev1.Image{} + err := ii.client.Get(ctx, types.NamespacedName{Name: sha}, image) + if err != nil { + // Image not found or not in OpenShift - this is expected in vanilla K8s + log.V(1).Info("Could not get image from OpenShift API", "sha", sha, "error", err.Error()) + return nil + } + + // Check environment variables in image metadata + return ii.checkImageEnvVars(image) +} + +// checkImageEnvVars inspects image environment variables for product markers +func (ii *ImageInspector) checkImageEnvVars(image *imagev1.Image) *ProductMatch { + // Unmarshal the DockerImageMetadata RawExtension + var metadata DockerImageMetadata + if err := json.Unmarshal(image.DockerImageMetadata.Raw, &metadata); err != nil { + return nil + } + + if metadata.Config == nil { + return nil + } + + // Parse environment variables from image config into a map + envMap := parseEnvArray(metadata.Config.Env) + + // Check if this is an EAP image + if envMap["JBOSS_PRODUCT"] != "eap" || envMap["JBOSS_HOME"] != "/opt/eap" { + return nil + } + + version := envMap["JBOSS_EAP_VERSION"] + if version == "" { + version = "unknown" + } + + // Use the builder image name if available, otherwise use the image name + imageName := envMap["JBOSS_IMAGE_NAME"] + if imageName == "" { + imageName = image.Name + } + + return &ProductMatch{ + ProductName: "jboss-eap", + Version: version, + Image: imageName, + Discovered: image.CreationTimestamp.Time, + } +} + +// parseEnvArray converts Docker env array format (KEY=VALUE) to a map +func parseEnvArray(envArray []string) map[string]string { + result := make(map[string]string, len(envArray)) + for _, env := range envArray { + if idx := strings.Index(env, "="); idx >= 0 { + result[env[:idx]] = env[idx+1:] + } + } + return result +} + +// extractSHA extracts the SHA256 digest from an image ID +// Input formats: +// - image-registry.openshift-image-registry.svc:5000/namespace/image@sha256:abc123... +// - sha256:abc123... +// +// Output: sha256:abc123... +func extractSHA(imageID string) string { + // Check if it already starts with sha256: + if strings.HasPrefix(imageID, "sha256:") { + return imageID + } + + // Look for @sha256: pattern + if idx := strings.Index(imageID, "@sha256:"); idx != -1 { + return imageID[idx+1:] // Skip the @ symbol + } + + return "" +} + +// Made with Bob 1.0.2