Skip to content

Commit 0dc4788

Browse files
committed
Integrate with IrSO for getting Ironic details
Add a way for BMO to use an existing IrSO Ironic object to get Ironic service details instead of user-provided environment variables. Two new environment variables are added IRONIC_NAME (the name of the Ironic object) and IRONIC_NAMESPACE (defaulting to POD_NAMESPACE). Now that DEPLOY_KERNEL/RAMDISK variables are also optional, this change will allow us to provide a single installation manifest for BMO that assumes a presence of Ironic object called "ironic" in the BMO's namespace. This is not a part of this PR and will be proposed later. The Ironic object is fetched on every reconciliation, and its Ready condition is checked before proceeding. On a failure, the reconciliation is retried (similarly to how errors accessing Ironic are handled). Note that IrSO does not yet support providing a CA for TLS settings. The certificate is used as a CA instead. Assisted-By: Claude Code (commercial license) Signed-off-by: Dmitry Tantsur <[email protected]>
1 parent 64b6414 commit 0dc4788

File tree

7 files changed

+241
-8
lines changed

7 files changed

+241
-8
lines changed

config/base/rbac/role.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ rules:
2525
- list
2626
- update
2727
- watch
28+
- apiGroups:
29+
- ironic.metal3.io
30+
resources:
31+
- ironics
32+
verbs:
33+
- get
34+
- list
35+
- watch
2836
- apiGroups:
2937
- metal3.io
3038
resources:

config/render/capm3.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2396,6 +2396,14 @@ rules:
23962396
- list
23972397
- update
23982398
- watch
2399+
- apiGroups:
2400+
- ironic.metal3.io
2401+
resources:
2402+
- ironics
2403+
verbs:
2404+
- get
2405+
- list
2406+
- watch
23992407
- apiGroups:
24002408
- metal3.io
24012409
resources:

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/gophercloud/gophercloud/v2 v2.8.0
1010
github.com/metal3-io/baremetal-operator/apis v0.5.1
1111
github.com/metal3-io/baremetal-operator/pkg/hardwareutils v0.5.1
12+
github.com/metal3-io/ironic-standalone-operator/api v0.6.0
1213
github.com/onsi/gomega v1.38.2
1314
github.com/prometheus/client_golang v1.23.2
1415
github.com/stretchr/testify v1.11.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
9494
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
9595
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
9696
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
97+
github.com/metal3-io/ironic-standalone-operator/api v0.6.0 h1:u2BDGpGdJqzzZAr4pL4LywKcxfjHXPQNxuJ47ej/6Cc=
98+
github.com/metal3-io/ironic-standalone-operator/api v0.6.0/go.mod h1:V2uX3UR0gK0J8IvF8msNRcdWKviZRrvDCCFMF0uVPQY=
9799
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
98100
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
99101
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

