Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/controller/bootstrap/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"context"
"errors"
"fmt"
"net"
"strings"

bootstrapv2 "github.com/k0sproject/k0smotron/v2/api/bootstrap/v1beta2"
Expand All @@ -34,6 +35,12 @@ var (
errExtractingFileContent = errors.New("failed to get file content from source")
)

// apiServerURL builds an HTTPS URL from a host and port, bracketing IPv6
// literals as required by net.JoinHostPort so the URL stays valid.
func apiServerURL(host, port string) string {
return "https://" + net.JoinHostPort(host, port)
}

func resolveContentFromFile(ctx context.Context, cli client.Client, cluster *clusterv1.Cluster, contentFrom *bootstrapv2.ContentSource) (string, error) {
switch {
case contentFrom.SecretRef != nil:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ func (c *ControlPlaneController) detectJoinHost(ctx context.Context, scope *Cont
if found && k0sAPIPort > 0 {
port = strconv.Itoa(int(k0sAPIPort))
}
host := fmt.Sprintf("https://%s:%s", scope.Cluster.Spec.ControlPlaneEndpoint.Host, port)
host := apiServerURL(scope.Cluster.Spec.ControlPlaneEndpoint.Host, port)

_, err = httpClient.Get(fmt.Sprintf("%s/v1beta1/ca", host))
if err == nil {
Expand All @@ -707,7 +707,7 @@ func (c *ControlPlaneController) detectJoinHost(ctx context.Context, scope *Cont
return "", fmt.Errorf("failed to get first controller IP: %w", err)
}

return fmt.Sprintf("https://%s:%s", firstControllerIP, port), nil
return apiServerURL(firstControllerIP, port), nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findFirstControllerIP already wraps IPv6 addresses in the brackets, at least in some cases, so this will break the things wrapping them twice.

}

func (c *ControlPlaneController) findFirstControllerIP(ctx context.Context, firstControllerMachine *clusterv1.Machine) (string, error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"fmt"
"maps"
"path/filepath"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -529,9 +530,9 @@ func (r *Controller) getK0sToken(ctx context.Context, scope *Scope) (string, err
}

var joinToken string
joinURL := fmt.Sprintf("https://%s:%d", scope.Cluster.Spec.ControlPlaneEndpoint.Host, scope.Cluster.Spec.ControlPlaneEndpoint.Port)
joinURL := apiServerURL(scope.Cluster.Spec.ControlPlaneEndpoint.Host, strconv.Itoa(int(scope.Cluster.Spec.ControlPlaneEndpoint.Port)))
if scope.ingressSpec != nil {
joinURL = fmt.Sprintf("https://%s:%d", scope.ingressSpec.APIHost, scope.ingressSpec.Port)
joinURL = apiServerURL(scope.ingressSpec.APIHost, strconv.FormatInt(scope.ingressSpec.Port, 10))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ingress by definition uses dns names, so it doesn't make much sense to do it here (and below).

}

joinToken, err := kutil.CreateK0sJoinToken(ca.KeyPair.Cert, token, joinURL, "kubelet-bootstrap")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,36 @@ func Test_getWindowsCommands(t *testing.T) {
}

}

func Test_apiServerURL(t *testing.T) {
tests := []struct {
name string
host string
port string
want string
}{
{
name: "ipv4 host",
host: "10.0.0.1",
port: "443",
want: "https://10.0.0.1:443",
},
{
name: "dns host",
host: "api.example.com",
port: "6443",
want: "https://api.example.com:6443",
},
{
name: "ipv6 host is bracketed",
host: "2001:db8:11:1103::3",
port: "443",
want: "https://[2001:db8:11:1103::3]:443",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, apiServerURL(tt.host, tt.port))
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"fmt"
"io"
"maps"
"net"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -317,7 +319,7 @@ func updateJoinTokenURL(token string, kmc km.Cluster) (string, error) {
}

for _, cluster := range cfg.Clusters {
cluster.Server = fmt.Sprintf("https://%s:%d", kmc.Spec.Ingress.APIHost, kmc.Spec.Ingress.Port)
cluster.Server = "https://" + net.JoinHostPort(kmc.Spec.Ingress.APIHost, strconv.FormatInt(kmc.Spec.Ingress.Port, 10))
}

updatedData, err := clientcmd.Write(*cfg)
Expand Down
100 changes: 100 additions & 0 deletions internal/controller/k0smotron.io/jointokenrequest_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//go:build !envtest

/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package k0smotronio

import (
"bytes"
"testing"

km "github.com/k0sproject/k0smotron/v2/api/k0smotron.io/v1beta2"
"github.com/stretchr/testify/require"
"k8s.io/client-go/tools/clientcmd"
)

const joinTokenSampleKubeconfig = `apiVersion: v1
clusters:
- cluster:
certificate-authority-data: Q0FjZXJ0
server: https://old-host:6443
name: k0s
contexts:
- context:
cluster: k0s
user: admin
name: k0s
current-context: k0s
kind: Config
preferences: {}
users:
- name: admin
user:
client-certificate-data: Q0xJRU5UQ0VSVA==
client-key-data: Q0xJRU5US0VZ
`

func Test_updateJoinTokenURL(t *testing.T) {
tests := []struct {
name string
apiHost string
port int64
wantServer string
}{
{
name: "ipv4 host",
apiHost: "10.0.0.1",
port: 6443,
wantServer: "https://10.0.0.1:6443",
},
{
name: "ipv6 host is bracketed",
apiHost: "2001:db8:11:1103::3",
port: 443,
wantServer: "https://[2001:db8:11:1103::3]:443",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token, err := tokenEncode(bytes.NewReader([]byte(joinTokenSampleKubeconfig)))
require.NoError(t, err)

kmc := km.Cluster{
Spec: km.ClusterSpec{
Ingress: &km.IngressSpec{
APIHost: tt.apiHost,
Port: tt.port,
},
},
}

updatedToken, err := updateJoinTokenURL(token, kmc)
require.NoError(t, err)

decoded, err := tokenDecode(updatedToken)
require.NoError(t, err)

cfg, err := clientcmd.Load(decoded)
require.NoError(t, err)

for _, cluster := range cfg.Clusters {
require.Equal(t, tt.wantServer, cluster.Server)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package k0smotronio
import (
"context"
"fmt"
"net"
"strconv"

v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -138,7 +140,7 @@ func rewriteKubeconfigValues(kubeconfigYAML string, kmc *km.Cluster) (string, er
return "", fmt.Errorf("cluster server is empty")
}
if kmc.Spec.Ingress != nil {
srcCluster.Server = fmt.Sprintf("https://%s:%d", kmc.Spec.Ingress.APIHost, kmc.Spec.Ingress.Port)
srcCluster.Server = "https://" + net.JoinHostPort(kmc.Spec.Ingress.APIHost, strconv.FormatInt(kmc.Spec.Ingress.Port, 10))
}
if len(srcUser.ClientCertificateData) == 0 || len(srcUser.ClientKeyData) == 0 {
return "", fmt.Errorf("client certificate/key data not found in kubeconfig")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,30 @@ func TestRewriteKubeconfigNames(t *testing.T) {
t.Fatalf("kubeconfig mismatch:\nGot:\n%s\nWant:\n%s", string(gotBytes), string(wantBytes))
}
}

func TestRewriteKubeconfigValuesIngressIPv6HostIsBracketed(t *testing.T) {
kmc := &v1beta2.Cluster{
ObjectMeta: metav1.ObjectMeta{Name: "wl1", Namespace: "default"},
Spec: v1beta2.ClusterSpec{
Ingress: &v1beta2.IngressSpec{
APIHost: "2001:db8:11:1103::3",
Port: 443,
},
},
}

out, err := rewriteKubeconfigValues(sampleKubeconfig, kmc)
if err != nil {
t.Fatalf("rewriteKubeconfigValues returned error: %v", err)
}

gotCfg, err := clientcmd.Load([]byte(out))
if err != nil {
t.Fatalf("failed to load processed kubeconfig: %v", err)
}
for _, cluster := range gotCfg.Clusters {
if cluster.Server != "https://[2001:db8:11:1103::3]:443" {
t.Fatalf("unexpected server value: %s", cluster.Server)
}
}
}
Loading