From 1e4a0a233b2b90f5630023474c627d5f5f9e8add Mon Sep 17 00:00:00 2001 From: tithakka Date: Fri, 21 Aug 2026 13:00:06 -0500 Subject: [PATCH] HYPERFLEET-1406 - feat: implement the bundle controller and reconcile the API component --- cmd/main.go | 17 +- config/manager/manager.yaml | 12 + config/rbac/role.yaml | 36 ++ internal/apply/apply.go | 68 ++++ internal/bundle/bundle.go | 75 ++++ internal/component/api/api.go | 86 +++++ internal/component/api/api_test.go | 184 ++++++++++ internal/component/api/render.go | 340 ++++++++++++++++++ .../controller/hyperfleetconfig_controller.go | 80 ++++- .../hyperfleetconfig_controller_test.go | 218 +++++++++-- 10 files changed, 1065 insertions(+), 51 deletions(-) create mode 100644 internal/apply/apply.go create mode 100644 internal/bundle/bundle.go create mode 100644 internal/component/api/api.go create mode 100644 internal/component/api/api_test.go create mode 100644 internal/component/api/render.go diff --git a/cmd/main.go b/cmd/main.go index b3eb955..8404c2e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -202,9 +202,22 @@ func main() { os.Exit(1) } + // operatorNamespace is where all operands are created. It comes from the + // downward API (POD_NAMESPACE) in-cluster; the fallback keeps `make run` and + // local development working when the env var is absent. The fallback must + // match the deploy namespace in config/default/kustomization.yaml. + operatorNamespace := os.Getenv("POD_NAMESPACE") + if operatorNamespace == "" { + operatorNamespace = "hyperfleet-operator-system" + setupLog.Info("POD_NAMESPACE not set; falling back to default operator namespace", + "namespace", operatorNamespace) + } + if err := (&controller.HyperFleetConfigReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + OperatorNamespace: operatorNamespace, + APIImage: os.Getenv("RELATED_IMAGE_HYPERFLEET_API"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "HyperFleetConfig") os.Exit(1) diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 043a339..12a4062 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -65,6 +65,18 @@ spec: - --health-probe-bind-address=:8081 image: controller:latest name: manager + env: + # POD_NAMESPACE tells the operator which namespace to create operands in + # (the operator's own namespace). Sourced from the downward API. + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + # RELATED_IMAGE_HYPERFLEET_API is the image used for the API operand. + # Follows the OLM relatedImages convention so it can be digest-pinned at + # bundle build time; override per environment as needed. + - name: RELATED_IMAGE_HYPERFLEET_API + value: quay.io/openshift-hyperfleet/hyperfleet-api:latest ports: [] securityContext: allowPrivilegeEscalation: false diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e3e10d9..4874875 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,30 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + - serviceaccounts + - services + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - get + - list + - patch + - update + - watch - apiGroups: - hyperfleet.redhat.com resources: @@ -30,3 +54,15 @@ rules: - get - patch - update +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - get + - list + - patch + - update + - watch diff --git a/internal/apply/apply.go b/internal/apply/apply.go new file mode 100644 index 0000000..f97ab03 --- /dev/null +++ b/internal/apply/apply.go @@ -0,0 +1,68 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package apply centralizes the idempotent server-side-apply upsert used to +// reconcile operands: stamp a controller owner reference, then apply. +package apply + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + hyperfleetv1alpha1 "github.com/openshift-hyperfleet/hyperfleet-operator/api/v1alpha1" +) + +// FieldManager is the server-side-apply field owner stamped on every operand the +// operator manages. It lets the API server distinguish operator-owned fields from +// any a human sets, which is what makes ForceOwnership drift-correction safe: the +// operator is the sole intended manager of these fields. +const FieldManager = "hyperfleet-operator" + +// Objects upserts each desired operand via server-side apply. For every object it: +// +// 1. sets a controller owner reference back to the CR, so the object is +// garbage-collected with the CR and wakes the controller via its Owns() watch +// when it drifts (a cluster-scoped owner owning namespaced dependents is +// valid — the owner has no namespace); +// 2. applies the object with a fixed field manager and ForceOwnership, so +// re-applying identical state is a no-op (idempotent) while out-of-band edits +// to operator-owned fields are reclaimed. +// +// Each object must carry its GVK (TypeMeta) — server-side apply requires it. +// Errors are wrapped with the object's kind and name so the caller can see which +// operand failed. +func Objects( + ctx context.Context, + c client.Client, + owner *hyperfleetv1alpha1.HyperFleetConfig, + scheme *runtime.Scheme, + objs []client.Object, +) error { + for _, obj := range objs { + kind := obj.GetObjectKind().GroupVersionKind().Kind + if err := ctrl.SetControllerReference(owner, obj, scheme); err != nil { + return fmt.Errorf("set controller reference on %s %q: %w", kind, obj.GetName(), err) + } + if err := c.Patch(ctx, obj, client.Apply, client.FieldOwner(FieldManager), client.ForceOwnership); err != nil { + return fmt.Errorf("apply %s %q: %w", kind, obj.GetName(), err) + } + } + return nil +} diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go new file mode 100644 index 0000000..11e6e09 --- /dev/null +++ b/internal/bundle/bundle.go @@ -0,0 +1,75 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package bundle defines the component contract and resolves a bundle to its +// ordered component set. It is the in-operator "bundle definition": adding a +// component later is one new entry here plus its own package — never a new +// controller. +package bundle + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + hyperfleetv1alpha1 "github.com/openshift-hyperfleet/hyperfleet-operator/api/v1alpha1" + "github.com/openshift-hyperfleet/hyperfleet-operator/internal/component/api" +) + +// Component is the contract every component satisfies. It is deliberately tiny — +// render the desired objects, report health — with no extension points until a +// second component exists to justify them. +// +// - Render is a pure function (CR → desired objects); it must not read or write +// the cluster. The controller applies what it returns. +// - Conditions reports component health. It is consumed starting in +// HYPERFLEET-1409; until then the controller does not roll it into status. +type Component interface { + Name() string + Render(ctx context.Context, cr *hyperfleetv1alpha1.HyperFleetConfig) ([]client.Object, error) + Conditions(ctx context.Context, cr *hyperfleetv1alpha1.HyperFleetConfig) ([]metav1.Condition, error) +} + +// Config carries the inputs the resolver needs to construct components. +type Config struct { + // APIImage is the image for the API component (empty → its compiled-in default). + APIImage string + // Namespace is the operator's own namespace, where operands are created. + Namespace string +} + +// sharedTier lists the components present in every bundle regardless of flavor. +// In phase 1 this is exactly [API], so every bundle resolves to [API]. +func sharedTier(cfg Config) []Component { + return []Component{ + api.New(cfg.APIImage, cfg.Namespace), + } +} + +// bundleSpecific returns the components unique to a bundle beyond the shared +// tier. Phase 1 has none for either bundle; this is the single extension point +// where a future bundle-specific component is registered. +func bundleSpecific(_ hyperfleetv1alpha1.BundleType) []Component { + return nil +} + +// Resolve maps a bundle to its ordered component set: the shared tier followed by +// any bundle-specific components. The shared tier is first so its components +// (currently the API) reconcile before anything that might depend on them. +func Resolve(b hyperfleetv1alpha1.BundleType, cfg Config) []Component { + return append(sharedTier(cfg), bundleSpecific(b)...) +} diff --git a/internal/component/api/api.go b/internal/component/api/api.go new file mode 100644 index 0000000..197c71f --- /dev/null +++ b/internal/component/api/api.go @@ -0,0 +1,86 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + hyperfleetv1alpha1 "github.com/openshift-hyperfleet/hyperfleet-operator/api/v1alpha1" +) + +// DefaultImage is the compiled-in fallback image used when the operator is not +// given RELATED_IMAGE_HYPERFLEET_API. Production deployments override it with a +// digest-pinned image via that env var (OLM relatedImages convention). +const DefaultImage = "quay.io/openshift-hyperfleet/hyperfleet-api:latest" + +// Component renders the HyperFleet API operand. It satisfies the bundle.Component +// contract structurally (no import of internal/bundle, avoiding an import cycle: +// bundle imports this package). It carries only rendering inputs — the Image and +// the target Namespace — and never touches the cluster; the controller applies +// what Render produces. +type Component struct { + // Image is the API container image. Empty falls back to DefaultImage. + Image string + // Namespace is the operator's own namespace, where all operands live. + Namespace string +} + +// New constructs the API component for the given image and operator namespace. +func New(image, namespace string) *Component { + return &Component{Image: image, Namespace: namespace} +} + +// Name identifies the component in logs and the app.kubernetes.io/component label. +func (c *Component) Name() string { + return ComponentName +} + +// Render returns the full desired-state operand set for the API, in a +// dependency-friendly order (identity and RBAC before the workload that uses +// them). It is a pure function of its inputs: no cluster reads or writes. +// +// The returned objects are structurally complete but not yet configured from the +// CR spec — database/auth/tls env, config-file content and profile→resources are +// wired in HYPERFLEET-1408. The ctx is part of the contract (later components may +// need it) but is unused here. +func (c *Component) Render(_ context.Context, cr *hyperfleetv1alpha1.HyperFleetConfig) ([]client.Object, error) { + image := c.Image + if image == "" { + image = DefaultImage + } + + return []client.Object{ + serviceAccount(cr, c.Namespace), + role(cr, c.Namespace), + roleBinding(cr, c.Namespace), + configMap(cr, c.Namespace), + service(cr, c.Namespace), + deployment(cr, image, c.Namespace), + }, nil +} + +// Conditions reports the component's health as metav1.Conditions. The contract is +// defined now (HYPERFLEET-1407) so it need not be reopened next story, but its +// output is not yet rolled up into status.conditions — real health derivation and +// status wiring land in HYPERFLEET-1409. Returning nil until then keeps the +// reconciler from writing status prematurely. +func (c *Component) Conditions(_ context.Context, _ *hyperfleetv1alpha1.HyperFleetConfig) ([]metav1.Condition, error) { + return nil, nil +} diff --git a/internal/component/api/api_test.go b/internal/component/api/api_test.go new file mode 100644 index 0000000..ba2b8f8 --- /dev/null +++ b/internal/component/api/api_test.go @@ -0,0 +1,184 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + hyperfleetv1alpha1 "github.com/openshift-hyperfleet/hyperfleet-operator/api/v1alpha1" +) + +const testNamespace = "hyperfleet-operator-system" + +// testCR returns the singleton in the shape Render consumes (only Name and +// spec.bundle matter to rendering in 1407). +func testCR() *hyperfleetv1alpha1.HyperFleetConfig { + return &hyperfleetv1alpha1.HyperFleetConfig{ + ObjectMeta: metav1.ObjectMeta{Name: hyperfleetv1alpha1.SingletonName}, + Spec: hyperfleetv1alpha1.HyperFleetConfigSpec{Bundle: hyperfleetv1alpha1.BundleCloudCAPI}, + } +} + +// byKind indexes rendered objects by their GVK Kind for assertion. Render sets +// TypeMeta explicitly (required for server-side apply), so Kind is populated. +func byKind(objs []client.Object) map[string]client.Object { + out := make(map[string]client.Object, len(objs)) + for _, o := range objs { + out[o.GetObjectKind().GroupVersionKind().Kind] = o + } + return out +} + +func TestRenderProducesTheOperandSet(t *testing.T) { + g := NewWithT(t) + const image = "example.com/hyperfleet-api:test" + + objs, err := New(image, testNamespace).Render(context.Background(), testCR()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(objs).To(HaveLen(6)) + + kinds := byKind(objs) + g.Expect(kinds).To(HaveKey("ServiceAccount")) + g.Expect(kinds).To(HaveKey("Role")) + g.Expect(kinds).To(HaveKey("RoleBinding")) + g.Expect(kinds).To(HaveKey("ConfigMap")) + g.Expect(kinds).To(HaveKey("Service")) + g.Expect(kinds).To(HaveKey("Deployment")) + + // Every operand lives in the operator namespace, carries the common labels + // (including the component marker) and a non-empty GVK for SSA. + for _, o := range objs { + g.Expect(o.GetNamespace()).To(Equal(testNamespace)) + g.Expect(o.GetLabels()).To(HaveKeyWithValue(labelComponent, ComponentName)) + g.Expect(o.GetLabels()).To(HaveKeyWithValue(labelManagedBy, managedByOperator)) + g.Expect(o.GetObjectKind().GroupVersionKind().Kind).NotTo(BeEmpty()) + g.Expect(o.GetObjectKind().GroupVersionKind().Version).NotTo(BeEmpty()) + } +} + +func TestRenderDeployment(t *testing.T) { + g := NewWithT(t) + const image = "example.com/hyperfleet-api:test" + + objs, err := New(image, testNamespace).Render(context.Background(), testCR()) + g.Expect(err).NotTo(HaveOccurred()) + + dep, ok := byKind(objs)["Deployment"].(*appsv1.Deployment) + g.Expect(ok).To(BeTrue()) + g.Expect(dep.Name).To(Equal(ResourceName)) + + spec := dep.Spec + g.Expect(spec.Replicas).To(HaveValue(BeEquivalentTo(1))) + // The selector is the immutable subset; the pod template carries the full + // label set, so the selector must be a subset of the template labels (a + // selector that is not a subset would make the Deployment adopt no pods). + for k, v := range spec.Selector.MatchLabels { + g.Expect(spec.Template.Labels).To(HaveKeyWithValue(k, v)) + } + g.Expect(spec.Template.Labels).To(HaveKeyWithValue(labelComponent, ComponentName)) + + g.Expect(spec.Template.Spec.ServiceAccountName).To(Equal(ResourceName)) + // The API does not use the Kubernetes API, so the SA token is not mounted. + g.Expect(spec.Template.Spec.AutomountServiceAccountToken).To(HaveValue(BeFalse())) + g.Expect(spec.Template.Spec.Containers).To(HaveLen(1)) + + c := spec.Template.Spec.Containers[0] + g.Expect(c.Image).To(Equal(image)) + g.Expect(c.Args).To(Equal([]string{"serve"})) + + ports := map[string]int32{} + for _, p := range c.Ports { + ports[p.Name] = p.ContainerPort + } + g.Expect(ports).To(Equal(map[string]int32{ + portNameHTTP: portHTTP, + portNameHealth: portHealth, + portNameMetrics: portMetrics, + })) + + g.Expect(c.LivenessProbe.HTTPGet.Path).To(Equal("/healthz")) + g.Expect(c.LivenessProbe.HTTPGet.Port.StrVal).To(Equal(portNameHealth)) + g.Expect(c.ReadinessProbe.HTTPGet.Path).To(Equal("/readyz")) + g.Expect(c.ReadinessProbe.HTTPGet.Port.StrVal).To(Equal(portNameHealth)) + + // Hardened container per the chart's securityContext. + g.Expect(c.SecurityContext.ReadOnlyRootFilesystem).To(HaveValue(BeTrue())) + g.Expect(c.SecurityContext.AllowPrivilegeEscalation).To(HaveValue(BeFalse())) + g.Expect(dep.Spec.Template.Spec.SecurityContext.RunAsNonRoot).To(HaveValue(BeTrue())) + + // The config ConfigMap is mounted read-only at the expected path. + var mountedConfig bool + for _, v := range spec.Template.Spec.Volumes { + if v.ConfigMap != nil && v.ConfigMap.Name == ConfigMapName { + mountedConfig = true + } + } + g.Expect(mountedConfig).To(BeTrue(), "expected a volume backed by the API ConfigMap") +} + +func TestRenderEmptyImageFallsBackToDefault(t *testing.T) { + g := NewWithT(t) + + objs, err := New("", testNamespace).Render(context.Background(), testCR()) + g.Expect(err).NotTo(HaveOccurred()) + + dep, ok := byKind(objs)["Deployment"].(*appsv1.Deployment) + g.Expect(ok).To(BeTrue()) + g.Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal(DefaultImage)) +} + +func TestRenderRoleHasNoRules(t *testing.T) { + g := NewWithT(t) + + objs, err := New("img", testNamespace).Render(context.Background(), testCR()) + g.Expect(err).NotTo(HaveOccurred()) + + role, ok := byKind(objs)["Role"].(*rbacv1.Role) + g.Expect(ok).To(BeTrue()) + // The API needs no in-cluster permissions today; the Role exists only to + // satisfy the RBAC operand and pre-wire the pattern (see render.go). + g.Expect(role.Rules).To(BeEmpty()) + + rb, ok := byKind(objs)["RoleBinding"].(*rbacv1.RoleBinding) + g.Expect(ok).To(BeTrue()) + g.Expect(rb.RoleRef.Name).To(Equal(ResourceName)) + g.Expect(rb.RoleRef.Kind).To(Equal("Role")) + g.Expect(rb.Subjects).To(HaveLen(1)) + g.Expect(rb.Subjects[0].Name).To(Equal(ResourceName)) + g.Expect(rb.Subjects[0].Namespace).To(Equal(testNamespace)) +} + +func TestRenderService(t *testing.T) { + g := NewWithT(t) + + objs, err := New("img", testNamespace).Render(context.Background(), testCR()) + g.Expect(err).NotTo(HaveOccurred()) + + svc, ok := byKind(objs)["Service"].(*corev1.Service) + g.Expect(ok).To(BeTrue()) + g.Expect(svc.Spec.Type).To(Equal(corev1.ServiceTypeClusterIP)) + g.Expect(svc.Spec.Ports).To(HaveLen(3)) + g.Expect(svc.Spec.Selector).To(HaveKeyWithValue(labelComponent, ComponentName)) +} diff --git a/internal/component/api/render.go b/internal/component/api/render.go new file mode 100644 index 0000000..a5258a6 --- /dev/null +++ b/internal/component/api/render.go @@ -0,0 +1,340 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package api renders and reports on the HyperFleet API component — the single +// component in the shared tier of every bundle (see internal/bundle). The +// builders here are pure functions of (CR, image, namespace): they never read or +// write the cluster. The controller does the applying. +// +// Operand shapes are translated from the source-of-truth Helm chart +// (hyperfleet-api/charts). Three deliberate departures from the chart, per the +// 1407 design: +// - Naming is uniform. The chart names the Service by chart-name and the rest +// by release-fullname; here every operand shares ResourceName so ownership +// and selectors are obvious. The chart's naming asymmetry is not replicated. +// - Role/RoleBinding are synthesized. The chart ships none (the API talks only +// to PostgreSQL, never the Kubernetes API), so the Role carries no rules; we +// still render it to satisfy the story's explicit RBAC operand and pre-wire +// the pattern for future components. +// - The service-account token is not auto-mounted. Because the API never calls +// the Kubernetes API, the pod opts out of the token mount (the chart leaves +// the Kubernetes default, which mounts it). +// +// Fields that depend on the CR spec (database/auth/tls env, config-file content, +// profile→resources) are intentionally baseline/placeholder here and are wired +// in HYPERFLEET-1408. +package api + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + + hyperfleetv1alpha1 "github.com/openshift-hyperfleet/hyperfleet-operator/api/v1alpha1" +) + +const ( + // ResourceName is the shared metadata.name for the API operands that are not + // the ConfigMap (Deployment, Service, ServiceAccount, Role, RoleBinding). + ResourceName = "hyperfleet-api" + // ConfigMapName is the metadata.name of the API's configuration ConfigMap. + ConfigMapName = "hyperfleet-api-config" + // ComponentName is both the component's Name() and its + // app.kubernetes.io/component label value. + ComponentName = "api" + + // managedByOperator marks operands as operator-managed (the chart uses Helm + // here; we do not go through Helm). + managedByOperator = "hyperfleet-operator" + partOfHyperfleet = "hyperfleet" + + // Container/Service port names and numbers, matching the chart defaults. + portNameHTTP = "http" + portNameHealth = "health" + portNameMetrics = "metrics" + portHTTP = 8000 + portHealth = 8080 + portMetrics = 9090 + + // configMountPath is where the config ConfigMap is mounted and where the + // HYPERFLEET_CONFIG env var points. + configMountPath = "/etc/hyperfleet" + configFilePath = "/etc/hyperfleet/config.yaml" + configVolume = "config" + tmpVolume = "tmp" +) + +// Label keys are declared once to keep them in sync between the common and +// selector label sets. +const ( + labelName = "app.kubernetes.io/name" + labelInstance = "app.kubernetes.io/instance" + labelComponent = "app.kubernetes.io/component" + labelPartOf = "app.kubernetes.io/part-of" + labelManagedBy = "app.kubernetes.io/managed-by" +) + +// placeholderConfig is a structurally valid but not-yet-spec-derived config +// file. HYPERFLEET-1408 replaces this with content rendered from the CR spec. +const placeholderConfig = `# HyperFleet API configuration (baseline placeholder). +# Rendered by the operator for HYPERFLEET-1407: structurally valid but not yet +# derived from the HyperFleetConfig spec. +# TODO(HYPERFLEET-1408): populate server/database/auth/tls/logging from the spec. +server: + host: "0.0.0.0" + port: 8000 +health: + host: "0.0.0.0" + port: 8080 +metrics: + host: "0.0.0.0" + port: 9090 +` + +// labels returns the common label set stamped on every operand's metadata. +func labels(cr *hyperfleetv1alpha1.HyperFleetConfig) map[string]string { + return map[string]string{ + labelName: ResourceName, + labelInstance: cr.Name, + labelComponent: ComponentName, + labelPartOf: partOfHyperfleet, + labelManagedBy: managedByOperator, + } +} + +// selectorLabels returns the immutable subset used for the Deployment selector, +// the pod template labels and the Service selector. It must stay stable: a +// Deployment's selector cannot be changed after creation. Every value here is +// immutable (ResourceName and ComponentName are constants; the CR name is pinned +// to the singleton "cluster"). +func selectorLabels(cr *hyperfleetv1alpha1.HyperFleetConfig) map[string]string { + return map[string]string{ + labelName: ResourceName, + labelInstance: cr.Name, + labelComponent: ComponentName, + } +} + +// deployment builds the API Deployment. Image is injected by the operator; the +// database credentials env, config-file content and profile→resources mapping +// are deferred to HYPERFLEET-1408. +func deployment(cr *hyperfleetv1alpha1.HyperFleetConfig, image, namespace string) *appsv1.Deployment { + return &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, + ObjectMeta: metav1.ObjectMeta{ + Name: ResourceName, + Namespace: namespace, + Labels: labels(cr), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](1), + Selector: &metav1.LabelSelector{MatchLabels: selectorLabels(cr)}, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: ptr.To(intstr.FromInt32(1)), + MaxUnavailable: ptr.To(intstr.FromInt32(0)), + }, + }, + Template: corev1.PodTemplateSpec{ + // Pods carry the full recommended label set; the Deployment selector + // (above) is the immutable subset of these, which stays valid because + // selectorLabels ⊆ labels. + ObjectMeta: metav1.ObjectMeta{Labels: labels(cr)}, + Spec: corev1.PodSpec{ + // The API never calls the Kubernetes API (its Role is empty), + // so it needs no service-account token. Opting out of the + // automatic mount removes an unused credential from every pod + // and shrinks the attack surface. + AutomountServiceAccountToken: ptr.To(false), + ServiceAccountName: ResourceName, + TerminationGracePeriodSeconds: ptr.To[int64](70), + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: ptr.To(true), + RunAsUser: ptr.To[int64](65532), + FSGroup: ptr.To[int64](65532), + }, + Containers: []corev1.Container{{ + Name: ResourceName, + Image: image, + ImagePullPolicy: corev1.PullAlways, // chart: values.image.pullPolicy (default Always) + WorkingDir: "/app", + Args: []string{"serve"}, + Ports: []corev1.ContainerPort{ + {Name: portNameHTTP, ContainerPort: portHTTP, Protocol: corev1.ProtocolTCP}, + {Name: portNameHealth, ContainerPort: portHealth, Protocol: corev1.ProtocolTCP}, + {Name: portNameMetrics, ContainerPort: portMetrics, Protocol: corev1.ProtocolTCP}, + }, + Env: []corev1.EnvVar{ + {Name: "HYPERFLEET_CONFIG", Value: configFilePath}, + // TODO(HYPERFLEET-1408): inject database credentials via + // secretKeyRef from spec.api.database.secretRef. + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{Path: "/healthz", Port: intstr.FromString(portNameHealth)}, + }, + InitialDelaySeconds: 15, + PeriodSeconds: 20, + TimeoutSeconds: 5, + FailureThreshold: 3, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{Path: "/readyz", Port: intstr.FromString(portNameHealth)}, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 5, + TimeoutSeconds: 3, + FailureThreshold: 3, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("512Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + ReadOnlyRootFilesystem: ptr.To(true), + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: configVolume, MountPath: configMountPath, ReadOnly: true}, + {Name: tmpVolume, MountPath: "/tmp"}, + }, + }}, + Volumes: []corev1.Volume{ + { + Name: configVolume, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: ConfigMapName}, + }, + }, + }, + { + Name: tmpVolume, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }, + }, + }, + }, + }, + } +} + +// service builds the API's ClusterIP Service. Ports target the container ports +// by name so they stay correct even if numbers change. +func service(cr *hyperfleetv1alpha1.HyperFleetConfig, namespace string) *corev1.Service { + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}, + ObjectMeta: metav1.ObjectMeta{ + Name: ResourceName, + Namespace: namespace, + Labels: labels(cr), + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: selectorLabels(cr), + Ports: []corev1.ServicePort{ + {Name: portNameHTTP, Port: portHTTP, TargetPort: intstr.FromString(portNameHTTP), Protocol: corev1.ProtocolTCP}, + {Name: portNameHealth, Port: portHealth, TargetPort: intstr.FromString(portNameHealth), Protocol: corev1.ProtocolTCP}, + {Name: portNameMetrics, Port: portMetrics, TargetPort: intstr.FromString(portNameMetrics), Protocol: corev1.ProtocolTCP}, + }, + }, + } +} + +// serviceAccount builds the API's identity. +func serviceAccount(cr *hyperfleetv1alpha1.HyperFleetConfig, namespace string) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ServiceAccount"}, + ObjectMeta: metav1.ObjectMeta{ + Name: ResourceName, + Namespace: namespace, + Labels: labels(cr), + }, + } +} + +// configMap builds the API's configuration. Content is a baseline placeholder +// until HYPERFLEET-1408 derives it from the CR spec. +func configMap(cr *hyperfleetv1alpha1.HyperFleetConfig, namespace string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{ + Name: ConfigMapName, + Namespace: namespace, + Labels: labels(cr), + }, + Data: map[string]string{"config.yaml": placeholderConfig}, + } +} + +// role builds the API's Role. The rules are intentionally empty: the API talks +// only to PostgreSQL and never to the Kubernetes API, so it needs no in-cluster +// permissions. The Role (and its binding) exist to satisfy the story's explicit +// RBAC operand and to pre-wire the pattern for future components that may need +// rules. +func role(cr *hyperfleetv1alpha1.HyperFleetConfig, namespace string) *rbacv1.Role { + return &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"}, + ObjectMeta: metav1.ObjectMeta{ + Name: ResourceName, + Namespace: namespace, + Labels: labels(cr), + }, + Rules: []rbacv1.PolicyRule{}, + } +} + +// roleBinding binds the API's Role to its ServiceAccount. The Role is empty, so +// creating this binding needs no bind/escalate permission today (the operator +// trivially "holds" the zero permissions it grants). When a future component +// ships a Role WITH rules (HYPERFLEET-1408+), the operator's own ClusterRole +// must either hold those permissions or gain `bind`/`escalate` on them, or the +// apply will fail on a real (RBAC-enforcing) cluster — note that envtest does +// not enforce RBAC, so such a regression would not surface in the suite. +func roleBinding(cr *hyperfleetv1alpha1.HyperFleetConfig, namespace string) *rbacv1.RoleBinding { + return &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "RoleBinding"}, + ObjectMeta: metav1.ObjectMeta{ + Name: ResourceName, + Namespace: namespace, + Labels: labels(cr), + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: ResourceName, + }, + Subjects: []rbacv1.Subject{{ + Kind: "ServiceAccount", + Name: ResourceName, + Namespace: namespace, + }}, + } +} diff --git a/internal/controller/hyperfleetconfig_controller.go b/internal/controller/hyperfleetconfig_controller.go index b96de15..fd3c630 100644 --- a/internal/controller/hyperfleetconfig_controller.go +++ b/internal/controller/hyperfleetconfig_controller.go @@ -18,46 +18,106 @@ package controller import ( "context" + "fmt" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" hyperfleetv1alpha1 "github.com/openshift-hyperfleet/hyperfleet-operator/api/v1alpha1" + "github.com/openshift-hyperfleet/hyperfleet-operator/internal/apply" + "github.com/openshift-hyperfleet/hyperfleet-operator/internal/bundle" ) -// HyperFleetConfigReconciler reconciles a HyperFleetConfig object +// HyperFleetConfigReconciler reconciles a HyperFleetConfig object. It is the +// single bundle controller: it resolves spec.bundle to a component set and +// reconciles each component's operands via server-side apply. type HyperFleetConfigReconciler struct { client.Client Scheme *runtime.Scheme + // OperatorNamespace is the namespace the operator runs in and where all + // operands are created. Sourced from POD_NAMESPACE (downward API) in main.go. + OperatorNamespace string + // APIImage is the container image for the API operand. Sourced from + // RELATED_IMAGE_HYPERFLEET_API in main.go; empty falls back to the API + // component's compiled-in default. + APIImage string } // +kubebuilder:rbac:groups=hyperfleet.redhat.com,resources=hyperfleetconfigs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=hyperfleet.redhat.com,resources=hyperfleetconfigs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=hyperfleet.redhat.com,resources=hyperfleetconfigs/finalizers,verbs=update +// The operator reconciles operands with server-side apply (create/update/patch) +// and relies on owner-reference garbage collection (run by kube-controller-manager, +// not this operator) for cleanup, so it needs no delete permission on operands. +// get;list;watch back the Owns() informer caches. +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="",resources=services;serviceaccounts;configmaps,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the HyperFleetConfig object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. +// Reconcile drives the cluster toward the desired state for the HyperFleetConfig +// singleton. It is level-based and idempotent: it renders each component's +// desired operands from the CR and server-side-applies them, so running it twice +// with no spec change writes nothing, and out-of-band drift self-heals (the +// Owns() watches re-invoke this loop). // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile func (r *HyperFleetConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = logf.FromContext(ctx) + log := logf.FromContext(ctx) - // TODO(user): your logic here + cr := &hyperfleetv1alpha1.HyperFleetConfig{} + if err := r.Get(ctx, req.NamespacedName, cr); err != nil { + // The CR is gone: its operands carry controller owner references, so the + // built-in garbage collector removes them. No finalizer is required. + return ctrl.Result{}, client.IgnoreNotFound(err) + } + components := bundle.Resolve(cr.Spec.Bundle, bundle.Config{ + APIImage: r.APIImage, + Namespace: r.OperatorNamespace, + }) + + for _, component := range components { + objs, err := component.Render(ctx, cr) + if err != nil { + return ctrl.Result{}, fmt.Errorf("render component %q: %w", component.Name(), err) + } + if err := apply.Objects(ctx, r.Client, cr, r.Scheme, objs); err != nil { + return ctrl.Result{}, fmt.Errorf("apply component %q: %w", component.Name(), err) + } + } + + // TODO(HYPERFLEET-1408): map CR fields (database/auth/tls/profile) into the + // rendered operand — env, config-file content, secret mounts, resources and + // replicas — plus a content-hash annotation to roll pods on config change. + // TODO(HYPERFLEET-1409): roll each component's Conditions up into + // status.conditions and set status.observedGeneration. + // TODO(HYPERFLEET-1512): resolve spec.api.database.secretRef (and any + // tls.secretRef) in r.OperatorNamespace and surface a Degraded condition when + // the referenced Secret is missing. + + log.Info("reconciled HyperFleetConfig", + "bundle", cr.Spec.Bundle, "components", len(components), "namespace", r.OperatorNamespace) return ctrl.Result{}, nil } -// SetupWithManager sets up the controller with the Manager. +// SetupWithManager sets up the controller with the Manager. It watches the +// HyperFleetConfig and every operand type it owns, so an out-of-band change to +// any operand re-invokes Reconcile and the desired state is re-applied. func (r *HyperFleetConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&hyperfleetv1alpha1.HyperFleetConfig{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Owns(&corev1.ServiceAccount{}). + Owns(&corev1.ConfigMap{}). + Owns(&rbacv1.Role{}). + Owns(&rbacv1.RoleBinding{}). Named("hyperfleetconfig"). Complete(r) } diff --git a/internal/controller/hyperfleetconfig_controller_test.go b/internal/controller/hyperfleetconfig_controller_test.go index 9b2e579..9b36d33 100644 --- a/internal/controller/hyperfleetconfig_controller_test.go +++ b/internal/controller/hyperfleetconfig_controller_test.go @@ -21,55 +21,195 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/reconcile" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" hyperfleetv1alpha1 "github.com/openshift-hyperfleet/hyperfleet-operator/api/v1alpha1" + apicomponent "github.com/openshift-hyperfleet/hyperfleet-operator/internal/component/api" ) +// Reconciler behavior specs (HYPERFLEET-1407). These run against envtest, which +// is apiserver + etcd only: there is NO garbage-collection controller and NO +// running manager. Two consequences shape the assertions below and must not be +// "fixed": +// - GC is verified structurally (owner references are set with controller:true +// and a matching UID); real cascade deletion is proven in the kind e2e. +// - Self-healing is verified by deleting an operand and calling Reconcile again +// directly; true Owns()-driven wake-ups also belong to e2e. + +// deleteOperands removes the API operands so each spec starts clean. envtest has +// no GC, so deleting the CR would not cascade; specs delete operands explicitly. +// NotFound is a valid "already clean" outcome; any other error fails the spec. +func deleteOperands(ctx context.Context, namespace string) { + GinkgoHelper() + operands := []client.Object{ + &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: apicomponent.ResourceName, Namespace: namespace}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: apicomponent.ResourceName, Namespace: namespace}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: apicomponent.ResourceName, Namespace: namespace}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: apicomponent.ConfigMapName, Namespace: namespace}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: apicomponent.ResourceName, Namespace: namespace}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: apicomponent.ResourceName, Namespace: namespace}}, + } + for _, o := range operands { + if err := k8sClient.Delete(ctx, o); err != nil && !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } +} + var _ = Describe("HyperFleetConfig Controller", func() { - Context("When reconciling a resource", func() { - // HyperFleetConfig is a cluster-scoped singleton: the only permitted name - // is "cluster" and there is no namespace. - const resourceName = hyperfleetv1alpha1.SingletonName + const ( + operatorNamespace = "hyperfleet-operator-system" + apiImage = "example.com/hyperfleet-api:test" + ) + + ctx := context.Background() + typeNamespacedName := types.NamespacedName{Name: hyperfleetv1alpha1.SingletonName} - ctx := context.Background() + // operandKey builds the lookup key for an operand in the operator namespace. + operandKey := func(name string) types.NamespacedName { + return types.NamespacedName{Name: name, Namespace: operatorNamespace} + } - typeNamespacedName := types.NamespacedName{ - Name: resourceName, + var reconciler *HyperFleetConfigReconciler + + BeforeEach(func() { + By("ensuring the operator namespace exists (envtest does not create it)") + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: operatorNamespace}} + if err := k8sClient.Create(ctx, ns); err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) } - hyperfleetconfig := &hyperfleetv1alpha1.HyperFleetConfig{} - - BeforeEach(func() { - By("creating the custom resource for the Kind HyperFleetConfig") - err := k8sClient.Get(ctx, typeNamespacedName, hyperfleetconfig) - if errors.IsNotFound(err) { - resource := validHyperFleetConfig() - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - } else { - // A Get error other than NotFound means the fixture state is unknown; - // fail loudly instead of silently skipping the Create and letting the - // reconcile spec pass without its required resource. - Expect(err).NotTo(HaveOccurred()) - } - // The singleton now exists (created just above or already present), so - // schedule its teardown. DeferCleanup runs after the spec and replaces a - // blanket AfterEach; deleteSingletonAndWait lives in suite_test.go. - DeferCleanup(deleteSingletonAndWait, ctx) - }) - - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &HyperFleetConfigReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - } - - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) + + By("creating the HyperFleetConfig singleton") + existing := &hyperfleetv1alpha1.HyperFleetConfig{} + if err := k8sClient.Get(ctx, typeNamespacedName, existing); errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, validHyperFleetConfig())).To(Succeed()) + } else { + // Any error other than NotFound leaves fixture state unknown: fail + // loudly rather than let a spec run without its resource. Expect(err).NotTo(HaveOccurred()) - }) + } + + // Both the singleton and the operands force fixed names, so tear them down + // after every spec. deleteSingletonAndWait lives in suite_test.go. + DeferCleanup(deleteSingletonAndWait, ctx) + DeferCleanup(deleteOperands, ctx, operatorNamespace) + + reconciler = &HyperFleetConfigReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + OperatorNamespace: operatorNamespace, + APIImage: apiImage, + } + }) + + // doReconcile runs one reconcile of the singleton and asserts it succeeds. + doReconcile := func() { + GinkgoHelper() + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + } + + // expectOwnedBy asserts the operand carries exactly one controller owner + // reference pointing at the CR — the structural stand-in for GC in envtest. + expectOwnedBy := func(obj client.Object, cr *hyperfleetv1alpha1.HyperFleetConfig) { + GinkgoHelper() + owners := obj.GetOwnerReferences() + Expect(owners).To(HaveLen(1)) + Expect(owners[0].Kind).To(Equal("HyperFleetConfig")) + Expect(owners[0].Name).To(Equal(cr.Name)) + Expect(owners[0].UID).To(Equal(cr.UID)) + Expect(owners[0].Controller).To(HaveValue(BeTrue())) + Expect(owners[0].BlockOwnerDeletion).To(HaveValue(BeTrue())) + } + + It("creates the full operand set, each owned by the CR", func() { + cr := &hyperfleetv1alpha1.HyperFleetConfig{} + Expect(k8sClient.Get(ctx, typeNamespacedName, cr)).To(Succeed()) + + By("reconciling the created resource") + doReconcile() + + By("verifying each operand exists and is owned by the CR") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), dep)).To(Succeed()) + expectOwnedBy(dep, cr) + Expect(dep.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(dep.Spec.Template.Spec.Containers[0].Image).To(Equal(apiImage)) + + svc := &corev1.Service{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), svc)).To(Succeed()) + expectOwnedBy(svc, cr) + Expect(svc.Spec.Ports).To(HaveLen(3)) + + sa := &corev1.ServiceAccount{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), sa)).To(Succeed()) + expectOwnedBy(sa, cr) + + cm := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ConfigMapName), cm)).To(Succeed()) + expectOwnedBy(cm, cr) + Expect(cm.Data).To(HaveKey("config.yaml")) + + role := &rbacv1.Role{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), role)).To(Succeed()) + expectOwnedBy(role, cr) + Expect(role.Rules).To(BeEmpty()) + + rb := &rbacv1.RoleBinding{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), rb)).To(Succeed()) + expectOwnedBy(rb, cr) + }) + + It("recreates an operand after out-of-band deletion (drift self-heals)", func() { + By("reconciling to create the operands") + doReconcile() + + By("deleting the Deployment out of band") + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), dep)).To(Succeed()) + Expect(k8sClient.Delete(ctx, dep)).To(Succeed()) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), &appsv1.Deployment{})) + }).Should(BeTrue()) + + By("reconciling again and asserting the Deployment reappears") + doReconcile() + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), &appsv1.Deployment{})).To(Succeed()) + }) + + It("is idempotent: a no-op reconcile does not rewrite operands", func() { + By("reconciling to create the operands") + doReconcile() + + dep := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), dep)).To(Succeed()) + depRV := dep.ResourceVersion + cm := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ConfigMapName), cm)).To(Succeed()) + cmRV := cm.ResourceVersion + + By("reconciling again with no spec change") + doReconcile() + + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ResourceName), dep)).To(Succeed()) + Expect(dep.ResourceVersion).To(Equal(depRV), "server-side apply of identical state must be a no-op") + Expect(k8sClient.Get(ctx, operandKey(apicomponent.ConfigMapName), cm)).To(Succeed()) + Expect(cm.ResourceVersion).To(Equal(cmRV), "server-side apply of identical state must be a no-op") + }) + + It("returns without error when the CR is absent (deletion path)", func() { + By("deleting the singleton before it is reconciled") + deleteSingletonAndWait(ctx) + + By("reconciling a now-absent CR") + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) }) })