Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions api/v1alpha1/common_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,24 +192,21 @@ type SidecarConfig struct {
// +optional
Resources *corev1.ResourceRequirements `json:"resources,omitempty"`

// TLS, if set, fronts the sidecar API with kube-rbac-proxy on
// :8443 backed by a cert-manager-issued cert. Init-only:
// toggling on a Running SeiNode requires recreation. See seictl#165.
// TLS, if set, fronts the sidecar API with kube-rbac-proxy on :8443
// using TLS material from a Secret in the SeiNode's namespace. The
// Secret is operator-provisioned; this controller does not create
// it. Immutable.
// +optional
TLS *SidecarTLSSpec `json:"tls,omitempty"`
}

// SidecarTLSSpec configures the cert-manager-issued serving cert for
// the kube-rbac-proxy fronting.
// SidecarTLSSpec references an externally-provisioned TLS Secret.
type SidecarTLSSpec struct {
// IssuerName references a cert-manager Issuer or ClusterIssuer
// that signs the proxy's serving certificate.
// SecretName is a kubernetes.io/tls Secret in the SeiNode's
// namespace. The cert SANs must include the DNS names published
// in status.sidecarTLS.requiredDNSNames; the controller validates
// this before allowing the pod to schedule.
// +kubebuilder:validation:Required
IssuerName string `json:"issuerName"`

// IssuerKind is "Issuer" (namespaced) or "ClusterIssuer".
// +kubebuilder:default=ClusterIssuer
// +kubebuilder:validation:Enum=Issuer;ClusterIssuer
// +optional
IssuerKind string `json:"issuerKind,omitempty"`
// +kubebuilder:validation:MinLength=1
SecretName string `json:"secretName"`
}
28 changes: 28 additions & 0 deletions api/v1alpha1/seinode_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
// the populated field determines the node's operating mode.
// +kubebuilder:validation:XValidation:rule="(has(self.fullNode) ? 1 : 0) + (has(self.archive) ? 1 : 0) + (has(self.replayer) ? 1 : 0) + (has(self.validator) ? 1 : 0) == 1",message="exactly one of fullNode, archive, replayer, or validator must be set"
// +kubebuilder:validation:XValidation:rule="!has(self.replayer) || (has(self.peers) && size(self.peers) > 0)",message="peers is required when replayer mode is set"
// +kubebuilder:validation:XValidation:rule="(!has(oldSelf.sidecar) || !has(oldSelf.sidecar.tls)) ? (!has(self.sidecar) || !has(self.sidecar.tls)) : (has(self.sidecar) && has(self.sidecar.tls) && self.sidecar.tls == oldSelf.sidecar.tls)",message="spec.sidecar.tls is immutable; delete + recreate the SeiNode to change TLS configuration"
type SeiNodeSpec struct {
// ChainID of the chain this node belongs to.
// +kubebuilder:validation:MinLength=1
Expand Down Expand Up @@ -273,6 +274,11 @@ const (
// pre-flight validation. Only set on SeiNodes with
// spec.validator.operatorKeyring.
ConditionOperatorKeyringReady = "OperatorKeyringReady"

// ConditionSidecarTLSSecretReady reports whether the Secret named
// in spec.sidecar.tls.secretName is present, well-formed, and has
// SANs matching status.sidecarTLS.requiredDNSNames.
ConditionSidecarTLSSecretReady = "SidecarTLSSecretReady"
)

// Reasons for the ImportPVCReady condition.
Expand Down Expand Up @@ -303,6 +309,15 @@ const (
ReasonOperatorKeyringInvalid = "OperatorKeyringInvalid" // terminal: fail the plan
)

// Reasons for the SidecarTLSSecretReady condition.
const (
ReasonTLSSecretReady = "TLSSecretReady" // ready
ReasonTLSSecretNotFound = "TLSSecretNotFound" // Secret missing
ReasonTLSSecretUnavailable = "TLSSecretUnavailable" // transient: API error reading the Secret
ReasonTLSSecretMalformed = "TLSSecretMalformed" // wrong type, empty tls.crt/tls.key, or unparseable cert
ReasonTLSSecretSANsMismatch = "TLSSecretSANsMismatch" // cert.DNSNames missing one or more required SANs
)

// SeiNodeStatus defines the observed state of a SeiNode.
type SeiNodeStatus struct {
// Phase is the high-level lifecycle state.
Expand Down Expand Up @@ -343,6 +358,19 @@ type SeiNodeStatus struct {
// config so the node advertises a reachable address for gossip discovery.
// +optional
ExternalAddress string `json:"externalAddress,omitempty"`

// SidecarTLS is set when spec.sidecar.tls is non-nil.
// +optional
SidecarTLS *SidecarTLSStatus `json:"sidecarTLS,omitempty"`
}

// SidecarTLSStatus is the controller's view of the referenced TLS Secret.
type SidecarTLSStatus struct {
// SecretName mirrors spec.sidecar.tls.secretName.
SecretName string `json:"secretName"`

// RequiredDNSNames is the SAN list the cert in SecretName must include.
RequiredDNSNames []string `json:"requiredDNSNames"`
}

// +kubebuilder:object:root=true
Expand Down
25 changes: 25 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

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

31 changes: 16 additions & 15 deletions config/crd/sei.io_seinodedeployments.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -611,25 +611,21 @@ spec:
type: object
tls:
description: |-
TLS, if set, fronts the sidecar API with kube-rbac-proxy on
:8443 backed by a cert-manager-issued cert. Init-only:
toggling on a Running SeiNode requires recreation. See seictl#165.
TLS, if set, fronts the sidecar API with kube-rbac-proxy on :8443
using TLS material from a Secret in the SeiNode's namespace. The
Secret is operator-provisioned; this controller does not create
it. Immutable.
properties:
issuerKind:
default: ClusterIssuer
description: IssuerKind is "Issuer" (namespaced) or
"ClusterIssuer".
enum:
- Issuer
- ClusterIssuer
type: string
issuerName:
secretName:
description: |-
IssuerName references a cert-manager Issuer or ClusterIssuer
that signs the proxy's serving certificate.
SecretName is a kubernetes.io/tls Secret in the SeiNode's
namespace. The cert SANs must include the DNS names published
in status.sidecarTLS.requiredDNSNames; the controller validates
this before allowing the pod to schedule.
minLength: 1
type: string
required:
- issuerName
- secretName
type: object
type: object
validator:
Expand Down Expand Up @@ -921,6 +917,11 @@ spec:
- message: peers is required when replayer mode is set
rule: '!has(self.replayer) || (has(self.peers) && size(self.peers)
> 0)'
- message: spec.sidecar.tls is immutable; delete + recreate the
SeiNode to change TLS configuration
rule: '(!has(oldSelf.sidecar) || !has(oldSelf.sidecar.tls))
? (!has(self.sidecar) || !has(self.sidecar.tls)) : (has(self.sidecar)
&& has(self.sidecar.tls) && self.sidecar.tls == oldSelf.sidecar.tls)'
required:
- spec
type: object
Expand Down
46 changes: 32 additions & 14 deletions config/crd/sei.io_seinodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -466,24 +466,21 @@ spec:
type: object
tls:
description: |-
TLS, if set, fronts the sidecar API with kube-rbac-proxy on
:8443 backed by a cert-manager-issued cert. Init-only:
toggling on a Running SeiNode requires recreation. See seictl#165.
TLS, if set, fronts the sidecar API with kube-rbac-proxy on :8443
using TLS material from a Secret in the SeiNode's namespace. The
Secret is operator-provisioned; this controller does not create
it. Immutable.
properties:
issuerKind:
default: ClusterIssuer
description: IssuerKind is "Issuer" (namespaced) or "ClusterIssuer".
enum:
- Issuer
- ClusterIssuer
type: string
issuerName:
secretName:
description: |-
IssuerName references a cert-manager Issuer or ClusterIssuer
that signs the proxy's serving certificate.
SecretName is a kubernetes.io/tls Secret in the SeiNode's
namespace. The cert SANs must include the DNS names published
in status.sidecarTLS.requiredDNSNames; the controller validates
this before allowing the pod to schedule.
minLength: 1
type: string
required:
- issuerName
- secretName
type: object
type: object
validator:
Expand Down Expand Up @@ -769,6 +766,11 @@ spec:
- message: peers is required when replayer mode is set
rule: '!has(self.replayer) || (has(self.peers) && size(self.peers) >
0)'
- message: spec.sidecar.tls is immutable; delete + recreate the SeiNode
to change TLS configuration
rule: '(!has(oldSelf.sidecar) || !has(oldSelf.sidecar.tls)) ? (!has(self.sidecar)
|| !has(self.sidecar.tls)) : (has(self.sidecar) && has(self.sidecar.tls)
&& self.sidecar.tls == oldSelf.sidecar.tls)'
status:
description: SeiNodeStatus defines the observed state of a SeiNode.
properties:
Expand Down Expand Up @@ -996,6 +998,22 @@ spec:
items:
type: string
type: array
sidecarTLS:
description: SidecarTLS is set when spec.sidecar.tls is non-nil.
properties:
requiredDNSNames:
description: RequiredDNSNames is the SAN list the cert in SecretName
must include.
items:
type: string
type: array
secretName:
description: SecretName mirrors spec.sidecar.tls.secretName.
type: string
required:
- requiredDNSNames
- secretName
type: object
type: object
type: object
served: true
Expand Down
11 changes: 0 additions & 11 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,6 @@ rules:
- patch
- update
- watch
- apiGroups:
- cert-manager.io
resources:
- certificates
verbs:
- create
- get
- list
- patch
- update
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
Expand Down
8 changes: 3 additions & 5 deletions docs/design-seinode-sidecar-tls-toggle-lld.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,9 @@ CRD-level immutability:

