Skip to content

Commit 7878669

Browse files
authored
feat(robot): allow Robot support without API credentials for IP-based LB targets (#1163)
When `robot.enabled` is set but no `ROBOT_USER` / `ROBOT_PASSWORD` are provided, the HCCM now derives IP targets directly from the Kubernetes Node's `InternalIP` instead of querying the Robot API. This is useful for setups where Robot servers are connected via vSwitch and only the service controller is needed. Existing behavior is unchanged when credentials are provided. Partial credentials (only user or only password) are rejected during validation. Fixes: #1162
1 parent d889c62 commit 7878669

File tree

7 files changed

+203
-30
lines changed

7 files changed

+203
-30
lines changed

docs/explanation/robot-support.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,20 @@ If you absolutely need to use different names in Robot & Hostname, you can also
5454

5555
## Credentials
5656

57+
Robot API credentials (`ROBOT_USER` / `ROBOT_PASSWORD`) are **optional**. They control which features are available:
58+
59+
### With Credentials
60+
61+
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.
62+
5763
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.
64+
65+
### Without Credentials
66+
67+
When `robot.enabled` is set to `true` but no `ROBOT_USER` / `ROBOT_PASSWORD` are provided, the HCCM operates in a limited mode:
68+
69+
- **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.
70+
- **Node Controller**: Must be disabled (`--controllers=*,-cloud-node,-cloud-node-lifecycle`), as it requires the Robot API to fetch server metadata.
71+
- **Node Lifecycle Controller**: Must be disabled (same flag as above).
72+
73+
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.

docs/guides/robot/private-networks.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ As a result, the annotation `load-balancer.hetzner.cloud/use-private-ip` can be
66

77
## Prerequisite
88

9-
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.
9+
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.
10+
11+
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.
1012

1113
## Configuration
1214

hcloud/cloud.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func NewCloud(cidr string) (cloudprovider.Interface, error) {
9696
metadataClient := metadata.NewClient()
9797

9898
var robotClient robot.Client
99-
if cfg.Robot.Enabled {
99+
if cfg.Robot.Enabled && cfg.Robot.User != "" && cfg.Robot.Password != "" {
100100
c := hrobot.NewBasicAuthClientWithCustomHttpClient(
101101
cfg.Robot.User,
102102
cfg.Robot.Password,

internal/config/config.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,11 +289,15 @@ func (c HCCMConfiguration) Validate() (err error) {
289289
}
290290

291291
if c.Robot.Enabled {
292-
if c.Robot.User == "" {
293-
errs = append(errs, fmt.Errorf("environment variable %q is required if Robot support is enabled", robotUser))
292+
// Robot credentials are optional. When only using the service
293+
// controller with IP-based LB targets, the node's InternalIP from
294+
// Kubernetes is sufficient and no Robot API access is needed.
295+
if (c.Robot.User == "") != (c.Robot.Password == "") {
296+
// Partial credentials are likely a misconfiguration.
297+
errs = append(errs, fmt.Errorf("both %q and %q must be provided, or neither", robotUser, robotPassword))
294298
}
295-
if c.Robot.Password == "" {
296-
errs = append(errs, fmt.Errorf("environment variable %q is required if Robot support is enabled", robotPassword))
299+
if c.Robot.User == "" && c.Robot.Password == "" {
300+
klog.Infof("Robot support enabled without credentials. Some features might not work as expected.")
297301
}
298302

299303
if c.Route.Enabled {

internal/config/config_test.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ func TestHCCMConfiguration_Validate(t *testing.T) {
484484
wantErr: errors.New("invalid value for \"HCLOUD_LOAD_BALANCERS_ALGORITHM_TYPE\": unsupported value \"invalid\""),
485485
},
486486
{
487-
name: "robot enabled but missing credentials",
487+
name: "robot enabled without credentials (valid)",
488488
fields: fields{
489489
HCloudClient: HCloudClientConfiguration{Token: "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq"},
490490
Instance: InstanceConfiguration{AddressFamily: AddressFamilyIPv4},
@@ -493,8 +493,35 @@ func TestHCCMConfiguration_Validate(t *testing.T) {
493493
Enabled: true,
494494
},
495495
},
496-
wantErr: errors.New(`environment variable "ROBOT_USER" is required if Robot support is enabled
497-
environment variable "ROBOT_PASSWORD" is required if Robot support is enabled`),
496+
wantErr: nil,
497+
},
498+
{
499+
name: "robot enabled with partial credentials (only user)",
500+
fields: fields{
501+
HCloudClient: HCloudClientConfiguration{Token: "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq"},
502+
Instance: InstanceConfiguration{AddressFamily: AddressFamilyIPv4},
503+
504+
Robot: RobotConfiguration{
505+
Enabled: true,
506+
User: "foo",
507+
Password: "",
508+
},
509+
},
510+
wantErr: errors.New(`both "ROBOT_USER" and "ROBOT_PASSWORD" must be provided, or neither`),
511+
},
512+
{
513+
name: "robot enabled with partial credentials (only password)",
514+
fields: fields{
515+
HCloudClient: HCloudClientConfiguration{Token: "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq"},
516+
Instance: InstanceConfiguration{AddressFamily: AddressFamilyIPv4},
517+
518+
Robot: RobotConfiguration{
519+
Enabled: true,
520+
User: "",
521+
Password: "bar",
522+
},
523+
},
524+
wantErr: errors.New(`both "ROBOT_USER" and "ROBOT_PASSWORD" must be provided, or neither`),
498525
},
499526
{
500527
name: "robot & routes activated",

internal/hcops/load_balancer.go

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -680,44 +680,76 @@ func (l *LoadBalancerOps) ReconcileHCLBTargets(
680680
// List all robot servers to check whether the ip targets of the load balancer
681681
// correspond to a dedicated server
682682

683-
if l.Cfg.Robot.Enabled {
683+
useRobotAPI := l.Cfg.Robot.Enabled && l.RobotClient != nil
684+
useRobotInternalIPs := l.Cfg.Robot.Enabled && l.RobotClient == nil && privateIPEnabled
685+
686+
// Use Robot API to either fetch ExternalIP or use InternalIP from Node objects
687+
if useRobotAPI {
684688
dedicatedServers, err := l.RobotClient.ServerGetList()
685689
if err != nil {
686690
return changed, fmt.Errorf("%s: failed to get list of dedicated servers: %w", op, err)
687691
}
688692

689693
for _, s := range dedicatedServers {
690-
if privateIPEnabled {
691-
node, ok := k8sNodes[int64(s.ServerNumber)]
692-
if !ok {
693-
continue
694-
}
694+
// Set ExternalIP as Load Balancer target
695+
robotIPsToIDs[s.ServerIP] = s.ServerNumber
696+
robotIDToIPv4[s.ServerNumber] = s.ServerIP
695697

696-
internalIP := getNodeInternalIP(node)
697-
if internalIP != "" {
698-
robotIPsToIDs[internalIP] = s.ServerNumber
699-
robotIDToIPv4[s.ServerNumber] = internalIP
700-
continue
701-
}
698+
// If user does not want private IPs we can skip this part
699+
if !privateIPEnabled {
700+
continue
701+
}
702702

703-
klog.Warningf(
703+
node, ok := k8sNodes[int64(s.ServerNumber)]
704+
if !ok {
705+
continue
706+
}
707+
708+
// Check if InternalIP is set at Node object
709+
internalIP := getNodeInternalIP(node)
710+
if internalIP == "" {
711+
warnMsg := fmt.Sprintf(
704712
"%s: load balancer %s has set `use-private-ip: true`, but no InternalIP found for node %s. Continuing with ExternalIP.",
705713
op,
706714
svc.Name,
707715
node.Name,
708716
)
709-
l.Recorder.Eventf(
710-
svc,
711-
corev1.EventTypeWarning,
712-
"InternalIPNotConfigured",
713-
"%s: load balancer has set `use-private-ip: true`, but no InternalIP found for node %s. Continuing with ExternalIP.",
714-
op,
717+
klog.Warning(warnMsg)
718+
l.Recorder.Eventf(svc, corev1.EventTypeWarning, "InternalIPNotConfigured", warnMsg)
719+
continue
720+
}
721+
722+
// Overwrite ExternalIP with InternalIP
723+
robotIPsToIDs[internalIP] = s.ServerNumber
724+
robotIDToIPv4[s.ServerNumber] = internalIP
725+
}
726+
}
727+
728+
// Use InternalIPs for Robot servers without querying the API
729+
if useRobotInternalIPs {
730+
// No Robot client: derive IP mapping directly from Kubernetes Node
731+
// objects. This works when the node's InternalIP is the correct
732+
// target (e.g. vSwitch private IP).
733+
for id := range k8sNodeIDsRobot {
734+
node, ok := k8sNodes[int64(id)]
735+
if !ok {
736+
continue
737+
}
738+
739+
internalIP := getNodeInternalIP(node)
740+
if internalIP == "" {
741+
warnMsg := fmt.Sprintf(
742+
"no InternalIP found for Robot node %s (id=%d), cannot add as LB target without Robot credentials; skipping",
715743
node.Name,
744+
id,
716745
)
746+
klog.Warning(warnMsg)
747+
l.Recorder.Eventf(svc, corev1.EventTypeWarning, "InternalIPNotConfigured", warnMsg)
748+
continue
717749
}
718750

719-
robotIPsToIDs[s.ServerIP] = s.ServerNumber
720-
robotIDToIPv4[s.ServerNumber] = s.ServerIP
751+
robotIPsToIDs[internalIP] = id
752+
robotIDToIPv4[id] = internalIP
721753
}
722754
}
723755

internal/hcops/load_balancer_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,6 +1588,98 @@ func TestLoadBalancerOps_ReconcileHCLBTargets(t *testing.T) {
15881588
assert.True(t, changed)
15891589
},
15901590
},
1591+
{
1592+
name: "robot enabled without credentials uses node InternalIP for IP targets",
1593+
k8sNodes: []*corev1.Node{
1594+
{Spec: corev1.NodeSpec{ProviderID: "hcloud://1"}},
1595+
{
1596+
Spec: corev1.NodeSpec{ProviderID: "hrobot://3"},
1597+
ObjectMeta: metav1.ObjectMeta{Name: "robot-3"},
1598+
Status: corev1.NodeStatus{
1599+
Addresses: []corev1.NodeAddress{
1600+
{Type: corev1.NodeInternalIP, Address: "10.0.1.10"},
1601+
},
1602+
},
1603+
},
1604+
{
1605+
Spec: corev1.NodeSpec{ProviderID: "hrobot://4"},
1606+
ObjectMeta: metav1.ObjectMeta{Name: "robot-4"},
1607+
Status: corev1.NodeStatus{
1608+
Addresses: []corev1.NodeAddress{
1609+
{Type: corev1.NodeInternalIP, Address: "10.0.1.11"},
1610+
},
1611+
},
1612+
},
1613+
},
1614+
initialLB: &hcloud.LoadBalancer{
1615+
ID: 1,
1616+
LoadBalancerType: &hcloud.LoadBalancerType{
1617+
MaxTargets: 25,
1618+
},
1619+
},
1620+
cfg: config.HCCMConfiguration{
1621+
LoadBalancer: config.LoadBalancerConfiguration{
1622+
IPv6Enabled: true,
1623+
PrivateIPEnabled: true,
1624+
},
1625+
Robot: config.RobotConfiguration{Enabled: true},
1626+
},
1627+
mock: func(_ *testing.T, tt *LBReconcilementTestCase) {
1628+
// Set RobotClient to nil to simulate missing credentials
1629+
tt.fx.LBOps.RobotClient = nil
1630+
tt.fx.LBOps.NetworkID = 4711
1631+
1632+
opts := hcloud.LoadBalancerAddServerTargetOpts{Server: &hcloud.Server{ID: 1}, UsePrivateIP: hcloud.Ptr(true)}
1633+
action := tt.fx.MockAddServerTarget(tt.initialLB, opts, nil)
1634+
tt.fx.ActionClient.On("WaitFor", tt.fx.Ctx, action).Return(nil)
1635+
1636+
optsIP := hcloud.LoadBalancerAddIPTargetOpts{IP: net.ParseIP("10.0.1.10")}
1637+
action = tt.fx.MockAddIPTarget(tt.initialLB, optsIP, nil)
1638+
tt.fx.ActionClient.On("WaitFor", tt.fx.Ctx, action).Return(nil)
1639+
1640+
optsIP = hcloud.LoadBalancerAddIPTargetOpts{IP: net.ParseIP("10.0.1.11")}
1641+
action = tt.fx.MockAddIPTarget(tt.initialLB, optsIP, nil)
1642+
tt.fx.ActionClient.On("WaitFor", tt.fx.Ctx, action).Return(nil)
1643+
},
1644+
perform: func(t *testing.T, tt *LBReconcilementTestCase) {
1645+
changed, err := tt.fx.LBOps.ReconcileHCLBTargets(tt.fx.Ctx, tt.initialLB, tt.service, tt.k8sNodes)
1646+
assert.NoError(t, err)
1647+
assert.True(t, changed)
1648+
},
1649+
},
1650+
{
1651+
name: "robot enabled without credentials skips nodes without InternalIP",
1652+
k8sNodes: []*corev1.Node{
1653+
{
1654+
Spec: corev1.NodeSpec{ProviderID: "hrobot://5"},
1655+
ObjectMeta: metav1.ObjectMeta{Name: "robot-no-ip"},
1656+
Status: corev1.NodeStatus{},
1657+
},
1658+
},
1659+
initialLB: &hcloud.LoadBalancer{
1660+
ID: 1,
1661+
LoadBalancerType: &hcloud.LoadBalancerType{
1662+
MaxTargets: 25,
1663+
},
1664+
},
1665+
cfg: config.HCCMConfiguration{
1666+
LoadBalancer: config.LoadBalancerConfiguration{
1667+
IPv6Enabled: true,
1668+
PrivateIPEnabled: true,
1669+
},
1670+
Robot: config.RobotConfiguration{Enabled: true},
1671+
},
1672+
mock: func(_ *testing.T, tt *LBReconcilementTestCase) {
1673+
// Set RobotClient to nil to simulate missing credentials
1674+
tt.fx.LBOps.RobotClient = nil
1675+
tt.fx.LBOps.NetworkID = 4711
1676+
},
1677+
perform: func(t *testing.T, tt *LBReconcilementTestCase) {
1678+
changed, err := tt.fx.LBOps.ReconcileHCLBTargets(tt.fx.Ctx, tt.initialLB, tt.service, tt.k8sNodes)
1679+
assert.NoError(t, err)
1680+
assert.False(t, changed)
1681+
},
1682+
},
15911683
}
15921684

15931685
for _, tt := range tests {

0 commit comments

Comments
 (0)