Added HCP (Hosted Control Plane) mode (#876)

* Add HCP (Hosted Control Plane) support

Introduce hosted control plane mode for k3k virtual clusters, including
API types, controller logic, server endpoint handling, CLI flags,
CRD updates, kubeconfig generation, and examples.

Co-Authored-By: RuFlo <ruv@ruv.net>

* removed hcpRegitration command

added HCP conformance tests

warning for hcp

fix multi-VM HCP conformance test networking

  Both QEMU workers booted with `-net user` and ended up registering the
  same InternalIP (10.0.2.15) because each VM gets its own isolated NAT
  slirp. Flannel propagated this to `public-ip` on both nodes, so VXLAN
  could not tunnel between workers and any cross-node pod traffic broke
  (89 failed / 335 passed of 424 conformance specs).

  Replace user-mode networking with a Linux bridge (k3kbr0,
  192.168.100.0/24) and one TAP device per VM, so the two workers share
  an L2 segment with unique routable IPs. NAT outbound from the bridge
  keeps internet access working for image pulls.

  Also set unique hostnames via cloud-init (worker-1/worker-2) and drop
  the `--node-name` flag from INSTALL_K3S_EXEC, since k3s now picks the
  correct node name from the OS hostname on its own.

  Bump hydrophone back to `--parallel 4` to match the single-VM job
  (parallelism was reduced earlier when the failure was thought to be
  resource-related).

added HCP print command

updated crds

adding e2e tests

Refactor selectNonLoopbackSAN function to accept SANs directly and update related logic in ensureHCPRegistration

* Update agent flag validation and enhance ingress host check with a warning log

Refactor descriptions for cluster provisioning mode and role in CRDs and documentation

Refactor logging in ServerURL function to use controller-runtime logger

Rename selectNonLoopbackSAN to findNonLoopbackSAN for clarity and update references

Refactor ServerURL function and related code to remove unused parameters and improve clarity

Remove unused imports from kubeconfig.go to improve code clarity

* suggested changes

* fix comment

* fix test

---------

Co-authored-by: jpgouin <jeanphilippe.gouin@suse.com>
Co-authored-by: RuFlo <ruv@ruv.net>
This commit is contained in:
Enrico Candino
2026-07-07 14:12:19 +02:00
committed by GitHub
co-authored by RuFlo jpgouin
parent 33216d49ae
commit 39cc69f3e3
28 changed files with 1355 additions and 212 deletions
+21 -2
View File
@@ -11,7 +11,6 @@ import (
"github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1"
"github.com/rancher/k3k/pkg/controller"
"github.com/rancher/k3k/pkg/controller/cluster/agent"
)
// serverConfig are few options from k3s server options that will
@@ -23,6 +22,7 @@ type serverConfig struct {
DisableAgent bool `yaml:"disable-agent,omitempty"`
Disable []string `yaml:"disable,omitempty"`
EgressSelectorMode string `yaml:"egress-selector-mode,omitempty"`
KubeApiServerArg []string `yaml:"kube-apiserver-arg,omitempty"`
Server string `yaml:"server,omitempty"`
ServiceCIDR string `yaml:"service-cidr,omitempty"`
TLSSAN []string `yaml:"tls-san,omitempty"`
@@ -77,10 +77,29 @@ func buildServerConfig(cluster *v1beta1.Cluster, initServer bool, serviceIP, tok
serverConfig.Server = "https://" + serviceIP
}
if cluster.Spec.Mode != agent.VirtualNodeMode {
// shared and hcp modes both run K3s with --disable-agent (agentless server).
switch cluster.Spec.Mode {
case "", v1beta1.SharedClusterMode:
serverConfig.DisableAgent = true
serverConfig.EgressSelectorMode = "disabled"
serverConfig.Disable = []string{"servicelb", "traefik", "metrics-server", "local-storage"}
case v1beta1.HCPClusterMode:
serverConfig.DisableAgent = true
// Tunnel apiserver egress through the k3s-agent WebSocket: the
// apiserver has no route to the virtual cluster's pod CIDR and
// bypasses kube-proxy when dialing pod IPs (webhooks, log/exec).
// "cluster" is the only safe mode — "agent" lets pod dials go
// direct (no route, fails); "pod" only permits pod IPs the agent
// has already watched, so a newly-created pod's IP is rejected
// and tears down the remotedialer session, making kubelet streams
// flaky. See k3s pkg/agent/tunnel/tunnel.go.
serverConfig.EgressSelectorMode = "cluster"
// Disable the apiserver's built-in endpoint reconciler so K3k can
// own default/kubernetes Endpoints and point it at the externally
// reachable host:port (NodePort / LB / Ingress).
serverConfig.KubeApiServerArg = append(serverConfig.KubeApiServerArg, "endpoint-reconciler-type=none")
case v1beta1.VirtualClusterMode:
// no extra config for virtual mode
}
return serverConfig
+138
View File
@@ -0,0 +1,138 @@
package server
import (
"context"
"fmt"
"net"
"net/url"
"slices"
"strconv"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1"
)
// ServerURL generates the API server URL for the kubeconfig based on the service configuration.
//
// It handles internal vs external access patterns:
// - Internal access (hostServerIP == service.ClusterIP): uses the ClusterIP for direct pod-to-pod communication
// - External access (hostServerIP != service.ClusterIP): uses the appropriate external endpoint based on the service type
//
// Service type handling:
// - ClusterIP: uses service.Spec.ClusterIP (internal-only)
// - NodePort: uses hostServerIP:NodePort for external access, ClusterIP:Port for internal access
// - LoadBalancer: uses the LoadBalancer ingress IP, falling back to its hostname
// - Ingress (if configured): takes precedence over the service-based URL
//
// The hostServerIP parameter determines the access pattern:
// - Controller reconciliation: passes service.Spec.ClusterIP → internal access
// - CLI kubeconfig export: passes the external host → external access
func ServerURL(ctx context.Context, c client.Client, cluster *v1beta1.Cluster, hostServerIP string) (*url.URL, error) {
log := ctrl.LoggerFrom(ctx)
key := types.NamespacedName{
Name: ServiceName(cluster.Name),
Namespace: cluster.Namespace,
}
// Check if ingress is configured
if cluster.Spec.Expose != nil && cluster.Spec.Expose.Ingress != nil {
key := types.NamespacedName{
Name: IngressName(cluster.Name),
Namespace: cluster.Namespace,
}
var k3kIngress networkingv1.Ingress
if err := c.Get(ctx, key, &k3kIngress); err != nil {
return nil, err
}
if len(k3kIngress.Spec.Rules) > 0 && k3kIngress.Spec.Rules[0].Host != "" {
return &url.URL{
Scheme: "https",
Host: k3kIngress.Spec.Rules[0].Host,
}, nil
}
log.V(1).Info("Ingress has no rule with a host set, falling back to the service URL.")
}
// Fall back to Service-based URL
var k3kService corev1.Service
if err := c.Get(ctx, key, &k3kService); err != nil {
return nil, err
}
// init to hostServerIP and 443 port
host := hostServerIP
port := int32(443)
// Use service port as default if available
if len(k3kService.Spec.Ports) > 0 {
port = k3kService.Spec.Ports[0].Port
}
// Handle each service type separately
switch k3kService.Spec.Type {
case corev1.ServiceTypeClusterIP:
host = k3kService.Spec.ClusterIP
case corev1.ServiceTypeNodePort:
// Only use NodePort if hostServerIP is NOT the ClusterIP
// If hostServerIP == ClusterIP, this is an internal connection, use ClusterIP
if hostServerIP != k3kService.Spec.ClusterIP {
if len(k3kService.Spec.Ports) > 0 {
port = k3kService.Spec.Ports[0].NodePort
}
} else {
// Internal connection: use ClusterIP
host = k3kService.Spec.ClusterIP
}
case corev1.ServiceTypeLoadBalancer:
if len(k3kService.Status.LoadBalancer.Ingress) > 0 {
ingress := k3kService.Status.LoadBalancer.Ingress[0]
switch {
case ingress.IP != "":
host = ingress.IP
case ingress.Hostname != "":
host = ingress.Hostname
default:
log.V(1).Info("No usable ingress address found in LoadBalancer service.")
}
}
}
if !slices.Contains(cluster.Status.TLSSANs, host) {
log.V(1).Info(fmt.Sprintf("IP %s not in tlsSANs.", host))
if len(cluster.Spec.TLSSANs) > 0 {
log.V(1).Info("Using the first TLS SAN in the spec as a fallback: " + cluster.Spec.TLSSANs[0])
host = cluster.Spec.TLSSANs[0]
} else if len(cluster.Status.TLSSANs) > 0 {
log.V(1).Info("No explicit tlsSANs specified. Trying to use the first TLS SAN in the status: " + cluster.Status.TLSSANs[0])
host = cluster.Status.TLSSANs[0]
} else {
log.V(1).Info("IP not found in tlsSANs. This could cause issue with the certificate validation.")
}
}
// Build URL with port only if not the default HTTPS port
if port != int32(443) {
host = net.JoinHostPort(host, strconv.Itoa(int(port)))
}
return &url.URL{
Scheme: "https",
Host: host,
}, nil
}
@@ -0,0 +1,422 @@
package server_test
// This file pins the behavior of ServerURL() across the different
// service types (ClusterIP, NodePort, LoadBalancer), Ingress exposure, and the
// TLS SAN fallback logic.
//
// The behaviors asserted here are:
//
// 1. ClusterIP port handling:
// Uses Spec.ClusterIP with the service's declared port (Spec.Ports[0].Port).
// The port suffix is omitted only when it is the default 443.
//
// 2. NodePort access:
// Internal/external aware. When hostServerIP == ClusterIP the connection is
// treated as internal and uses ClusterIP:Port; otherwise it uses
// hostServerIP:NodePort.
//
// 3. LoadBalancer hostname support:
// Reads Status.LoadBalancer.Ingress[0], preferring IP and falling back to
// Hostname, so a hostname-only ingress produces a valid URL.
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1"
"github.com/rancher/k3k/pkg/controller/cluster/server"
)
// TestURLGeneration_ClusterIP tests URL generation for ClusterIP service type
func TestURLGeneration_ClusterIP(t *testing.T) {
tests := []struct {
name string
hostServerIP string
servicePort int32
expectedURL string
}{
{
name: "ClusterIP with default port 443",
hostServerIP: "10.0.0.1",
servicePort: 443,
expectedURL: "https://10.43.0.100",
},
{
// the service's declared port is used, so it appears in the URL
name: "ClusterIP uses custom service port",
hostServerIP: "10.0.0.1",
servicePort: 8443,
expectedURL: "https://10.43.0.100:8443",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cluster, svc := createClusterIPService("test-cluster", "default", tt.servicePort)
fakeClient := createFakeClient(t, cluster, svc)
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
require.NoError(t, err)
assert.Equal(t, tt.expectedURL, url.String())
})
}
}
// TestURLGeneration_NodePort tests URL generation for NodePort service type
func TestURLGeneration_NodePort(t *testing.T) {
tests := []struct {
name string
hostServerIP string
nodePort int32
servicePort int32
expectedURL string
}{
{
name: "NodePort external access",
hostServerIP: "192.168.1.100",
nodePort: 30443,
servicePort: 443,
expectedURL: "https://192.168.1.100:30443",
},
{
// internal access: hostServerIP == ClusterIP, so ClusterIP:Port is used
// instead of the NodePort (port 443 is the default, so it is omitted)
name: "NodePort internal access",
hostServerIP: "10.43.0.100", // same as ClusterIP
nodePort: 30443,
servicePort: 443,
expectedURL: "https://10.43.0.100",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cluster, svc := createNodePortService("test-cluster", "default", tt.servicePort, tt.nodePort)
fakeClient := createFakeClient(t, cluster, svc)
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
require.NoError(t, err)
assert.Equal(t, tt.expectedURL, url.String())
})
}
}
// TestURLGeneration_LoadBalancer tests URL generation for LoadBalancer service type
func TestURLGeneration_LoadBalancer(t *testing.T) {
tests := []struct {
name string
hostServerIP string
lbIP string
lbHostname string
servicePort int32
expectedURL string
}{
{
name: "LoadBalancer with IP ingress",
hostServerIP: "10.0.0.1",
lbIP: "203.0.113.10",
lbHostname: "",
servicePort: 443,
expectedURL: "https://203.0.113.10",
},
{
// the Hostname field is used as a fallback when no IP is present
name: "LoadBalancer with hostname ingress",
hostServerIP: "10.0.0.1",
lbIP: "",
lbHostname: "cluster.example.com",
servicePort: 443,
expectedURL: "https://cluster.example.com",
},
{
name: "LoadBalancer with custom port",
hostServerIP: "10.0.0.1",
lbIP: "203.0.113.10",
lbHostname: "",
servicePort: 8443,
expectedURL: "https://203.0.113.10:8443",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cluster, svc := createLoadBalancerService("test-cluster", "default", tt.servicePort, tt.lbIP, tt.lbHostname)
fakeClient := createFakeClient(t, cluster, svc)
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
require.NoError(t, err)
assert.Equal(t, tt.expectedURL, url.String())
})
}
}
// TestURLGeneration_Ingress tests URL generation when Ingress is configured
func TestURLGeneration_Ingress(t *testing.T) {
tests := []struct {
name string
ingressHost string
expectedURL string
}{
{
name: "Ingress with host",
ingressHost: "api.example.com",
expectedURL: "https://api.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cluster, svc, ingress := createIngressService("test-cluster", "default", tt.ingressHost)
fakeClient := createFakeClient(t, cluster, svc, ingress)
url, err := server.ServerURL(t.Context(), fakeClient, cluster, "10.0.0.1")
require.NoError(t, err)
assert.Equal(t, tt.expectedURL, url.String())
})
}
}
// TestURLGeneration_TLSSANs tests TLS SAN fallback logic
func TestURLGeneration_TLSSANs(t *testing.T) {
tests := []struct {
name string
clusterIP string
hostServerIP string
specTLSSANs []string
statusTLSSANs []string
expectedURL string
}{
{
name: "IP in status TLSSANs",
clusterIP: "10.43.0.100",
hostServerIP: "10.43.0.100",
specTLSSANs: nil,
statusTLSSANs: []string{"10.43.0.100", "cluster.local"},
expectedURL: "https://10.43.0.100",
},
{
name: "IP not in status, fallback to spec",
clusterIP: "10.43.0.100",
hostServerIP: "10.43.0.100",
specTLSSANs: []string{"custom.example.com"},
statusTLSSANs: []string{"other.example.com"},
expectedURL: "https://custom.example.com",
},
{
name: "IP not in spec, fallback to status",
clusterIP: "10.43.0.100",
hostServerIP: "10.43.0.100",
specTLSSANs: nil,
statusTLSSANs: []string{"fallback.example.com"},
expectedURL: "https://fallback.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cluster := &v1beta1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cluster",
Namespace: "default",
},
Spec: v1beta1.ClusterSpec{
TLSSANs: tt.specTLSSANs,
},
Status: v1beta1.ClusterStatus{
TLSSANs: tt.statusTLSSANs,
},
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: server.ServiceName("test-cluster"),
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
ClusterIP: tt.clusterIP,
Ports: []corev1.ServicePort{
{Port: 443},
},
},
}
fakeClient := createFakeClient(t, cluster, svc)
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
require.NoError(t, err)
assert.Equal(t, tt.expectedURL, url.String())
})
}
}
// Helper functions to create test objects
func createClusterIPService(clusterName, namespace string, port int32) (*v1beta1.Cluster, *corev1.Service) {
cluster := &v1beta1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName,
Namespace: namespace,
},
Status: v1beta1.ClusterStatus{
TLSSANs: []string{"10.43.0.100"},
},
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: server.ServiceName(clusterName),
Namespace: namespace,
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
ClusterIP: "10.43.0.100",
Ports: []corev1.ServicePort{
{Port: port},
},
},
}
return cluster, svc
}
func createNodePortService(clusterName, namespace string, port, nodePort int32) (*v1beta1.Cluster, *corev1.Service) {
cluster := &v1beta1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName,
Namespace: namespace,
},
Status: v1beta1.ClusterStatus{
TLSSANs: []string{"10.43.0.100", "192.168.1.100"},
},
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: server.ServiceName(clusterName),
Namespace: namespace,
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeNodePort,
ClusterIP: "10.43.0.100",
Ports: []corev1.ServicePort{
{Port: port, NodePort: nodePort},
},
},
}
return cluster, svc
}
func createLoadBalancerService(clusterName, namespace string, port int32, lbIP, lbHostname string) (*v1beta1.Cluster, *corev1.Service) {
cluster := &v1beta1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName,
Namespace: namespace,
},
Status: v1beta1.ClusterStatus{
TLSSANs: []string{"10.43.0.100", lbIP, lbHostname},
},
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: server.ServiceName(clusterName),
Namespace: namespace,
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeLoadBalancer,
ClusterIP: "10.43.0.100",
Ports: []corev1.ServicePort{
{Port: port},
},
},
Status: corev1.ServiceStatus{
LoadBalancer: corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{
{IP: lbIP, Hostname: lbHostname},
},
},
},
}
return cluster, svc
}
func createIngressService(clusterName, namespace, ingressHost string) (*v1beta1.Cluster, *corev1.Service, *networkingv1.Ingress) {
cluster := &v1beta1.Cluster{
ObjectMeta: metav1.ObjectMeta{
Name: clusterName,
Namespace: namespace,
},
Spec: v1beta1.ClusterSpec{
Expose: &v1beta1.ExposeConfig{
Ingress: &v1beta1.IngressConfig{},
},
},
Status: v1beta1.ClusterStatus{
TLSSANs: []string{"10.43.0.100", ingressHost},
},
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: server.ServiceName(clusterName),
Namespace: namespace,
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
ClusterIP: "10.43.0.100",
Ports: []corev1.ServicePort{
{Port: 443},
},
},
}
ingress := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: server.IngressName(clusterName),
Namespace: namespace,
},
Spec: networkingv1.IngressSpec{
Rules: []networkingv1.IngressRule{
{Host: ingressHost},
},
},
}
return cluster, svc, ingress
}
func createFakeClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
scheme := runtime.NewScheme()
schemeBuilder := runtime.NewSchemeBuilder(
corev1.AddToScheme,
networkingv1.AddToScheme,
v1beta1.AddToScheme,
)
err := schemeBuilder.AddToScheme(scheme)
require.NoError(t, err)
return fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(objs...).
Build()
}
+4 -3
View File
@@ -22,7 +22,6 @@ import (
"github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1"
"github.com/rancher/k3k/pkg/controller"
"github.com/rancher/k3k/pkg/controller/cluster/agent"
"github.com/rancher/k3k/pkg/controller/cluster/mounts"
)
@@ -250,8 +249,10 @@ func (s *Server) podSpec(ctx context.Context, image, name string, persistent boo
},
},
}
// start the pod unprivileged in shared mode
if s.mode == agent.VirtualNodeMode {
// virtual mode runs an embedded kubelet inside the server pod and therefore
// requires Privileged. shared and hcp modes are agentless (no kubelet) and
// run unprivileged.
if s.mode == string(v1beta1.VirtualClusterMode) {
podSpec.Containers[0].SecurityContext = &corev1.SecurityContext{
Privileged: ptr.To(true),
}
+3 -2
View File
@@ -19,7 +19,7 @@ safe_mode() {
CURRENT_IP=$(cat /var/lib/rancher/k3s/k3k-node-ip)
fi
if [ -z "$CURRENT_IP" ] || [ "$CURRENT_IP" = "$POD_IP" ] || [ {{.K3K_MODE}} != "virtual" ]; then
if [ -z "$CURRENT_IP" ] || [ "$CURRENT_IP" = "$POD_IP" ] || [ "{{.K3K_MODE}}" != "virtual" ]; then
return
fi
@@ -116,7 +116,8 @@ configure_cgroups() {
fi
# only configure the cgroups if the runtime used is the default and the mode is virtual
if [ -n "$runtime_class" ] || [ "{{.K3K_MODE}}" != "virtual" ]; then
# shared and hcp run agentless (no kubelet) and don't need cgroup overrides.
if [ -n "$runtime_class" ] || [ "{{.K3K_MODE}}" != "virtual" ]; then
return
fi