Skip to content
Open
22 changes: 22 additions & 0 deletions api/hypershift/v1beta1/hostedcluster_conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,18 @@ const (
// cluster's shared ingress. Status reflects observed state: True means
// public endpoints are reachable, False means they are not.
PublicEndpointExposed ConditionType = "PublicEndpointExposed"

// IngressDefaultCertificateSynced indicates whether the user-provided default
// ingress certificate referenced by
// spec.operatorConfiguration.ingressOperator.defaultCertificate has been
// synced from the HostedCluster namespace into the control plane namespace.
// **True** means the referenced Secret was found, contains tls.crt and tls.key,
// and its data was synced.
// **False** means the referenced Secret is missing or malformed; in that case
// the previously synced certificate (or the auto-generated wildcard certificate)
// keeps serving and the HostedCluster does not become degraded.
// The condition is absent when no defaultCertificate is configured.
IngressDefaultCertificateSynced ConditionType = "IngressDefaultCertificateSynced"
)

// Reasons for PublicEndpointExposed condition.
Expand Down Expand Up @@ -340,6 +352,16 @@ const (

ReconcileErrorReason = "ReconcileError"

// IngressDefaultCertificateInvalidReason is used when the referenced default
// ingress certificate Secret exists but does not contain the required tls.crt
// and tls.key entries.
IngressDefaultCertificateInvalidReason = "InvalidCertificateSecret"

// IngressDefaultCertificatePlatformNotSupportedReason is used when a default
// ingress certificate is configured on a platform whose ingress controller does
// not consume it (e.g. IBM Cloud), so the certificate is intentionally not synced.
IngressDefaultCertificatePlatformNotSupportedReason = "PlatformNotSupported"

CloudResourcesCleanupSkippedReason = "CloudResourcesCleanupSkipped"

CloudResourcesDeletionTimedOutReason = "CloudResourcesDeletionTimedOut"
Expand Down
42 changes: 42 additions & 0 deletions api/hypershift/v1beta1/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,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.
//
// 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
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"`
}
91 changes: 91 additions & 0 deletions api/hypershift/v1beta1/operator_test.go
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
}

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)
}
}
})
}
}
16 changes: 16 additions & 0 deletions api/hypershift/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3512,6 +3512,50 @@ spec:
ingressOperator specifies the configuration for the Ingress Operator in the hosted cluster.
This allows configuring how the default ingress controller endpoints are published.
properties:
defaultCertificate:
description: |-
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.

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.
properties:
name:
description: |-
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.
maxLength: 253
minLength: 1
type: string
x-kubernetes-validations:
- 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'
rule: self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')
required:
- name
type: object
endpointPublishingStrategy:
description: |-
endpointPublishingStrategy is used to publish the default ingress controller endpoints.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3503,6 +3503,50 @@ spec:
ingressOperator specifies the configuration for the Ingress Operator in the hosted cluster.
This allows configuring how the default ingress controller endpoints are published.
properties:
defaultCertificate:
description: |-
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.

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.
properties:
name:
description: |-
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.
maxLength: 253
minLength: 1
type: string
x-kubernetes-validations:
- 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'
rule: self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')
required:
- name
type: object
endpointPublishingStrategy:
description: |-
endpointPublishingStrategy is used to publish the default ingress controller endpoints.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3523,6 +3523,50 @@ spec:
ingressOperator specifies the configuration for the Ingress Operator in the hosted cluster.
This allows configuring how the default ingress controller endpoints are published.
properties:
defaultCertificate:
description: |-
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.

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.
properties:
name:
description: |-
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.
maxLength: 253
minLength: 1
type: string
x-kubernetes-validations:
- 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'
rule: self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')
required:
- name
type: object
endpointPublishingStrategy:
description: |-
endpointPublishingStrategy is used to publish the default ingress controller endpoints.
Expand Down
Loading