From 5ea08400f39ced647dc34b6d0b2e03657683728d Mon Sep 17 00:00:00 2001 From: Brandon Palm Date: Tue, 4 Aug 2026 13:42:19 -0500 Subject: [PATCH 1/2] CM-716: HTTP01 Proxy with nftables MachineConfig Implement HTTP01 challenge proxy for baremetal clusters using nftables DNAT+SNAT rules applied via MachineConfig. Includes platform discovery, validation, MachineConfig lifecycle management, and unit tests covering all controller paths. --- .gitignore | 1 + Makefile | 11 + api/operator/v1alpha1/features.go | 10 + api/operator/v1alpha1/http01proxy_types.go | 109 +++++ .../v1alpha1/zz_generated.deepcopy.go | 110 +++++ ...anager-operator.clusterserviceversion.yaml | 24 + .../operator.openshift.io_http01proxies.yaml | 185 ++++++++ .../operator.openshift.io_http01proxies.yaml | 179 ++++++++ config/crd/kustomization.yaml | 1 + config/manager/manager.yaml | 4 + config/rbac/role.yaml | 15 + ...tor.openshift.io_v1alpha1_http01proxy.yaml | 7 + pkg/controller/http01proxy/constants.go | 34 ++ pkg/controller/http01proxy/controller.go | 159 +++++++ pkg/controller/http01proxy/controller_test.go | 197 ++++++++ pkg/controller/http01proxy/infrastructure.go | 106 +++++ .../http01proxy/infrastructure_test.go | 292 ++++++++++++ .../http01proxy/install_http01proxy.go | 38 ++ .../http01proxy/install_http01proxy_test.go | 189 ++++++++ pkg/controller/http01proxy/machineconfig.go | 168 +++++++ .../http01proxy/machineconfig_test.go | 424 ++++++++++++++++++ pkg/controller/http01proxy/utils.go | 71 +++ pkg/controller/http01proxy/utils_test.go | 297 ++++++++++++ pkg/features/features_test.go | 12 +- .../applyconfigurations/internal/internal.go | 10 + .../operator/v1alpha1/http01proxy.go | 282 ++++++++++++ .../http01proxycustomdeploymentspec.go | 27 ++ .../operator/v1alpha1/http01proxyspec.go | 43 ++ .../operator/v1alpha1/http01proxystatus.go | 45 ++ pkg/operator/applyconfigurations/utils.go | 8 + .../v1alpha1/fake/fake_http01proxy.go | 37 ++ .../v1alpha1/fake/fake_operator_client.go | 4 + .../operator/v1alpha1/generated_expansion.go | 2 + .../typed/operator/v1alpha1/http01proxy.go | 58 +++ .../operator/v1alpha1/operator_client.go | 5 + .../informers/externalversions/generic.go | 2 + .../operator/v1alpha1/http01proxy.go | 86 ++++ .../operator/v1alpha1/interface.go | 7 + .../operator/v1alpha1/expansion_generated.go | 8 + .../listers/operator/v1alpha1/http01proxy.go | 54 +++ pkg/operator/setup_manager.go | 33 +- pkg/operator/starter.go | 5 +- test/e2e/http01proxy_test.go | 128 ++++++ 43 files changed, 3479 insertions(+), 8 deletions(-) create mode 100644 api/operator/v1alpha1/http01proxy_types.go create mode 100644 bundle/manifests/operator.openshift.io_http01proxies.yaml create mode 100644 config/crd/bases/operator.openshift.io_http01proxies.yaml create mode 100644 config/samples/operator.openshift.io_v1alpha1_http01proxy.yaml create mode 100644 pkg/controller/http01proxy/constants.go create mode 100644 pkg/controller/http01proxy/controller.go create mode 100644 pkg/controller/http01proxy/controller_test.go create mode 100644 pkg/controller/http01proxy/infrastructure.go create mode 100644 pkg/controller/http01proxy/infrastructure_test.go create mode 100644 pkg/controller/http01proxy/install_http01proxy.go create mode 100644 pkg/controller/http01proxy/install_http01proxy_test.go create mode 100644 pkg/controller/http01proxy/machineconfig.go create mode 100644 pkg/controller/http01proxy/machineconfig_test.go create mode 100644 pkg/controller/http01proxy/utils.go create mode 100644 pkg/controller/http01proxy/utils_test.go create mode 100644 pkg/operator/applyconfigurations/operator/v1alpha1/http01proxy.go create mode 100644 pkg/operator/applyconfigurations/operator/v1alpha1/http01proxycustomdeploymentspec.go create mode 100644 pkg/operator/applyconfigurations/operator/v1alpha1/http01proxyspec.go create mode 100644 pkg/operator/applyconfigurations/operator/v1alpha1/http01proxystatus.go create mode 100644 pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_http01proxy.go create mode 100644 pkg/operator/clientset/versioned/typed/operator/v1alpha1/http01proxy.go create mode 100644 pkg/operator/informers/externalversions/operator/v1alpha1/http01proxy.go create mode 100644 pkg/operator/listers/operator/v1alpha1/http01proxy.go create mode 100644 test/e2e/http01proxy_test.go diff --git a/.gitignore b/.gitignore index d6495a05c..7c9713113 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ *.out /cert-manager-operator +/http01-proxy # Log output from telepresence telepresence.log diff --git a/Makefile b/Makefile index 101accaa9..1b47966b9 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,7 @@ endif CERT_MANAGER_VERSION ?= v1.20.3 ISTIO_CSR_VERSION ?= v0.16.0 TRUST_MANAGER_VERSION ?= v0.20.3 +HTTP01PROXY_VERSION ?= v0.1.0 # --- Test Versions --- @@ -329,10 +330,12 @@ local-run: build ## Run the operator locally against the cluster configured in ~ RELATED_IMAGE_CERT_MANAGER_ACMESOLVER=quay.io/jetstack/cert-manager-acmesolver:$(CERT_MANAGER_VERSION) \ RELATED_IMAGE_CERT_MANAGER_ISTIOCSR=quay.io/jetstack/cert-manager-istio-csr:$(ISTIO_CSR_VERSION) \ RELATED_IMAGE_CERT_MANAGER_TRUST_MANAGER=quay.io/jetstack/trust-manager:$(TRUST_MANAGER_VERSION) \ + RELATED_IMAGE_CERT_MANAGER_HTTP01PROXY=quay.io/openshift/cert-manager-http01-proxy:$(HTTP01PROXY_VERSION) \ OPERATOR_NAME=cert-manager-operator \ OPERAND_IMAGE_VERSION=$(BUNDLE_VERSION) \ ISTIOCSR_OPERAND_IMAGE_VERSION=$(ISTIO_CSR_VERSION) \ TRUSTMANAGER_OPERAND_IMAGE_VERSION=$(TRUST_MANAGER_VERSION) \ + HTTP01PROXY_OPERAND_IMAGE_VERSION=$(HTTP01PROXY_VERSION) \ OPERATOR_IMAGE_VERSION=$(BUNDLE_VERSION) \ ./cert-manager-operator start \ --config=./hack/local-run-config.yaml \ @@ -352,6 +355,10 @@ build: generate fmt vet build-operator ## Build operator binary with all checks build-operator: ## Build operator binary only (no checks or code generation). @GOFLAGS="-mod=vendor" source hack/go-fips.sh && $(GO) build $(GOBUILD_VERSION_ARGS) -o $(BIN) +.PHONY: build-http01-proxy +build-http01-proxy: ## Build HTTP01 proxy binary. + @GOFLAGS="-mod=vendor" source hack/go-fips.sh && $(GO) build $(GOBUILD_VERSION_ARGS) -o $(PROJECT_ROOT)/http01-proxy ./cmd/http01-proxy + .PHONY: run run: manifests generate fmt vet ## Run the operator from your host (for development). go run $(PACKAGE) @@ -364,6 +371,10 @@ image-build: ## Build container image with the operator. image-push: ## Push container image with the operator. $(CONTAINER_ENGINE) push $(IMG) $(CONTAINER_PUSH_ARGS) +.PHONY: image-build-http01-proxy +image-build-http01-proxy: ## Build HTTP01 proxy container image. + $(CONTAINER_ENGINE) build -t cert-manager-http01-proxy:$(HTTP01PROXY_VERSION) -f images/ci/http01proxy.Dockerfile . + # ============================================================================ # Deployment # ============================================================================ diff --git a/api/operator/v1alpha1/features.go b/api/operator/v1alpha1/features.go index 6d9c380a5..bdaad7f1f 100644 --- a/api/operator/v1alpha1/features.go +++ b/api/operator/v1alpha1/features.go @@ -21,9 +21,19 @@ var ( // For more details, // https://github.com/openshift/enhancements/blob/master/enhancements/cert-manager/trust-manager-controller.md FeatureTrustManager featuregate.Feature = "TrustManager" + + // HTTP01Proxy enables the controller for http01proxies.operator.openshift.io resource, + // which extends cert-manager-operator to deploy and manage the HTTP01 challenge proxy. + // The proxy enables cert-manager to complete HTTP01 ACME challenges for the API endpoint + // on baremetal platforms where the API VIP is not exposed via OpenShift Ingress. + // + // For more details, + // https://github.com/openshift/enhancements/pull/1929 + FeatureHTTP01Proxy featuregate.Feature = "HTTP01Proxy" ) var OperatorFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ FeatureIstioCSR: {Default: true, PreRelease: featuregate.GA}, FeatureTrustManager: {Default: false, PreRelease: "TechPreview"}, + FeatureHTTP01Proxy: {Default: false, PreRelease: featuregate.Alpha}, } diff --git a/api/operator/v1alpha1/http01proxy_types.go b/api/operator/v1alpha1/http01proxy_types.go new file mode 100644 index 000000000..21a4b62ba --- /dev/null +++ b/api/operator/v1alpha1/http01proxy_types.go @@ -0,0 +1,109 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func init() { + SchemeBuilder.Register(&HTTP01Proxy{}, &HTTP01ProxyList{}) +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true + +// HTTP01ProxyList is a list of HTTP01Proxy objects. +type HTTP01ProxyList struct { + metav1.TypeMeta `json:",inline"` + + // metadata is the standard list's metadata. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + metav1.ListMeta `json:"metadata"` + Items []HTTP01Proxy `json:"items"` +} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=http01proxies,scope=Namespaced,categories={cert-manager-operator},shortName=http01proxy +// +kubebuilder:printcolumn:name="Mode",type="string",JSONPath=".spec.mode" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:metadata:labels={"app.kubernetes.io/name=http01proxy", "app.kubernetes.io/part-of=cert-manager-operator"} + +// HTTP01Proxy describes the configuration for the HTTP01 challenge proxy +// that redirects traffic from the API endpoint on port 80 to ingress routers. +// This enables cert-manager to perform HTTP01 ACME challenges for API endpoint certificates. +// The name must be `default` to make HTTP01Proxy a singleton. +// +// When an HTTP01Proxy is created, a MachineConfig with nftables DNAT/SNAT rules is applied to control plane nodes. +// +// +kubebuilder:validation:XValidation:rule="self.metadata.name == 'default'",message="http01proxy is a singleton, .metadata.name must be 'default'" +// +operator-sdk:csv:customresourcedefinitions:displayName="HTTP01Proxy" +type HTTP01Proxy struct { + metav1.TypeMeta `json:",inline"` + + // metadata is the standard object's metadata. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + metav1.ObjectMeta `json:"metadata,omitempty"` + + // spec is the specification of the desired behavior of the HTTP01Proxy. + // +kubebuilder:validation:Required + // +required + Spec HTTP01ProxySpec `json:"spec"` + + // status is the most recently observed status of the HTTP01Proxy. + // +kubebuilder:validation:Optional + // +optional + Status HTTP01ProxyStatus `json:"status,omitempty"` +} + +// HTTP01ProxyMode controls how the HTTP01 challenge proxy is deployed. +// +kubebuilder:validation:Enum=DefaultDeployment;CustomDeployment +type HTTP01ProxyMode string + +const ( + // HTTP01ProxyModeDefault enables the proxy with default configuration. + HTTP01ProxyModeDefault HTTP01ProxyMode = "DefaultDeployment" + + // HTTP01ProxyModeCustom enables the proxy with user-specified configuration. + HTTP01ProxyModeCustom HTTP01ProxyMode = "CustomDeployment" +) + +// HTTP01ProxySpec is the specification of the desired behavior of the HTTP01Proxy. +// +kubebuilder:validation:XValidation:rule="self.mode == 'CustomDeployment' ? has(self.customDeployment) : !has(self.customDeployment)",message="customDeployment is required when mode is CustomDeployment and forbidden otherwise" +type HTTP01ProxySpec struct { + // mode controls whether the HTTP01 challenge proxy is active and how it should be deployed. + // DefaultDeployment enables the proxy with default configuration. + // CustomDeployment enables the proxy with user-specified configuration. + // +kubebuilder:validation:Required + // +required + Mode HTTP01ProxyMode `json:"mode"` + + // customDeployment contains configuration options when mode is CustomDeployment. + // This field is only valid when mode is CustomDeployment. + // +kubebuilder:validation:Optional + // +optional + CustomDeployment *HTTP01ProxyCustomDeploymentSpec `json:"customDeployment,omitempty"` +} + +// HTTP01ProxyCustomDeploymentSpec contains configuration for custom proxy deployment. +type HTTP01ProxyCustomDeploymentSpec struct { + // internalPort specifies the internal port used by the proxy service. + // Valid values are 1024-65535. + // +kubebuilder:validation:Minimum=1024 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:default=8888 + // +optional + InternalPort int32 `json:"internalPort,omitempty"` +} + +// HTTP01ProxyStatus is the most recently observed status of the HTTP01Proxy. +type HTTP01ProxyStatus struct { + // conditions holds information about the current state of the HTTP01 proxy deployment. + ConditionalStatus `json:",inline,omitempty"` + + // proxyImage is the name of the image and the tag used for deploying the proxy. + ProxyImage string `json:"proxyImage,omitempty"` +} diff --git a/api/operator/v1alpha1/zz_generated.deepcopy.go b/api/operator/v1alpha1/zz_generated.deepcopy.go index 883cddf1b..a0d015685 100644 --- a/api/operator/v1alpha1/zz_generated.deepcopy.go +++ b/api/operator/v1alpha1/zz_generated.deepcopy.go @@ -334,6 +334,116 @@ func (in *DeploymentConfig) DeepCopy() *DeploymentConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTP01Proxy) DeepCopyInto(out *HTTP01Proxy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTP01Proxy. +func (in *HTTP01Proxy) DeepCopy() *HTTP01Proxy { + if in == nil { + return nil + } + out := new(HTTP01Proxy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HTTP01Proxy) 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 *HTTP01ProxyCustomDeploymentSpec) DeepCopyInto(out *HTTP01ProxyCustomDeploymentSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTP01ProxyCustomDeploymentSpec. +func (in *HTTP01ProxyCustomDeploymentSpec) DeepCopy() *HTTP01ProxyCustomDeploymentSpec { + if in == nil { + return nil + } + out := new(HTTP01ProxyCustomDeploymentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTP01ProxyList) DeepCopyInto(out *HTTP01ProxyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HTTP01Proxy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTP01ProxyList. +func (in *HTTP01ProxyList) DeepCopy() *HTTP01ProxyList { + if in == nil { + return nil + } + out := new(HTTP01ProxyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HTTP01ProxyList) 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 *HTTP01ProxySpec) DeepCopyInto(out *HTTP01ProxySpec) { + *out = *in + if in.CustomDeployment != nil { + in, out := &in.CustomDeployment, &out.CustomDeployment + *out = new(HTTP01ProxyCustomDeploymentSpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTP01ProxySpec. +func (in *HTTP01ProxySpec) DeepCopy() *HTTP01ProxySpec { + if in == nil { + return nil + } + out := new(HTTP01ProxySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTP01ProxyStatus) DeepCopyInto(out *HTTP01ProxyStatus) { + *out = *in + in.ConditionalStatus.DeepCopyInto(&out.ConditionalStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTP01ProxyStatus. +func (in *HTTP01ProxyStatus) DeepCopy() *HTTP01ProxyStatus { + if in == nil { + return nil + } + out := new(HTTP01ProxyStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IstioCSR) DeepCopyInto(out *IstioCSR) { *out = *in diff --git a/bundle/manifests/cert-manager-operator.clusterserviceversion.yaml b/bundle/manifests/cert-manager-operator.clusterserviceversion.yaml index aeb2b2fff..c6f316b45 100644 --- a/bundle/manifests/cert-manager-operator.clusterserviceversion.yaml +++ b/bundle/manifests/cert-manager-operator.clusterserviceversion.yaml @@ -365,6 +365,9 @@ spec: kind: ClusterIssuer name: clusterissuers.cert-manager.io version: v1 + - kind: HTTP01Proxy + name: http01proxies.operator.openshift.io + version: v1alpha1 - description: |- An Issuer represents a certificate issuing authority which can be referenced as part of `issuerRef` fields. @@ -625,6 +628,18 @@ spec: - patch - update - watch + - apiGroups: + - machineconfiguration.openshift.io + resources: + - machineconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - networking.k8s.io resources: @@ -655,6 +670,7 @@ spec: - operator.openshift.io resources: - certmanagers/finalizers + - http01proxies/finalizers - istiocsrs/finalizers - trustmanagers/finalizers verbs: @@ -663,6 +679,7 @@ spec: - operator.openshift.io resources: - certmanagers/status + - http01proxies/status - istiocsrs/status - trustmanagers/status verbs: @@ -672,6 +689,7 @@ spec: - apiGroups: - operator.openshift.io resources: + - http01proxies - istiocsrs - trustmanagers verbs: @@ -811,12 +829,16 @@ spec: value: quay.io/jetstack/cert-manager-istio-csr:v0.16.0 - name: RELATED_IMAGE_CERT_MANAGER_TRUST_MANAGER value: quay.io/jetstack/trust-manager:v0.20.3 + - name: RELATED_IMAGE_CERT_MANAGER_HTTP01PROXY + value: quay.io/openshift/cert-manager-http01-proxy:v0.1.0 - name: OPERAND_IMAGE_VERSION value: 1.20.3 - name: ISTIOCSR_OPERAND_IMAGE_VERSION value: 0.16.0 - name: TRUSTMANAGER_OPERAND_IMAGE_VERSION value: 0.20.3 + - name: HTTP01PROXY_OPERAND_IMAGE_VERSION + value: 0.1.0 - name: OPERATOR_IMAGE_VERSION value: 1.20.0 - name: OPERATOR_LOG_LEVEL @@ -933,5 +955,7 @@ spec: name: cert-manager-istiocsr - image: quay.io/jetstack/trust-manager:v0.20.3 name: cert-manager-trust-manager + - image: quay.io/openshift/cert-manager-http01-proxy:v0.1.0 + name: cert-manager-http01proxy replaces: cert-manager-operator.v1.19.0 version: 1.20.0 diff --git a/bundle/manifests/operator.openshift.io_http01proxies.yaml b/bundle/manifests/operator.openshift.io_http01proxies.yaml new file mode 100644 index 000000000..65d3d25af --- /dev/null +++ b/bundle/manifests/operator.openshift.io_http01proxies.yaml @@ -0,0 +1,185 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + creationTimestamp: null + labels: + app.kubernetes.io/name: http01proxy + app.kubernetes.io/part-of: cert-manager-operator + name: http01proxies.operator.openshift.io +spec: + group: operator.openshift.io + names: + categories: + - cert-manager-operator + kind: HTTP01Proxy + listKind: HTTP01ProxyList + plural: http01proxies + shortNames: + - http01proxy + singular: http01proxy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.mode + name: Mode + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].message + name: Message + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + HTTP01Proxy describes the configuration for the HTTP01 challenge proxy + that redirects traffic from the API endpoint on port 80 to ingress routers. + This enables cert-manager to perform HTTP01 ACME challenges for API endpoint certificates. + The name must be `default` to make HTTP01Proxy a singleton. + + When an HTTP01Proxy is created, a MachineConfig with nftables DNAT/SNAT rules is applied to control plane nodes. + 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 is the specification of the desired behavior of the + HTTP01Proxy. + properties: + customDeployment: + description: |- + customDeployment contains configuration options when mode is CustomDeployment. + This field is only valid when mode is CustomDeployment. + properties: + internalPort: + default: 8888 + description: |- + internalPort specifies the internal port used by the proxy service. + Valid values are 1024-65535. + format: int32 + maximum: 65535 + minimum: 1024 + type: integer + type: object + mode: + description: |- + mode controls whether the HTTP01 challenge proxy is active and how it should be deployed. + DefaultDeployment enables the proxy with default configuration. + CustomDeployment enables the proxy with user-specified configuration. + enum: + - DefaultDeployment + - CustomDeployment + type: string + required: + - mode + type: object + x-kubernetes-validations: + - message: customDeployment is required when mode is CustomDeployment + and forbidden otherwise + rule: 'self.mode == ''CustomDeployment'' ? has(self.customDeployment) + : !has(self.customDeployment)' + status: + description: status is the most recently observed status of the HTTP01Proxy. + properties: + conditions: + description: conditions holds information about the current state + of the operand deployment. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + proxyImage: + description: proxyImage is the name of the image and the tag used + for deploying the proxy. + type: string + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: http01proxy is a singleton, .metadata.name must be 'default' + rule: self.metadata.name == 'default' + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/config/crd/bases/operator.openshift.io_http01proxies.yaml b/config/crd/bases/operator.openshift.io_http01proxies.yaml new file mode 100644 index 000000000..26deba512 --- /dev/null +++ b/config/crd/bases/operator.openshift.io_http01proxies.yaml @@ -0,0 +1,179 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + app.kubernetes.io/name: http01proxy + app.kubernetes.io/part-of: cert-manager-operator + name: http01proxies.operator.openshift.io +spec: + group: operator.openshift.io + names: + categories: + - cert-manager-operator + kind: HTTP01Proxy + listKind: HTTP01ProxyList + plural: http01proxies + shortNames: + - http01proxy + singular: http01proxy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.mode + name: Mode + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].message + name: Message + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + HTTP01Proxy describes the configuration for the HTTP01 challenge proxy + that redirects traffic from the API endpoint on port 80 to ingress routers. + This enables cert-manager to perform HTTP01 ACME challenges for API endpoint certificates. + The name must be `default` to make HTTP01Proxy a singleton. + + When an HTTP01Proxy is created, a MachineConfig with nftables DNAT/SNAT rules is applied to control plane nodes. + 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 is the specification of the desired behavior of the + HTTP01Proxy. + properties: + customDeployment: + description: |- + customDeployment contains configuration options when mode is CustomDeployment. + This field is only valid when mode is CustomDeployment. + properties: + internalPort: + default: 8888 + description: |- + internalPort specifies the internal port used by the proxy service. + Valid values are 1024-65535. + format: int32 + maximum: 65535 + minimum: 1024 + type: integer + type: object + mode: + description: |- + mode controls whether the HTTP01 challenge proxy is active and how it should be deployed. + DefaultDeployment enables the proxy with default configuration. + CustomDeployment enables the proxy with user-specified configuration. + enum: + - DefaultDeployment + - CustomDeployment + type: string + required: + - mode + type: object + x-kubernetes-validations: + - message: customDeployment is required when mode is CustomDeployment + and forbidden otherwise + rule: 'self.mode == ''CustomDeployment'' ? has(self.customDeployment) + : !has(self.customDeployment)' + status: + description: status is the most recently observed status of the HTTP01Proxy. + properties: + conditions: + description: conditions holds information about the current state + of the operand deployment. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + proxyImage: + description: proxyImage is the name of the image and the tag used + for deploying the proxy. + type: string + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: http01proxy is a singleton, .metadata.name must be 'default' + rule: self.metadata.name == 'default' + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index b88e88475..bad417696 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -11,6 +11,7 @@ resources: - bases/orders.acme.cert-manager.io-crd.yaml - bases/operator.openshift.io_istiocsrs.yaml - bases/operator.openshift.io_trustmanagers.yaml +- bases/operator.openshift.io_http01proxies.yaml - bases/customresourcedefinition_bundles.trust.cert-manager.io.yml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index af071a2b7..2f1131332 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -86,12 +86,16 @@ spec: value: quay.io/jetstack/cert-manager-istio-csr:v0.16.0 - name: RELATED_IMAGE_CERT_MANAGER_TRUST_MANAGER value: quay.io/jetstack/trust-manager:v0.20.3 + - name: RELATED_IMAGE_CERT_MANAGER_HTTP01PROXY + value: quay.io/openshift/cert-manager-http01-proxy:v0.1.0 - name: OPERAND_IMAGE_VERSION value: 1.20.3 - name: ISTIOCSR_OPERAND_IMAGE_VERSION value: 0.16.0 - name: TRUSTMANAGER_OPERAND_IMAGE_VERSION value: 0.20.3 + - name: HTTP01PROXY_OPERAND_IMAGE_VERSION + value: 0.1.0 - name: OPERATOR_IMAGE_VERSION value: 1.20.0 - name: OPERATOR_LOG_LEVEL diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f5d902e21..6349271ae 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -216,6 +216,18 @@ rules: - patch - update - watch +- apiGroups: + - machineconfiguration.openshift.io + resources: + - machineconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - networking.k8s.io resources: @@ -246,6 +258,7 @@ rules: - operator.openshift.io resources: - certmanagers/finalizers + - http01proxies/finalizers - istiocsrs/finalizers - trustmanagers/finalizers verbs: @@ -254,6 +267,7 @@ rules: - operator.openshift.io resources: - certmanagers/status + - http01proxies/status - istiocsrs/status - trustmanagers/status verbs: @@ -263,6 +277,7 @@ rules: - apiGroups: - operator.openshift.io resources: + - http01proxies - istiocsrs - trustmanagers verbs: diff --git a/config/samples/operator.openshift.io_v1alpha1_http01proxy.yaml b/config/samples/operator.openshift.io_v1alpha1_http01proxy.yaml new file mode 100644 index 000000000..d5988f361 --- /dev/null +++ b/config/samples/operator.openshift.io_v1alpha1_http01proxy.yaml @@ -0,0 +1,7 @@ +apiVersion: operator.openshift.io/v1alpha1 +kind: HTTP01Proxy +metadata: + name: default + namespace: cert-manager-operator +spec: + mode: DefaultDeployment diff --git a/pkg/controller/http01proxy/constants.go b/pkg/controller/http01proxy/constants.go new file mode 100644 index 000000000..a0796d1ef --- /dev/null +++ b/pkg/controller/http01proxy/constants.go @@ -0,0 +1,34 @@ +package http01proxy + +import ( + "time" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + infrastructureGVK = schema.GroupVersionKind{ + Group: "config.openshift.io", + Version: "v1", + Kind: "Infrastructure", + } + + machineConfigGVK = schema.GroupVersionKind{ + Group: "machineconfiguration.openshift.io", + Version: "v1", + Kind: "MachineConfig", + } +) + +const ( + http01proxyCommonName = "cert-manager-http01-proxy" + ControllerName = http01proxyCommonName + "-controller" + + controllerProcessedAnnotation = "operator.openshift.io/http01-proxy-processed" + finalizer = "http01proxy.openshift.operator.io/" + ControllerName + defaultRequeueTime = time.Second * 30 + + http01proxyObjectName = "default" + + machineConfigName = "98-nftables-crtmgr-http01-dnat" +) diff --git a/pkg/controller/http01proxy/controller.go b/pkg/controller/http01proxy/controller.go new file mode 100644 index 000000000..16ee18bd4 --- /dev/null +++ b/pkg/controller/http01proxy/controller.go @@ -0,0 +1,159 @@ +package http01proxy + +import ( + "context" + "fmt" + "sync" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/go-logr/logr" + configv1 "github.com/openshift/api/config/v1" + + v1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + "github.com/openshift/cert-manager-operator/pkg/controller/common" +) + +// Reconciler reconciles HTTP01Proxy objects and manages the nftables MachineConfig on baremetal clusters. +type Reconciler struct { + common.CtrlClient + + eventRecorder record.EventRecorder + log logr.Logger + + cachedPlatform *platformInfo + platformMu sync.Mutex +} + +// +kubebuilder:rbac:groups=operator.openshift.io,resources=http01proxies,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=operator.openshift.io,resources=http01proxies/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=operator.openshift.io,resources=http01proxies/finalizers,verbs=update +// +kubebuilder:rbac:groups=config.openshift.io,resources=infrastructures,verbs=get;list;watch +// +kubebuilder:rbac:groups=machineconfiguration.openshift.io,resources=machineconfigs,verbs=get;list;watch;create;update;patch;delete + +func New(mgr ctrl.Manager) (*Reconciler, error) { + c, err := common.NewClient(mgr) + if err != nil { + return nil, err + } + return &Reconciler{ + CtrlClient: c, + eventRecorder: mgr.GetEventRecorderFor(ControllerName), + log: ctrl.Log.WithName(ControllerName), + }, nil +} + +// SetupWithManager registers the controller with the manager and sets up watches for HTTP01Proxy and Infrastructure resources. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + infrastructureMapFunc := func(ctx context.Context, obj client.Object) []reconcile.Request { + if obj.GetName() != "cluster" { + return []reconcile.Request{} + } + r.platformMu.Lock() + r.cachedPlatform = nil + r.platformMu.Unlock() + r.log.V(2).Info("infrastructure/cluster changed, invalidated platform cache") + + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: http01proxyObjectName, + Namespace: common.OperatorNamespace, + }, + }, + } + } + + builder := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.HTTP01Proxy{}). + Named(ControllerName) + + // Only watch Infrastructure if the CRD exists (MicroShift does not serve config.openshift.io). + if _, err := mgr.GetRESTMapper().RESTMapping(infrastructureGVK.GroupKind(), infrastructureGVK.Version); err == nil { + builder = builder.Watches(&configv1.Infrastructure{}, handler.EnqueueRequestsFromMapFunc(infrastructureMapFunc)) + } else { + r.log.V(1).Info("Infrastructure CRD not available, skipping watch") + } + + return builder.Complete(r) +} + +// Reconcile handles a single reconciliation loop for an HTTP01Proxy resource. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.log.V(1).Info("reconciling", "request", req) + + if req.Namespace != common.OperatorNamespace { + r.log.V(1).Info("ignoring http01proxy in unexpected namespace", "namespace", req.Namespace, "expected", common.OperatorNamespace) + return ctrl.Result{}, nil + } + + proxy := &v1alpha1.HTTP01Proxy{} + if err := r.Get(ctx, req.NamespacedName, proxy); err != nil { + if errors.IsNotFound(err) { + r.log.V(1).Info("http01proxy object not found, skipping reconciliation", "request", req) + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("failed to fetch http01proxy %q during reconciliation: %w", req.NamespacedName, err) + } + + if !proxy.DeletionTimestamp.IsZero() { + r.log.V(1).Info("http01proxy is marked for deletion", "namespace", req.NamespacedName) + + if err := r.cleanUp(ctx, proxy); err != nil { + return ctrl.Result{}, fmt.Errorf("clean up failed for %q http01proxy deletion: %w", req.NamespacedName, err) + } + + if err := r.removeFinalizer(ctx, proxy); err != nil { + return ctrl.Result{}, err + } + + r.log.V(1).Info("removed finalizer, cleanup complete", "request", req.NamespacedName) + return ctrl.Result{}, nil + } + + if err := r.addFinalizer(ctx, proxy); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update %q http01proxy with finalizers: %w", req.NamespacedName, err) + } + + return r.processReconcileRequest(ctx, proxy, req.NamespacedName) +} + +func (r *Reconciler) processReconcileRequest(ctx context.Context, proxy *v1alpha1.HTTP01Proxy, req types.NamespacedName) (ctrl.Result, error) { + if !common.ContainsAnnotation(proxy, controllerProcessedAnnotation) && len(proxy.Status.Conditions) == 0 { + r.log.V(1).Info("starting reconciliation of newly created http01proxy", "namespace", proxy.GetNamespace(), "name", proxy.GetName()) + } + + reconcileErr := r.reconcileHTTP01ProxyDeployment(ctx, proxy) + if reconcileErr != nil { + r.log.Error(reconcileErr, "failed to reconcile HTTP01Proxy deployment", "request", req) + } + + return common.HandleReconcileResult( + &proxy.Status.ConditionalStatus, + reconcileErr, + r.log.WithValues("namespace", proxy.GetNamespace(), "name", proxy.GetName()), + func(prependErr error) error { + return r.updateCondition(ctx, proxy, prependErr) + }, + defaultRequeueTime, + ) +} + +func (r *Reconciler) cleanUp(ctx context.Context, proxy *v1alpha1.HTTP01Proxy) error { + r.log.V(1).Info("cleaning up http01proxy resources", "namespace", proxy.GetNamespace(), "name", proxy.GetName()) + r.eventRecorder.Eventf(proxy, corev1.EventTypeNormal, "CleanUp", "cleaning up resources for http01proxy %s/%s", proxy.GetNamespace(), proxy.GetName()) + + if err := r.deleteMachineConfig(ctx); err != nil { + return fmt.Errorf("failed to delete MachineConfig: %w", err) + } + + return nil +} diff --git a/pkg/controller/http01proxy/controller_test.go b/pkg/controller/http01proxy/controller_test.go new file mode 100644 index 000000000..868047277 --- /dev/null +++ b/pkg/controller/http01proxy/controller_test.go @@ -0,0 +1,197 @@ +package http01proxy + +import ( + "context" + "fmt" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + + "github.com/go-logr/logr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + v1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + "github.com/openshift/cert-manager-operator/pkg/controller/common" + "github.com/openshift/cert-manager-operator/pkg/controller/common/fakes" +) + +func newTestReconciler(client *fakes.FakeCtrlClient) *Reconciler { + return &Reconciler{ + CtrlClient: client, + eventRecorder: record.NewFakeRecorder(10), + log: logr.Discard(), + } +} + +func TestReconcileWrongNamespace(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + r := newTestReconciler(fakeClient) + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "default", Namespace: "wrong-namespace"}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != (ctrl.Result{}) { + t.Errorf("expected empty result, got %v", result) + } + if fakeClient.GetCallCount() != 0 { + t.Errorf("expected no Get calls for wrong namespace, got %d", fakeClient.GetCallCount()) + } +} + +func TestReconcileNotFound(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetReturns(errors.NewNotFound(schema.GroupResource{Group: "operator.openshift.io", Resource: "http01proxies"}, http01proxyObjectName)) + + r := newTestReconciler(fakeClient) + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: http01proxyObjectName, Namespace: common.OperatorNamespace}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != (ctrl.Result{}) { + t.Errorf("expected empty result, got %v", result) + } +} + +func TestReconcileGetError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetReturns(fmt.Errorf("api server unavailable")) + + r := newTestReconciler(fakeClient) + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: http01proxyObjectName, Namespace: common.OperatorNamespace}, + }) + + if err == nil { + t.Fatal("expected error for Get failure") + } + if !strings.Contains(err.Error(), "failed to fetch") { + t.Errorf("error = %q, want substring %q", err.Error(), "failed to fetch") + } +} + +func TestReconcileDeletion(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + now := metav1.Now() + fakeClient.GetStub = func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + proxy := obj.(*v1alpha1.HTTP01Proxy) + proxy.Name = http01proxyObjectName + proxy.Namespace = common.OperatorNamespace + proxy.DeletionTimestamp = &now + proxy.Finalizers = []string{finalizer} + return nil + } + fakeClient.DeleteReturns(nil) + fakeClient.UpdateWithRetryReturns(nil) + + r := newTestReconciler(fakeClient) + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: http01proxyObjectName, Namespace: common.OperatorNamespace}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != (ctrl.Result{}) { + t.Errorf("expected empty result after deletion, got %v", result) + } + if fakeClient.DeleteCallCount() == 0 { + t.Error("expected Delete to be called during cleanup") + } +} + +func TestCleanUpDeletesMachineConfig(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.DeleteReturns(nil) + + r := newTestReconciler(fakeClient) + proxy := &v1alpha1.HTTP01Proxy{} + proxy.SetName(http01proxyObjectName) + proxy.SetNamespace(common.OperatorNamespace) + + err := r.cleanUp(context.Background(), proxy) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fakeClient.DeleteCallCount() != 1 { + t.Errorf("expected 1 Delete call (MachineConfig), got %d", fakeClient.DeleteCallCount()) + } +} + +func TestCleanUpMachineConfigDeleteFails(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.DeleteReturnsOnCall(0, fmt.Errorf("mc delete failed")) + + r := newTestReconciler(fakeClient) + proxy := &v1alpha1.HTTP01Proxy{} + proxy.SetName(http01proxyObjectName) + proxy.SetNamespace(common.OperatorNamespace) + + err := r.cleanUp(context.Background(), proxy) + if err == nil { + t.Fatal("expected error when MachineConfig delete fails") + } + if !strings.Contains(err.Error(), "MachineConfig") { + t.Errorf("error = %q, want substring %q", err.Error(), "MachineConfig") + } +} + +func TestCleanUpMachineConfigAlreadyGone(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.DeleteReturns(errors.NewNotFound( + schema.GroupResource{Group: "machineconfiguration.openshift.io", Resource: "machineconfigs"}, + machineConfigName, + )) + + r := newTestReconciler(fakeClient) + proxy := &v1alpha1.HTTP01Proxy{} + proxy.SetName(http01proxyObjectName) + proxy.SetNamespace(common.OperatorNamespace) + + err := r.cleanUp(context.Background(), proxy) + if err != nil { + t.Fatalf("cleanUp should succeed when MachineConfig is already gone, got: %v", err) + } +} + +func TestReconcileDeletionWithoutFinalizer(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + now := metav1.Now() + fakeClient.GetStub = func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + proxy := obj.(*v1alpha1.HTTP01Proxy) + proxy.Name = http01proxyObjectName + proxy.Namespace = common.OperatorNamespace + proxy.DeletionTimestamp = &now + proxy.Finalizers = []string{} + return nil + } + + r := newTestReconciler(fakeClient) + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: http01proxyObjectName, Namespace: common.OperatorNamespace}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != (ctrl.Result{}) { + t.Errorf("expected empty result, got %v", result) + } +} diff --git a/pkg/controller/http01proxy/infrastructure.go b/pkg/controller/http01proxy/infrastructure.go new file mode 100644 index 000000000..b8f1333c5 --- /dev/null +++ b/pkg/controller/http01proxy/infrastructure.go @@ -0,0 +1,106 @@ +package http01proxy + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +const ( + platformBareMetal = "BareMetal" + platformUnknown = "Unknown" +) + +// platformInfo holds the discovered platform details needed to decide +// whether the HTTP01 proxy should be deployed. +type platformInfo struct { + platformType string + apiVIPs []string + ingressVIPs []string +} + +// getOrDiscoverPlatform returns cached platform info, or fetches it on first call. +func (r *Reconciler) getOrDiscoverPlatform(ctx context.Context) (*platformInfo, error) { + r.platformMu.Lock() + defer r.platformMu.Unlock() + if r.cachedPlatform != nil { + return r.cachedPlatform, nil + } + info, err := r.discoverPlatform(ctx) + if err != nil { + return nil, err + } + r.cachedPlatform = info + return info, nil +} + +// discoverPlatform reads the Infrastructure CR and returns platform details. +func (r *Reconciler) discoverPlatform(ctx context.Context) (*platformInfo, error) { + infra := &unstructured.Unstructured{} + infra.SetGroupVersionKind(infrastructureGVK) + + if err := r.Get(ctx, types.NamespacedName{Name: "cluster"}, infra); err != nil { + if errors.IsNotFound(err) || meta.IsNoMatchError(err) { + return &platformInfo{platformType: platformUnknown}, nil + } + return nil, fmt.Errorf("failed to get infrastructure/cluster: %w", err) + } + + platformType, found, err := unstructured.NestedString(infra.Object, "status", "platformStatus", "type") + if err != nil { + return nil, fmt.Errorf("failed to parse infrastructure status.platformStatus.type: %w", err) + } + if !found { + return nil, fmt.Errorf("infrastructure status.platformStatus.type not found") + } + + info := &platformInfo{ + platformType: platformType, + } + + switch platformType { + case platformBareMetal: + apiVIPs, _, err := unstructured.NestedStringSlice(infra.Object, "status", "platformStatus", "baremetal", "apiServerInternalIPs") + if err != nil { + return nil, fmt.Errorf("failed to parse baremetal.apiServerInternalIPs: %w", err) + } + ingressVIPs, _, err := unstructured.NestedStringSlice(infra.Object, "status", "platformStatus", "baremetal", "ingressIPs") + if err != nil { + return nil, fmt.Errorf("failed to parse baremetal.ingressIPs: %w", err) + } + info.apiVIPs = apiVIPs + info.ingressVIPs = ingressVIPs + } + + return info, nil +} + +// validatePlatform checks whether the platform supports HTTP01 proxy deployment. +// Returns a human-readable reason if the platform is not supported, or empty string if OK. +func validatePlatform(info *platformInfo) string { + if info.platformType != platformBareMetal { + return fmt.Sprintf("platform type %q is not supported; HTTP01 proxy is only supported on BareMetal platforms", info.platformType) + } + + if len(info.apiVIPs) == 0 { + return "no API server VIPs found in infrastructure status; cannot deploy HTTP01 proxy" + } + + if len(info.ingressVIPs) == 0 { + return "no ingress VIPs found in infrastructure status; cannot deploy HTTP01 proxy" + } + + for _, apiVIP := range info.apiVIPs { + for _, ingressVIP := range info.ingressVIPs { + if apiVIP == ingressVIP { + return "API VIP and ingress VIP are the same; HTTP01 proxy is not needed" + } + } + } + + return "" +} diff --git a/pkg/controller/http01proxy/infrastructure_test.go b/pkg/controller/http01proxy/infrastructure_test.go new file mode 100644 index 000000000..b54822455 --- /dev/null +++ b/pkg/controller/http01proxy/infrastructure_test.go @@ -0,0 +1,292 @@ +package http01proxy + +import ( + "context" + "fmt" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/go-logr/logr" + + "github.com/openshift/cert-manager-operator/pkg/controller/common/fakes" +) + +func TestValidatePlatform(t *testing.T) { + tests := []struct { + name string + info *platformInfo + wantMsg string + wantEmpty bool + }{ + { + name: "non-baremetal platform", + info: &platformInfo{platformType: "AWS"}, + wantMsg: "not supported", + }, + { + name: "baremetal no API VIPs", + info: &platformInfo{platformType: "BareMetal", apiVIPs: nil, ingressVIPs: []string{"10.0.0.2"}}, + wantMsg: "no API server VIPs", + }, + { + name: "baremetal no ingress VIPs", + info: &platformInfo{platformType: "BareMetal", apiVIPs: []string{"10.0.0.1"}, ingressVIPs: nil}, + wantMsg: "no ingress VIPs", + }, + { + name: "baremetal overlapping VIPs", + info: &platformInfo{platformType: "BareMetal", apiVIPs: []string{"10.0.0.1"}, ingressVIPs: []string{"10.0.0.1"}}, + wantMsg: "are the same", + }, + { + name: "baremetal valid distinct VIPs", + info: &platformInfo{platformType: "BareMetal", apiVIPs: []string{"10.0.0.1"}, ingressVIPs: []string{"10.0.0.2"}}, + wantEmpty: true, + }, + { + name: "baremetal multiple distinct VIPs", + info: &platformInfo{platformType: "BareMetal", apiVIPs: []string{"10.0.0.1", "fd00::1"}, ingressVIPs: []string{"10.0.0.2", "fd00::2"}}, + wantEmpty: true, + }, + { + name: "baremetal one overlapping pair among multiple", + info: &platformInfo{platformType: "BareMetal", apiVIPs: []string{"10.0.0.1", "10.0.0.3"}, ingressVIPs: []string{"10.0.0.2", "10.0.0.1"}}, + wantMsg: "are the same", + }, + { + name: "empty platform type", + info: &platformInfo{platformType: ""}, + wantMsg: "not supported", + }, + { + name: "None platform type", + info: &platformInfo{platformType: "None"}, + wantMsg: "not supported", + }, + { + name: "baremetal empty VIP slices", + info: &platformInfo{platformType: "BareMetal", apiVIPs: []string{}, ingressVIPs: []string{"10.0.0.2"}}, + wantMsg: "no API server VIPs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validatePlatform(tt.info) + if tt.wantEmpty { + if got != "" { + t.Errorf("validatePlatform() = %q, want empty string", got) + } + return + } + if got == "" { + t.Error("validatePlatform() = empty string, want non-empty error message") + return + } + if !strings.Contains(got, tt.wantMsg) { + t.Errorf("validatePlatform() = %q, want substring %q", got, tt.wantMsg) + } + }) + } +} + +func TestDiscoverPlatform(t *testing.T) { + tests := []struct { + name string + getStub func(context.Context, client.ObjectKey, client.Object) error + wantErr bool + wantErrMsg string + wantPlatform string + wantAPIVIPs int + }{ + { + name: "Get error", + getStub: func(_ context.Context, _ client.ObjectKey, _ client.Object) error { + return fmt.Errorf("connection refused") + }, + wantErr: true, + wantErrMsg: "failed to get infrastructure/cluster", + }, + { + name: "missing platformStatus.type", + getStub: func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.Object = map[string]interface{}{ + "status": map[string]interface{}{}, + } + return nil + }, + wantErr: true, + wantErrMsg: "not found", + }, + { + name: "non-BareMetal platform", + getStub: func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.Object = map[string]interface{}{ + "status": map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "AWS", + }, + }, + } + return nil + }, + wantPlatform: "AWS", + wantAPIVIPs: 0, + }, + { + name: "BareMetal with VIPs", + getStub: func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.Object = map[string]interface{}{ + "status": map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "BareMetal", + "baremetal": map[string]interface{}{ + "apiServerInternalIPs": []interface{}{"10.0.0.1"}, + "ingressIPs": []interface{}{"10.0.0.2"}, + }, + }, + }, + } + return nil + }, + wantPlatform: "BareMetal", + wantAPIVIPs: 1, + }, + { + name: "BareMetal without VIP fields", + getStub: func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.Object = map[string]interface{}{ + "status": map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "BareMetal", + "baremetal": map[string]interface{}{}, + }, + }, + } + return nil + }, + wantPlatform: "BareMetal", + wantAPIVIPs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetStub = tt.getStub + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + } + + info, err := r.discoverPlatform(context.Background()) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErrMsg) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.platformType != tt.wantPlatform { + t.Errorf("platformType = %q, want %q", info.platformType, tt.wantPlatform) + } + if len(info.apiVIPs) != tt.wantAPIVIPs { + t.Errorf("len(apiVIPs) = %d, want %d", len(info.apiVIPs), tt.wantAPIVIPs) + } + }) + } +} + +func TestGetOrDiscoverPlatform(t *testing.T) { + t.Run("returns cached platform without calling Get", func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + cached := &platformInfo{platformType: "BareMetal", apiVIPs: []string{"10.0.0.1"}, ingressVIPs: []string{"10.0.0.2"}} + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + cachedPlatform: cached, + } + + info, err := r.getOrDiscoverPlatform(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info != cached { + t.Error("expected cached platform to be returned") + } + if fakeClient.GetCallCount() != 0 { + t.Errorf("expected 0 Get calls with cached platform, got %d", fakeClient.GetCallCount()) + } + }) + + t.Run("discovers and caches on first call", func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetStub = func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.Object = map[string]interface{}{ + "status": map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "AWS", + }, + }, + } + return nil + } + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + } + + info, err := r.getOrDiscoverPlatform(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.platformType != "AWS" { + t.Errorf("platformType = %q, want %q", info.platformType, "AWS") + } + if r.cachedPlatform == nil { + t.Error("expected cachedPlatform to be set") + } + + // Second call should use cache + info2, err := r.getOrDiscoverPlatform(context.Background()) + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + if info2 != info { + t.Error("second call should return same cached pointer") + } + if fakeClient.GetCallCount() != 1 { + t.Errorf("expected 1 Get call total (cached second time), got %d", fakeClient.GetCallCount()) + } + }) + + t.Run("does not cache on error", func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetReturns(fmt.Errorf("api unavailable")) + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + } + + _, err := r.getOrDiscoverPlatform(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if r.cachedPlatform != nil { + t.Error("cachedPlatform should remain nil on error") + } + }) +} diff --git a/pkg/controller/http01proxy/install_http01proxy.go b/pkg/controller/http01proxy/install_http01proxy.go new file mode 100644 index 000000000..e3f4bff0a --- /dev/null +++ b/pkg/controller/http01proxy/install_http01proxy.go @@ -0,0 +1,38 @@ +package http01proxy + +import ( + "context" + "fmt" + + "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + "github.com/openshift/cert-manager-operator/pkg/controller/common" +) + +func (r *Reconciler) reconcileHTTP01ProxyDeployment(ctx context.Context, proxy *v1alpha1.HTTP01Proxy) error { + info, err := r.getOrDiscoverPlatform(ctx) + if err != nil { + return common.NewRetryRequiredError(err, "failed to discover platform") + } + + if reason := validatePlatform(info); reason != "" { + r.log.V(1).Info("platform not supported for HTTP01 proxy", "platformType", info.platformType) + if err := r.cleanUp(ctx, proxy); err != nil { + return common.NewRetryRequiredError(err, "failed to clean up resources after platform validation failure") + } + return common.NewIrrecoverableError(fmt.Errorf("platform validation failed"), "%s", reason) + } + + if err := r.createOrApplyMachineConfig(ctx, info); err != nil { + r.log.Error(err, "failed to reconcile DNAT MachineConfig") + return err + } + + if common.AddAnnotation(proxy, controllerProcessedAnnotation, "true") { + if err := r.UpdateWithRetry(ctx, proxy); err != nil { + return fmt.Errorf("failed to update processed annotation to %s/%s: %w", proxy.GetNamespace(), proxy.GetName(), err) + } + } + + r.log.V(4).Info("finished reconciliation of http01proxy", "namespace", proxy.GetNamespace(), "name", proxy.GetName()) + return nil +} diff --git a/pkg/controller/http01proxy/install_http01proxy_test.go b/pkg/controller/http01proxy/install_http01proxy_test.go new file mode 100644 index 000000000..26901c967 --- /dev/null +++ b/pkg/controller/http01proxy/install_http01proxy_test.go @@ -0,0 +1,189 @@ +package http01proxy + +import ( + "context" + "fmt" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openshift/cert-manager-operator/pkg/controller/common/fakes" +) + +func TestReconcileHTTP01ProxyDeploymentPlatformDiscoveryError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetReturns(fmt.Errorf("infrastructure unavailable")) + + r := &Reconciler{ + CtrlClient: fakeClient, + eventRecorder: record.NewFakeRecorder(10), + log: logr.Discard(), + } + + proxy := testProxy() + err := r.reconcileHTTP01ProxyDeployment(context.Background(), proxy) + if err == nil { + t.Fatal("expected error when platform discovery fails") + } + if !strings.Contains(err.Error(), "failed to discover platform") { + t.Errorf("error = %q, want substring %q", err.Error(), "failed to discover platform") + } +} + +func TestReconcileHTTP01ProxyDeploymentUnsupportedPlatform(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetCalls(func(_ context.Context, nn types.NamespacedName, obj client.Object) error { + if u, ok := obj.(*unstructured.Unstructured); ok { + u.SetName("cluster") + u.Object["status"] = map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "AWS", + }, + } + return nil + } + return errors.NewNotFound(schema.GroupResource{}, nn.Name) + }) + fakeClient.DeleteReturns(nil) + + r := &Reconciler{ + CtrlClient: fakeClient, + eventRecorder: record.NewFakeRecorder(10), + log: logr.Discard(), + } + + proxy := testProxy() + err := r.reconcileHTTP01ProxyDeployment(context.Background(), proxy) + if err == nil { + t.Fatal("expected error for unsupported platform") + } + if !strings.Contains(err.Error(), "not supported") { + t.Errorf("error = %q, want substring %q", err.Error(), "not supported") + } +} + +func TestReconcileHTTP01ProxyDeploymentHappyPath(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + getCalls := 0 + fakeClient.GetCalls(func(_ context.Context, nn types.NamespacedName, obj client.Object) error { + if u, ok := obj.(*unstructured.Unstructured); ok { + getCalls++ + if getCalls == 1 { + u.SetName("cluster") + u.Object["status"] = map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "BareMetal", + "baremetal": map[string]interface{}{ + "apiServerInternalIPs": []interface{}{"192.168.1.1"}, + "ingressIPs": []interface{}{"192.168.1.2"}, + }, + }, + } + return nil + } + return errors.NewNotFound(schema.GroupResource{Group: "machineconfiguration.openshift.io", Resource: "machineconfigs"}, machineConfigName) + } + return nil + }) + fakeClient.CreateReturns(nil) + fakeClient.UpdateWithRetryReturns(nil) + + r := &Reconciler{ + CtrlClient: fakeClient, + eventRecorder: record.NewFakeRecorder(10), + log: logr.Discard(), + } + + proxy := testProxy() + err := r.reconcileHTTP01ProxyDeployment(context.Background(), proxy) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReconcileHTTP01ProxyDeploymentMachineConfigError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + getCalls := 0 + fakeClient.GetCalls(func(_ context.Context, nn types.NamespacedName, obj client.Object) error { + if u, ok := obj.(*unstructured.Unstructured); ok { + getCalls++ + if getCalls == 1 { + u.SetName("cluster") + u.Object["status"] = map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "BareMetal", + "baremetal": map[string]interface{}{ + "apiServerInternalIPs": []interface{}{"192.168.1.1"}, + "ingressIPs": []interface{}{"192.168.1.2"}, + }, + }, + } + return nil + } + return fmt.Errorf("machineconfig get failed") + } + return nil + }) + + r := &Reconciler{ + CtrlClient: fakeClient, + eventRecorder: record.NewFakeRecorder(10), + log: logr.Discard(), + } + + proxy := testProxy() + err := r.reconcileHTTP01ProxyDeployment(context.Background(), proxy) + if err == nil { + t.Fatal("expected error when MachineConfig operations fail") + } +} + +func TestReconcileHTTP01ProxyDeploymentAnnotationUpdateError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + getCalls := 0 + fakeClient.GetCalls(func(_ context.Context, nn types.NamespacedName, obj client.Object) error { + if u, ok := obj.(*unstructured.Unstructured); ok { + getCalls++ + if getCalls == 1 { + u.SetName("cluster") + u.Object["status"] = map[string]interface{}{ + "platformStatus": map[string]interface{}{ + "type": "BareMetal", + "baremetal": map[string]interface{}{ + "apiServerInternalIPs": []interface{}{"192.168.1.1"}, + "ingressIPs": []interface{}{"192.168.1.2"}, + }, + }, + } + return nil + } + return errors.NewNotFound(schema.GroupResource{Group: "machineconfiguration.openshift.io", Resource: "machineconfigs"}, machineConfigName) + } + return nil + }) + fakeClient.CreateReturns(nil) + fakeClient.UpdateWithRetryReturns(fmt.Errorf("update annotation failed")) + + r := &Reconciler{ + CtrlClient: fakeClient, + eventRecorder: record.NewFakeRecorder(10), + log: logr.Discard(), + } + + proxy := testProxy() + err := r.reconcileHTTP01ProxyDeployment(context.Background(), proxy) + if err == nil { + t.Fatal("expected error when annotation update fails") + } + if !strings.Contains(err.Error(), "failed to update processed annotation") { + t.Errorf("error = %q, want substring %q", err.Error(), "failed to update processed annotation") + } +} diff --git a/pkg/controller/http01proxy/machineconfig.go b/pkg/controller/http01proxy/machineconfig.go new file mode 100644 index 000000000..f7959f1dd --- /dev/null +++ b/pkg/controller/http01proxy/machineconfig.go @@ -0,0 +1,168 @@ +package http01proxy + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "reflect" + "text/template" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/yaml" +) + +const nftRulesTemplate = `table inet crtmgr_http01_dnat +delete table inet crtmgr_http01_dnat +table inet crtmgr_http01_dnat { + chain prerouting { + type nat hook prerouting priority 0; + ip daddr {{ .APIVIP }} tcp dport 80 dnat ip to {{ .IngressVIP }}:80 + } + chain postrouting { + type nat hook postrouting priority 100; + ip daddr {{ .IngressVIP }} tcp dport 80 masquerade + } +} +` + +const machineConfigTemplate = `apiVersion: machineconfiguration.openshift.io/v1 +kind: MachineConfig +metadata: + labels: + machineconfiguration.openshift.io/role: master + name: {{ .Name }} +spec: + config: + ignition: + version: 3.4.0 + storage: + files: + - contents: + source: data:text/plain;charset=utf-8;base64,{{ .NFTRulesBase64 }} + mode: 384 + overwrite: true + path: /etc/sysconfig/nftables-crtmgr-http01.conf + systemd: + units: + - contents: | + [Unit] + Description=cert-manager HTTP01 DNAT nftables rules + Wants=network-pre.target + Before=network-pre.target + [Service] + Type=oneshot + ProtectSystem=full + ProtectHome=true + ExecStartPre=/sbin/sysctl -w net.ipv4.ip_forward=1 + ExecStart=/sbin/nft -f /etc/sysconfig/nftables-crtmgr-http01.conf + ExecStart=/bin/bash -c '/usr/sbin/iptables -C FORWARD -p tcp -d {{ .IngressVIP }}/32 --dport 80 -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I FORWARD 1 -p tcp -d {{ .IngressVIP }}/32 --dport 80 -j ACCEPT' + ExecReload=/sbin/nft -f /etc/sysconfig/nftables-crtmgr-http01.conf + ExecStop=/sbin/nft 'add table inet crtmgr_http01_dnat; delete table inet crtmgr_http01_dnat' + ExecStop=/bin/bash -c '/usr/sbin/iptables -D FORWARD -p tcp -d {{ .IngressVIP }}/32 --dport 80 -j ACCEPT 2>/dev/null; true' + RemainAfterExit=yes + [Install] + WantedBy=multi-user.target + enabled: true + name: crtmgr-http01-dnat.service +` + +var ( + nftRulesTmpl = template.Must(template.New("nft").Parse(nftRulesTemplate)) + machineConfigTmpl = template.Must(template.New("mc").Parse(machineConfigTemplate)) +) + +type nftRulesRenderData struct { + APIVIP string + IngressVIP string +} + +type machineConfigRenderData struct { + Name string + NFTRulesBase64 string + IngressVIP string +} + +func renderMachineConfig(apiVIP, ingressVIP string) (*unstructured.Unstructured, error) { + var nftBuf bytes.Buffer + if err := nftRulesTmpl.Execute(&nftBuf, nftRulesRenderData{APIVIP: apiVIP, IngressVIP: ingressVIP}); err != nil { + return nil, fmt.Errorf("failed to render nftables rules: %w", err) + } + + mcData := machineConfigRenderData{ + Name: machineConfigName, + NFTRulesBase64: base64.StdEncoding.EncodeToString(nftBuf.Bytes()), + IngressVIP: ingressVIP, + } + + var mcBuf bytes.Buffer + if err := machineConfigTmpl.Execute(&mcBuf, mcData); err != nil { + return nil, fmt.Errorf("failed to render MachineConfig template: %w", err) + } + + obj := &unstructured.Unstructured{} + if err := yaml.NewYAMLOrJSONDecoder(&mcBuf, mcBuf.Len()).Decode(&obj.Object); err != nil { + return nil, fmt.Errorf("failed to decode rendered MachineConfig: %w", err) + } + + return obj, nil +} + +func (r *Reconciler) createOrApplyMachineConfig(ctx context.Context, info *platformInfo) error { + if len(info.apiVIPs) == 0 { + return fmt.Errorf("no API VIPs available for MachineConfig") + } + if len(info.ingressVIPs) == 0 { + return fmt.Errorf("no ingress VIPs available for MachineConfig") + } + + desired, err := renderMachineConfig(info.apiVIPs[0], info.ingressVIPs[0]) + if err != nil { + return fmt.Errorf("failed to render DNAT MachineConfig: %w", err) + } + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(machineConfigGVK) + err = r.Get(ctx, types.NamespacedName{Name: machineConfigName}, existing) + if err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to get MachineConfig %q: %w", machineConfigName, err) + } + r.log.V(2).Info("creating MachineConfig", "name", machineConfigName) + if err := r.Create(ctx, desired); err != nil { + return fmt.Errorf("failed to create MachineConfig %q: %w", machineConfigName, err) + } + return nil + } + + desiredSpec, _, _ := unstructured.NestedMap(desired.Object, "spec") + existingSpec, _, _ := unstructured.NestedMap(existing.Object, "spec") + if reflect.DeepEqual(desiredSpec, existingSpec) { + r.log.V(4).Info("MachineConfig unchanged, skipping update", "name", machineConfigName) + return nil + } + + desired.SetResourceVersion(existing.GetResourceVersion()) + r.log.V(2).Info("updating MachineConfig", "name", machineConfigName) + if err := r.Update(ctx, desired); err != nil { + return fmt.Errorf("failed to update MachineConfig %q: %w", machineConfigName, err) + } + return nil +} + +func (r *Reconciler) deleteMachineConfig(ctx context.Context) error { + mc := &unstructured.Unstructured{} + mc.SetGroupVersionKind(machineConfigGVK) + mc.SetName(machineConfigName) + if err := r.Delete(ctx, mc); err != nil { + if errors.IsNotFound(err) || meta.IsNoMatchError(err) { + return nil + } + return fmt.Errorf("failed to delete MachineConfig %q: %w", machineConfigName, err) + } + r.log.V(2).Info("deleted MachineConfig", "name", machineConfigName) + return nil +} diff --git a/pkg/controller/http01proxy/machineconfig_test.go b/pkg/controller/http01proxy/machineconfig_test.go new file mode 100644 index 000000000..68d7b4bcc --- /dev/null +++ b/pkg/controller/http01proxy/machineconfig_test.go @@ -0,0 +1,424 @@ +package http01proxy + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openshift/cert-manager-operator/pkg/controller/common/fakes" +) + +var machineConfigGR = schema.GroupResource{Group: "machineconfiguration.openshift.io", Resource: "machineconfigs"} + +func extractNFTRules(t *testing.T, mc *unstructured.Unstructured) string { + t.Helper() + files, found, err := unstructured.NestedSlice(mc.Object, "spec", "config", "storage", "files") + if err != nil || !found || len(files) == 0 { + t.Fatalf("storage files not found: found=%v err=%v", found, err) + } + file := files[0].(map[string]interface{}) + contents := file["contents"].(map[string]interface{}) + source := contents["source"].(string) + b64Data := strings.TrimPrefix(source, "data:text/plain;charset=utf-8;base64,") + decoded, err := base64.StdEncoding.DecodeString(b64Data) + if err != nil { + t.Fatalf("failed to decode base64 nftables rules: %v", err) + } + return string(decoded) +} + +func extractUnitContents(t *testing.T, mc *unstructured.Unstructured) string { + t.Helper() + units, found, err := unstructured.NestedSlice(mc.Object, "spec", "config", "systemd", "units") + if err != nil || !found || len(units) == 0 { + t.Fatalf("systemd units not found: found=%v err=%v", found, err) + } + unit := units[0].(map[string]interface{}) + return unit["contents"].(string) +} + +func TestRenderMachineConfig(t *testing.T) { + mc, err := renderMachineConfig("10.46.97.1", "10.46.97.48") + if err != nil { + t.Fatalf("renderMachineConfig() error: %v", err) + } + + if mc.GetKind() != "MachineConfig" { + t.Errorf("kind = %q, want MachineConfig", mc.GetKind()) + } + if mc.GetName() != machineConfigName { + t.Errorf("name = %q, want %q", mc.GetName(), machineConfigName) + } + + labels := mc.GetLabels() + if labels["machineconfiguration.openshift.io/role"] != "master" { + t.Errorf("role label = %q, want master", labels["machineconfiguration.openshift.io/role"]) + } + + nftRules := extractNFTRules(t, mc) + + if !strings.Contains(nftRules, "10.46.97.48") { + t.Error("nftables rules should contain the ingress VIP") + } + if !strings.Contains(nftRules, "ip daddr 10.46.97.1") { + t.Error("nftables DNAT rule should match API VIP as destination") + } + if !strings.Contains(nftRules, "dnat ip to 10.46.97.48:80") { + t.Error("nftables rules should contain DNAT rule with 'dnat ip to' for inet table") + } + if !strings.Contains(nftRules, "masquerade") { + t.Error("nftables rules should contain masquerade rule") + } + if !strings.Contains(nftRules, "crtmgr_http01_dnat") { + t.Error("nftables rules should reference the table name") + } + if !strings.Contains(nftRules, "table inet crtmgr_http01_dnat") { + t.Error("nftables rules should use inet (dual-stack) family") + } + if !strings.Contains(nftRules, "hook prerouting") { + t.Error("nftables rules should have prerouting chain") + } + if !strings.Contains(nftRules, "hook postrouting") { + t.Error("nftables rules should have postrouting chain") + } + + units, _, _ := unstructured.NestedSlice(mc.Object, "spec", "config", "systemd", "units") + unit := units[0].(map[string]interface{}) + unitContents := unit["contents"].(string) + + if !strings.Contains(unitContents, "nft -f /etc/sysconfig/nftables-crtmgr-http01.conf") { + t.Error("unit should load nftables rules from config file") + } + if !strings.Contains(unitContents, "sysctl -w net.ipv4.ip_forward=1") { + t.Error("unit should enable ip_forward via sysctl") + } + if !strings.Contains(unitContents, "iptables -C FORWARD") { + t.Error("unit should check for existing iptables FORWARD rule before inserting") + } + if !strings.Contains(unitContents, "iptables -I FORWARD 1 -p tcp -d 10.46.97.48/32 --dport 80 -j ACCEPT") { + t.Error("unit should insert iptables FORWARD ACCEPT rule for ingress VIP") + } + if !strings.Contains(unitContents, "ExecStop=/sbin/nft") { + t.Error("unit ExecStop should clean up nftables table") + } + if !strings.Contains(unitContents, "iptables -D FORWARD -p tcp -d 10.46.97.48/32 --dport 80 -j ACCEPT") { + t.Error("unit ExecStop should remove iptables FORWARD rule") + } + if !strings.Contains(unitContents, "Type=oneshot") { + t.Error("unit should be Type=oneshot") + } + if !strings.Contains(unitContents, "RemainAfterExit=yes") { + t.Error("unit should have RemainAfterExit=yes") + } + if unit["name"] != "crtmgr-http01-dnat.service" { + t.Errorf("unit name = %q, want crtmgr-http01-dnat.service", unit["name"]) + } + if unit["enabled"] != true { + t.Errorf("unit enabled = %v, want true", unit["enabled"]) + } +} + +func TestRenderMachineConfigDifferentVIP(t *testing.T) { + mc, err := renderMachineConfig("192.168.1.1", "192.168.1.100") + if err != nil { + t.Fatalf("renderMachineConfig() error: %v", err) + } + + nftRules := extractNFTRules(t, mc) + if !strings.Contains(nftRules, "192.168.1.100") { + t.Error("nftables rules should contain the provided VIP") + } + if !strings.Contains(nftRules, "dnat ip to 192.168.1.100:80") { + t.Error("nftables DNAT rule should use the provided VIP") + } + + unitContents := extractUnitContents(t, mc) + if !strings.Contains(unitContents, "192.168.1.100") { + t.Error("systemd unit iptables rules should reference the provided VIP") + } +} + +func TestRenderMachineConfigIgnitionVersion(t *testing.T) { + mc, err := renderMachineConfig("10.0.0.254", "10.0.0.1") + if err != nil { + t.Fatalf("renderMachineConfig() error: %v", err) + } + + version, found, err := unstructured.NestedString(mc.Object, "spec", "config", "ignition", "version") + if err != nil || !found { + t.Fatalf("ignition version not found: found=%v err=%v", found, err) + } + if version != "3.4.0" { + t.Errorf("ignition version = %q, want 3.4.0", version) + } +} + +func TestRenderMachineConfigFilePermissions(t *testing.T) { + mc, err := renderMachineConfig("10.0.0.254", "10.0.0.1") + if err != nil { + t.Fatalf("renderMachineConfig() error: %v", err) + } + + files, _, _ := unstructured.NestedSlice(mc.Object, "spec", "config", "storage", "files") + file := files[0].(map[string]interface{}) + + path, ok := file["path"].(string) + if !ok || path != "/etc/sysconfig/nftables-crtmgr-http01.conf" { + t.Errorf("file path = %q, want /etc/sysconfig/nftables-crtmgr-http01.conf", path) + } + + mode, ok := file["mode"].(float64) + if !ok || mode != 384 { // 0600 octal + t.Errorf("file mode = %v, want 384 (0600)", mode) + } +} + +func TestCreateOrApplyMachineConfigNoVIPs(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + r := newTestReconciler(fakeClient) + + err := r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{}, + ingressVIPs: []string{"10.0.0.1"}, + }) + if err == nil { + t.Fatal("expected error for empty apiVIPs") + } + if !strings.Contains(err.Error(), "no API VIPs") { + t.Errorf("error = %q, want substring 'no API VIPs'", err.Error()) + } + + err = r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{"10.0.0.254"}, + ingressVIPs: []string{}, + }) + if err == nil { + t.Fatal("expected error for empty ingressVIPs") + } + if !strings.Contains(err.Error(), "no ingress VIPs") { + t.Errorf("error = %q, want substring 'no ingress VIPs'", err.Error()) + } + if fakeClient.GetCallCount() != 0 { + t.Errorf("expected 0 Get calls, got %d", fakeClient.GetCallCount()) + } +} + +func TestCreateOrApplyMachineConfigCreate(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetReturns(errors.NewNotFound(machineConfigGR, machineConfigName)) + fakeClient.CreateReturns(nil) + + r := newTestReconciler(fakeClient) + + err := r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{"10.0.0.253"}, + ingressVIPs: []string{"10.0.0.2"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fakeClient.CreateCallCount() != 1 { + t.Errorf("expected 1 Create call, got %d", fakeClient.CreateCallCount()) + } + + _, created, _ := fakeClient.CreateArgsForCall(0) + u := created.(*unstructured.Unstructured) + if u.GetName() != machineConfigName { + t.Errorf("created MachineConfig name = %q, want %q", u.GetName(), machineConfigName) + } +} + +func TestCreateOrApplyMachineConfigCreateError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetReturns(errors.NewNotFound(machineConfigGR, machineConfigName)) + fakeClient.CreateReturns(fmt.Errorf("forbidden")) + + r := newTestReconciler(fakeClient) + + err := r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{"10.0.0.253"}, + ingressVIPs: []string{"10.0.0.2"}, + }) + if err == nil { + t.Fatal("expected error when Create fails") + } + if !strings.Contains(err.Error(), "failed to create MachineConfig") { + t.Errorf("error = %q, want substring 'failed to create MachineConfig'", err.Error()) + } +} + +func TestCreateOrApplyMachineConfigGetError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetReturns(fmt.Errorf("connection refused")) + + r := newTestReconciler(fakeClient) + + err := r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{"10.0.0.253"}, + ingressVIPs: []string{"10.0.0.2"}, + }) + if err == nil { + t.Fatal("expected error when Get fails with non-NotFound") + } + if !strings.Contains(err.Error(), "failed to get MachineConfig") { + t.Errorf("error = %q, want substring 'failed to get MachineConfig'", err.Error()) + } + if fakeClient.CreateCallCount() != 0 { + t.Error("should not attempt Create when Get fails") + } +} + +func TestCreateOrApplyMachineConfigUpdateWhenSpecDiffers(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetStub = func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.SetGroupVersionKind(machineConfigGVK) + u.SetName(machineConfigName) + u.SetResourceVersion("12345") + // Existing spec has a different VIP + unstructured.SetNestedField(u.Object, "old-ignition-data", "spec", "config", "ignition", "version") + return nil + } + fakeClient.UpdateReturns(nil) + + r := newTestReconciler(fakeClient) + + err := r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{"10.0.0.253"}, + ingressVIPs: []string{"10.0.0.2"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fakeClient.UpdateCallCount() != 1 { + t.Errorf("expected 1 Update call, got %d", fakeClient.UpdateCallCount()) + } + if fakeClient.CreateCallCount() != 0 { + t.Error("should not Create when MachineConfig already exists") + } + + _, updated, _ := fakeClient.UpdateArgsForCall(0) + u := updated.(*unstructured.Unstructured) + if u.GetResourceVersion() != "12345" { + t.Errorf("Update should preserve resourceVersion, got %q", u.GetResourceVersion()) + } +} + +func TestCreateOrApplyMachineConfigUpdateError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetStub = func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.SetGroupVersionKind(machineConfigGVK) + u.SetName(machineConfigName) + u.SetResourceVersion("12345") + unstructured.SetNestedField(u.Object, "stale", "spec", "config", "ignition", "version") + return nil + } + fakeClient.UpdateReturns(fmt.Errorf("conflict")) + + r := newTestReconciler(fakeClient) + + err := r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{"10.0.0.253"}, + ingressVIPs: []string{"10.0.0.2"}, + }) + if err == nil { + t.Fatal("expected error when Update fails") + } + if !strings.Contains(err.Error(), "failed to update MachineConfig") { + t.Errorf("error = %q, want substring 'failed to update MachineConfig'", err.Error()) + } +} + +func TestCreateOrApplyMachineConfigNoOpWhenUnchanged(t *testing.T) { + desired, err := renderMachineConfig("10.0.0.253", "10.0.0.2") + if err != nil { + t.Fatalf("renderMachineConfig() error: %v", err) + } + desiredSpec, _, _ := unstructured.NestedMap(desired.Object, "spec") + + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.GetStub = func(_ context.Context, _ client.ObjectKey, obj client.Object) error { + u := obj.(*unstructured.Unstructured) + u.SetGroupVersionKind(machineConfigGVK) + u.SetName(machineConfigName) + u.SetResourceVersion("99") + unstructured.SetNestedField(u.Object, desiredSpec, "spec") + return nil + } + + r := newTestReconciler(fakeClient) + + err = r.createOrApplyMachineConfig(context.Background(), &platformInfo{ + apiVIPs: []string{"10.0.0.253"}, + ingressVIPs: []string{"10.0.0.2"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fakeClient.UpdateCallCount() != 0 { + t.Error("should not Update when spec is unchanged") + } + if fakeClient.CreateCallCount() != 0 { + t.Error("should not Create when MachineConfig already exists") + } +} + +func TestDeleteMachineConfigSuccess(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.DeleteReturns(nil) + + r := newTestReconciler(fakeClient) + + err := r.deleteMachineConfig(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fakeClient.DeleteCallCount() != 1 { + t.Errorf("expected 1 Delete call, got %d", fakeClient.DeleteCallCount()) + } + + _, deleted, _ := fakeClient.DeleteArgsForCall(0) + u := deleted.(*unstructured.Unstructured) + if u.GetName() != machineConfigName { + t.Errorf("deleted object name = %q, want %q", u.GetName(), machineConfigName) + } + if u.GetObjectKind().GroupVersionKind() != machineConfigGVK { + t.Errorf("deleted object GVK = %v, want %v", u.GetObjectKind().GroupVersionKind(), machineConfigGVK) + } +} + +func TestDeleteMachineConfigNotFound(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.DeleteReturns(errors.NewNotFound(machineConfigGR, machineConfigName)) + + r := newTestReconciler(fakeClient) + + err := r.deleteMachineConfig(context.Background()) + if err != nil { + t.Fatalf("deleteMachineConfig should succeed when MachineConfig is NotFound, got: %v", err) + } +} + +func TestDeleteMachineConfigError(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + fakeClient.DeleteReturns(fmt.Errorf("permission denied")) + + r := newTestReconciler(fakeClient) + + err := r.deleteMachineConfig(context.Background()) + if err == nil { + t.Fatal("expected error when Delete fails") + } + if !strings.Contains(err.Error(), "failed to delete MachineConfig") { + t.Errorf("error = %q, want substring 'failed to delete MachineConfig'", err.Error()) + } +} diff --git a/pkg/controller/http01proxy/utils.go b/pkg/controller/http01proxy/utils.go new file mode 100644 index 000000000..a591be081 --- /dev/null +++ b/pkg/controller/http01proxy/utils.go @@ -0,0 +1,71 @@ +package http01proxy + +import ( + "context" + "fmt" + + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" +) + +func (r *Reconciler) updateStatus(ctx context.Context, changed *v1alpha1.HTTP01Proxy) error { + namespacedName := client.ObjectKeyFromObject(changed) + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + r.log.V(4).Info("updating http01proxy status", "request", namespacedName) + current := &v1alpha1.HTTP01Proxy{} + if err := r.Get(ctx, namespacedName, current); err != nil { + return fmt.Errorf("failed to fetch http01proxy %q for status update: %w", namespacedName, err) + } + changed.Status.DeepCopyInto(¤t.Status) + if err := r.StatusUpdate(ctx, current); err != nil { + return fmt.Errorf("failed to update http01proxy %q status: %w", namespacedName, err) + } + return nil + }) +} + +func (r *Reconciler) addFinalizer(ctx context.Context, proxy *v1alpha1.HTTP01Proxy) error { + namespacedName := client.ObjectKeyFromObject(proxy) + if !controllerutil.ContainsFinalizer(proxy, finalizer) { + if !controllerutil.AddFinalizer(proxy, finalizer) { + return fmt.Errorf("failed to create %q http01proxy object with finalizers added", namespacedName) + } + if err := r.UpdateWithRetry(ctx, proxy); err != nil { + return fmt.Errorf("failed to add finalizers on %q http01proxy with %w", namespacedName, err) + } + updated := &v1alpha1.HTTP01Proxy{} + if err := r.Get(ctx, namespacedName, updated); err != nil { + return fmt.Errorf("failed to fetch http01proxy %q after updating finalizers: %w", namespacedName, err) + } + updated.DeepCopyInto(proxy) + } + return nil +} + +func (r *Reconciler) removeFinalizer(ctx context.Context, proxy *v1alpha1.HTTP01Proxy) error { + namespacedName := client.ObjectKeyFromObject(proxy) + if controllerutil.ContainsFinalizer(proxy, finalizer) { + if !controllerutil.RemoveFinalizer(proxy, finalizer) { + return fmt.Errorf("failed to create %q http01proxy object with finalizers removed", namespacedName) + } + if err := r.UpdateWithRetry(ctx, proxy); err != nil { + return fmt.Errorf("failed to remove finalizers on %q http01proxy with %w", namespacedName, err) + } + } + return nil +} + +func (r *Reconciler) updateCondition(ctx context.Context, proxy *v1alpha1.HTTP01Proxy, prependErr error) error { + if err := r.updateStatus(ctx, proxy); err != nil { + errUpdate := fmt.Errorf("failed to update %s/%s status: %w", proxy.GetNamespace(), proxy.GetName(), err) + if prependErr != nil { + return utilerrors.NewAggregate([]error{prependErr, errUpdate}) + } + return errUpdate + } + return prependErr +} diff --git a/pkg/controller/http01proxy/utils_test.go b/pkg/controller/http01proxy/utils_test.go new file mode 100644 index 000000000..01b4b22be --- /dev/null +++ b/pkg/controller/http01proxy/utils_test.go @@ -0,0 +1,297 @@ +package http01proxy + +import ( + "context" + "fmt" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/types" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/client" + + v1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + "github.com/openshift/cert-manager-operator/pkg/controller/common/fakes" +) + +func testProxy() *v1alpha1.HTTP01Proxy { + p := &v1alpha1.HTTP01Proxy{} + p.SetName(http01proxyObjectName) + p.SetNamespace("cert-manager-operator") + return p +} + +func TestUpdateCondition(t *testing.T) { + tests := []struct { + name string + prependErr error + statusFail bool + wantErr bool + wantMsg string + }{ + { + name: "status succeeds with nil prependErr", + wantErr: false, + }, + { + name: "status succeeds with prependErr", + prependErr: fmt.Errorf("original error"), + wantErr: true, + wantMsg: "original error", + }, + { + name: "status fails with nil prependErr", + statusFail: true, + wantErr: true, + wantMsg: "failed to update", + }, + { + name: "status fails with prependErr preserves both errors", + prependErr: fmt.Errorf("original error"), + statusFail: true, + wantErr: true, + wantMsg: "original error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + if tt.statusFail { + fakeClient.GetReturns(fmt.Errorf("simulated get error")) + } else { + fakeClient.GetCalls(func(_ context.Context, _ types.NamespacedName, obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.HTTP01Proxy: + testProxy().DeepCopyInto(o) + } + return nil + }) + fakeClient.StatusUpdateReturns(nil) + } + + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + } + + proxy := testProxy() + err := r.updateCondition(context.Background(), proxy, tt.prependErr) + + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantMsg) + } + if tt.name == "status fails with prependErr preserves both errors" { + if !strings.Contains(err.Error(), "failed to update") { + t.Errorf("error %q should also contain status update failure", err.Error()) + } + } + }) + } +} + +func TestAddFinalizer(t *testing.T) { + tests := []struct { + name string + finalizer bool + updateErr error + getErr error + wantErr bool + wantMsg string + }{ + { + name: "already has finalizer", + finalizer: true, + wantErr: false, + }, + { + name: "adds finalizer successfully", + wantErr: false, + }, + { + name: "update fails", + updateErr: fmt.Errorf("update failed"), + wantErr: true, + wantMsg: "failed to add finalizers", + }, + { + name: "re-fetch fails after update", + getErr: fmt.Errorf("get failed"), + wantErr: true, + wantMsg: "failed to fetch http01proxy", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + + if tt.updateErr != nil { + fakeClient.UpdateWithRetryReturns(tt.updateErr) + } else { + fakeClient.UpdateWithRetryReturns(nil) + } + + if tt.getErr != nil { + fakeClient.GetReturns(tt.getErr) + } else { + fakeClient.GetCalls(func(_ context.Context, _ types.NamespacedName, obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.HTTP01Proxy: + testProxy().DeepCopyInto(o) + } + return nil + }) + } + + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + } + + proxy := testProxy() + if tt.finalizer { + proxy.Finalizers = []string{finalizer} + } + + err := r.addFinalizer(context.Background(), proxy) + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantMsg) + } + }) + } +} + +func TestRemoveFinalizer(t *testing.T) { + tests := []struct { + name string + finalizer bool + updateErr error + wantErr bool + wantMsg string + }{ + { + name: "no finalizer is no-op", + wantErr: false, + }, + { + name: "removes finalizer successfully", + finalizer: true, + wantErr: false, + }, + { + name: "update fails", + finalizer: true, + updateErr: fmt.Errorf("update failed"), + wantErr: true, + wantMsg: "failed to remove finalizers", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + if tt.updateErr != nil { + fakeClient.UpdateWithRetryReturns(tt.updateErr) + } else { + fakeClient.UpdateWithRetryReturns(nil) + } + + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + } + + proxy := testProxy() + if tt.finalizer { + proxy.Finalizers = []string{finalizer} + } + + err := r.removeFinalizer(context.Background(), proxy) + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantMsg) + } + }) + } +} + +func TestUpdateStatus(t *testing.T) { + tests := []struct { + name string + getErr error + statusErr error + wantErr bool + wantMsg string + }{ + { + name: "success", + wantErr: false, + }, + { + name: "get fails", + getErr: fmt.Errorf("get failed"), + wantErr: true, + wantMsg: "failed to fetch http01proxy", + }, + { + name: "status update fails", + statusErr: fmt.Errorf("status update failed"), + wantErr: true, + wantMsg: "failed to update http01proxy", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &fakes.FakeCtrlClient{} + if tt.getErr != nil { + fakeClient.GetReturns(tt.getErr) + } else { + fakeClient.GetCalls(func(_ context.Context, _ types.NamespacedName, obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.HTTP01Proxy: + testProxy().DeepCopyInto(o) + } + return nil + }) + } + if tt.statusErr != nil { + fakeClient.StatusUpdateReturns(tt.statusErr) + } else { + fakeClient.StatusUpdateReturns(nil) + } + + r := &Reconciler{ + CtrlClient: fakeClient, + log: logr.Discard(), + } + + proxy := testProxy() + err := r.updateStatus(context.Background(), proxy) + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantMsg) + } + }) + } +} diff --git a/pkg/features/features_test.go b/pkg/features/features_test.go index b064b67d0..98a05cc95 100644 --- a/pkg/features/features_test.go +++ b/pkg/features/features_test.go @@ -65,6 +65,7 @@ var expectedDefaultFeatureState = map[bool][]featuregate.Feature{ // list of features which are expected to be disabled at runtime. false: { featuregate.Feature("TrustManager"), + featuregate.Feature("HTTP01Proxy"), }, } @@ -87,7 +88,7 @@ func TestFeatureGates(t *testing.T) { } slices.Sort(knownOperatorFeatures) - assert.Equal(t, knownOperatorFeatures, testFeatureNames, + assert.ElementsMatch(t, knownOperatorFeatures, testFeatureNames, `the list of features known to the operator differ from what is being tested here, it could be that there was a new Feature added to the api which wasn't added to the tests. Please verify "api/operator/v1alpha1" and "pkg/features" have identical features.`) @@ -102,7 +103,7 @@ func TestFeatureGates(t *testing.T) { } }) - t.Run("all TechPreview features should be disabled by default", func(t *testing.T) { + t.Run("all pre-GA features should be disabled by default", func(t *testing.T) { feats := mutableFeatureGate.GetAll() for feat, spec := range feats { // skip "AllBeta", "AllAlpha": our operator does not use those @@ -110,9 +111,10 @@ func TestFeatureGates(t *testing.T) { continue } - assert.Equal(t, spec.PreRelease == "TechPreview", !spec.Default, - "prerelease TechPreview %q feature should default to disabled", - feat) + isPreGA := spec.PreRelease == "TechPreview" || spec.PreRelease == featuregate.Alpha || spec.PreRelease == featuregate.Beta + assert.Equal(t, isPreGA, !spec.Default, + "pre-GA %q feature (prerelease=%s) should default to disabled", + feat, spec.PreRelease) } }) diff --git a/pkg/operator/applyconfigurations/internal/internal.go b/pkg/operator/applyconfigurations/internal/internal.go index 6b910267e..671b8b064 100644 --- a/pkg/operator/applyconfigurations/internal/internal.go +++ b/pkg/operator/applyconfigurations/internal/internal.go @@ -33,6 +33,16 @@ var schemaYAML = typed.YAMLObject(`types: elementType: namedType: __untyped_deduced_ elementRelationship: separable +- name: com.github.openshift.cert-manager-operator.api.operator.v1alpha1.HTTP01Proxy + scalar: untyped + list: + elementType: + namedType: __untyped_atomic_ + elementRelationship: atomic + map: + elementType: + namedType: __untyped_deduced_ + elementRelationship: separable - name: com.github.openshift.cert-manager-operator.api.operator.v1alpha1.IstioCSR scalar: untyped list: diff --git a/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxy.go b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxy.go new file mode 100644 index 000000000..1be19a40a --- /dev/null +++ b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxy.go @@ -0,0 +1,282 @@ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + operatorv1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + internal "github.com/openshift/cert-manager-operator/pkg/operator/applyconfigurations/internal" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + managedfields "k8s.io/apimachinery/pkg/util/managedfields" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// HTTP01ProxyApplyConfiguration represents a declarative configuration of the HTTP01Proxy type for use +// with apply. +// +// HTTP01Proxy describes the configuration for the HTTP01 challenge proxy +// that redirects traffic from the API endpoint on port 80 to ingress routers. +// This enables cert-manager to perform HTTP01 ACME challenges for API endpoint certificates. +// The name must be `default` to make HTTP01Proxy a singleton. +// +// When an HTTP01Proxy is created, a MachineConfig with nftables DNAT/SNAT rules is applied to control plane nodes. +type HTTP01ProxyApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + // metadata is the standard object's metadata. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + // spec is the specification of the desired behavior of the HTTP01Proxy. + Spec *HTTP01ProxySpecApplyConfiguration `json:"spec,omitempty"` + // status is the most recently observed status of the HTTP01Proxy. + Status *HTTP01ProxyStatusApplyConfiguration `json:"status,omitempty"` +} + +// HTTP01Proxy constructs a declarative configuration of the HTTP01Proxy type for use with +// apply. +func HTTP01Proxy(name, namespace string) *HTTP01ProxyApplyConfiguration { + b := &HTTP01ProxyApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("HTTP01Proxy") + b.WithAPIVersion("operator.openshift.io/v1alpha1") + return b +} + +// ExtractHTTP01ProxyFrom extracts the applied configuration owned by fieldManager from +// hTTP01Proxy for the specified subresource. Pass an empty string for subresource to extract +// the main resource. Common subresources include "status", "scale", etc. +// hTTP01Proxy must be a unmodified HTTP01Proxy API object that was retrieved from the Kubernetes API. +// ExtractHTTP01ProxyFrom provides a way to perform a extract/modify-in-place/apply workflow. +// Note that an extracted apply configuration will contain fewer fields than what the fieldManager previously +// applied if another fieldManager has updated or force applied any of the previously applied fields. +func ExtractHTTP01ProxyFrom(hTTP01Proxy *operatorv1alpha1.HTTP01Proxy, fieldManager string, subresource string) (*HTTP01ProxyApplyConfiguration, error) { + b := &HTTP01ProxyApplyConfiguration{} + err := managedfields.ExtractInto(hTTP01Proxy, internal.Parser().Type("com.github.openshift.cert-manager-operator.api.operator.v1alpha1.HTTP01Proxy"), fieldManager, b, subresource) + if err != nil { + return nil, err + } + b.WithName(hTTP01Proxy.Name) + b.WithNamespace(hTTP01Proxy.Namespace) + + b.WithKind("HTTP01Proxy") + b.WithAPIVersion("operator.openshift.io/v1alpha1") + return b, nil +} + +// ExtractHTTP01Proxy extracts the applied configuration owned by fieldManager from +// hTTP01Proxy. If no managedFields are found in hTTP01Proxy for fieldManager, a +// HTTP01ProxyApplyConfiguration is returned with only the Name, Namespace (if applicable), +// APIVersion and Kind populated. It is possible that no managed fields were found for because other +// field managers have taken ownership of all the fields previously owned by fieldManager, or because +// the fieldManager never owned fields any fields. +// hTTP01Proxy must be a unmodified HTTP01Proxy API object that was retrieved from the Kubernetes API. +// ExtractHTTP01Proxy provides a way to perform a extract/modify-in-place/apply workflow. +// Note that an extracted apply configuration will contain fewer fields than what the fieldManager previously +// applied if another fieldManager has updated or force applied any of the previously applied fields. +func ExtractHTTP01Proxy(hTTP01Proxy *operatorv1alpha1.HTTP01Proxy, fieldManager string) (*HTTP01ProxyApplyConfiguration, error) { + return ExtractHTTP01ProxyFrom(hTTP01Proxy, fieldManager, "") +} + +// ExtractHTTP01ProxyStatus extracts the applied configuration owned by fieldManager from +// hTTP01Proxy for the status subresource. +func ExtractHTTP01ProxyStatus(hTTP01Proxy *operatorv1alpha1.HTTP01Proxy, fieldManager string) (*HTTP01ProxyApplyConfiguration, error) { + return ExtractHTTP01ProxyFrom(hTTP01Proxy, fieldManager, "status") +} + +func (b HTTP01ProxyApplyConfiguration) IsApplyConfiguration() {} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithKind(value string) *HTTP01ProxyApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithAPIVersion(value string) *HTTP01ProxyApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithName(value string) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithGenerateName(value string) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithNamespace(value string) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithUID(value types.UID) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithResourceVersion(value string) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithGeneration(value int64) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithCreationTimestamp(value metav1.Time) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *HTTP01ProxyApplyConfiguration) WithLabels(entries map[string]string) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *HTTP01ProxyApplyConfiguration) WithAnnotations(entries map[string]string) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *HTTP01ProxyApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *HTTP01ProxyApplyConfiguration) WithFinalizers(values ...string) *HTTP01ProxyApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *HTTP01ProxyApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithSpec(value *HTTP01ProxySpecApplyConfiguration) *HTTP01ProxyApplyConfiguration { + b.Spec = value + return b +} + +// WithStatus sets the Status field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Status field is set to the value of the last call. +func (b *HTTP01ProxyApplyConfiguration) WithStatus(value *HTTP01ProxyStatusApplyConfiguration) *HTTP01ProxyApplyConfiguration { + b.Status = value + return b +} + +// GetKind retrieves the value of the Kind field in the declarative configuration. +func (b *HTTP01ProxyApplyConfiguration) GetKind() *string { + return b.TypeMetaApplyConfiguration.Kind +} + +// GetAPIVersion retrieves the value of the APIVersion field in the declarative configuration. +func (b *HTTP01ProxyApplyConfiguration) GetAPIVersion() *string { + return b.TypeMetaApplyConfiguration.APIVersion +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *HTTP01ProxyApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} + +// GetNamespace retrieves the value of the Namespace field in the declarative configuration. +func (b *HTTP01ProxyApplyConfiguration) GetNamespace() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Namespace +} diff --git a/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxycustomdeploymentspec.go b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxycustomdeploymentspec.go new file mode 100644 index 000000000..711df3414 --- /dev/null +++ b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxycustomdeploymentspec.go @@ -0,0 +1,27 @@ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// HTTP01ProxyCustomDeploymentSpecApplyConfiguration represents a declarative configuration of the HTTP01ProxyCustomDeploymentSpec type for use +// with apply. +// +// HTTP01ProxyCustomDeploymentSpec contains configuration for custom proxy deployment. +type HTTP01ProxyCustomDeploymentSpecApplyConfiguration struct { + // internalPort specifies the internal port used by the proxy service. + // Valid values are 1024-65535. + InternalPort *int32 `json:"internalPort,omitempty"` +} + +// HTTP01ProxyCustomDeploymentSpecApplyConfiguration constructs a declarative configuration of the HTTP01ProxyCustomDeploymentSpec type for use with +// apply. +func HTTP01ProxyCustomDeploymentSpec() *HTTP01ProxyCustomDeploymentSpecApplyConfiguration { + return &HTTP01ProxyCustomDeploymentSpecApplyConfiguration{} +} + +// WithInternalPort sets the InternalPort field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the InternalPort field is set to the value of the last call. +func (b *HTTP01ProxyCustomDeploymentSpecApplyConfiguration) WithInternalPort(value int32) *HTTP01ProxyCustomDeploymentSpecApplyConfiguration { + b.InternalPort = &value + return b +} diff --git a/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxyspec.go b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxyspec.go new file mode 100644 index 000000000..4d0bcbabc --- /dev/null +++ b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxyspec.go @@ -0,0 +1,43 @@ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + operatorv1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" +) + +// HTTP01ProxySpecApplyConfiguration represents a declarative configuration of the HTTP01ProxySpec type for use +// with apply. +// +// HTTP01ProxySpec is the specification of the desired behavior of the HTTP01Proxy. +type HTTP01ProxySpecApplyConfiguration struct { + // mode controls whether the HTTP01 challenge proxy is active and how it should be deployed. + // DefaultDeployment enables the proxy with default configuration. + // CustomDeployment enables the proxy with user-specified configuration. + Mode *operatorv1alpha1.HTTP01ProxyMode `json:"mode,omitempty"` + // customDeployment contains configuration options when mode is CustomDeployment. + // This field is only valid when mode is CustomDeployment. + CustomDeployment *HTTP01ProxyCustomDeploymentSpecApplyConfiguration `json:"customDeployment,omitempty"` +} + +// HTTP01ProxySpecApplyConfiguration constructs a declarative configuration of the HTTP01ProxySpec type for use with +// apply. +func HTTP01ProxySpec() *HTTP01ProxySpecApplyConfiguration { + return &HTTP01ProxySpecApplyConfiguration{} +} + +// WithMode sets the Mode field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Mode field is set to the value of the last call. +func (b *HTTP01ProxySpecApplyConfiguration) WithMode(value operatorv1alpha1.HTTP01ProxyMode) *HTTP01ProxySpecApplyConfiguration { + b.Mode = &value + return b +} + +// WithCustomDeployment sets the CustomDeployment field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CustomDeployment field is set to the value of the last call. +func (b *HTTP01ProxySpecApplyConfiguration) WithCustomDeployment(value *HTTP01ProxyCustomDeploymentSpecApplyConfiguration) *HTTP01ProxySpecApplyConfiguration { + b.CustomDeployment = value + return b +} diff --git a/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxystatus.go b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxystatus.go new file mode 100644 index 000000000..97a0a87b5 --- /dev/null +++ b/pkg/operator/applyconfigurations/operator/v1alpha1/http01proxystatus.go @@ -0,0 +1,45 @@ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// HTTP01ProxyStatusApplyConfiguration represents a declarative configuration of the HTTP01ProxyStatus type for use +// with apply. +// +// HTTP01ProxyStatus is the most recently observed status of the HTTP01Proxy. +type HTTP01ProxyStatusApplyConfiguration struct { + // conditions holds information about the current state of the HTTP01 proxy deployment. + ConditionalStatusApplyConfiguration `json:",omitempty,inline"` + // proxyImage is the name of the image and the tag used for deploying the proxy. + ProxyImage *string `json:"proxyImage,omitempty"` +} + +// HTTP01ProxyStatusApplyConfiguration constructs a declarative configuration of the HTTP01ProxyStatus type for use with +// apply. +func HTTP01ProxyStatus() *HTTP01ProxyStatusApplyConfiguration { + return &HTTP01ProxyStatusApplyConfiguration{} +} + +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *HTTP01ProxyStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *HTTP01ProxyStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.ConditionalStatusApplyConfiguration.Conditions = append(b.ConditionalStatusApplyConfiguration.Conditions, *values[i]) + } + return b +} + +// WithProxyImage sets the ProxyImage field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ProxyImage field is set to the value of the last call. +func (b *HTTP01ProxyStatusApplyConfiguration) WithProxyImage(value string) *HTTP01ProxyStatusApplyConfiguration { + b.ProxyImage = &value + return b +} diff --git a/pkg/operator/applyconfigurations/utils.go b/pkg/operator/applyconfigurations/utils.go index 37cba6978..ba97e423e 100644 --- a/pkg/operator/applyconfigurations/utils.go +++ b/pkg/operator/applyconfigurations/utils.go @@ -38,6 +38,14 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &operatorv1alpha1.DefaultCAPackageConfigApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("DeploymentConfig"): return &operatorv1alpha1.DeploymentConfigApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("HTTP01Proxy"): + return &operatorv1alpha1.HTTP01ProxyApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("HTTP01ProxyCustomDeploymentSpec"): + return &operatorv1alpha1.HTTP01ProxyCustomDeploymentSpecApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("HTTP01ProxySpec"): + return &operatorv1alpha1.HTTP01ProxySpecApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("HTTP01ProxyStatus"): + return &operatorv1alpha1.HTTP01ProxyStatusApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("IstioConfig"): return &operatorv1alpha1.IstioConfigApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("IstioCSR"): diff --git a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_http01proxy.go b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_http01proxy.go new file mode 100644 index 000000000..0fb97493a --- /dev/null +++ b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_http01proxy.go @@ -0,0 +1,37 @@ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + operatorv1alpha1 "github.com/openshift/cert-manager-operator/pkg/operator/applyconfigurations/operator/v1alpha1" + typedoperatorv1alpha1 "github.com/openshift/cert-manager-operator/pkg/operator/clientset/versioned/typed/operator/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeHTTP01Proxies implements HTTP01ProxyInterface +type fakeHTTP01Proxies struct { + *gentype.FakeClientWithListAndApply[*v1alpha1.HTTP01Proxy, *v1alpha1.HTTP01ProxyList, *operatorv1alpha1.HTTP01ProxyApplyConfiguration] + Fake *FakeOperatorV1alpha1 +} + +func newFakeHTTP01Proxies(fake *FakeOperatorV1alpha1, namespace string) typedoperatorv1alpha1.HTTP01ProxyInterface { + return &fakeHTTP01Proxies{ + gentype.NewFakeClientWithListAndApply[*v1alpha1.HTTP01Proxy, *v1alpha1.HTTP01ProxyList, *operatorv1alpha1.HTTP01ProxyApplyConfiguration]( + fake.Fake, + namespace, + v1alpha1.SchemeGroupVersion.WithResource("http01proxies"), + v1alpha1.SchemeGroupVersion.WithKind("HTTP01Proxy"), + func() *v1alpha1.HTTP01Proxy { return &v1alpha1.HTTP01Proxy{} }, + func() *v1alpha1.HTTP01ProxyList { return &v1alpha1.HTTP01ProxyList{} }, + func(dst, src *v1alpha1.HTTP01ProxyList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.HTTP01ProxyList) []*v1alpha1.HTTP01Proxy { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.HTTP01ProxyList, items []*v1alpha1.HTTP01Proxy) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_operator_client.go b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_operator_client.go index aaca26cb7..ddd167035 100644 --- a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_operator_client.go +++ b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/fake/fake_operator_client.go @@ -16,6 +16,10 @@ func (c *FakeOperatorV1alpha1) CertManagers() v1alpha1.CertManagerInterface { return newFakeCertManagers(c) } +func (c *FakeOperatorV1alpha1) HTTP01Proxies(namespace string) v1alpha1.HTTP01ProxyInterface { + return newFakeHTTP01Proxies(c, namespace) +} + func (c *FakeOperatorV1alpha1) IstioCSRs(namespace string) v1alpha1.IstioCSRInterface { return newFakeIstioCSRs(c, namespace) } diff --git a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/generated_expansion.go b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/generated_expansion.go index df39e06da..9a6c56b17 100644 --- a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/generated_expansion.go +++ b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/generated_expansion.go @@ -4,6 +4,8 @@ package v1alpha1 type CertManagerExpansion interface{} +type HTTP01ProxyExpansion interface{} + type IstioCSRExpansion interface{} type TrustManagerExpansion interface{} diff --git a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/http01proxy.go b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/http01proxy.go new file mode 100644 index 000000000..ef636b340 --- /dev/null +++ b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/http01proxy.go @@ -0,0 +1,58 @@ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + operatorv1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + applyconfigurationsoperatorv1alpha1 "github.com/openshift/cert-manager-operator/pkg/operator/applyconfigurations/operator/v1alpha1" + scheme "github.com/openshift/cert-manager-operator/pkg/operator/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// HTTP01ProxiesGetter has a method to return a HTTP01ProxyInterface. +// A group's client should implement this interface. +type HTTP01ProxiesGetter interface { + HTTP01Proxies(namespace string) HTTP01ProxyInterface +} + +// HTTP01ProxyInterface has methods to work with HTTP01Proxy resources. +type HTTP01ProxyInterface interface { + Create(ctx context.Context, hTTP01Proxy *operatorv1alpha1.HTTP01Proxy, opts v1.CreateOptions) (*operatorv1alpha1.HTTP01Proxy, error) + Update(ctx context.Context, hTTP01Proxy *operatorv1alpha1.HTTP01Proxy, opts v1.UpdateOptions) (*operatorv1alpha1.HTTP01Proxy, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, hTTP01Proxy *operatorv1alpha1.HTTP01Proxy, opts v1.UpdateOptions) (*operatorv1alpha1.HTTP01Proxy, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*operatorv1alpha1.HTTP01Proxy, error) + List(ctx context.Context, opts v1.ListOptions) (*operatorv1alpha1.HTTP01ProxyList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *operatorv1alpha1.HTTP01Proxy, err error) + Apply(ctx context.Context, hTTP01Proxy *applyconfigurationsoperatorv1alpha1.HTTP01ProxyApplyConfiguration, opts v1.ApplyOptions) (result *operatorv1alpha1.HTTP01Proxy, err error) + // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). + ApplyStatus(ctx context.Context, hTTP01Proxy *applyconfigurationsoperatorv1alpha1.HTTP01ProxyApplyConfiguration, opts v1.ApplyOptions) (result *operatorv1alpha1.HTTP01Proxy, err error) + HTTP01ProxyExpansion +} + +// hTTP01Proxies implements HTTP01ProxyInterface +type hTTP01Proxies struct { + *gentype.ClientWithListAndApply[*operatorv1alpha1.HTTP01Proxy, *operatorv1alpha1.HTTP01ProxyList, *applyconfigurationsoperatorv1alpha1.HTTP01ProxyApplyConfiguration] +} + +// newHTTP01Proxies returns a HTTP01Proxies +func newHTTP01Proxies(c *OperatorV1alpha1Client, namespace string) *hTTP01Proxies { + return &hTTP01Proxies{ + gentype.NewClientWithListAndApply[*operatorv1alpha1.HTTP01Proxy, *operatorv1alpha1.HTTP01ProxyList, *applyconfigurationsoperatorv1alpha1.HTTP01ProxyApplyConfiguration]( + "http01proxies", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *operatorv1alpha1.HTTP01Proxy { return &operatorv1alpha1.HTTP01Proxy{} }, + func() *operatorv1alpha1.HTTP01ProxyList { return &operatorv1alpha1.HTTP01ProxyList{} }, + ), + } +} diff --git a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/operator_client.go b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/operator_client.go index 9eabd32fe..b042f2907 100644 --- a/pkg/operator/clientset/versioned/typed/operator/v1alpha1/operator_client.go +++ b/pkg/operator/clientset/versioned/typed/operator/v1alpha1/operator_client.go @@ -13,6 +13,7 @@ import ( type OperatorV1alpha1Interface interface { RESTClient() rest.Interface CertManagersGetter + HTTP01ProxiesGetter IstioCSRsGetter TrustManagersGetter } @@ -26,6 +27,10 @@ func (c *OperatorV1alpha1Client) CertManagers() CertManagerInterface { return newCertManagers(c) } +func (c *OperatorV1alpha1Client) HTTP01Proxies(namespace string) HTTP01ProxyInterface { + return newHTTP01Proxies(c, namespace) +} + func (c *OperatorV1alpha1Client) IstioCSRs(namespace string) IstioCSRInterface { return newIstioCSRs(c, namespace) } diff --git a/pkg/operator/informers/externalversions/generic.go b/pkg/operator/informers/externalversions/generic.go index 7dc954ca9..a440afd15 100644 --- a/pkg/operator/informers/externalversions/generic.go +++ b/pkg/operator/informers/externalversions/generic.go @@ -39,6 +39,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource // Group=operator.openshift.io, Version=v1alpha1 case v1alpha1.SchemeGroupVersion.WithResource("certmanagers"): return &genericInformer{resource: resource.GroupResource(), informer: f.Operator().V1alpha1().CertManagers().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("http01proxies"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Operator().V1alpha1().HTTP01Proxies().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("istiocsrs"): return &genericInformer{resource: resource.GroupResource(), informer: f.Operator().V1alpha1().IstioCSRs().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("trustmanagers"): diff --git a/pkg/operator/informers/externalversions/operator/v1alpha1/http01proxy.go b/pkg/operator/informers/externalversions/operator/v1alpha1/http01proxy.go new file mode 100644 index 000000000..ffb2f4921 --- /dev/null +++ b/pkg/operator/informers/externalversions/operator/v1alpha1/http01proxy.go @@ -0,0 +1,86 @@ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + apioperatorv1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + versioned "github.com/openshift/cert-manager-operator/pkg/operator/clientset/versioned" + internalinterfaces "github.com/openshift/cert-manager-operator/pkg/operator/informers/externalversions/internalinterfaces" + operatorv1alpha1 "github.com/openshift/cert-manager-operator/pkg/operator/listers/operator/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// HTTP01ProxyInformer provides access to a shared informer and lister for +// HTTP01Proxies. +type HTTP01ProxyInformer interface { + Informer() cache.SharedIndexInformer + Lister() operatorv1alpha1.HTTP01ProxyLister +} + +type hTTP01ProxyInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewHTTP01ProxyInformer constructs a new informer for HTTP01Proxy type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewHTTP01ProxyInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHTTP01ProxyInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredHTTP01ProxyInformer constructs a new informer for HTTP01Proxy type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredHTTP01ProxyInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.OperatorV1alpha1().HTTP01Proxies(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.OperatorV1alpha1().HTTP01Proxies(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.OperatorV1alpha1().HTTP01Proxies(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.OperatorV1alpha1().HTTP01Proxies(namespace).Watch(ctx, options) + }, + }, client), + &apioperatorv1alpha1.HTTP01Proxy{}, + resyncPeriod, + indexers, + ) +} + +func (f *hTTP01ProxyInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHTTP01ProxyInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *hTTP01ProxyInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apioperatorv1alpha1.HTTP01Proxy{}, f.defaultInformer) +} + +func (f *hTTP01ProxyInformer) Lister() operatorv1alpha1.HTTP01ProxyLister { + return operatorv1alpha1.NewHTTP01ProxyLister(f.Informer().GetIndexer()) +} diff --git a/pkg/operator/informers/externalversions/operator/v1alpha1/interface.go b/pkg/operator/informers/externalversions/operator/v1alpha1/interface.go index 422750840..fbcba0144 100644 --- a/pkg/operator/informers/externalversions/operator/v1alpha1/interface.go +++ b/pkg/operator/informers/externalversions/operator/v1alpha1/interface.go @@ -10,6 +10,8 @@ import ( type Interface interface { // CertManagers returns a CertManagerInformer. CertManagers() CertManagerInformer + // HTTP01Proxies returns a HTTP01ProxyInformer. + HTTP01Proxies() HTTP01ProxyInformer // IstioCSRs returns a IstioCSRInformer. IstioCSRs() IstioCSRInformer // TrustManagers returns a TrustManagerInformer. @@ -32,6 +34,11 @@ func (v *version) CertManagers() CertManagerInformer { return &certManagerInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} } +// HTTP01Proxies returns a HTTP01ProxyInformer. +func (v *version) HTTP01Proxies() HTTP01ProxyInformer { + return &hTTP01ProxyInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // IstioCSRs returns a IstioCSRInformer. func (v *version) IstioCSRs() IstioCSRInformer { return &istioCSRInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/operator/listers/operator/v1alpha1/expansion_generated.go b/pkg/operator/listers/operator/v1alpha1/expansion_generated.go index 1692896d0..e76b36ac2 100644 --- a/pkg/operator/listers/operator/v1alpha1/expansion_generated.go +++ b/pkg/operator/listers/operator/v1alpha1/expansion_generated.go @@ -6,6 +6,14 @@ package v1alpha1 // CertManagerLister. type CertManagerListerExpansion interface{} +// HTTP01ProxyListerExpansion allows custom methods to be added to +// HTTP01ProxyLister. +type HTTP01ProxyListerExpansion interface{} + +// HTTP01ProxyNamespaceListerExpansion allows custom methods to be added to +// HTTP01ProxyNamespaceLister. +type HTTP01ProxyNamespaceListerExpansion interface{} + // IstioCSRListerExpansion allows custom methods to be added to // IstioCSRLister. type IstioCSRListerExpansion interface{} diff --git a/pkg/operator/listers/operator/v1alpha1/http01proxy.go b/pkg/operator/listers/operator/v1alpha1/http01proxy.go new file mode 100644 index 000000000..951da193a --- /dev/null +++ b/pkg/operator/listers/operator/v1alpha1/http01proxy.go @@ -0,0 +1,54 @@ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + operatorv1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// HTTP01ProxyLister helps list HTTP01Proxies. +// All objects returned here must be treated as read-only. +type HTTP01ProxyLister interface { + // List lists all HTTP01Proxies in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*operatorv1alpha1.HTTP01Proxy, err error) + // HTTP01Proxies returns an object that can list and get HTTP01Proxies. + HTTP01Proxies(namespace string) HTTP01ProxyNamespaceLister + HTTP01ProxyListerExpansion +} + +// hTTP01ProxyLister implements the HTTP01ProxyLister interface. +type hTTP01ProxyLister struct { + listers.ResourceIndexer[*operatorv1alpha1.HTTP01Proxy] +} + +// NewHTTP01ProxyLister returns a new HTTP01ProxyLister. +func NewHTTP01ProxyLister(indexer cache.Indexer) HTTP01ProxyLister { + return &hTTP01ProxyLister{listers.New[*operatorv1alpha1.HTTP01Proxy](indexer, operatorv1alpha1.Resource("http01proxy"))} +} + +// HTTP01Proxies returns an object that can list and get HTTP01Proxies. +func (s *hTTP01ProxyLister) HTTP01Proxies(namespace string) HTTP01ProxyNamespaceLister { + return hTTP01ProxyNamespaceLister{listers.NewNamespaced[*operatorv1alpha1.HTTP01Proxy](s.ResourceIndexer, namespace)} +} + +// HTTP01ProxyNamespaceLister helps list and get HTTP01Proxies. +// All objects returned here must be treated as read-only. +type HTTP01ProxyNamespaceLister interface { + // List lists all HTTP01Proxies in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*operatorv1alpha1.HTTP01Proxy, err error) + // Get retrieves the HTTP01Proxy from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*operatorv1alpha1.HTTP01Proxy, error) + HTTP01ProxyNamespaceListerExpansion +} + +// hTTP01ProxyNamespaceLister implements the HTTP01ProxyNamespaceLister +// interface. +type hTTP01ProxyNamespaceLister struct { + listers.ResourceIndexer[*operatorv1alpha1.HTTP01Proxy] +} diff --git a/pkg/operator/setup_manager.go b/pkg/operator/setup_manager.go index 852217a84..989d274a2 100644 --- a/pkg/operator/setup_manager.go +++ b/pkg/operator/setup_manager.go @@ -20,12 +20,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" configv1 "github.com/openshift/api/config/v1" v1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" "github.com/openshift/cert-manager-operator/pkg/controller/common" + "github.com/openshift/cert-manager-operator/pkg/controller/http01proxy" "github.com/openshift/cert-manager-operator/pkg/controller/istiocsr" "github.com/openshift/cert-manager-operator/pkg/controller/trustmanager" "github.com/openshift/cert-manager-operator/pkg/version" @@ -80,7 +82,7 @@ var istioCSRManagedResources = []client.Object{ // cert-manager Issuer (and ClusterIssuer, which is never listed here) must not use a // managed-resource label selector: IstioCSR reconciles user-created Issuers referenced // from the spec, which are not labeled by the operator. Those types are left out of -// ByObject so they use the manager cache’s default unfiltered informer per GVK. +// ByObject so they use the manager cache's default unfiltered informer per GVK. var trustManagerManagedResources = []client.Object{ &certmanagerv1.Certificate{}, &appsv1.Deployment{}, @@ -115,6 +117,7 @@ type Manager struct { type ControllerConfig struct { EnableIstioCSR bool EnableTrustManager bool + EnableHTTP01Proxy bool } // NewControllerManager creates a unified manager for all enabled operand controllers. @@ -122,7 +125,7 @@ type ControllerConfig struct { func NewControllerManager(config ControllerConfig) (*Manager, error) { setupLog.Info("setting up unified operator manager") setupLog.Info("controller", "version", version.Get()) - setupLog.Info("enabled controllers", "istioCSR", config.EnableIstioCSR, "trustManager", config.EnableTrustManager) + setupLog.Info("enabled controllers", "istioCSR", config.EnableIstioCSR, "trustManager", config.EnableTrustManager, "http01Proxy", config.EnableHTTP01Proxy) cacheBuilder := newUnifiedCacheBuilder(config) @@ -130,6 +133,9 @@ func NewControllerManager(config ControllerConfig) (*Manager, error) { Scheme: scheme, NewCache: cacheBuilder, Logger: ctrl.Log.WithName("operator-manager"), + // Use a separate port for the controller-runtime metrics server to avoid + // conflicting with the library-go metrics server on :8080. + Metrics: metricsserver.Options{BindAddress: ":8085"}, }) if err != nil { return nil, fmt.Errorf("failed to create manager: %w", err) @@ -148,6 +154,12 @@ func NewControllerManager(config ControllerConfig) (*Manager, error) { } } + if config.EnableHTTP01Proxy { + if err := setupHTTP01ProxyController(mgr); err != nil { + return nil, err + } + } + return &Manager{ manager: mgr, }, nil @@ -179,6 +191,19 @@ func setupTrustManagerController(mgr ctrl.Manager) error { return nil } +// setupHTTP01ProxyController creates and registers the HTTP01Proxy controller with the manager. +func setupHTTP01ProxyController(mgr ctrl.Manager) error { + setupLog.Info("setting up controller", "name", http01proxy.ControllerName) + r, err := http01proxy.New(mgr) + if err != nil { + return fmt.Errorf("failed to create %s reconciler object: %w", http01proxy.ControllerName, err) + } + if err := r.SetupWithManager(mgr); err != nil { + return fmt.Errorf("failed to create %s controller: %w", http01proxy.ControllerName, err) + } + return nil +} + // newUnifiedCacheBuilder creates a cache builder that combines cache configurations // for all enabled controllers into a single unified cache. func newUnifiedCacheBuilder(config ControllerConfig) cache.NewCacheFunc { @@ -214,6 +239,10 @@ func buildCacheObjectList(config ControllerConfig) (map[client.Object]cache.ByOb objectList[&v1alpha1.TrustManager{}] = cache.ByObject{} } + if config.EnableHTTP01Proxy { + objectList[&v1alpha1.HTTP01Proxy{}] = cache.ByObject{} + } + return objectList, nil } diff --git a/pkg/operator/starter.go b/pkg/operator/starter.go index 3f07c3496..0ec9f6d3e 100644 --- a/pkg/operator/starter.go +++ b/pkg/operator/starter.go @@ -19,6 +19,7 @@ import ( "github.com/openshift/library-go/pkg/operator/status" "github.com/openshift/library-go/pkg/operator/v1helpers" + v1alpha1 "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" "github.com/openshift/cert-manager-operator/pkg/controller/certmanager" "github.com/openshift/cert-manager-operator/pkg/features" certmanoperatorclient "github.com/openshift/cert-manager-operator/pkg/operator/clientset/versioned" @@ -160,12 +161,14 @@ func RunOperator(ctx context.Context, cc *controllercmd.ControllerContext) error } istioCSREnabled := features.IsIstioCSRFeatureGateEnabled() trustManagerEnabled := featureStatus.IsTrustManagerFeatureGateEnabled() + http01ProxyEnabled := features.DefaultFeatureGate.Enabled(v1alpha1.FeatureHTTP01Proxy) - if istioCSREnabled || trustManagerEnabled { + if istioCSREnabled || trustManagerEnabled || http01ProxyEnabled { // Create unified manager for all enabled operand controllers manager, err := NewControllerManager(ControllerConfig{ EnableIstioCSR: istioCSREnabled, EnableTrustManager: trustManagerEnabled, + EnableHTTP01Proxy: http01ProxyEnabled, }) if err != nil { return fmt.Errorf("failed to create unified controller manager: %w", err) diff --git a/test/e2e/http01proxy_test.go b/test/e2e/http01proxy_test.go new file mode 100644 index 000000000..8ec96a1c2 --- /dev/null +++ b/test/e2e/http01proxy_test.go @@ -0,0 +1,128 @@ +//go:build e2e +// +build e2e + +package e2e + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + + "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" +) + +var _ = Describe("HTTP01 Proxy [apigroup:operator.openshift.io]", Label("Platform:Generic"), Ordered, func() { + var ( + ctx context.Context + originalUnsupportedAddonFeatures string + unsupportedAddonFeaturesEnvVarName = "UNSUPPORTED_ADDON_FEATURES" + ) + + BeforeAll(func() { + ctx = context.Background() + + By("capturing original UNSUPPORTED_ADDON_FEATURES value") + original, err := getSubscriptionEnvVar(ctx, loader, unsupportedAddonFeaturesEnvVarName) + Expect(err).NotTo(HaveOccurred(), "failed to get original UNSUPPORTED_ADDON_FEATURES") + originalUnsupportedAddonFeatures = original + + By("enabling HTTP01Proxy feature gate via subscription env var") + err = patchSubscriptionWithEnvVars(ctx, loader, map[string]string{ + unsupportedAddonFeaturesEnvVarName: "HTTP01Proxy=true", + }) + Expect(err).NotTo(HaveOccurred(), "failed to enable HTTP01Proxy feature gate") + + By("waiting for operator to restart with feature gate enabled") + err = waitForDeploymentEnvVarAndRollout(ctx, operatorNamespace, operatorDeploymentName, + unsupportedAddonFeaturesEnvVarName, "HTTP01Proxy=true", highTimeout) + Expect(err).NotTo(HaveOccurred(), "operator did not roll out after enabling HTTP01Proxy feature gate") + + By("waiting for operator to become available after restart") + err = VerifyHealthyOperatorConditions(certmanageroperatorclient.OperatorV1alpha1()) + Expect(err).NotTo(HaveOccurred(), "operator not healthy after enabling HTTP01Proxy feature gate") + }) + + AfterAll(func() { + By("restoring original UNSUPPORTED_ADDON_FEATURES value") + err := patchSubscriptionWithEnvVars(ctx, loader, map[string]string{ + unsupportedAddonFeaturesEnvVarName: originalUnsupportedAddonFeatures, + }) + if err != nil { + fmt.Fprintf(GinkgoWriter, "failed to restore UNSUPPORTED_ADDON_FEATURES during cleanup: %v\n", err) + return + } + + By("waiting for operator to roll out after restoring feature gates") + if originalUnsupportedAddonFeatures == "" { + err = waitForDeploymentEnvVarRemovedAndRollout(ctx, operatorNamespace, operatorDeploymentName, + unsupportedAddonFeaturesEnvVarName, highTimeout) + } else { + err = waitForDeploymentEnvVarAndRollout(ctx, operatorNamespace, operatorDeploymentName, + unsupportedAddonFeaturesEnvVarName, originalUnsupportedAddonFeatures, highTimeout) + } + if err != nil { + fmt.Fprintf(GinkgoWriter, "operator did not roll out after restoring feature gates: %v\n", err) + } + }) + + BeforeEach(func() { + By("waiting for operator status to become available") + err := VerifyHealthyOperatorConditions(certmanageroperatorclient.OperatorV1alpha1()) + Expect(err).NotTo(HaveOccurred(), "operator is expected to be available") + }) + + Context("on a non-baremetal cluster", func() { + It("should reject HTTP01Proxy CR with unsupported platform condition", func() { + proxy := &v1alpha1.HTTP01Proxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Namespace: operatorNamespace, + }, + Spec: v1alpha1.HTTP01ProxySpec{ + Mode: v1alpha1.HTTP01ProxyModeDefault, + }, + } + + By("creating HTTP01Proxy CR") + _, err := certmanageroperatorclient.OperatorV1alpha1().HTTP01Proxies(operatorNamespace).Create(ctx, proxy, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to create HTTP01Proxy CR") + + DeferCleanup(func(ctx context.Context) { + By("deleting HTTP01Proxy CR") + err := certmanageroperatorclient.OperatorV1alpha1().HTTP01Proxies(operatorNamespace).Delete(ctx, "default", metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + fmt.Fprintf(GinkgoWriter, "failed to delete HTTP01Proxy CR during cleanup: %v\n", err) + } + + By("waiting for HTTP01Proxy CR to be fully removed") + _ = wait.PollUntilContextTimeout(ctx, fastPollInterval, lowTimeout, true, func(ctx context.Context) (bool, error) { + _, getErr := certmanageroperatorclient.OperatorV1alpha1().HTTP01Proxies(operatorNamespace).Get(ctx, "default", metav1.GetOptions{}) + return apierrors.IsNotFound(getErr), nil + }) + }) + + By("waiting for Degraded=True and Ready=False conditions") + Eventually(func(g Gomega) { + fetched, getErr := certmanageroperatorclient.OperatorV1alpha1().HTTP01Proxies(operatorNamespace).Get(ctx, "default", metav1.GetOptions{}) + g.Expect(getErr).NotTo(HaveOccurred()) + + degraded := meta.FindStatusCondition(fetched.Status.Conditions, v1alpha1.Degraded) + g.Expect(degraded).NotTo(BeNil(), "Degraded condition not found on HTTP01Proxy") + g.Expect(degraded.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(degraded.Reason).To(Equal(v1alpha1.ReasonFailed)) + g.Expect(degraded.Message).To(ContainSubstring("not supported")) + + ready := meta.FindStatusCondition(fetched.Status.Conditions, v1alpha1.Ready) + g.Expect(ready).NotTo(BeNil(), "Ready condition not found on HTTP01Proxy") + g.Expect(ready.Status).To(Equal(metav1.ConditionFalse)) + }, lowTimeout, fastPollInterval).Should(Succeed()) + }) + }) +}) From 6344afadc8464a313806959dfb02ef4c99cc0840 Mon Sep 17 00:00:00 2001 From: Anand Kumar Date: Mon, 17 Aug 2026 16:44:34 +0530 Subject: [PATCH 2/2] add: learning --- PR-459-deep-dive.md | 791 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 791 insertions(+) create mode 100644 PR-459-deep-dive.md diff --git a/PR-459-deep-dive.md b/PR-459-deep-dive.md new file mode 100644 index 000000000..a416741ad --- /dev/null +++ b/PR-459-deep-dive.md @@ -0,0 +1,791 @@ +# PR #459 Deep Dive: HTTP01 Proxy with nftables MachineConfig + +**PR**: [CM-716: HTTP01 Proxy with nftables MachineConfig](https://github.com/openshift/cert-manager-operator/pull/459) +**Author**: Brandon Palm (`sebrandon1`) +**Branch**: `experiment/dnat-snat-operator` +**Commit**: `5ea08400f` +**Jira**: [CM-716](https://issues.redhat.com/browse/CM-716) +**Enhancement**: [openshift/enhancements#1929](https://github.com/openshift/enhancements/pull/1929) + +--- + +## Table of Contents + +1. [The Networking Problem](#1-the-networking-problem) +2. [The API Layer](#2-the-api-layer) +3. [The Controller](#3-the-controller) +4. [Platform Discovery and Validation](#4-platform-discovery-and-validation) +5. [MachineConfig Rendering](#5-machineconfig-rendering) +6. [Finalizer Lifecycle](#6-finalizer-lifecycle) +7. [Status Condition Management](#7-status-condition-management) +8. [Wiring: Feature Gate and Startup](#8-wiring-feature-gate-and-startup) +9. [Generated Kubernetes Client Infrastructure](#9-generated-kubernetes-client-infrastructure) +10. [Config, RBAC, and Bundle Changes](#10-config-rbac-and-bundle-changes) +11. [Test Suite](#11-test-suite) +12. [End-to-End Data Flow](#12-end-to-end-data-flow) +13. [Key Design Decisions](#13-key-design-decisions) +14. [Files Changed Summary](#14-files-changed-summary) + +--- + +## 1. The Networking Problem + +### 1.1 What Are VIPs? + +On a baremetal OpenShift cluster, there's no cloud load balancer. Instead, **keepalived** (implementing the VRRP protocol) manages **Virtual IPs** — IP addresses that "float" between physical nodes. If the node holding the VIP goes down, another node takes over within seconds. + +There are two VIPs: + +- **API VIP** (e.g., `10.46.97.32`): DNS for `api.cluster.example.com` points here. The kube-apiserver listens on port 6443. **Port 80 is unused.** +- **Ingress VIP** (e.g., `10.46.97.48`): DNS for `*.apps.cluster.example.com` points here. The OpenShift router (HAProxy) listens on ports 80 and 443. + +### 1.2 The HTTP-01 Challenge Flow + +When cert-manager requests a certificate from Let's Encrypt using HTTP-01: + +1. cert-manager creates a **solver pod** that serves a token at `/.well-known/acme-challenge/` on port 8089 +2. cert-manager creates a **Service** and **Ingress** (or Route) pointing to the solver pod +3. The ACME server (Let's Encrypt) tries `http://:80/.well-known/acme-challenge/` + +If the domain is `api.cluster.example.com`, DNS resolves to the **API VIP**. But the solver pod is behind the **Ingress VIP**. The request hits the API VIP on port 80 and gets nothing — connection refused. The challenge fails. + +### 1.3 DNAT and MASQUERADE + +**DNAT (Destination NAT)**: The kernel rewrites the destination IP of incoming packets. A packet arriving at `10.46.97.32:80` gets its destination changed to `10.46.97.48:80` *before* the kernel makes a routing decision. + +**MASQUERADE**: When the kernel forwards this DNAT'd packet to the Ingress VIP, the source IP is the external ACME server. The ingress node would try to send the response directly back to the ACME server — but the ACME server expects a response from the API VIP, not the Ingress VIP. **MASQUERADE** rewrites the source IP of the forwarded packet to the node's own IP, so the response comes back through the same node and the DNAT is "reversed" automatically by the kernel's connection tracking (conntrack) table. + +``` +ACME server --> API VIP:80 --> [nftables DNAT] --> Ingress VIP:80 --> router --> solver pod + [MASQUERADE ensures return traffic routes back] +``` + +### 1.4 Why Both nftables AND iptables? + +OpenShift uses **iptables-nft** — iptables syntax running on top of the nftables kernel subsystem. The kernel maintains two parallel hook chains: + +``` +Packet arrives + └─> nftables native hooks (our DNAT rules) ─> packet gets DNAT'd ✓ + └─> iptables-nft hooks (OpenShift's FORWARD chain, policy DROP) ─> packet dropped ✗ +``` + +Both must ACCEPT the packet. So the PR adds: + +1. **nftables rules** for DNAT + MASQUERADE (the NAT itself) +2. **iptables FORWARD rules** to ACCEPT the forwarded packets (so OpenShift's DROP policy doesn't kill them) + +--- + +## 2. The API Layer + +### 2.1 Feature Gate Definition + +File: `api/operator/v1alpha1/features.go` + +```go +FeatureHTTP01Proxy featuregate.Feature = "HTTP01Proxy" + +var OperatorFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ + FeatureIstioCSR: {Default: true, PreRelease: featuregate.GA}, + FeatureTrustManager: {Default: false, PreRelease: "TechPreview"}, + FeatureHTTP01Proxy: {Default: false, PreRelease: featuregate.Alpha}, +} +``` + +Alpha, disabled by default. Must be explicitly enabled with `--unsupported-addon-features="HTTP01Proxy=true"`. + +### 2.2 The Type Hierarchy + +File: `api/operator/v1alpha1/http01proxy_types.go` + +``` +HTTP01Proxy +├── TypeMeta (apiVersion, kind) +├── ObjectMeta (name, namespace, labels, annotations, finalizers, deletionTimestamp...) +├── Spec: HTTP01ProxySpec +│ ├── Mode: "DefaultDeployment" | "CustomDeployment" +│ └── CustomDeployment: *HTTP01ProxyCustomDeploymentSpec (optional) +│ └── InternalPort: int32 (1024-65535, default 8888) +└── Status: HTTP01ProxyStatus + ├── ConditionalStatus (embedded) + │ └── Conditions: []metav1.Condition + │ ├── {Type: "Degraded", Status: True/False, Reason: "Failed"/"Ready"} + │ └── {Type: "Ready", Status: True/False, Reason: "Ready"/"Failed"/"Progressing"} + └── ProxyImage: string +``` + +### 2.3 CEL Validation Rules + +**Singleton enforcement** (on the object itself): + +```go +// +kubebuilder:validation:XValidation:rule="self.metadata.name == 'default'",message="http01proxy is a singleton, .metadata.name must be 'default'" +``` + +If you try `kubectl create` with `name: foo`, the API server rejects it with a 422 Unprocessable Entity. The controller never sees the request. + +**Conditional field enforcement** (on the spec): + +```go +// +kubebuilder:validation:XValidation:rule="self.mode == 'CustomDeployment' ? has(self.customDeployment) : !has(self.customDeployment)",message="customDeployment is required when mode is CustomDeployment and forbidden otherwise" +``` + +If mode is `CustomDeployment`, the `customDeployment` field must be present; otherwise, it must be absent. + +### 2.4 Additional Printer Columns + +```go +// +kubebuilder:printcolumn:name="Mode",type="string",JSONPath=".spec.mode" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +``` + +When you run `kubectl get http01proxy`: + +``` +NAME MODE READY MESSAGE AGE +default DefaultDeployment True reconciliation successful 5m +``` + +### 2.5 The ConditionalStatus Pattern + +File: `api/operator/v1alpha1/meta.go` + +```go +type ConditionalStatus struct { + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} +``` + +- `+listType=map` and `+listMapKey=type` tell Kubernetes this is a **map-keyed list** — can't have two conditions with the same `type`, and strategic merge patches work on the `type` field as the key. +- Shared across IstioCSR, TrustManager, and HTTP01Proxy. + +The `SetCondition` method (in `conditions.go`) only returns `true` if the status or reason actually changed — preventing unnecessary API writes on no-op reconciliations. + +--- + +## 3. The Controller + +### 3.1 The Reconciler Struct + +File: `pkg/controller/http01proxy/controller.go` + +```go +type Reconciler struct { + common.CtrlClient // embedded client with retry helpers + eventRecorder record.EventRecorder + log logr.Logger + cachedPlatform *platformInfo // cached platform discovery + platformMu sync.Mutex // thread-safe cache access +} +``` + +- `common.CtrlClient`: Project-wide abstraction wrapping `client.Client` with `UpdateWithRetry` and `StatusUpdate`. Also enables testing via `fakes.FakeCtrlClient`. +- `cachedPlatform` + `platformMu`: Multiple reconciliation goroutines could call `getOrDiscoverPlatform` concurrently, so mutex protection is required. + +### 3.2 Controller Registration (SetupWithManager) + +```go +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + // When Infrastructure CR changes, invalidate cache and enqueue reconcile + infrastructureMapFunc := func(ctx context.Context, obj client.Object) []reconcile.Request { + if obj.GetName() != "cluster" { return nil } + r.platformMu.Lock() + r.cachedPlatform = nil + r.platformMu.Unlock() + return []reconcile.Request{{NamespacedName: types.NamespacedName{ + Name: "default", Namespace: common.OperatorNamespace, + }}} + } + + builder := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.HTTP01Proxy{}). + Named(ControllerName) + + // Runtime capability check — skip watch on MicroShift + if _, err := mgr.GetRESTMapper().RESTMapping(infrastructureGVK.GroupKind(), infrastructureGVK.Version); err == nil { + builder = builder.Watches(&configv1.Infrastructure{}, handler.EnqueueRequestsFromMapFunc(infrastructureMapFunc)) + } + + return builder.Complete(r) +} +``` + +Key points: +- `For(&v1alpha1.HTTP01Proxy{})` — primary watch: any create/update/delete of HTTP01Proxy triggers reconciliation. +- `Watches(&configv1.Infrastructure{}, ...)` — secondary watch: Infrastructure changes invalidate the platform cache and trigger re-reconciliation. +- The `RESTMapping` check prevents crashes on MicroShift where the Infrastructure CRD doesn't exist. + +### 3.3 The Reconcile Method — Full Flow + +```go +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +``` + +| Path | Condition | Action | +|------|-----------|--------| +| Wrong namespace | `req.Namespace != common.OperatorNamespace` | Silently ignore | +| Object not found | `errors.IsNotFound(err)` | Return empty result (already deleted) | +| Get error | Any other API error | Return error (controller-runtime retries with exponential backoff) | +| Deletion in progress | `!proxy.DeletionTimestamp.IsZero()` | Clean up MachineConfig → remove finalizer → return | +| Normal reconciliation | Object exists, not being deleted | Add finalizer → `processReconcileRequest` | + +### 3.4 processReconcileRequest — Error Handling State Machine + +```go +func (r *Reconciler) processReconcileRequest(...) (ctrl.Result, error) { + reconcileErr := r.reconcileHTTP01ProxyDeployment(ctx, proxy) + return common.HandleReconcileResult( + &proxy.Status.ConditionalStatus, + reconcileErr, + r.log, + func(prependErr error) error { return r.updateCondition(ctx, proxy, prependErr) }, + defaultRequeueTime, // 30 seconds + ) +} +``` + +`HandleReconcileResult` (in `pkg/controller/common/reconcile_result.go`) maps errors to status conditions: + +| `reconcileErr` | Degraded | Ready | Reason | Return | +|----------------|----------|-------|--------|--------| +| `nil` (success) | False | True | Ready | No error, no requeue | +| `IrrecoverableError` | True | False | Failed | No error (**don't retry**) | +| `RetryRequiredError` | False | False | Progressing | `RequeueAfter: 30s` | + +Unsupported platform → IrrecoverableError → won't keep retrying. +Transient API error → RetryRequiredError → tries again in 30 seconds. + +### 3.5 reconcileHTTP01ProxyDeployment — The Core Logic + +File: `pkg/controller/http01proxy/install_http01proxy.go` + +```go +func (r *Reconciler) reconcileHTTP01ProxyDeployment(ctx context.Context, proxy *v1alpha1.HTTP01Proxy) error { + // 1. Discover platform (cached, mutex-protected) + info, err := r.getOrDiscoverPlatform(ctx) + if err != nil { + return common.NewRetryRequiredError(err, "failed to discover platform") + } + + // 2. Validate platform + if reason := validatePlatform(info); reason != "" { + // Clean up any existing MachineConfig first (platform may have changed) + if err := r.cleanUp(ctx, proxy); err != nil { + return common.NewRetryRequiredError(err, "failed to clean up after platform validation failure") + } + return common.NewIrrecoverableError(fmt.Errorf("platform validation failed"), "%s", reason) + } + + // 3. Create or update MachineConfig + if err := r.createOrApplyMachineConfig(ctx, info); err != nil { + return err + } + + // 4. Mark as processed (for logging distinguishing new vs existing) + if common.AddAnnotation(proxy, controllerProcessedAnnotation, "true") { + if err := r.UpdateWithRetry(ctx, proxy); err != nil { + return fmt.Errorf("failed to update processed annotation: %w", err) + } + } + + return nil +} +``` + +--- + +## 4. Platform Discovery and Validation + +### 4.1 Cached Discovery + +File: `pkg/controller/http01proxy/infrastructure.go` + +```go +func (r *Reconciler) getOrDiscoverPlatform(ctx context.Context) (*platformInfo, error) { + r.platformMu.Lock() + defer r.platformMu.Unlock() + if r.cachedPlatform != nil { + return r.cachedPlatform, nil + } + info, err := r.discoverPlatform(ctx) + if err != nil { return nil, err } + r.cachedPlatform = info + return info, nil +} +``` + +Cache is invalidated only when the Infrastructure CR changes (via the secondary watch in `SetupWithManager`). + +### 4.2 Unstructured Objects + +```go +func (r *Reconciler) discoverPlatform(ctx context.Context) (*platformInfo, error) { + infra := &unstructured.Unstructured{} + infra.SetGroupVersionKind(infrastructureGVK) + // ... + platformType, _, _ := unstructured.NestedString(infra.Object, "status", "platformStatus", "type") +} +``` + +Why `unstructured.Unstructured` instead of typed Go structs? + +1. **No import dependency** on MachineConfig operator types — avoids `go.mod` coupling. +2. **Resilience** to API version changes — string-based nested field access works regardless of struct layout. +3. **`meta.IsNoMatchError(err)`** handles the case where the entire CRD doesn't exist on the API server (MicroShift). + +### 4.3 Validation Logic + +```go +func validatePlatform(info *platformInfo) string { + // Must be BareMetal + // Must have at least one API VIP + // Must have at least one Ingress VIP + // API VIP and Ingress VIP must not be the same +} +``` + +The overlapping VIP check is a **cross-product comparison** — checks every `(apiVIP, ingressVIP)` pair, not just the first. This handles dual-stack clusters with both IPv4 and IPv6 VIPs. + +--- + +## 5. MachineConfig Rendering + +### 5.1 What Is a MachineConfig? + +An OpenShift-specific resource declaring OS-level configuration (files, systemd units) for nodes. The Machine Config Operator drains each node, applies the config, and reboots it. + +### 5.2 The nftables Configuration + +File: `pkg/controller/http01proxy/machineconfig.go` + +``` +table inet crtmgr_http01_dnat ← create if not exists (inet = dual-stack) +delete table inet crtmgr_http01_dnat ← idempotency: remove old rules +table inet crtmgr_http01_dnat { + chain prerouting { + type nat hook prerouting priority 0; + ip daddr {{ .APIVIP }} tcp dport 80 dnat ip to {{ .IngressVIP }}:80 + ↑ match: dest=API VIP, port=80 ↑ action: rewrite dest to Ingress VIP + } + chain postrouting { + type nat hook postrouting priority 100; + ip daddr {{ .IngressVIP }} tcp dport 80 masquerade + ↑ rewrite source IP so return traffic routes back through this node + } +} +``` + +- The `ip` keyword in `dnat ip to` disambiguates IPv4 inside an `inet` table. +- The create-delete-create pattern ensures idempotent reloads. + +### 5.3 The Systemd Service + +```ini +[Unit] +Wants=network-pre.target +Before=network-pre.target ← runs before networking is fully up + +[Service] +Type=oneshot ← runs commands and exits +ProtectSystem=full ← security: can't write to /usr, /boot +ProtectHome=true ← security: can't access /home + +ExecStartPre=/sbin/sysctl -w net.ipv4.ip_forward=1 ← enable IP forwarding +ExecStart=/sbin/nft -f /etc/sysconfig/nftables-crtmgr-http01.conf ← load nftables +ExecStart=/bin/bash -c 'iptables -C FORWARD ... 2>/dev/null || iptables -I FORWARD 1 ...' +↑ check-then-insert pattern: add iptables FORWARD rule only if not present + +ExecReload=/sbin/nft -f ... ← allows systemctl reload + +ExecStop=/sbin/nft 'add table ...; delete table ...' ← atomic cleanup +ExecStop=/bin/bash -c 'iptables -D FORWARD ...; true' ← remove FORWARD rule + +RemainAfterExit=yes ← crucial: keeps service "active" so ExecStop can fire +``` + +### 5.4 The createOrApplyMachineConfig Method + +Three-way reconciliation: + +1. **Pre-checks**: Both `apiVIPs` and `ingressVIPs` must be non-empty. +2. **Render desired state**: `renderMachineConfig(info.apiVIPs[0], info.ingressVIPs[0])`. Only first VIP used (IPv6 out of scope). +3. **Get existing state**: Fetch MachineConfig by name. +4. **Create if NotFound**. +5. **Compare specs**: `reflect.DeepEqual(desiredSpec, existingSpec)`. Skip if equal. +6. **Update if different**: Set existing `ResourceVersion` on desired object (required for optimistic concurrency). + +MachineConfig name: `98-nftables-crtmgr-http01-dnat`. The `98` prefix is high priority (applied late), avoiding conflicts with lower-numbered system configs. + +--- + +## 6. Finalizer Lifecycle + +### 6.1 What Are Finalizers? + +Strings stored in `metadata.finalizers[]`. When you delete a Kubernetes object with finalizers: + +1. API server sets `metadata.deletionTimestamp` +2. Does NOT actually delete the object +3. Waits for all finalizers to be removed + +The controller's finalizer: `"http01proxy.openshift.operator.io/cert-manager-http01-proxy-controller"`. + +### 6.2 Add Finalizer + +File: `pkg/controller/http01proxy/utils.go` + +```go +func (r *Reconciler) addFinalizer(ctx context.Context, proxy *v1alpha1.HTTP01Proxy) error { + if !controllerutil.ContainsFinalizer(proxy, finalizer) { + controllerutil.AddFinalizer(proxy, finalizer) + r.UpdateWithRetry(ctx, proxy) + // Re-fetch to get updated ResourceVersion + r.Get(ctx, namespacedName, updated) + updated.DeepCopyInto(proxy) + } + return nil +} +``` + +The re-fetch after update is critical: `Update` changes the `ResourceVersion`, and the in-memory object becomes stale. Without the re-fetch, subsequent status updates would fail with a conflict error. + +### 6.3 Remove Finalizer + +```go +func (r *Reconciler) removeFinalizer(ctx context.Context, proxy *v1alpha1.HTTP01Proxy) error { + if controllerutil.ContainsFinalizer(proxy, finalizer) { + controllerutil.RemoveFinalizer(proxy, finalizer) + r.UpdateWithRetry(ctx, proxy) + } + return nil +} +``` + +Once removed, Kubernetes garbage-collects the HTTP01Proxy object. + +--- + +## 7. Status Condition Management + +### 7.1 updateCondition with Error Aggregation + +```go +func (r *Reconciler) updateCondition(ctx context.Context, proxy *v1alpha1.HTTP01Proxy, prependErr error) error { + if err := r.updateStatus(ctx, proxy); err != nil { + errUpdate := fmt.Errorf("failed to update status: %w", err) + if prependErr != nil { + return utilerrors.NewAggregate([]error{prependErr, errUpdate}) + } + return errUpdate + } + return prependErr +} +``` + +Handles the case where both reconciliation AND status update fail — `NewAggregate` preserves both errors. + +### 7.2 updateStatus with Retry + +```go +func (r *Reconciler) updateStatus(ctx context.Context, changed *v1alpha1.HTTP01Proxy) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := &v1alpha1.HTTP01Proxy{} + r.Get(ctx, namespacedName, current) // 1. Read latest + changed.Status.DeepCopyInto(¤t.Status) // 2. Copy desired status + r.StatusUpdate(ctx, current) // 3. Write back + }) +} +``` + +Read-modify-write with retry. The status subresource (`/status`) is separate from the main resource — updating status doesn't trigger a new reconciliation (preventing infinite loops). + +--- + +## 8. Wiring: Feature Gate and Startup + +### 8.1 Startup Flow + +File: `pkg/operator/starter.go` + +```go +// 1. Parse feature flags +features.SetupWithFlagValue(UnsupportedAddonFeatures) + +// 2. Check if HTTP01Proxy is enabled +http01ProxyEnabled := features.DefaultFeatureGate.Enabled(v1alpha1.FeatureHTTP01Proxy) + +// 3. Create unified controller manager if any addon is enabled +if istioCSREnabled || trustManagerEnabled || http01ProxyEnabled { + manager, _ := NewControllerManager(ControllerConfig{ + EnableHTTP01Proxy: http01ProxyEnabled, + }) + go manager.Start(ctx) +} +``` + +### 8.2 Controller Manager Setup + +File: `pkg/operator/setup_manager.go` + +```go +func setupHTTP01ProxyController(mgr ctrl.Manager) error { + r, _ := http01proxy.New(mgr) + return r.SetupWithManager(mgr) +} +``` + +The HTTP01Proxy controller is registered with an unfiltered cache entry (`cache.ByObject{}`), unlike IstioCSR and TrustManager which use label-selector-filtered caches for their managed sub-resources. + +--- + +## 9. Generated Kubernetes Client Infrastructure + +~800 lines of boilerplate generated by Kubernetes code generators: + +| Package | Purpose | Used By | +|---------|---------|---------| +| `pkg/operator/clientset/.../http01proxy.go` | Typed CRUD client (Create, Get, List, Watch, Update, Delete, Patch, Apply) | E2E tests, operator startup | +| `pkg/operator/clientset/.../fake/fake_http01proxy.go` | In-memory fake client | Unit tests | +| `pkg/operator/informers/.../http01proxy.go` | Shared informer (watches API server, maintains local cache) | Operator event-driven architecture | +| `pkg/operator/listers/.../http01proxy.go` | Read-only cache accessor (List, Get by namespace) | Informer-based code | +| `pkg/operator/applyconfigurations/.../http01proxy.go` | Builder pattern for server-side apply | Declarative patching | +| `api/operator/v1alpha1/zz_generated.deepcopy.go` | DeepCopy implementations | All Kubernetes object handling | + +--- + +## 10. Config, RBAC, and Bundle Changes + +### 10.1 CRD Manifest + +File: `config/crd/bases/operator.openshift.io_http01proxies.yaml` + +- 179 lines of OpenAPI v3 schema +- Includes CEL validation rules, printer columns, status subresource +- Labels: `app.kubernetes.io/name=http01proxy`, `app.kubernetes.io/part-of=cert-manager-operator` + +### 10.2 RBAC + +File: `config/rbac/role.yaml` + +New permissions added: + +```yaml +- apiGroups: [machineconfiguration.openshift.io] + resources: [machineconfigs] + verbs: [create, delete, get, list, patch, update, watch] + +- apiGroups: [operator.openshift.io] + resources: [http01proxies] + verbs: [get, list, patch, update, watch] + +- apiGroups: [operator.openshift.io] + resources: [http01proxies/status] + verbs: [get, patch, update] + +- apiGroups: [operator.openshift.io] + resources: [http01proxies/finalizers] + verbs: [update] + +- apiGroups: [config.openshift.io] + resources: [infrastructures] + verbs: [get, list, watch] +``` + +### 10.3 Manager Deployment + +File: `config/manager/manager.yaml` + +New env vars: + +```yaml +- name: RELATED_IMAGE_CERT_MANAGER_HTTP01PROXY + value: quay.io/openshift/cert-manager-http01-proxy:v0.1.0 +- name: HTTP01PROXY_OPERAND_IMAGE_VERSION + value: 0.1.0 +``` + +`RELATED_IMAGE_*` follows OLM convention for declaring operand images (enables disconnected registry mirroring). + +### 10.4 Makefile + +New targets: `build-http01-proxy`, `image-build-http01-proxy`, plus `HTTP01PROXY_VERSION` variable and `local-run` env var additions. + +--- + +## 11. Test Suite + +### 11.1 Controller Tests (`controller_test.go` — 7 tests) + +| Test | Verifies | +|------|----------| +| `TestReconcileWrongNamespace` | Non-operator namespace requests silently ignored, no API calls | +| `TestReconcileNotFound` | Deleted objects return empty result, no error | +| `TestReconcileGetError` | API server errors propagate (triggers retry) | +| `TestReconcileDeletion` | Full deletion: Delete called (MachineConfig cleanup), finalizer removed | +| `TestCleanUpDeletesMachineConfig` | Cleanup issues exactly 1 Delete call | +| `TestCleanUpMachineConfigDeleteFails` | Delete failure propagates as error | +| `TestCleanUpMachineConfigAlreadyGone` | NotFound during cleanup is OK (idempotent) | +| `TestReconcileDeletionWithoutFinalizer` | No finalizer = no-op | + +### 11.2 Infrastructure Tests (`infrastructure_test.go` — 13 tests) + +**validatePlatform** (10 sub-tests): + +| Sub-test | Input | Expected | +|----------|-------|----------| +| non-baremetal platform | `{platformType: "AWS"}` | "not supported" | +| baremetal no API VIPs | `{BareMetal, apiVIPs: nil}` | "no API server VIPs" | +| baremetal no ingress VIPs | `{BareMetal, ingressVIPs: nil}` | "no ingress VIPs" | +| baremetal overlapping VIPs | `{apiVIPs: ["10.0.0.1"], ingressVIPs: ["10.0.0.1"]}` | "are the same" | +| baremetal valid distinct VIPs | `{apiVIPs: ["10.0.0.1"], ingressVIPs: ["10.0.0.2"]}` | empty (valid) | +| baremetal multiple distinct VIPs | `{apiVIPs: ["10.0.0.1","fd00::1"], ingressVIPs: ["10.0.0.2","fd00::2"]}` | empty (valid) | +| one overlapping pair | `{apiVIPs: ["10.0.0.1","10.0.0.3"], ingressVIPs: ["10.0.0.2","10.0.0.1"]}` | "are the same" | +| empty platform type | `{platformType: ""}` | "not supported" | +| None platform type | `{platformType: "None"}` | "not supported" | +| empty VIP slices | `{BareMetal, apiVIPs: []}` | "no API server VIPs" | + +**discoverPlatform** (4 sub-tests): Get error, missing field, non-BareMetal, BareMetal with/without VIPs. + +**getOrDiscoverPlatform** (3 sub-tests): Cache hit (no API call), cache miss (API called then cached), error doesn't cache. + +### 11.3 MachineConfig Tests (`machineconfig_test.go` — 14 tests) + +| Test | Verifies | +|------|----------| +| `TestRenderMachineConfig` | Kind, name, role label, nftables content (VIP, DNAT, masquerade, table name, inet family, prerouting/postrouting), systemd unit content (nft load, sysctl, iptables check-and-insert, ExecStop cleanup, Type=oneshot, RemainAfterExit), unit name and enabled state | +| `TestRenderMachineConfigDifferentVIP` | Rules use actual VIP passed, not hardcoded | +| `TestRenderMachineConfigIgnitionVersion` | Ignition version is 3.4.0 | +| `TestRenderMachineConfigFilePermissions` | File path correct, mode is 0600 (384 decimal) | +| `TestCreateOrApplyMachineConfigNoVIPs` | Empty apiVIPs and ingressVIPs return specific errors, no API calls | +| `TestCreateOrApplyMachineConfigCreate` | NotFound → Create called with correct name | +| `TestCreateOrApplyMachineConfigCreateError` | Create failure propagates | +| `TestCreateOrApplyMachineConfigGetError` | Non-NotFound Get errors propagate, no Create | +| `TestCreateOrApplyMachineConfigUpdateWhenSpecDiffers` | Different spec → Update called, ResourceVersion preserved | +| `TestCreateOrApplyMachineConfigUpdateError` | Update failure propagates | +| `TestCreateOrApplyMachineConfigNoOpWhenUnchanged` | Same spec → no Update, no Create | +| `TestDeleteMachineConfigSuccess` | Delete called with correct name and GVK | +| `TestDeleteMachineConfigNotFound` | NotFound during delete is OK | +| `TestDeleteMachineConfigError` | Delete failure propagates | + +### 11.4 Utils Tests (`utils_test.go` — 12 tests) + +| Test | Verifies | +|------|----------| +| `TestUpdateCondition` | 4 combinations: nil/nil=OK, err/nil=err, nil/fail=fail, err/fail=both errors aggregated | +| `TestAddFinalizer` | Already has finalizer=no-op, adds successfully, update fails, re-fetch fails | +| `TestRemoveFinalizer` | No finalizer=no-op, removes successfully, update fails | +| `TestUpdateStatus` | Success, Get fails, StatusUpdate fails | + +### 11.5 Install Tests (`install_http01proxy_test.go` — 5 tests) + +| Test | Verifies | +|------|----------| +| `PlatformDiscoveryError` | Infrastructure unavailable → retryable error | +| `UnsupportedPlatform` | AWS → "not supported" + cleanup called | +| `HappyPath` | BareMetal + VIPs → MachineConfig created, annotation set | +| `MachineConfigError` | MachineConfig API failure → error propagated | +| `AnnotationUpdateError` | MachineConfig succeeds but annotation update fails → error propagated | + +### 11.6 Feature Gate Tests (`features_test.go`) + +- HTTP01Proxy listed in `expectedDefaultFeatureState[false]` (disabled by default) +- Test verifies all pre-GA features default to disabled +- Test verifies all operator features can be enabled at runtime + +### 11.7 E2E Test (`test/e2e/http01proxy_test.go`) + +Runs on CI (non-baremetal cluster): + +1. Enables `HTTP01Proxy` feature gate via OLM subscription patch +2. Waits for operator rollout +3. Creates HTTP01Proxy CR → waits for `Degraded=True` with "not supported" message +4. Cleans up CR and restores original feature gates + +--- + +## 12. End-to-End Data Flow + +``` +1. User enables feature gate: + UNSUPPORTED_ADDON_FEATURES=HTTP01Proxy=true → operator restarts + +2. User creates CR: + kubectl apply -f http01proxy.yaml + (name=default, mode=DefaultDeployment) + +3. API server validates: + CEL: name == "default" ✓ + CEL: mode != CustomDeployment → no customDeployment field ✓ + +4. Controller receives reconcile event: + → Add finalizer to HTTP01Proxy + → Discover platform (read Infrastructure CR, cache result) + → Validate: BareMetal? VIPs present? VIPs different? + → Render MachineConfig with nftables + systemd templates + → Create/update MachineConfig in Kubernetes + → Set status: Degraded=False, Ready=True + +5. Machine Config Operator picks up MachineConfig: + → Drains each control plane node + → Writes /etc/sysconfig/nftables-crtmgr-http01.conf + → Creates crtmgr-http01-dnat.service + → Reboots node + +6. Node boots: + → systemd starts crtmgr-http01-dnat.service (oneshot, before network) + → sysctl enables ip_forward + → nft loads DNAT/MASQUERADE rules + → iptables inserts FORWARD ACCEPT rule + +7. ACME challenge works: + Let's Encrypt → API VIP:80 → DNAT → Ingress VIP:80 → router → solver pod + solver pod → MASQUERADE → Let's Encrypt + Certificate issued ✓ + +8. User deletes CR: + → DeletionTimestamp set + → Controller: delete MachineConfig → MCO rolls out removal → remove finalizer + → Kubernetes GCs the HTTP01Proxy object +``` + +--- + +## 13. Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| **nftables + iptables together** | Kernel evaluates both iptables-nft and native nftables hooks. OpenShift's iptables FORWARD chain has `policy DROP`, so both must ACCEPT forwarded packets. | +| **MachineConfig instead of DaemonSet** | Previous approach (PR #458) used a reverse proxy DaemonSet requiring a container image, Dockerfile, CI pipeline, ServiceAccount, RBAC, NetworkPolicies, and SCC. MachineConfig eliminates all of that. | +| **Singleton CR** | One API VIP and one Ingress VIP per cluster. Multiple HTTP01Proxy objects would be meaningless. | +| **Cached platform info** | Avoids redundant Infrastructure CR reads on every reconciliation loop. Cache invalidated via Infrastructure watch. | +| **`unstructured.Unstructured`** | Avoids Go module dependency on MachineConfig types. Works on clusters without MCO CRDs (MicroShift). | +| **Alpha feature gate, disabled by default** | New feature with limited platform support. Opt-in prevents accidental activation. | +| **Finalizer for cleanup** | Guarantees MachineConfig removal even if user deletes CR unexpectedly. | +| **IrrecoverableError for unsupported platforms** | Prevents futile 30-second retry loops on AWS/GCP/MicroShift. Sets `Degraded=True` and stops. | +| **File mode 0600 (384 decimal)** | nftables config contains VIP addresses — restrictive permissions limit exposure. | +| **MachineConfig name prefix `98-`** | High priority number ensures application late in the MCO stack, avoiding conflicts with system configs. | + +--- + +## 14. Files Changed Summary + +**43 files changed, +3479 lines, -8 lines** + +| Category | Files | Lines | +|----------|-------|-------| +| API types and deepcopy | `api/operator/v1alpha1/` (3 files) | ~230 | +| Controller logic | `pkg/controller/http01proxy/` (6 source files) | ~580 | +| Controller tests | `pkg/controller/http01proxy/` (5 test files) | ~1400 | +| Generated clients | `pkg/operator/clientset/`, `informers/`, `listers/`, `applyconfigurations/` (11 files) | ~800 | +| CRD, RBAC, CSV, bundle | `config/`, `bundle/` (6 files) | ~400 | +| Manager wiring | `pkg/operator/setup_manager.go`, `starter.go` | ~40 | +| E2E test | `test/e2e/http01proxy_test.go` | ~130 | +| Build and config | `Makefile`, `.gitignore`, `config/manager/`, `config/samples/` | ~25 | +| Feature gate test update | `pkg/features/features_test.go` | ~5 |