internal/controller/metal3.io/baremetalhost_controller.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ func (info *reconcileInfo) publishEvent(reason, message string) {
106106
// Allow for updating hostupdatepolicies
107107
// +kubebuilder:rbac:groups=metal3.io,resources=hostupdatepolicies,verbs=get;list;watch;update
108108

109+
// Allow reading Ironic resources
110+
// +kubebuilder:rbac:groups=ironic.metal3.io,resources=ironics,verbs=get;list;watch
111+
109112
// Reconcile handles changes to BareMetalHost resources.
110113
func (r *BareMetalHostReconciler) Reconcile(ctx context.Context, request ctrl.Request) (result ctrl.Result, err error) {
111114
reconcileCounters.With(hostMetricLabels(request)).Inc()

main.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ import (
3535
"github.com/metal3-io/baremetal-operator/pkg/provisioner/ironic"
3636
"github.com/metal3-io/baremetal-operator/pkg/secretutils"
3737
"github.com/metal3-io/baremetal-operator/pkg/version"
38+
ironicv1alpha1 "github.com/metal3-io/ironic-standalone-operator/api/v1alpha1"
3839
"go.uber.org/zap/zapcore"
3940
k8sruntime "k8s.io/apimachinery/pkg/runtime"
4041
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
4142
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
4243
cliflag "k8s.io/component-base/cli/flag"
4344
ctrl "sigs.k8s.io/controller-runtime"
4445
"sigs.k8s.io/controller-runtime/pkg/cache"
46+
"sigs.k8s.io/controller-runtime/pkg/client"
4547
"sigs.k8s.io/controller-runtime/pkg/healthz"
4648
"sigs.k8s.io/controller-runtime/pkg/log/zap"
4749
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
@@ -75,6 +77,7 @@ func init() {
7577
_ = clientgoscheme.AddToScheme(scheme)
7678

7779
_ = metal3api.AddToScheme(scheme)
80+
_ = ironicv1alpha1.AddToScheme(scheme)
7881
}
7982

8083
func printVersion() {
@@ -215,6 +218,24 @@ func main() {
215218
setupLog.Info("Manager set up with cluster scope")
216219
}
217220

221+
// Setup cache options
222+
var byObject map[client.Object]cache.ByObject
223+
224+
ironicName := os.Getenv("IRONIC_NAME")
225+
ironicNamespace := os.Getenv("IRONIC_NAMESPACE")
226+
if ironicNamespace == "" {
227+
ironicNamespace = leaderElectionNamespace
228+
}
229+
if ironicName != "" && ironicNamespace != "" {
230+
byObject = map[client.Object]cache.ByObject{
231+
&ironicv1alpha1.Ironic{}: {
232+
Namespaces: map[string]cache.Config{
233+
ironicNamespace: {},
234+
},
235+
},
236+
}
237+
}
238+
218239
ctrlOpts := ctrl.Options{
219240
Scheme: scheme,
220241
Metrics: metricsserver.Options{
@@ -233,7 +254,7 @@ func main() {
233254
LeaderElectionReleaseOnCancel: true,
234255
HealthProbeBindAddress: healthAddr,
235256
Cache: cache.Options{
236-
ByObject: secretutils.AddSecretSelector(nil),
257+
ByObject: secretutils.AddSecretSelector(byObject),
237258
DefaultNamespaces: watchNamespaces,
238259
},
239260
}
@@ -288,7 +309,13 @@ func main() {
288309
provisionerFactory = &demo.Demo{}
289310
} else {
290311
provLog := zap.New(zap.UseFlagOptions(&logOpts)).WithName("provisioner")
291-
provisionerFactory, err = ironic.NewProvisionerFactory(provLog, preprovImgEnable)
312+
// Check if we should use Ironic CR integration
313+
if ironicName != "" && ironicNamespace != "" {
314+
provisionerFactory, err = ironic.NewProvisionerFactoryWithClient(provLog, preprovImgEnable,
315+
mgr.GetClient(), mgr.GetAPIReader(), ironicName, ironicNamespace)
316+
} else {
317+
provisionerFactory, err = ironic.NewProvisionerFactory(provLog, preprovImgEnable)
318+
}
292319
if err != nil {
293320
setupLog.Error(err, "cannot start ironic provisioner")
294321
os.Exit(1)

pkg/provisioner/ironic/factory.go

Lines changed: 190 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import (
1313
"github.com/gophercloud/gophercloud/v2"
1414
"github.com/metal3-io/baremetal-operator/pkg/provisioner"
1515
"github.com/metal3-io/baremetal-operator/pkg/provisioner/ironic/clients"
16+
"github.com/metal3-io/baremetal-operator/pkg/secretutils"
17+
ironicv1alpha1 "github.com/metal3-io/ironic-standalone-operator/api/v1alpha1"
18+
"k8s.io/apimachinery/pkg/api/meta"
19+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20+
"k8s.io/apimachinery/pkg/types"
21+
"sigs.k8s.io/controller-runtime/pkg/client"
1622
)
1723

1824
type ironicProvisionerFactory struct {
@@ -22,6 +28,14 @@ type ironicProvisionerFactory struct {
2228
// Keep pointers to ironic client configured with the global
2329
// auth settings to reuse the connection between reconcilers.
2430
clientIronic *gophercloud.ServiceClient
31+
32+
// Kubernetes client for reading Ironic CR
33+
k8sClient client.Client
34+
apiReader client.Reader
35+
36+
// Ironic CR configuration
37+
ironicName string
38+
ironicNamespace string
2539
}
2640

2741
func NewProvisionerFactory(logger logr.Logger, havePreprovImgBuilder bool) (provisioner.Factory, error) {
@@ -33,13 +47,42 @@ func NewProvisionerFactory(logger logr.Logger, havePreprovImgBuilder bool) (prov
3347
return factory, err
3448
}
3549

50+
func NewProvisionerFactoryWithClient(logger logr.Logger, havePreprovImgBuilder bool, k8sClient client.Client, apiReader client.Reader, ironicName, ironicNamespace string) (provisioner.Factory, error) {
51+
factory := ironicProvisionerFactory{
52+
log: logger.WithName("ironic"),
53+
k8sClient: k8sClient,
54+
apiReader: apiReader,
55+
ironicName: ironicName,
56+
ironicNamespace: ironicNamespace,
57+
}
58+
59+
err := factory.init(havePreprovImgBuilder)
60+
return factory, err
61+
}
62+
3663
func (f *ironicProvisionerFactory) init(havePreprovImgBuilder bool) error {
37-
ironicAuth, err := clients.LoadAuth()
64+
var err error
65+
f.config, err = loadConfigFromEnv(havePreprovImgBuilder)
3866
if err != nil {
3967
return err
4068
}
4169

42-
f.config, err = loadConfigFromEnv(havePreprovImgBuilder)
70+
if f.ironicName != "" && f.ironicNamespace != "" {
71+
f.log.Info("will use Ironic resource configuration",
72+
"ironicName", f.ironicName,
73+
"ironicNamespace", f.ironicNamespace,
74+
"deployKernelURL", f.config.deployKernelURL,
75+
"deployRamdiskURL", f.config.deployRamdiskURL,
76+
"deployISOURL", f.config.deployISOURL,
77+
"liveISOForcePersistentBootDevice", f.config.liveISOForcePersistentBootDevice,
78+
)
79+
// NOTE(dtantsur): the Ironic object will be loaded from the client cache on each reconciliation, so exiting here.
80+
return nil
81+
}
82+
83+
f.log.V(1).Info("will use environment variables configuration")
84+
// For environment variable mode, validate configuration early and create a static client
85+
ironicAuth, err := clients.LoadAuth()
4386
if err != nil {
4487
return err
4588
}
@@ -51,7 +94,7 @@ func (f *ironicProvisionerFactory) init(havePreprovImgBuilder bool) error {
5194

5295
tlsConf := loadTLSConfigFromEnv()
5396

54-
f.log.Info("ironic settings",
97+
f.log.Info("ironic settings from environment variables",
5598
"endpoint", ironicEndpoint,
5699
"ironicAuthType", ironicAuth.Type,
57100
"deployKernelURL", f.config.deployKernelURL,
@@ -65,8 +108,7 @@ func (f *ironicProvisionerFactory) init(havePreprovImgBuilder bool) error {
65108
"SkipClientSANVerify", tlsConf.SkipClientSANVerify,
66109
)
67110

68-
f.clientIronic, err = clients.IronicClient(
69-
ironicEndpoint, ironicAuth, tlsConf)
111+
f.clientIronic, err = clients.IronicClient(ironicEndpoint, ironicAuth, tlsConf)
70112
if err != nil {
71113
return err
72114
}
@@ -77,6 +119,31 @@ func (f *ironicProvisionerFactory) init(havePreprovImgBuilder bool) error {
77119
func (f ironicProvisionerFactory) ironicProvisioner(ctx context.Context, hostData provisioner.HostData, publisher provisioner.EventPublisher) (*ironicProvisioner, error) {
78120
provisionerLogger := f.log.WithValues("host", ironicNodeName(hostData.ObjectMeta))
79121

122+
var ironicClient *gophercloud.ServiceClient
123+
124+
// Check if we should use Ironic CR configuration (fetch fresh config on each provisioner creation)
125+
if f.ironicName != "" && f.ironicNamespace != "" && f.k8sClient != nil {
126+
ironicEndpoint, ironicAuth, tlsConf, err := f.loadConfigFromIronicCR(ctx)
127+
if err != nil {
128+
return nil, fmt.Errorf("failed to load configuration from Ironic resource %s/%s: %w", f.ironicNamespace, f.ironicName, err)
129+
}
130+
131+
provisionerLogger.Info("ironic settings from Ironic resource",
132+
"ironicName", f.ironicName,
133+
"ironicNamespace", f.ironicNamespace,
134+
"endpoint", ironicEndpoint,
135+
"CACertFile", tlsConf.TrustedCAFile,
136+
)
137+
138+
ironicClient, err = clients.IronicClient(ironicEndpoint, ironicAuth, tlsConf)
139+
if err != nil {
140+
return nil, fmt.Errorf("failed to create a client from Ironic resource %s/%s: %w", f.ironicNamespace, f.ironicName, err)
141+
}
142+
} else {
143+
// Use the pre-configured client from environment variables
144+
ironicClient = f.clientIronic
145+
}
146+
80147
p := &ironicProvisioner{
81148
config: f.config,
82149
objectMeta: hostData.ObjectMeta,
@@ -85,7 +152,7 @@ func (f ironicProvisionerFactory) ironicProvisioner(ctx context.Context, hostDat
85152
bmcAddress: hostData.BMCAddress,
86153
disableCertVerification: hostData.DisableCertificateVerification,
87154
bootMACAddress: hostData.BootMACAddress,
88-
client: f.clientIronic,
155+
client: ironicClient,
89156
log: provisionerLogger,
90157
debugLog: provisionerLogger.V(1),
91158
publisher: publisher,
@@ -192,3 +259,120 @@ func loadTLSConfigFromEnv() clients.TLSConfig {
192259
SkipClientSANVerify: skipClientSANVerify,
193260
}
194261
}
262+
263+
func ironicUnreadyReason(ironic *ironicv1alpha1.Ironic) string {
264+
cond := meta.FindStatusCondition(ironic.Status.Conditions, string(ironicv1alpha1.IronicStatusReady))
265+
if cond == nil || cond.ObservedGeneration != ironic.Generation {
266+
return "reconciliation hasn't started yet"
267+
}
268+
if cond.Status != metav1.ConditionTrue {
269+
return cond.Message
270+
}
271+
return ""
272+
}
273+
274+
func (f *ironicProvisionerFactory) loadConfigFromIronicCR(ctx context.Context) (endpoint string, auth clients.AuthConfig, tlsConfig clients.TLSConfig, err error) {
275+
sm := secretutils.NewSecretManager(ctx, f.log, f.k8sClient, f.apiReader)
276+
277+
// Get the Ironic CR
278+
ironicCR := &ironicv1alpha1.Ironic{}
279+
key := types.NamespacedName{
280+
Name: f.ironicName,
281+
Namespace: f.ironicNamespace,
282+
}
283+
284+
err = f.k8sClient.Get(ctx, key, ironicCR)
285+
if err != nil {
286+
return "", clients.AuthConfig{}, clients.TLSConfig{}, fmt.Errorf("failed to get Ironic resource %s/%s: %w", f.ironicNamespace, f.ironicName, err)
287+
}
288+
289+
// Check if the Ironic CR is ready
290+
if unreadyReason := ironicUnreadyReason(ironicCR); unreadyReason != "" {
291+
return "", clients.AuthConfig{}, clients.TLSConfig{}, fmt.Errorf("ironic resource %s/%s is not ready: %s", f.ironicNamespace, f.ironicName, unreadyReason)
292+
}
293+
294+
endpoint = fmt.Sprintf("http://%s.%s.svc", f.ironicName, f.ironicNamespace)
295+
296+
// Handle TLS
297+
if ironicCR.Spec.TLS.CertificateName != "" {
298+
endpoint = fmt.Sprintf("https://%s.%s.svc", f.ironicName, f.ironicNamespace)
299+
tlsConfig, err = f.loadTLSConfigFromIronicCR(&sm, ironicCR)
300+
if err != nil {
301+
return "", clients.AuthConfig{}, clients.TLSConfig{}, err
302+
}
303+
}
304+
305+
// Handle authentication
306+
auth, err = f.loadAuthConfigFromIronicCR(&sm, ironicCR)
307+
if err != nil {
308+
return "", clients.AuthConfig{}, clients.TLSConfig{}, err
309+
}
310+
311+
return endpoint, auth, tlsConfig, nil
312+
}
313+
314+
func (f *ironicProvisionerFactory) loadAuthConfigFromIronicCR(sm *secretutils.SecretManager, ironicCR *ironicv1alpha1.Ironic) (clients.AuthConfig, error) {
315+
key := types.NamespacedName{
316+
Name: ironicCR.Spec.APICredentialsName,
317+
Namespace: ironicCR.Namespace,
318+
}
319+
320+
secret, err := sm.ObtainSecret(key)
321+
if err != nil {
322+
return clients.AuthConfig{}, fmt.Errorf("failed to get auth secret %s/%s: %w", key.Namespace, key.Name, err)
323+
}
324+
325+
username, usernameExists := secret.Data["username"]
326+
password, passwordExists := secret.Data["password"]
327+
328+
if !usernameExists || !passwordExists {
329+
return clients.AuthConfig{}, fmt.Errorf("auth secret %s/%s must contain 'username' and 'password' keys", key.Namespace, key.Name)
330+
}
331+
332+
return clients.AuthConfig{
333+
Type: clients.HTTPBasicAuth,
334+
Username: string(username),
335+
Password: string(password),
336+
}, nil
337+
}
338+
339+
func (f *ironicProvisionerFactory) loadTLSConfigFromIronicCR(sm *secretutils.SecretManager, ironicCR *ironicv1alpha1.Ironic) (clients.TLSConfig, error) {
340+
// Allow client TLS configuration from the environment
341+
tlsConfig := loadTLSConfigFromEnv()
342+
343+
key := types.NamespacedName{
344+
Name: ironicCR.Spec.TLS.CertificateName,
345+
Namespace: ironicCR.Namespace,
346+
}
347+
348+
secret, err := sm.ObtainSecret(key)
349+
if err != nil {
350+
return clients.TLSConfig{}, fmt.Errorf("failed to get TLS secret %s/%s: %w", ironicCR.Namespace, ironicCR.Spec.TLS.CertificateName, err)
351+
}
352+
353+
caCert := secret.Data["tls.crt"]
354+
if caCert != nil {
355+
caFile, err := writeTempFile("ironic-ca-", caCert)
356+
if err != nil {
357+
return clients.TLSConfig{}, fmt.Errorf("failed to write CA certificate: %w", err)
358+
}
359+
tlsConfig.TrustedCAFile = caFile
360+
}
361+
362+
return tlsConfig, nil
363+
}
364+
365+
func writeTempFile(prefix string, data []byte) (string, error) {
366+
tmpFile, err := os.CreateTemp("", prefix)
367+
if err != nil {
368+
return "", err
369+
}
370+
defer tmpFile.Close()
371+
372+
_, err = tmpFile.Write(data)
373+
if err != nil {
374+
return "", err
375+
}
376+
377+
return tmpFile.Name(), nil
378+
}

0 commit comments

Comments
 (0)