Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
29 changes: 14 additions & 15 deletions cmd/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ func NewControllerCmd() *cobra.Command {

or CLI flag:
$ k0s controller --token-file [path_to_file]
Note: Token can be passed either as a CLI argument or as a flag`,

or environment variable:
$ K0S_TOKEN=[token] k0s controller
Note: Token can be passed either as a CLI argument, a flag, or an environment variable`,
Args: cobra.MaximumNArgs(1),
PersistentPreRun: debugFlags.Run,
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -90,8 +93,8 @@ func NewControllerCmd() *cobra.Command {
if len(args) > 0 {
c.TokenArg = args[0]
}
if c.TokenArg != "" && c.TokenFile != "" {
Copy link
Member

Choose a reason for hiding this comment

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

I'd prefer to leave this check in place (or refactor it out and reuse it here via a function, whichever makes more sense.) We should verify the command line's correctness before attempting to do anything.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hopefully handled by internal.CheckSingleTokenSource

return errors.New("you can only pass one token argument either as a CLI argument 'k0s controller [join-token]' or as a flag 'k0s controller --token-file [path]'")
if err := internal.CheckSingleTokenSource(c.TokenArg, c.TokenFile); err != nil {
return err
}
if err := controllerFlags.Normalize(); err != nil {
return err
Expand Down Expand Up @@ -183,20 +186,16 @@ func (c *command) start(ctx context.Context, flags *config.ControllerOptions, de

var joinClient *token.JoinClient

if (c.TokenArg != "" || c.TokenFile != "") && c.needToJoin(nodeConfig) {
var tokenData string
if c.TokenArg != "" {
tokenData = c.TokenArg
} else {
data, err := os.ReadFile(c.TokenFile)
if c.needToJoin(nodeConfig) {
tokenData, err := internal.GetTokenData(c.TokenArg, c.TokenFile)
if err != nil {
return err
}
if tokenData != "" {
joinClient, err = joinController(ctx, tokenData, c.K0sVars.CertRootDir)
if err != nil {
return fmt.Errorf("read token file %q: %w", c.TokenFile, err)
return fmt.Errorf("failed to join controller: %w", err)
}
tokenData = string(data)
}
joinClient, err = joinController(ctx, tokenData, c.K0sVars.CertRootDir)
if err != nil {
return fmt.Errorf("failed to join controller: %w", err)
}
}

Expand Down
5 changes: 4 additions & 1 deletion cmd/controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ Examples:

or CLI flag:
$ k0s controller --token-file [path_to_file]
Note: Token can be passed either as a CLI argument or as a flag

or environment variable:
$ K0S_TOKEN=[token] k0s controller
Note: Token can be passed either as a CLI argument, a flag, or an environment variable

Flags:
-c, --config string config file, use '-' to read the config from stdin (default `+defaultConfigPath+`)
Expand Down
70 changes: 70 additions & 0 deletions cmd/internal/tokendata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2025 k0s authors
// SPDX-License-Identifier: Apache-2.0

package internal

import (
"errors"
"fmt"
"os"
)

// EnvVarToken is the environment variable name for the join token
const EnvVarToken = "K0S_TOKEN"

// CheckSingleTokenSource verifies that at most one token source is provided.
// Returns an error if multiple sources are specified.
func CheckSingleTokenSource(tokenArg, tokenFile string) error {
tokenSources := 0
if tokenArg != "" {
tokenSources++
}
if tokenFile != "" {
tokenSources++
}
if os.Getenv(EnvVarToken) != "" {
tokenSources++
}

if tokenSources > 1 {
return fmt.Errorf("you can only pass one token source: either as a CLI argument, via '--token-file [path]', or via the %s environment variable", EnvVarToken)
}

return nil
}

// GetTokenData resolves the join token from multiple possible sources:
// CLI argument, token file, or K0S_TOKEN environment variable.
// Returns empty string if no token source is available.
func GetTokenData(tokenArg, tokenFile string) (string, error) {
tokenEnvValue := os.Getenv(EnvVarToken)

if tokenArg != "" {
return tokenArg, nil
}

if tokenEnvValue != "" {
return tokenEnvValue, nil
}

if tokenFile == "" {
return "", nil
}

var problem string
data, err := os.ReadFile(tokenFile)
if errors.Is(err, os.ErrNotExist) {
problem = "not found"
} else if err != nil {
return "", fmt.Errorf("failed to read token file: %w", err)
} else if len(data) == 0 {
problem = "is empty"
}
if problem != "" {
return "", fmt.Errorf("token file %q %s"+
`: obtain a new token via "k0s token create ..." and store it in the file`+
` or reinstall this node via "k0s install --force ..." or "k0sctl apply --force ..."`,
tokenFile, problem)
}
return string(data), nil
}
160 changes: 160 additions & 0 deletions cmd/internal/tokendata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// SPDX-FileCopyrightText: 2025 k0s authors
// SPDX-License-Identifier: Apache-2.0

package internal

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCheckSingleTokenSource(t *testing.T) {
testToken := "test-token-data"

t.Run("returns nil when no token sources provided", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

err := CheckSingleTokenSource("", "")
require.NoError(t, err)
})

t.Run("returns nil when only arg provided", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

err := CheckSingleTokenSource(testToken, "")
require.NoError(t, err)
})

t.Run("returns nil when only file provided", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

err := CheckSingleTokenSource("", "/path/to/token")
require.NoError(t, err)
})

t.Run("returns nil when only env provided", func(t *testing.T) {
t.Setenv(EnvVarToken, testToken)

err := CheckSingleTokenSource("", "")
require.NoError(t, err)
})

t.Run("returns error when multiple token sources provided - env and arg", func(t *testing.T) {
t.Setenv(EnvVarToken, testToken)

err := CheckSingleTokenSource(testToken, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "you can only pass one token source")
assert.Contains(t, err.Error(), EnvVarToken)
})

t.Run("returns error when multiple token sources provided - env and file", func(t *testing.T) {
t.Setenv(EnvVarToken, testToken)

err := CheckSingleTokenSource("", "/path/to/token")
require.Error(t, err)
assert.Contains(t, err.Error(), "you can only pass one token source")
})

t.Run("returns error when multiple token sources provided - arg and file", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

err := CheckSingleTokenSource(testToken, "/path/to/token")
require.Error(t, err)
assert.Contains(t, err.Error(), "you can only pass one token source")
})

t.Run("returns error when all three token sources provided", func(t *testing.T) {
t.Setenv(EnvVarToken, testToken)

err := CheckSingleTokenSource(testToken, "/path/to/token")
require.Error(t, err)
assert.Contains(t, err.Error(), "you can only pass one token source")
})
}

func TestGetTokenData_EnvVar(t *testing.T) {
testToken := "test-token-data"

t.Run("reads token from K0S_TOKEN env var", func(t *testing.T) {
t.Setenv(EnvVarToken, testToken)

token, err := GetTokenData("", "")
require.NoError(t, err)
assert.Equal(t, testToken, token)
})

t.Run("empty K0S_TOKEN returns empty string", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

token, err := GetTokenData("", "")
require.NoError(t, err)
assert.Empty(t, token)
})
}

func TestGetTokenData_TokenArg(t *testing.T) {
testToken := "test-token-data"

t.Run("reads token from argument", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

token, err := GetTokenData(testToken, "")
require.NoError(t, err)
assert.Equal(t, testToken, token)
})
}

func TestGetTokenData_TokenFile(t *testing.T) {
testToken := "test-token-from-file"

t.Run("reads token from file", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

tmpDir := t.TempDir()
tokenFile := filepath.Join(tmpDir, "token")
require.NoError(t, os.WriteFile(tokenFile, []byte(testToken), 0600))

token, err := GetTokenData("", tokenFile)
require.NoError(t, err)
assert.Equal(t, testToken, token)
})

t.Run("returns error for non-existent file", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

_, err := GetTokenData("", "/non/existent/path")
require.Error(t, err)
assert.Contains(t, err.Error(), "token file")
assert.Contains(t, err.Error(), "not found")
assert.Contains(t, err.Error(), "k0s token create")
})

