-
Notifications
You must be signed in to change notification settings - Fork 357
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[ENC-1742] Add
encore kubernetes configure
command (#892)
This commit adds a new command to the Encore CLI, which allows Encore to configure your `~/.kube/config` file with all the Kubernetes clusters running in your applications environment. Once configured, you can use `kubectl` directly from your computer against your Encore provisioned kubernetes clusters, without needing to configure firewalls in your cloud account. Initially, this command is only available for application owners.
- Loading branch information
Showing
13 changed files
with
966 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
package k8s | ||
|
||
import ( | ||
"encoding/json" | ||
"os" | ||
|
||
"github.com/spf13/cobra" | ||
|
||
"encr.dev/cli/cmd/encore/cmdutil" | ||
"encr.dev/cli/cmd/encore/k8s/types" | ||
"encr.dev/internal/conf" | ||
) | ||
|
||
var genAuthCmd = &cobra.Command{ | ||
Use: "exec-credentials", | ||
Short: "Used by kubectl to get an authentication token for the Encore Kubernetes Proxy", | ||
Args: cobra.NoArgs, | ||
Hidden: true, | ||
DisableFlagsInUseLine: true, | ||
Run: func(cmd *cobra.Command, args []string) { generateExecCredentials() }, | ||
} | ||
|
||
func init() { | ||
kubernetesCmd.AddCommand(genAuthCmd) | ||
} | ||
|
||
// GenerateExecCredentials generates the Kubernetes exec credentials and writes them to stdout. | ||
// | ||
// If an error occurs, it is written to stderr and the program exits with a non-zero exit code. | ||
func generateExecCredentials() { | ||
// Get the OAuth token from the Encore API | ||
token, err := conf.DefaultTokenSource.Token() | ||
if err != nil { | ||
cmdutil.Fatalf("error getting token: %v", err) | ||
} | ||
|
||
// Generate the kuberentes exec credentials datastructures | ||
expiryTime := types.NewTime(token.Expiry) | ||
execCredentials := &types.ExecCredential{ | ||
TypeMeta: types.TypeMeta{ | ||
APIVersion: "client.authentication.k8s.io/v1", | ||
Kind: "ExecCredential", | ||
}, | ||
Status: &types.ExecCredentialStatus{ | ||
Token: token.AccessToken, | ||
ExpirationTimestamp: &expiryTime, | ||
}, | ||
} | ||
|
||
// Marshal the exec credentials to JSON and write to stdout | ||
output, err := json.MarshalIndent(execCredentials, "", " ") | ||
if err != nil { | ||
cmdutil.Fatalf("error marshalling exec credentials: %v", err) | ||
} | ||
_, _ = os.Stdout.Write(output) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,278 @@ | ||
package k8s | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"io/fs" | ||
"os" | ||
"path/filepath" | ||
"slices" | ||
"strings" | ||
"text/tabwriter" | ||
"time" | ||
|
||
"github.com/cockroachdb/errors" | ||
"github.com/fatih/color" | ||
"github.com/spf13/cobra" | ||
|
||
"encr.dev/cli/cmd/encore/cmdutil" | ||
"encr.dev/cli/cmd/encore/k8s/types" | ||
"encr.dev/cli/internal/platform" | ||
"encr.dev/internal/conf" | ||
|
||
"sigs.k8s.io/yaml" | ||
) | ||
|
||
var configCmd = &cobra.Command{ | ||
Use: "configure --env=ENV_NAME", | ||
Short: "Updates your kubectl config to point to the Kubernetes cluster(s) for the specified environment", | ||
Run: func(cmd *cobra.Command, args []string) { | ||
appSlug := cmdutil.AppSlug() | ||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) | ||
defer cancel() | ||
|
||
if k8sEnvName == "" { | ||
_ = cmd.Help() | ||
cmdutil.Fatal("must specify environment name with --env") | ||
} | ||
|
||
err := configureForAppEnv(ctx, appSlug, k8sEnvName) | ||
if err != nil { | ||
cmdutil.Fatalf("error configuring kubectl: %v", err) | ||
} | ||
}, | ||
} | ||
|
||
var ( | ||
k8sEnvName string | ||
) | ||
|
||
func init() { | ||
configCmd.Flags().StringVarP(&k8sEnvName, "env", "e", "", "Environment name") | ||
_ = configCmd.MarkFlagRequired("env") | ||
kubernetesCmd.AddCommand(configCmd) | ||
} | ||
|
||
func configureForAppEnv(ctx context.Context, appID string, envName string) error { | ||
appSlug, envName, clusters, err := platform.KubernetesClusters(ctx, appID, envName) | ||
if err != nil { | ||
return errors.Wrap(err, "unable to get Kubernetes clusters for environment") | ||
} | ||
if len(clusters) == 0 { | ||
return errors.New("no Kubernetes clusters found for environment") | ||
} | ||
|
||
// Read the existing kubeconfig file | ||
configFilePath := filepath.Join(types.HomeDir(), ".kube", "config") | ||
cfg, err := readKubeConfig(configFilePath) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
// Add the clusters | ||
contextPrefix := fmt.Sprintf("encore_%s_%s", appSlug, envName) | ||
authName := "encore-proxy-auth" | ||
contextNames := make([]string, len(clusters)) | ||
for i, cluster := range clusters { | ||
// Create a context name for the cluster | ||
// by default we use the app slug and env name seperated by a underscore (e.g. encore-myapp_prod) | ||
// however if the environment has multiple clusters then we also include the cluster name (e.g. encore-myapp_prod_cluster1) | ||
contextName := contextPrefix | ||
if len(clusters) > 1 { | ||
contextName += "_" + cluster.Name | ||
} | ||
contextNames[i] = contextName | ||
|
||
// Add the cluster using the cluster name as the context name | ||
cfg.clusters = appendOrUpdate(cfg.clusters, map[string]any{ | ||
"name": contextName, | ||
"cluster": map[string]any{ | ||
"server": fmt.Sprintf("%s/k8s-api-proxy/%s/%s/", conf.APIBaseURL, cluster.EnvID, cluster.ResID), | ||
}, | ||
}) | ||
|
||
k8sContext := map[string]any{ | ||
"cluster": contextName, | ||
"user": authName, | ||
} | ||
if cluster.DefaultNamespace != "" { | ||
k8sContext["namespace"] = cluster.DefaultNamespace | ||
} | ||
|
||
cfg.contexts = appendOrUpdate(cfg.contexts, map[string]any{ | ||
"name": contextName, | ||
"context": k8sContext, | ||
}) | ||
} | ||
|
||
// Remove any old contexts or clusters | ||
// We do this by iterating over the existing contexts and clusters and removing any that are not in the new list | ||
for i := len(cfg.contexts) - 1; i >= 0; i-- { | ||
if foundContext, ok := cfg.contexts[i].(map[string]any); ok { | ||
if contextName, ok := foundContext["name"].(string); ok { | ||
if strings.HasPrefix(contextName, contextPrefix) && !slices.Contains(contextNames, contextName) { | ||
cfg.contexts = append(cfg.contexts[:i], cfg.contexts[i+1:]...) | ||
} | ||
} | ||
} | ||
} | ||
for i := len(cfg.clusters) - 1; i >= 0; i-- { | ||
if foundCluster, ok := cfg.clusters[i].(map[string]any); ok { | ||
if clusterName, ok := foundCluster["name"].(string); ok { | ||
if strings.HasPrefix(clusterName, contextPrefix) && !slices.Contains(contextNames, clusterName) { | ||
cfg.clusters = append(cfg.clusters[:i], cfg.clusters[i+1:]...) | ||
} | ||
} | ||
} | ||
} | ||
|
||
// If we added a cluster then we need to update the encore-k8s-proxy user | ||
cfg.users = appendOrUpdate(cfg.users, map[string]any{ | ||
"name": authName, | ||
"user": map[string]any{ | ||
"exec": map[string]any{ | ||
"apiVersion": "client.authentication.k8s.io/v1", | ||
"args": []string{"kubernetes", "exec-credentials"}, | ||
"command": "encore", | ||
"env": nil, | ||
"installHint": "Install encore for use with kubectl, see https://encore.dev", | ||
"interactiveMode": "Never", | ||
"provideClusterInfo": false, | ||
}, | ||
}, | ||
}) | ||
|
||
// Update the current context to the first cluster for the environment | ||
cfg.raw["current-context"] = contextNames[0] | ||
|
||
if err := writeKubeConfig(configFilePath, cfg); err != nil { | ||
return err | ||
} | ||
|
||
if len(clusters) == 1 { | ||
_, _ = fmt.Fprintf(os.Stdout, "kubectl configured for cluster %s under context %s.\n", color.CyanString(clusters[0].Name), color.CyanString(contextNames[0])) | ||
} else { | ||
_, _ = fmt.Fprintf(os.Stdout, "kubectl configured for %d clusters:\n\n", len(clusters)) | ||
|
||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.StripEscape) | ||
_, _ = fmt.Fprint(w, "CLUSTER\tCONTEXT\tACTIVE\n") | ||
for i, cluster := range clusters { | ||
active := "" | ||
if i == 0 { | ||
active = "yes" | ||
} | ||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", cluster.Name, contextNames[0], active) | ||
} | ||
_ = w.Flush() | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// readKubeConfig reads the existing kubeconfig file and returns a Cfg struct. | ||
// however this is as untyped as possible, so that we can easily marshal it back without losing any data. | ||
func readKubeConfig(file string) (*Cfg, error) { | ||
b, err := os.ReadFile(file) | ||
if err != nil && !errors.Is(err, fs.ErrNotExist) { | ||
return nil, errors.Wrap(err, "unable to read kubeconfig file") | ||
} | ||
|
||
// Read the existing kubeconfig file | ||
var kubeConfig map[string]any | ||
if len(b) > 0 { | ||
if err = yaml.Unmarshal(b, &kubeConfig); err != nil { | ||
return nil, errors.Wrap(err, "unable to parse kubeconfig file") | ||
} | ||
} | ||
|
||
// Ensure the kubeConfig struct is valid | ||
if kubeConfig == nil { | ||
kubeConfig = map[string]any{ | ||
"apiVersion": "v1", | ||
"kind": "Config", | ||
} | ||
} else if kubeConfig["apiVersion"] != "v1" || kubeConfig["kind"] != "Config" { | ||
return nil, errors.New("invalid existing kubeconfig file") | ||
} | ||
cfg := &Cfg{ | ||
raw: kubeConfig, | ||
} | ||
|
||
if clusters, ok := kubeConfig["clusters"]; ok { | ||
if clusters, ok := clusters.([]any); ok { | ||
cfg.clusters = clusters | ||
} else { | ||
return nil, errors.Newf("clusters is not an array got %T", clusters) | ||
} | ||
} | ||
|
||
if users, ok := kubeConfig["users"]; ok { | ||
if users, ok := users.([]any); ok { | ||
cfg.users = users | ||
} else { | ||
return nil, errors.Newf("users is not an array got %T", users) | ||
} | ||
} | ||
|
||
if contexts, ok := kubeConfig["contexts"]; ok { | ||
if contexts, ok := contexts.([]any); ok { | ||
cfg.contexts = contexts | ||
} else { | ||
return nil, errors.Newf("contexts is not an array got %T", contexts) | ||
} | ||
} | ||
|
||
return cfg, nil | ||
} | ||
|
||
// writeKubeConfig writes the kubeconfig back to the file. | ||
func writeKubeConfig(file string, cfg *Cfg) error { | ||
// Update the raw kubeconfig struct | ||
cfg.raw["clusters"] = cfg.clusters | ||
cfg.raw["users"] = cfg.users | ||
cfg.raw["contexts"] = cfg.contexts | ||
|
||
b, err := yaml.Marshal(cfg.raw) | ||
if err != nil { | ||
return errors.Wrap(err, "unable to marshal kubeconfig back into yaml") | ||
} | ||
|
||
// Ensure the directory exists | ||
if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { | ||
return errors.Wrap(err, "unable to create kubeconfig directory") | ||
} | ||
|
||
// Then write the file | ||
err = os.WriteFile(file, b, 0600) | ||
if err != nil { | ||
return errors.Wrap(err, "unable to write kubeconfig file") | ||
} | ||
return nil | ||
} | ||
|
||
type Cfg struct { | ||
raw map[string]any | ||
clusters []any | ||
users []any | ||
contexts []any | ||
} | ||
|
||
// appendOrUpdate looks at the array for an entry which is a map and has a "name" key which matches the name in val, if found | ||
// it will update the entry with val, otherwise it will append val to the array. | ||
func appendOrUpdate(dst []any, val map[string]any) []any { | ||
idx := slices.IndexFunc(dst, func(entry any) bool { | ||
if entry, ok := entry.(map[string]any); ok { | ||
if entry["name"] == val["name"] { | ||
return true | ||
} | ||
} | ||
return false | ||
}) | ||
|
||
if idx == -1 { | ||
return append(dst, val) | ||
} else { | ||
dst[idx] = val | ||
return dst | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package k8s | ||
|
||
import ( | ||
"github.com/spf13/cobra" | ||
|
||
"encr.dev/cli/cmd/encore/root" | ||
) | ||
|
||
var kubernetesCmd = &cobra.Command{ | ||
Use: "kubernetes", | ||
Short: "Kubernetes management commands", | ||
Aliases: []string{"k8s"}, | ||
} | ||
|
||
func init() { | ||
root.Cmd.AddCommand(kubernetesCmd) | ||
} |
Oops, something went wrong.