From 8939c0395f02af3d13430a2618b474950b70d1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Wed, 16 Sep 2026 09:46:51 +0200 Subject: [PATCH] Add ConsoleConnection CRD and controller for console health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a ConsoleConnection resource that verifies serial console connectivity to devices via SSH console servers. The controller supports three verification strategies (Wait, SendCRLF, SendChar) and reports five health states: ConsoleServerUnreachable, ConsoleServerAuthFailure, Dead, Alive, and Verified. Includes envtest integration tests with an in-process SSH server, sample manifests for all verification strategies, and Tiltfile integration. Signed-off-by: Felix KΓ€stner --- PROJECT | 8 + Tiltfile | 6 + api/core/v1alpha1/consoleconnection_types.go | 206 ++++++++ api/core/v1alpha1/zz_generated.deepcopy.go | 174 +++++++ cmd/main.go | 9 + ...metal.ironcore.dev_consoleconnections.yaml | 280 ++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/consoleconnection_admin_role.yaml | 27 + .../rbac/consoleconnection_editor_role.yaml | 33 ++ .../rbac/consoleconnection_viewer_role.yaml | 29 ++ config/rbac/kustomization.yaml | 3 + config/rbac/role.yaml | 3 + config/samples/kustomization.yaml | 1 + .../samples/v1alpha1_consoleconnection.yaml | 94 ++++ .../core/consoleconnection_controller.go | 482 ++++++++++++++++++ .../core/consoleconnection_controller_test.go | 423 +++++++++++++++ internal/controller/core/suite_test.go | 7 + 17 files changed, 1786 insertions(+) create mode 100644 api/core/v1alpha1/consoleconnection_types.go create mode 100644 config/crd/bases/networking.metal.ironcore.dev_consoleconnections.yaml create mode 100644 config/rbac/consoleconnection_admin_role.yaml create mode 100644 config/rbac/consoleconnection_editor_role.yaml create mode 100644 config/rbac/consoleconnection_viewer_role.yaml create mode 100644 config/samples/v1alpha1_consoleconnection.yaml create mode 100644 internal/controller/core/consoleconnection_controller.go create mode 100644 internal/controller/core/consoleconnection_controller_test.go diff --git a/PROJECT b/PROJECT index be2d8eb2d..de701c9dc 100644 --- a/PROJECT +++ b/PROJECT @@ -414,4 +414,12 @@ resources: kind: Probe path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: networking.metal.ironcore.dev + kind: ConsoleConnection + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 version: "3" diff --git a/Tiltfile b/Tiltfile index ad9857573..12274e0ba 100644 --- a/Tiltfile +++ b/Tiltfile @@ -209,6 +209,12 @@ k8s_resource(new_name='mac-entry', objects=['mac-entry:probe'], trigger_mode=TRI k8s_resource(new_name='route-prefix', objects=['route-prefix:probe'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='vtep-peers', objects=['vtep-peers:probe'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_yaml('./config/samples/v1alpha1_consoleconnection.yaml') +k8s_resource(new_name='console-default', objects=['console-default:consoleconnection'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='console-scheduled', objects=['console-scheduled:consoleconnection'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='console-regex', objects=['console-regex:consoleconnection'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='console-sendchar', objects=['console-sendchar:consoleconnection', 'console-credentials:secret'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) + print('πŸš€ network-operator development environment') print('πŸ‘‰ Edit the code inside the api/, cmd/, or internal/ directories') print('πŸ‘‰ Tilt will automatically rebuild and redeploy when changes are detected') diff --git a/api/core/v1alpha1/consoleconnection_types.go b/api/core/v1alpha1/consoleconnection_types.go new file mode 100644 index 000000000..f842edf65 --- /dev/null +++ b/api/core/v1alpha1/consoleconnection_types.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// ConsoleConnectionSpec defines the desired state of ConsoleConnection. +type ConsoleConnectionSpec struct { + // DeviceRef is a reference to the Device this console connection targets. + // The Device object must exist in the same namespace. + // Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="DeviceRef is immutable" + DeviceRef LocalObjectReference `json:"deviceRef"` + + // Endpoint contains the console server connection details. + // +required + Endpoint ConsoleEndpoint `json:"endpoint"` + + // Verification configures how the controller confirms the serial + // line is alive and connected to the expected device. + // +optional + Verification ConsoleVerification `json:"verification,omitempty"` + + // Schedule is an optional cron expression (e.g., "*/5 * * * *"). + // If omitted, the controller performs a one-shot check only once + // for the resource; it does not re-execute on subsequent reconciliations. + // If set, the controller checks periodically according to the schedule. + // +optional + Schedule string `json:"schedule,omitempty"` + + // Timeout is the maximum duration the controller waits for output on + // the serial line before declaring the connection dead. + // +kubebuilder:default="30s" + // +optional + Timeout metav1.Duration `json:"timeout,omitempty"` +} + +// ConsoleEndpoint contains the console server connection details. +type ConsoleEndpoint struct { + // Address is the console server address in IP:Port format. + // The port identifies the serial line on the console server. + // +required + // +kubebuilder:validation:Pattern=`^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$` + Address string `json:"address"` + + // Protocol is the connection protocol. + // +kubebuilder:default=SSH + // +optional + Protocol ConsoleProtocol `json:"protocol,omitempty"` + + // SecretRef references a kubernetes.io/basic-auth secret containing + // 'username' and 'password' for the console server. + // +required + SecretRef SecretReference `json:"secretRef"` +} + +// ConsoleProtocol is the connection protocol used to reach the console server. +// +kubebuilder:validation:Enum=SSH +type ConsoleProtocol string + +const ConsoleProtocolSSH ConsoleProtocol = "SSH" + +// ConsoleVerification configures how the controller confirms the serial +// line is alive and connected to the expected device. +// +kubebuilder:validation:XValidation:rule="self.strategy != 'SendChar' || has(self.char)",message="char must be specified when strategy is SendChar" +// +kubebuilder:validation:XValidation:rule="self.strategy == 'SendChar' || !has(self.char)",message="char must be omitted when strategy is not SendChar" +type ConsoleVerification struct { + // Strategy selects how the controller stimulates the serial line. + // + // Wait β€” passively wait for output without sending anything. + // SendCRLF β€” send a carriage-return/line-feed to trigger a prompt or response. + // SendChar β€” send a single printable character to trigger a response. + // + // Defaults to SendCRLF. + // +kubebuilder:default=SendCRLF + // +optional + Strategy ConsoleVerificationStrategy `json:"strategy,omitempty"` + + // Char is the character to send when Strategy is SendChar. + // Ignored for other strategies. + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1 + Char *string `json:"char,omitempty"` + + // Expect configures what the controller looks for in the serial output. + // If omitted, the controller matches the device hostname or serial number + // from Device.Status. + // +optional + Expect *ConsoleExpect `json:"expect,omitempty"` +} + +// ConsoleVerificationStrategy selects how the controller stimulates the serial line. +// +kubebuilder:validation:Enum=Wait;SendCRLF;SendChar +type ConsoleVerificationStrategy string + +const ( + ConsoleVerificationWait ConsoleVerificationStrategy = "Wait" + ConsoleVerificationSendCRLF ConsoleVerificationStrategy = "SendCRLF" + ConsoleVerificationSendChar ConsoleVerificationStrategy = "SendChar" +) + +// ConsoleExpect configures what the controller looks for in the serial output. +type ConsoleExpect struct { + // String is a literal string to match in the serial output. + // +optional + String *string `json:"string,omitempty"` + + // Regex is a regular expression to match in the serial output. + // +optional + Regex *string `json:"regex,omitempty"` +} + +// ConsoleConnectionStatus defines the observed state of ConsoleConnection. +type ConsoleConnectionStatus struct { + // LastCheckTime is the timestamp of the most recent check. + // +optional + LastCheckTime *metav1.Time `json:"lastCheckTime,omitempty"` + + // NextCheckTime is the next scheduled check. Only set when Schedule is configured. + // +optional + NextCheckTime *metav1.Time `json:"nextCheckTime,omitempty"` + + // Conditions represent the current state of the ConsoleConnection resource. + // The Ready condition reports the health of the console connection. + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// Console connection Ready condition reasons. +const ( + // ConsoleServerUnreachableReason indicates the console server could not be reached. + ConsoleServerUnreachableReason = "ConsoleServerUnreachable" + // ConsoleServerAuthFailureReason indicates authentication to the console server failed. + ConsoleServerAuthFailureReason = "ConsoleServerAuthFailure" + // ConsoleDeadReason indicates the console server was reachable but no output was received. + ConsoleDeadReason = "Dead" + // ConsoleAliveReason indicates output was received but the expected string was not matched. + ConsoleAliveReason = "Alive" + // ConsoleVerifiedReason indicates the expected output was matched, confirming device identity. + ConsoleVerifiedReason = "Verified" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=consoleconnections +// +kubebuilder:resource:singular=consoleconnection +// +kubebuilder:resource:shortName=conn;console;connection +// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`,priority=1 +// +kubebuilder:printcolumn:name="Last Check",type=date,JSONPath=`.status.lastCheckTime`,priority=1 +// +kubebuilder:printcolumn:name="Next Check",type=string,JSONPath=`.status.nextCheckTime`,priority=1 +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// ConsoleConnection is the Schema for the consoleconnections API. +type ConsoleConnection struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Specification of the desired state of the resource. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +required + Spec ConsoleConnectionSpec `json:"spec"` + + // Status of the resource. This is set and updated automatically. + // Read-only. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +optional + Status ConsoleConnectionStatus `json:"status,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (c *ConsoleConnection) GetConditions() []metav1.Condition { + return c.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (c *ConsoleConnection) SetConditions(conditions []metav1.Condition) { + c.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// ConsoleConnectionList contains a list of ConsoleConnection. +type ConsoleConnectionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []ConsoleConnection `json:"items"` +} + +func init() { + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, &ConsoleConnection{}, &ConsoleConnectionList{}) + return nil + }) +} diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 5e0a83e3a..afeabe1ee 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1560,6 +1560,180 @@ func (in *ConfigMapReference) DeepCopy() *ConfigMapReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConsoleConnection) DeepCopyInto(out *ConsoleConnection) { + *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 ConsoleConnection. +func (in *ConsoleConnection) DeepCopy() *ConsoleConnection { + if in == nil { + return nil + } + out := new(ConsoleConnection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ConsoleConnection) 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 *ConsoleConnectionList) DeepCopyInto(out *ConsoleConnectionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ConsoleConnection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsoleConnectionList. +func (in *ConsoleConnectionList) DeepCopy() *ConsoleConnectionList { + if in == nil { + return nil + } + out := new(ConsoleConnectionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ConsoleConnectionList) 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 *ConsoleConnectionSpec) DeepCopyInto(out *ConsoleConnectionSpec) { + *out = *in + out.DeviceRef = in.DeviceRef + out.Endpoint = in.Endpoint + in.Verification.DeepCopyInto(&out.Verification) + out.Timeout = in.Timeout +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsoleConnectionSpec. +func (in *ConsoleConnectionSpec) DeepCopy() *ConsoleConnectionSpec { + if in == nil { + return nil + } + out := new(ConsoleConnectionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConsoleConnectionStatus) DeepCopyInto(out *ConsoleConnectionStatus) { + *out = *in + if in.LastCheckTime != nil { + in, out := &in.LastCheckTime, &out.LastCheckTime + *out = (*in).DeepCopy() + } + if in.NextCheckTime != nil { + in, out := &in.NextCheckTime, &out.NextCheckTime + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsoleConnectionStatus. +func (in *ConsoleConnectionStatus) DeepCopy() *ConsoleConnectionStatus { + if in == nil { + return nil + } + out := new(ConsoleConnectionStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConsoleEndpoint) DeepCopyInto(out *ConsoleEndpoint) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsoleEndpoint. +func (in *ConsoleEndpoint) DeepCopy() *ConsoleEndpoint { + if in == nil { + return nil + } + out := new(ConsoleEndpoint) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConsoleExpect) DeepCopyInto(out *ConsoleExpect) { + *out = *in + if in.String != nil { + in, out := &in.String, &out.String + *out = new(string) + **out = **in + } + if in.Regex != nil { + in, out := &in.Regex, &out.Regex + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsoleExpect. +func (in *ConsoleExpect) DeepCopy() *ConsoleExpect { + if in == nil { + return nil + } + out := new(ConsoleExpect) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConsoleVerification) DeepCopyInto(out *ConsoleVerification) { + *out = *in + if in.Char != nil { + in, out := &in.Char, &out.Char + *out = new(string) + **out = **in + } + if in.Expect != nil { + in, out := &in.Expect, &out.Expect + *out = new(ConsoleExpect) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsoleVerification. +func (in *ConsoleVerification) DeepCopy() *ConsoleVerification { + if in == nil { + return nil + } + out := new(ConsoleVerification) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ControlProtocol) DeepCopyInto(out *ControlProtocol) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index d121a5633..5078cf86a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -809,6 +809,15 @@ func main() { //nolint:gocyclo os.Exit(1) } + if err := (&corecontroller.ConsoleConnectionReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorder("consoleconnection-controller"), + WatchFilterValue: watchFilterValue, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ConsoleConnection") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/networking.metal.ironcore.dev_consoleconnections.yaml b/config/crd/bases/networking.metal.ironcore.dev_consoleconnections.yaml new file mode 100644 index 000000000..dc9f594e5 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_consoleconnections.yaml @@ -0,0 +1,280 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.22.0 + name: consoleconnections.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: ConsoleConnection + listKind: ConsoleConnectionList + plural: consoleconnections + shortNames: + - conn + - console + - connection + singular: consoleconnection + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].reason + name: Reason + priority: 1 + type: string + - jsonPath: .status.lastCheckTime + name: Last Check + priority: 1 + type: date + - jsonPath: .status.nextCheckTime + name: Next Check + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: ConsoleConnection is the Schema for the consoleconnections API. + 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: |- + Specification of the desired state of the resource. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + deviceRef: + description: |- + DeviceRef is a reference to the Device this console connection targets. + The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + endpoint: + description: Endpoint contains the console server connection details. + properties: + address: + description: |- + Address is the console server address in IP:Port format. + The port identifies the serial line on the console server. + pattern: ^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$ + type: string + protocol: + default: SSH + description: Protocol is the connection protocol. + enum: + - SSH + type: string + secretRef: + description: |- + SecretRef references a kubernetes.io/basic-auth secret containing + 'username' and 'password' for the console server. + properties: + name: + description: Name is unique within a namespace to reference + a secret resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the secret name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + required: + - address + - secretRef + type: object + schedule: + description: |- + Schedule is an optional cron expression (e.g., "*/5 * * * *"). + If omitted, the controller performs a one-shot check only once + for the resource; it does not re-execute on subsequent reconciliations. + If set, the controller checks periodically according to the schedule. + type: string + timeout: + default: 30s + description: |- + Timeout is the maximum duration the controller waits for output on + the serial line before declaring the connection dead. + type: string + verification: + description: |- + Verification configures how the controller confirms the serial + line is alive and connected to the expected device. + properties: + char: + description: |- + Char is the character to send when Strategy is SendChar. + Ignored for other strategies. + maxLength: 1 + minLength: 1 + type: string + expect: + description: |- + Expect configures what the controller looks for in the serial output. + If omitted, the controller matches the device hostname or serial number + from Device.Status. + properties: + regex: + description: Regex is a regular expression to match in the + serial output. + type: string + string: + description: String is a literal string to match in the serial + output. + type: string + type: object + strategy: + default: SendCRLF + description: |- + Strategy selects how the controller stimulates the serial line. + + Wait β€” passively wait for output without sending anything. + SendCRLF β€” send a carriage-return/line-feed to trigger a prompt or response. + SendChar β€” send a single printable character to trigger a response. + + Defaults to SendCRLF. + enum: + - Wait + - SendCRLF + - SendChar + type: string + type: object + x-kubernetes-validations: + - message: char must be specified when strategy is SendChar + rule: self.strategy != 'SendChar' || has(self.char) + - message: char must be omitted when strategy is not SendChar + rule: self.strategy == 'SendChar' || !has(self.char) + required: + - deviceRef + - endpoint + type: object + status: + description: |- + Status of the resource. This is set and updated automatically. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + conditions: + description: |- + Conditions represent the current state of the ConsoleConnection resource. + The Ready condition reports the health of the console connection. + 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 + lastCheckTime: + description: LastCheckTime is the timestamp of the most recent check. + format: date-time + type: string + nextCheckTime: + description: NextCheckTime is the next scheduled check. Only set when + Schedule is configured. + format: date-time + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 0281b3f4e..77600bd90 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -30,6 +30,7 @@ resources: - bases/networking.metal.ironcore.dev_ethernetsegments.yaml - bases/networking.metal.ironcore.dev_aaa.yaml - bases/networking.metal.ironcore.dev_probes.yaml +- bases/networking.metal.ironcore.dev_consoleconnections.yaml - bases/pool.networking.metal.ironcore.dev_indexpools.yaml - bases/pool.networking.metal.ironcore.dev_ipaddresspools.yaml - bases/pool.networking.metal.ironcore.dev_ipprefixpools.yaml diff --git a/config/rbac/consoleconnection_admin_role.yaml b/config/rbac/consoleconnection_admin_role.yaml new file mode 100644 index 000000000..67a7e4beb --- /dev/null +++ b/config/rbac/consoleconnection_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: consoleconnection-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - consoleconnections + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - consoleconnections/status + verbs: + - get diff --git a/config/rbac/consoleconnection_editor_role.yaml b/config/rbac/consoleconnection_editor_role.yaml new file mode 100644 index 000000000..aef9204d6 --- /dev/null +++ b/config/rbac/consoleconnection_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: consoleconnection-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - consoleconnections + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - consoleconnections/status + verbs: + - get diff --git a/config/rbac/consoleconnection_viewer_role.yaml b/config/rbac/consoleconnection_viewer_role.yaml new file mode 100644 index 000000000..2633d25de --- /dev/null +++ b/config/rbac/consoleconnection_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to networking.metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: consoleconnection-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - consoleconnections + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - consoleconnections/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index c8e18ad6b..f830088b6 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -103,6 +103,9 @@ resources: - probe_admin_role.yaml - probe_editor_role.yaml - probe_viewer_role.yaml +- consoleconnection_admin_role.yaml +- consoleconnection_editor_role.yaml +- consoleconnection_viewer_role.yaml # The following RBAC configurations apply to Cisco NX specific CRDs - cisco/nx/bordergateway_admin_role.yaml - cisco/nx/bordergateway_editor_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 66cdebb60..939ac0ecd 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -75,6 +75,7 @@ rules: - bgppeers - certificates - configbackups + - consoleconnections - devices - dhcprelays - dns @@ -113,6 +114,7 @@ rules: - bgp/finalizers - bgppeers/finalizers - certificates/finalizers + - consoleconnections/finalizers - devices/finalizers - dhcprelays/finalizers - dns/finalizers @@ -145,6 +147,7 @@ rules: - bgppeers/status - certificates/status - configbackups/status + - consoleconnections/status - devices/status - dhcprelays/status - dns/status diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 4fdb74fa5..17973829c 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -36,6 +36,7 @@ resources: - v1alpha1_claim.yaml - v1alpha1_fabric.yaml - v1alpha1_probe.yaml +- v1alpha1_consoleconnection.yaml - cisco/nx/v1alpha1_bordergateway.yaml - cisco/nx/v1alpha1_managementaccessconfig.yaml - cisco/nx/v1alpha1_nveconfig.yaml diff --git a/config/samples/v1alpha1_consoleconnection.yaml b/config/samples/v1alpha1_consoleconnection.yaml new file mode 100644 index 000000000..00519aca8 --- /dev/null +++ b/config/samples/v1alpha1_consoleconnection.yaml @@ -0,0 +1,94 @@ +--- +# Console connection with default verification (matches Device hostname or serial number). +# Uses the default SendCRLF strategy and 30s timeout. +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ConsoleConnection +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: console-default +spec: + deviceRef: + name: leaf1 + endpoint: + address: "10.0.100.1:2001" + secretRef: + name: console-credentials +--- +# Console connection with explicit string match, scheduled check every 5 minutes. +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ConsoleConnection +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: console-scheduled +spec: + deviceRef: + name: leaf1 + endpoint: + address: "10.0.100.1:2001" + secretRef: + name: console-credentials + schedule: "*/5 * * * *" + verification: + strategy: SendCRLF + expect: + string: "leaf1#" +--- +# Console connection with regex match and passive Wait strategy. +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ConsoleConnection +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: console-regex +spec: + deviceRef: + name: leaf1 + endpoint: + address: "10.0.100.1:2001" + secretRef: + name: console-credentials + timeout: 10s + verification: + strategy: Wait + expect: + regex: "leaf1[>#]" +--- +# Console connection with SendChar strategy. +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ConsoleConnection +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: console-sendchar +spec: + deviceRef: + name: leaf1 + endpoint: + address: "10.0.100.1:2001" + secretRef: + name: console-credentials + verification: + strategy: SendChar + char: "x" + expect: + string: "leaf1" +--- +# Secret for console server authentication. +apiVersion: v1 +kind: Secret +metadata: + name: console-credentials +type: kubernetes.io/basic-auth +stringData: + username: admin + password: changeme diff --git a/internal/controller/core/consoleconnection_controller.go b/internal/controller/core/consoleconnection_controller.go new file mode 100644 index 000000000..bfd1a1396 --- /dev/null +++ b/internal/controller/core/consoleconnection_controller.go @@ -0,0 +1,482 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "fmt" + "net" + "regexp" + "strings" + "time" + + "github.com/robfig/cron/v3" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/events" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/clientutil" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" +) + +const DefaultConsoleTimeout = 30 * time.Second + +// ConsoleConnectionReconciler reconciles a ConsoleConnection object. +type ConsoleConnectionReconciler struct { + client.Client + Scheme *runtime.Scheme + + // WatchFilterValue is the label value used to filter events prior to reconciliation. + WatchFilterValue string + + // Recorder is used to record events for the controller. + Recorder events.EventRecorder +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=consoleconnections,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=consoleconnections/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=consoleconnections/finalizers,verbs=update +// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch + +func (r *ConsoleConnectionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.V(3).Info("Reconciling resource") + + obj := new(v1alpha1.ConsoleConnection) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + // If the custom resource is not found then it usually means that it was deleted or not created + // In this way, we will stop the reconciliation + log.V(3).Info("Resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + if !obj.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + controllerutil.RemoveFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to remove finalizer from resource") + return ctrl.Result{}, err + } + } + log.V(3).Info("Resource is being deleted, skipping reconciliation") + return ctrl.Result{}, nil + } + + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers + if !controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + controllerutil.AddFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to add finalizer to resource") + return ctrl.Result{}, err + } + log.V(1).Info("Added finalizer to resource") + return ctrl.Result{}, nil + } + + orig := obj.DeepCopy() + if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition) { + log.V(1).Info("Initializing status conditions") + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + + // Always attempt to update the metadata/status after reconciliation + defer func() { + if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) { + // Pass obj.DeepCopy() to avoid Patch() modifying obj and interfering with status update below + if err := r.Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update resource metadata") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + if !equality.Semantic.DeepEqual(orig.Status, obj.Status) { + if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update status") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + }() + + device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name) + if err != nil { + return ctrl.Result{}, err + } + + res, err := r.reconcile(ctx, obj, device) + if err != nil { + log.Error(err, "Failed to reconcile resource") + return ctrl.Result{}, apistatus.WrapTerminalError(err) + } + + return res, nil +} + +func (r *ConsoleConnectionReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + labelSelector := metav1.LabelSelector{} + if r.WatchFilterValue != "" { + labelSelector.MatchLabels = map[string]string{v1alpha1.WatchLabel: r.WatchFilterValue} + } + + filter, err := predicate.LabelSelectorPredicate(labelSelector) + if err != nil { + return fmt.Errorf("failed to create label selector predicate: %w", err) + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.ConsoleConnection{}, v1alpha1.DeviceRefIndexKey, func(obj client.Object) []string { + o := obj.(*v1alpha1.ConsoleConnection) + return []string{o.Spec.DeviceRef.Name} + }); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.ConsoleConnection{}). + Named("consoleconnection"). + WithEventFilter(filter). + // Watches enqueues Probes when their referenced Device is created, deleted or updated. + Watches( + &v1alpha1.Device{}, + handler.EnqueueRequestsFromMapFunc(r.deviceToConsoleConnections), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldDevice := e.ObjectOld.(*v1alpha1.Device) + newDevice := e.ObjectNew.(*v1alpha1.Device) + return oldDevice.Status.Hostname != newDevice.Status.Hostname || oldDevice.Status.SerialNumber != newDevice.Status.SerialNumber + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + // Watches enqueues ConsoleConnection for referenced Secret resources. + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.secretToConsoleConnections), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} + +func (r *ConsoleConnectionReconciler) reconcile(ctx context.Context, obj *v1alpha1.ConsoleConnection, device *v1alpha1.Device) (res ctrl.Result, reterr error) { + if obj.Labels == nil { + obj.Labels = make(map[string]string) + } + obj.Labels[v1alpha1.DeviceLabel] = device.Name + + if !controllerutil.HasControllerReference(obj) { + if err := controllerutil.SetOwnerReference(device, obj, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return ctrl.Result{}, err + } + } + + var schedule cron.Schedule + if obj.Spec.Schedule != "" { + var err error + schedule, err = cron.ParseStandard(obj.Spec.Schedule) + if err != nil { + conditions.Set(obj, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.ScheduleInvalidReason, + Message: err.Error(), + }) + return ctrl.Result{}, reconcile.TerminalError(err) + } + + // Determine the last check time. If no checks have been performed yet, + // use the creation timestamp of the resource. + last := obj.CreationTimestamp.UTC() + if obj.Status.LastCheckTime != nil { + last = obj.Status.LastCheckTime.UTC() + } + + // If the next scheduled check is in the future, requeue until that time. + // Otherwise, continue to check now. + if now, next := time.Now().UTC(), schedule.Next(last); next.After(now) { + obj.Status.NextCheckTime = &metav1.Time{Time: next} + r.Recorder.Eventf(obj, nil, "Normal", "Scheduled", "Reconcile", "Next console check scheduled at %s", next.Format(time.RFC3339)) + return ctrl.Result{RequeueAfter: next.Sub(now)}, nil + } + + defer func() { + if reterr != nil { + return + } + next := schedule.Next(time.Now().UTC()) + obj.Status.NextCheckTime = &metav1.Time{Time: next} + r.Recorder.Eventf(obj, nil, "Normal", "Scheduled", "Reconcile", "Next console check scheduled at %s", next.Format(time.RFC3339)) + res.RequeueAfter = time.Until(next) + }() + } + + if schedule == nil && obj.Status.LastCheckTime != nil { + r.Recorder.Eventf(obj, nil, "Normal", "CheckCompleted", "Reconcile", "One-shot check already completed at %s", obj.Status.LastCheckTime.String()) + return ctrl.Result{}, nil + } + + c := clientutil.NewClient(r, obj.Namespace) + user, pass, err := c.BasicAuth(ctx, &obj.Spec.Endpoint.SecretRef) + if err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(obj, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.SecretNotFoundReason, + Message: fmt.Sprintf("Secret %q not found", obj.Spec.Endpoint.SecretRef.Name), + }) + return ctrl.Result{}, reconcile.TerminalError(err) + } + return ctrl.Result{}, err + } + + timeout := obj.Spec.Timeout.Duration + if timeout == 0 { + timeout = DefaultConsoleTimeout + } + + match, err := r.buildMatcher(obj, device) + if err != nil { + conditions.Set(obj, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.NotReadyReason, + Message: err.Error(), + }) + return ctrl.Result{}, reconcile.TerminalError(err) + } + + reason, message := r.check(ctx, obj, string(user), string(pass), timeout, match) + now := metav1.Now() + obj.Status.LastCheckTime = &now + + status := metav1.ConditionFalse + if reason == v1alpha1.ConsoleVerifiedReason { + status = metav1.ConditionTrue + } + conditions.Set(obj, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: status, + Reason: reason, + Message: message, + }) + + eventType := "Warning" + if status == metav1.ConditionTrue { + eventType = "Normal" + } + r.Recorder.Eventf(obj, nil, eventType, reason, "Reconcile", message) + + return ctrl.Result{}, nil +} + +// buildMatcher constructs a function that checks whether the console output matches the expected string or regex. +// If no explicit expectation is set, it defaults to matching the device's hostname or serial number. +func (r *ConsoleConnectionReconciler) buildMatcher(obj *v1alpha1.ConsoleConnection, device *v1alpha1.Device) (func(string) bool, error) { + if obj.Spec.Verification.Expect != nil { + if obj.Spec.Verification.Expect.String != nil { + s := *obj.Spec.Verification.Expect.String + return func(output string) bool { return strings.Contains(output, s) }, nil + } + if obj.Spec.Verification.Expect.Regex != nil { + re, err := regexp.Compile(*obj.Spec.Verification.Expect.Regex) + if err != nil { + return nil, fmt.Errorf("invalid expect regex: %w", err) + } + return re.MatchString, nil + } + } + // Default: match device hostname or serial number. + hostname, serial := device.Status.Hostname, device.Status.SerialNumber + if hostname == "" && serial == "" { + return nil, errors.New("device has no hostname or serial number in status; set spec.verification.expect explicitly") + } + return func(output string) bool { + return (hostname != "" && strings.Contains(output, hostname)) || (serial != "" && strings.Contains(output, serial)) + }, nil +} + +func (r *ConsoleConnectionReconciler) check(ctx context.Context, obj *v1alpha1.ConsoleConnection, user, pass string, timeout time.Duration, match func(string) bool) (reason, message string) { + config := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.Password(pass)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // console servers rarely have known host keys + Timeout: timeout, + } + + conn, err := ssh.Dial("tcp", obj.Spec.Endpoint.Address, config) + if err != nil { + if isAuthError(err) { + return v1alpha1.ConsoleServerAuthFailureReason, fmt.Sprintf("Authentication failed: %v", err) + } + return v1alpha1.ConsoleServerUnreachableReason, fmt.Sprintf("Could not reach console server: %v", err) + } + defer conn.Close() + + session, err := conn.NewSession() + if err != nil { + return v1alpha1.ConsoleServerUnreachableReason, fmt.Sprintf("Could not open SSH session: %v", err) + } + defer session.Close() + + stdout, err := session.StdoutPipe() + if err != nil { + return v1alpha1.ConsoleServerUnreachableReason, fmt.Sprintf("Could not attach to session output: %v", err) + } + + if err := session.Shell(); err != nil { + return v1alpha1.ConsoleServerUnreachableReason, fmt.Sprintf("Could not start shell: %v", err) + } + + stdin, err := session.StdinPipe() + if err == nil { + switch obj.Spec.Verification.Strategy { + case v1alpha1.ConsoleVerificationSendCRLF: + _, _ = stdin.Write([]byte("\r\n")) //nolint:errcheck // best-effort stimulus on serial line + case v1alpha1.ConsoleVerificationSendChar: + if obj.Spec.Verification.Char != nil { + _, _ = stdin.Write([]byte(*obj.Spec.Verification.Char)) //nolint:errcheck // best-effort stimulus on serial line + } + case v1alpha1.ConsoleVerificationWait: + // Do nothing. + } + } + + // Read output until timeout or match. + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + buf := make([]byte, 4096) + var output strings.Builder + done := make(chan struct{}) + go func() { + defer close(done) + for { + n, err := stdout.Read(buf) + if n > 0 { + output.Write(buf[:n]) + if match(output.String()) { + return + } + } + if err != nil { + return + } + } + }() + + select { + case <-done: + // Reader finished β€” either matched or stream ended. + case <-ctx.Done(): + // Timeout reached. + } + + received := output.String() + if received == "" { + return v1alpha1.ConsoleDeadReason, "No output received on serial connection" + } + if match(received) { + return v1alpha1.ConsoleVerifiedReason, "Console connection verified" + } + return v1alpha1.ConsoleAliveReason, "Received output but expected string not matched" +} + +func isAuthError(err error) bool { + // Network-level errors (dial timeout, connection refused) are not auth failures. + if _, ok := errors.AsType[*net.OpError](err); ok { //nolint:errcheck // second return is the typed error, unused + return false + } + // ssh.Dial returns a plain error for auth failures; if we got past + // the network layer, treat it as an auth failure. + return true +} + +// deviceToConsoleConnections is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for ConsoleConnections when their referenced Device's gets created or deleted. +func (r *ConsoleConnectionReconciler) deviceToConsoleConnections(ctx context.Context, obj client.Object) []ctrl.Request { + device, ok := obj.(*v1alpha1.Device) + if !ok { + panic(fmt.Sprintf("expected a Device but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) + + list := new(v1alpha1.ConsoleConnectionList) + if err := r.List( + ctx, list, + client.InNamespace(device.Namespace), + client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, + ); err != nil { + log.Error(err, "Failed to list ConsoleConnections") + return nil + } + + requests := make([]ctrl.Request, 0, len(list.Items)) + for _, i := range list.Items { + log.V(2).Info("Enqueuing ConsoleConnection for reconciliation", "ConsoleConnection", klog.KObj(&i)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: i.Name, + Namespace: i.Namespace, + }, + }) + } + + return requests +} + +// secretToConsoleConnections is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for a ConsoleConnection to update when one of its referenced Secrets gets updated. +func (r *ConsoleConnectionReconciler) secretToConsoleConnections(ctx context.Context, obj client.Object) []ctrl.Request { + secret, ok := obj.(*corev1.Secret) + if !ok { + panic(fmt.Sprintf("expected a Secret but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Secret", klog.KObj(secret)) + + list := new(v1alpha1.ConsoleConnectionList) + if err := r.List(ctx, list, client.InNamespace(secret.Namespace)); err != nil { + log.Error(err, "Failed to list ConsoleConnections") + return nil + } + + var requests []ctrl.Request + for _, c := range list.Items { + if c.Spec.Endpoint.SecretRef.Name == secret.Name && c.Namespace == secret.Namespace { + log.V(2).Info("Enqueuing ConsoleConnection for reconciliation", "ConsoleConnection", klog.KObj(&c)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: c.Name, + Namespace: c.Namespace, + }, + }) + } + } + + return requests +} diff --git a/internal/controller/core/consoleconnection_controller_test.go b/internal/controller/core/consoleconnection_controller_test.go new file mode 100644 index 000000000..3131ba99b --- /dev/null +++ b/internal/controller/core/consoleconnection_controller_test.go @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "crypto/rand" + "crypto/rsa" + "errors" + "net" + "sync" + "time" + + "golang.org/x/crypto/ssh" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("ConsoleConnection Controller", func() { + Context("When reconciling a resource", func() { + var ( + name string + key client.ObjectKey + ) + + BeforeEach(func() { + By("Creating the Device") + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-console-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + name = device.Name + key = client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + By("Creating the auth secret") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-console", + Namespace: metav1.NamespaceDefault, + }, + Type: corev1.SecretTypeBasicAuth, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("admin"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up the ConsoleConnection resource") + cc := &v1alpha1.ConsoleConnection{} + cc.Name = name + cc.Namespace = metav1.NamespaceDefault + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, cc))).To(Succeed()) + + By("Waiting for the ConsoleConnection to be deleted") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, key, &v1alpha1.ConsoleConnection{}) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + + By("Cleaning up the secret") + secret := &corev1.Secret{} + secret.Name = name + "-console" + secret.Namespace = metav1.NamespaceDefault + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, secret))).To(Succeed()) + + By("Cleaning up the Device resource") + device := &v1alpha1.Device{} + device.Name = name + device.Namespace = metav1.NamespaceDefault + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, device))).To(Succeed()) + }) + + It("Should add a finalizer and set owner reference", func() { + resource := &v1alpha1.ConsoleConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConsoleConnectionSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Endpoint: v1alpha1.ConsoleEndpoint{ + Address: "10.0.0.1:2001", + SecretRef: v1alpha1.SecretReference{Name: name + "-console"}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(cc, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(cc.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + g.Expect(cc.OwnerReferences).To(HaveLen(1)) + g.Expect(cc.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(cc.OwnerReferences[0].Name).To(Equal(name)) + }).Should(Succeed()) + }) + + It("Should report ConsoleServerUnreachable when console server is not reachable", func() { + resource := &v1alpha1.ConsoleConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConsoleConnectionSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Endpoint: v1alpha1.ConsoleEndpoint{ + Address: "192.0.2.1:2001", + SecretRef: v1alpha1.SecretReference{Name: name + "-console"}, + }, + Verification: v1alpha1.ConsoleVerification{ + Expect: &v1alpha1.ConsoleExpect{String: new("anything")}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(cc.Status.LastCheckTime).NotTo(BeNil()) + g.Expect(cc.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", v1alpha1.ReadyCondition), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", v1alpha1.ConsoleServerUnreachableReason), + ))) + }).Should(Succeed()) + }) + + It("Should report ConsoleServerAuthFailure when credentials are wrong", func() { + addr, cleanup := StartTestSSHServer("other", nil) + DeferCleanup(cleanup) + + resource := &v1alpha1.ConsoleConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConsoleConnectionSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Endpoint: v1alpha1.ConsoleEndpoint{ + Address: addr, + SecretRef: v1alpha1.SecretReference{Name: name + "-console"}, // has admin/password, server expects admin/other + }, + Verification: v1alpha1.ConsoleVerification{ + Expect: &v1alpha1.ConsoleExpect{String: new("anything")}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(cc.Status.LastCheckTime).NotTo(BeNil()) + g.Expect(cc.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", v1alpha1.ReadyCondition), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", v1alpha1.ConsoleServerAuthFailureReason), + ))) + }).Should(Succeed()) + }) + + It("Should report Dead when no output is received", func() { + addr, cleanup := StartTestSSHServer("password", nil) + DeferCleanup(cleanup) + + resource := &v1alpha1.ConsoleConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConsoleConnectionSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Endpoint: v1alpha1.ConsoleEndpoint{ + Address: addr, + SecretRef: v1alpha1.SecretReference{Name: name + "-console"}, + }, + Timeout: metav1.Duration{Duration: 2 * time.Second}, + Verification: v1alpha1.ConsoleVerification{ + Strategy: v1alpha1.ConsoleVerificationWait, + Expect: &v1alpha1.ConsoleExpect{String: new("anything")}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(cc.Status.LastCheckTime).NotTo(BeNil()) + g.Expect(cc.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", v1alpha1.ReadyCondition), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", v1alpha1.ConsoleDeadReason), + ))) + }).Should(Succeed()) + }) + + It("Should report Alive when output does not match expected string", func() { + addr, cleanup := StartTestSSHServer("password", []byte("switch-B login:")) + DeferCleanup(cleanup) + + resource := &v1alpha1.ConsoleConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConsoleConnectionSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Endpoint: v1alpha1.ConsoleEndpoint{ + Address: addr, + SecretRef: v1alpha1.SecretReference{Name: name + "-console"}, + }, + Timeout: metav1.Duration{Duration: 2 * time.Second}, + Verification: v1alpha1.ConsoleVerification{ + Strategy: v1alpha1.ConsoleVerificationWait, + Expect: &v1alpha1.ConsoleExpect{String: new("switch-A")}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(cc.Status.LastCheckTime).NotTo(BeNil()) + g.Expect(cc.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", v1alpha1.ReadyCondition), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", v1alpha1.ConsoleAliveReason), + ))) + }).Should(Succeed()) + }) + + It("Should report Verified when output matches expected string", func() { + addr, cleanup := StartTestSSHServer("password", []byte("switch-A login:")) + DeferCleanup(cleanup) + + resource := &v1alpha1.ConsoleConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConsoleConnectionSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Endpoint: v1alpha1.ConsoleEndpoint{ + Address: addr, + SecretRef: v1alpha1.SecretReference{Name: name + "-console"}, + }, + Verification: v1alpha1.ConsoleVerification{ + Strategy: v1alpha1.ConsoleVerificationWait, + Expect: &v1alpha1.ConsoleExpect{String: new("switch-A")}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(cc.Status.LastCheckTime).NotTo(BeNil()) + g.Expect(cc.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", v1alpha1.ReadyCondition), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", v1alpha1.ConsoleVerifiedReason), + ))) + }).Should(Succeed()) + }) + + It("Should report Verified using Device hostname when expect is omitted", func() { + addr, cleanup := StartTestSSHServer("password", []byte("mydevice>")) + DeferCleanup(cleanup) + + Eventually(func(g Gomega) { + device := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, device)).To(Succeed()) + device.Status.Hostname = "mydevice" + g.Expect(k8sClient.Status().Update(ctx, device)).To(Succeed()) + }).Should(Succeed()) + + resource := &v1alpha1.ConsoleConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConsoleConnectionSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Endpoint: v1alpha1.ConsoleEndpoint{ + Address: addr, + SecretRef: v1alpha1.SecretReference{Name: name + "-console"}, + }, + Verification: v1alpha1.ConsoleVerification{ + Strategy: v1alpha1.ConsoleVerificationWait, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + Eventually(func(g Gomega) { + cc := &v1alpha1.ConsoleConnection{} + g.Expect(k8sClient.Get(ctx, key, cc)).To(Succeed()) + g.Expect(cc.Status.LastCheckTime).NotTo(BeNil()) + g.Expect(cc.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", v1alpha1.ReadyCondition), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", v1alpha1.ConsoleVerifiedReason), + ))) + }).Should(Succeed()) + }) + }) +}) + +// StartTestSSHServer starts an in-process SSH server on an ephemeral port. +// It accepts password authentication with the given user/pass. After a shell +// request, it writes output to the channel (nil output means write nothing). +// The server accepts one connection at a time and resets for each new one. +// Returns the listener address and a cleanup function. +func StartTestSSHServer(pass string, output []byte) (addr string, cleanup func()) { + hostKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + signer, err := ssh.NewSignerFromKey(hostKey) + Expect(err).NotTo(HaveOccurred()) + + config := &ssh.ServerConfig{ + PasswordCallback: func(_ ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if string(password) == pass { + return &ssh.Permissions{}, nil + } + return nil, errors.New("invalid credentials") + }, + } + config.AddHostKey(signer) + + ln, err := new(net.ListenConfig).Listen(ctx, "tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + for { + tcpConn, err := ln.Accept() + if err != nil { + return // listener closed + } + go HandleSSHConn(config, tcpConn, output) + } + }() + + return ln.Addr().String(), func() { + ln.Close() + wg.Wait() + } +} + +func HandleSSHConn(config *ssh.ServerConfig, tcpConn net.Conn, output []byte) { + defer GinkgoRecover() + defer tcpConn.Close() + + sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, config) + if err != nil { + return // auth failure or handshake error + } + defer sshConn.Close() + go ssh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(ssh.UnknownChannelType, "unsupported channel type") //nolint:errcheck + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + return + } + go func() { + defer ch.Close() + for req := range requests { + if req.Type == "shell" { + _ = req.Reply(true, nil) //nolint:errcheck + if output != nil { + ch.Write(output) //nolint:errcheck + } + // Hold the channel open until the client disconnects. + buf := make([]byte, 1) + for { + if _, err := ch.Read(buf); err != nil { + return + } + } + } + _ = req.Reply(false, nil) //nolint:errcheck + } + }() + } +} diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 48ca12a49..0dea10cd9 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -349,6 +349,13 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&ConsoleConnectionReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx)