From 47025fb8c702db30b3e887e918412de9ac05dc09 Mon Sep 17 00:00:00 2001 From: Thomas Kooi Date: Fri, 11 Sep 2026 21:53:20 +0200 Subject: [PATCH] feat(cli): support directory-local context and cluster defaults Walk up from the working directory for an optional .thalassa overlay so repos can pin context, organisation, project, and Kubernetes cluster without constantly switching the global ~/.tcloud config. Signed-off-by: Thomas Kooi --- README.md | 21 ++ cmd/cmd.go | 4 + cmd/context/current.go | 2 +- cmd/dir/dir.go | 19 ++ cmd/dir/init.go | 58 ++++ cmd/dir/show.go | 48 ++++ cmd/kubernetes/connect/connect.go | 16 +- cmd/kubernetes/credential/credential.go | 3 + cmd/kubernetes/kubeconfig.go | 16 +- cmd/kubernetes/kubeconfigsessions/delete.go | 10 +- cmd/kubernetes/kubeconfigsessions/list.go | 8 +- cmd/kubernetes/machines/list.go | 7 +- cmd/kubernetes/nodepools/create.go | 9 +- cmd/kubernetes/nodepools/delete.go | 9 +- cmd/kubernetes/nodepools/list.go | 12 +- cmd/kubernetes/nodepools/update.go | 20 +- docs/tcloud/context/current/_index.md | 2 +- docs/tcloud/dir/_index.md | 55 ++++ docs/tcloud/dir/init/_index.md | 55 ++++ docs/tcloud/dir/show/_index.md | 45 ++++ docs/tcloud/kubernetes/connect/_index.md | 6 +- .../kubeconfig-sessions_delete/_index.md | 4 +- .../kubeconfig-sessions_list/_index.md | 4 +- docs/tcloud/kubernetes/kubeconfig/_index.md | 6 +- .../tcloud/kubernetes/machines_list/_index.md | 4 +- docs/tcloud/kubernetes/nodepools/_index.md | 2 +- .../kubernetes/nodepools_create/_index.md | 2 +- .../kubernetes/nodepools_delete/_index.md | 2 +- .../kubernetes/nodepools_list/_index.md | 2 +- .../kubernetes/nodepools_update/_index.md | 2 +- docs/tcloud/tcloud.md | 2 + internal/config/contextstate/config.go | 13 +- internal/config/contextstate/manager.go | 63 +++-- internal/config/contextstate/overlay_test.go | 182 +++++++++++++ internal/dirconfig/config.go | 52 ++++ internal/dirconfig/discover.go | 197 ++++++++++++++ internal/dirconfig/discover_test.go | 254 ++++++++++++++++++ internal/dirconfig/write.go | 41 +++ internal/kuberesolve/preferred.go | 40 +++ internal/kuberesolve/preferred_test.go | 114 ++++++++ 40 files changed, 1341 insertions(+), 70 deletions(-) create mode 100644 cmd/dir/dir.go create mode 100644 cmd/dir/init.go create mode 100644 cmd/dir/show.go create mode 100644 docs/tcloud/dir/_index.md create mode 100644 docs/tcloud/dir/init/_index.md create mode 100644 docs/tcloud/dir/show/_index.md create mode 100644 internal/config/contextstate/overlay_test.go create mode 100644 internal/dirconfig/config.go create mode 100644 internal/dirconfig/discover.go create mode 100644 internal/dirconfig/discover_test.go create mode 100644 internal/dirconfig/write.go create mode 100644 internal/kuberesolve/preferred.go create mode 100644 internal/kuberesolve/preferred_test.go diff --git a/README.md b/README.md index e426757..d7cb6e8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,27 @@ users: user: {} ``` +## Directory defaults (`.thalassa`) + +Optionally, a repository can pin a CLI context and Kubernetes cluster without changing `~/.tcloud`. From the working directory, `tcloud` walks up looking for: + +- `.thalassa` (a YAML file), or +- `.thalassa/config.yaml` (when `.thalassa` is a directory) + +The nearest file wins. Missing files are ignored. The overlay holds **names and references only** — never tokens or kubeconfig data. + +```yaml +context: prod +organisation: acme # optional; overrides the named context's organisation +project: platform # optional +kubernetes: + cluster: prod-cluster # identity, name, or slug +``` + +Precedence is: command flags and positional arguments, then environment variables (`THALASSA_*`, `TCLOUD_CLUSTER_*`), then `.thalassa`, then `~/.tcloud`. Cluster create/delete/update/upgrade still require an explicit cluster argument. + +Create a file with `tcloud dir init --cluster `, inspect it with `tcloud dir show`, and disable it for one invocation with `--ignore-dir-config` or `THALASSA_DIR_CONFIG=0`. + ## Credential storage By default, the CLI stores secrets in the OS credential store when one is available: diff --git a/cmd/cmd.go b/cmd/cmd.go index 6cb9286..f007afe 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/thalassa-cloud/cli/cmd/audit" "github.com/thalassa-cloud/cli/cmd/context" "github.com/thalassa-cloud/cli/cmd/dbaas" + "github.com/thalassa-cloud/cli/cmd/dir" "github.com/thalassa-cloud/cli/cmd/dns" "github.com/thalassa-cloud/cli/cmd/iaas/compute" "github.com/thalassa-cloud/cli/cmd/iaas/networking" @@ -28,6 +29,7 @@ import ( "github.com/thalassa-cloud/cli/cmd/version" "github.com/thalassa-cloud/cli/internal/completion" "github.com/thalassa-cloud/cli/internal/config/contextstate" + "github.com/thalassa-cloud/cli/internal/dirconfig" ) var RootCmd = &cobra.Command{ @@ -59,6 +61,7 @@ func init() { RootCmd.PersistentFlags().StringVar(&contextstate.OidcClientIDFlag, "client-id", "", "OIDC client ID for OIDC authentication (overrides context)") RootCmd.PersistentFlags().StringVar(&contextstate.OidcClientSecretFlag, "client-secret", "", "OIDC client secret for OIDC authentication (overrides context)") RootCmd.PersistentFlags().BoolVar(&contextstate.DebugFlag, "debug", false, "Debug mode") + RootCmd.PersistentFlags().BoolVar(&dirconfig.IgnoreDirConfigFlag, "ignore-dir-config", false, "Ignore directory-local .thalassa defaults") // Register completions _ = RootCmd.RegisterFlagCompletionFunc("organisation", completion.CompleteOrganisation) @@ -66,6 +69,7 @@ func init() { RootCmd.AddCommand(api.ApiCmd) RootCmd.AddCommand(context.ContextCmd) + RootCmd.AddCommand(dir.DirCmd) RootCmd.AddCommand(version.VersionCmd) RootCmd.AddCommand(regions.RegionsCmd) diff --git a/cmd/context/current.go b/cmd/context/current.go index 699bcde..9bbd3b0 100644 --- a/cmd/context/current.go +++ b/cmd/context/current.go @@ -12,7 +12,7 @@ import ( var currentContextCmd = &cobra.Command{ Use: "current", Short: "Shows the current context", - Long: "Shows the current context (or the context set with the --context flag)", + Long: "Shows the current context (the --context flag, a directory .thalassa overlay, or current-context in ~/.tcloud)", Example: "tcloud context current", Args: cobra.NoArgs, diff --git a/cmd/dir/dir.go b/cmd/dir/dir.go new file mode 100644 index 0000000..a373af9 --- /dev/null +++ b/cmd/dir/dir.go @@ -0,0 +1,19 @@ +package dir + +import ( + "github.com/spf13/cobra" +) + +// DirCmd manages project-local directory configuration. +var DirCmd = &cobra.Command{ + Use: "dir", + Short: "Manage directory-local Thalassa defaults", + Long: `Manage optional project-local defaults discovered from a .thalassa file (or .thalassa/config.yaml) by walking up from the current directory. + +Directory config can set a preferred CLI context, organisation, project, and Kubernetes cluster.`, +} + +func init() { + DirCmd.AddCommand(showCmd) + DirCmd.AddCommand(initCmd) +} diff --git a/cmd/dir/init.go b/cmd/dir/init.go new file mode 100644 index 0000000..1133043 --- /dev/null +++ b/cmd/dir/init.go @@ -0,0 +1,58 @@ +package dir + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/config/contextstate" + "github.com/thalassa-cloud/cli/internal/dirconfig" + "github.com/thalassa-cloud/cli/internal/kuberesolve" +) + +var ( + initCluster string + initForce bool +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Write a .thalassa file in the current directory", + Long: `Write a .thalassa file in the current directory from the effective CLI context and an optional Kubernetes cluster. + +The file contains names and references only. Credentials stay in ~/.tcloud or the system credential store. Existing files are not overwritten unless --force is set.`, + Example: "tcloud dir init --cluster prod-cluster", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + cfg := dirconfig.Config{ + Context: contextstate.Name(), + Organisation: contextstate.Organisation(), + Project: contextstate.Project(), + } + if cluster, ok := kuberesolve.PreferredClusterRef(initCluster); ok { + cfg.Kubernetes.Cluster = cluster + } + if cfg.Empty() { + return fmt.Errorf("nothing to write; set a context or pass --cluster") + } + + path := filepath.Join(cwd, dirconfig.FileName) + if err := dirconfig.WriteFile(path, cfg, initForce); err != nil { + return err + } + fmt.Printf("Wrote %s\n", path) + return nil + }, +} + +func init() { + initCmd.Flags().StringVar(&initCluster, "cluster", "", "Kubernetes cluster identity, name, or slug to pin in the file") + initCmd.Flags().BoolVar(&initForce, "force", false, "Overwrite an existing .thalassa file") +} diff --git a/cmd/dir/show.go b/cmd/dir/show.go new file mode 100644 index 0000000..74047d3 --- /dev/null +++ b/cmd/dir/show.go @@ -0,0 +1,48 @@ +package dir + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/thalassa-cloud/cli/internal/dirconfig" +) + +var showCmd = &cobra.Command{ + Use: "show", + Aliases: []string{"status", "view"}, + Short: "Show the directory-local config in effect", + Long: "Show the nearest .thalassa (or .thalassa/config.yaml) found by walking up from the current directory.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + cfg, path, err := dirconfig.Discover(cwd) + if err != nil { + return err + } + if path == "" { + fmt.Println("No directory config found") + return nil + } + + fmt.Printf("path: %s\n", path) + if dirconfig.Disabled() { + fmt.Println("applied: no (ignored by --ignore-dir-config or THALASSA_DIR_CONFIG)") + } else { + fmt.Println("applied: yes") + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal directory config: %w", err) + } + fmt.Print(string(data)) + return nil + }, +} diff --git a/cmd/kubernetes/connect/connect.go b/cmd/kubernetes/connect/connect.go index 7a86488..42594d4 100644 --- a/cmd/kubernetes/connect/connect.go +++ b/cmd/kubernetes/connect/connect.go @@ -26,9 +26,10 @@ var ( ) var KubernetesConnectCmd = &cobra.Command{ - Use: "connect", + Use: "connect [cluster]", Aliases: []string{"connection", "shell", "c"}, Short: "Connect your shell to the Kubernetes Cluster", + Long: "Connect your shell to a Kubernetes cluster. The cluster argument may be omitted when kubernetes.cluster is set in a directory .thalassa file, or when TCLOUD_CLUSTER_* is already set by a previous connect.", Args: cobra.MaximumNArgs(1), ValidArgsFunction: completion.CompleteKubernetesCluster, RunE: func(cmd *cobra.Command, args []string) error { @@ -157,15 +158,18 @@ func prepareKubeconfigFile(clusterSlug, config string) (path string, cleanup fun } func getSelectedCluster(args []string) (string, error) { - if len(args) == 0 && fzf.IsInteractiveMode(os.Stdout) { - command := fmt.Sprintf("%s kubernetes clusters --no-header", os.Args[0]) - return fzf.InteractiveChoice(command) - } if len(args) == 1 { return args[0], nil } + if ref, ok := kuberesolve.PreferredClusterRef(""); ok { + return ref, nil + } + if fzf.IsInteractiveMode(os.Stdout) { + command := fmt.Sprintf("%s kubernetes clusters --no-header", os.Args[0]) + return fzf.InteractiveChoice(command) + } if contextstate.OrganisationFlag != "" { return "", errors.New("must provide a cluster when organisation is set via flag") } - return "", errors.New("must provide a cluster") + return "", errors.New("must provide a cluster (argument or kubernetes.cluster in .thalassa)") } diff --git a/cmd/kubernetes/credential/credential.go b/cmd/kubernetes/credential/credential.go index e89791e..9867035 100644 --- a/cmd/kubernetes/credential/credential.go +++ b/cmd/kubernetes/credential/credential.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/thalassa-cloud/cli/internal/config/contextstate" + "github.com/thalassa-cloud/cli/internal/dirconfig" "github.com/thalassa-cloud/cli/internal/kubernetes/auth" "github.com/thalassa-cloud/cli/internal/thalassaclient" ) @@ -24,6 +25,8 @@ var CredentialCmd = &cobra.Command{ Hidden: true, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + dirconfig.Disable() + if clusterIdentity == "" { return fmt.Errorf("cluster identity is required") } diff --git a/cmd/kubernetes/kubeconfig.go b/cmd/kubernetes/kubeconfig.go index 57ffb1e..4de140d 100644 --- a/cmd/kubernetes/kubeconfig.go +++ b/cmd/kubernetes/kubeconfig.go @@ -20,10 +20,11 @@ var ( ) var KubernetesKubeConfigCmd = &cobra.Command{ - Use: "kubeconfig", + Use: "kubeconfig [cluster]", Short: "Print a kubeconfig for a Kubernetes cluster", + Long: "Print a kubeconfig for a Kubernetes cluster. The cluster may be omitted when kubernetes.cluster is set in a directory .thalassa file.", ValidArgsFunction: completion.CompleteKubernetesCluster, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() client, err := thalassaclient.GetThalassaClient() @@ -31,7 +32,16 @@ var KubernetesKubeConfigCmd = &cobra.Command{ return err } - cluster, err := kuberesolve.ResolveKubernetesClusterRef(ctx, client.Kubernetes(), args[0]) + clusterRef := "" + if len(args) == 1 { + clusterRef = args[0] + } + clusterRef, err = kuberesolve.RequireClusterRef(clusterRef) + if err != nil { + return err + } + + cluster, err := kuberesolve.ResolveKubernetesClusterRef(ctx, client.Kubernetes(), clusterRef) if err != nil { return err } diff --git a/cmd/kubernetes/kubeconfigsessions/delete.go b/cmd/kubernetes/kubeconfigsessions/delete.go index 5dc077b..ff10afb 100644 --- a/cmd/kubernetes/kubeconfigsessions/delete.go +++ b/cmd/kubernetes/kubeconfigsessions/delete.go @@ -21,7 +21,7 @@ var deleteCmd = &cobra.Command{ Short: "Delete a kubeconfig session", Long: `Revoke a kubeconfig session for a Kubernetes cluster. -Provide the cluster as the first argument or with --cluster, and the session identity as the final argument.`, +Provide the cluster as the first argument or with --cluster, and the session identity as the final argument. The cluster may be omitted when kubernetes.cluster is set in a directory .thalassa file.`, Aliases: []string{"rm", "remove"}, Args: cobra.RangeArgs(1, 2), ValidArgsFunction: completeDeleteArgs, @@ -41,7 +41,11 @@ Provide the cluster as the first argument or with --cluster, and the session ide sessionIdentity = args[1] } if clusterRef == "" { - return fmt.Errorf("cluster is required (argument or --cluster)") + resolved, err := kuberesolve.RequireClusterRef("") + if err != nil { + return err + } + clusterRef = resolved } if sessionIdentity == "" { return fmt.Errorf("session identity is required") @@ -90,7 +94,7 @@ func completeDeleteArgs(cmd *cobra.Command, args []string, toComplete string) ([ func init() { KubeconfigSessionsCmd.AddCommand(deleteCmd) - deleteCmd.Flags().StringVar(&deleteCluster, ClusterFlag, "", "Cluster identity, name, or slug") + deleteCmd.Flags().StringVar(&deleteCluster, ClusterFlag, "", "Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa)") deleteCmd.Flags().BoolVar(&deleteForce, shared.ForceKey, false, "Skip the confirmation prompt and delete") _ = deleteCmd.RegisterFlagCompletionFunc(ClusterFlag, completion.CompleteKubernetesCluster) } diff --git a/cmd/kubernetes/kubeconfigsessions/list.go b/cmd/kubernetes/kubeconfigsessions/list.go index 258ff50..95eeba9 100644 --- a/cmd/kubernetes/kubeconfigsessions/list.go +++ b/cmd/kubernetes/kubeconfigsessions/list.go @@ -42,9 +42,11 @@ var listCmd = &cobra.Command{ } clusterRef = args[0] } - if clusterRef == "" { - return fmt.Errorf("cluster is required (argument or --cluster)") + resolved, err := kuberesolve.RequireClusterRef(clusterRef) + if err != nil { + return err } + clusterRef = resolved client, err := thalassaclient.GetThalassaClient() if err != nil { @@ -112,6 +114,6 @@ func init() { KubeconfigSessionsCmd.AddCommand(listCmd) listCmd.Flags().BoolVar(&noHeader, NoHeaderKey, false, "Do not print the header") listCmd.Flags().BoolVar(&showExactTime, "exact-time", false, "Show full timestamps instead of relative time") - listCmd.Flags().StringVar(&listCluster, ClusterFlag, "", "Cluster identity, name, or slug") + listCmd.Flags().StringVar(&listCluster, ClusterFlag, "", "Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa)") _ = listCmd.RegisterFlagCompletionFunc(ClusterFlag, completion.CompleteKubernetesCluster) } diff --git a/cmd/kubernetes/machines/list.go b/cmd/kubernetes/machines/list.go index f37b283..cbb495e 100644 --- a/cmd/kubernetes/machines/list.go +++ b/cmd/kubernetes/machines/list.go @@ -36,7 +36,11 @@ var listCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() if cluster == "" { - return fmt.Errorf("--cluster is required") + ref, err := kuberesolve.RequireClusterRef("") + if err != nil { + return err + } + cluster = ref } client, err := thalassaclient.GetThalassaClient() @@ -126,7 +130,6 @@ func init() { listCmd.Flags().BoolVar(&showExactTime, "show-exact-time", false, "Show exact time instead of relative time") listCmd.Flags().StringVar(&cluster, ClusterFlag, "", "Cluster identity, name, or slug") listCmd.Flags().StringVar(&nodePool, NodePoolFlag, "", "Filter by node pool identity, name, or slug") - _ = listCmd.MarkFlagRequired(ClusterFlag) _ = listCmd.RegisterFlagCompletionFunc(ClusterFlag, completion.CompleteKubernetesCluster) _ = listCmd.RegisterFlagCompletionFunc(NodePoolFlag, completion.CompleteKubernetesNodePool) } diff --git a/cmd/kubernetes/nodepools/create.go b/cmd/kubernetes/nodepools/create.go index 5529e17..a1eaa4e 100644 --- a/cmd/kubernetes/nodepools/create.go +++ b/cmd/kubernetes/nodepools/create.go @@ -10,6 +10,7 @@ import ( "github.com/thalassa-cloud/cli/internal/completion" "github.com/thalassa-cloud/cli/internal/formattime" "github.com/thalassa-cloud/cli/internal/fzf" + "github.com/thalassa-cloud/cli/internal/kuberesolve" "github.com/thalassa-cloud/cli/internal/shared" "github.com/thalassa-cloud/cli/internal/table" "github.com/thalassa-cloud/cli/internal/thalassaclient" @@ -55,10 +56,10 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - if createNodePoolCluster == "" { - return fmt.Errorf("--cluster is required") + clusterIdentifier, err := kuberesolve.RequireClusterRef(createNodePoolCluster) + if err != nil { + return err } - clusterIdentifier := createNodePoolCluster client, err := thalassaclient.GetThalassaClient() if err != nil { @@ -210,7 +211,7 @@ Examples: func init() { // Command is registered in kubernetesclusters.go - createCmd.Flags().StringVar(&createNodePoolCluster, "cluster", "", "Cluster identity, name, or slug (required)") + createCmd.Flags().StringVar(&createNodePoolCluster, "cluster", "", "Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa)") createCmd.Flags().StringVar(&createNodePoolName, "name", "worker", "Name of the node pool (default: worker)") createCmd.Flags().StringVar(&createNodePoolMachineType, "machine-type", "", "Machine type for the node pool (required)") createCmd.Flags().IntVar(&createNodePoolReplicas, "num-nodes", 1, "Number of nodes in the node pool (ignored if --enable-autoscaling is set)") diff --git a/cmd/kubernetes/nodepools/delete.go b/cmd/kubernetes/nodepools/delete.go index 3f94608..2d3a479 100644 --- a/cmd/kubernetes/nodepools/delete.go +++ b/cmd/kubernetes/nodepools/delete.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/kuberesolve" "github.com/thalassa-cloud/cli/internal/shared" "github.com/thalassa-cloud/cli/internal/thalassaclient" "github.com/thalassa-cloud/client-go/kubernetes" @@ -45,14 +46,14 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - if deleteNodePoolCluster == "" { - return fmt.Errorf("--cluster is required") + clusterIdentifier, err := kuberesolve.RequireClusterRef(deleteNodePoolCluster) + if err != nil { + return err } if deleteNodePoolId == "" { return fmt.Errorf("--nodepool is required") } - clusterIdentifier := deleteNodePoolCluster nodePoolIdentifier := deleteNodePoolId client, err := thalassaclient.GetThalassaClient() @@ -130,7 +131,7 @@ Examples: func init() { // Command is registered in kubernetesclusters.go - deleteCmd.Flags().StringVar(&deleteNodePoolCluster, "cluster", "", "Cluster identity, name, or slug (required)") + deleteCmd.Flags().StringVar(&deleteNodePoolCluster, "cluster", "", "Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa)") deleteCmd.Flags().StringVar(&deleteNodePoolId, "nodepool", "", "Node pool name, identity, or slug (required)") deleteCmd.Flags().BoolVar(&deleteNodePoolWait, "wait", false, "Wait for the node pool to be deleted before returning") deleteCmd.Flags().DurationVar(&deleteNodePoolWaitTimeout, "wait-timeout", 20*time.Minute, "Maximum time to wait for the node pool to be deleted") diff --git a/cmd/kubernetes/nodepools/list.go b/cmd/kubernetes/nodepools/list.go index c47066e..7480342 100644 --- a/cmd/kubernetes/nodepools/list.go +++ b/cmd/kubernetes/nodepools/list.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/thalassa-cloud/cli/internal/completion" "github.com/thalassa-cloud/cli/internal/formattime" + "github.com/thalassa-cloud/cli/internal/kuberesolve" "github.com/thalassa-cloud/cli/internal/table" "github.com/thalassa-cloud/cli/internal/thalassaclient" "github.com/thalassa-cloud/client-go/iaas" @@ -52,6 +53,13 @@ var listCmd = &cobra.Command{ } } + clusterFilter := cluster + if clusterFilter == "" { + if ref, ok := kuberesolve.PreferredClusterRef(""); ok { + clusterFilter = ref + } + } + // Get clusters clusters, err := client.Kubernetes().ListKubernetesClusters(ctx, &kubernetes.ListKubernetesClustersRequest{}) if err != nil { @@ -67,7 +75,7 @@ var listCmd = &cobra.Command{ } // Skip clusters that don't match cluster filter - if cluster != "" && !matchesClusterRef(&c, cluster) { + if clusterFilter != "" && !matchesClusterRef(&c, clusterFilter) { continue } @@ -141,7 +149,7 @@ func formatReplicas(np *kubernetes.KubernetesNodePool) string { func init() { listCmd.Flags().BoolVar(&noHeader, NoHeaderKey, false, "Do not print the header") - listCmd.Flags().StringVar(&cluster, ClusterFlag, "", "Cluster ID") + listCmd.Flags().StringVar(&cluster, ClusterFlag, "", "Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa)") listCmd.Flags().StringVar(&vpc, VpcFlag, "", "VPC ID") // Register completions diff --git a/cmd/kubernetes/nodepools/update.go b/cmd/kubernetes/nodepools/update.go index 0588c32..5f685c2 100644 --- a/cmd/kubernetes/nodepools/update.go +++ b/cmd/kubernetes/nodepools/update.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/thalassa-cloud/cli/internal/completion" "github.com/thalassa-cloud/cli/internal/formattime" + "github.com/thalassa-cloud/cli/internal/kuberesolve" "github.com/thalassa-cloud/cli/internal/shared" "github.com/thalassa-cloud/cli/internal/table" "github.com/thalassa-cloud/cli/internal/thalassaclient" @@ -57,8 +58,13 @@ to manage labels and annotations separately.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - if updateNodePoolCluster == nil || *updateNodePoolCluster == "" { - return fmt.Errorf("--cluster is required") + clusterFlag := "" + if updateNodePoolCluster != nil { + clusterFlag = *updateNodePoolCluster + } + clusterIdentifier, err := kuberesolve.RequireClusterRef(clusterFlag) + if err != nil { + return err } if updateNodePoolName == nil || *updateNodePoolName == "" { return fmt.Errorf("--name is required") @@ -77,16 +83,16 @@ to manage labels and annotations separately.`, var cluster *kubernetes.KubernetesCluster for _, c := range clusters { - if strings.EqualFold(c.Identity, *updateNodePoolCluster) || - strings.EqualFold(c.Name, *updateNodePoolCluster) || - strings.EqualFold(c.Slug, *updateNodePoolCluster) { + if strings.EqualFold(c.Identity, clusterIdentifier) || + strings.EqualFold(c.Name, clusterIdentifier) || + strings.EqualFold(c.Slug, clusterIdentifier) { cluster = &c break } } if cluster == nil { - return fmt.Errorf("cluster not found: %s", *updateNodePoolCluster) + return fmt.Errorf("cluster not found: %s", clusterIdentifier) } // Get node pools for the cluster @@ -282,7 +288,7 @@ to manage labels and annotations separately.`, func init() { // Command is registered in nodepools.go - updateNodePoolCluster = updateCmd.Flags().String("cluster", "", "Cluster identity, name, or slug (required)") + updateNodePoolCluster = updateCmd.Flags().String("cluster", "", "Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa)") updateNodePoolName = updateCmd.Flags().String("name", "", "Node pool name, identity, or slug (required)") updateNodePoolMachineType = updateCmd.Flags().String("machine-type", "", "Machine type for the node pool") updateNodePoolReplicas = updateCmd.Flags().Int("num-nodes", 0, "Number of nodes in the node pool (only when autoscaling is disabled)") diff --git a/docs/tcloud/context/current/_index.md b/docs/tcloud/context/current/_index.md index 1dc91f5..720405c 100644 --- a/docs/tcloud/context/current/_index.md +++ b/docs/tcloud/context/current/_index.md @@ -13,7 +13,7 @@ Shows the current context ### Synopsis -Shows the current context (or the context set with the --context flag) +Shows the current context (the `--context` flag, a directory `.thalassa` overlay, or `current-context` in `~/.tcloud`) ``` tcloud context current [flags] diff --git a/docs/tcloud/dir/_index.md b/docs/tcloud/dir/_index.md new file mode 100644 index 0000000..2df9bbe --- /dev/null +++ b/docs/tcloud/dir/_index.md @@ -0,0 +1,55 @@ +--- +linkTitle: "tcloud dir" +title: "dir" +slug: tcloud_dir +url: /docs/tcloud/tcloud_dir/ +weight: 9974 +cascade: + type: docs +--- +## tcloud dir + +Manage directory-local Thalassa defaults + +### Synopsis + +Manage optional project-local defaults discovered from a `.thalassa` file (or `.thalassa/config.yaml`) by walking up from the current directory. + +Directory config can set a preferred CLI context, organisation, project, and Kubernetes cluster. + +```yaml +context: prod +organisation: acme +project: platform +kubernetes: + cluster: prod-cluster +``` + +Disable with `--ignore-dir-config` or `THALASSA_DIR_CONFIG=0`. + +### Options + +``` + -h, --help help for dir +``` + +### Options inherited from parent commands + +``` + --access-token string Access Token authentication (overrides context) + --api string API endpoint (overrides context) + --client-id string OIDC client ID for OIDC authentication (overrides context) + --client-secret string OIDC client secret for OIDC authentication (overrides context) + -c, --context string Context name + --debug Debug mode + --ignore-dir-config Ignore directory-local .thalassa defaults + -O, --organisation string Organisation slug or identity (overrides context) + -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) + --token string Personal access token (overrides context) +``` + +### SEE ALSO + +* [tcloud](/docs/tcloud/tcloud/) - A CLI for working with the Thalassa Cloud Platform +* [tcloud dir init](/docs/tcloud/dir/init/) - Write a .thalassa file in the current directory +* [tcloud dir show](/docs/tcloud/dir/show/) - Show the directory-local config in effect diff --git a/docs/tcloud/dir/init/_index.md b/docs/tcloud/dir/init/_index.md new file mode 100644 index 0000000..1819fef --- /dev/null +++ b/docs/tcloud/dir/init/_index.md @@ -0,0 +1,55 @@ +--- +linkTitle: "tcloud dir init" +title: "dir init" +slug: tcloud_dir_init +url: /docs/tcloud/dir/init/ +weight: 9972 +cascade: + type: docs +--- +## tcloud dir init + +Write a .thalassa file in the current directory + +### Synopsis + +Write a `.thalassa` file in the current directory from the effective CLI context and an optional Kubernetes cluster. + +The file contains names and references only. Credentials stay in `~/.tcloud` or the system credential store. Existing files are not overwritten unless `--force` is set. + +``` +tcloud dir init [flags] +``` + +### Examples + +``` +tcloud dir init --cluster prod-cluster +``` + +### Options + +``` + --cluster string Kubernetes cluster identity, name, or slug to pin in the file + --force Overwrite an existing .thalassa file + -h, --help help for init +``` + +### Options inherited from parent commands + +``` + --access-token string Access Token authentication (overrides context) + --api string API endpoint (overrides context) + --client-id string OIDC client ID for OIDC authentication (overrides context) + --client-secret string OIDC client secret for OIDC authentication (overrides context) + -c, --context string Context name + --debug Debug mode + --ignore-dir-config Ignore directory-local .thalassa defaults + -O, --organisation string Organisation slug or identity (overrides context) + -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) + --token string Personal access token (overrides context) +``` + +### SEE ALSO + +* [tcloud dir](/docs/tcloud/tcloud_dir/) - Manage directory-local Thalassa defaults diff --git a/docs/tcloud/dir/show/_index.md b/docs/tcloud/dir/show/_index.md new file mode 100644 index 0000000..bb38525 --- /dev/null +++ b/docs/tcloud/dir/show/_index.md @@ -0,0 +1,45 @@ +--- +linkTitle: "tcloud dir show" +title: "dir show" +slug: tcloud_dir_show +url: /docs/tcloud/dir/show/ +weight: 9973 +cascade: + type: docs +--- +## tcloud dir show + +Show the directory-local config in effect + +### Synopsis + +Show the nearest `.thalassa` (or `.thalassa/config.yaml`) found by walking up from the current directory. + +``` +tcloud dir show [flags] +``` + +### Options + +``` + -h, --help help for show +``` + +### Options inherited from parent commands + +``` + --access-token string Access Token authentication (overrides context) + --api string API endpoint (overrides context) + --client-id string OIDC client ID for OIDC authentication (overrides context) + --client-secret string OIDC client secret for OIDC authentication (overrides context) + -c, --context string Context name + --debug Debug mode + --ignore-dir-config Ignore directory-local .thalassa defaults + -O, --organisation string Organisation slug or identity (overrides context) + -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) + --token string Personal access token (overrides context) +``` + +### SEE ALSO + +* [tcloud dir](/docs/tcloud/tcloud_dir/) - Manage directory-local Thalassa defaults diff --git a/docs/tcloud/kubernetes/connect/_index.md b/docs/tcloud/kubernetes/connect/_index.md index 2506c45..fc4673e 100644 --- a/docs/tcloud/kubernetes/connect/_index.md +++ b/docs/tcloud/kubernetes/connect/_index.md @@ -11,8 +11,12 @@ cascade: Connect your shell to the Kubernetes Cluster +### Synopsis + +Connect your shell to a Kubernetes cluster. The cluster argument may be omitted when `kubernetes.cluster` is set in a directory `.thalassa` file, or when `TCLOUD_CLUSTER_*` is already set by a previous connect. + ``` -tcloud kubernetes connect [flags] +tcloud kubernetes connect [cluster] [flags] ``` ### Options diff --git a/docs/tcloud/kubernetes/kubeconfig-sessions_delete/_index.md b/docs/tcloud/kubernetes/kubeconfig-sessions_delete/_index.md index 64dfa45..0482179 100644 --- a/docs/tcloud/kubernetes/kubeconfig-sessions_delete/_index.md +++ b/docs/tcloud/kubernetes/kubeconfig-sessions_delete/_index.md @@ -15,7 +15,7 @@ Delete a kubeconfig session Revoke a kubeconfig session for a Kubernetes cluster. -Provide the cluster as the first argument or with --cluster, and the session identity as the final argument. +Provide the cluster as the first argument or with --cluster, and the session identity as the final argument. The cluster may be omitted when `kubernetes.cluster` is set in a directory `.thalassa` file. ``` tcloud kubernetes kubeconfig-sessions delete [cluster] [flags] @@ -24,7 +24,7 @@ tcloud kubernetes kubeconfig-sessions delete [cluster] [flags] ### Options ``` - --cluster string Cluster identity, name, or slug + --cluster string Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa) --force Skip the confirmation prompt and delete -h, --help help for delete ``` diff --git a/docs/tcloud/kubernetes/kubeconfig-sessions_list/_index.md b/docs/tcloud/kubernetes/kubeconfig-sessions_list/_index.md index c640557..d3bf9c4 100644 --- a/docs/tcloud/kubernetes/kubeconfig-sessions_list/_index.md +++ b/docs/tcloud/kubernetes/kubeconfig-sessions_list/_index.md @@ -11,6 +11,8 @@ cascade: List kubeconfig sessions for a Kubernetes cluster +The cluster may be omitted when `kubernetes.cluster` is set in a directory `.thalassa` file. + ``` tcloud kubernetes kubeconfig-sessions list [cluster] [flags] ``` @@ -18,7 +20,7 @@ tcloud kubernetes kubeconfig-sessions list [cluster] [flags] ### Options ``` - --cluster string Cluster identity, name, or slug + --cluster string Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa) --exact-time Show full timestamps instead of relative time -h, --help help for list --no-header Do not print the header diff --git a/docs/tcloud/kubernetes/kubeconfig/_index.md b/docs/tcloud/kubernetes/kubeconfig/_index.md index 7413555..261b06b 100644 --- a/docs/tcloud/kubernetes/kubeconfig/_index.md +++ b/docs/tcloud/kubernetes/kubeconfig/_index.md @@ -11,8 +11,12 @@ cascade: Print a kubeconfig for a Kubernetes cluster +### Synopsis + +Print a kubeconfig for a Kubernetes cluster. The cluster may be omitted when `kubernetes.cluster` is set in a directory `.thalassa` file. + ``` -tcloud kubernetes kubeconfig [flags] +tcloud kubernetes kubeconfig [cluster] [flags] ``` ### Options diff --git a/docs/tcloud/kubernetes/machines_list/_index.md b/docs/tcloud/kubernetes/machines_list/_index.md index 445f66a..14417bc 100644 --- a/docs/tcloud/kubernetes/machines_list/_index.md +++ b/docs/tcloud/kubernetes/machines_list/_index.md @@ -13,7 +13,7 @@ List machines in a Kubernetes cluster ### Synopsis -Lists worker machines (nodes) across node pools in the given cluster. +Lists worker machines (nodes) across node pools in the given cluster. `--cluster` may be omitted when `kubernetes.cluster` is set in a directory `.thalassa` file. ``` tcloud kubernetes machines list [flags] @@ -22,7 +22,7 @@ tcloud kubernetes machines list [flags] ### Options ``` - --cluster string Cluster identity, name, or slug + --cluster string Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa) -h, --help help for list --no-header Do not print the header --nodepool string Filter by node pool identity, name, or slug diff --git a/docs/tcloud/kubernetes/nodepools/_index.md b/docs/tcloud/kubernetes/nodepools/_index.md index 7e60019..229dc0a 100644 --- a/docs/tcloud/kubernetes/nodepools/_index.md +++ b/docs/tcloud/kubernetes/nodepools/_index.md @@ -14,7 +14,7 @@ Manage Kubernetes NodePools ### Examples ``` - # List all nodepools in a cluster + # List all nodepools in a cluster (uses kubernetes.cluster from .thalassa when --cluster is omitted) tcloud kubernetes nodepools list # Create a new nodepool diff --git a/docs/tcloud/kubernetes/nodepools_create/_index.md b/docs/tcloud/kubernetes/nodepools_create/_index.md index 7040a4f..1402a4e 100644 --- a/docs/tcloud/kubernetes/nodepools_create/_index.md +++ b/docs/tcloud/kubernetes/nodepools_create/_index.md @@ -33,7 +33,7 @@ tcloud kubernetes nodepools create [flags] ``` --availability-zone strings Availability zone for the node pool (can be specified multiple times). If not specified, a random AZ from the cluster's region will be selected. - --cluster string Cluster identity, name, or slug (required) + --cluster string Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa) --enable-autohealing Enable autohealing for the node pool --enable-autoscaling Enable autoscaling for the node pool -h, --help help for create diff --git a/docs/tcloud/kubernetes/nodepools_delete/_index.md b/docs/tcloud/kubernetes/nodepools_delete/_index.md index 7116264..2c9a075 100644 --- a/docs/tcloud/kubernetes/nodepools_delete/_index.md +++ b/docs/tcloud/kubernetes/nodepools_delete/_index.md @@ -38,7 +38,7 @@ tcloud kubernetes nodepools delete [flags] ### Options ``` - --cluster string Cluster identity, name, or slug (required) + --cluster string Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa) --force Skip confirmation prompt -h, --help help for delete --nodepool string Node pool name, identity, or slug (required) diff --git a/docs/tcloud/kubernetes/nodepools_list/_index.md b/docs/tcloud/kubernetes/nodepools_list/_index.md index 7aaa0c1..0ce8261 100644 --- a/docs/tcloud/kubernetes/nodepools_list/_index.md +++ b/docs/tcloud/kubernetes/nodepools_list/_index.md @@ -18,7 +18,7 @@ tcloud kubernetes nodepools list [flags] ### Options ``` - --cluster string Cluster ID + --cluster string Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa) -h, --help help for list --no-header Do not print the header --vpc string VPC ID diff --git a/docs/tcloud/kubernetes/nodepools_update/_index.md b/docs/tcloud/kubernetes/nodepools_update/_index.md index 5b54603..0cf2dd6 100644 --- a/docs/tcloud/kubernetes/nodepools_update/_index.md +++ b/docs/tcloud/kubernetes/nodepools_update/_index.md @@ -40,7 +40,7 @@ tcloud kubernetes nodepools update [flags] ### Options ``` - --cluster string Cluster identity, name, or slug (required) + --cluster string Cluster identity, name, or slug (defaults to kubernetes.cluster in .thalassa) --enable-autohealing Enable autohealing for the node pool --enable-autoscaling Enable autoscaling for the node pool -h, --help help for update diff --git a/docs/tcloud/tcloud.md b/docs/tcloud/tcloud.md index 082e63b..a5df6df 100644 --- a/docs/tcloud/tcloud.md +++ b/docs/tcloud/tcloud.md @@ -21,6 +21,7 @@ A CLI for working with the Thalassa Cloud Platform -c, --context string Context name --debug Debug mode -h, --help help for tcloud + --ignore-dir-config Ignore directory-local .thalassa defaults -O, --organisation string Organisation slug or identity (overrides context) -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) --token string Personal access token (overrides context) @@ -33,6 +34,7 @@ A CLI for working with the Thalassa Cloud Platform * [tcloud compute](/docs/tcloud/tcloud_compute/) - Manage compute resources * [tcloud context](/docs/tcloud/tcloud_context/) - Manage context * [tcloud dbaas](/docs/tcloud/tcloud_dbaas/) - Manage database clusters and related services +* [tcloud dir](/docs/tcloud/tcloud_dir/) - Manage directory-local Thalassa defaults * [tcloud dns](/docs/tcloud/tcloud_dns/) - Manage DNS zones and records (beta) * [tcloud iam](/docs/tcloud/tcloud_iam/) - Identity and access management for your organisation * [tcloud kms](/docs/tcloud/tcloud_kms/) - Manage KMS keys and cryptographic operations (beta) diff --git a/internal/config/contextstate/config.go b/internal/config/contextstate/config.go index 664f282..529088a 100644 --- a/internal/config/contextstate/config.go +++ b/internal/config/contextstate/config.go @@ -24,15 +24,20 @@ func NewConfigFileContextManager(filename string) ConfigManager { } } -// Get returns the current context. +// Get returns the current context from the config file (current-context). func (c *configFileContextManager) Get() (Context, error) { - if c.config.CurrentContext == "" { + return c.GetByName(c.config.CurrentContext) +} + +// GetByName returns the named context from the config file. +func (c *configFileContextManager) GetByName(name string) (Context, error) { + if name == "" { return Context{}, errors.New("no current context set in config") } - contextRef, ok := c.getContextRef(c.config.CurrentContext) + contextRef, ok := c.getContextRef(name) if !ok { - return Context{}, fmt.Errorf("missing current context %q in config", c.config.CurrentContext) + return Context{}, fmt.Errorf("missing current context %q in config", name) } api, ok := c.getAPI(contextRef.Context.API) diff --git a/internal/config/contextstate/manager.go b/internal/config/contextstate/manager.go index 79ddf5c..d85f8c9 100644 --- a/internal/config/contextstate/manager.go +++ b/internal/config/contextstate/manager.go @@ -9,6 +9,7 @@ import ( "github.com/mitchellh/go-homedir" "github.com/thalassa-cloud/cli/internal/credentials" + "github.com/thalassa-cloud/cli/internal/dirconfig" "github.com/thalassa-cloud/cli/internal/projectresolve" ) @@ -53,10 +54,13 @@ var ( // ConfigManager defines an interface for managing contexts within the application. // It provides methods to get, set, and manipulate contexts, as well as to load and save configurations. type ConfigManager interface { - // Get returns the current context. + // Get returns the current context from the config file (current-context). // It returns an error if there is an issue retrieving the context. Get() (Context, error) + // GetByName returns the named context from the config file without changing current-context. + GetByName(name string) (Context, error) + // Set sets the current context to the one specified by name. // It returns an error if there is an issue setting the context. Set(name string) error @@ -105,6 +109,10 @@ func Init() { fmt.Printf("Failed to initialize context: %v\n", err) os.Exit(1) } + if err := dirconfig.Load(); err != nil { + fmt.Printf("Failed to load directory config: %v\n", err) + os.Exit(1) + } } func getConfigFilename() string { @@ -146,7 +154,7 @@ func MigrateCredentialsToKeychain() error { } func GetContextConfiguration() (Context, error) { - return globalConfigManager.Get() + return getEffectiveContext() } func Set(name string) error { @@ -166,14 +174,7 @@ func Save() error { } func Name() string { - if ContextFlag != "" { - return ContextFlag - } - currentcontext, err := globalConfigManager.Get() - if err != nil { - return "" - } - return currentcontext.Name + return effectiveContextName() } func Organisation() string { @@ -183,8 +184,11 @@ func Organisation() string { if organisation := os.Getenv(ThalassaOrganisationIDEnvVar); organisation != "" { return organisation } + if organisation := dirconfig.Current().Config.Organisation; organisation != "" { + return organisation + } - currentcontext, err := globalConfigManager.Get() + currentcontext, err := getEffectiveContext() if err != nil { return "" } @@ -198,8 +202,11 @@ func Project() string { if project := os.Getenv(ThalassaProjectIDEnvVar); project != "" { return projectresolve.NormalizeProjectRef(project) } + if project := dirconfig.Current().Config.Project; project != "" { + return projectresolve.NormalizeProjectRef(project) + } - currentcontext, err := globalConfigManager.Get() + currentcontext, err := getEffectiveContext() if err != nil { return "" } @@ -214,7 +221,7 @@ func Server() string { return server } - currentcontext, err := globalConfigManager.Get() + currentcontext, err := getEffectiveContext() if err != nil { return DefaultAPIURL } @@ -232,7 +239,7 @@ func AccessToken() string { return accessToken } - currentcontext, err := globalConfigManager.Get() + currentcontext, err := getEffectiveContext() if err != nil { return "" } @@ -246,7 +253,7 @@ func PersonalAccessToken() string { if personalAccessToken := os.Getenv(ThalassaPersonalAccessTokenEnvVar); personalAccessToken != "" { return personalAccessToken } - currentcontext, err := globalConfigManager.Get() + currentcontext, err := getEffectiveContext() if err != nil { return "" } @@ -265,7 +272,7 @@ func ClientIdOrFlag() string { } func ClientId() string { - currentcontext, err := globalConfigManager.Get() + currentcontext, err := getEffectiveContext() if err != nil { return "" } @@ -283,7 +290,7 @@ func ClientSecretOrFlag() string { } func ClientSecret() string { - currentcontext, err := globalConfigManager.Get() + currentcontext, err := getEffectiveContext() if err != nil { return "" } @@ -291,7 +298,27 @@ func ClientSecret() string { } func GetContext() (Context, error) { - return globalConfigManager.Get() + return getEffectiveContext() +} + +func effectiveContextName() string { + if ContextFlag != "" { + return ContextFlag + } + if name := dirconfig.Current().Config.Context; name != "" { + return name + } + if globalConfigManager == nil { + return "" + } + return globalConfigManager.Config().CurrentContext +} + +func getEffectiveContext() (Context, error) { + if globalConfigManager == nil { + return Context{}, errors.New("no current context set in config") + } + return globalConfigManager.GetByName(effectiveContextName()) } func Debug() bool { diff --git a/internal/config/contextstate/overlay_test.go b/internal/config/contextstate/overlay_test.go new file mode 100644 index 0000000..093d11b --- /dev/null +++ b/internal/config/contextstate/overlay_test.go @@ -0,0 +1,182 @@ +package contextstate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + + "github.com/thalassa-cloud/cli/internal/credentials" + "github.com/thalassa-cloud/cli/internal/dirconfig" +) + +func TestDirectoryOverlayPrecedence(t *testing.T) { + keyring.MockInit() + t.Cleanup(credentials.ResetKeyring) + t.Setenv(credentials.ThalassaCredentialStoreEnvVar, credentials.StoreKeychain) + + path := filepath.Join(t.TempDir(), ".tcloud") + globalConfigManager = NewConfigFileContextManager(path) + t.Cleanup(func() { + dirconfig.Reset() + resetAccessorFlags() + }) + dirconfig.Reset() + resetAccessorFlags() + + require.NoError(t, globalConfigManager.AddOrMergeContext(Context{ + Name: "default", + Organisation: "org-default", + Project: "proj-default", + Users: Users{Name: "default", User: User{}}, + Servers: Servers{Name: "api", API: API{Server: "https://api.example.com"}}, + })) + require.NoError(t, globalConfigManager.AddOrMergeContext(Context{ + Name: "prod", + Organisation: "org-prod", + Project: "proj-prod", + Users: Users{Name: "prod", User: User{}}, + Servers: Servers{Name: "api-prod", API: API{Server: "https://api.prod.example.com"}}, + })) + require.NoError(t, globalConfigManager.Set("default")) + + tests := []struct { + name string + setup func(t *testing.T) + wantContext string + wantOrg string + wantProject string + wantServer string + }{ + { + name: "file current-context when no overlay", + setup: func(t *testing.T) {}, + wantContext: "default", + wantOrg: "org-default", + wantProject: "proj-default", + wantServer: "https://api.example.com", + }, + { + name: "directory context name switches without mutating file", + setup: func(t *testing.T) { + dirconfig.SetCurrentForTest(dirconfig.Config{Context: "prod"}, "/repo/.thalassa") + }, + wantContext: "prod", + wantOrg: "org-prod", + wantProject: "proj-prod", + wantServer: "https://api.prod.example.com", + }, + { + name: "directory organisation overrides named context", + setup: func(t *testing.T) { + dirconfig.SetCurrentForTest(dirconfig.Config{Context: "prod", Organisation: "org-overlay"}, "/repo/.thalassa") + }, + wantContext: "prod", + wantOrg: "org-overlay", + wantProject: "proj-prod", + wantServer: "https://api.prod.example.com", + }, + { + name: "flag wins over directory overlay", + setup: func(t *testing.T) { + dirconfig.SetCurrentForTest(dirconfig.Config{Context: "prod", Organisation: "org-overlay", Project: "proj-overlay"}, "/repo/.thalassa") + ContextFlag = "default" + OrganisationFlag = "org-flag" + ProjectFlag = "proj-flag" + }, + wantContext: "default", + wantOrg: "org-flag", + wantProject: "proj-flag", + wantServer: "https://api.example.com", + }, + { + name: "env wins over directory overlay", + setup: func(t *testing.T) { + dirconfig.SetCurrentForTest(dirconfig.Config{Organisation: "org-overlay", Project: "proj-overlay"}, "/repo/.thalassa") + t.Setenv(ThalassaOrganisationIDEnvVar, "org-env") + t.Setenv(ThalassaProjectIDEnvVar, "proj-env") + }, + wantContext: "default", + wantOrg: "org-env", + wantProject: "proj-env", + wantServer: "https://api.example.com", + }, + { + name: "disabled overlay is ignored", + setup: func(t *testing.T) { + dirconfig.SetCurrentForTest(dirconfig.Config{Context: "prod"}, "/repo/.thalassa") + dirconfig.Disable() + }, + wantContext: "default", + wantOrg: "org-default", + wantProject: "proj-default", + wantServer: "https://api.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dirconfig.Reset() + resetAccessorFlags() + t.Setenv(ThalassaOrganisationIDEnvVar, "") + t.Setenv(ThalassaProjectIDEnvVar, "") + tt.setup(t) + + assert.Equal(t, tt.wantContext, Name()) + assert.Equal(t, tt.wantOrg, Organisation()) + assert.Equal(t, tt.wantProject, Project()) + assert.Equal(t, tt.wantServer, Server()) + + cfg, err := GetContextConfiguration() + require.NoError(t, err) + assert.Equal(t, tt.wantContext, cfg.Name) + + fileCtx, err := globalConfigManager.Get() + require.NoError(t, err) + assert.Equal(t, "default", fileCtx.Name, "directory overlay must not mutate ~/.tcloud current-context") + }) + } +} + +func TestGetByName(t *testing.T) { + path := filepath.Join(t.TempDir(), ".tcloud") + manager := NewConfigFileContextManager(path) + require.NoError(t, manager.AddOrMergeContext(Context{ + Name: "default", + Organisation: "org-default", + Users: Users{Name: "default", User: User{}}, + Servers: Servers{Name: "api", API: API{Server: "https://api.example.com"}}, + })) + require.NoError(t, manager.AddOrMergeContext(Context{ + Name: "prod", + Organisation: "org-prod", + Users: Users{Name: "prod", User: User{}}, + Servers: Servers{Name: "api", API: API{Server: "https://api.example.com"}}, + })) + require.NoError(t, manager.Set("default")) + + got, err := manager.GetByName("prod") + require.NoError(t, err) + assert.Equal(t, "prod", got.Name) + assert.Equal(t, "org-prod", got.Organisation) + + fileCtx, err := manager.Get() + require.NoError(t, err) + assert.Equal(t, "default", fileCtx.Name) +} + +func resetAccessorFlags() { + OrganisationFlag = "" + ProjectFlag = "" + ContextFlag = "" + EndpointFlag = "" + AccessTokenFlag = "" + PersonalAccessTokenFlag = "" + OidcClientIDFlag = "" + OidcClientSecretFlag = "" + _ = os.Unsetenv(ThalassaOrganisationIDEnvVar) + _ = os.Unsetenv(ThalassaProjectIDEnvVar) +} diff --git a/internal/dirconfig/config.go b/internal/dirconfig/config.go new file mode 100644 index 0000000..b627c54 --- /dev/null +++ b/internal/dirconfig/config.go @@ -0,0 +1,52 @@ +package dirconfig + +import "strings" + +const ( + // FileName is the project-local config file (or directory) discovered by walking up from the working directory. + FileName = ".thalassa" + // NestedConfigFile is used when FileName is a directory. + NestedConfigFile = "config.yaml" + // MaxSize is the maximum size of a directory config file. + MaxSize = 64 * 1024 + + // EnvDisable controls whether directory config is applied. Set to 0, false, no, or off to skip it. + EnvDisable = "THALASSA_DIR_CONFIG" +) + +// IgnoreDirConfigFlag disables directory config for this process when set (bound to --ignore-dir-config). +var IgnoreDirConfigFlag bool + +// Config is the project-local overlay. It may only contain names and references — never credentials. +type Config struct { + Context string `yaml:"context,omitempty"` + Organisation string `yaml:"organisation,omitempty"` + Project string `yaml:"project,omitempty"` + Kubernetes KubernetesConfig `yaml:"kubernetes,omitempty"` +} + +// KubernetesConfig holds Kubernetes defaults for shells in this directory tree. +type KubernetesConfig struct { + Cluster string `yaml:"cluster,omitempty"` +} + +// Loaded is a discovered directory config and the path it was read from. +type Loaded struct { + Config Config + Path string +} + +// Empty reports whether the overlay contains no values. +func (c Config) Empty() bool { + return strings.TrimSpace(c.Context) == "" && + strings.TrimSpace(c.Organisation) == "" && + strings.TrimSpace(c.Project) == "" && + strings.TrimSpace(c.Kubernetes.Cluster) == "" +} + +func (c *Config) normalize() { + c.Context = strings.TrimSpace(c.Context) + c.Organisation = strings.TrimSpace(c.Organisation) + c.Project = strings.TrimSpace(c.Project) + c.Kubernetes.Cluster = strings.TrimSpace(c.Kubernetes.Cluster) +} diff --git a/internal/dirconfig/discover.go b/internal/dirconfig/discover.go new file mode 100644 index 0000000..f8e107a --- /dev/null +++ b/internal/dirconfig/discover.go @@ -0,0 +1,197 @@ +package dirconfig + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +var ( + mu sync.Mutex + cached Loaded + loaded bool +) + +// Disabled reports whether directory config should be ignored. +func Disabled() bool { + if IgnoreDirConfigFlag { + return true + } + switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvDisable))) { + case "0", "false", "no", "off": + return true + } + return false +} + +// Disable ignores directory config for the rest of this process. +// Used by the Kubernetes exec-credential plugin so a repo file cannot redirect it. +func Disable() { + IgnoreDirConfigFlag = true +} + +// Reset clears the process-wide cache. Tests only. +func Reset() { + mu.Lock() + defer mu.Unlock() + cached = Loaded{} + loaded = false + IgnoreDirConfigFlag = false +} + +// SetCurrentForTest installs an overlay without walking the filesystem. Tests only. +func SetCurrentForTest(cfg Config, path string) { + mu.Lock() + defer mu.Unlock() + cfg.normalize() + cached = Loaded{Config: cfg, Path: path} + loaded = true +} + +// Current returns the cached overlay. Missing or disabled config yields a zero Loaded value. +func Current() Loaded { + mu.Lock() + defer mu.Unlock() + if Disabled() { + return Loaded{} + } + if !loaded { + _ = loadLocked() + } + return cached +} + +// Load discovers directory config from the process working directory and caches it. +// A missing file is not an error. Invalid YAML or an oversized file is an error. +func Load() error { + mu.Lock() + defer mu.Unlock() + return loadLocked() +} + +func loadLocked() error { + if loaded { + return nil + } + loaded = true + cached = Loaded{} + if Disabled() { + return nil + } + cwd, err := os.Getwd() + if err != nil { + return nil + } + cfg, path, err := Discover(cwd) + if err != nil { + loaded = false + return err + } + cached = Loaded{Config: cfg, Path: path} + return nil +} + +// Discover walks from cwd toward the filesystem root and returns the nearest overlay. +// It does not merge ancestor files. A missing file returns a zero Config and empty path. +func Discover(cwd string) (Config, string, error) { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return Config{}, "", nil + } + dir, err := filepath.Abs(cwd) + if err != nil { + return Config{}, "", fmt.Errorf("resolve directory config path: %w", err) + } + + for { + cfg, path, err := configInDir(dir) + if err != nil { + return Config{}, "", err + } + if path != "" { + return cfg, path, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return Config{}, "", nil + } + dir = parent + } +} + +func configInDir(dir string) (Config, string, error) { + candidate := filepath.Join(dir, FileName) + info, err := os.Stat(candidate) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return Config{}, "", nil + } + return Config{}, "", fmt.Errorf("stat directory config %s: %w", candidate, err) + } + + switch { + case info.Mode().IsRegular(): + cfg, err := readConfig(candidate) + if err != nil { + return Config{}, "", err + } + return cfg, candidate, nil + case info.IsDir(): + nested := filepath.Join(candidate, NestedConfigFile) + nestedInfo, err := os.Stat(nested) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return Config{}, "", nil + } + return Config{}, "", fmt.Errorf("stat directory config %s: %w", nested, err) + } + if !nestedInfo.Mode().IsRegular() { + return Config{}, "", nil + } + cfg, err := readConfig(nested) + if err != nil { + return Config{}, "", err + } + return cfg, nested, nil + default: + return Config{}, "", nil + } +} + +func readConfig(path string) (Config, error) { + f, err := os.Open(path) + if err != nil { + return Config{}, fmt.Errorf("read directory config %s: %w", path, err) + } + defer func() { _ = f.Close() }() + + info, err := f.Stat() + if err != nil { + return Config{}, fmt.Errorf("stat directory config %s: %w", path, err) + } + if info.Size() > MaxSize { + return Config{}, fmt.Errorf("directory config %s is larger than %d bytes", path, MaxSize) + } + + data, err := io.ReadAll(io.LimitReader(f, MaxSize+1)) + if err != nil { + return Config{}, fmt.Errorf("read directory config %s: %w", path, err) + } + if len(data) > MaxSize { + return Config{}, fmt.Errorf("directory config %s is larger than %d bytes", path, MaxSize) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parse directory config %s: %w", path, err) + } + cfg.normalize() + return cfg, nil +} diff --git a/internal/dirconfig/discover_test.go b/internal/dirconfig/discover_test.go new file mode 100644 index 0000000..d82ffe2 --- /dev/null +++ b/internal/dirconfig/discover_test.go @@ -0,0 +1,254 @@ +package dirconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestDiscover(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T, root string) string + want Config + wantFile string + wantErr string + wantEmpty bool + }{ + { + name: "missing file is empty", + setup: func(t *testing.T, root string) string { + t.Helper() + return filepath.Join(root, "repo") + }, + wantEmpty: true, + }, + { + name: "file form", + setup: func(t *testing.T, root string) string { + t.Helper() + dir := filepath.Join(root, "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + writeThalassa(t, filepath.Join(dir, FileName), `context: prod +kubernetes: + cluster: prod-cluster +`) + return dir + }, + want: Config{ + Context: "prod", + Kubernetes: KubernetesConfig{Cluster: "prod-cluster"}, + }, + wantFile: FileName, + }, + { + name: "directory form", + setup: func(t *testing.T, root string) string { + t.Helper() + dir := filepath.Join(root, "repo") + cfgDir := filepath.Join(dir, FileName) + require.NoError(t, os.MkdirAll(cfgDir, 0o755)) + writeThalassa(t, filepath.Join(cfgDir, NestedConfigFile), "context: staging\n") + return dir + }, + want: Config{ + Context: "staging", + }, + wantFile: filepath.Join(FileName, NestedConfigFile), + }, + { + name: "nearest child wins over parent", + setup: func(t *testing.T, root string) string { + t.Helper() + parent := filepath.Join(root, "repo") + child := filepath.Join(parent, "services", "api") + require.NoError(t, os.MkdirAll(child, 0o755)) + writeThalassa(t, filepath.Join(parent, FileName), "context: parent\n") + writeThalassa(t, filepath.Join(child, FileName), "context: child\n") + return child + }, + want: Config{Context: "child"}, + wantFile: FileName, + }, + { + name: "walks up to parent when child has none", + setup: func(t *testing.T, root string) string { + t.Helper() + parent := filepath.Join(root, "repo") + child := filepath.Join(parent, "nested") + require.NoError(t, os.MkdirAll(child, 0o755)) + writeThalassa(t, filepath.Join(parent, FileName), "organisation: acme\n") + return child + }, + want: Config{Organisation: "acme"}, + wantFile: FileName, + }, + { + name: "directory without config.yaml continues walk", + setup: func(t *testing.T, root string) string { + t.Helper() + parent := filepath.Join(root, "repo") + child := filepath.Join(parent, "app") + require.NoError(t, os.MkdirAll(filepath.Join(child, FileName), 0o755)) + writeThalassa(t, filepath.Join(parent, FileName), "project: platform\n") + return child + }, + want: Config{Project: "platform"}, + wantFile: FileName, + }, + { + name: "invalid yaml fails closed with path", + setup: func(t *testing.T, root string) string { + t.Helper() + dir := filepath.Join(root, "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + writeThalassa(t, filepath.Join(dir, FileName), "context: [\n") + return dir + }, + wantErr: FileName, + }, + { + name: "oversized file fails closed", + setup: func(t *testing.T, root string) string { + t.Helper() + dir := filepath.Join(root, "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, FileName), []byte(strings.Repeat("a", MaxSize+1)), 0o644)) + return dir + }, + wantErr: "larger than", + }, + { + name: "unknown secret keys are ignored", + setup: func(t *testing.T, root string) string { + t.Helper() + dir := filepath.Join(root, "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + writeThalassa(t, filepath.Join(dir, FileName), `context: prod +token: tc_pat_should_not_be_used +accessToken: leaked +kubernetes: + cluster: prod-cluster +`) + return dir + }, + want: Config{ + Context: "prod", + Kubernetes: KubernetesConfig{Cluster: "prod-cluster"}, + }, + wantFile: FileName, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + cwd := tt.setup(t, root) + require.NoError(t, os.MkdirAll(cwd, 0o755)) + + got, path, err := Discover(cwd) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantEmpty { + assert.True(t, got.Empty()) + assert.Empty(t, path) + return + } + assert.Equal(t, tt.want, got) + assert.Truef(t, strings.HasSuffix(path, filepath.FromSlash(tt.wantFile)), "path %q should end with %q", path, tt.wantFile) + }) + } +} + +func TestLoadRespectsDisableEnv(t *testing.T) { + dir := t.TempDir() + writeThalassa(t, filepath.Join(dir, FileName), "context: prod\n") + t.Chdir(dir) + t.Setenv(EnvDisable, "0") + t.Cleanup(Reset) + Reset() + + require.NoError(t, Load()) + assert.True(t, Disabled()) + assert.Empty(t, Current().Path) + assert.True(t, Current().Config.Empty()) +} + +func TestLoadCachesFile(t *testing.T) { + dir := t.TempDir() + writeThalassa(t, filepath.Join(dir, FileName), "context: prod\nkubernetes:\n cluster: c1\n") + t.Chdir(dir) + t.Cleanup(Reset) + Reset() + + require.NoError(t, Load()) + got := Current() + assert.Equal(t, "prod", got.Config.Context) + assert.Equal(t, "c1", got.Config.Kubernetes.Cluster) + assert.Equal(t, filepath.Join(dir, FileName), got.Path) +} + +func TestDisableClearsCurrent(t *testing.T) { + t.Cleanup(Reset) + Reset() + SetCurrentForTest(Config{Context: "prod"}, "/tmp/.thalassa") + assert.Equal(t, "prod", Current().Config.Context) + + Disable() + assert.True(t, Disabled()) + assert.True(t, Current().Config.Empty()) +} + +func TestWriteFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, FileName) + cfg := Config{Context: "prod", Kubernetes: KubernetesConfig{Cluster: "c1"}} + + require.NoError(t, WriteFile(path, cfg, false)) + data, err := os.ReadFile(path) + require.NoError(t, err) + + var got Config + require.NoError(t, yaml.Unmarshal(data, &got)) + assert.Equal(t, cfg, got) + + err = WriteFile(path, cfg, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + cfg.Kubernetes.Cluster = "c2" + require.NoError(t, WriteFile(path, cfg, true)) +} + +func TestWriteFileRefusesDirectory(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, FileName) + require.NoError(t, os.Mkdir(path, 0o755)) + err := WriteFile(path, Config{Context: "prod"}, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "is a directory") +} + +func TestWriteFileRejectsEmpty(t *testing.T) { + err := WriteFile(filepath.Join(t.TempDir(), FileName), Config{}, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "no values") +} + +func writeThalassa(t *testing.T, path, contents string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o644)) +} diff --git a/internal/dirconfig/write.go b/internal/dirconfig/write.go new file mode 100644 index 0000000..a8883fe --- /dev/null +++ b/internal/dirconfig/write.go @@ -0,0 +1,41 @@ +package dirconfig + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const fileMode = 0o644 + +// WriteFile writes cfg to path. It refuses to overwrite an existing path unless force is true. +// The file form is intended to be committed, so credentials must never be included in cfg. +func WriteFile(path string, cfg Config, force bool) error { + cfg.normalize() + if cfg.Empty() { + return fmt.Errorf("directory config has no values to write") + } + + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + return fmt.Errorf("%s is a directory; write %s or remove the directory", path, filepath.Join(path, NestedConfigFile)) + } + if !force { + return fmt.Errorf("%s already exists (use --force to overwrite)", path) + } + } else if !os.IsNotExist(err) { + return err + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal directory config: %w", err) + } + if err := os.WriteFile(path, data, fileMode); err != nil { + return fmt.Errorf("write directory config %s: %w", path, err) + } + return nil +} diff --git a/internal/kuberesolve/preferred.go b/internal/kuberesolve/preferred.go new file mode 100644 index 0000000..e5451c1 --- /dev/null +++ b/internal/kuberesolve/preferred.go @@ -0,0 +1,40 @@ +package kuberesolve + +import ( + "fmt" + "os" + "strings" + + "github.com/thalassa-cloud/cli/internal/dirconfig" +) + +const ( + EnvClusterIdentity = "TCLOUD_CLUSTER_IDENTITY" + EnvClusterSlug = "TCLOUD_CLUSTER_SLUG" + EnvClusterName = "TCLOUD_CLUSTER_NAME" +) + +// PreferredClusterRef resolves a cluster reference from explicit input, connect-shell env, or directory config. +func PreferredClusterRef(explicit string) (string, bool) { + if ref := strings.TrimSpace(explicit); ref != "" { + return ref, true + } + for _, env := range []string{EnvClusterIdentity, EnvClusterSlug, EnvClusterName} { + if v := strings.TrimSpace(os.Getenv(env)); v != "" { + return v, true + } + } + if cluster := strings.TrimSpace(dirconfig.Current().Config.Kubernetes.Cluster); cluster != "" { + return cluster, true + } + return "", false +} + +// RequireClusterRef is PreferredClusterRef with an error when no cluster is configured. +func RequireClusterRef(explicit string) (string, error) { + ref, ok := PreferredClusterRef(explicit) + if !ok { + return "", fmt.Errorf("cluster is required (argument, --cluster, or kubernetes.cluster in .thalassa)") + } + return ref, nil +} diff --git a/internal/kuberesolve/preferred_test.go b/internal/kuberesolve/preferred_test.go new file mode 100644 index 0000000..dbde1f9 --- /dev/null +++ b/internal/kuberesolve/preferred_test.go @@ -0,0 +1,114 @@ +package kuberesolve + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thalassa-cloud/cli/internal/dirconfig" +) + +func TestPreferredClusterRef(t *testing.T) { + t.Cleanup(dirconfig.Reset) + + tests := []struct { + name string + explicit string + env map[string]string + overlay dirconfig.Config + disabled bool + want string + wantOK bool + }{ + { + name: "empty", + wantOK: false, + }, + { + name: "explicit wins", + explicit: "from-flag", + env: map[string]string{ + EnvClusterIdentity: "from-env", + }, + overlay: dirconfig.Config{Kubernetes: dirconfig.KubernetesConfig{Cluster: "from-dir"}}, + want: "from-flag", + wantOK: true, + }, + { + name: "env identity before directory", + env: map[string]string{ + EnvClusterIdentity: "id-env", + }, + overlay: dirconfig.Config{Kubernetes: dirconfig.KubernetesConfig{Cluster: "from-dir"}}, + want: "id-env", + wantOK: true, + }, + { + name: "env slug when identity unset", + env: map[string]string{ + EnvClusterSlug: "slug-env", + }, + want: "slug-env", + wantOK: true, + }, + { + name: "env name last among env vars", + env: map[string]string{ + EnvClusterName: "name-env", + }, + want: "name-env", + wantOK: true, + }, + { + name: "directory cluster", + overlay: dirconfig.Config{Kubernetes: dirconfig.KubernetesConfig{Cluster: "from-dir"}}, + want: "from-dir", + wantOK: true, + }, + { + name: "disabled directory is ignored", + overlay: dirconfig.Config{Kubernetes: dirconfig.KubernetesConfig{Cluster: "from-dir"}}, + disabled: true, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dirconfig.Reset() + t.Setenv(EnvClusterIdentity, "") + t.Setenv(EnvClusterSlug, "") + t.Setenv(EnvClusterName, "") + for k, v := range tt.env { + t.Setenv(k, v) + } + if !tt.overlay.Empty() { + dirconfig.SetCurrentForTest(tt.overlay, "/repo/.thalassa") + } + if tt.disabled { + dirconfig.Disable() + } + + got, ok := PreferredClusterRef(tt.explicit) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRequireClusterRef(t *testing.T) { + t.Cleanup(dirconfig.Reset) + dirconfig.Reset() + t.Setenv(EnvClusterIdentity, "") + t.Setenv(EnvClusterSlug, "") + t.Setenv(EnvClusterName, "") + + _, err := RequireClusterRef("") + require.Error(t, err) + assert.Contains(t, err.Error(), ".thalassa") + + got, err := RequireClusterRef("c1") + require.NoError(t, err) + assert.Equal(t, "c1", got) +}