diff --git a/cmd/build.go b/cmd/build.go index 51692b11a2..1bfe950477 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -264,6 +264,7 @@ func newBuildConfig() buildConfig { return buildConfig{ Global: config.Global{ Builder: viper.GetString("builder"), + Cluster: viper.GetString("cluster"), Confirm: viper.GetBool("confirm"), Registry: registry(), // deferred defaulting Verbose: viper.GetBool("verbose"), diff --git a/cmd/cluster_override.go b/cmd/cluster_override.go new file mode 100644 index 0000000000..263c5bcbf4 --- /dev/null +++ b/cmd/cluster_override.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + "io" + + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/k8s" +) + +// setupClusterOverride looks up stored auth for clusterURL, applies a token +// override if clusterToken is non-empty, and configures the kubeconfig +// override. Returns a cleanup function that must be deferred by the caller. +// Callers should guard the call with a clusterURL != "" check. +func setupClusterOverride(clusterURL, clusterToken, namespace string, local fn.Local, errOut io.Writer) (cleanup func(), err error) { + var clusterTLS fn.ClusterVerify + var user fn.UserAuth + if entry := local.FindAuth(clusterURL); entry != nil { + clusterTLS = entry.Cluster + user = entry.User + } + if clusterToken != "" { + user.Token = clusterToken + } + cleanup, err = k8s.SetClusterOverride(clusterURL, namespace, clusterTLS, user) + if err != nil { + return nil, fmt.Errorf("failed to set cluster override for %s: %w", clusterURL, err) + } + fmt.Fprintf(errOut, "Using cluster: %s\n", clusterURL) + return cleanup, nil +} diff --git a/cmd/delete.go b/cmd/delete.go index 103a6270c4..d0f66ea411 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -33,7 +33,7 @@ No local files are deleted. SuggestFor: []string{"remove", "del"}, Aliases: []string{"rm"}, ValidArgsFunction: CompleteFunctionList, - PreRunE: bindEnv("path", "confirm", "all", "namespace", "verbose"), + PreRunE: bindEnv("cluster", "cluster-token", "path", "confirm", "all", "namespace", "verbose"), SilenceUsage: true, // no usage dump on error RunE: func(cmd *cobra.Command, args []string) error { // Layer 2: Catch technical errors and provide CLI-specific user-friendly messages @@ -47,7 +47,15 @@ No local files are deleted. fmt.Fprintf(cmd.OutOrStdout(), "error loading config at '%v'. %v\n", config.File(), err) } + // Function Context + f, _ := fn.NewFunction(effectivePath()) + if f.Initialized() { + cfg = cfg.Apply(f) + } + // Flags + cmd.Flags().String("cluster", cfg.Cluster, "Specify a cluster api url for your function deployment. ($FUNC_CLUSTER)") + cmd.Flags().String("cluster-token", "", "Bearer token for cluster authentication. ($FUNC_CLUSTER_TOKEN)") cmd.Flags().StringP("namespace", "n", defaultNamespace(fn.Function{}, false), "The namespace when deleting by name. ($FUNC_NAMESPACE)") cmd.Flags().StringP("all", "a", "true", "Delete all resources created for a function, eg. Pipelines, Secrets, etc. ($FUNC_ALL) (allowed values: \"true\", \"false\")") addConfirmFlag(cmd, cfg.Confirm) @@ -78,26 +86,49 @@ func runDelete(cmd *cobra.Command, args []string, newClient ClientFactory) (err return } - client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) - defer done() - if cfg.Name != "" { // Delete by name if provided + // don't use local.yaml auth because we are not concerned about func at path + if cfg.Cluster != "" { + cleanup, overrideErr := setupClusterOverride(cfg.Cluster, cfg.ClusterToken, cfg.Namespace, fn.Local{}, cmd.OutOrStderr()) + if overrideErr != nil { + return overrideErr + } + defer cleanup() + } + + client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) + defer done() return client.Remove(cmd.Context(), cfg.Name, cfg.Namespace, fn.Function{}, cfg.All) - } else { // Otherwise; delete the function at path (cwd by default) - f, err := fn.NewFunction(cfg.Path) - if err != nil { - return err + } + + // Delete by path — set cluster override if available + f, err := fn.NewFunction(cfg.Path) + if err != nil { + return err + } + + // use local auth - function was **most likely** created locally + if cfg.Cluster != "" { + cleanup, overrideErr := setupClusterOverride(cfg.Cluster, cfg.ClusterToken, cfg.Namespace, f.Local, cmd.OutOrStderr()) + if overrideErr != nil { + return overrideErr } - return client.Remove(cmd.Context(), "", "", f, cfg.All) + defer cleanup() } + + client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) + defer done() + return client.Remove(cmd.Context(), "", "", f, cfg.All) } type deleteConfig struct { - Name string - Namespace string - Path string - All bool - Verbose bool + Cluster string + ClusterToken string + Name string + Namespace string + Path string + All bool + Verbose bool } // newDeleteConfig returns a config populated from the current execution context @@ -108,11 +139,13 @@ func newDeleteConfig(cmd *cobra.Command, args []string) (cfg deleteConfig, err e name = args[0] } cfg = deleteConfig{ - All: viper.GetBool("all"), - Name: name, // args[0] or derived - Namespace: viper.GetString("namespace"), - Path: viper.GetString("path"), - Verbose: viper.GetBool("verbose"), // defined on root + All: viper.GetBool("all"), + Cluster: viper.GetString("cluster"), + ClusterToken: viper.GetString("cluster-token"), + Name: name, // args[0] or derived + Namespace: viper.GetString("namespace"), + Path: viper.GetString("path"), + Verbose: viper.GetBool("verbose"), // defined on root } if cfg.Name == "" && cmd.Flags().Changed("namespace") { // logicially inconsistent to supply only a namespace. diff --git a/cmd/deploy.go b/cmd/deploy.go index f7d7284647..142f396177 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -130,7 +130,7 @@ EXAMPLES `, SuggestFor: []string{"delpoy", "deplyo"}, PreRunE: bindEnv("build", "build-timestamp", "builder", "builder-image", - "base-image", "confirm", "domain", "env", "git-branch", "git-dir", + "base-image", "cluster", "cluster-token", "confirm", "domain", "env", "git-branch", "git-dir", "git-url", "image", "image-pull-secret", "namespace", "path", "platform", "push", "pvc-size", "service-account", "deployer", "registry", "registry-insecure", "registry-authfile", "remote", "username", "password", @@ -159,6 +159,8 @@ EXAMPLES // contextually relevant function; but sets are flattened via cfg.Apply(f) cmd.Flags().StringP("builder", "b", cfg.Builder, fmt.Sprintf("Builder to use when creating the function's container. Currently supported builders are %s.", KnownBuilders())) + cmd.Flags().String("cluster", cfg.Cluster, "Specify a cluster api url for your function deployment. ($FUNC_CLUSTER)") + cmd.Flags().String("cluster-token", "", "Bearer token for cluster authentication. Persisted to .func/local.yaml on successful deploy. ($FUNC_CLUSTER_TOKEN)") cmd.Flags().StringP("registry", "r", cfg.Registry, "Container registry + registry namespace. (ex 'ghcr.io/myuser'). The full image name is automatically determined using this along with function name. ($FUNC_REGISTRY)") cmd.Flags().Bool("registry-insecure", cfg.RegistryInsecure, "Skip TLS certificate verification when communicating in HTTPS with the registry. The value is persisted over consecutive runs ($FUNC_REGISTRY_INSECURE)") @@ -279,6 +281,9 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { return wrapValidateError(err, "deploy") } + // warn when changing cluster for + warnClusterChange(cmd.OutOrStderr(), cfg.Cluster, f.Deploy.Cluster) + // Warn if registry changed but registryInsecure is still true warnRegistryInsecureChange(cmd.OutOrStderr(), cfg.Registry, f) @@ -286,6 +291,13 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { return } + if cfg.Cluster != "" { + cleanup, overrideErr := setupClusterOverride(cfg.Cluster, cfg.ClusterToken, cfg.Namespace, f.Local, cmd.OutOrStderr()) + if overrideErr != nil { + return overrideErr + } + defer cleanup() + } changingNamespace := func(f fn.Function) bool { // We're changing namespace if: return f.Deploy.Namespace != "" && // it's already deployed @@ -389,6 +401,28 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { } } + // After kubeconfig-based deploy, capture credentials for future kubeconfig-free deploys. + // Note: explicitly providing flag '--cluster=' overrides the f.Deploy.Cluster + // intentionally to "refresh" cluster+auth from kubeconfig active context. + if f.Deploy.Cluster == "" { + if url, tls, user, extractErr := k8s.ExtractClusterAuth(); extractErr == nil { + f.Deploy.Cluster = url + f.Local.SetAuth(url, tls, user) + } + } + + // Persist token from --cluster-token + if f.Deploy.Cluster != "" && cfg.ClusterToken != "" { + clusterTLS := fn.ClusterVerify{} + user := fn.UserAuth{} + if entry := f.Local.FindAuth(f.Deploy.Cluster); entry != nil { + clusterTLS = entry.Cluster + user = entry.User + } + user.Token = cfg.ClusterToken + f.Local.SetAuth(f.Deploy.Cluster, clusterTLS, user) + } + // Write if err = f.Write(); err != nil { return @@ -471,6 +505,10 @@ func KnownBuilders() builders.Known { type deployConfig struct { buildConfig // further embeds config.Global + // ClusterToken is a bearer token for authenticating to the deployment + // cluster. When set, it takes precedence over stored credentials. + ClusterToken string + // Perform build using the settings from the embedded buildConfig struct. // Acceptable values are the keyword 'auto', or a truthy value such as // 'true', 'false, '1' or '0'. @@ -562,6 +600,7 @@ type deployConfig struct { func newDeployConfig(cmd *cobra.Command) deployConfig { cfg := deployConfig{ buildConfig: newBuildConfig(), + ClusterToken: viper.GetString("cluster-token"), Build: viper.GetString("build"), Env: viper.GetStringSlice("env"), Domain: viper.GetString("domain"), @@ -897,3 +936,11 @@ func isDigested(v string) (validDigest bool, err error) { _, ok := ref.(name.Digest) return ok, nil } + +// warnClusterChange determines if the cluster is being changed deliberately and +// if so, warn the user that creds might need to be added +func warnClusterChange(w io.Writer, newCluster string, old string) { + if newCluster != "" && old != "" && newCluster != old { + fmt.Fprintf(w, "Warning: changing cluster from '%s' to '%s'. Ensure your credentials are valid for the new cluster.\n", old, newCluster) + } +} diff --git a/cmd/describe.go b/cmd/describe.go index 5a6f4a8d49..44e27bc6d0 100644 --- a/cmd/describe.go +++ b/cmd/describe.go @@ -34,7 +34,7 @@ the current directory or from the directory specified with --path. ValidArgsFunction: CompleteFunctionList, Aliases: []string{"info", "desc"}, - PreRunE: bindEnv("output", "path", "namespace", "verbose"), + PreRunE: bindEnv("cluster", "cluster-token", "output", "path", "namespace", "verbose"), RunE: func(cmd *cobra.Command, args []string) error { return runDescribe(cmd, args, newClient) }, @@ -46,7 +46,15 @@ the current directory or from the directory specified with --path. fmt.Fprintf(cmd.OutOrStdout(), "error loading config at '%v'. %v\n", config.File(), err) } + // Function Context + f, _ := fn.NewFunction(effectivePath()) + if f.Initialized() { + cfg = cfg.Apply(f) + } + // Flags + cmd.Flags().String("cluster", cfg.Cluster, "Specify a cluster api url for your function deployment. ($FUNC_CLUSTER)") + cmd.Flags().String("cluster-token", "", "Bearer token for cluster authentication. ($FUNC_CLUSTER_TOKEN)") cmd.Flags().StringP("output", "o", "human", "Output format (human|plain|json|yaml|url) ($FUNC_OUTPUT)") cmd.Flags().StringP("namespace", "n", defaultNamespace(fn.Function{}, false), "The namespace in which to look for the named function. ($FUNC_NAMESPACE)") addPathFlag(cmd) @@ -66,11 +74,19 @@ func runDescribe(cmd *cobra.Command, args []string, newClient ClientFactory) (er } // TODO cfg.Prompt() - client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) - defer done() - var details fn.Instance if cfg.Name != "" { // Describe by name if provided + // don't use local.yaml auth because we are not concerned about func at path + if cfg.Cluster != "" { + cleanup, overrideErr := setupClusterOverride(cfg.Cluster, cfg.ClusterToken, cfg.Namespace, fn.Local{}, cmd.OutOrStderr()) + if overrideErr != nil { + return overrideErr + } + defer cleanup() + } + + client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) + defer done() details, err = client.Describe(cmd.Context(), cfg.Name, cfg.Namespace, fn.Function{}) if err != nil { return err @@ -83,6 +99,18 @@ func runDescribe(cmd *cobra.Command, args []string, newClient ClientFactory) (er if !f.Initialized() { return NewErrNotInitializedFromPath(f.Root, "describe") } + + // use local auth - function was **most likely** created locally + if cfg.Cluster != "" { + cleanup, overrideErr := setupClusterOverride(cfg.Cluster, cfg.ClusterToken, cfg.Namespace, f.Local, cmd.OutOrStderr()) + if overrideErr != nil { + return overrideErr + } + defer cleanup() + } + + client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) + defer done() details, err = client.Describe(cmd.Context(), "", "", f) if err != nil { return err @@ -97,11 +125,13 @@ func runDescribe(cmd *cobra.Command, args []string, newClient ClientFactory) (er // ------------------------------ type describeConfig struct { - Name string - Namespace string - Output string - Path string - Verbose bool + Cluster string + ClusterToken string + Name string + Namespace string + Output string + Path string + Verbose bool } func newDescribeConfig(cmd *cobra.Command, args []string) (cfg describeConfig, err error) { @@ -110,11 +140,13 @@ func newDescribeConfig(cmd *cobra.Command, args []string) (cfg describeConfig, e name = args[0] } cfg = describeConfig{ - Name: name, - Namespace: viper.GetString("namespace"), - Output: viper.GetString("output"), - Path: viper.GetString("path"), - Verbose: viper.GetBool("verbose"), + Cluster: viper.GetString("cluster"), + ClusterToken: viper.GetString("cluster-token"), + Name: name, + Namespace: viper.GetString("namespace"), + Output: viper.GetString("output"), + Path: viper.GetString("path"), + Verbose: viper.GetBool("verbose"), } if cfg.Name == "" && cmd.Flags().Changed("namespace") { // logically inconsistent to supply only a namespace. diff --git a/cmd/environment.go b/cmd/environment.go index 31aee04f31..c7cbcea2bf 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -39,7 +39,7 @@ DESCRIPTION the version of func, the version of the function spec, the default builder, available runtimes, and available templates. `, - PreRunE: bindEnv("verbose", "format", "path"), + PreRunE: bindEnv("verbose", "format", "path", "cluster-token"), RunE: func(cmd *cobra.Command, args []string) error { return runEnvironment(cmd, newClient, version) }, @@ -146,7 +146,7 @@ func runEnvironment(cmd *cobra.Command, newClient ClientFactory, v *Version) (er Defaults: defaults, } - function, instance := describeFuncInformation(cmd.Context(), newClient, cfg) + function, instance := describeFuncInformation(cmd.Context(), cmd, newClient, cfg) if function != nil { environment.Function = function } @@ -191,12 +191,21 @@ func getTemplates(client *functions.Client, runtimes []string) (map[string][]str return templateMap, nil } -func describeFuncInformation(context context.Context, newClient ClientFactory, cfg environmentConfig) (*functions.Function, *functions.Instance) { +func describeFuncInformation(context context.Context, cmd *cobra.Command, newClient ClientFactory, cfg environmentConfig) (*functions.Function, *functions.Instance) { function, err := functions.NewFunction(cfg.Path) if err != nil || !function.Initialized() { return nil, nil } + // use local auth - function was **most likely** created locally + if function.Deploy.Cluster != "" { + cleanup, overrideErr := setupClusterOverride(function.Deploy.Cluster, viper.GetString("cluster-token"), function.Deploy.Namespace, function.Local, cmd.OutOrStderr()) + if overrideErr != nil { + return &function, nil + } + defer cleanup() + } + client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) defer done() diff --git a/cmd/errors.go b/cmd/errors.go index 60d3361b4a..41da8a94f9 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -267,10 +267,11 @@ func (e *ErrClusterNotAccessible) Error() string { Cannot connect to Kubernetes cluster. No valid cluster configuration found. Try this: - minikube start Start Minikube cluster - kind create cluster Start Kind cluster - kubectl cluster-info Verify cluster is running - kubectl config get-contexts List available contexts + func deploy --cluster --cluster-token Connect directly without kubeconfig + minikube start Start Minikube cluster + kind create cluster Start Kind cluster + kubectl cluster-info Verify cluster is running + kubectl config get-contexts List available contexts For more options, run 'func deploy --help'`, e.Err) } // end if diff --git a/cmd/root.go b/cmd/root.go index a34fca3a9f..fb2d7cbc91 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -401,6 +401,6 @@ func surveySelectDefault(value string, options []string) string { return options[0] // Sync with the option which will be shown by the UX } // Either the value is not an option or there are no options. Either of - // which should fail proper validation + // which should fail proper validation return "" } diff --git a/docs/reference/func_delete.md b/docs/reference/func_delete.md index c22a87a692..52247bec9a 100644 --- a/docs/reference/func_delete.md +++ b/docs/reference/func_delete.md @@ -32,12 +32,14 @@ func delete myfunc --namespace apps ### Options ``` - -a, --all string Delete all resources created for a function, eg. Pipelines, Secrets, etc. ($FUNC_ALL) (allowed values: "true", "false") (default "true") - -c, --confirm Prompt to confirm options interactively ($FUNC_CONFIRM) - -h, --help help for delete - -n, --namespace string The namespace when deleting by name. ($FUNC_NAMESPACE) (default "default") - -p, --path string Path to the function. Default is current directory ($FUNC_PATH) - -v, --verbose Print verbose logs ($FUNC_VERBOSE) + -a, --all string Delete all resources created for a function, eg. Pipelines, Secrets, etc. ($FUNC_ALL) (allowed values: "true", "false") (default "true") + --cluster string Specify a cluster api url for your function deployment. ($FUNC_CLUSTER) + --cluster-token string Bearer token for cluster authentication. ($FUNC_CLUSTER_TOKEN) + -c, --confirm Prompt to confirm options interactively ($FUNC_CONFIRM) + -h, --help help for delete + -n, --namespace string The namespace when deleting by name. ($FUNC_NAMESPACE) (default "default") + -p, --path string Path to the function. Default is current directory ($FUNC_PATH) + -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` ### SEE ALSO diff --git a/docs/reference/func_deploy.md b/docs/reference/func_deploy.md index 630542c342..bac2e4e81c 100644 --- a/docs/reference/func_deploy.md +++ b/docs/reference/func_deploy.md @@ -118,6 +118,8 @@ func deploy --build-timestamp Use the actual time as the created time for the docker image. This is only useful for buildpacks builder. -b, --builder string Builder to use when creating the function's container. Currently supported builders are "host", "pack" and "s2i". (default "pack") --builder-image string Specify a custom builder image for use by the builder other than its default. ($FUNC_BUILDER_IMAGE) + --cluster string Specify a cluster api url for your function deployment. ($FUNC_CLUSTER) + --cluster-token string Bearer token for cluster authentication. Persisted to .func/local.yaml on successful deploy. ($FUNC_CLUSTER_TOKEN) -c, --confirm Prompt to confirm options interactively ($FUNC_CONFIRM) --deployer string Type of deployment to use: 'knative' for Knative Service (default), 'raw' for Kubernetes Deployment or 'keda' for Deployment with a Keda HTTP scaler ($FUNC_DEPLOY_TYPE) --domain string Domain to use for the function's route. Cluster must be configured with domain matching for the given domain (ignored if unrecognized) ($FUNC_DOMAIN) diff --git a/docs/reference/func_describe.md b/docs/reference/func_describe.md index 7e5254392b..1904a9ff7e 100644 --- a/docs/reference/func_describe.md +++ b/docs/reference/func_describe.md @@ -29,11 +29,13 @@ func describe --output yaml --path myotherfunc ### Options ``` - -h, --help help for describe - -n, --namespace string The namespace in which to look for the named function. ($FUNC_NAMESPACE) (default "default") - -o, --output string Output format (human|plain|json|yaml|url) ($FUNC_OUTPUT) (default "human") - -p, --path string Path to the function. Default is current directory ($FUNC_PATH) - -v, --verbose Print verbose logs ($FUNC_VERBOSE) + --cluster string Specify a cluster api url for your function deployment. ($FUNC_CLUSTER) + --cluster-token string Bearer token for cluster authentication. ($FUNC_CLUSTER_TOKEN) + -h, --help help for describe + -n, --namespace string The namespace in which to look for the named function. ($FUNC_NAMESPACE) (default "default") + -o, --output string Output format (human|plain|json|yaml|url) ($FUNC_OUTPUT) (default "human") + -p, --path string Path to the function. Default is current directory ($FUNC_PATH) + -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` ### SEE ALSO diff --git a/pkg/config/config.go b/pkg/config/config.go index 55a1fac13e..f16e5652cb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -31,17 +31,18 @@ const ( // Global configuration settings. type Global struct { - Builder string `yaml:"builder,omitempty"` - Confirm bool `yaml:"confirm,omitempty"` - Language string `yaml:"language,omitempty"` - Namespace string `yaml:"namespace,omitempty"` - Registry string `yaml:"registry,omitempty"` - Verbose bool `yaml:"verbose,omitempty"` + Builder string `yaml:"builder,omitempty"` + Confirm bool `yaml:"confirm,omitempty"` + Language string `yaml:"language,omitempty"` + Namespace string `yaml:"namespace,omitempty"` + Registry string `yaml:"registry,omitempty"` + Verbose bool `yaml:"verbose,omitempty"` + RegistryInsecure bool `yaml:"registryInsecure,omitempty"` + Cluster string `yaml:"cluster,omitempty"` + // NOTE: all members must include their yaml serialized names, even when // this is the default, because these tag values are used for the static // getter/setter accessors to match requests. - - RegistryInsecure bool `yaml:"registryInsecure,omitempty"` } // New Config struct with all members set to static defaults. See NewDefaults @@ -141,6 +142,9 @@ func (c Global) Apply(f fn.Function) Global { // Unconditional because bool has no "empty value". Works because // viper resolves the correct precedence via our defaulting. c.RegistryInsecure = f.RegistryInsecure + if f.Deploy.Cluster != "" { + c.Cluster = f.Deploy.Cluster + } return c } @@ -164,6 +168,9 @@ func (c Global) Configure(f fn.Function) fn.Function { // viper resolves the correct precedence via our defaulting. f.RegistryInsecure = c.RegistryInsecure + // Unconditional to allow --cluster= (empty value) to use kubeconfig context + f.Deploy.Cluster = c.Cluster + return f } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1e3dd287b8..d8a0d6f655 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -372,6 +372,7 @@ func TestList(t *testing.T) { values := config.List() expected := []string{ "builder", + "cluster", "confirm", "language", "namespace", diff --git a/pkg/functions/function.go b/pkg/functions/function.go index 09d2506273..6ddbff6e49 100644 --- a/pkg/functions/function.go +++ b/pkg/functions/function.go @@ -48,6 +48,74 @@ type Local struct { // Remote indicates the deployment (and possibly build) process are to // be triggered in a remote environment rather than run locally. Remote bool `yaml:"remote,omitempty"` + + // Auth holds per-cluster authentication entries + Auth []AuthEntry `yaml:"auth,omitempty"` +} + +// Note: the following cluster auth yaml tags are in kebab-case because they +// intend to mirror kubeconfig's naming convention even though it breaks the +// consistency of the rest of this codebase which uses camelCase. + +// AuthEntry holds cluster TLS and user auth for a single cluster URL. +type AuthEntry struct { + ClusterURL string `yaml:"cluster-url"` + Cluster ClusterVerify `yaml:"cluster,omitempty"` + User UserAuth `yaml:"user,omitempty"` +} + +// ClusterVerify holds server identity verification settings for a cluster. +type ClusterVerify struct { + CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty"` + InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify,omitempty"` +} + +// UserAuth holds user credentials for authenticating to a cluster. +type UserAuth struct { + ClientCertificateData string `yaml:"client-certificate-data,omitempty"` + ClientKeyData string `yaml:"client-key-data,omitempty"` + Token string `yaml:"token,omitempty"` + Exec *ExecAuth `yaml:"exec,omitempty"` +} + +type ExecAuth struct { + Command string `yaml:"command"` + Args []string `yaml:"args,omitempty"` + Env []ExecEnv `yaml:"env,omitempty"` + APIVersion string `yaml:"apiVersion,omitempty"` +} + +type ExecEnv struct { + Name string `yaml:"name"` + Value string `yaml:"value"` +} + +// FindAuth returns the AuthEntry matching the given cluster URL, or nil if +// no entry matches. +func (l Local) FindAuth(clusterURL string) *AuthEntry { + for i := range l.Auth { + if l.Auth[i].ClusterURL == clusterURL { + return &l.Auth[i] + } + } + return nil +} + +// SetAuth upserts an auth entry for the given cluster URL. If an entry with the +// same URL already exists it is updated; otherwise a new entry is appended. +func (l *Local) SetAuth(clusterURL string, cluster ClusterVerify, user UserAuth) { + for i := range l.Auth { + if l.Auth[i].ClusterURL == clusterURL { + l.Auth[i].Cluster = cluster + l.Auth[i].User = user + return + } + } + l.Auth = append(l.Auth, AuthEntry{ + ClusterURL: clusterURL, + Cluster: cluster, + User: user, + }) } // Function @@ -196,6 +264,9 @@ type DeploySpec struct { // Namespace into which the function was deployed on supported platforms. Namespace string `yaml:"namespace,omitempty"` + // Cluster is the cluster api url where the function is deployed + Cluster string `yaml:"cluster,omitempty"` + // Image is the deployed image including sha256 Image string `yaml:"image,omitempty"` diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index 8d528280df..53b1d3496d 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -1,13 +1,18 @@ package k8s import ( + "encoding/base64" "fmt" "time" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + fn "knative.dev/func/pkg/functions" ) const ( @@ -15,6 +20,157 @@ const ( DefaultErrorWindowTimeout = 2 * time.Second ) +var restConfigOverride *rest.Config +var namespaceOverride string + +// SetClusterOverride bypasses kubeconfig entirely by building a rest.Config +// from the given cluster URL and auth credentials. All subsequent +// GetClientConfig() calls will use this config until the returned cleanup +// function is called. +// +// The namespace parameter sets the default namespace returned by +// staticClientConfig.Namespace(). This is needed because the in-cluster +// dialer creates pods in the namespace returned by GetDefaultNamespace(), +// and without it the pod lands in "default" — which on OpenShift is +// restricted by SCC and rejects containers running as root. +func SetClusterOverride(clusterURL, namespace string, cluster fn.ClusterVerify, user fn.UserAuth) (func(), error) { + cfg, err := BuildRestConfig(clusterURL, cluster, user) + if err != nil { + return nil, err + } + restConfigOverride = cfg + namespaceOverride = namespace + return ClearClusterOverride, nil +} + +// ClearClusterOverride removes the direct cluster override, reverting to +// kubeconfig-based configuration. +func ClearClusterOverride() { + restConfigOverride = nil + namespaceOverride = "" +} + +// BuildRestConfig creates a *rest.Config from a cluster URL, ClusterVerify, and +// UserAuth. Base64-encoded fields are decoded. +// +// All credentials present in UserAuth are applied to the rest.Config. The +// Kubernetes API server will authenticate whichever succeeds — for example a +// stale token may fail while a client certificate still works. If no +// credentials are provided the config is still valid; the API server will +// return 401/403. +func BuildRestConfig(clusterURL string, cluster fn.ClusterVerify, user fn.UserAuth) (*rest.Config, error) { + cfg := &rest.Config{ + Host: clusterURL, + } + + if cluster.CertificateAuthorityData != "" { + raw, err := base64.StdEncoding.DecodeString(cluster.CertificateAuthorityData) + if err != nil { + return nil, fmt.Errorf("failed to decode certificate-authority-data: %w", err) + } + cfg.CAData = raw + } + cfg.Insecure = cluster.InsecureSkipTLSVerify + + if user.Token != "" { + cfg.BearerToken = user.Token + } + + if user.ClientCertificateData != "" { + raw, err := base64.StdEncoding.DecodeString(user.ClientCertificateData) + if err != nil { + return nil, fmt.Errorf("failed to decode client-certificate-data: %w", err) + } + cfg.CertData = raw + } + if user.ClientKeyData != "" { + raw, err := base64.StdEncoding.DecodeString(user.ClientKeyData) + if err != nil { + return nil, fmt.Errorf("failed to decode client-key-data: %w", err) + } + cfg.KeyData = raw + } + + if user.Exec != nil { + execCfg := &clientcmdapi.ExecConfig{ + Command: user.Exec.Command, + Args: user.Exec.Args, + APIVersion: user.Exec.APIVersion, + } + for _, e := range user.Exec.Env { + execCfg.Env = append(execCfg.Env, clientcmdapi.ExecEnvVar{ + Name: e.Name, + Value: e.Value, + }) + } + cfg.ExecProvider = execCfg + } + + return cfg, nil +} + +// staticClientConfig wraps a *rest.Config to implement clientcmd.ClientConfig. +// This allows code that expects a ClientConfig (e.g. the dialer) to work with +// a directly-constructed rest.Config. +type staticClientConfig struct { + cfg *rest.Config +} + +func (s *staticClientConfig) ClientConfig() (*rest.Config, error) { + out := *s.cfg + return &out, nil +} + +func (s *staticClientConfig) Namespace() (string, bool, error) { + if namespaceOverride != "" { + return namespaceOverride, true, nil + } + return "default", true, nil +} + +func (s *staticClientConfig) RawConfig() (clientcmdapi.Config, error) { + const name = "static" + ns := namespaceOverride + if ns == "" { + ns = "default" + } + return clientcmdapi.Config{ + CurrentContext: name, + Contexts: map[string]*clientcmdapi.Context{ + name: {Cluster: name, AuthInfo: name, Namespace: ns}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + name: { + Server: s.cfg.Host, + CertificateAuthorityData: s.cfg.CAData, + InsecureSkipTLSVerify: s.cfg.Insecure, + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + name: { + Token: s.cfg.BearerToken, + ClientCertificateData: s.cfg.CertData, + ClientKeyData: s.cfg.KeyData, + Exec: s.cfg.ExecProvider, + }, + }, + }, nil +} + +func (s *staticClientConfig) ConfigAccess() clientcmd.ConfigAccess { + return nil +} + +func GetClientConfig() clientcmd.ClientConfig { + if restConfigOverride != nil { + return &staticClientConfig{cfg: restConfigOverride} + } + + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}) +} + func NewClientAndResolvedNamespace(ns string) (*kubernetes.Clientset, string, error) { var err error if ns == "" { @@ -51,9 +207,3 @@ func GetDefaultNamespace() (namespace string, err error) { namespace, _, err = GetClientConfig().Namespace() return } - -func GetClientConfig() clientcmd.ClientConfig { - return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - clientcmd.NewDefaultClientConfigLoadingRules(), - &clientcmd.ConfigOverrides{}) -} diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go new file mode 100644 index 0000000000..8aff503682 --- /dev/null +++ b/pkg/k8s/client_test.go @@ -0,0 +1,316 @@ +package k8s + +import ( + "encoding/base64" + "testing" + + "k8s.io/client-go/rest" + + fn "knative.dev/func/pkg/functions" +) + +func TestBuildRestConfig_NoAuth(t *testing.T) { + cfg, err := BuildRestConfig("https://example.com", fn.ClusterVerify{}, fn.UserAuth{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Host != "https://example.com" { + t.Fatalf("expected host https://example.com, got %s", cfg.Host) + } + if cfg.BearerToken != "" { + t.Fatalf("expected empty token, got %s", cfg.BearerToken) + } +} + +func TestBuildRestConfig_Token(t *testing.T) { + cfg, err := BuildRestConfig("https://example.com:6443", fn.ClusterVerify{}, fn.UserAuth{ + Token: "my-token", + }) + if err != nil { + t.Fatal(err) + } + if cfg.Host != "https://example.com:6443" { + t.Fatalf("expected host https://example.com:6443, got %s", cfg.Host) + } + if cfg.BearerToken != "my-token" { + t.Fatalf("expected token my-token, got %s", cfg.BearerToken) + } +} + +func TestBuildRestConfig_ClientCert(t *testing.T) { + certPEM := base64.StdEncoding.EncodeToString([]byte("CERT")) + keyPEM := base64.StdEncoding.EncodeToString([]byte("KEY")) + + cfg, err := BuildRestConfig("https://example.com", fn.ClusterVerify{}, fn.UserAuth{ + ClientCertificateData: certPEM, + ClientKeyData: keyPEM, + }) + if err != nil { + t.Fatal(err) + } + if string(cfg.CertData) != "CERT" { + t.Fatalf("unexpected CertData: %s", cfg.CertData) + } + if string(cfg.KeyData) != "KEY" { + t.Fatalf("unexpected KeyData: %s", cfg.KeyData) + } +} + +func TestBuildRestConfig_Exec(t *testing.T) { + cfg, err := BuildRestConfig("https://example.com", fn.ClusterVerify{}, fn.UserAuth{ + Exec: &fn.ExecAuth{ + Command: "gke-gcloud-auth-plugin", + Args: []string{"--region", "us-central1"}, + APIVersion: "client.authentication.k8s.io/v1beta1", + Env: []fn.ExecEnv{ + {Name: "USE_GKE_GCLOUD_AUTH_PLUGIN", Value: "True"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if cfg.ExecProvider == nil { + t.Fatal("expected ExecProvider to be set") + } + if cfg.ExecProvider.Command != "gke-gcloud-auth-plugin" { + t.Fatalf("unexpected command: %s", cfg.ExecProvider.Command) + } + if cfg.ExecProvider.APIVersion != "client.authentication.k8s.io/v1beta1" { + t.Fatalf("unexpected apiVersion: %s", cfg.ExecProvider.APIVersion) + } + if len(cfg.ExecProvider.Args) != 2 || cfg.ExecProvider.Args[0] != "--region" { + t.Fatalf("unexpected args: %v", cfg.ExecProvider.Args) + } + if len(cfg.ExecProvider.Env) != 1 || cfg.ExecProvider.Env[0].Name != "USE_GKE_GCLOUD_AUTH_PLUGIN" { + t.Fatalf("unexpected env: %v", cfg.ExecProvider.Env) + } +} + +func TestBuildRestConfig_TLS(t *testing.T) { + caData := base64.StdEncoding.EncodeToString([]byte("CA-CERT")) + + cfg, err := BuildRestConfig("https://example.com", + fn.ClusterVerify{ + CertificateAuthorityData: caData, + InsecureSkipTLSVerify: true, + }, + fn.UserAuth{ + Token: "tok", + }, + ) + if err != nil { + t.Fatal(err) + } + if string(cfg.CAData) != "CA-CERT" { + t.Fatalf("unexpected CAData: %s", cfg.CAData) + } + if !cfg.Insecure { + t.Fatal("expected Insecure=true") + } +} + +func TestBuildRestConfig_BadBase64(t *testing.T) { + _, err := BuildRestConfig("https://example.com", + fn.ClusterVerify{ + CertificateAuthorityData: "not-valid-base64!@#$", + }, + fn.UserAuth{ + Token: "tok", + }, + ) + if err == nil { + t.Fatal("expected error for bad base64 certificate-authority-data") + } + + _, err = BuildRestConfig("https://example.com", fn.ClusterVerify{}, fn.UserAuth{ + ClientCertificateData: "not-valid!@#$", + ClientKeyData: base64.StdEncoding.EncodeToString([]byte("KEY")), + }) + if err == nil { + t.Fatal("expected error for bad base64 client-certificate-data") + } + + _, err = BuildRestConfig("https://example.com", fn.ClusterVerify{}, fn.UserAuth{ + ClientCertificateData: base64.StdEncoding.EncodeToString([]byte("CERT")), + ClientKeyData: "not-valid!@#$", + }) + if err == nil { + t.Fatal("expected error for bad base64 client-key-data") + } +} + +func TestBuildRestConfig_AllCredentialsApplied(t *testing.T) { + certPEM := base64.StdEncoding.EncodeToString([]byte("CERT")) + keyPEM := base64.StdEncoding.EncodeToString([]byte("KEY")) + + cfg, err := BuildRestConfig("https://example.com", fn.ClusterVerify{}, fn.UserAuth{ + Token: "my-token", + ClientCertificateData: certPEM, + ClientKeyData: keyPEM, + Exec: &fn.ExecAuth{ + Command: "some-plugin", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + }) + if err != nil { + t.Fatal(err) + } + if cfg.BearerToken != "my-token" { + t.Fatalf("expected token my-token, got %s", cfg.BearerToken) + } + if string(cfg.CertData) != "CERT" { + t.Fatal("expected CertData to be set alongside token") + } + if string(cfg.KeyData) != "KEY" { + t.Fatal("expected KeyData to be set alongside token") + } + if cfg.ExecProvider == nil { + t.Fatal("expected ExecProvider to be set alongside token") + } +} + +func TestSetClusterOverride(t *testing.T) { + cleanup, err := SetClusterOverride("https://override.example.com", "", fn.ClusterVerify{}, fn.UserAuth{ + Token: "override-token", + }) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cc := GetClientConfig() + cfg, err := cc.ClientConfig() + if err != nil { + t.Fatal(err) + } + if cfg.Host != "https://override.example.com" { + t.Fatalf("expected override host, got %s", cfg.Host) + } + if cfg.BearerToken != "override-token" { + t.Fatalf("expected override token, got %s", cfg.BearerToken) + } + + ns, _, err := cc.Namespace() + if err != nil { + t.Fatal(err) + } + if ns != "default" { + t.Fatalf("expected default, got %s", ns) + } +} + +func TestSetClusterOverride_WithNamespace(t *testing.T) { + cleanup, err := SetClusterOverride("https://override.example.com", "my-namespace", fn.ClusterVerify{}, fn.UserAuth{ + Token: "override-token", + }) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cc := GetClientConfig() + ns, _, err := cc.Namespace() + if err != nil { + t.Fatal(err) + } + if ns != "my-namespace" { + t.Fatalf("expected my-namespace, got %s", ns) + } +} + +func TestSetClusterOverride_NoAuth(t *testing.T) { + cleanup, err := SetClusterOverride("https://example.com", "", fn.ClusterVerify{}, fn.UserAuth{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cleanup == nil { + t.Fatal("expected cleanup function") + } + cleanup() +} + +func TestClearClusterOverride(t *testing.T) { + cleanup, err := SetClusterOverride("https://override.example.com", "test-ns", fn.ClusterVerify{}, fn.UserAuth{ + Token: "tok", + }) + if err != nil { + t.Fatal(err) + } + cleanup() + + if restConfigOverride != nil { + t.Fatal("expected restConfigOverride to be nil after cleanup") + } + if namespaceOverride != "" { + t.Fatal("expected namespaceOverride to be empty after cleanup") + } +} + +func TestStaticClientConfig_ReturnsCopy(t *testing.T) { + cleanup, err := SetClusterOverride("https://example.com", "", fn.ClusterVerify{}, fn.UserAuth{ + Token: "tok", + }) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cc := GetClientConfig() + cfg1, _ := cc.ClientConfig() + cfg2, _ := cc.ClientConfig() + cfg1.Host = "mutated" + if cfg2.Host == "mutated" { + t.Fatal("ClientConfig() should return independent copies") + } +} + +func TestStaticClientConfig_RawConfig(t *testing.T) { + cleanup, err := SetClusterOverride("https://example.com", "my-ns", fn.ClusterVerify{}, fn.UserAuth{ + Token: "my-token", + }) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + cc := GetClientConfig() + raw, err := cc.RawConfig() + if err != nil { + t.Fatal(err) + } + if raw.CurrentContext == "" { + t.Fatal("expected non-empty CurrentContext") + } + ctx := raw.Contexts[raw.CurrentContext] + if ctx == nil { + t.Fatal("expected context entry") + } + cluster := raw.Clusters[ctx.Cluster] + if cluster == nil || cluster.Server != "https://example.com" { + t.Fatalf("expected server https://example.com, got %v", cluster) + } + authInfo := raw.AuthInfos[ctx.AuthInfo] + if authInfo == nil || authInfo.Token != "my-token" { + t.Fatalf("expected token my-token, got %v", authInfo) + } + if ctx.Namespace != "my-ns" { + t.Fatalf("expected namespace my-ns, got %s", ctx.Namespace) + } +} + +func TestStaticClientConfig_ConfigAccess(t *testing.T) { + s := &staticClientConfig{cfg: &rest.Config{}} + if s.ConfigAccess() != nil { + t.Fatal("expected nil ConfigAccess") + } +} + +func TestGetClientConfig_FallsBackToKubeconfig(t *testing.T) { + ClearClusterOverride() + cc := GetClientConfig() + _, ok := cc.(*staticClientConfig) + if ok { + t.Fatal("expected kubeconfig-based ClientConfig when no override is set") + } +} diff --git a/pkg/k8s/extract.go b/pkg/k8s/extract.go new file mode 100644 index 0000000000..08582ba306 --- /dev/null +++ b/pkg/k8s/extract.go @@ -0,0 +1,73 @@ +package k8s + +import ( + "encoding/base64" + "fmt" + + fn "knative.dev/func/pkg/functions" +) + +// ExtractClusterAuth reads the active kubeconfig context and returns the +// cluster URL, ClusterVerify and UserAuth suitable for storage in local.yaml. +// Certificate/key data from the kubeconfig ([]byte) is base64-encoded into the +// string fields expected by ClusterVerify / UserAuth. +func ExtractClusterAuth() (clusterURL string, clusterTLS fn.ClusterVerify, user fn.UserAuth, err error) { + // Load and resolve the active kubeconfig context + rawConfig, err := GetClientConfig().RawConfig() + if err != nil { + return "", fn.ClusterVerify{}, fn.UserAuth{}, fmt.Errorf("failed to load kubeconfig: %w", err) + } + + ctxName := rawConfig.CurrentContext + if ctxName == "" { + return "", fn.ClusterVerify{}, fn.UserAuth{}, fmt.Errorf("no current context set in kubeconfig") + } + + ctx, ok := rawConfig.Contexts[ctxName] + if !ok { + return "", fn.ClusterVerify{}, fn.UserAuth{}, fmt.Errorf("context %q not found in kubeconfig", ctxName) + } + + // Look up cluster and user entries referenced by the context + cluster, ok := rawConfig.Clusters[ctx.Cluster] + if !ok { + return "", fn.ClusterVerify{}, fn.UserAuth{}, fmt.Errorf("cluster %q (from context %q) not found in kubeconfig", ctx.Cluster, ctxName) + } + + authInfo, ok := rawConfig.AuthInfos[ctx.AuthInfo] + if !ok { + return "", fn.ClusterVerify{}, fn.UserAuth{}, fmt.Errorf("user %q (from context %q) not found in kubeconfig", ctx.AuthInfo, ctxName) + } + + // Extract cluster verification settings + clusterURL = cluster.Server + if len(cluster.CertificateAuthorityData) > 0 { + clusterTLS.CertificateAuthorityData = base64.StdEncoding.EncodeToString(cluster.CertificateAuthorityData) + } + clusterTLS.InsecureSkipTLSVerify = cluster.InsecureSkipTLSVerify + + // Extract all auth credentials present — client-go handles precedence + if authInfo.Token != "" { + user.Token = authInfo.Token + } + if len(authInfo.ClientCertificateData) > 0 && len(authInfo.ClientKeyData) > 0 { + user.ClientCertificateData = base64.StdEncoding.EncodeToString(authInfo.ClientCertificateData) + user.ClientKeyData = base64.StdEncoding.EncodeToString(authInfo.ClientKeyData) + } + if authInfo.Exec != nil { + execAuth := &fn.ExecAuth{ + Command: authInfo.Exec.Command, + Args: authInfo.Exec.Args, + APIVersion: authInfo.Exec.APIVersion, + } + for _, e := range authInfo.Exec.Env { + execAuth.Env = append(execAuth.Env, fn.ExecEnv{ + Name: e.Name, + Value: e.Value, + }) + } + user.Exec = execAuth + } + + return clusterURL, clusterTLS, user, nil +} diff --git a/pkg/k8s/extract_test.go b/pkg/k8s/extract_test.go new file mode 100644 index 0000000000..2cafaecefe --- /dev/null +++ b/pkg/k8s/extract_test.go @@ -0,0 +1,332 @@ +package k8s + +import ( + "encoding/base64" + "path/filepath" + "testing" + + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "k8s.io/client-go/tools/clientcmd" +) + +// writeTestKubeconfig serialises a clientcmdapi.Config to a temp file and +// points the KUBECONFIG env var at it for the duration of the test. +func writeTestKubeconfig(t *testing.T, cfg clientcmdapi.Config) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "kubeconfig") + if err := clientcmd.WriteToFile(cfg, path); err != nil { + t.Fatalf("failed to write test kubeconfig: %v", err) + } + t.Setenv("KUBECONFIG", path) +} + +func TestExtractClusterAuth_Token(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "test-ctx", + Contexts: map[string]*clientcmdapi.Context{ + "test-ctx": {Cluster: "test-cluster", AuthInfo: "test-user"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "test-cluster": { + Server: "https://example.com:6443", + CertificateAuthorityData: []byte("CA-DATA"), + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "test-user": {Token: "my-token"}, + }, + }) + + // Ensure no cluster override interferes. + ClearClusterOverride() + + url, clusterTLS, user, err := ExtractClusterAuth() + if err != nil { + t.Fatal(err) + } + if url != "https://example.com:6443" { + t.Fatalf("expected https://example.com:6443, got %s", url) + } + if user.Token != "my-token" { + t.Fatalf("expected token my-token, got %s", user.Token) + } + + // CertificateAuthorityData should be base64-encoded. + decoded, err := base64.StdEncoding.DecodeString(clusterTLS.CertificateAuthorityData) + if err != nil { + t.Fatalf("CertificateAuthorityData is not valid base64: %v", err) + } + if string(decoded) != "CA-DATA" { + t.Fatalf("unexpected CertificateAuthorityData: %s", string(decoded)) + } +} + +func TestExtractClusterAuth_ClientCert(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "ctx", + Contexts: map[string]*clientcmdapi.Context{ + "ctx": {Cluster: "c", AuthInfo: "u"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "c": {Server: "https://cert.example.com"}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "u": { + ClientCertificateData: []byte("CERT-PEM"), + ClientKeyData: []byte("KEY-PEM"), + }, + }, + }) + ClearClusterOverride() + + url, _, user, err := ExtractClusterAuth() + if err != nil { + t.Fatal(err) + } + if url != "https://cert.example.com" { + t.Fatalf("unexpected URL: %s", url) + } + if user.Token != "" { + t.Fatal("expected empty token for client cert auth") + } + + certDecoded, _ := base64.StdEncoding.DecodeString(user.ClientCertificateData) + if string(certDecoded) != "CERT-PEM" { + t.Fatalf("unexpected ClientCertificateData: %s", string(certDecoded)) + } + keyDecoded, _ := base64.StdEncoding.DecodeString(user.ClientKeyData) + if string(keyDecoded) != "KEY-PEM" { + t.Fatalf("unexpected ClientKeyData: %s", string(keyDecoded)) + } +} + +func TestExtractClusterAuth_Exec(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "ctx", + Contexts: map[string]*clientcmdapi.Context{ + "ctx": {Cluster: "c", AuthInfo: "u"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "c": {Server: "https://exec.example.com"}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "u": { + Exec: &clientcmdapi.ExecConfig{ + Command: "gke-gcloud-auth-plugin", + Args: []string{"--region", "us-central1"}, + APIVersion: "client.authentication.k8s.io/v1beta1", + Env: []clientcmdapi.ExecEnvVar{ + {Name: "USE_GKE_GCLOUD_AUTH_PLUGIN", Value: "True"}, + }, + }, + }, + }, + }) + ClearClusterOverride() + + _, _, user, err := ExtractClusterAuth() + if err != nil { + t.Fatal(err) + } + if user.Exec == nil { + t.Fatal("expected Exec to be set") + } + if user.Exec.Command != "gke-gcloud-auth-plugin" { + t.Fatalf("unexpected command: %s", user.Exec.Command) + } + if user.Exec.APIVersion != "client.authentication.k8s.io/v1beta1" { + t.Fatalf("unexpected apiVersion: %s", user.Exec.APIVersion) + } + if len(user.Exec.Args) != 2 || user.Exec.Args[0] != "--region" { + t.Fatalf("unexpected args: %v", user.Exec.Args) + } + if len(user.Exec.Env) != 1 || user.Exec.Env[0].Name != "USE_GKE_GCLOUD_AUTH_PLUGIN" { + t.Fatalf("unexpected env: %v", user.Exec.Env) + } +} + +func TestExtractClusterAuth_InsecureSkipTLS(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "ctx", + Contexts: map[string]*clientcmdapi.Context{ + "ctx": {Cluster: "c", AuthInfo: "u"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "c": { + Server: "https://insecure.example.com", + InsecureSkipTLSVerify: true, + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "u": {Token: "tok"}, + }, + }) + ClearClusterOverride() + + _, clusterTLS, _, err := ExtractClusterAuth() + if err != nil { + t.Fatal(err) + } + if !clusterTLS.InsecureSkipTLSVerify { + t.Fatal("expected InsecureSkipTLSVerify=true") + } +} + +func TestExtractClusterAuth_NoCurrentContext(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{}) + ClearClusterOverride() + + _, _, _, err := ExtractClusterAuth() + if err == nil { + t.Fatal("expected error for no current context") + } +} + +func TestExtractClusterAuth_MissingContext(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "does-not-exist", + }) + ClearClusterOverride() + + _, _, _, err := ExtractClusterAuth() + if err == nil { + t.Fatal("expected error for missing context") + } +} + +func TestExtractClusterAuth_MissingCluster(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "ctx", + Contexts: map[string]*clientcmdapi.Context{ + "ctx": {Cluster: "no-such-cluster", AuthInfo: "u"}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "u": {Token: "tok"}, + }, + }) + ClearClusterOverride() + + _, _, _, err := ExtractClusterAuth() + if err == nil { + t.Fatal("expected error for missing cluster") + } +} + +func TestExtractClusterAuth_MissingUser(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "ctx", + Contexts: map[string]*clientcmdapi.Context{ + "ctx": {Cluster: "c", AuthInfo: "no-such-user"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "c": {Server: "https://example.com"}, + }, + }) + ClearClusterOverride() + + _, _, _, err := ExtractClusterAuth() + if err == nil { + t.Fatal("expected error for missing user") + } +} + +func TestExtractClusterAuth_AllCredentialsExtracted(t *testing.T) { + // When both token and client cert are present, both are extracted. + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "ctx", + Contexts: map[string]*clientcmdapi.Context{ + "ctx": {Cluster: "c", AuthInfo: "u"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "c": {Server: "https://example.com"}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "u": { + Token: "my-token", + ClientCertificateData: []byte("CERT"), + ClientKeyData: []byte("KEY"), + }, + }, + }) + ClearClusterOverride() + + _, _, user, err := ExtractClusterAuth() + if err != nil { + t.Fatal(err) + } + if user.Token != "my-token" { + t.Fatalf("expected token, got %s", user.Token) + } + if user.ClientCertificateData == "" { + t.Fatal("expected ClientCertificateData to be extracted alongside token") + } +} + +func TestExtractClusterAuth_NoCAData(t *testing.T) { + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "ctx", + Contexts: map[string]*clientcmdapi.Context{ + "ctx": {Cluster: "c", AuthInfo: "u"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "c": {Server: "https://example.com"}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "u": {Token: "tok"}, + }, + }) + ClearClusterOverride() + + _, clusterTLS, _, err := ExtractClusterAuth() + if err != nil { + t.Fatal(err) + } + if clusterTLS.CertificateAuthorityData != "" { + t.Fatalf("expected empty CertificateAuthorityData, got %s", clusterTLS.CertificateAuthorityData) + } +} + +func TestExtractClusterAuth_IgnoresFileKubeconfig(t *testing.T) { + // Verify that a non-existent KUBECONFIG file causes a sensible error + // (not a panic). + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "nonexistent")) + ClearClusterOverride() + + _, _, _, err := ExtractClusterAuth() + if err == nil { + // We expect an error because the kubeconfig doesn't exist or is empty, + // so there's no current context. + t.Fatal("expected error for missing kubeconfig file") + } +} + +func TestExtractClusterAuth_RealEnvIsolated(t *testing.T) { + // Ensure KUBECONFIG override isolates us from the real config. + writeTestKubeconfig(t, clientcmdapi.Config{ + CurrentContext: "isolated", + Contexts: map[string]*clientcmdapi.Context{ + "isolated": {Cluster: "c", AuthInfo: "u"}, + }, + Clusters: map[string]*clientcmdapi.Cluster{ + "c": {Server: "https://isolated.example.com"}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "u": {Token: "isolated-token"}, + }, + }) + ClearClusterOverride() + + url, _, user, err := ExtractClusterAuth() + if err != nil { + t.Fatal(err) + } + if url != "https://isolated.example.com" { + t.Fatalf("expected isolated URL, got %s", url) + } + if user.Token != "isolated-token" { + t.Fatalf("expected isolated-token, got %s", user.Token) + } + +} diff --git a/schema/func_yaml-schema.json b/schema/func_yaml-schema.json index 762abdfe47..d731b04aa2 100644 --- a/schema/func_yaml-schema.json +++ b/schema/func_yaml-schema.json @@ -72,6 +72,10 @@ "type": "string", "description": "Namespace into which the function was deployed on supported platforms." }, + "cluster": { + "type": "string", + "description": "Cluster is the cluster api url where the function is deployed" + }, "image": { "type": "string", "description": "Image is the deployed image including sha256"