mirror of
https://github.com/rancher/k3k.git
synced 2026-08-19 04:16:16 +00:00
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:
co-authored by
RuFlo
jpgouin
parent
33216d49ae
commit
39cc69f3e3
@@ -401,9 +401,10 @@ func (c *ClusterReconciler) reconcile(ctx context.Context, cluster *v1beta1.Clus
|
||||
}
|
||||
}
|
||||
|
||||
// in virtual mode assign a default serviceCIDR
|
||||
if cluster.Spec.Mode == v1beta1.VirtualClusterMode {
|
||||
log.V(1).Info("assign default service CIDR for virtual mode")
|
||||
// virtual and hcp modes both run a self-contained K3s control plane and
|
||||
// need their own pod/service CIDR independent of the host cluster.
|
||||
if cluster.Spec.Mode == v1beta1.VirtualClusterMode || cluster.Spec.Mode == v1beta1.HCPClusterMode {
|
||||
log.V(1).Info("assign default service CIDR", "mode", cluster.Spec.Mode)
|
||||
|
||||
cluster.Status.ServiceCIDR = defaultVirtualServiceCIDR
|
||||
}
|
||||
@@ -448,6 +449,23 @@ func (c *ClusterReconciler) reconcile(ctx context.Context, cluster *v1beta1.Clus
|
||||
return err
|
||||
}
|
||||
|
||||
// In hcp mode, validate that the cluster has an externally-routable
|
||||
// API server endpoint (NodePort / LoadBalancer / Ingress) so external
|
||||
// workers can join. The join command itself is printed by the CLI
|
||||
// (`k3kcli cluster create` / `k3kcli kubeconfig generate`).
|
||||
// We also own the default/kubernetes Endpoints/EndpointSlice inside the
|
||||
// virtual cluster (the apiserver reconciler is disabled for HCP) so
|
||||
// external-node pods can reach the in-cluster apiserver ClusterIP.
|
||||
if cluster.Spec.Mode == v1beta1.HCPClusterMode {
|
||||
if err := c.ensureHCPKubernetesEndpointSlice(ctx, cluster); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.ensureHCPKubernetesEndpoints(ctx, cluster); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Important: if you need to call the Server API of the Virtual Cluster
|
||||
// this needs to be done AFTER he kubeconfig has been generated
|
||||
|
||||
@@ -909,6 +927,14 @@ func (c *ClusterReconciler) bindClusterRoles(ctx context.Context, cluster *v1bet
|
||||
}
|
||||
|
||||
func (c *ClusterReconciler) ensureAgent(ctx context.Context, cluster *v1beta1.Cluster, serviceIP, token string) error {
|
||||
// hcp mode is BYO-node by design: external (out-of-host-cluster) nodes
|
||||
// join using the standard K3s installer command, which is printed by
|
||||
// `k3kcli cluster create` / `k3kcli kubeconfig generate`. k3k therefore
|
||||
// does not provision any agent pods on the host cluster.
|
||||
if cluster.Spec.Mode == v1beta1.HCPClusterMode {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := agent.NewConfig(cluster, c.Client)
|
||||
|
||||
var agentEnsurer agent.ResourceEnsurer
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
|
||||
"github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1"
|
||||
"github.com/rancher/k3k/pkg/controller/cluster/server"
|
||||
)
|
||||
|
||||
// findNonLoopbackSAN returns the first non-loopback address from the given
|
||||
// TLS SANs. Returns empty string if none is found.
|
||||
func findNonLoopbackSAN(sans []string) string {
|
||||
for _, san := range sans {
|
||||
if san == "localhost" {
|
||||
continue
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(san); ip != nil && ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
return san
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ensureHCPKubernetesEndpointSlice maintains the default/kubernetes Service
|
||||
// EndpointSlice inside the virtual cluster, pointing it at the externally
|
||||
// reachable host:port (NodePort / LoadBalancer / Ingress) so that pods
|
||||
// scheduled on external worker nodes can reach the in-cluster apiserver
|
||||
// ClusterIP.
|
||||
//
|
||||
// Background: the kube-apiserver normally reconciles default/kubernetes
|
||||
// EndpointSlice to its own --advertise-address:--secure-port (the host-cluster
|
||||
// pod IP and 6443). External worker nodes have no route to the host-cluster
|
||||
// pod CIDR, so kube-proxy DNAT to that endpoint fails. We disable the
|
||||
// apiserver reconciler in HCP mode (see serverOptions) and own this
|
||||
// EndpointSlice object instead.
|
||||
func (c *ClusterReconciler) ensureHCPKubernetesEndpointSlice(ctx context.Context, cluster *v1beta1.Cluster) error {
|
||||
log := ctrl.LoggerFrom(ctx)
|
||||
|
||||
url, err := server.ServerURL(ctx, c.Client, cluster, findNonLoopbackSAN(cluster.Spec.TLSSANs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(url.Port())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addr, err := hcpEndpointAddress(ctx, url.Hostname())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var addressType discoveryv1.AddressType
|
||||
|
||||
if ip := net.ParseIP(addr.IP); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
addressType = discoveryv1.AddressTypeIPv4
|
||||
} else {
|
||||
addressType = discoveryv1.AddressTypeIPv6
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid IP address %q", addr.IP)
|
||||
}
|
||||
|
||||
virtClient, err := newVirtualClient(ctx, c.Client, cluster.Name, cluster.Namespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating virtual cluster client: %w", err)
|
||||
}
|
||||
|
||||
endpointSlice := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "kubernetes",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = controllerutil.CreateOrUpdate(ctx, virtClient, endpointSlice, func() error {
|
||||
if endpointSlice.Labels == nil {
|
||||
endpointSlice.Labels = make(map[string]string)
|
||||
}
|
||||
|
||||
// Ensure the service-name label is set
|
||||
endpointSlice.Labels[discoveryv1.LabelServiceName] = "kubernetes"
|
||||
endpointSlice.AddressType = addressType
|
||||
|
||||
endpointSlice.Endpoints = []discoveryv1.Endpoint{
|
||||
{Addresses: []string{addr.IP}},
|
||||
}
|
||||
|
||||
endpointSlice.Ports = []discoveryv1.EndpointPort{
|
||||
{
|
||||
Name: new("https"),
|
||||
Port: new(int32(port)),
|
||||
Protocol: new(corev1.ProtocolTCP),
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("upserting default/kubernetes endpointslice in virtual cluster: %w", err)
|
||||
}
|
||||
|
||||
log.V(1).Info("HCP kubernetes endpointslice reconciled", "address", addr.IP, "host", url.Hostname(), "port", port)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClusterReconciler) ensureHCPKubernetesEndpoints(ctx context.Context, cluster *v1beta1.Cluster) error {
|
||||
log := ctrl.LoggerFrom(ctx)
|
||||
|
||||
url, err := server.ServerURL(ctx, c.Client, cluster, findNonLoopbackSAN(cluster.Spec.TLSSANs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addr, err := hcpEndpointAddress(ctx, url.Hostname())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
virtClient, err := newVirtualClient(ctx, c.Client, cluster.Name, cluster.Namespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating virtual cluster client: %w", err)
|
||||
}
|
||||
|
||||
//nolint:staticcheck // SA1019 corev1.Endpoints is deprecated in v1.33+, but needed in the Conformance tests
|
||||
// We are already using the discoveryv1.EndpointSlice
|
||||
endpoints := &corev1.Endpoints{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "kubernetes",
|
||||
Namespace: metav1.NamespaceDefault,
|
||||
},
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(url.Port())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = controllerutil.CreateOrUpdate(ctx, virtClient, endpoints, func() error {
|
||||
if endpoints.Labels == nil {
|
||||
endpoints.Labels = make(map[string]string)
|
||||
}
|
||||
|
||||
// Ensure the skip-mirror label is set
|
||||
endpoints.Labels[discoveryv1.LabelSkipMirror] = "true"
|
||||
|
||||
//nolint:staticcheck // SA1019 corev1.EndpointSubset is deprecated in v1.33+, but needed in the Conformance tests
|
||||
endpoints.Subsets = []corev1.EndpointSubset{
|
||||
{
|
||||
Addresses: []corev1.EndpointAddress{addr},
|
||||
Ports: []corev1.EndpointPort{
|
||||
{
|
||||
Name: "https",
|
||||
Port: int32(port),
|
||||
Protocol: corev1.ProtocolTCP,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("upserting default/kubernetes endpoints in virtual cluster: %w", err)
|
||||
}
|
||||
|
||||
log.V(1).Info("HCP kubernetes endpoints reconciled", "address", addr.IP, "host", url.Host, "port", port)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hcpEndpointAddress builds a corev1.EndpointAddress from the externally
|
||||
// reachable host. Endpoints require an IP; if the host is a DNS name we
|
||||
// resolve it. The Hostname field is intentionally left unset:
|
||||
// the kubernetes API validates it as a DNS-1123 label (no dots),
|
||||
// so an FQDN like "host.example.com" would be rejected.
|
||||
func hcpEndpointAddress(ctx context.Context, host string) (corev1.EndpointAddress, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ip.IsLoopback() {
|
||||
return corev1.EndpointAddress{}, fmt.Errorf("HCP endpoint host %q is a loopback address and cannot be used", host)
|
||||
}
|
||||
|
||||
return corev1.EndpointAddress{IP: host}, nil
|
||||
}
|
||||
|
||||
ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return corev1.EndpointAddress{}, fmt.Errorf("HCP endpoint host %q is not an IP and does not resolve: %w", host, err)
|
||||
}
|
||||
|
||||
var filteredIPs []net.IP
|
||||
|
||||
for _, addr := range ipAddrs {
|
||||
if !addr.IP.IsLoopback() {
|
||||
filteredIPs = append(filteredIPs, addr.IP)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filteredIPs) == 0 {
|
||||
return corev1.EndpointAddress{}, fmt.Errorf("HCP endpoint host %q resolved to no non-loopback IPs", host)
|
||||
}
|
||||
|
||||
if v4 := filteredIPs[0].To4(); v4 != nil {
|
||||
return corev1.EndpointAddress{IP: v4.String()}, nil
|
||||
}
|
||||
|
||||
return corev1.EndpointAddress{IP: filteredIPs[0].String()}, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/rancher/k3k/pkg/controller"
|
||||
)
|
||||
|
||||
func Test_findNonLoopbackSAN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sans []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "loopback first, external second",
|
||||
sans: []string{"127.0.0.1", "10.0.0.100"},
|
||||
want: "10.0.0.100",
|
||||
},
|
||||
{
|
||||
name: "external first",
|
||||
sans: []string{"10.0.0.100", "127.0.0.1"},
|
||||
want: "10.0.0.100",
|
||||
},
|
||||
{
|
||||
name: "only loopback",
|
||||
sans: []string{"127.0.0.1", "::1"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "localhost hostname filtered",
|
||||
sans: []string{"localhost", "example.com"},
|
||||
want: "example.com",
|
||||
},
|
||||
{
|
||||
name: "external hostname",
|
||||
sans: []string{"hcp.example.com"},
|
||||
want: "hcp.example.com",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
sans: []string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "ipv6 loopback filtered",
|
||||
sans: []string{"::1", "2001:db8::1"},
|
||||
want: "2001:db8::1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := findNonLoopbackSAN(tt.sans)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hcpEndpointAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantIP string
|
||||
wantHostname string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ipv4 literal is passed through",
|
||||
input: "10.144.101.195",
|
||||
wantIP: "10.144.101.195",
|
||||
wantHostname: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unresolvable hostname errors",
|
||||
input: "definitely-not-a-real-host.invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ipv4 loopback literal is rejected",
|
||||
input: "127.0.0.1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ipv6 loopback literal is rejected",
|
||||
input: "::1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ipv4 loopback in range is rejected",
|
||||
input: "127.0.0.100",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid ipv6 literal passes through",
|
||||
input: "2001:db8::1",
|
||||
wantIP: "2001:db8::1",
|
||||
wantHostname: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "localhost hostname filters loopbacks",
|
||||
input: "localhost",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := hcpEndpointAddress(context.Background(), tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wantIP, got.IP)
|
||||
assert.Equal(t, tt.wantHostname, got.Hostname)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time assertion: every reused exported name from the controller
|
||||
// package below this test file must remain stable. If `controller.K3SImage`
|
||||
// disappears (refactor), this guards the dependency.
|
||||
var _ = controller.K3SImage
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
package kubeconfig
|
||||
package server_test
|
||||
|
||||
// This file pins the behavior of getURLFromService() across the different
|
||||
// This file pins the behavior of ServerURL() across the different
|
||||
// service types (ClusterIP, NodePort, LoadBalancer), Ingress exposure, and the
|
||||
// TLS SAN fallback logic.
|
||||
//
|
||||
@@ -64,7 +64,7 @@ func TestURLGeneration_ClusterIP(t *testing.T) {
|
||||
cluster, svc := createClusterIPService("test-cluster", "default", tt.servicePort)
|
||||
fakeClient := createFakeClient(t, cluster, svc)
|
||||
|
||||
url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedURL, url.String())
|
||||
@@ -104,7 +104,7 @@ func TestURLGeneration_NodePort(t *testing.T) {
|
||||
cluster, svc := createNodePortService("test-cluster", "default", tt.servicePort, tt.nodePort)
|
||||
fakeClient := createFakeClient(t, cluster, svc)
|
||||
|
||||
url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedURL, url.String())
|
||||
@@ -154,7 +154,7 @@ func TestURLGeneration_LoadBalancer(t *testing.T) {
|
||||
cluster, svc := createLoadBalancerService("test-cluster", "default", tt.servicePort, tt.lbIP, tt.lbHostname)
|
||||
fakeClient := createFakeClient(t, cluster, svc)
|
||||
|
||||
url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedURL, url.String())
|
||||
@@ -181,7 +181,7 @@ func TestURLGeneration_Ingress(t *testing.T) {
|
||||
cluster, svc, ingress := createIngressService("test-cluster", "default", tt.ingressHost)
|
||||
fakeClient := createFakeClient(t, cluster, svc, ingress)
|
||||
|
||||
url, err := getURLFromService(t.Context(), fakeClient, cluster, "10.0.0.1")
|
||||
url, err := server.ServerURL(t.Context(), fakeClient, cluster, "10.0.0.1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedURL, url.String())
|
||||
@@ -256,7 +256,7 @@ func TestURLGeneration_TLSSANs(t *testing.T) {
|
||||
|
||||
fakeClient := createFakeClient(t, cluster, svc)
|
||||
|
||||
url, err := getURLFromService(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
url, err := server.ServerURL(t.Context(), fakeClient, cluster, tt.hostServerIP)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedURL, url.String())
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,22 +3,13 @@ package kubeconfig
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
certutil "github.com/rancher/dynamiclistener/cert"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
|
||||
"github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1"
|
||||
"github.com/rancher/k3k/pkg/controller"
|
||||
@@ -63,7 +54,7 @@ func (k *KubeConfig) Generate(ctx context.Context, client client.Client, cluster
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverURL, err := getURLFromService(ctx, client, cluster, hostServerIP)
|
||||
serverURL, err := server.ServerURL(ctx, client, cluster, hostServerIP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -95,119 +86,3 @@ func NewConfig(url string, serverCA, clientCert, clientKey []byte) *clientcmdapi
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// getURLFromService 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 getURLFromService(ctx context.Context, c client.Client, cluster *v1beta1.Cluster, hostServerIP string) (*url.URL, error) {
|
||||
log := ctrl.LoggerFrom(ctx)
|
||||
|
||||
key := types.NamespacedName{
|
||||
Name: server.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: server.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.Parse(fmt.Sprintf("https://%s", k3kIngress.Spec.Rules[0].Host))
|
||||
}
|
||||
|
||||
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
|
||||
ip := 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:
|
||||
ip = 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
|
||||
ip = k3kService.Spec.ClusterIP
|
||||
}
|
||||
|
||||
case corev1.ServiceTypeLoadBalancer:
|
||||
if len(k3kService.Status.LoadBalancer.Ingress) > 0 {
|
||||
ingress := k3kService.Status.LoadBalancer.Ingress[0]
|
||||
|
||||
switch {
|
||||
case ingress.IP != "":
|
||||
ip = ingress.IP
|
||||
case ingress.Hostname != "":
|
||||
ip = ingress.Hostname
|
||||
default:
|
||||
log.V(1).Info("No usable ingress address found in LoadBalancer service.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(cluster.Status.TLSSANs, ip) {
|
||||
log.V(1).Info(fmt.Sprintf("IP %s not in tlsSANs.", ip))
|
||||
|
||||
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])
|
||||
|
||||
ip = 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])
|
||||
|
||||
ip = 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
|
||||
var rawURL string
|
||||
if port != int32(443) {
|
||||
rawURL = fmt.Sprintf("https://%s", net.JoinHostPort(ip, strconv.Itoa(int(port))))
|
||||
} else {
|
||||
rawURL = fmt.Sprintf("https://%s", ip)
|
||||
}
|
||||
|
||||
return url.Parse(rawURL)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user