-
Notifications
You must be signed in to change notification settings - Fork 567
CNTRLPLANE-4150: feat: Maintain ingress serving cert for HostedCluster ingress #9132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2d05470
aa8e527
d346163
408113b
004ae9b
01dee99
57221be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -349,4 +349,46 @@ type IngressOperatorSpec struct { | |
| // +kubebuilder:pruning:PreserveUnknownFields | ||
| // +kubebuilder:validation:Type=object | ||
| EndpointPublishingStrategy *operatorv1.EndpointPublishingStrategy `json:"endpointPublishingStrategy,omitempty"` | ||
|
|
||
| // defaultCertificate is a reference to a secret in the HostedCluster namespace | ||
| // that contains the default certificate served by the default ingress controller. | ||
| // When Routes don't specify their own certificate, defaultCertificate is used. | ||
| // | ||
| // The secret must contain the following keys and data: | ||
| // tls.crt: certificate file contents | ||
| // tls.key: key file contents | ||
| // | ||
| // When set, this certificate replaces the auto-generated wildcard certificate | ||
| // that is normally created by the control plane operator. The secret is synced | ||
| // from the HostedCluster namespace to the control plane, and then propagated | ||
| // to the hosted cluster's openshift-ingress namespace. | ||
| // | ||
| // When the referenced secret is updated, the new certificate data is | ||
| // automatically propagated to the hosted cluster. | ||
|
Comment on lines
+366
to
+367
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not entirely true. The guest default-ingress-cert Secret is reconciled each HCCO resync, but ReconcileDefaultIngressController returns early on day-2 (if ingressController.ResourceVersion != ""), so a guest cluster-admin can edit IngressController.spec.defaultCertificate and HyperShift won't revert it
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Pretty sure the secret itself is still automatically propagated into the default location. The default location is simply unused. Do you have preference for different wording?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. true, I just think we need to make this clear, something like: // Note: a cluster-admin in the hosted cluster can override the default ingress
// controller's certificate directly. That override takes precedence
// and the certificate referenced here is no longer served. |
||
| // | ||
| // When not set, the control plane operator generates a wildcard certificate | ||
| // signed by the cluster's root CA. | ||
| // | ||
| // Note: a cluster-admin in the hosted cluster can override the default ingress | ||
| // controller's certificate directly. That override takes precedence and the | ||
| // certificate referenced here is no longer served. | ||
| // | ||
| // +optional | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just some driveby feedback having just seen all this for the first time, not requesting any changes: I personally found the verbiage clear but potentially ambiguous with regards to the functional outcome depending on the state of the field... the preamble is written in the way that makes the most sense to me as a user (emphasis mine):
The functional intent of the field as I understand it is to drive what certificate is served by the default ingress controller, and that line above makes it clear to me. But the rest of the description about what happens under certain conditions changes the framing to talk about how the secret is synced to the hosted cluster, wildcard generation, etc. Which is all useful detail about what happens mechanically, but from a user's perspective, it seems like it boils down to whether this certificate or the auto-generated wildcard certificate is served by the ingress controller. Everything else feels like implementation details. Just my 0.02
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1, I think some sentences document internal plumbing (the two-hop sync + specific internal namespace names) rather than user-observable behavior |
||
| DefaultCertificate IngressDefaultCertificateReference `json:"defaultCertificate,omitzero"` | ||
| } | ||
|
|
||
| // IngressDefaultCertificateReference contains a reference to a TLS Secret | ||
| // in the HostedCluster namespace used as the default serving certificate | ||
| // for the ingress controller. | ||
| type IngressDefaultCertificateReference struct { | ||
| // name is the name of the Secret containing tls.crt and tls.key. | ||
| // The Secret must exist in the same namespace as the HostedCluster. | ||
| // name must be a valid DNS subdomain name (RFC 1123): it must contain only | ||
| // lowercase alphanumeric characters, '-' or '.', and start and end with an | ||
| // alphanumeric character. | ||
| // +required | ||
| // +kubebuilder:validation:MinLength=1 | ||
| // +kubebuilder:validation:MaxLength=253 | ||
| // +kubebuilder:validation:XValidation:rule="self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')",message="name must be a valid DNS subdomain name: contain no more than 253 characters, contain only lowercase alphanumeric characters, '-' or '.', and start and end with an alphanumeric character" | ||
| Name string `json:"name,omitempty"` | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| package v1beta1 | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
|
|
||
| operatorv1 "github.com/openshift/api/operator/v1" | ||
| ) | ||
|
|
||
| // ingressOperatorSpecNMinus1 represents the previous version of IngressOperatorSpec | ||
| // without the DefaultCertificate field. | ||
| type ingressOperatorSpecNMinus1 struct { | ||
| EndpointPublishingStrategy json.RawMessage `json:"endpointPublishingStrategy,omitempty"` //nolint:kubeapilinter | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — the round-trip is vacuous as written; the repo's own precedent shows the fix. This file follows
nMinus1Result: nodePoolAutoScalingNMinus1{Min: 3, Max: 5},Both cases here leave it empty: nMinus1Result: ingressOperatorSpecNMinus1{},
Populating Two things I checked and am not raising: round-tripping Genuinely open question, not a finding: is the |
||
| } | ||
|
|
||
| func TestIngressOperatorSpecSerializationCompatibility(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| current IngressOperatorSpec | ||
| expectedJSON string | ||
| nMinus1Result ingressOperatorSpecNMinus1 | ||
| }{ | ||
| { | ||
| name: "When DefaultCertificate is zero it should be omitted and N-1 should deserialize cleanly", | ||
| current: IngressOperatorSpec{}, | ||
| expectedJSON: `{}`, | ||
| nMinus1Result: ingressOperatorSpecNMinus1{}, | ||
| }, | ||
| { | ||
| name: "When DefaultCertificate is set it should serialize and N-1 should ignore it", | ||
| current: IngressOperatorSpec{ | ||
| DefaultCertificate: IngressDefaultCertificateReference{ | ||
| Name: "my-cert", | ||
| }, | ||
| }, | ||
| expectedJSON: `{"defaultCertificate":{"name":"my-cert"}}`, | ||
| nMinus1Result: ingressOperatorSpecNMinus1{}, | ||
| }, | ||
| { | ||
| name: "When N-1 data carries EndpointPublishingStrategy it should survive the round-trip into N", | ||
| current: IngressOperatorSpec{}, | ||
| expectedJSON: `{}`, | ||
| nMinus1Result: ingressOperatorSpecNMinus1{ | ||
| EndpointPublishingStrategy: json.RawMessage(`{"type":"LoadBalancerService"}`), | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| data, err := json.Marshal(tt.current) | ||
| if err != nil { | ||
| t.Fatalf("failed to marshal current struct: %v", err) | ||
| } | ||
| if string(data) != tt.expectedJSON { | ||
| t.Errorf("unexpected JSON output: got %s, want %s", string(data), tt.expectedJSON) | ||
| } | ||
|
|
||
| // N -> N-1: old code should ignore the unknown DefaultCertificate field | ||
| var nMinus1 ingressOperatorSpecNMinus1 | ||
| if err := json.Unmarshal(data, &nMinus1); err != nil { | ||
| t.Fatalf("N-1 failed to unmarshal JSON from N: %v", err) | ||
| } | ||
|
|
||
| // N-1 -> N: data from old code should deserialize into new struct with zero DefaultCertificate | ||
| nMinus1Data, err := json.Marshal(tt.nMinus1Result) | ||
| if err != nil { | ||
| t.Fatalf("failed to marshal N-1 struct: %v", err) | ||
| } | ||
| var roundTrip IngressOperatorSpec | ||
| if err := json.Unmarshal(nMinus1Data, &roundTrip); err != nil { | ||
| t.Fatalf("N failed to unmarshal JSON from N-1: %v", err) | ||
| } | ||
| if roundTrip.DefaultCertificate.Name != "" { | ||
| t.Errorf("expected DefaultCertificate to be zero after N-1 round-trip, got %+v", roundTrip.DefaultCertificate) | ||
| } | ||
|
|
||
| // Sibling fields written by N-1 must survive into N unchanged; otherwise | ||
| // the round-trip would silently drop data the enhancement requires to be | ||
| // preserved. | ||
| if len(tt.nMinus1Result.EndpointPublishingStrategy) > 0 { | ||
| if roundTrip.EndpointPublishingStrategy == nil { | ||
| t.Errorf("expected EndpointPublishingStrategy to survive N-1 -> N round-trip, got nil") | ||
| } else if roundTrip.EndpointPublishingStrategy.Type != operatorv1.LoadBalancerServiceStrategyType { | ||
| t.Errorf("expected EndpointPublishingStrategy.Type %q to survive N-1 -> N round-trip, got %q", | ||
| operatorv1.LoadBalancerServiceStrategyType, roundTrip.EndpointPublishingStrategy.Type) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Synced might be useful, but would the user also care for a high level signal as to whether the default ingress controller is serving a user-defined cert vs. the auto-generated wildcard at the high level? Sorry if that's already exposed in another way, I forget
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To my knowledge, not even exposed by the ingress operation in OCP.