From 3bf2c0291f7ecaf1d6b437494fb00b5d361065cc Mon Sep 17 00:00:00 2001 From: Robert Lemke Date: Tue, 24 Feb 2026 11:51:02 +0100 Subject: [PATCH] feat(robot): allow Robot support without credentials for IP-based LB targets Previously, enabling Robot support (`ROBOT_ENABLED=true`) required both `ROBOT_USER` and `ROBOT_PASSWORD` to be set. This made it impossible to use Robot nodes as IP-based load balancer targets without providing Robot API credentials. With this change, Robot credentials become optional. When no credentials are provided, the load balancer reconciler derives IP targets directly from the Kubernetes Node objects' InternalIP instead of querying the Robot API. This is sufficient for setups where the node's InternalIP (e.g. a vSwitch private IP) is the correct LB target address. Partial credentials (only user or only password) are still rejected as a likely misconfiguration. --- docs/explanation/robot-support.md | 16 +++++ docs/guides/robot/private-networks.md | 4 +- hcloud/cloud.go | 2 +- internal/config/config.go | 12 ++-- internal/config/config_test.go | 33 +++++++++- internal/hcops/load_balancer.go | 74 +++++++++++++++------ internal/hcops/load_balancer_test.go | 92 +++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 30 deletions(-) diff --git a/docs/explanation/robot-support.md b/docs/explanation/robot-support.md index 5caeb9bf8..0895865e1 100644 --- a/docs/explanation/robot-support.md +++ b/docs/explanation/robot-support.md @@ -54,4 +54,20 @@ If you absolutely need to use different names in Robot & Hostname, you can also ## Credentials +Robot API credentials (`ROBOT_USER` / `ROBOT_PASSWORD`) are **optional**. They control which features are available: + +### With Credentials + +All features described above are available: the Node Controller sets labels and addresses from the Robot API, the Node Lifecycle Controller manages shutdown detection and node deletion, and the Service Controller adds Robot servers as Load Balancer targets. + If you only plan to use a single Robot server, you can also use an "Admin login" (see the `Admin login` tab on the [server administration page](https://robot.hetzner.com/server)) for this server instead of the account credentials. + +### Without Credentials + +When `robot.enabled` is set to `true` but no `ROBOT_USER` / `ROBOT_PASSWORD` are provided, the HCCM operates in a limited mode: + +- **Service Controller (Load Balancers)**: Fully functional. Robot servers with `hrobot://` provider IDs are added as IP targets using their `InternalIP` from the Kubernetes Node object. This is ideal for setups where Robot servers are connected via a vSwitch and only the Load Balancer integration is needed. +- **Node Controller**: Must be disabled (`--controllers=*,-cloud-node,-cloud-node-lifecycle`), as it requires the Robot API to fetch server metadata. +- **Node Lifecycle Controller**: Must be disabled (same flag as above). + +This mode is useful when you manage nodes externally (e.g., via Talos or another provisioning tool) and only need the CCM for Load Balancer target management. It avoids exposing account-wide Robot API credentials to the cluster. diff --git a/docs/guides/robot/private-networks.md b/docs/guides/robot/private-networks.md index c669ff227..f57051858 100644 --- a/docs/guides/robot/private-networks.md +++ b/docs/guides/robot/private-networks.md @@ -6,7 +6,9 @@ As a result, the annotation `load-balancer.hetzner.cloud/use-private-ip` can be ## Prerequisite -Enable Robot support as outlined in the [Robot setup guide](./quickstart.md). As mentioned there, for a Robot server we pass along configured InternalIPs, that do not appear as an ExternalIP and are within the configured address family. Check with `kubectl get nodes -o json | jq ".items.[].status.addresses"` if you have configured an InternalIP. +Enable Robot support as outlined in the [Robot setup guide](./quickstart.md). For a Robot server we pass along configured InternalIPs, that do not appear as an ExternalIP and are within the configured address family. Check with `kubectl get nodes -o json | jq ".items.[].status.addresses"` if you have configured an InternalIP. + +Robot API credentials (`ROBOT_USER` / `ROBOT_PASSWORD`) are optional for this use case. When credentials are not provided, the HCCM derives IP targets directly from the Kubernetes Node's `InternalIP` instead of querying the Robot API. This requires disabling the node controllers: `--controllers=*,-cloud-node,-cloud-node-lifecycle`. See the [Robot Support explanation](../../explanation/robot-support.md#without-credentials) for details. ## Configuration diff --git a/hcloud/cloud.go b/hcloud/cloud.go index 0d90c50b6..8840fd567 100644 --- a/hcloud/cloud.go +++ b/hcloud/cloud.go @@ -96,7 +96,7 @@ func NewCloud(cidr string) (cloudprovider.Interface, error) { metadataClient := metadata.NewClient() var robotClient robot.Client - if cfg.Robot.Enabled { + if cfg.Robot.Enabled && cfg.Robot.User != "" && cfg.Robot.Password != "" { c := hrobot.NewBasicAuthClientWithCustomHttpClient( cfg.Robot.User, cfg.Robot.Password, diff --git a/internal/config/config.go b/internal/config/config.go index 44995acd2..32c08f409 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -289,11 +289,15 @@ func (c HCCMConfiguration) Validate() (err error) { } if c.Robot.Enabled { - if c.Robot.User == "" { - errs = append(errs, fmt.Errorf("environment variable %q is required if Robot support is enabled", robotUser)) + // Robot credentials are optional. When only using the service + // controller with IP-based LB targets, the node's InternalIP from + // Kubernetes is sufficient and no Robot API access is needed. + if (c.Robot.User == "") != (c.Robot.Password == "") { + // Partial credentials are likely a misconfiguration. + errs = append(errs, fmt.Errorf("both %q and %q must be provided, or neither", robotUser, robotPassword)) } - if c.Robot.Password == "" { - errs = append(errs, fmt.Errorf("environment variable %q is required if Robot support is enabled", robotPassword)) + if c.Robot.User == "" && c.Robot.Password == "" { + klog.Infof("Robot support enabled without credentials. Some features might not work as expected.") } if c.Route.Enabled { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c1099d092..60d7cc3cc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -484,7 +484,7 @@ func TestHCCMConfiguration_Validate(t *testing.T) { wantErr: errors.New("invalid value for \"HCLOUD_LOAD_BALANCERS_ALGORITHM_TYPE\": unsupported value \"invalid\""), }, { - name: "robot enabled but missing credentials", + name: "robot enabled without credentials (valid)", fields: fields{ HCloudClient: HCloudClientConfiguration{Token: "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq"}, Instance: InstanceConfiguration{AddressFamily: AddressFamilyIPv4}, @@ -493,8 +493,35 @@ func TestHCCMConfiguration_Validate(t *testing.T) { Enabled: true, }, }, - wantErr: errors.New(`environment variable "ROBOT_USER" is required if Robot support is enabled -environment variable "ROBOT_PASSWORD" is required if Robot support is enabled`), + wantErr: nil, + }, + { + name: "robot enabled with partial credentials (only user)", + fields: fields{ + HCloudClient: HCloudClientConfiguration{Token: "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq"}, + Instance: InstanceConfiguration{AddressFamily: AddressFamilyIPv4}, + + Robot: RobotConfiguration{ + Enabled: true, + User: "foo", + Password: "", + }, + }, + wantErr: errors.New(`both "ROBOT_USER" and "ROBOT_PASSWORD" must be provided, or neither`), + }, + { + name: "robot enabled with partial credentials (only password)", + fields: fields{ + HCloudClient: HCloudClientConfiguration{Token: "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq"}, + Instance: InstanceConfiguration{AddressFamily: AddressFamilyIPv4}, + + Robot: RobotConfiguration{ + Enabled: true, + User: "", + Password: "bar", + }, + }, + wantErr: errors.New(`both "ROBOT_USER" and "ROBOT_PASSWORD" must be provided, or neither`), }, { name: "robot & routes activated", diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go index 7c9ebb7eb..94ccdbcba 100644 --- a/internal/hcops/load_balancer.go +++ b/internal/hcops/load_balancer.go @@ -680,44 +680,76 @@ func (l *LoadBalancerOps) ReconcileHCLBTargets( // List all robot servers to check whether the ip targets of the load balancer // correspond to a dedicated server - if l.Cfg.Robot.Enabled { + useRobotAPI := l.Cfg.Robot.Enabled && l.RobotClient != nil + useRobotInternalIPs := l.Cfg.Robot.Enabled && l.RobotClient == nil && privateIPEnabled + + // Use Robot API to either fetch ExternalIP or use InternalIP from Node objects + if useRobotAPI { dedicatedServers, err := l.RobotClient.ServerGetList() if err != nil { return changed, fmt.Errorf("%s: failed to get list of dedicated servers: %w", op, err) } for _, s := range dedicatedServers { - if privateIPEnabled { - node, ok := k8sNodes[int64(s.ServerNumber)] - if !ok { - continue - } + // Set ExternalIP as Load Balancer target + robotIPsToIDs[s.ServerIP] = s.ServerNumber + robotIDToIPv4[s.ServerNumber] = s.ServerIP - internalIP := getNodeInternalIP(node) - if internalIP != "" { - robotIPsToIDs[internalIP] = s.ServerNumber - robotIDToIPv4[s.ServerNumber] = internalIP - continue - } + // If user does not want private IPs we can skip this part + if !privateIPEnabled { + continue + } - klog.Warningf( + node, ok := k8sNodes[int64(s.ServerNumber)] + if !ok { + continue + } + + // Check if InternalIP is set at Node object + internalIP := getNodeInternalIP(node) + if internalIP == "" { + warnMsg := fmt.Sprintf( "%s: load balancer %s has set `use-private-ip: true`, but no InternalIP found for node %s. Continuing with ExternalIP.", op, svc.Name, node.Name, ) - l.Recorder.Eventf( - svc, - corev1.EventTypeWarning, - "InternalIPNotConfigured", - "%s: load balancer has set `use-private-ip: true`, but no InternalIP found for node %s. Continuing with ExternalIP.", - op, + klog.Warning(warnMsg) + l.Recorder.Eventf(svc, corev1.EventTypeWarning, "InternalIPNotConfigured", warnMsg) + continue + } + + // Overwrite ExternalIP with InternalIP + robotIPsToIDs[internalIP] = s.ServerNumber + robotIDToIPv4[s.ServerNumber] = internalIP + } + } + + // Use InternalIPs for Robot servers without querying the API + if useRobotInternalIPs { + // No Robot client: derive IP mapping directly from Kubernetes Node + // objects. This works when the node's InternalIP is the correct + // target (e.g. vSwitch private IP). + for id := range k8sNodeIDsRobot { + node, ok := k8sNodes[int64(id)] + if !ok { + continue + } + + internalIP := getNodeInternalIP(node) + if internalIP == "" { + warnMsg := fmt.Sprintf( + "no InternalIP found for Robot node %s (id=%d), cannot add as LB target without Robot credentials; skipping", node.Name, + id, ) + klog.Warning(warnMsg) + l.Recorder.Eventf(svc, corev1.EventTypeWarning, "InternalIPNotConfigured", warnMsg) + continue } - robotIPsToIDs[s.ServerIP] = s.ServerNumber - robotIDToIPv4[s.ServerNumber] = s.ServerIP + robotIPsToIDs[internalIP] = id + robotIDToIPv4[id] = internalIP } } diff --git a/internal/hcops/load_balancer_test.go b/internal/hcops/load_balancer_test.go index 9aba8124b..5b48fe22f 100644 --- a/internal/hcops/load_balancer_test.go +++ b/internal/hcops/load_balancer_test.go @@ -1588,6 +1588,98 @@ func TestLoadBalancerOps_ReconcileHCLBTargets(t *testing.T) { assert.True(t, changed) }, }, + { + name: "robot enabled without credentials uses node InternalIP for IP targets", + k8sNodes: []*corev1.Node{ + {Spec: corev1.NodeSpec{ProviderID: "hcloud://1"}}, + { + Spec: corev1.NodeSpec{ProviderID: "hrobot://3"}, + ObjectMeta: metav1.ObjectMeta{Name: "robot-3"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.1.10"}, + }, + }, + }, + { + Spec: corev1.NodeSpec{ProviderID: "hrobot://4"}, + ObjectMeta: metav1.ObjectMeta{Name: "robot-4"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.1.11"}, + }, + }, + }, + }, + initialLB: &hcloud.LoadBalancer{ + ID: 1, + LoadBalancerType: &hcloud.LoadBalancerType{ + MaxTargets: 25, + }, + }, + cfg: config.HCCMConfiguration{ + LoadBalancer: config.LoadBalancerConfiguration{ + IPv6Enabled: true, + PrivateIPEnabled: true, + }, + Robot: config.RobotConfiguration{Enabled: true}, + }, + mock: func(_ *testing.T, tt *LBReconcilementTestCase) { + // Set RobotClient to nil to simulate missing credentials + tt.fx.LBOps.RobotClient = nil + tt.fx.LBOps.NetworkID = 4711 + + opts := hcloud.LoadBalancerAddServerTargetOpts{Server: &hcloud.Server{ID: 1}, UsePrivateIP: hcloud.Ptr(true)} + action := tt.fx.MockAddServerTarget(tt.initialLB, opts, nil) + tt.fx.ActionClient.On("WaitFor", tt.fx.Ctx, action).Return(nil) + + optsIP := hcloud.LoadBalancerAddIPTargetOpts{IP: net.ParseIP("10.0.1.10")} + action = tt.fx.MockAddIPTarget(tt.initialLB, optsIP, nil) + tt.fx.ActionClient.On("WaitFor", tt.fx.Ctx, action).Return(nil) + + optsIP = hcloud.LoadBalancerAddIPTargetOpts{IP: net.ParseIP("10.0.1.11")} + action = tt.fx.MockAddIPTarget(tt.initialLB, optsIP, nil) + tt.fx.ActionClient.On("WaitFor", tt.fx.Ctx, action).Return(nil) + }, + perform: func(t *testing.T, tt *LBReconcilementTestCase) { + changed, err := tt.fx.LBOps.ReconcileHCLBTargets(tt.fx.Ctx, tt.initialLB, tt.service, tt.k8sNodes) + assert.NoError(t, err) + assert.True(t, changed) + }, + }, + { + name: "robot enabled without credentials skips nodes without InternalIP", + k8sNodes: []*corev1.Node{ + { + Spec: corev1.NodeSpec{ProviderID: "hrobot://5"}, + ObjectMeta: metav1.ObjectMeta{Name: "robot-no-ip"}, + Status: corev1.NodeStatus{}, + }, + }, + initialLB: &hcloud.LoadBalancer{ + ID: 1, + LoadBalancerType: &hcloud.LoadBalancerType{ + MaxTargets: 25, + }, + }, + cfg: config.HCCMConfiguration{ + LoadBalancer: config.LoadBalancerConfiguration{ + IPv6Enabled: true, + PrivateIPEnabled: true, + }, + Robot: config.RobotConfiguration{Enabled: true}, + }, + mock: func(_ *testing.T, tt *LBReconcilementTestCase) { + // Set RobotClient to nil to simulate missing credentials + tt.fx.LBOps.RobotClient = nil + tt.fx.LBOps.NetworkID = 4711 + }, + perform: func(t *testing.T, tt *LBReconcilementTestCase) { + changed, err := tt.fx.LBOps.ReconcileHCLBTargets(tt.fx.Ctx, tt.initialLB, tt.service, tt.k8sNodes) + assert.NoError(t, err) + assert.False(t, changed) + }, + }, } for _, tt := range tests {