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) +}