```go
// SeiNodeSpec
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.sidecar.tls) || (has(self.sidecar.tls) && self.sidecar.tls == oldSelf.sidecar.tls)",message="spec.sidecar.tls is immutable; delete + recreate the SeiNode to change TLS configuration"
// +kubebuilder:validation:XValidation:rule="(!has(oldSelf.sidecar) || !has(oldSelf.sidecar.tls)) ? (!has(self.sidecar) || !has(self.sidecar.tls)) : (has(self.sidecar) && has(self.sidecar.tls) && self.sidecar.tls == oldSelf.sidecar.tls)",message="spec.sidecar.tls is immutable; delete + recreate the SeiNode to change TLS configuration"
```

(Exact CEL pending — the rule needs to handle nil sidecar gracefully. May land at the `SidecarConfig` level instead.)

Status additions:

```go
Expand Down Expand Up @@ -197,9 +195,9 @@ There is no `WaitForSidecarTLSSecret` task in the plan — its job is absorbed i

If an operator attempts to mutate `spec.sidecar.tls`, the CRD CEL rejects the API request — no controller code runs.

If the externally-provisioned Secret rotates (cert-manager renewal): kube-rbac-proxy's existing `--tls-reload-interval=30s` flag picks up the new material from the same Secret mount; no pod restart needed; no controller action. The pre-flight validation re-runs on each reconcile and continues stamping `SidecarTLSSecretReady=True` as long as the new cert still has matching SANs.
If the externally-provisioned Secret rotates in place (cert-manager renewal with the same SANs): kube-rbac-proxy's existing `--tls-reload-interval=30s` flag picks up the new material from the same Secret mount; no pod restart needed; no controller action. Pre-flight re-runs each reconcile and continues stamping `SidecarTLSSecretReady=True`.

If the Secret SAN coverage *changes* such that the contract breaks (e.g., wrong SANs after a misconfigured re-issuance): pre-flight flips the condition to `SidecarTLSSecretReady=False, Reason=SANsMismatch`. The Running node continues to serve traffic with the now-wrong cert (kube-rbac-proxy doesn't care about controller-side validation); operators get a visible signal via the condition and can fix the cert. The pod doesn't cycle; this is observability, not enforcement.
If the Secret SAN coverage breaks (wrong SANs after a misconfigured re-issuance, Secret deleted, etc.): pre-flight flips the condition to `SidecarTLSSecretReady=False`. Plan creation is gated — any plan that fires under a broken Secret would eventually cycle the pod (image-drift NodeUpdate plans always do; even a mark-ready replan ultimately retries the sidecar HTTP call through the proxy), and a cycled pod with a missing or mis-SAN'd Secret won't bind cleanly. The Running node keeps serving on whatever cert kube-rbac-proxy is already bound to (no controller action can force a reload-from-bad-Secret), so the user-visible effect is "running pod stays as-is, no new rollouts until the operator fixes the Secret."

## 5. Operator workflow for "enable TLS on existing fleet"

Expand Down
32 changes: 31 additions & 1 deletion internal/controller/node/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ type SeiNodeReconciler struct {
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;list;watch;create;update;patch

// Reconcile drives the SeiNode lifecycle. All status mutations after the
// finalizer are accumulated in-memory and flushed in a single status patch.
Expand Down Expand Up @@ -100,17 +99,21 @@ func (r *SeiNodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
statusBase := client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{})
observedPhase := node.Status.Phase
prevSidecar := apimeta.FindStatusCondition(node.Status.Conditions, seiv1alpha1.ConditionSidecarReady)
prevTLSSecret := apimeta.FindStatusCondition(node.Status.Conditions, seiv1alpha1.ConditionSidecarTLSSecretReady)

if err := r.reconcilePeers(ctx, node); err != nil {
return ctrl.Result{}, fmt.Errorf("reconciling peers: %w", err)
}

r.reconcileSidecarTLSReady(ctx, node)

planAlreadyActive := node.Status.Plan != nil && node.Status.Plan.Phase == seiv1alpha1.TaskPlanActive
if err := r.Planner.ResolvePlan(ctx, node); err != nil {
return ctrl.Result{}, fmt.Errorf("resolving plan: %w", err)
}

r.emitSidecarReadinessEvent(node, prevSidecar)
r.emitSidecarTLSSecretEvent(node, prevTLSSecret)

var result ctrl.Result
var execErr error
Expand Down Expand Up @@ -170,6 +173,16 @@ func (r *SeiNodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
return ctrl.Result{RequeueAfter: statusPollInterval}, nil
}

// Pending nodes blocked on the SidecarTLSSecretReady gate poll for
// the Secret appearing. Without this the controller would wait on the
// informer resync (~10h) since the builder does not Watches() Secrets.
if noderesource.SidecarTLSEnabled(node) {
c := apimeta.FindStatusCondition(node.Status.Conditions, seiv1alpha1.ConditionSidecarTLSSecretReady)
if c == nil || c.Status != metav1.ConditionTrue {
return ctrl.Result{RequeueAfter: statusPollInterval}, nil
}
}

return result, nil
}

Expand Down Expand Up @@ -255,3 +268,20 @@ func (r *SeiNodeReconciler) emitSidecarReadinessEvent(node *seiv1alpha1.SeiNode,
"sidecar Healthz returned 200; mark-ready gate is open")
}
}

func (r *SeiNodeReconciler) emitSidecarTLSSecretEvent(node *seiv1alpha1.SeiNode, prev *metav1.Condition) {
cur := apimeta.FindStatusCondition(node.Status.Conditions, seiv1alpha1.ConditionSidecarTLSSecretReady)
if cur == nil {
return
}
switch {
case cur.Status == metav1.ConditionFalse &&
(prev == nil || prev.Status != metav1.ConditionFalse):
r.Recorder.Eventf(node, corev1.EventTypeWarning, "SidecarTLSSecretNotReady",
"sidecar TLS Secret %q: %s", node.Spec.Sidecar.TLS.SecretName, cur.Message)
case cur.Status == metav1.ConditionTrue &&
prev != nil && prev.Status == metav1.ConditionFalse:
r.Recorder.Event(node, corev1.EventTypeNormal, "SidecarTLSSecretReady",
"sidecar TLS Secret validated; plan gate is open")
}
}
Loading
Loading