Files
k3k/pkg/controller/cluster/server/server.go
T
39cc69f3e3 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>
2026-07-07 14:12:19 +02:00

640 lines
17 KiB
Go

package server
import (
"bytes"
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"text/template"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/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"
"github.com/rancher/k3k/pkg/controller/cluster/mounts"
)
const (
initConfigName = "init-server-config"
configName = "server-config"
serverName = "server"
k3sInitConfigDir = "/opt/rancher/k3s/init"
k3sConfigDir = "/opt/rancher/k3s/server"
k3sRunDir = "/run"
k3sCNIDir = "/var/lib/cni"
k3sKubeletDir = "/var/lib/kubelet"
k3sDataDir = "/var/lib/rancher/k3s"
k3sETCDDataDir = "/var/lib/rancher/k3s/server/db/etcd"
k3sManifestDir = "/var/lib/rancher/k3s/server/manifests"
k3sTLSDir = "/var/lib/rancher/k3s/server/tls"
k3sLogDir = "/var/log"
k3sVarRunDir = "/var/run"
)
type Server struct {
cluster *v1beta1.Cluster
client client.Client
mode string
token string
image string
imagePullPolicy string
imagePullSecrets []string
}
func New(cluster *v1beta1.Cluster, client client.Client, token, image, imagePullPolicy string, imagePullSecrets []string) *Server {
return &Server{
cluster: cluster,
client: client,
token: token,
mode: string(cluster.Spec.Mode),
image: image,
imagePullPolicy: imagePullPolicy,
imagePullSecrets: imagePullSecrets,
}
}
func (s *Server) podSpec(ctx context.Context, image, name string, persistent bool, startupCmd string) corev1.PodSpec {
log := ctrl.LoggerFrom(ctx)
// Use the server affinity from the policy status if it exists, otherwise fall back to the spec.
serverAffinity := s.cluster.Spec.ServerAffinity
if s.cluster.Status.Policy != nil && s.cluster.Status.Policy.ServerAffinity != nil {
log.V(1).Info("Using server affinity from policy", "policyName", s.cluster.Status.PolicyName, "clusterName", s.cluster.Name)
serverAffinity = s.cluster.Status.Policy.ServerAffinity
}
podSpec := corev1.PodSpec{
Affinity: serverAffinity,
NodeSelector: s.cluster.Spec.NodeSelector,
PriorityClassName: s.cluster.Spec.PriorityClass,
Volumes: []corev1.Volume{
{
Name: "init-config",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: configSecretName(s.cluster.Name, true),
Items: []corev1.KeyToPath{
{
Key: "config.yaml",
Path: "config.yaml",
},
},
},
},
},
{
Name: "config",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: configSecretName(s.cluster.Name, false),
Items: []corev1.KeyToPath{
{
Key: "config.yaml",
Path: "config.yaml",
},
},
},
},
},
{
Name: "run",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
{
Name: "var-run",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
{
Name: "var-lib-cni",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
{
Name: "var-log",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
{
Name: "var-lib-kubelet",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
},
Containers: []corev1.Container{
{
Name: name,
Image: image,
ImagePullPolicy: corev1.PullPolicy(s.imagePullPolicy),
Env: []corev1.EnvVar{
{
Name: "POD_NAME",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
FieldPath: "metadata.name",
},
},
},
{
Name: "POD_IP",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
FieldPath: "status.podIP",
},
},
},
},
VolumeMounts: []corev1.VolumeMount{
{
Name: "config",
MountPath: k3sConfigDir,
ReadOnly: false,
},
{
Name: "init-config",
MountPath: k3sInitConfigDir,
ReadOnly: false,
},
{
Name: "run",
MountPath: k3sRunDir,
ReadOnly: false,
},
{
Name: "var-run",
MountPath: k3sVarRunDir,
ReadOnly: false,
},
{
Name: "var-lib-cni",
MountPath: k3sCNIDir,
ReadOnly: false,
},
{
Name: "var-lib-kubelet",
MountPath: k3sKubeletDir,
ReadOnly: false,
},
{
Name: "var-lib-rancher-k3s",
MountPath: k3sDataDir,
ReadOnly: false,
},
{
Name: "var-log",
MountPath: k3sLogDir,
ReadOnly: false,
},
},
},
},
}
cmd := []string{
"/bin/sh",
"-c",
startupCmd,
}
podSpec.Containers[0].Command = cmd
if !persistent {
podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{
Name: "var-lib-rancher-k3s",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
)
}
// Adding readiness probes to statefulset
podSpec.Containers[0].ReadinessProbe = &corev1.Probe{
InitialDelaySeconds: 60,
FailureThreshold: 5,
TimeoutSeconds: 10,
ProbeHandler: corev1.ProbeHandler{
TCPSocket: &corev1.TCPSocketAction{
Port: intstr.FromInt(6443),
},
},
}
podSpec.Containers[0].LivenessProbe = &corev1.Probe{
InitialDelaySeconds: 10,
FailureThreshold: 3,
PeriodSeconds: 3,
ProbeHandler: corev1.ProbeHandler{
Exec: &corev1.ExecAction{
Command: []string{
"sh",
"-c",
`grep -q "rejoin the cluster" /var/log/k3s.log && exit 1 || exit 0`,
},
},
},
}
// 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),
}
}
securityContext := s.cluster.Spec.SecurityContext
if s.cluster.Status.Policy != nil && s.cluster.Status.Policy.SecurityContext != nil {
log.V(1).Info("Using securityContext configuration from policy", "policyName", s.cluster.Status.PolicyName, "clusterName", s.cluster.Name)
securityContext = s.cluster.Status.Policy.SecurityContext
}
if securityContext != nil {
podSpec.Containers[0].SecurityContext = securityContext
}
runtimeClassName := s.cluster.Spec.RuntimeClassName
if s.cluster.Status.Policy != nil && s.cluster.Status.Policy.RuntimeClassName != nil {
log.V(1).Info("Using runtimeClassName from policy", "policyName", s.cluster.Status.PolicyName, "clusterName", s.cluster.Name)
runtimeClassName = s.cluster.Status.Policy.RuntimeClassName
}
podSpec.RuntimeClassName = runtimeClassName
hostUsers := s.cluster.Spec.HostUsers
if s.cluster.Status.Policy != nil && s.cluster.Status.Policy.HostUsers != nil {
log.V(1).Info("Using hostUsers from policy", "policyName", s.cluster.Status.PolicyName, "clusterName", s.cluster.Name)
hostUsers = s.cluster.Status.Policy.HostUsers
}
podSpec.HostUsers = hostUsers
// specify resource limits if specified for the servers.
if s.cluster.Spec.ServerLimit != nil {
podSpec.Containers[0].Resources = corev1.ResourceRequirements{
Limits: s.cluster.Spec.ServerLimit,
}
}
// specifying ServerResources will take precedence over ServerLimits
if s.cluster.Spec.ServerResources != nil {
// removing container previous limit
podSpec.Containers[0].Resources = corev1.ResourceRequirements{}
podSpec.Resources = s.cluster.Spec.ServerResources
}
podSpec.Containers[0].Env = append(podSpec.Containers[0].Env, s.cluster.Spec.ServerEnvs...)
// add image pull secrets
for _, imagePullSecret := range s.imagePullSecrets {
podSpec.ImagePullSecrets = append(podSpec.ImagePullSecrets, corev1.LocalObjectReference{Name: imagePullSecret})
}
if podSpec.RuntimeClassName != nil && strings.HasPrefix(*podSpec.RuntimeClassName, "kata") {
mounts.AddKmsgMount(&podSpec)
mounts.FilterEmptyDirVolumes(&podSpec)
}
return podSpec
}
func (s *Server) StatefulServer(ctx context.Context) (*appsv1.StatefulSet, error) {
var (
replicas int32
pvClaim corev1.PersistentVolumeClaim
err error
persistent bool
)
image := controller.K3SImage(s.cluster, s.image)
name := controller.SafeConcatNameWithPrefix(s.cluster.Name, serverName)
replicas = *s.cluster.Spec.Servers
if s.cluster.Spec.Persistence.Type == v1beta1.DynamicPersistenceMode {
persistent = true
pvClaim = s.setupDynamicPersistence()
if err := controllerutil.SetControllerReference(s.cluster, &pvClaim, s.client.Scheme()); err != nil {
return nil, err
}
}
var (
volumes []corev1.Volume
volumeMounts []corev1.VolumeMount
)
if len(s.cluster.Spec.Addons) > 0 {
addonsVols, addonsMounts, err := s.buildAddonsVolumes(ctx)
if err != nil {
return nil, err
}
volumes = append(volumes, addonsVols...)
volumeMounts = append(volumeMounts, addonsMounts...)
}
if s.cluster.Spec.CustomCAs != nil && s.cluster.Spec.CustomCAs.Enabled {
vols, mounts, err := s.buildCABundleVolumes(ctx)
if err != nil {
return nil, err
}
volumes = append(volumes, vols...)
volumeMounts = append(volumeMounts, mounts...)
}
if len(s.cluster.Spec.SecretMounts) > 0 {
vols, mounts := mounts.BuildSecretsMountsVolumes(s.cluster.Spec.SecretMounts, "server")
volumes = append(volumes, vols...)
volumeMounts = append(volumeMounts, mounts...)
}
selector := metav1.LabelSelector{
MatchLabels: map[string]string{
"cluster": s.cluster.Name,
"role": "server",
},
}
startupCommand, err := s.setupStartCommand()
if err != nil {
return nil, err
}
podSpec := s.podSpec(ctx, image, name, persistent, startupCommand)
podSpec.Volumes = append(podSpec.Volumes, volumes...)
podSpec.Containers[0].VolumeMounts = append(podSpec.Containers[0].VolumeMounts, volumeMounts...)
ss := &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: s.cluster.Namespace,
Labels: selector.MatchLabels,
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas,
ServiceName: headlessServiceName(s.cluster.Name),
Selector: &selector,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: selector.MatchLabels,
},
Spec: podSpec,
},
},
}
if s.cluster.Spec.Persistence.Type == v1beta1.DynamicPersistenceMode {
ss.Spec.VolumeClaimTemplates = []corev1.PersistentVolumeClaim{pvClaim}
}
return ss, nil
}
func (s *Server) setupDynamicPersistence() corev1.PersistentVolumeClaim {
return corev1.PersistentVolumeClaim{
TypeMeta: metav1.TypeMeta{
Kind: "PersistentVolumeClaim",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "var-lib-rancher-k3s",
Namespace: s.cluster.Namespace,
},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
StorageClassName: s.cluster.Spec.Persistence.StorageClassName,
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
"storage": *s.cluster.Spec.Persistence.StorageRequestSize,
},
},
},
}
}
func (s *Server) setupStartCommand() (string, error) {
var output bytes.Buffer
tmpl := StartupCommand
mode := "single"
if *s.cluster.Spec.Servers > 1 {
mode = "ha"
}
var runtimeClass string
if s.cluster.Spec.RuntimeClassName != nil {
runtimeClass = *s.cluster.Spec.RuntimeClassName
}
tmplCmd, err := template.New("").Parse(tmpl)
if err != nil {
return "", err
}
if err := tmplCmd.Execute(&output, map[string]string{
"ETCD_DIR": k3sETCDDataDir,
"INIT_CONFIG": filepath.Join(k3sInitConfigDir, "config.yaml"),
"SERVER_CONFIG": filepath.Join(k3sConfigDir, "config.yaml"),
"CLUSTER_MODE": mode,
"K3K_MODE": string(s.cluster.Spec.Mode),
"EXTRA_ARGS": strings.Join(s.cluster.Spec.ServerArgs, " "),
"RUNTIME_CLASS": runtimeClass,
}); err != nil {
return "", err
}
return output.String(), nil
}
func (s *Server) buildCABundleVolumes(ctx context.Context) ([]corev1.Volume, []corev1.VolumeMount, error) {
if s.cluster.Spec.CustomCAs == nil {
return nil, nil, fmt.Errorf("customCAs not found")
}
customCerts := s.cluster.Spec.CustomCAs.Sources
caCertMap := map[string]string{
"server-ca": customCerts.ServerCA.SecretName,
"client-ca": customCerts.ClientCA.SecretName,
"request-header-ca": customCerts.RequestHeaderCA.SecretName,
"etcd-peer-ca": customCerts.ETCDPeerCA.SecretName,
"etcd-server-ca": customCerts.ETCDServerCA.SecretName,
"service": customCerts.ServiceAccountToken.SecretName,
}
var (
volumes []corev1.Volume
mounts []corev1.VolumeMount
sortedCertIDs = sortedKeys(caCertMap)
)
for _, certName := range sortedCertIDs {
var certSecret corev1.Secret
secretName := string(caCertMap[certName])
key := types.NamespacedName{Name: secretName, Namespace: s.cluster.Namespace}
if err := s.client.Get(ctx, key, &certSecret); err != nil {
return nil, nil, err
}
cert := certSecret.Data["tls.crt"]
keyData := certSecret.Data["tls.key"]
// Service account token secret is an exception (may not contain crt/key).
if certName != "service" && (len(cert) == 0 || len(keyData) == 0) {
return nil, nil, fmt.Errorf("cert or key is not found in secret %s", certName)
}
volumeName := certName + "-vol"
vol, certMounts := s.mountCACert(volumeName, certName, secretName, "tls")
volumes = append(volumes, *vol)
mounts = append(mounts, certMounts...)
}
return volumes, mounts, nil
}
func (s *Server) mountCACert(volumeName, certName, secretName string, subPathMount string) (*corev1.Volume, []corev1.VolumeMount) {
var (
volume *corev1.Volume
mounts []corev1.VolumeMount
)
// avoid re-adding secretName in case of combined secret
volume = &corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{SecretName: secretName},
},
}
etcdPrefix := ""
mountFile := certName
if strings.HasPrefix(certName, "etcd-") {
etcdPrefix = "/etcd"
mountFile = strings.TrimPrefix(certName, "etcd-")
}
for _, crtOrKey := range []string{"crt", "key"} {
// skip adding cert mount for service account token
if certName == "service" && crtOrKey == "crt" {
continue
}
mounts = append(mounts, corev1.VolumeMount{
Name: volumeName,
MountPath: filepath.Join(k3sTLSDir, etcdPrefix, mountFile) + "." + crtOrKey,
SubPath: subPathMount + "." + crtOrKey,
})
}
return volume, mounts
}
func (s *Server) buildAddonsVolumes(ctx context.Context) ([]corev1.Volume, []corev1.VolumeMount, error) {
var (
volumes []corev1.Volume
mounts []corev1.VolumeMount
)
for _, addon := range s.cluster.Spec.Addons {
namespace := s.cluster.Namespace
if addon.SecretNamespace != "" {
namespace = addon.SecretNamespace
}
nn := types.NamespacedName{
Name: addon.SecretRef,
Namespace: namespace,
}
var addons corev1.Secret
if err := s.client.Get(ctx, nn, &addons); err != nil {
return nil, nil, err
}
// skip creating the addon secret if it already exists and in the same namespace as the cluster
if namespace != s.cluster.Namespace {
clusterAddons := corev1.Secret{
TypeMeta: metav1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: addons.Name,
Namespace: s.cluster.Namespace,
},
Data: addons.Data,
}
if _, err := controllerutil.CreateOrUpdate(ctx, s.client, &clusterAddons, func() error {
return controllerutil.SetOwnerReference(s.cluster, &clusterAddons, s.client.Scheme())
}); err != nil {
return nil, nil, err
}
}
name := "addon-" + addon.SecretRef
volume := corev1.Volume{
Name: name,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: addon.SecretRef,
},
},
}
volumes = append(volumes, volume)
volumeMount := corev1.VolumeMount{
Name: name,
MountPath: filepath.Join(k3sManifestDir, addon.SecretRef),
ReadOnly: true,
}
mounts = append(mounts, volumeMount)
}
return volumes, mounts, nil
}
func sortedKeys(keyMap map[string]string) []string {
keys := make([]string, 0, len(keyMap))
for k := range keyMap {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}