From b9e8d11476685f0a9baf532d5304ce3d4d5a4228 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:33 +0530 Subject: [PATCH 01/14] api: Add versioned operator configuration resource Give administrators a typed cluster-scoped API for controller and daemon settings without editing workloads. Keep naming flexible; startup selection enforces that only one instance is used. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- PROJECT | 8 ++ api/v1alpha1/bootcoperatorconfig_types.go | 81 +++++++++++ api/v1alpha1/groupversion_info.go | 1 + api/v1alpha1/zz_generated.deepcopy.go | 128 ++++++++++++++++++ .../node.bootc.dev_bootcoperatorconfigs.yaml | 86 ++++++++++++ config/crd/kustomization.yaml | 1 + 6 files changed, 305 insertions(+) create mode 100644 api/v1alpha1/bootcoperatorconfig_types.go create mode 100644 config/crd/bases/node.bootc.dev_bootcoperatorconfigs.yaml diff --git a/PROJECT b/PROJECT index 198623b1..5e64685f 100644 --- a/PROJECT +++ b/PROJECT @@ -26,4 +26,12 @@ resources: kind: BootcNode path: github.com/jlebon/bootc-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: false + domain: bootc.dev + group: node + kind: BootcOperatorConfig + path: github.com/bootc-dev/bootc-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/bootcoperatorconfig_types.go b/api/v1alpha1/bootcoperatorconfig_types.go new file mode 100644 index 00000000..6f89387f --- /dev/null +++ b/api/v1alpha1/bootcoperatorconfig_types.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +const ( + // DefaultTagResolutionPeriodSeconds is the default interval between resolving image tags. + DefaultTagResolutionPeriodSeconds int32 = 300 + + // DefaultStatusPollPeriodSeconds is the default interval between fallback bootc status polls. + DefaultStatusPollPeriodSeconds int32 = 300 +) + +// OperatorControllerConfig configures the operator controller at startup. +type OperatorControllerConfig struct { + // allowInsecureRegistry allows falling back to HTTP when accessing image + // registries. Enable this only for trusted registries that do not support TLS. + // +optional + // +kubebuilder:default=false + AllowInsecureRegistry *bool `json:"allowInsecureRegistry,omitempty"` + + // tagResolutionPeriodSeconds is the interval in seconds between resolving + // image tags to digests. Defaults to 300 seconds. + // +optional + // +kubebuilder:default=300 + // +kubebuilder:validation:Minimum=1 + TagResolutionPeriodSeconds *int32 `json:"tagResolutionPeriodSeconds,omitempty"` +} + +// OperatorDaemonConfig configures each node daemon at startup. +type OperatorDaemonConfig struct { + // statusPollPeriodSeconds is the interval in seconds between bootc status + // polls when filesystem notifications are unavailable. Defaults to 300 seconds. + // +optional + // +kubebuilder:default=300 + // +kubebuilder:validation:Minimum=1 + StatusPollPeriodSeconds *int32 `json:"statusPollPeriodSeconds,omitempty"` +} + +// BootcOperatorConfigSpec defines operator settings consumed at process startup. +// Restart the affected controller or daemon pods after changing these settings. +type BootcOperatorConfigSpec struct { + // controller configures the controller. Omitted settings use their defaults. + // +optional + // +kubebuilder:default={} + Controller *OperatorControllerConfig `json:"controller,omitempty"` + + // daemon configures all node daemons. Omitted settings use their defaults. + // +optional + // +kubebuilder:default={} + Daemon *OperatorDaemonConfig `json:"daemon,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster + +// BootcOperatorConfig configures the controller and node daemons in a cluster. +// The optional resource is administrator-owned and may have any valid name. +// The operator rejects multiple configurations at startup. Changes take effect +// when the affected pods restart; explicit command-line flags take precedence. +type BootcOperatorConfig struct { + metav1.TypeMeta `json:",inline"` + + // metadata is standard object metadata. + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the configuration consumed when operator processes start. + // +required + Spec BootcOperatorConfigSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// BootcOperatorConfigList contains a list of BootcOperatorConfig. +type BootcOperatorConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []BootcOperatorConfig `json:"items"` +} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index 2575b926..c5a1426d 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -30,6 +30,7 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &BootcNode{}, &BootcNodeList{}, &BootcNodePool{}, &BootcNodePoolList{}, + &BootcOperatorConfig{}, &BootcOperatorConfigList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index be71e825..fe75543b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -249,6 +249,89 @@ func (in *BootcNodeStatus) DeepCopy() *BootcNodeStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BootcOperatorConfig) DeepCopyInto(out *BootcOperatorConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BootcOperatorConfig. +func (in *BootcOperatorConfig) DeepCopy() *BootcOperatorConfig { + if in == nil { + return nil + } + out := new(BootcOperatorConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BootcOperatorConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BootcOperatorConfigList) DeepCopyInto(out *BootcOperatorConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BootcOperatorConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BootcOperatorConfigList. +func (in *BootcOperatorConfigList) DeepCopy() *BootcOperatorConfigList { + if in == nil { + return nil + } + out := new(BootcOperatorConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BootcOperatorConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BootcOperatorConfigSpec) DeepCopyInto(out *BootcOperatorConfigSpec) { + *out = *in + if in.Controller != nil { + in, out := &in.Controller, &out.Controller + *out = new(OperatorControllerConfig) + (*in).DeepCopyInto(*out) + } + if in.Daemon != nil { + in, out := &in.Daemon, &out.Daemon + *out = new(OperatorDaemonConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BootcOperatorConfigSpec. +func (in *BootcOperatorConfigSpec) DeepCopy() *BootcOperatorConfigSpec { + if in == nil { + return nil + } + out := new(BootcOperatorConfigSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DisruptionSpec) DeepCopyInto(out *DisruptionSpec) { *out = *in @@ -298,6 +381,51 @@ func (in *ImageSpec) DeepCopy() *ImageSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OperatorControllerConfig) DeepCopyInto(out *OperatorControllerConfig) { + *out = *in + if in.AllowInsecureRegistry != nil { + in, out := &in.AllowInsecureRegistry, &out.AllowInsecureRegistry + *out = new(bool) + **out = **in + } + if in.TagResolutionPeriodSeconds != nil { + in, out := &in.TagResolutionPeriodSeconds, &out.TagResolutionPeriodSeconds + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperatorControllerConfig. +func (in *OperatorControllerConfig) DeepCopy() *OperatorControllerConfig { + if in == nil { + return nil + } + out := new(OperatorControllerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OperatorDaemonConfig) DeepCopyInto(out *OperatorDaemonConfig) { + *out = *in + if in.StatusPollPeriodSeconds != nil { + in, out := &in.StatusPollPeriodSeconds, &out.StatusPollPeriodSeconds + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperatorDaemonConfig. +func (in *OperatorDaemonConfig) DeepCopy() *OperatorDaemonConfig { + if in == nil { + return nil + } + out := new(OperatorDaemonConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PullSecretRef) DeepCopyInto(out *PullSecretRef) { *out = *in diff --git a/config/crd/bases/node.bootc.dev_bootcoperatorconfigs.yaml b/config/crd/bases/node.bootc.dev_bootcoperatorconfigs.yaml new file mode 100644 index 00000000..d7900094 --- /dev/null +++ b/config/crd/bases/node.bootc.dev_bootcoperatorconfigs.yaml @@ -0,0 +1,86 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: bootcoperatorconfigs.node.bootc.dev +spec: + group: node.bootc.dev + names: + kind: BootcOperatorConfig + listKind: BootcOperatorConfigList + plural: bootcoperatorconfigs + singular: bootcoperatorconfig + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BootcOperatorConfig configures the controller and node daemons in a cluster. + The optional resource is administrator-owned and may have any valid name. + The operator rejects multiple configurations at startup. Changes take effect + when the affected pods restart; explicit command-line flags take precedence. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the configuration consumed when operator processes + start. + properties: + controller: + default: {} + description: controller configures the controller. Omitted settings + use their defaults. + properties: + allowInsecureRegistry: + default: false + description: |- + allowInsecureRegistry allows falling back to HTTP when accessing image + registries. Enable this only for trusted registries that do not support TLS. + type: boolean + tagResolutionPeriodSeconds: + default: 300 + description: |- + tagResolutionPeriodSeconds is the interval in seconds between resolving + image tags to digests. Defaults to 300 seconds. + format: int32 + minimum: 1 + type: integer + type: object + daemon: + default: {} + description: daemon configures all node daemons. Omitted settings + use their defaults. + properties: + statusPollPeriodSeconds: + default: 300 + description: |- + statusPollPeriodSeconds is the interval in seconds between bootc status + polls when filesystem notifications are unavailable. Defaults to 300 seconds. + format: int32 + minimum: 1 + type: integer + type: object + type: object + required: + - spec + type: object + served: true + storage: true diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 24041517..7230f693 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -1,4 +1,5 @@ resources: - bases/node.bootc.dev_bootcnodepools.yaml - bases/node.bootc.dev_bootcnodes.yaml +- bases/node.bootc.dev_bootcoperatorconfigs.yaml # +kubebuilder:scaffold:crdkustomizeresource From aee67b4fd55a0530e1304a9feadfec99a06e3593 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:33 +0530 Subject: [PATCH 02/14] api: Test configuration defaults and schema validation Prove API-server defaulting and rejection of invalid period, type, and field values before the startup path depends on the resource. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/controller/operatorconfig_test.go | 115 +++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 internal/controller/operatorconfig_test.go diff --git a/internal/controller/operatorconfig_test.go b/internal/controller/operatorconfig_test.go new file mode 100644 index 00000000..7ddeb7c4 --- /dev/null +++ b/internal/controller/operatorconfig_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "encoding/json" + "testing" + + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" +) + +func TestBootcOperatorConfigDefaults(t *testing.T) { + const ( + tagDefault = bootcv1alpha1.DefaultTagResolutionPeriodSeconds + pollDefault = bootcv1alpha1.DefaultStatusPollPeriodSeconds + ) + for _, tc := range []struct { + name string + spec string + wantInsecure bool + wantTag int32 + wantPoll int32 + }{ + {"empty spec", `{}`, false, tagDefault, pollDefault}, + {"partial controller", `{"controller":{"allowInsecureRegistry":true}}`, true, tagDefault, pollDefault}, + {"partial daemon", `{"daemon":{"statusPollPeriodSeconds":17}}`, false, tagDefault, 17}, + {"explicit false", `{"controller":{"allowInsecureRegistry":false,"tagResolutionPeriodSeconds":10}}`, false, 10, pollDefault}, + {"null sections", `{"controller":null,"daemon":null}`, false, tagDefault, pollDefault}, + {"null fields", `{"controller":{"allowInsecureRegistry":null,"tagResolutionPeriodSeconds":null},"daemon":{"statusPollPeriodSeconds":null}}`, false, tagDefault, pollDefault}, + {"minimum periods", `{"controller":{"tagResolutionPeriodSeconds":1},"daemon":{"statusPollPeriodSeconds":1}}`, false, 1, 1}, + {"maximum periods", `{"controller":{"tagResolutionPeriodSeconds":2147483647},"daemon":{"statusPollPeriodSeconds":2147483647}}`, false, 2147483647, 2147483647}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + object := operatorConfigObject(t, "cluster", tc.spec) + g.Expect(k8sClient.Create(ctx, object)).To(Succeed()) + t.Cleanup(func() { + g.Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, object))).To(Succeed()) + }) + + var got bootcv1alpha1.BootcOperatorConfig + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(object), &got)).To(Succeed()) + g.Expect(got.Spec).To(Equal(bootcv1alpha1.BootcOperatorConfigSpec{ + Controller: &bootcv1alpha1.OperatorControllerConfig{ + AllowInsecureRegistry: ptr.To(tc.wantInsecure), + TagResolutionPeriodSeconds: ptr.To(tc.wantTag), + }, + Daemon: &bootcv1alpha1.OperatorDaemonConfig{ + StatusPollPeriodSeconds: ptr.To(tc.wantPoll), + }, + })) + }) + } +} + +func TestBootcOperatorConfigValidation(t *testing.T) { + for _, tc := range []struct { + name string + spec string + field string + badRequest bool + }{ + {"missing spec", "", "spec", false}, + {"null spec", `null`, "spec", false}, + {"zero tag period", `{"controller":{"tagResolutionPeriodSeconds":0}}`, "spec.controller.tagResolutionPeriodSeconds", false}, + {"negative tag period", `{"controller":{"tagResolutionPeriodSeconds":-1}}`, "spec.controller.tagResolutionPeriodSeconds", false}, + {"zero poll period", `{"daemon":{"statusPollPeriodSeconds":0}}`, "spec.daemon.statusPollPeriodSeconds", false}, + {"negative poll period", `{"daemon":{"statusPollPeriodSeconds":-1}}`, "spec.daemon.statusPollPeriodSeconds", false}, + {"overflowing tag period", `{"controller":{"tagResolutionPeriodSeconds":2147483648}}`, "spec.controller.tagResolutionPeriodSeconds", false}, + {"overflowing poll period", `{"daemon":{"statusPollPeriodSeconds":2147483648}}`, "spec.daemon.statusPollPeriodSeconds", false}, + {"string period", `{"daemon":{"statusPollPeriodSeconds":"10s"}}`, "spec.daemon.statusPollPeriodSeconds", false}, + {"fractional period", `{"controller":{"tagResolutionPeriodSeconds":1.5}}`, "spec.controller.tagResolutionPeriodSeconds", false}, + {"string boolean", `{"controller":{"allowInsecureRegistry":"true"}}`, "spec.controller.allowInsecureRegistry", false}, + {"unknown field", `{"controller":{"tagResolutionPeriodSecond":10}}`, "spec.controller.tagResolutionPeriodSecond", true}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + object := operatorConfigObject(t, "cluster", tc.spec) + err := k8sClient.Create(context.Background(), object, + client.DryRunAll, client.FieldValidation(metav1.FieldValidationStrict)) + if tc.badRequest { + g.Expect(err).To(MatchError(apierrors.IsBadRequest, "IsBadRequest")) + } else { + g.Expect(err).To(MatchError(apierrors.IsInvalid, "IsInvalid")) + } + g.Expect(err.Error()).To(ContainSubstring(tc.field)) + }) + } +} + +// Use unstructured objects to exercise missing, null, and invalid fields that +// typed Go objects cannot represent on the wire. +func operatorConfigObject(t *testing.T, name, spec string) *unstructured.Unstructured { + t.Helper() + object := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": bootcv1alpha1.GroupVersion.String(), + "kind": "BootcOperatorConfig", + "metadata": map[string]any{"name": name}, + }} + if spec != "" { + var value any + NewWithT(t).Expect(json.Unmarshal([]byte(spec), &value)).To(Succeed()) + object.Object["spec"] = value + } + return object +} From a0408107382bde0aa67a30a40b23951b3e4c17da Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:33 +0530 Subject: [PATCH 03/14] config: Apply resource settings with explicit flag precedence Preserve existing CLI behavior while allowing administrators to supply defaults through the resource. Explicit false values and fractional CLI durations continue to take precedence. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/config/config.go | 95 ++++++++++++++++++++++ internal/config/config_test.go | 139 +++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..d00a5d57 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "flag" + "fmt" + "strconv" + "time" + + "github.com/go-logr/logr" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" +) + +// Flag names shared by startup configuration and the component entry points. +const ( + AllowInsecureRegistryFlag = "allow-insecure-registry" + TagResolutionIntervalFlag = "tag-resolution-interval" + BootcPollIntervalFlag = "bootc-poll-interval" +) + +// ExplicitFlags snapshots flags supplied on the command line, including false +// boolean values. Call it immediately after parsing, before applying configuration. +func ExplicitFlags(fs *flag.FlagSet) map[string]bool { + explicit := make(map[string]bool) + fs.Visit(func(f *flag.Flag) { explicit[f.Name] = true }) + return explicit +} + +// ApplyToFlags applies fields supported by this process to parsed flag values. +// It leaves explicitly supplied flags untouched, including false and fractional +// durations. Setting Flag.Value directly does not mark CR values as CLI flags. +// cfg must have been validated by Load. +func ApplyToFlags(fs *flag.FlagSet, cfg *bootcv1alpha1.BootcOperatorConfig) error { + if cfg == nil { + return nil + } + explicit := ExplicitFlags(fs) + set := func(name, value string) error { + f := fs.Lookup(name) + if f == nil || explicit[name] { + return nil // The other process may not register this flag. + } + if err := f.Value.Set(value); err != nil { + return fmt.Errorf("apply BootcOperatorConfig/%s to --%s: %w", cfg.Name, name, err) + } + return nil + } + if c := cfg.Spec.Controller; c != nil { + if c.AllowInsecureRegistry != nil { + if err := set( + AllowInsecureRegistryFlag, + strconv.FormatBool(*c.AllowInsecureRegistry), + ); err != nil { + return err + } + } + if c.TagResolutionPeriodSeconds != nil { + period := time.Duration(*c.TagResolutionPeriodSeconds) * time.Second + if err := set(TagResolutionIntervalFlag, period.String()); err != nil { + return err + } + } + } + if d := cfg.Spec.Daemon; d != nil && d.StatusPollPeriodSeconds != nil { + period := time.Duration(*d.StatusPollPeriodSeconds) * time.Second + if err := set(BootcPollIntervalFlag, period.String()); err != nil { + return err + } + } + return nil +} + +// LogSource records the selected resource and CLI overrides without logging +// credentials or bootstrap flags. Supported flags are supplied by the component. +func LogSource( + log logr.Logger, + cfg *bootcv1alpha1.BootcOperatorConfig, + explicit map[string]bool, + flags ...string, +) { + overrides := make([]string, 0, len(flags)) + for _, name := range flags { + if explicit[name] { + overrides = append(overrides, name) + } + } + if cfg == nil { + log.Info("Loaded operator configuration", "source", "defaults", "cliOverrides", overrides) + return + } + log.Info("Loaded operator configuration", "source", "BootcOperatorConfig/"+cfg.Name, + "uid", cfg.UID, "generation", cfg.Generation, "cliOverrides", overrides) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..c428c386 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "errors" + "flag" + "math" + "testing" + "time" + + . "github.com/onsi/gomega" + "k8s.io/utils/ptr" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" +) + +func TestApplyToFlags(t *testing.T) { + for _, tt := range []struct { + name string + controller *bootcv1alpha1.OperatorControllerConfig + daemon *bootcv1alpha1.OperatorDaemonConfig + args []string + want bool + wantTag time.Duration + wantPoll time.Duration + }{ + {name: "absent instance", wantTag: 5 * time.Minute, wantPoll: 5 * time.Minute}, + { + name: "empty sections", controller: &bootcv1alpha1.OperatorControllerConfig{}, + daemon: &bootcv1alpha1.OperatorDaemonConfig{}, wantTag: 5 * time.Minute, wantPoll: 5 * time.Minute, + }, + { + name: "partial controller", controller: &bootcv1alpha1.OperatorControllerConfig{AllowInsecureRegistry: ptr.To(true)}, + want: true, wantTag: 5 * time.Minute, wantPoll: 5 * time.Minute, + }, + { + name: "configured periods", + controller: &bootcv1alpha1.OperatorControllerConfig{TagResolutionPeriodSeconds: ptr.To(int32(10))}, + daemon: &bootcv1alpha1.OperatorDaemonConfig{StatusPollPeriodSeconds: ptr.To(int32(17))}, + wantTag: 10 * time.Second, wantPoll: 17 * time.Second, + }, + { + name: "maximum periods do not overflow", + controller: &bootcv1alpha1.OperatorControllerConfig{TagResolutionPeriodSeconds: ptr.To(int32(math.MaxInt32))}, + daemon: &bootcv1alpha1.OperatorDaemonConfig{StatusPollPeriodSeconds: ptr.To(int32(math.MaxInt32))}, + wantTag: time.Duration(math.MaxInt32) * time.Second, + wantPoll: time.Duration(math.MaxInt32) * time.Second, + }, + { + name: "explicit false and fractional durations override CR", + controller: &bootcv1alpha1.OperatorControllerConfig{ + AllowInsecureRegistry: ptr.To(true), TagResolutionPeriodSeconds: ptr.To(int32(10)), + }, + daemon: &bootcv1alpha1.OperatorDaemonConfig{StatusPollPeriodSeconds: ptr.To(int32(17))}, + args: []string{ + "--allow-insecure-registry=false", + "--tag-resolution-interval=1500ms", + "--bootc-poll-interval=500ms", + }, + wantTag: 1500 * time.Millisecond, + wantPoll: 500 * time.Millisecond, + }, + { + name: "explicit true overrides false", + controller: &bootcv1alpha1.OperatorControllerConfig{AllowInsecureRegistry: ptr.To(false)}, + args: []string{"--allow-insecure-registry"}, want: true, + wantTag: 5 * time.Minute, wantPoll: 5 * time.Minute, + }, + { + name: "CLI retains zero and negative duration behavior", + controller: &bootcv1alpha1.OperatorControllerConfig{TagResolutionPeriodSeconds: ptr.To(int32(10))}, + daemon: &bootcv1alpha1.OperatorDaemonConfig{StatusPollPeriodSeconds: ptr.To(int32(17))}, + args: []string{"--tag-resolution-interval=0", "--bootc-poll-interval=-1s"}, + wantPoll: -time.Second, + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + var allowInsecure bool + var tagInterval, pollInterval time.Duration + fs.BoolVar(&allowInsecure, AllowInsecureRegistryFlag, false, "") + fs.DurationVar(&tagInterval, TagResolutionIntervalFlag, 5*time.Minute, "") + fs.DurationVar(&pollInterval, BootcPollIntervalFlag, 5*time.Minute, "") + g.Expect(fs.Parse(tt.args)).To(Succeed()) + explicit := ExplicitFlags(fs) + var cfg *bootcv1alpha1.BootcOperatorConfig + if tt.controller != nil || tt.daemon != nil { + cfg = &bootcv1alpha1.BootcOperatorConfig{ + Spec: bootcv1alpha1.BootcOperatorConfigSpec{ + Controller: tt.controller, + Daemon: tt.daemon, + }, + } + } + g.Expect(ApplyToFlags(fs, cfg)).To(Succeed()) + g.Expect(allowInsecure).To(Equal(tt.want)) + g.Expect(tagInterval).To(Equal(tt.wantTag)) + g.Expect(pollInterval).To(Equal(tt.wantPoll)) + g.Expect(ExplicitFlags(fs)). + To(Equal(explicit), "configuration must not mark flags as explicit") + }) + } +} + +func TestApplyToFlagsSkipsOtherComponent(t *testing.T) { + g := NewWithT(t) + fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + var pollInterval time.Duration + fs.DurationVar(&pollInterval, BootcPollIntervalFlag, 5*time.Minute, "") + cfg := &bootcv1alpha1.BootcOperatorConfig{ + Spec: bootcv1alpha1.BootcOperatorConfigSpec{ + Controller: &bootcv1alpha1.OperatorControllerConfig{ + AllowInsecureRegistry: ptr.To(true), + }, + Daemon: &bootcv1alpha1.OperatorDaemonConfig{ + StatusPollPeriodSeconds: ptr.To(int32(17)), + }, + }, + } + g.Expect(ApplyToFlags(fs, cfg)).To(Succeed()) + g.Expect(pollInterval).To(Equal(17 * time.Second)) +} + +func TestApplyToFlagsReportsSetterError(t *testing.T) { + g := NewWithT(t) + fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + fs.Func(AllowInsecureRegistryFlag, "", func(string) error { return errors.New("rejected") }) + cfg := &bootcv1alpha1.BootcOperatorConfig{ + Spec: bootcv1alpha1.BootcOperatorConfigSpec{ + Controller: &bootcv1alpha1.OperatorControllerConfig{ + AllowInsecureRegistry: ptr.To(true), + }, + }, + } + g.Expect(ApplyToFlags(fs, cfg)). + To(MatchError(ContainSubstring("--allow-insecure-registry: rejected"))) +} From a2822aa1e4b6c00b9cc1b845a767cb06b4efbcb1 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:33 +0530 Subject: [PATCH 04/14] config: Load one resource directly from the API at startup Read the administrator resource before informer caches exist. Treat missing CRDs, permission failures, API errors, and multiple instances as actionable startup errors while an absent instance keeps defaults. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/config/load.go | 113 +++++++++++++++++++ internal/config/load_test.go | 206 +++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 internal/config/load.go create mode 100644 internal/config/load_test.go diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 00000000..9576ed03 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilnet "k8s.io/apimachinery/pkg/util/net" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" +) + +// Bound the bootstrap read without imposing a timeout on the manager's watches. +const startupTimeout = 10 * time.Second + +// Load lists configurations directly from the API before caches start. An empty +// list permits defaults; installation, permission, and network failures do not. +func Load( + ctx context.Context, + restConfig *rest.Config, + scheme *runtime.Scheme, +) (*bootcv1alpha1.BootcOperatorConfig, error) { + return load(ctx, restConfig, scheme, startupTimeout) +} + +func load( + ctx context.Context, + restConfig *rest.Config, + scheme *runtime.Scheme, + timeout time.Duration, +) (*bootcv1alpha1.BootcOperatorConfig, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + bootstrap := rest.CopyConfig(restConfig) + bootstrap.Timeout = timeout + // This client only lists our known resource, so API discovery is unnecessary. + gv := bootcv1alpha1.GroupVersion + mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{gv}) + mapper.AddSpecific( + gv.WithKind("BootcOperatorConfig"), + gv.WithResource( + "bootcoperatorconfigs", + ), + gv.WithResource("bootcoperatorconfig"), + meta.RESTScopeRoot, + ) + httpClient, err := rest.HTTPClientFor(bootstrap) + if err != nil { + return nil, fmt.Errorf("create HTTP client for BootcOperatorConfig: %w", err) + } + defer utilnet.CloseIdleConnectionsFor(httpClient.Transport) + c, err := client.New(bootstrap, client.Options{ + Scheme: scheme, Mapper: mapper, HTTPClient: httpClient, + }) + if err != nil { + return nil, fmt.Errorf("create client for BootcOperatorConfig: %w", err) + } + var configs bootcv1alpha1.BootcOperatorConfigList + if err := c.List(ctx, &configs); err != nil { + return nil, fmt.Errorf( + "list BootcOperatorConfig: ensure the bootcoperatorconfigs CRD is installed, "+ + "the API server is reachable, and this service account can list bootcoperatorconfigs: %w", + err, + ) + } + if len(configs.Items) == 0 { + return nil, nil + } + if len(configs.Items) > 1 { + names := make([]string, 0, len(configs.Items)) + for _, cfg := range configs.Items { + names = append(names, cfg.Name) + } + sort.Strings(names) + return nil, fmt.Errorf("found multiple BootcOperatorConfig resources (%s); keep only one", + strings.Join(names, ", ")) + } + cfg := &configs.Items[0] + if err := validate(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func validate(cfg *bootcv1alpha1.BootcOperatorConfig) error { + if cfg.Name == "" { + return fmt.Errorf("BootcOperatorConfig list returned an object without a name") + } + if c := cfg.Spec.Controller; c != nil && c.TagResolutionPeriodSeconds != nil && + *c.TagResolutionPeriodSeconds < 1 { + return fmt.Errorf( + "BootcOperatorConfig/%s spec.controller.tagResolutionPeriodSeconds must be at least 1", + cfg.Name, + ) + } + if d := cfg.Spec.Daemon; d != nil && d.StatusPollPeriodSeconds != nil && + *d.StatusPollPeriodSeconds < 1 { + return fmt.Errorf( + "BootcOperatorConfig/%s spec.daemon.statusPollPeriodSeconds must be at least 1", + cfg.Name, + ) + } + return nil +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 00000000..7b819024 --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" +) + +const configPath = "/apis/node.bootc.dev/v1alpha1/bootcoperatorconfigs" + +func TestLoad(t *testing.T) { + resource := schema.GroupResource{Group: "node.bootc.dev", Resource: "bootcoperatorconfigs"} + for _, tt := range []struct { + name string + status *apierrors.StatusError + body string + code int + wantNil bool + wantError string + }{ + {name: "one custom-named object"}, + {name: "no objects", body: `{"items":[]}`, wantNil: true}, + { + name: "multiple objects", body: `{"items":[{"metadata":{"name":"z-config"}},` + + `{"metadata":{"name":"a-config"}}]}`, + wantError: "a-config, z-config); keep only one", + }, + { + name: "missing CRD", status: apierrors.NewNotFound(resource, ""), + wantError: "ensure the bootcoperatorconfigs CRD is installed", + }, + { + name: "plain HTTP 404", code: 404, body: "not found", + wantError: "ensure the bootcoperatorconfigs CRD is installed", + }, + { + name: "forbidden", status: apierrors.NewForbidden(resource, "", errors.New("access denied")), + wantError: "this service account can list bootcoperatorconfigs", + }, + { + name: "unauthorized", status: apierrors.NewUnauthorized("authentication failed"), + wantError: "authentication failed", + }, + {name: "malformed response", body: `{`, wantError: "unexpected end"}, + { + name: "zero daemon period", body: `{"items":[{"metadata":{"name":"custom-config"},` + + `"spec":{"daemon":{"statusPollPeriodSeconds":0}}}]}`, + wantError: "BootcOperatorConfig/custom-config spec.daemon.statusPollPeriodSeconds must be at least 1", + }, + { + name: "zero controller period", body: `{"items":[{"metadata":{"name":"custom-config"},` + + `"spec":{"controller":{"tagResolutionPeriodSeconds":0}}}]}`, + wantError: "BootcOperatorConfig/custom-config spec.controller.tagResolutionPeriodSeconds must be at least 1", + }, + { + name: "negative daemon period", body: `{"items":[{"metadata":{"name":"custom-config"},` + + `"spec":{"daemon":{"statusPollPeriodSeconds":-1}}}]}`, + wantError: "spec.daemon.statusPollPeriodSeconds must be at least 1", + }, + {name: "unnamed object", body: `{"items":[{"spec":{}}]}`, wantError: "object without a name"}, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + var resourceRequests []string + wrapped := false + roundTrip := roundTripFunc(func(req *http.Request) (*http.Response, error) { + g.Expect(req.Method).To(Equal(http.MethodGet)) + g.Expect(req.URL.Path).To(Equal(configPath), "list the resource without discovery") + g.Expect(req.URL.Query().Has("timeout")).To(BeTrue()) + g.Expect(req.URL.Query()).To(HaveLen(1), "list without a name or field selector") + resourceRequests = append(resourceRequests, req.URL.Path) + if tt.status != nil { + tt.status.ErrStatus.TypeMeta = metav1.TypeMeta{Kind: "Status", APIVersion: "v1"} + body, err := json.Marshal(tt.status.ErrStatus) + g.Expect(err).NotTo(HaveOccurred()) + return jsonResponse(int(tt.status.ErrStatus.Code), string(body)), nil + } + body := tt.body + if body == "" { + body = `{"items":[{"metadata":{"name":"custom-config","uid":"original",` + + `"generation":3},"spec":{"daemon":{"statusPollPeriodSeconds":17}}}]}` + } + code := tt.code + if code == 0 { + code = http.StatusOK + } + return jsonResponse(code, body), nil + }) + transport := &closeTrackingTransport{RoundTripper: roundTrip} + restConfig := &rest.Config{ + Host: "https://api.example.test", + BearerToken: "test-token", + UserAgent: "config-test", + Transport: transport, + Timeout: time.Minute, + } + restConfig.Wrap( + func(rt http.RoundTripper) http.RoundTripper { wrapped = true; return rt }, + ) + cfg, err := Load(context.Background(), restConfig, configScheme(t)) + g.Expect(transport.closed).To(BeTrue(), "close idle bootstrap connections") + g.Expect(restConfig.Timeout).To(Equal(time.Minute)) + g.Expect(wrapped).To(BeTrue(), "preserve existing transport wrappers") + g.Expect(resourceRequests).To(Equal([]string{configPath})) + if tt.wantError != "" { + g.Expect(err).To(MatchError(ContainSubstring(tt.wantError))) + g.Expect(cfg).To(BeNil()) + return + } + g.Expect(err).NotTo(HaveOccurred()) + if tt.wantNil { + g.Expect(cfg).To(BeNil()) + return + } + g.Expect(cfg.Name).To(Equal("custom-config")) + g.Expect(cfg.UID).To(BeEquivalentTo("original")) + g.Expect(cfg.Generation).To(Equal(int64(3))) + g.Expect(*cfg.Spec.Daemon.StatusPollPeriodSeconds).To(Equal(int32(17))) + }) + } +} + +func TestLoadRequestFailures(t *testing.T) { + for _, tt := range []struct{ failure, wantError string }{ + {"timeout", "deadline exceeded"}, + {"cancel", "context canceled"}, + {"connection", "connection refused"}, + } { + t.Run(tt.failure, func(t *testing.T) { + g := NewWithT(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + injected := false + roundTrip := roundTripFunc(func(req *http.Request) (*http.Response, error) { + g.Expect(req.Method).To(Equal(http.MethodGet)) + g.Expect(req.URL.Path).To(Equal(configPath)) + injected = true + if tt.failure == "connection" { + return nil, errors.New("connection refused") + } + if tt.failure == "cancel" { + cancel() + } + <-req.Context().Done() + return nil, req.Context().Err() + }) + transport := &closeTrackingTransport{RoundTripper: roundTrip} + cfg, err := load( + ctx, + &rest.Config{ + Host: "https://api.example.test", + Transport: transport, + BearerToken: "test-token", + }, + configScheme(t), + 20*time.Millisecond, + ) + g.Expect(transport.closed).To(BeTrue(), "close idle bootstrap connections") + g.Expect(injected).To(BeTrue()) + g.Expect(cfg).To(BeNil()) + g.Expect(err).To(MatchError(ContainSubstring(tt.wantError))) + }) + } +} + +func configScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + NewWithT(t).Expect(bootcv1alpha1.AddToScheme(scheme)).To(Succeed()) + return scheme +} + +func jsonResponse(code int, body string) *http.Response { + return &http.Response{ + StatusCode: code, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +type closeTrackingTransport struct { + http.RoundTripper + closed bool +} + +func (t *closeTrackingTransport) CloseIdleConnections() { t.closed = true } From c6de6ab89e69cbd2630034794ecd49d197d4027c Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 05/14] config: Test startup selection against an API server Exercise absent, custom-named, updated, conflicting, and missing-CRD resources through envtest so the loader behavior is covered beyond mocked HTTP responses. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/controller/operatorconfig_test.go | 86 ++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/internal/controller/operatorconfig_test.go b/internal/controller/operatorconfig_test.go index 7ddeb7c4..063f8c9c 100644 --- a/internal/controller/operatorconfig_test.go +++ b/internal/controller/operatorconfig_test.go @@ -13,8 +13,10 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" + operatorconfig "github.com/bootc-dev/bootc-operator/internal/config" ) func TestBootcOperatorConfigDefaults(t *testing.T) { @@ -97,6 +99,90 @@ func TestBootcOperatorConfigValidation(t *testing.T) { } } +func TestBootcOperatorConfigRoundTrip(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + loaded, err := operatorconfig.Load(ctx, testEnv.Config, k8sClient.Scheme()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(loaded).To(BeNil(), "an absent instance permits startup defaults") + config := &bootcv1alpha1.BootcOperatorConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "custom-config"}, + Spec: bootcv1alpha1.BootcOperatorConfigSpec{ + Controller: &bootcv1alpha1.OperatorControllerConfig{ + AllowInsecureRegistry: ptr.To(true), + TagResolutionPeriodSeconds: ptr.To(int32(10)), + }, + Daemon: &bootcv1alpha1.OperatorDaemonConfig{ + StatusPollPeriodSeconds: ptr.To(int32(17)), + }, + }, + } + wantSpec := *config.Spec.DeepCopy() + g.Expect(k8sClient.Create(ctx, config)).To(Succeed()) + t.Cleanup(func() { + g.Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, config))).To(Succeed()) + }) + got, err := operatorconfig.Load(ctx, testEnv.Config, k8sClient.Scheme()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).NotTo(BeNil()) + g.Expect(got.Name).To(Equal("custom-config")) + g.Expect(got.Spec).To(Equal(wantSpec)) + + startup := got + updated := got.DeepCopy() + updated.Spec.Controller.AllowInsecureRegistry = ptr.To(false) + updated.Spec.Daemon.StatusPollPeriodSeconds = ptr.To(int32(23)) + wantSpec = *updated.Spec.DeepCopy() + g.Expect(k8sClient.Update(ctx, updated)).To(Succeed()) + got, err = operatorconfig.Load(ctx, testEnv.Config, k8sClient.Scheme()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).NotTo(BeNil()) + g.Expect(got.Spec).To(Equal(wantSpec)) + g.Expect(got.UID).To(Equal(config.UID)) + g.Expect(got.Generation).To(Equal(config.Generation + 1)) + g.Expect(startup.Spec). + To(Equal(config.Spec), "previously loaded configuration remains a snapshot") + + g.Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + loaded, err = operatorconfig.Load(ctx, testEnv.Config, k8sClient.Scheme()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(loaded).To(BeNil(), "deleting the instance restores defaults on the next load") +} + +func TestBootcOperatorConfigMultipleInstances(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + for _, name := range []string{"operator-one", "operator-two"} { + object := operatorConfigObject(t, name, `{}`) + g.Expect(k8sClient.Create(ctx, object)).To(Succeed(), "the API allows arbitrary names") + t.Cleanup(func() { + g.Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, object))).To(Succeed()) + }) + } + + loaded, err := operatorconfig.Load(ctx, testEnv.Config, k8sClient.Scheme()) + g.Expect(loaded).To(BeNil()) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(And( + ContainSubstring("operator-one"), + ContainSubstring("operator-two"), + )) +} + +func TestBootcOperatorConfigMissingCRD(t *testing.T) { + g := NewWithT(t) + // A separate API server keeps the missing-CRD case isolated from the suite. + env := &envtest.Environment{} + restConfig, err := env.Start() + g.Expect(err).NotTo(HaveOccurred()) + t.Cleanup(func() { g.Expect(env.Stop()).To(Succeed()) }) + + loaded, err := operatorconfig.Load(context.Background(), restConfig, k8sClient.Scheme()) + g.Expect(err).To(MatchError(apierrors.IsNotFound, "IsNotFound")) + g.Expect(err.Error()).To(ContainSubstring("ensure the bootcoperatorconfigs CRD is installed")) + g.Expect(loaded).To(BeNil()) +} + // Use unstructured objects to exercise missing, null, and invalid fields that // typed Go objects cannot represent on the wire. func operatorConfigObject(t *testing.T, name, spec string) *unstructured.Unstructured { From 9f3704b243140a7efce2e0fd13064af9832607f8 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 06/14] controller: Read operator configuration before manager startup Make registry policy and tag resolution configurable on controller startup without changing explicit CLI overrides. Grant its service account only the list permission needed to select the singleton. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- cmd/controller/main.go | 31 ++++++++++++++++--- config/rbac/role.yaml | 6 ++++ .../controller/bootcnodepool_controller.go | 1 + 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/cmd/controller/main.go b/cmd/controller/main.go index f15223d3..9f3a1459 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" + "github.com/bootc-dev/bootc-operator/internal/config" "github.com/bootc-dev/bootc-operator/internal/controller" "github.com/bootc-dev/bootc-operator/internal/registry" "github.com/bootc-dev/bootc-operator/internal/version" @@ -57,13 +58,13 @@ func main() { ) flag.DurationVar( &tagResolutionInterval, - "tag-resolution-interval", - 5*time.Minute, + config.TagResolutionIntervalFlag, + time.Duration(bootcv1alpha1.DefaultTagResolutionPeriodSeconds)*time.Second, "How often to re-resolve tag-based image refs.", ) flag.BoolVar( &allowInsecureRegistry, - "allow-insecure-registry", + config.AllowInsecureRegistryFlag, false, "Allow falling back to HTTP when resolving tag-based image refs against registries that do not serve TLS.", ) @@ -75,6 +76,7 @@ func main() { } opts.BindFlags(flag.CommandLine) flag.Parse() + explicit := config.ExplicitFlags(flag.CommandLine) ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) @@ -88,7 +90,26 @@ func main() { version.GitCommit, ) - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + ctx := ctrl.SetupSignalHandler() + restConfig := ctrl.GetConfigOrDie() + operatorConfig, err := config.Load(ctx, restConfig, scheme) + if err != nil { + setupLog.Error(err, "Failed to load operator configuration") + os.Exit(1) + } + if err := config.ApplyToFlags(flag.CommandLine, operatorConfig); err != nil { + setupLog.Error(err, "Failed to apply operator configuration") + os.Exit(1) + } + config.LogSource( + setupLog, + operatorConfig, + explicit, + config.AllowInsecureRegistryFlag, + config.TagResolutionIntervalFlag, + ) + + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ Scheme: scheme, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, @@ -129,7 +150,7 @@ func main() { } setupLog.Info("Starting manager") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { setupLog.Error(err, "Failed to run manager") os.Exit(1) } diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index c25de146..fbb238f9 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -82,3 +82,9 @@ rules: - bootcnodes/status verbs: - get +- apiGroups: + - node.bootc.dev + resources: + - bootcoperatorconfigs + verbs: + - list diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index 7d334476..34c3c787 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -78,6 +78,7 @@ type BootcNodePoolReconciler struct { } // +kubebuilder:rbac:groups=node.bootc.dev,resources=bootcnodepools,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=node.bootc.dev,resources=bootcoperatorconfigs,verbs=list // +kubebuilder:rbac:groups=node.bootc.dev,resources=bootcnodepools/status,verbs=get;update;patch // +kubebuilder:rbac:groups=node.bootc.dev,resources=bootcnodepools/finalizers,verbs=update // +kubebuilder:rbac:groups=node.bootc.dev,resources=bootcnodes,verbs=get;list;watch;create;update;patch;delete From 5f74e6d3457f53359bd478680f36e88d7a38576d Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 07/14] daemon: Read fallback poll period before manager startup Let each daemon load the same administrator-owned resource at startup while preserving explicit CLI overrides. Give the daemon service account the list permission needed for the read. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- cmd/daemon/main.go | 23 +++++++++++++++++++---- config/rbac/daemon_role.yaml | 6 ++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 829142f4..e8bcb4ec 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -20,6 +20,7 @@ import ( bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" "github.com/bootc-dev/bootc-operator/internal/bootc" + "github.com/bootc-dev/bootc-operator/internal/config" "github.com/bootc-dev/bootc-operator/internal/daemon" "github.com/bootc-dev/bootc-operator/internal/version" ) @@ -38,8 +39,8 @@ func main() { var pollInterval time.Duration flag.DurationVar( &pollInterval, - "bootc-poll-interval", - 5*time.Minute, + config.BootcPollIntervalFlag, + time.Duration(bootcv1alpha1.DefaultStatusPollPeriodSeconds)*time.Second, "Interval for polling bootc status as a fallback to fsnotify", ) @@ -48,6 +49,7 @@ func main() { } opts.BindFlags(flag.CommandLine) flag.Parse() + explicit := config.ExplicitFlags(flag.CommandLine) ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) @@ -70,7 +72,20 @@ func main() { os.Exit(1) } - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + ctx := ctrl.SetupSignalHandler() + restConfig := ctrl.GetConfigOrDie() + operatorConfig, err := config.Load(ctx, restConfig, scheme) + if err != nil { + setupLog.Error(err, "Failed to load operator configuration") + os.Exit(1) + } + if err := config.ApplyToFlags(flag.CommandLine, operatorConfig); err != nil { + setupLog.Error(err, "Failed to apply operator configuration") + os.Exit(1) + } + config.LogSource(setupLog, operatorConfig, explicit, config.BootcPollIntervalFlag) + + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ Scheme: scheme, // Only cache the BootcNode object for this node to avoid unnecessary watches. Cache: cache.Options{ @@ -115,7 +130,7 @@ func main() { } setupLog.Info("Starting daemon", "node", nodeName, "pollInterval", pollInterval) - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { setupLog.Error(err, "Failed to run daemon") os.Exit(1) } diff --git a/config/rbac/daemon_role.yaml b/config/rbac/daemon_role.yaml index f026a8eb..16dcdd4c 100644 --- a/config/rbac/daemon_role.yaml +++ b/config/rbac/daemon_role.yaml @@ -6,6 +6,12 @@ metadata: app.kubernetes.io/managed-by: kustomize name: daemon-role rules: +- apiGroups: + - node.bootc.dev + resources: + - bootcoperatorconfigs + verbs: + - list - apiGroups: - "" resources: From d6d9b11b5656eb4c7a6a144b487b8acbd9e02cec Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 08/14] dev: Apply bink registry settings after Helm install Bink now installs the operator through Helm. Apply the local registry configuration after the chart creates the CRD, then restart the controller so it reads the settings and refreshes the development image. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- Makefile | 12 +++++++----- config/bink/operator-config.yaml | 8 ++++++++ hack/deploy-bink.sh | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 config/bink/operator-config.yaml create mode 100755 hack/deploy-bink.sh diff --git a/Makefile b/Makefile index 62b938c3..ac151540 100644 --- a/Makefile +++ b/Makefile @@ -252,7 +252,9 @@ start-bink: seed-node-image ## Start a bink cluster (idempotent). kubectl --kubeconfig $(KUBECONFIG_BINK) wait --for=condition=Ready node/controller --timeout=5m .PHONY: deploy-bink -deploy-bink: start-bink build-update-image $(if $(RELEASED_OPERATOR_IMG),push-released-operator-image) helm ## Deploy to a bink cluster (requires: buildimg). +deploy-bink: start-bink helm ## Deploy to a bink cluster (requires: buildimg). + $(MAKE) build-update-image + $(if $(RELEASED_OPERATOR_IMG),$(MAKE) push-released-operator-image) podman push --tls-verify=false $(IMG) localhost:5000/bootc-operator-e2e:latest helm dependency build ./chart/bootc-operator img="$(IMG_BINK)" && \ @@ -261,10 +263,10 @@ deploy-bink: start-bink build-update-image $(if $(RELEASED_OPERATOR_IMG),push-re --create-namespace --namespace bootc-operator \ --set-string image.repository="$${img%:*}" \ --set-string image.tag="$${img##*:}" \ - --set-string image.pullPolicy=Always \ - --set-json 'controller.extraArgs=["--allow-insecure-registry","--tag-resolution-interval=10s"]' - kubectl --kubeconfig $(KUBECONFIG_BINK) -n bootc-operator rollout restart deployment/bootc-operator-controller - kubectl --kubeconfig $(KUBECONFIG_BINK) -n bootc-operator rollout status deployment/bootc-operator-controller --timeout=3m + --set-string image.pullPolicy=Always + KUBECONFIG="$(abspath $(KUBECONFIG_BINK))" \ + KUBECTL="$(KUBECTL)" ./hack/deploy-bink.sh + .PHONY: gather-bink gather-bink: ## Gather diagnostic logs from the bink cluster. diff --git a/config/bink/operator-config.yaml b/config/bink/operator-config.yaml new file mode 100644 index 00000000..48c2136c --- /dev/null +++ b/config/bink/operator-config.yaml @@ -0,0 +1,8 @@ +apiVersion: node.bootc.dev/v1alpha1 +kind: BootcOperatorConfig +metadata: + name: cluster +spec: + controller: + allowInsecureRegistry: true + tagResolutionPeriodSeconds: 10 diff --git a/hack/deploy-bink.sh b/hack/deploy-bink.sh new file mode 100755 index 00000000..f75d8670 --- /dev/null +++ b/hack/deploy-bink.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Configure the operator after make deploy-bink installs its Helm chart. +set -euo pipefail + +: "${KUBECONFIG:?must be set}" +: "${KUBECTL:=kubectl}" +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +"$KUBECTL" --kubeconfig "$KUBECONFIG" wait --for=condition=Established \ + crd/bootcoperatorconfigs.node.bootc.dev --timeout=1m +"$KUBECTL" --kubeconfig "$KUBECONFIG" apply -f config/bink/operator-config.yaml +"$KUBECTL" --kubeconfig "$KUBECONFIG" -n bootc-operator rollout restart \ + deployment/bootc-operator-controller +"$KUBECTL" --kubeconfig "$KUBECONFIG" -n bootc-operator rollout status \ + deployment/bootc-operator-controller --timeout=3m From 6eb642558d7c0518338f62339bcc35a884db3129 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 09/14] test: Preserve administrator configuration during upgrade tests The release upgrade test removes CRDs and their configuration. Snapshot and restore the optional resource during cleanup, then restart workloads so later tests observe the original settings. Derive legacy controller flags from that saved configuration while preserving explicit Deployment arguments for releases that predate the configuration API. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- hack/gather-logs.sh | 1 + test/e2e/upgrade_test.go | 203 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 192 insertions(+), 12 deletions(-) diff --git a/hack/gather-logs.sh b/hack/gather-logs.sh index a580b12a..c87fe235 100755 --- a/hack/gather-logs.sh +++ b/hack/gather-logs.sh @@ -37,6 +37,7 @@ run "k-describe-pods.txt" kubectl describe pods -n bootc-operator run "k-get-deployment.yaml" kubectl get deployment -n bootc-operator -o yaml run "k-describe-bootcnodepools.txt" kubectl describe bootcnodepools run "k-describe-bootcnodes.txt" kubectl describe bootcnodes +run "k-get-operator-config.yaml" kubectl get bootcoperatorconfigs.node.bootc.dev -o yaml run "k-get-events.txt" kubectl get events -n bootc-operator --sort-by=.lastTimestamp # Pod logs diff --git a/test/e2e/upgrade_test.go b/test/e2e/upgrade_test.go index da7ff675..2b9f5f07 100644 --- a/test/e2e/upgrade_test.go +++ b/test/e2e/upgrade_test.go @@ -5,6 +5,8 @@ package e2e import ( "context" "encoding/json" + "flag" + "fmt" "os" "os/exec" "path/filepath" @@ -17,9 +19,11 @@ import ( corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" + "github.com/bootc-dev/bootc-operator/internal/config" "github.com/bootc-dev/bootc-operator/test/e2e/e2eutil" testutil "github.com/bootc-dev/bootc-operator/test/util" ) @@ -29,6 +33,7 @@ const helmReleaseName = "bootc-operator" var operatorCRDNames = []string{ "bootcnodepools.node.bootc.dev", "bootcnodes.node.bootc.dev", + "bootcoperatorconfigs.node.bootc.dev", } // TestOperatorUpgrade installs the released version of the operator @@ -66,21 +71,29 @@ func TestOperatorUpgrade(t *testing.T) { t.Logf("Current operator image: %s", currentImg) t.Logf("Released operator tag: %s, image: %s", releaseTag, releasedImg) - // Phase 1: Uninstall the current operator and re-install with - // the released image via Helm. - uninstallOperator(t, g, ctx, env) - helmInstallOperator(t, chartPath, releasedImg, extraArgs) - waitForOperatorReady(t, g, ctx, env.Client) - - t.Logf("Released operator %s installed via Helm", releaseTag) - + originalConfig, err := readOperatorConfig(ctx, env.Client) + g.Expect(err).NotTo(HaveOccurred()) + // Uninstall removes CRDs and their instances. Register recovery first. t.Cleanup(func() { t.Logf("Restoring operator to current version...") helmUpgradeOperator(t, chartPath, currentImg, extraArgs) + waitForOperatorConfigCRD(t, g, ctx, env.Client) + g.Expect(restoreOperatorConfig(ctx, env.Client, originalConfig)).To(Succeed(), + "restore administrator-owned operator configuration") + restartOperator(t) waitForOperatorReady(t, g, ctx, env.Client) t.Logf("Operator restored") }) + // Phase 1: Replace the current Helm release with the released image. + uninstallOperator(t, g, ctx, env) + // The released image predates BootcOperatorConfig and needs bink's + // registry settings through its command-line flags. + releasedArgs := releasedControllerArgs(originalConfig, extraArgs) + helmInstallOperator(t, chartPath, releasedImg, releasedArgs) + waitForOperatorReady(t, g, ctx, env.Client) + t.Logf("Released operator %s installed via Helm", releaseTag) + // Phase 2: Verify the released version works. nodeName := env.AddNode(t) @@ -91,8 +104,17 @@ func TestOperatorUpgrade(t *testing.T) { t.Logf("Node %q is Idle with released operator", nodeName) - // Phase 3: Upgrade via helm upgrade to the current version. + // Phase 3: Upgrade via Helm to the current version. helmUpgradeOperator(t, chartPath, currentImg, extraArgs) + waitForOperatorConfigCRD(t, g, ctx, env.Client) + config, err := readOperatorConfig(ctx, env.Client) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(config).To(BeNil(), "installation must not create a configuration instance") + g.Expect(restoreOperatorConfig(ctx, env.Client, originalConfig)).To(Succeed(), + "restore administrator-owned operator configuration before upgrade checks") + if originalConfig != nil { + restartOperator(t) + } waitForOperatorReady(t, g, ctx, env.Client) t.Logf("Upgraded operator to current version via Helm") @@ -128,8 +150,7 @@ func TestOperatorUpgrade(t *testing.T) { // uninstallOperator removes the operator. Custom resources are deleted // first while the operator is still running so it can handle finalizer -// removal. CRDs are bundled as a subchart dependency so they are -// removed together with the main release. +// removal. The test also removes a standalone CRD release if present. func uninstallOperator( t *testing.T, g Gomega, @@ -194,7 +215,8 @@ func helmInstallOperator(t *testing.T, chartPath, img string, extraArgs []string helmRun(t, args...) } -// helmUpgradeOperator upgrades the operator via helm upgrade. +// helmUpgradeOperator upgrades the operator, installing it if cleanup follows +// a failed release installation. func helmUpgradeOperator(t *testing.T, chartPath, img string, extraArgs []string) { t.Helper() args := []string{ @@ -276,6 +298,63 @@ func splitImage(ref string) (repo, tag string) { return ref, "latest" } +// releasedControllerArgs carries CR settings to releases that only support CLI +// flags. Explicit Deployment arguments come last and retain precedence. +func releasedControllerArgs(cfg *bootcv1alpha1.BootcOperatorConfig, explicit []string) []string { + var args []string + if cfg != nil && cfg.Spec.Controller != nil { + c := cfg.Spec.Controller + if c.AllowInsecureRegistry != nil { + args = append( + args, + fmt.Sprintf("--%s=%t", config.AllowInsecureRegistryFlag, *c.AllowInsecureRegistry), + ) + } + if c.TagResolutionPeriodSeconds != nil { + period := time.Duration(*c.TagResolutionPeriodSeconds) * time.Second + args = append(args, "--"+config.TagResolutionIntervalFlag+"="+period.String()) + } + } + return append(args, explicit...) +} + +func TestReleasedControllerArgs(t *testing.T) { + allowInsecure, seconds := true, int32(17) + cfg := &bootcv1alpha1.BootcOperatorConfig{ + Spec: bootcv1alpha1.BootcOperatorConfigSpec{ + Controller: &bootcv1alpha1.OperatorControllerConfig{ + AllowInsecureRegistry: &allowInsecure, TagResolutionPeriodSeconds: &seconds, + }, + }, + } + for _, tt := range []struct { + name string + cfg *bootcv1alpha1.BootcOperatorConfig + explicit []string + wantHTTP bool + wantWait time.Duration + }{ + {name: "absent config", wantWait: 5 * time.Minute}, + {name: "omitted fields", cfg: &bootcv1alpha1.BootcOperatorConfig{}, wantWait: 5 * time.Minute}, + {name: "saved settings", cfg: cfg, wantHTTP: true, wantWait: 17 * time.Second}, + { + name: "explicit arguments win", cfg: cfg, + explicit: []string{"--allow-insecure-registry=false", "--tag-resolution-interval=1500ms"}, + wantWait: 1500 * time.Millisecond, + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + fs := flag.NewFlagSet(tt.name, flag.ContinueOnError) + http := fs.Bool(config.AllowInsecureRegistryFlag, false, "") + wait := fs.Duration(config.TagResolutionIntervalFlag, 5*time.Minute, "") + g.Expect(fs.Parse(releasedControllerArgs(tt.cfg, tt.explicit))).To(Succeed()) + g.Expect(*http).To(Equal(tt.wantHTTP)) + g.Expect(*wait).To(Equal(tt.wantWait)) + }) + } +} + // controllerExtraArgs returns the controller's extra arguments, // filtering out the flags that the Helm chart always includes. func controllerExtraArgs(t *testing.T, containers []corev1.Container) []string { @@ -317,6 +396,106 @@ func waitForOperatorReady( "expected operator deployment to be ready") } +// readOperatorConfig returns the optional administrator-owned instance. An +// ambiguous configuration should fail the upgrade instead of being discarded. +func readOperatorConfig( + ctx context.Context, + c client.Client, +) (*bootcv1alpha1.BootcOperatorConfig, error) { + var configs bootcv1alpha1.BootcOperatorConfigList + if err := c.List(ctx, &configs); err != nil { + return nil, fmt.Errorf("list operator configurations: %w", err) + } + switch len(configs.Items) { + case 0: + return nil, nil + case 1: + return configs.Items[0].DeepCopy(), nil + default: + return nil, fmt.Errorf( + "expected at most one operator configuration, found %d", + len(configs.Items), + ) + } +} + +// restoreOperatorConfig restores only administrator-owned fields after CRD +// deletion. A different instance name is an error rather than an implicit rename. +func restoreOperatorConfig( + ctx context.Context, + c client.Client, + original *bootcv1alpha1.BootcOperatorConfig, +) error { + current, err := readOperatorConfig(ctx, c) + if err != nil { + return err + } + if original == nil { + if current == nil { + return nil + } + return c.Delete(ctx, current) + } + if current != nil && current.Name != original.Name { + return fmt.Errorf( + "restore operator configuration %q: unexpected current instance %q", + original.Name, + current.Name, + ) + } + if current == nil { + restored := original.DeepCopy() + restored.ObjectMeta = metav1.ObjectMeta{ + Name: original.Name, + Labels: original.Labels, + Annotations: original.Annotations, + } + return c.Create(ctx, restored) + } + current.Spec = original.Spec + current.Labels = original.Labels + current.Annotations = original.Annotations + return c.Update(ctx, current) +} + +func waitForOperatorConfigCRD(t *testing.T, g Gomega, ctx context.Context, c client.Client) { + t.Helper() + g.Eventually(func() ([]apiextensionsv1.CustomResourceDefinitionCondition, error) { + var crd apiextensionsv1.CustomResourceDefinition + err := c.Get(ctx, client.ObjectKey{Name: "bootcoperatorconfigs.node.bootc.dev"}, &crd) + return crd.Status.Conditions, err + }).WithTimeout(time.Minute).Should(ContainElement(And( + HaveField("Type", apiextensionsv1.Established), + HaveField("Status", apiextensionsv1.ConditionTrue), + )), "waiting for the operator configuration API to be established") +} + +// Both components may have started with defaults before configuration was +// restored. Restart and await each workload so later tests see the saved values. +func restartOperator(t *testing.T) { + t.Helper() + workloads := []string{ + "deployment/" + operatorDeployKey().Name, + "daemonset/" + operatorDaemonSetKey().Name, + } + for _, workload := range workloads { + for _, command := range [][]string{ + {"rollout", "restart", workload}, + {"rollout", "status", workload, "--timeout=3m"}, + } { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + args := append([]string{ + "--kubeconfig", os.Getenv("KUBECONFIG"), "-n", testutil.OperatorNamespaceName, + }, command...) + out, err := exec.CommandContext(ctx, "kubectl", args...).CombinedOutput() + cancel() + if err != nil { + t.Fatalf("restart operator workload %s: %v\n%s", workload, err, out) + } + } + } +} + // operatorDeployKey returns the namespaced name of the operator Deployment. func operatorDeployKey() client.ObjectKey { return client.ObjectKey{ From 41fc1bb570288c49bce455fb8921068c39fca777 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 10/14] docs: Explain applying and reverting operator configuration Give administrators a concrete example and document the single-instance rule, CLI precedence, manual restarts, and upgrade-safe ownership before they adopt the new API. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- README.md | 6 + .../bootc_v1alpha1_bootcoperatorconfig.yaml | 10 ++ docs/src/SUMMARY.md | 1 + docs/src/operations/configuration.md | 142 ++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml create mode 100644 docs/src/operations/configuration.md diff --git a/README.md b/README.md index 20c09df0..97bf1d4d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,12 @@ kubectl apply -k https://github.com/bootc-dev/bootc-operator//config/default This creates the `bootc-operator` namespace and deploys the controller and daemon. The operator does nothing until you create a BootcNodePool. +To configure the controller and daemon with an optional +`BootcOperatorConfig` resource, see +[Configuring the operator](docs/src/operations/configuration.md). Releases +publish an editable `operator-config.yaml` example separately from +`install.yaml`. + > [!NOTE] > The operator namespace requires a [Pod Security Admission] exemption for the > `privileged` level. The daemon DaemonSet runs privileged to execute bootc diff --git a/config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml b/config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml new file mode 100644 index 00000000..c226059e --- /dev/null +++ b/config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml @@ -0,0 +1,10 @@ +apiVersion: node.bootc.dev/v1alpha1 +kind: BootcOperatorConfig +metadata: + name: cluster +spec: + controller: + allowInsecureRegistry: false + tagResolutionPeriodSeconds: 300 + daemon: + statusPollPeriodSeconds: 300 diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fbfaf433..1e990ba4 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -7,4 +7,5 @@ - [Concepts](concepts.md) - [Helm Chart](helm.md) - [Operations](operations/index.md) + - [Configuring the operator](operations/configuration.md) - [Managing a pool](operations/pool.md) diff --git a/docs/src/operations/configuration.md b/docs/src/operations/configuration.md new file mode 100644 index 00000000..835174a6 --- /dev/null +++ b/docs/src/operations/configuration.md @@ -0,0 +1,142 @@ +# Configuring the operator + +Configure the controller and daemon with a cluster-scoped +`BootcOperatorConfig`. Its typed `node.bootc.dev/v1alpha1` API keeps +administrator settings separate from installation manifests. Choose any valid +Kubernetes resource name; use at most one configuration in the cluster. + +For example: + +```yaml +apiVersion: node.bootc.dev/v1alpha1 +kind: BootcOperatorConfig +metadata: + name: cluster +spec: + controller: + tagResolutionPeriodSeconds: 60 + daemon: + statusPollPeriodSeconds: 60 +``` + +The operator installation includes the CRD and read permissions. The +configuration instance is optional, administrator-owned, and is not created or +overwritten by the default installation. With no instance, the controller and +daemon use their existing defaults and explicit command-line arguments. +`cluster` is only the example name; the resource has no namespace. If more +than one instance exists, both processes stop at startup until an +administrator removes the extras. + +## Supported settings + +| Field under spec | Component | Default | +| --- | --- | --- | +| `controller.allowInsecureRegistry` | Controller | `false` | +| `controller.tagResolutionPeriodSeconds` | Controller | `300` (5 minutes) | +| `daemon.statusPollPeriodSeconds` | Daemon | `300` (5 minutes) | + +Period values are positive whole seconds. Invalid types and zero or negative +periods are rejected by the API server. An empty `spec: {}` uses all defaults; +either component section may be omitted. +Use YAML booleans and integers directly, without string quotes. + +`allowInsecureRegistry` lets the controller's tag resolver fall back to HTTP +for registries without TLS. The bink development workflow enables it for its +local registry. Registry credentials continue to use each pool's +`pullSecretRef`. + +The daemon normally observes bootc status through filesystem notifications. +`statusPollPeriodSeconds` controls fallback polling when those notifications +are unavailable. + +## Applying configuration + +To use custom settings on the first startup with Helm, follow +[Configure before first startup](../helm.md#configure-before-first-startup). + +After installing or upgrading the operator, check for an existing configuration: + +```shell +kubectl get bootcoperatorconfigs +``` + +If none exists, edit and apply the sample: + +```shell +kubectl apply -f config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml +kubectl get bootcoperatorconfig cluster -o yaml +``` + +The sample contains the existing defaults (`false`, `300`, and `300`); edit it +to set the values you need before applying it. If a configuration already +exists, edit that resource instead of creating another. The sample name +`cluster` can be changed. +Reapplying `install.yaml` will not overwrite the configuration. + +Releases also provide a separate, optional `operator-config.yaml` asset with +the same default values. On a released installation, replace `vX.Y.Z` with +the installed release tag, then download and edit the file before applying it: + +```shell +curl -fL -o operator-config.yaml \ + https://github.com/bootc-dev/bootc-operator/releases/download/vX.Y.Z/operator-config.yaml +# Edit operator-config.yaml, then: +kubectl apply -f operator-config.yaml +``` + +Check for an existing instance first as shown above. The optional asset is +not part of `install.yaml`, so routine operator upgrades leave administrator +settings alone. + +The configuration is read once when each controller or daemon process starts. +Editing the resource does not restart pods or change running processes. +Restart the workload whose settings changed. The example above changes both: + +```shell +kubectl -n bootc-operator rollout restart deployment/bootc-operator-controller-manager +kubectl -n bootc-operator rollout status deployment/bootc-operator-controller-manager --timeout=3m +kubectl -n bootc-operator rollout restart daemonset/bootc-operator-daemon +kubectl -n bootc-operator rollout status daemonset/bootc-operator-daemon --timeout=3m +``` + +For Helm installations, use `deployment/bootc-operator-controller` instead of +`deployment/bootc-operator-controller-manager` in the restart and status commands. +Use the workload names and namespace from your installation if customized. +Daemon pods run only on nodes selected by a pool. New pods read the current +configuration automatically. Restarting the controller alone does not +reconfigure existing daemon pods. + +## Precedence and startup failures + +For each supported setting, the value is selected in this order: + +1. An explicitly supplied command-line flag. +2. The corresponding configuration field. +3. The existing default when configuration is absent. + +An explicit `--allow-insecure-registry=false` overrides a resource that sets +`allowInsecureRegistry: true`. Existing duration flags keep their duration +syntax, including fractional seconds, independently of the whole-second API +fields. + +More than one configuration, a missing CRD, denied API access, or a connection +failure stops startup with an error. Remove extra configurations, install the +matching CRD and permissions, or restore API connectivity before restarting. +A running process keeps its loaded settings during a later API outage. + +## Reverting and upgrading + +Restore a field's previous value, or remove it to use its schema default, +then restart the affected workload. To remove all overrides from this resource: + +```shell +kubectl delete bootcoperatorconfig cluster +``` + +Replace `cluster` with the resource's actual name if you used a different one. +Then restart the affected controller and daemon pods as above. Deletion alone +does not change running processes. Explicit command-line flags still apply. + +Reapplying installation manifests during an ordinary upgrade preserves the +configuration resource. For Helm installation and removal behavior, see the +[Helm guide](../helm.md). From 227d50650058287e92125cd551295d0579f4401a Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 11/14] helm: Integrate configuration with the CRD subchart Document installing CRDs, applying administrator configuration, then installing the operator so custom settings apply on first startup. Explain upgrades and removal for combined and separate Helm releases. Follow the CRD subchart lifecycle introduced by #234, removing the old generated directory so existing checkouts do not package duplicate CRDs. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Refs: https://github.com/bootc-dev/bootc-operator/pull/234 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- docs/src/helm.md | 71 ++++++++++++++++++++++++++++++++++++++++------- hack/sync-helm.sh | 2 ++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/docs/src/helm.md b/docs/src/helm.md index 91a73e49..077ae894 100644 --- a/docs/src/helm.md +++ b/docs/src/helm.md @@ -26,27 +26,63 @@ helm install bootc-operator oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc- --version 0.1.0 --create-namespace --namespace bootc-operator ``` -Alternatively, install from a local checkout: +Alternatively, generate and install the chart from a local checkout: ```bash +make helm helm dependency build ./chart/bootc-operator helm install bootc-operator ./chart/bootc-operator \ --create-namespace --namespace bootc-operator ``` -To manage CRDs separately (e.g. with a GitOps tool), disable the -dependency and install the CRD chart on its own: - -```bash -helm install bootc-operator-crds oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc-operator-crds -helm install bootc-operator oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc-operator \ - --create-namespace --namespace bootc-operator --set crds.install=false -``` +The chart installs the configuration CRD and the operator's read permissions. +It does not create a `BootcOperatorConfig` instance. See +[Configuring the operator](operations/configuration.md) to add one. By default the chart uses the image `ghcr.io/bootc-dev/bootc-operator:` where `appVersion` is defined in `Chart.yaml`. +### Configure before first startup + +For custom settings on first startup, install the CRDs and operator as separate +Helm releases. This procedure is for a fresh installation; do not use it to +transfer CRDs from an existing combined release. + +1. Install the CRDs. Replace `X.Y.Z` with a published chart version containing + `BootcOperatorConfig`, and use that version for both charts: + + ```bash + CHART_VERSION=X.Y.Z + helm install bootc-operator-crds \ + oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc-operator-crds \ + --version "$CHART_VERSION" --create-namespace --namespace bootc-operator + kubectl wait --for=condition=Established \ + crd/bootcoperatorconfigs.node.bootc.dev --timeout=1m + ``` + +2. Check for an existing configuration. If one exists, edit it instead of + creating another. Otherwise, prepare an `operator-config.yaml` using the + [configuration example](operations/configuration.md), set the desired + values, and apply it: + + ```bash + kubectl get bootcoperatorconfigs + kubectl apply -f operator-config.yaml + ``` + +3. Install the operator with its CRD dependency disabled, since the separate + release already owns the CRDs: + + ```bash + helm install bootc-operator \ + oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc-operator \ + --version "$CHART_VERSION" --namespace bootc-operator --set crds.install=false + ``` + +The controller and daemon read the configuration on their first startup. +Later configuration edits still require restarting the affected workloads. + ## Custom image Override the repository and tag to use a different image: @@ -112,6 +148,17 @@ helm upgrade bootc-operator oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc- Replace `` with the chart version (e.g. `0.2.0`). The CRD sub-chart is upgraded automatically. +If you installed CRDs separately, upgrade their release first, then upgrade the +operator with `--set crds.install=false`. Use the same target chart version for +both commands: + +```bash +helm upgrade bootc-operator-crds oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc-operator-crds \ + --version --namespace bootc-operator +helm upgrade bootc-operator oci://ghcr.io/bootc-dev/bootc-operator/charts/bootc-operator \ + --version --namespace bootc-operator --set crds.install=false +``` + ## Uninstall ```bash @@ -119,7 +166,11 @@ helm uninstall bootc-operator --namespace bootc-operator ``` > **Warning**: uninstalling the chart also removes the CRDs and all -> their instances cluster-wide. +> their instances cluster-wide, including administrator-owned +> `BootcOperatorConfig` settings. This applies when the CRD dependency is enabled +> (the default). With separate releases, uninstalling only `bootc-operator` +> preserves the CRDs and configuration. Uninstalling `bootc-operator-crds` +> deletes the CRDs and all their instances. ## Regenerating the chart diff --git a/hack/sync-helm.sh b/hack/sync-helm.sh index f55df976..39fc1fb7 100755 --- a/hack/sync-helm.sh +++ b/hack/sync-helm.sh @@ -16,6 +16,8 @@ command -v "$YQ" >/dev/null 2>&1 || { echo "error: yq required (run: make yq)" > # --- CRDs (separate chart) --- mkdir -p "$CRD_CHART_DIR/templates" cp "$CONFIG_DIR"/crd/bases/*.yaml "$CRD_CHART_DIR/templates/" +# Remove generated CRDs from the old layout to avoid packaging them twice. +rm -rf -- "$CHART_DIR/crds" echo "Synced CRDs" # --- RBAC --- From 7d0e2d9900bf7dda99625d5ae7d587f509d45f22 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 12/14] release: Generate a separate operator configuration asset Provide an editable example without putting an administrator-owned instance into install.yaml, so routine installs and upgrades cannot overwrite configuration. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- .gitignore | 2 ++ Makefile | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 01dc05d8..15860136 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ bin/ kubeconfig-* docs/book/ user-docs +/install.yaml +/operator-config.yaml chart/bootc-operator/crds/ chart/bootc-operator/charts/ chart/bootc-operator/Chart.lock diff --git a/Makefile b/Makefile index ac151540..e78915bb 100644 --- a/Makefile +++ b/Makefile @@ -172,11 +172,12 @@ buildimg: ## Build container image. $(CONTAINER_TOOL) build -t $(IMG) . .PHONY: release-manifest -release-manifest: kustomize yq ## Build install manifest (override IMG to set the image reference). +release-manifest: kustomize yq ## Build install manifest and optional configuration asset (override IMG). "$(KUSTOMIZE)" build config/default | \ "$(YQ)" '(select(.kind == "Deployment") | .spec.template.spec.containers[] | select(.name == "manager")).image = "$(IMG)"' | \ "$(YQ)" '(select(.kind == "DaemonSet") | .spec.template.spec.containers[] | select(.name == "daemon")).image = "$(IMG)"' \ > install.yaml + cp config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml operator-config.yaml .PHONY: build-update-image build-update-image: ## Build derived node images for update testing and push to bink registry. From df81aa38a3d05cbe5d771a90e7babb3f370cad0b Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 13/14] release: Verify generated configuration assets in CI Keep the downloadable example aligned with API defaults and ensure install.yaml ships the CRD without creating a configuration instance. Run the checks on every PR. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- .github/workflows/ci.yaml | 3 +++ Makefile | 9 ++++++++ internal/controller/operatorconfig_test.go | 25 ++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 864fc603..fb50bb0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,6 +42,9 @@ jobs: - name: Unit tests run: make unit + - name: Check release assets + run: make verify-release-manifest + - name: Vet run: make vet diff --git a/Makefile b/Makefile index e78915bb..f94d7e19 100644 --- a/Makefile +++ b/Makefile @@ -179,6 +179,15 @@ release-manifest: kustomize yq ## Build install manifest and optional configurat > install.yaml cp config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml operator-config.yaml +.PHONY: verify-release-manifest +verify-release-manifest: release-manifest ## Check the generated release assets. + @cmp -s config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml operator-config.yaml || \ + { echo "operator-config.yaml differs from the validated example"; exit 1; } + @test "$$("$(YQ)" ea '[.] | map(select(.kind == "CustomResourceDefinition" and .metadata.name == "bootcoperatorconfigs.node.bootc.dev")) | length' install.yaml)" -eq 1 || \ + { echo "install.yaml must contain the BootcOperatorConfig CRD exactly once"; exit 1; } + @test "$$("$(YQ)" ea '[.] | map(select(.kind == "BootcOperatorConfig")) | length' install.yaml)" -eq 0 || \ + { echo "install.yaml must not create an administrator-owned BootcOperatorConfig"; exit 1; } + .PHONY: build-update-image build-update-image: ## Build derived node images for update testing and push to bink registry. @printf 'FROM localhost:5000/node:latest\nRUN touch /usr/share/update-marker\n' | \ diff --git a/internal/controller/operatorconfig_test.go b/internal/controller/operatorconfig_test.go index 063f8c9c..9f3f6c71 100644 --- a/internal/controller/operatorconfig_test.go +++ b/internal/controller/operatorconfig_test.go @@ -5,6 +5,7 @@ package controller import ( "context" "encoding/json" + "os" "testing" . "github.com/onsi/gomega" @@ -14,6 +15,7 @@ import ( "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" + sigsyaml "sigs.k8s.io/yaml" bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" operatorconfig "github.com/bootc-dev/bootc-operator/internal/config" @@ -64,6 +66,29 @@ func TestBootcOperatorConfigDefaults(t *testing.T) { } } +func TestBootcOperatorConfigReleaseExample(t *testing.T) { + g := NewWithT(t) + data, err := os.ReadFile("../../config/samples/bootc_v1alpha1_bootcoperatorconfig.yaml") + g.Expect(err).NotTo(HaveOccurred()) + + var example bootcv1alpha1.BootcOperatorConfig + g.Expect(sigsyaml.UnmarshalStrict(data, &example)).To(Succeed()) + g.Expect(example.APIVersion).To(Equal(bootcv1alpha1.GroupVersion.String())) + g.Expect(example.Kind).To(Equal("BootcOperatorConfig")) + g.Expect(example.Name).To(Equal("cluster")) + g.Expect(example.Spec).To(Equal(bootcv1alpha1.BootcOperatorConfigSpec{ + Controller: &bootcv1alpha1.OperatorControllerConfig{ + AllowInsecureRegistry: ptr.To(false), + TagResolutionPeriodSeconds: ptr.To(bootcv1alpha1.DefaultTagResolutionPeriodSeconds), + }, + Daemon: &bootcv1alpha1.OperatorDaemonConfig{ + StatusPollPeriodSeconds: ptr.To(bootcv1alpha1.DefaultStatusPollPeriodSeconds), + }, + })) + g.Expect(k8sClient.Create(context.Background(), &example, + client.DryRunAll, client.FieldValidation(metav1.FieldValidationStrict))).To(Succeed()) +} + func TestBootcOperatorConfigValidation(t *testing.T) { for _, tc := range []struct { name string From 76867d9759f3bf2d2963a9050f5646bf5eb1c70e Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Tue, 6 Oct 2026 13:14:34 +0530 Subject: [PATCH 14/14] release: Publish the optional configuration example Make the versioned API discoverable alongside each release. Validate both assets before publishing and direct administrators to check for an existing instance before applying the example. Refs: https://github.com/bootc-dev/bootc-operator/issues/92 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- .github/workflows/release.yaml | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c8556aca..a3b90bd5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -83,6 +83,17 @@ jobs: echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" echo "tag=v${VERSION}" >> "${GITHUB_OUTPUT}" + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true # zizmor: ignore[cache-poisoning] + + - name: Build and check release manifests + env: + TAG: ${{ steps.version.outputs.tag }} + run: make verify-release-manifest IMG="${IMAGE}:${TAG}" + - name: Create and push tag env: TAG: ${{ steps.version.outputs.tag }} @@ -114,12 +125,6 @@ jobs: exit 1 fi - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - cache: true # zizmor: ignore[cache-poisoning] - - name: Build and push container image env: TAG: ${{ steps.version.outputs.tag }} @@ -130,11 +135,6 @@ jobs: podman login -u "${ACTOR}" -p "${GH_TOKEN}" ghcr.io podman push "${IMAGE}:${TAG}" - - name: Build install manifest - env: - TAG: ${{ steps.version.outputs.tag }} - run: make release-manifest IMG="${IMAGE}:${TAG}" - - name: Package and push Helm charts env: VERSION: ${{ steps.version.outputs.version }} @@ -169,6 +169,11 @@ jobs: printf 'kubectl apply -f https://github.com/%s/releases/download/%s/install.yaml\n' \ "${GITHUB_REPOSITORY}" "${TAG}" printf '```\n\n' + printf 'Optional configuration example: [operator-config.yaml](https://github.com/%s/releases/download/%s/operator-config.yaml). ' \ + "${GITHUB_REPOSITORY}" "${TAG}" + printf 'Before applying it, check for an existing instance; see the [configuration guide](https://github.com/%s/blob/%s/docs/src/operations/configuration.md).\n' \ + "${GITHUB_REPOSITORY}" "${TAG}" + printf '\n' printf '### Helm\n\n' printf '```bash\n' printf 'helm install bootc-operator oci://ghcr.io/%s/charts/bootc-operator --version %s \\\n' \ @@ -181,4 +186,4 @@ jobs: --notes-file notes.md \ --generate-notes \ --draft \ - install.yaml + install.yaml operator-config.yaml