Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist/
*.swp
*.swo
*~

# Local, test specific folder
test-eap-builder
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -48,6 +49,7 @@ var (

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(imagev1.AddToScheme(scheme))

// +kubebuilder:scaffold:scheme
}
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch", "patch", "update"]
- apiGroups: ["image.openshift.io"]
resources: ["images"]
verbs: ["get", "list", "watch"]
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
10 changes: 5 additions & 5 deletions internal/controller/discovery_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -97,18 +97,18 @@ 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)
if _, exists := pod.Labels["rht.comp_discovered"]; !exists {
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
Expand Down
32 changes: 16 additions & 16 deletions internal/controller/discovery_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
}

Expand All @@ -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{
Expand Down Expand Up @@ -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")
}
}
Expand All @@ -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
},
},
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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")
}
}

Expand Down
94 changes: 85 additions & 9 deletions internal/identifier/identifier.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package identifier

import (
"context"
"strings"
"time"

Expand All @@ -17,24 +18,38 @@ 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
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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading