Root cause
Injection & unsafe execution introduced
Add support for ironic-prometheus-exporter
AI-assisted change: Claude
How AI contributed: Direct introductionPublished May 29, 2026 in
metal3-io/ironic-standalone-operator
The AI-written Prometheus exporter support created an ironic-prometheus-exporter sidecar that runs /bin/runironic-exporter and set only the metrics port, leaving the listener host to the image default of every interface. The exporter serves operational metrics without authentication, so enabling the feature exposed those metrics to any adjacent network. A later release added a bindAddress field and pinned the container to loopback by default.
newPrometheusExporterContainer defined the sidecar command with no bind host, so the container defaulted to 0.0.0.0 while serving unauthenticated metrics. The fix sets FLASK_RUN_HOST to 127.0.0.1, adds a bindAddress setting that must parse as an IP, and rejects loopback values while ServiceMonitor scraping is enabled.
Root cause
Add support for ironic-prometheus-exporter
AI-assisted change: Claude
Fix
Add bindAddress configuration for prometheus exporter
Fix by Lennart Jern · no AI marker found
Case facts
@@ -0,0 +1,228 @@+package ironic++import (+ "testing"++ "github.com/stretchr/testify/assert"+ "github.com/stretchr/testify/require"+ corev1 "k8s.io/api/core/v1"+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"+ "k8s.io/apimachinery/pkg/runtime"+ "k8s.io/client-go/kubernetes/fake"+ fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"++ metal3api "github.com/metal3-io/ironic-standalone-operator/api/v1alpha1"+)++func TestPrometheusExporterVersionCheck(t *testing.T) {+ testCases := []struct {+ name string+ version metal3api.Version+ enabled bool+ expectedError string+ }{+ {+ name: "PrometheusExporter with version 31.0",+ version: metal3api.Version310,+ enabled: true,+ expectedError: "",+ },+ {+ name: "PrometheusExporter with version 32.0",+ version: metal3api.Version320,+ enabled: true,+ expectedError: "",+ },+ {+ name: "PrometheusExporter with latest version",+ version: metal3api.VersionLatest,+ enabled: true,+ expectedError: "",+ },+ {+ name: "PrometheusExporter with version 30.0 (too old)",+ version: metal3api.Version300,+ enabled: true,+ expectedError: "using prometheusExporter is only possible for Ironic 31.0 or newer",+ },+ {+ name: "PrometheusExporter disabled with version 30.0",+ version: metal3api.Version300,+ enabled: false,+ expectedError: "",+ },+ {+ name: "PrometheusExporter not configured with version 30.0",+ version: metal3api.Version300,+ enabled: false,+ expectedError: "",+ },+ }++ for _, tc := range testCases {+ t.Run(tc.name, func(t *testing.T) {+ scheme := runtime.NewScheme()+ _ = metal3api.AddToScheme(scheme)+ _ = corev1.AddToScheme(scheme)++ var prometheusExporter *metal3api.PrometheusExporter+ if tc.name != "PrometheusExporter not configured with version 30.0" {+ prometheusExporter = &metal3api.PrometheusExporter{+ Enabled: tc.enabled,+ SensorCollectionInterval: 60,+ }+ }++ ironic := &metal3api.Ironic{+ ObjectMeta: metav1.ObjectMeta{+ Name: "test-ironic",+ Namespace: "test",+ },+ Spec: metal3api.IronicSpec{+ PrometheusExporter: prometheusExporter,+ },+ }++ secret := &corev1.Secret{+ ObjectMeta: metav1.ObjectMeta{+ Name: "api-secret",+ Namespace: "test",@@ -66,6 +66,27 @@ func TestExpectedContainers(t *testing.T) { ExpectedContainerNames: []string{"httpd", "ironic", "ramdisk-logs"}, ExpectedInitContainerNames: []string{}, },+ {+ Scenario: "PrometheusExporter enabled",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ SensorCollectionInterval: 60,+ },+ },+ ExpectedContainerNames: []string{"httpd", "ironic", "ironic-prometheus-exporter", "ramdisk-logs"},+ ExpectedInitContainerNames: []string{"ramdisk-downloader"},+ },+ {+ Scenario: "PrometheusExporter disabled",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: false,+ },+ },+ ExpectedContainerNames: []string{"httpd", "ironic", "ramdisk-logs"},+ ExpectedInitContainerNames: []string{"ramdisk-downloader"},+ }, } for _, tc := range testCases {@@ -400,3 +421,106 @@ func TestTrustedCAConfigMap(t *testing.T) { }) } }++func TestPrometheusExporterEnvVars(t *testing.T) {+ testCases := []struct {+ name string+ prometheusExporter *metal3api.PrometheusExporter+ expectedSendSensorData string+ expectedSensorInterval string+ }{+ {+ name: "PrometheusExporter enabled with default interval",+ prometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ SensorCollectionInterval: 0, // Should default to 60+ },+ expectedSendSensorData: "true",+ expectedSensorInterval: "60",+ },+ {+ name: "PrometheusExporter enabled with custom interval",+ prometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ SensorCollectionInterval: 120,+ },+ expectedSendSensorData: "true",+ expectedSensorInterval: "120",+ },+ {+ name: "PrometheusExporter disabled",+ prometheusExporter: &metal3api.PrometheusExporter{+ Enabled: false,+ },+ },+ {+ name: "PrometheusExporter nil",+ prometheusExporter: nil,+ },+ }++ for _, tc := range testCases {+ t.Run(tc.name, func(t *testing.T) {+ cctx := ControllerContext{}+ secret := &corev1.Secret{+ Data: map[string][]byte{+ "htpasswd": []byte("abcd"),+ },+ }+ ironic := &metal3api.Ironic{+ ObjectMeta: metav1.ObjectMeta{+ Namespace: "test",+ Name: "test",+ },+ Spec: metal3api.IronicSpec{+ PrometheusExporter: tc.prometheusExporter,+ },+ }++ resources := Resources{Ironic: ironic, APISecret: secret}+ podTemplate, err := newIronicPodTemplate(cctx, resources)@@ -20,6 +20,7 @@ const ( ironicPortName = "ironic-api" imagesPortName = "image-svc" imagesTLSPortName = "image-svc-tls"+ metricsPortName = "metrics" ironicUser int64 = 997 ironicGroup int64 = 994@@ -30,6 +31,8 @@ const ( certsDir = "/certs" sharedDir = "/shared" + metricsPort = 9608+ knownExistingPath = "/images/ironic-python-agent.kernel" ) @@ -274,6 +277,22 @@ func buildIronicEnvVars(cctx ControllerContext, resources Resources) []corev1.En result = appendStringEnv(result, "IRONIC_EXTERNAL_IP", resources.Ironic.Spec.Networking.ExternalIP) + // Add sensor data environment variables when PrometheusExporter is enabled+ if resources.Ironic.Spec.PrometheusExporter != nil && resources.Ironic.Spec.PrometheusExporter.Enabled {+ result = append(result, corev1.EnvVar{+ Name: "SEND_SENSOR_DATA",+ Value: "true",+ })+ sensorInterval := resources.Ironic.Spec.PrometheusExporter.SensorCollectionInterval+ if sensorInterval == 0 {+ sensorInterval = 60 // default+ }+ result = append(result, corev1.EnvVar{+ Name: "OS_SENSOR_DATA__INTERVAL",+ Value: strconv.Itoa(sensorInterval),+ })+ }+ return result } @@ -605,6 +624,40 @@ func newKeepalivedContainer(versionInfo VersionInfo, ironic *metal3api.Ironic) c } } +func newPrometheusExporterContainer(versionInfo VersionInfo, ironic *metal3api.Ironic, volumeMount corev1.VolumeMount) corev1.Container {+ port := int32(metricsPort)+ if ironic.Spec.Networking.PrometheusExporterPort != 0 {+ port = ironic.Spec.Networking.PrometheusExporterPort+ }++ return corev1.Container{+ Name: "ironic-prometheus-exporter",+ Image: versionInfo.IronicImage,+ Command: []string{"/bin/runironic-exporter"},+ Env: []corev1.EnvVar{+ {+ Name: "FLASK_RUN_PORT",+ Value: strconv.Itoa(int(port)),+ },+ },+ Ports: []corev1.ContainerPort{+ {+ Name: metricsPortName,+ Protocol: corev1.ProtocolTCP,+ ContainerPort: port,+ },+ },+ VolumeMounts: []corev1.VolumeMount{volumeMount},+ SecurityContext: &corev1.SecurityContext{+ RunAsUser: ptr.To(ironicUser),+ RunAsGroup: ptr.To(ironicGroup),+ Capabilities: &corev1.Capabilities{+ Drop: []corev1.Capability{"ALL"},+ },+ },+ }+}+ func newIronicPodTemplate(cctx ControllerContext, resources Resources) (corev1.PodTemplateSpec, error) { if len(resources.APISecret.Data[htpasswdKey]) == 0 { return corev1.PodTemplateSpec{}, errors.New("no htpasswd in the API secret")@@ -703,6 +756,10 @@ func newIronicPodTemplate(cctx ControllerContext, resources Resources) (corev1.P containers = append(containers, newKeepalivedContainer(cctx.VersionInfo, resources.Ironic)) } + if resources.Ironic.Spec.PrometheusExporter != nil && resources.Ironic.Spec.PrometheusExporter.Enabled {+ containers = append(containers, newPrometheusExporterContainer(cctx.VersionInfo, resources.Ironic, sharedVolumeMount))+ }+ // Make sure the pod is restarted when secrets change.@@ -339,11 +339,115 @@ func TestValidateIronic(t *testing.T) { }, HighAvailability: true, PrometheusExporter: &metal3api.PrometheusExporter{- Enabled: true,+ Enabled: true,+ BindAddress: "0.0.0.0", }, }, ExpectedError: "ServiceMonitor support is currently incompatible with the highly available architecture", },+ {+ // With the default bindAddress of "0.0.0.0", ServiceMonitor must be valid.+ Scenario: "ServiceMonitor with default bindAddress is valid",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ },+ },+ },+ {+ Scenario: "ServiceMonitor incompatible with explicit loopback bindAddress",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ BindAddress: "127.0.0.1",+ },+ },+ ExpectedError: "ServiceMonitor is not compatible with a loopback bindAddress",+ },+ {+ Scenario: "ServiceMonitor incompatible with IPv6 loopback bindAddress",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ BindAddress: "::1",+ },+ },+ ExpectedError: "ServiceMonitor is not compatible with a loopback bindAddress",+ },+ {+ Scenario: "ServiceMonitor with bindAddress 0.0.0.0 is valid",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ BindAddress: "0.0.0.0",+ },+ },+ },+ {+ Scenario: "ServiceMonitor with specific IP bindAddress is valid",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ BindAddress: "192.168.1.10",+ },+ },+ },+ {+ // disableServiceMonitor must allow a loopback bindAddress that would otherwise+ // be rejected when ServiceMonitor creation is enabled.+ Scenario: "disableServiceMonitor bypasses loopback bindAddress requirement",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: true,+ DisableServiceMonitor: true,+ BindAddress: "127.0.0.1",+ },+ },+ },+ {+ // Disabled exporter with default (loopback) bindAddress must pass validation+ // even though DisableServiceMonitor defaults to false. The loopback restriction+ // only applies when the exporter is actually enabled.+ Scenario: "disabled exporter with default bindAddress passes validation",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: false,+ },+ },+ },+ {+ // Disabled exporter with explicit loopback bindAddress must also pass.+ Scenario: "disabled exporter with loopback bindAddress passes validation",+ Ironic: metal3api.IronicSpec{+ PrometheusExporter: &metal3api.PrometheusExporter{+ Enabled: false,+ BindAddress: "127.0.0.1",+ },+ },@@ -3,6 +3,7 @@ package ironic import ( "errors" "fmt"+ "net" "net/netip" "net/url" "reflect"@@ -215,6 +216,24 @@ func ValidateIronic(ironic *metal3api.IronicSpec, old *metal3api.IronicSpec) err return errors.New("ServiceMonitor support is currently incompatible with the highly available architecture") } + if ironic.PrometheusExporter != nil && ironic.PrometheusExporter.BindAddress != "" {+ ip := net.ParseIP(ironic.PrometheusExporter.BindAddress)+ if ip == nil {+ return fmt.Errorf("prometheusExporter: bindAddress %q is not a valid IP address", ironic.PrometheusExporter.BindAddress)+ }+ }++ if ironic.PrometheusExporter != nil && ironic.PrometheusExporter.Enabled && !ironic.PrometheusExporter.DisableServiceMonitor {+ bindAddr := ironic.PrometheusExporter.BindAddress+ if bindAddr == "" {+ bindAddr = defaultMetricsBindAddr+ }+ ip := net.ParseIP(bindAddr)+ if ip != nil && ip.IsLoopback() {+ return fmt.Errorf("ServiceMonitor is not compatible with a loopback bindAddress %q, since the metrics endpoint is not reachable from remote Prometheus instances; set a non-loopback bindAddress or set disableServiceMonitor to true", bindAddr)+ }+ }+ if ironic.HighAvailability && !metal3api.CurrentFeatureGate.Enabled(metal3api.FeatureHighAvailability) { return errors.New("highly available architecture is disabled via feature gate") }@@ -41,10 +41,6 @@ func ensureServiceMonitor(cctx ControllerContext, ironic *metal3api.Ironic) (Sta { Port: metricsPortName, // TODO(dtantsur): TLS support?- // NOTE: The ironic-prometheus-exporter binds to localhost (127.0.0.1) by default- // to prevent unauthenticated access from the host network. This means that- // ServiceMonitor-based scraping from a remote Prometheus instance will NOT work- // unless the exporter is explicitly configured to listen on all interfaces. Scheme: ptr.To(monitoringv1.SchemeHTTP), Path: "/metrics", },AI-assisted change 13a39f2701d2b43c8dc79f92e4cfaff254742fa5dd202e0d10b842292efc00d1 · Fix df0bd16965e97dea13dd4a591d28208d6710a7882c7ddc03b0a72a303d634d87