t.Run("returns error for empty file", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

tmpDir := t.TempDir()
tokenFile := filepath.Join(tmpDir, "empty-token")
require.NoError(t, os.WriteFile(tokenFile, []byte{}, 0600))

_, err := GetTokenData("", tokenFile)
require.Error(t, err)
assert.Contains(t, err.Error(), "token file")
assert.Contains(t, err.Error(), "is empty")
assert.Contains(t, err.Error(), "k0s token create")
})
}

func TestGetTokenData_NoToken(t *testing.T) {
t.Run("returns empty string when no token provided", func(t *testing.T) {
t.Setenv(EnvVarToken, "")

token, err := GetTokenData("", "")
require.NoError(t, err)
assert.Empty(t, token)
})
}
53 changes: 17 additions & 36 deletions cmd/worker/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ func NewWorkerCmd() *cobra.Command {

or CLI flag:
$ k0s worker --token-file [path_to_file]
Note: Token can be passed either as a CLI argument or as a flag`,

or environment variable:
$ K0S_TOKEN=[token] k0s worker
Note: Token can be passed either as a CLI argument, a flag, or an environment variable`,
Args: cobra.MaximumNArgs(1),
PersistentPreRun: debugFlags.Run,
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -81,6 +84,9 @@ func NewWorkerCmd() *cobra.Command {
if len(args) > 0 {
c.TokenArg = args[0]
}
if err := internal.CheckSingleTokenSource(c.TokenArg, c.TokenFile); err != nil {
return err
}

getBootstrapKubeconfig, err := kubeconfigGetterFromJoinToken(c.TokenFile, c.TokenArg)
if err != nil {
Expand Down Expand Up @@ -153,27 +159,22 @@ func GetNodeName(opts *config.WorkerOptions) (apitypes.NodeName, stringmap.Strin
}

func kubeconfigGetterFromJoinToken(tokenFile, tokenArg string) (clientcmd.KubeconfigGetter, error) {
if tokenArg != "" {
if tokenFile != "" {
return nil, errors.New("you can only pass one token argument either as a CLI argument 'k0s worker [token]' or as a flag 'k0s worker --token-file [path]'")
}

kubeconfig, err := loadKubeconfigFromJoinToken(tokenArg)
if err != nil {
return nil, err
}

return func() (*clientcmdapi.Config, error) {
return kubeconfig, nil
}, nil
tokenData, err := internal.GetTokenData(tokenArg, tokenFile)
if err != nil {
return nil, err
}

if tokenFile == "" {
if tokenData == "" {
return nil, nil
}

kubeconfig, err := loadKubeconfigFromJoinToken(tokenData)
if err != nil {
return nil, err
}

return func() (*clientcmdapi.Config, error) {
return loadKubeconfigFromTokenFile(tokenFile)
return kubeconfig, nil
}, nil
}

Expand All @@ -195,26 +196,6 @@ func loadKubeconfigFromJoinToken(tokenData string) (*clientcmdapi.Config, error)
return kubeconfig, nil
}

func loadKubeconfigFromTokenFile(path string) (*clientcmdapi.Config, error) {
var problem string
tokenBytes, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
problem = "not found"
} else if err != nil {
return nil, fmt.Errorf("failed to read token file: %w", err)
} else if len(tokenBytes) == 0 {
problem = "is empty"
}
if problem != "" {
return nil, fmt.Errorf("token file %q %s"+
`: obtain a new token via "k0s token create ..." and store it in the file`+
` or reinstall this node via "k0s install --force ..." or "k0sctl apply --force ..."`,
path, problem)
}

return loadKubeconfigFromJoinToken(string(tokenBytes))
}

// Start starts the worker components based on the given [config.CLIOptions].
func (c *Command) Start(ctx context.Context, nodeName apitypes.NodeName, kubeletExtraArgs stringmap.StringMap, getBootstrapKubeconfig clientcmd.KubeconfigGetter, controller EmbeddingController) error {
if err := worker.BootstrapKubeletClientConfig(ctx, c.K0sVars, nodeName, &c.WorkerOptions, getBootstrapKubeconfig); err != nil {
Expand Down
Loading