diff --git a/cmd/controller/controller.go b/cmd/controller/controller.go index 165a649ed21c..f62a62c72d94 100644 --- a/cmd/controller/controller.go +++ b/cmd/controller/controller.go @@ -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 { @@ -90,8 +93,8 @@ func NewControllerCmd() *cobra.Command { if len(args) > 0 { c.TokenArg = args[0] } - if c.TokenArg != "" && c.TokenFile != "" { - 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 @@ -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) } } diff --git a/cmd/controller/controller_test.go b/cmd/controller/controller_test.go index fca8e2ae013d..b84a523fcbdd 100644 --- a/cmd/controller/controller_test.go +++ b/cmd/controller/controller_test.go @@ -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+`) diff --git a/cmd/internal/tokendata.go b/cmd/internal/tokendata.go new file mode 100644 index 000000000000..ae897ad25d04 --- /dev/null +++ b/cmd/internal/tokendata.go @@ -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 +} diff --git a/cmd/internal/tokendata_test.go b/cmd/internal/tokendata_test.go new file mode 100644 index 000000000000..991f768c5caa --- /dev/null +++ b/cmd/internal/tokendata_test.go @@ -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) + }) +} diff --git a/cmd/worker/worker.go b/cmd/worker/worker.go index e42b7f384353..5984ee79e0d0 100644 --- a/cmd/worker/worker.go +++ b/cmd/worker/worker.go @@ -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 { @@ -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 { @@ -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 } @@ -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 {