diff --git a/PROJECT b/PROJECT
index c5a6f881d..1b5793b06 100644
--- a/PROJECT
+++ b/PROJECT
@@ -417,4 +417,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 c1c757ce3..16035d317 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -56,13 +56,14 @@ k8s_resource('network-operator-controller-manager', resource_deps=['controller-g
k8s_resource('rustfs', port_forwards=['9001:9001'])
k8s_resource('rustfs-create-buckets', resource_deps=['rustfs'])
+k8s_resource('console-server', objects=['console-emulator:configmap'])
# Sample resources with manual trigger mode
def device_yaml():
decoded = read_yaml_stream('./config/samples/v1alpha1_device.yaml')
if provider != None:
decoded[0]['spec']['provider'] = provider
- ip = str(local("docker run --rm busybox:1.37.0 nslookup -type=a host.docker.internal 2>/dev/null | grep 'Address:' | tail -n 1 | awk '{print $2}' || echo ''", quiet=True)).rstrip('\n')
+ ip = str(local("docker run --rm busybox:1.37.0 nslookup -type=a host.docker.internal 2>/dev/null | grep 'Address:' | grep -v ':53' | tail -n 1 | awk '{print $2}' || echo ''", quiet=True)).rstrip('\n')
if len(ip) > 0:
decoded[0]['spec']['endpoint']['address'] = ip+':9339'
return encode_yaml_stream(decoded)
@@ -221,6 +222,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', 'console-credentials:secret'], resource_deps=['console-server'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples'])
+k8s_resource(new_name='console-scheduled', objects=['console-scheduled:consoleconnection'], resource_deps=['console-default'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples'])
+k8s_resource(new_name='console-regex', objects=['console-regex:consoleconnection'], resource_deps=['console-default'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples'])
+k8s_resource(new_name='console-sendchar', objects=['console-sendchar:consoleconnection'], resource_deps=['console-default'], 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 abd005a1f..76f149253 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/charts/network-operator/templates/crd/consoleconnections.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/consoleconnections.networking.metal.ironcore.dev.yaml
new file mode 100644
index 000000000..6e30e51dc
--- /dev/null
+++ b/charts/network-operator/templates/crd/consoleconnections.networking.metal.ironcore.dev.yaml
@@ -0,0 +1,284 @@
+{{- if .Values.crd.enabled }}
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ {{- if .Values.crd.keep }}
+ "helm.sh/resource-policy": keep
+ {{- end }}
+ 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: {}
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/consoleconnection-admin-role.yaml b/charts/network-operator/templates/rbac/consoleconnection-admin-role.yaml
new file mode 100644
index 000000000..46435424d
--- /dev/null
+++ b/charts/network-operator/templates/rbac/consoleconnection-admin-role.yaml
@@ -0,0 +1,31 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "consoleconnection-admin-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - consoleconnections
+ verbs:
+ - '*'
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - consoleconnections/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/consoleconnection-editor-role.yaml b/charts/network-operator/templates/rbac/consoleconnection-editor-role.yaml
new file mode 100644
index 000000000..30b83b45c
--- /dev/null
+++ b/charts/network-operator/templates/rbac/consoleconnection-editor-role.yaml
@@ -0,0 +1,37 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "consoleconnection-editor-role" "context" $) }}
+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
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/consoleconnection-viewer-role.yaml b/charts/network-operator/templates/rbac/consoleconnection-viewer-role.yaml
new file mode 100644
index 000000000..438e0334b
--- /dev/null
+++ b/charts/network-operator/templates/rbac/consoleconnection-viewer-role.yaml
@@ -0,0 +1,33 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "consoleconnection-viewer-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - consoleconnections
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - consoleconnections/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml
index 79729cf5f..b08f5a2ac 100644
--- a/charts/network-operator/templates/rbac/manager-role.yaml
+++ b/charts/network-operator/templates/rbac/manager-role.yaml
@@ -81,6 +81,7 @@ rules:
- bgppeers
- certificates
- configbackups
+ - consoleconnections
- devices
- dhcprelays
- dns
@@ -119,6 +120,7 @@ rules:
- bgp/finalizers
- bgppeers/finalizers
- certificates/finalizers
+ - consoleconnections/finalizers
- devices/finalizers
- dhcprelays/finalizers
- dns/finalizers
@@ -151,6 +153,7 @@ rules:
- bgppeers/status
- certificates/status
- configbackups/status
+ - consoleconnections/status
- devices/status
- dhcprelays/status
- dns/status
diff --git a/cmd/main.go b/cmd/main.go
index be9ce0b10..f4a6eaee8 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -814,6 +814,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/develop/console-server.yaml b/config/develop/console-server.yaml
new file mode 100644
index 000000000..a3ee741f7
--- /dev/null
+++ b/config/develop/console-server.yaml
@@ -0,0 +1,97 @@
+# Console server emulator for local development.
+# Emulates an IOLan-style console server: SSH into a port, get raw serial output.
+# Uses linuxserver/openssh-server with a custom init script that installs
+# a ForceCommand to simulate device console output.
+#
+# Default credentials: admin / admin
+# Console endpoint from within the cluster: console-server.default.svc:2222
+#
+# linuxserver/openssh-server is licensed under GPL-3.0:
+# https://github.com/linuxserver/docker-openssh-server/blob/master/LICENSE
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: console-emulator
+ namespace: default
+data:
+ # s6-overlay init script: runs once at container startup before sshd starts.
+ setup-console.sh: |
+ #!/bin/sh
+ cat > /usr/local/bin/console.sh << 'SCRIPT'
+ #!/bin/sh
+ HOSTNAME="${DEVICE_HOSTNAME:-leaf1}"
+ sleep 0.5
+ printf "\r\n%s login: " "$HOSTNAME"
+ read -r _user 2>/dev/null || true
+ printf "Password: "
+ read -r _pass 2>/dev/null || true
+ printf "\r\n%s> " "$HOSTNAME"
+ cat >/dev/null 2>&1
+ SCRIPT
+ chmod +x /usr/local/bin/console.sh
+ echo "ForceCommand /usr/local/bin/console.sh" >> /config/sshd/sshd_config
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: console-server
+ namespace: default
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: console-server
+ template:
+ metadata:
+ labels:
+ app: console-server
+ spec:
+ containers:
+ - name: openssh
+ image: lscr.io/linuxserver/openssh-server:latest
+ env:
+ - name: PUID
+ value: "1000"
+ - name: PGID
+ value: "1000"
+ - name: PASSWORD_ACCESS
+ value: "true"
+ - name: USER_NAME
+ value: "admin"
+ - name: USER_PASSWORD
+ value: "admin"
+ - name: DEVICE_HOSTNAME
+ value: "leaf1"
+ ports:
+ - name: ssh
+ containerPort: 2222
+ readinessProbe:
+ tcpSocket:
+ port: 2222
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ volumeMounts:
+ - name: init
+ mountPath: /custom-cont-init.d/setup-console.sh
+ subPath: setup-console.sh
+ volumes:
+ - name: init
+ configMap:
+ name: console-emulator
+ defaultMode: 0755
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: console-server
+ namespace: default
+spec:
+ type: ClusterIP
+ clusterIP: 10.96.200.1
+ selector:
+ app: console-server
+ ports:
+ - name: ssh
+ port: 2222
+ targetPort: 2222
diff --git a/config/develop/kustomization.yaml b/config/develop/kustomization.yaml
index 89777f901..ecf5f283d 100644
--- a/config/develop/kustomization.yaml
+++ b/config/develop/kustomization.yaml
@@ -2,6 +2,7 @@ resources:
- ../default
- ../prometheus
- rustfs.yaml
+- console-server.yaml
patches:
- path: manager_patch.yaml
diff --git a/config/develop/rustfs.yaml b/config/develop/rustfs.yaml
index c0c4d2973..77dc20501 100644
--- a/config/develop/rustfs.yaml
+++ b/config/develop/rustfs.yaml
@@ -86,5 +86,5 @@ spec:
command: ["sh", "-c"]
args:
- |
- rc alias set local http://rustfs.default.svc:9000 rustfsadmin rustfsadmin
+ rc alias set local http://rustfs.default.svc.cluster.local:9000 rustfsadmin rustfsadmin
rc bucket create --ignore-existing local/config-backups
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..b461d2ae1
--- /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.96.200.1:2222"
+ 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.96.200.1:2222"
+ 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.96.200.1:2222"
+ secretRef:
+ name: console-credentials
+ timeout: 10s
+ verification:
+ strategy: Wait
+ expect:
+ regex: "leaf1.*login"
+---
+# 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.96.200.1:2222"
+ 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: admin
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index fef20cbf9..7e2e030ab 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -332,6 +332,7 @@ Package v1alpha1 contains API Schema definitions for the networking.metal.ironco
- [Banner](#banner)
- [Certificate](#certificate)
- [ConfigBackup](#configbackup)
+- [ConsoleConnection](#consoleconnection)
- [DHCPRelay](#dhcprelay)
- [DNS](#dns)
- [Device](#device)
@@ -1606,6 +1607,153 @@ _Appears in:_
| `namespace` _string_ | Namespace defines the space within which the configmap name must be unique.
If omitted, the namespace of the object being reconciled will be used. | | MaxLength: 63
MinLength: 1
Optional: \{\}
|
+#### ConsoleConnection
+
+
+
+ConsoleConnection is the Schema for the consoleconnections API.
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | |
+| `kind` _string_ | `ConsoleConnection` | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[ConsoleConnectionSpec](#consoleconnectionspec)_ | 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: \{\}
|
+| `status` _[ConsoleConnectionStatus](#consoleconnectionstatus)_ | 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: \{\}
|
+
+
+#### ConsoleConnectionSpec
+
+
+
+ConsoleConnectionSpec defines the desired state of ConsoleConnection.
+
+
+
+_Appears in:_
+- [ConsoleConnection](#consoleconnection)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceRef is a reference to the Device this console connection targets.
The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
|
+| `endpoint` _[ConsoleEndpoint](#consoleendpoint)_ | Endpoint contains the console server connection details. | | Required: \{\}
|
+| `verification` _[ConsoleVerification](#consoleverification)_ | Verification configures how the controller confirms the serial
line is alive and connected to the expected device. | | Optional: \{\}
|
+| `schedule` _string_ | 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: \{\}
|
+| `timeout` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#duration-v1-meta)_ | Timeout is the maximum duration the controller waits for output on
the serial line before declaring the connection dead. | 30s | Optional: \{\}
|
+
+
+#### ConsoleConnectionStatus
+
+
+
+ConsoleConnectionStatus defines the observed state of ConsoleConnection.
+
+
+
+_Appears in:_
+- [ConsoleConnection](#consoleconnection)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `lastCheckTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | LastCheckTime is the timestamp of the most recent check. | | Optional: \{\}
|
+| `nextCheckTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | NextCheckTime is the next scheduled check. Only set when Schedule is configured. | | Optional: \{\}
|
+| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | Conditions represent the current state of the ConsoleConnection resource.
The Ready condition reports the health of the console connection. | | Optional: \{\}
|
+
+
+#### ConsoleEndpoint
+
+
+
+ConsoleEndpoint contains the console server connection details.
+
+
+
+_Appears in:_
+- [ConsoleConnectionSpec](#consoleconnectionspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `address` _string_ | 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\}$`
Required: \{\}
|
+| `protocol` _[ConsoleProtocol](#consoleprotocol)_ | Protocol is the connection protocol. | SSH | Enum: [SSH]
Optional: \{\}
|
+| `secretRef` _[SecretReference](#secretreference)_ | SecretRef references a kubernetes.io/basic-auth secret containing
'username' and 'password' for the console server. | | Required: \{\}
|
+
+
+#### ConsoleExpect
+
+
+
+ConsoleExpect configures what the controller looks for in the serial output.
+
+
+
+_Appears in:_
+- [ConsoleVerification](#consoleverification)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `string` _string_ | String is a literal string to match in the serial output. | | Optional: \{\}
|
+| `regex` _string_ | Regex is a regular expression to match in the serial output. | | Optional: \{\}
|
+
+
+#### ConsoleProtocol
+
+_Underlying type:_ _string_
+
+ConsoleProtocol is the connection protocol used to reach the console server.
+
+_Validation:_
+- Enum: [SSH]
+
+_Appears in:_
+- [ConsoleEndpoint](#consoleendpoint)
+
+| Field | Description |
+| --- | --- |
+| `SSH` | |
+
+
+#### ConsoleVerification
+
+
+
+ConsoleVerification configures how the controller confirms the serial
+line is alive and connected to the expected device.
+
+
+
+_Appears in:_
+- [ConsoleConnectionSpec](#consoleconnectionspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `strategy` _[ConsoleVerificationStrategy](#consoleverificationstrategy)_ | 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. | SendCRLF | Enum: [Wait SendCRLF SendChar]
Optional: \{\}
|
+| `char` _string_ | Char is the character to send when Strategy is SendChar.
Ignored for other strategies. | | MaxLength: 1
MinLength: 1
Optional: \{\}
|
+| `expect` _[ConsoleExpect](#consoleexpect)_ | 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: \{\}
|
+
+
+#### ConsoleVerificationStrategy
+
+_Underlying type:_ _string_
+
+ConsoleVerificationStrategy selects how the controller stimulates the serial line.
+
+_Validation:_
+- Enum: [Wait SendCRLF SendChar]
+
+_Appears in:_
+- [ConsoleVerification](#consoleverification)
+
+| Field | Description |
+| --- | --- |
+| `Wait` | |
+| `SendCRLF` | |
+| `SendChar` | |
+
+
#### ControlProtocol
@@ -2674,6 +2822,7 @@ _Appears in:_
- [BorderGatewaySpec](#bordergatewayspec)
- [CertificateSpec](#certificatespec)
- [ConfigBackupSpec](#configbackupspec)
+- [ConsoleConnectionSpec](#consoleconnectionspec)
- [DHCPRelaySpec](#dhcprelayspec)
- [DNSSpec](#dnsspec)
- [DevicePort](#deviceport)
@@ -4024,6 +4173,7 @@ _Appears in:_
- [CertificateSource](#certificatesource)
- [CertificateSpec](#certificatespec)
- [ConfigBackupS3](#configbackups3)
+- [ConsoleEndpoint](#consoleendpoint)
- [Endpoint](#endpoint)
- [SecretKeySelector](#secretkeyselector)
diff --git a/internal/controller/core/consoleconnection_controller.go b/internal/controller/core/consoleconnection_controller.go
new file mode 100644
index 000000000..941620c24
--- /dev/null
+++ b/internal/controller/core/consoleconnection_controller.go
@@ -0,0 +1,478 @@
+// 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.Status, obj.Status) {
+ // Pass obj.DeepCopy() to avoid Patch() modifying obj and interfering with metadata update below
+ if err := r.Status().Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update status")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }
+ if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) {
+ if err := r.Patch(ctx, obj, client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update resource metadata")
+ 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 // CodeQL[go/insecure-hostkeycallback] console servers lack managed 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 {
+ 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{
+ 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{
+ 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..12e261896
--- /dev/null
+++ b/internal/controller/core/consoleconnection_controller_test.go
@@ -0,0 +1,405 @@
+// 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{
+ 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{
+ 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{
+ 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{
+ 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{
+ 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{
+ 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{
+ 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{
+ 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{
+ 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 10679d5fe..9502d5470 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)