Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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:
Expand Down
4 changes: 4 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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{
Expand Down Expand Up @@ -59,13 +61,15 @@ 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)
_ = RootCmd.RegisterFlagCompletionFunc("project", completion.CompleteProject)

RootCmd.AddCommand(api.ApiCmd)
RootCmd.AddCommand(context.ContextCmd)
RootCmd.AddCommand(dir.DirCmd)
RootCmd.AddCommand(version.VersionCmd)

RootCmd.AddCommand(regions.RegionsCmd)
Expand Down
2 changes: 1 addition & 1 deletion cmd/context/current.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
19 changes: 19 additions & 0 deletions cmd/dir/dir.go
Original file line number Diff line number Diff line change
@@ -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)
}
58 changes: 58 additions & 0 deletions cmd/dir/init.go
Original file line number Diff line number Diff line change
@@ -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")
}
48 changes: 48 additions & 0 deletions cmd/dir/show.go
Original file line number Diff line number Diff line change
@@ -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
},
}
16 changes: 10 additions & 6 deletions cmd/kubernetes/connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
}
3 changes: 3 additions & 0 deletions cmd/kubernetes/credential/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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")
}
Expand Down
16 changes: 13 additions & 3 deletions cmd/kubernetes/kubeconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,28 @@ 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()
if err != nil {
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
}
Expand Down
10 changes: 7 additions & 3 deletions cmd/kubernetes/kubeconfigsessions/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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)
}
8 changes: 5 additions & 3 deletions cmd/kubernetes/kubeconfigsessions/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
7 changes: 5 additions & 2 deletions cmd/kubernetes/machines/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
9 changes: 5 additions & 4 deletions cmd/kubernetes/nodepools/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
Expand Down
Loading
Loading