diff --git a/cmd/cliCredential.go b/cmd/cliCredential.go new file mode 100644 index 0000000..4a97967 --- /dev/null +++ b/cmd/cliCredential.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" +) + +// expiredWarnWindow is how long the expiry hint stays suppressed after it is +// printed. kubectl retries discovery several times per invocation, re-running +// this plugin each time, so without this the hint would print once per retry. +const expiredWarnWindow = 5 * time.Minute + +// cliCredentialCmd is a client-go exec credential plugin. kubectl invokes it to +// obtain the bearer token for silta-proxied clusters. It is hidden from normal +// help output. +var cliCredentialCmd = &cobra.Command{ + Use: "cli-credential", + Short: "Output a Kubernetes ExecCredential for the Silta Hub proxy", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + creds, err := common.LoadCredentials() + if err != nil { + fmt.Fprintf(os.Stderr, "silta: %s\n", err) + os.Exit(1) + } + + if creds.Expired() { + if shouldPrintExpiredHint() { + fmt.Fprintf(os.Stderr, "silta: session has expired (expired at %s). Log in again: silta hub login (add --device on headless machines)\n", creds.ExpiresAt) + } + os.Exit(1) + } + + status := map[string]interface{}{ + "token": creds.Token, + } + if creds.ExpiresAt != "" { + status["expirationTimestamp"] = creds.ExpiresAt + } + + out := map[string]interface{}{ + "apiVersion": "client.authentication.k8s.io/v1", + "kind": "ExecCredential", + "status": status, + } + + if err := json.NewEncoder(os.Stdout).Encode(out); err != nil { + fmt.Fprintf(os.Stderr, "silta: failed to encode credential: %s\n", err) + os.Exit(1) + } + }, +} + +// shouldPrintExpiredHint reports whether the session-expiry hint should be +// shown now, recording that it was shown under a marker file in the +// configuration directory so that repeat invocations within +// expiredWarnWindow (kubectl re-fetches credentials on every discovery retry) +// stay quiet. File errors fail open: print the hint. +func shouldPrintExpiredHint() bool { + path := filepath.Join(common.ConfigDir(), ".cli-credential-expired") + + if data, err := os.ReadFile(path); err == nil { + if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data))); err == nil { + if time.Since(ts) < expiredWarnWindow { + return false + } + } + } + + if err := os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)), 0600); err != nil { + return true + } + return true +} + +func init() { + hubCmd.AddCommand(cliCredentialCmd) +} diff --git a/cmd/hub.go b/cmd/hub.go new file mode 100644 index 0000000..f352571 --- /dev/null +++ b/cmd/hub.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" +) + +// hubCmd groups commands that interact with a Silta Hub. +var hubCmd = &cobra.Command{ + Use: "hub", + Short: "Interact with a Silta hub", + Long: "Authenticate against a Silta hub, manage kubeconfig access and open the hub in a browser.", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(cmd.Usage()) + }, +} + +// hubDashboardCmd opens the configured hub dashboard in the default browser. +var hubDashboardCmd = &cobra.Command{ + Use: "dashboard", + Short: "Open the Silta dashboard in a browser", + Run: func(cmd *cobra.Command, args []string) { + target := resolveDashboardBrowserURL() + if target == "" { + fmt.Println("Error: no Silta hub URL configured. Pass --hub-url or run 'silta config set hub.url '.") + return + } + + fmt.Printf("Opening %s\n", target) + if err := common.OpenBrowser(target); err != nil { + fmt.Printf("Failed to open browser: %s\n", err) + fmt.Printf("Open this URL manually: %s\n", target) + } + }, +} + +// resolveDashboardBrowserURL prefers the dashboard-advertised frontend URL, +// falling back to the configured hub URL when it cannot be reached. +func resolveDashboardBrowserURL() string { + hubURL := resolveHubURL() + if hubURL == "" { + if creds, err := common.LoadCredentials(); err == nil { + hubURL = creds.HubURL + } + } + if hubURL == "" { + return "" + } + + client := common.NewHubClient(hubURL, "") + var info struct { + FrontendURL string `json:"frontend_url"` + } + if _, err := client.GetJSON("/api/cli/info", &info); err == nil && info.FrontendURL != "" { + return info.FrontendURL + } + return hubURL +} + +func init() { + hubCmd.AddCommand(hubDashboardCmd) + rootCmd.AddCommand(hubCmd) +} diff --git a/cmd/hubClusters.go b/cmd/hubClusters.go new file mode 100644 index 0000000..d57cbab --- /dev/null +++ b/cmd/hubClusters.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" +) + +var hubClustersJSON bool + +// hubClustersCmd lists the clusters the logged-in user may access via the CLI. +var hubClustersCmd = &cobra.Command{ + Use: "clusters", + Short: "List the clusters accessible from your Silta account", + Long: `List the clusters the logged-in Silta user may access, with the +kubeconfig context name, assigned namespace and (when reported by cluster +inventory) the Kubernetes version. + +Use --json for machine-readable output (exit code 0 on success, 1 on +failure).`, + Run: func(cmd *cobra.Command, args []string) { + creds, err := common.LoadCredentials() + if err != nil { + fmt.Fprintln(os.Stderr, "Error: not logged in. Run 'silta hub login' first.") + os.Exit(1) + } + + client := common.NewHubClient(creds.HubURL, creds.Token) + resp, err := common.FetchHubClusters(client) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to fetch clusters from Silta hub: %s\n", err) + os.Exit(1) + } + + if hubClustersJSON { + encoded, err := json.MarshalIndent(resp, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to encode clusters: %s\n", err) + os.Exit(1) + } + fmt.Println(string(encoded)) + return + } + + if len(resp.Clusters) == 0 { + fmt.Println("No cluster access is currently assigned to your account.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "CONTEXT\tNAMESPACE\tKUBERNETES VERSION") + for _, cluster := range resp.Clusters { + ns := cluster.Namespace + if ns == "" { + ns = "(none)" + } + version := cluster.KubernetesVersion + if version == "" { + version = "(unknown)" + } + fmt.Fprintf(w, "silta-%s\t%s\t%s\n", cluster.ID, ns, version) + } + if err := w.Flush(); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to print clusters: %s\n", err) + os.Exit(1) + } + }, +} + +func init() { + hubClustersCmd.Flags().BoolVar(&hubClustersJSON, "json", false, "Output clusters as JSON") + hubCmd.AddCommand(hubClustersCmd) +} diff --git a/cmd/hubInfo.go b/cmd/hubInfo.go new file mode 100644 index 0000000..1c5ac0f --- /dev/null +++ b/cmd/hubInfo.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" +) + +var infoVerify bool + +// hubInfoCmd reports the current Silta hub login state from the locally +// stored credentials. +var hubInfoCmd = &cobra.Command{ + Use: "info", + Short: "Show current Silta Hub login state", + Run: func(cmd *cobra.Command, args []string) { + creds, err := common.LoadCredentials() + if err != nil { + fmt.Println("Not logged in. Run 'silta hub login' first.") + return + } + + fmt.Printf("Logged in as: %s\n", creds.Username) + fmt.Printf("Silta Hub URL: %s\n", creds.HubURL) + if creds.ExpiresAt != "" { + state := "valid" + if creds.Expired() { + state = "EXPIRED - run 'silta hub login' again" + } + fmt.Printf("Token expiry: %s (%s)\n", creds.ExpiresAt, state) + } + + if !infoVerify { + return + } + + client := common.NewHubClient(creds.HubURL, creds.Token) + resp, err := common.FetchHubClusters(client) + if err != nil { + fmt.Printf("\nServer verification failed: %s\n", err) + return + } + + fmt.Printf("\nServer confirmed session for: %s\n", resp.Username) + if len(resp.Clusters) == 0 { + fmt.Println("No cluster access is currently assigned to your account.") + return + } + fmt.Printf("Cluster access (%d):\n", len(resp.Clusters)) + for _, cluster := range resp.Clusters { + ns := cluster.Namespace + if ns == "" { + ns = "(none)" + } + if cluster.KubernetesVersion == "" { + fmt.Printf(" silta-%s [namespace: %s]\n", cluster.ID, ns) + } else { + fmt.Printf(" silta-%s [namespace: %s, kubernetes: %s]\n", cluster.ID, ns, cluster.KubernetesVersion) + } + } + }, +} + +func init() { + hubInfoCmd.Flags().BoolVar(&infoVerify, "verify", false, "Verify the token with the silta hub and list cluster access") + hubCmd.AddCommand(hubInfoCmd) +} diff --git a/cmd/hubKubeconfig.go b/cmd/hubKubeconfig.go new file mode 100644 index 0000000..7befb79 --- /dev/null +++ b/cmd/hubKubeconfig.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" +) + +// hubKubeconfigCmd refreshes the local kubeconfig with the user's current cluster +// access from the Silta hub. It merges silta- contexts into the user's kubeconfig, each authenticated via the 'silta hub cli-credential' exec plugin. +var hubKubeconfigCmd = &cobra.Command{ + Use: "kubeconfig", + Short: "Update kubeconfig with Silta cluster access", + Long: "Fetch the clusters and namespaces you can access and merge silta- contexts into your kubeconfig.", + Run: func(cmd *cobra.Command, args []string) { + _, creds, err := common.NewHubClientFromCredentials() + if err != nil { + fmt.Printf("%s\n", err) + return + } + if err := syncKubeconfig(creds); err != nil { + fmt.Printf("Failed to update kubeconfig: %s\n", err) + } + }, +} + +func init() { + hubCmd.AddCommand(hubKubeconfigCmd) +} diff --git a/cmd/hubLogin.go b/cmd/hubLogin.go new file mode 100644 index 0000000..a105b6c --- /dev/null +++ b/cmd/hubLogin.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" +) + +var ( + loginHubURL string + loginDevice bool +) + +// hubLoginCmd authenticates the CLI against a Silta hub and stores a token. +var hubLoginCmd = &cobra.Command{ + Use: "login", + Short: "Log in to a Silta hub", + Long: `Authenticate the Silta CLI against a Silta hub. + +By default a browser window is opened to approve the login. On headless +machines use --device to complete the login using a short code instead. + +The Silta hub URL can be provided with --hub-url or stored in the +configuration under 'hub.url'.`, + Run: func(cmd *cobra.Command, args []string) { + if cmd.Flags().Changed("hub-url") && loginHubURL != "" { + if err := saveHubURL(loginHubURL); err != nil { + fmt.Printf("Warning: could not persist hub URL: %s\n", err) + } + } + + hubURL := resolveHubURL() + if hubURL == "" { + fmt.Println("Error: no Silta hub URL configured. Pass --hub-url or run 'silta config set hub.url '.") + return + } + + client := common.NewHubClient(hubURL, "") + + var token *cliTokenResult + var err error + if loginDevice { + token, err = runDeviceLogin(client) + } else { + token, err = runBrowserLogin(client, hubURL) + } + if err != nil { + fmt.Printf("Login failed: %s\n", err) + return + } + + creds := &common.Credentials{ + HubURL: strings.TrimRight(hubURL, "/"), + Token: token.Token, + ExpiresAt: token.ExpiresAt, + Username: token.Username, + } + if err := common.SaveCredentials(creds); err != nil { + fmt.Printf("Failed to store credentials: %s\n", err) + return + } + + fmt.Printf("Logged in as %s.\n", token.Username) + + // Update kubeconfig with the clusters the user can access. + if err := syncKubeconfig(creds); err != nil { + fmt.Printf("Warning: logged in but failed to update kubeconfig: %s\n", err) + } + }, +} + +// syncKubeconfig fetches the user's authorized clusters and merges them into the +// local kubeconfig as silta- contexts. +func syncKubeconfig(creds *common.Credentials) error { + client := common.NewHubClient(creds.HubURL, creds.Token) + + resp, err := common.FetchHubClusters(client) + if err != nil { + return err + } + + if len(resp.Clusters) == 0 { + fmt.Println("No cluster access is currently assigned to your account.") + return nil + } + + path, err := common.UpdateKubeconfig(resp.Clusters) + if err != nil { + return err + } + + fmt.Printf("Updated kubeconfig (%s) with %d cluster context(s):\n", path, len(resp.Clusters)) + for _, cluster := range resp.Clusters { + fmt.Printf(" silta-%s\n", cluster.ID) + } + return nil +} + +// cliTokenResult mirrors the hub token response. +type cliTokenResult struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + Username string `json:"username"` +} + +// resolveHubURL determines the hub URL from the flag or config. +func resolveHubURL() string { + raw := loginHubURL + if raw == "" { + store := common.ConfigStore() + raw = store.GetString("hub.url") + } + if raw == "" { + return "" + } + return applyBackendSuffix(raw) +} + +// applyBackendSuffix appends the hub backend path for remote hubs; local +// proxies serve the hub directly at the root. +func applyBackendSuffix(raw string) string { + u, err := url.Parse(raw) + if err == nil && u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" { + return strings.TrimRight(raw, "/") + "/backend" + } + return raw +} + +// saveHubURL persists the hub URL to the CLI configuration so future +// 'silta hub login' runs do not need the --hub-url flag. +func saveHubURL(raw string) error { + store := common.ConfigStore() + store.Set("hub.url", raw) + return store.WriteConfig() +} + +// randomState returns a URL-safe random string used to guard the loopback flow. +func randomState() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// runBrowserLogin performs the browser loopback flow. +func runBrowserLogin(client *common.HubClient, hubURL string) (*cliTokenResult, error) { + // Discover the frontend URL used to build the approval page link. + var info struct { + FrontendURL string `json:"frontend_url"` + } + if _, err := client.GetJSON("/api/cli/info", &info); err != nil { + return nil, fmt.Errorf("failed to reach Silta hub: %w", err) + } + if info.FrontendURL == "" { + return nil, fmt.Errorf("Silta hub did not advertise a frontend URL") + } + + state, err := randomState() + if err != nil { + return nil, err + } + + // Start a loopback server on a random local port. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to start local server: %w", err) + } + defer listener.Close() + + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", listener.Addr().(*net.TCPAddr).Port) + + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + + srv := &http.Server{} + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("state") != state { + http.Error(w, "Invalid state", http.StatusBadRequest) + errCh <- fmt.Errorf("state mismatch on callback") + return + } + code := q.Get("code") + if code == "" { + http.Error(w, "Missing code", http.StatusBadRequest) + errCh <- fmt.Errorf("no code returned") + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "

Silta CLI login complete

You can close this window and return to your terminal.

") + codeCh <- code + }) + srv.Handler = mux + + go srv.Serve(listener) + defer srv.Shutdown(context.Background()) + + approvalURL := fmt.Sprintf("%s/cli-login?redirect_uri=%s&state=%s", + strings.TrimRight(info.FrontendURL, "/"), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + fmt.Println("Opening your browser to approve the login...") + fmt.Printf("If it does not open automatically, visit:\n %s\n", approvalURL) + _ = common.OpenBrowser(approvalURL) + + var code string + select { + case code = <-codeCh: + case err = <-errCh: + return nil, err + case <-time.After(5 * time.Minute): + return nil, fmt.Errorf("timed out waiting for browser approval") + } + + var token cliTokenResult + if _, err := client.PostJSON("/api/cli/auth/exchange", map[string]string{"code": code}, &token); err != nil { + return nil, err + } + return &token, nil +} + +// runDeviceLogin performs the device grant flow. +func runDeviceLogin(client *common.HubClient) (*cliTokenResult, error) { + var start struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + } + if _, err := client.PostJSON("/api/cli/auth/start", map[string]string{}, &start); err != nil { + return nil, fmt.Errorf("failed to start device login: %w", err) + } + + fmt.Printf("To authorize this device, visit:\n %s\n", start.VerificationURI) + fmt.Printf("And enter the code: %s\n\n", start.UserCode) + + interval := start.Interval + if interval <= 0 { + interval = 5 + } + deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second) + if start.ExpiresIn <= 0 { + deadline = time.Now().Add(10 * time.Minute) + } + + for time.Now().Before(deadline) { + time.Sleep(time.Duration(interval) * time.Second) + + var token cliTokenResult + status, err := client.PostJSON("/api/cli/auth/poll", map[string]string{"device_code": start.DeviceCode}, &token) + if err != nil && status != http.StatusAccepted && status != 0 { + // 410 (expired) and other 4xx return an error message. + if status == http.StatusGone { + return nil, fmt.Errorf("authorization expired, please try again") + } + return nil, err + } + if status == http.StatusOK && token.Token != "" { + return &token, nil + } + // status == 202: still pending, keep polling. + } + + return nil, fmt.Errorf("timed out waiting for approval") +} + +func init() { + hubLoginCmd.Flags().StringVar(&loginHubURL, "hub-url", "", "Silta hub URL (overrides config 'hub.url'; the value is saved to config)") + hubLoginCmd.Flags().BoolVar(&loginDevice, "device", false, "Use device code flow instead of opening a browser") + hubCmd.AddCommand(hubLoginCmd) +} diff --git a/cmd/hubLogout.go b/cmd/hubLogout.go new file mode 100644 index 0000000..081444e --- /dev/null +++ b/cmd/hubLogout.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" +) + +// hubLogoutCmd revokes the stored CLI token and removes local credentials. +var hubLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Log out of the Silta hub", + Long: "Revoke the stored CLI token on the Silta hub and remove local credentials.", + Run: func(cmd *cobra.Command, args []string) { + client, _, err := common.NewHubClientFromCredentials() + if err != nil { + fmt.Println("Not logged in.") + return + } + + // Best-effort remote revocation; always clear local credentials. + if status, err := client.PostJSON("/api/cli/auth/revoke", nil, nil); err != nil && status != http.StatusUnauthorized { + fmt.Printf("Warning: failed to revoke token on Silta hub: %s\n", err) + } + + if err := common.DeleteCredentials(); err != nil { + fmt.Printf("Failed to remove local credentials: %s\n", err) + return + } + + fmt.Println("Logged out.") + }, +} + +func init() { + hubCmd.AddCommand(hubLogoutCmd) +} diff --git a/docs/silta.md b/docs/silta.md index 6ee9ad5..b3f75ee 100644 --- a/docs/silta.md +++ b/docs/silta.md @@ -18,6 +18,7 @@ Silta CLI * [silta completion](silta_completion.md) - Generate the autocompletion script for the specified shell * [silta config](silta_config.md) - Silta configuration commands * [silta doc](silta_doc.md) - Generate menu documentation markdown +* [silta hub](silta_hub.md) - Interact with a Silta hub * [silta scripts](silta_scripts.md) - Convenience scripts for silta * [silta secrets](silta_secrets.md) - Manage encrypted secret files * [silta tools](silta_tools.md) - CI tooling diff --git a/docs/silta_config.md b/docs/silta_config.md index 8bb6aed..8e8fb68 100644 --- a/docs/silta_config.md +++ b/docs/silta_config.md @@ -5,7 +5,7 @@ Silta configuration commands ### Synopsis Silta configuration commands, allows setting and getting configuration values. -Configuration is persistent and is stored in file "/home/jancis/.config/silta/config.yaml". +Configuration is persistent and is stored in file "/home/ubuntu/.config/silta/config.yaml". ``` silta config [flags] diff --git a/docs/silta_hub.md b/docs/silta_hub.md new file mode 100644 index 0000000..d86faea --- /dev/null +++ b/docs/silta_hub.md @@ -0,0 +1,35 @@ +## silta hub + +Interact with a Silta hub + +### Synopsis + +Authenticate against a Silta hub, manage kubeconfig access and open the hub in a browser. + +``` +silta hub [flags] +``` + +### Options + +``` + -h, --help help for hub +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta](silta.md) - Silta CLI +* [silta hub clusters](silta_hub_clusters.md) - List the clusters accessible from your Silta account +* [silta hub dashboard](silta_hub_dashboard.md) - Open the Silta dashboard in a browser +* [silta hub info](silta_hub_info.md) - Show current Silta Hub login state +* [silta hub kubeconfig](silta_hub_kubeconfig.md) - Update kubeconfig with Silta cluster access +* [silta hub login](silta_hub_login.md) - Log in to a Silta hub +* [silta hub logout](silta_hub_logout.md) - Log out of the Silta hub + diff --git a/docs/silta_hub_clusters.md b/docs/silta_hub_clusters.md new file mode 100644 index 0000000..d95bd17 --- /dev/null +++ b/docs/silta_hub_clusters.md @@ -0,0 +1,35 @@ +## silta hub clusters + +List the clusters accessible from your Silta account + +### Synopsis + +List the clusters the logged-in Silta user may access, with the +kubeconfig context name, assigned namespace and (when reported by cluster +inventory) the Kubernetes version. + +Use --json for machine-readable output (exit code 0 on success, 1 on +failure). + +``` +silta hub clusters [flags] +``` + +### Options + +``` + -h, --help help for clusters + --json Output clusters as JSON +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta hub](silta_hub.md) - Interact with a Silta hub + diff --git a/docs/silta_hub_dashboard.md b/docs/silta_hub_dashboard.md new file mode 100644 index 0000000..2e1e6ee --- /dev/null +++ b/docs/silta_hub_dashboard.md @@ -0,0 +1,25 @@ +## silta hub dashboard + +Open the Silta dashboard in a browser + +``` +silta hub dashboard [flags] +``` + +### Options + +``` + -h, --help help for dashboard +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta hub](silta_hub.md) - Interact with a Silta hub + diff --git a/docs/silta_hub_info.md b/docs/silta_hub_info.md new file mode 100644 index 0000000..ba1c9a9 --- /dev/null +++ b/docs/silta_hub_info.md @@ -0,0 +1,26 @@ +## silta hub info + +Show current Silta Hub login state + +``` +silta hub info [flags] +``` + +### Options + +``` + -h, --help help for info + --verify Verify the token with the silta hub and list cluster access +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta hub](silta_hub.md) - Interact with a Silta hub + diff --git a/docs/silta_hub_kubeconfig.md b/docs/silta_hub_kubeconfig.md new file mode 100644 index 0000000..fcfc01e --- /dev/null +++ b/docs/silta_hub_kubeconfig.md @@ -0,0 +1,29 @@ +## silta hub kubeconfig + +Update kubeconfig with Silta cluster access + +### Synopsis + +Fetch the clusters and namespaces you can access and merge silta- contexts into your kubeconfig. + +``` +silta hub kubeconfig [flags] +``` + +### Options + +``` + -h, --help help for kubeconfig +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta hub](silta_hub.md) - Interact with a Silta hub + diff --git a/docs/silta_hub_login.md b/docs/silta_hub_login.md new file mode 100644 index 0000000..763e3b6 --- /dev/null +++ b/docs/silta_hub_login.md @@ -0,0 +1,37 @@ +## silta hub login + +Log in to a Silta hub + +### Synopsis + +Authenticate the Silta CLI against a Silta hub. + +By default a browser window is opened to approve the login. On headless +machines use --device to complete the login using a short code instead. + +The Silta hub URL can be provided with --hub-url or stored in the +configuration under 'hub.url'. + +``` +silta hub login [flags] +``` + +### Options + +``` + --device Use device code flow instead of opening a browser + -h, --help help for login + --hub-url string Silta hub URL (overrides config 'hub.url'; the value is saved to config) +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta hub](silta_hub.md) - Interact with a Silta hub + diff --git a/docs/silta_hub_logout.md b/docs/silta_hub_logout.md new file mode 100644 index 0000000..cba37a3 --- /dev/null +++ b/docs/silta_hub_logout.md @@ -0,0 +1,29 @@ +## silta hub logout + +Log out of the Silta hub + +### Synopsis + +Revoke the stored CLI token on the Silta hub and remove local credentials. + +``` +silta hub logout [flags] +``` + +### Options + +``` + -h, --help help for logout +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta hub](silta_hub.md) - Interact with a Silta hub + diff --git a/internal/common/browser.go b/internal/common/browser.go new file mode 100644 index 0000000..8f02f91 --- /dev/null +++ b/internal/common/browser.go @@ -0,0 +1,27 @@ +package common + +import ( + "os/exec" + "runtime" +) + +// OpenBrowser attempts to open the given URL in the user's default browser. It +// returns an error if the platform's opener command could not be started. +func OpenBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "rundll32" + args = []string{"url.dll,FileProtocolHandler", url} + case "darwin": + cmd = "open" + args = []string{url} + default: // linux, bsd, etc. + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} diff --git a/internal/common/config.go b/internal/common/config.go index bdae33d..06197b4 100644 --- a/internal/common/config.go +++ b/internal/common/config.go @@ -10,7 +10,20 @@ import ( "github.com/spf13/viper" ) -func ConfigStore() viper.Viper { +// ConfigDir returns the silta CLI configuration directory, creating it if it +// does not yet exist. The directory can be overridden with the SILTA_CONFIG_DIR +// environment variable (used by tests and custom installs). +func ConfigDir() string { + if dir := os.Getenv("SILTA_CONFIG_DIR"); dir != "" { + _, err := os.Stat(dir) + if !os.IsExist(err) { + err = os.MkdirAll(dir, 0700) + if err != nil { + log.Fatalf("Error creating config directory, %s", err) + } + } + return dir + } // Default configuration subpath siltaConfigDir := ".config/silta" @@ -41,6 +54,13 @@ func ConfigStore() viper.Viper { } } + return configDir +} + +func ConfigStore() viper.Viper { + + configDir := ConfigDir() + // Set the configuration file viper.SetConfigFile(filepath.Join(configDir, "config.yaml")) viper.AddConfigPath(configDir) diff --git a/internal/common/credentials.go b/internal/common/credentials.go new file mode 100644 index 0000000..be4ef9f --- /dev/null +++ b/internal/common/credentials.go @@ -0,0 +1,74 @@ +package common + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v2" +) + +// credentialsFileName is the on-disk name of the CLI credentials file. It is +// kept separate from config.yaml and stored with 0600 permissions. +const credentialsFileName = "credentials" + +// Credentials holds the silta hub-issued CLI token and related metadata. +type Credentials struct { + HubURL string `yaml:"hub_url"` + Token string `yaml:"token"` + ExpiresAt string `yaml:"expires_at"` + Username string `yaml:"username"` +} + +// credentialsPath returns the absolute path to the credentials file. +func credentialsPath() string { + return filepath.Join(ConfigDir(), credentialsFileName) +} + +// LoadCredentials reads the stored CLI credentials. It returns an error if no +// credentials have been saved yet. +func LoadCredentials() (*Credentials, error) { + data, err := os.ReadFile(credentialsPath()) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("not logged in: run 'silta hub login' first") + } + return nil, err + } + var creds Credentials + if err := yaml.Unmarshal(data, &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + return &creds, nil +} + +// SaveCredentials writes the CLI credentials to disk with 0600 permissions. +func SaveCredentials(creds *Credentials) error { + data, err := yaml.Marshal(creds) + if err != nil { + return err + } + return os.WriteFile(credentialsPath(), data, 0600) +} + +// DeleteCredentials removes the stored CLI credentials, if present. +func DeleteCredentials() error { + err := os.Remove(credentialsPath()) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// Expired reports whether the credentials have an expiry in the past. +func (c *Credentials) Expired() bool { + if c.ExpiresAt == "" { + return false + } + t, err := time.Parse(time.RFC3339, c.ExpiresAt) + if err != nil { + return false + } + return time.Now().After(t) +} diff --git a/internal/common/dashboardclient.go b/internal/common/dashboardclient.go new file mode 100644 index 0000000..37dde13 --- /dev/null +++ b/internal/common/dashboardclient.go @@ -0,0 +1,117 @@ +package common + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// HubClient talks to the Silta hub REST API, optionally using a +// stored CLI bearer token for authenticated requests. +type HubClient struct { + BaseURL string + Token string + http *http.Client +} + +// NewHubClient builds a client for the given hub base URL. +func NewHubClient(baseURL, token string) *HubClient { + return &HubClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// NewHubClientFromCredentials builds a client using stored credentials. +func NewHubClientFromCredentials() (*HubClient, *Credentials, error) { + creds, err := LoadCredentials() + if err != nil { + return nil, nil, err + } + return NewHubClient(creds.HubURL, creds.Token), creds, nil +} + +// PostJSON sends a JSON body to the given path and decodes the JSON response +// into out (when non-nil). It returns the HTTP status code and any error. +func (c *HubClient) PostJSON(path string, body interface{}, out interface{}) (int, error) { + return c.doJSON(http.MethodPost, path, body, out) +} + +// GetJSON performs a GET request and decodes the JSON response into out. +func (c *HubClient) GetJSON(path string, out interface{}) (int, error) { + return c.doJSON(http.MethodGet, path, nil, out) +} + +func (c *HubClient) doJSON(method, path string, body interface{}, out interface{}) (int, error) { + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return 0, err + } + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, c.BaseURL+path, reader) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.http.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if out != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil && err != io.EOF { + return resp.StatusCode, err + } + return resp.StatusCode, nil + } + + if resp.StatusCode >= 400 { + return resp.StatusCode, apiError(resp) + } + return resp.StatusCode, nil +} + +// HubClustersResponse is the response of GET /api/cli/clusters. +type HubClustersResponse struct { + Username string `json:"username"` + Clusters []SiltaCluster `json:"clusters"` +} + +// FetchHubClusters returns the clusters the authenticated user may access via +// the CLI, along with the identity the hub reports for the token. +func FetchHubClusters(client *HubClient) (*HubClustersResponse, error) { + resp := &HubClustersResponse{} + if _, err := client.GetJSON("/api/cli/clusters", resp); err != nil { + return nil, err + } + return resp, nil +} + +// apiError extracts an error message from a non-2xx JSON response. +func apiError(resp *http.Response) error { + var body struct { + Error string `json:"error"` + } + data, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(data, &body); err == nil && body.Error != "" { + return fmt.Errorf("%s", body.Error) + } + return fmt.Errorf("request failed with status %d", resp.StatusCode) +} diff --git a/internal/common/kubeconfig.go b/internal/common/kubeconfig.go new file mode 100644 index 0000000..d1c8eda --- /dev/null +++ b/internal/common/kubeconfig.go @@ -0,0 +1,87 @@ +package common + +import ( + "fmt" + "os" + "strings" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// SiltaCluster describes a hub-proxied cluster the CLI can access. +type SiltaCluster struct { + ID string `json:"id"` + Server string `json:"server"` + Namespace string `json:"namespace"` + KubernetesVersion string `json:"kubernetesVersion"` +} + +// contextName returns the kubeconfig context/cluster/user name for a cluster id. +func contextName(clusterID string) string { + return "silta-" + clusterID +} + +// UpdateKubeconfig merges silta- contexts into the user's kubeconfig, +// each authenticated via the 'silta cli-credential' exec plugin. Existing +// unrelated entries are preserved. silta-* entries for clusters the user no +// longer has access to are removed, and the current-context is cleared if it +// pointed at a removed entry. +func UpdateKubeconfig(clusters []SiltaCluster) (string, error) { + pathOptions := clientcmd.NewDefaultPathOptions() + config, err := pathOptions.GetStartingConfig() + if err != nil { + return "", err + } + + self, err := os.Executable() + if err != nil || self == "" { + // Fall back to the command name resolved from PATH. + self = "silta" + } + + for _, cluster := range clusters { + name := contextName(cluster.ID) + + config.Clusters[name] = &clientcmdapi.Cluster{ + Server: cluster.Server, + } + + config.AuthInfos[name] = &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: self, + Args: []string{"hub", "cli-credential"}, + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, + }, + } + + config.Contexts[name] = &clientcmdapi.Context{ + Cluster: name, + AuthInfo: name, + Namespace: cluster.Namespace, + } + } + + authorized := make(map[string]bool, len(clusters)) + for _, cluster := range clusters { + authorized[contextName(cluster.ID)] = true + } + for name := range config.Contexts { + if !strings.HasPrefix(name, "silta-") || authorized[name] { + continue + } + delete(config.Contexts, name) + delete(config.Clusters, name) + delete(config.AuthInfos, name) + if config.CurrentContext == name { + config.CurrentContext = "" + } + } + + if err := clientcmd.ModifyConfig(pathOptions, *config, true); err != nil { + return "", fmt.Errorf("failed to update kubeconfig: %w", err) + } + + return pathOptions.GetDefaultFilename(), nil +} diff --git a/tests/hubClusters_test.go b/tests/hubClusters_test.go new file mode 100644 index 0000000..d3541c9 --- /dev/null +++ b/tests/hubClusters_test.go @@ -0,0 +1,198 @@ +package cmd_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeHubServer serves GET /api/cli/clusters returning the given JSON payload. +func fakeHubServer(t *testing.T, clustersJSON string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/cli/clusters" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, clustersJSON) + })) + t.Cleanup(srv.Close) + return srv +} + +const testClustersJSON = `{"username":"test-user","clusters":[ + {"id":"cluster-a","server":"https://hub/api/kube/cluster-a","namespace":"team-a","kubernetesVersion":"v1.30.2"}, + {"id":"cluster-b","server":"https://hub/api/kube/cluster-b","namespace":"","kubernetesVersion":""} +]}` + +// TestHubClustersTable verifies the human-readable table output of +// 'silta hub clusters', including empty namespace/version fallbacks. +func TestHubClustersTable(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + srv := fakeHubServer(t, testClustersJSON) + code, out, errOut := runCli(t, "hub clusters", hubCredentialEnv(t, srv.URL, "")) + if code != 0 { + t.Fatalf("expected exit 0, got %d (stdout: %q, stderr: %q)", code, out, errOut) + } + for _, want := range []string{ + "silta-cluster-a", "team-a", "v1.30.2", + "silta-cluster-b", "(none)", "(unknown)", + "KUBERNETES VERSION", + } { + if !strings.Contains(out, want) { + t.Errorf("expected stdout to contain %q, got: %s", want, out) + } + } +} + +// TestHubClustersJSON verifies machine-readable output decodes as the +// documented shape and preserves all fields. +func TestHubClustersJSON(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + srv := fakeHubServer(t, testClustersJSON) + code, out, errOut := runCli(t, "hub clusters --json", hubCredentialEnv(t, srv.URL, "")) + if code != 0 { + t.Fatalf("expected exit 0, got %d (stdout: %q, stderr: %q)", code, out, errOut) + } + + var resp struct { + Username string `json:"username"` + Clusters []struct { + ID string `json:"id"` + Server string `json:"server"` + Namespace string `json:"namespace"` + KubernetesVersion string `json:"kubernetesVersion"` + } `json:"clusters"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, out) + } + if resp.Username != "test-user" { + t.Errorf("expected username test-user, got %q", resp.Username) + } + if len(resp.Clusters) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(resp.Clusters)) + } + first := resp.Clusters[0] + if first.ID != "cluster-a" || first.Namespace != "team-a" || first.KubernetesVersion != "v1.30.2" { + t.Errorf("unexpected first cluster: %+v", first) + } + if resp.Clusters[1].ID != "cluster-b" || resp.Clusters[1].Namespace != "" || resp.Clusters[1].KubernetesVersion != "" { + t.Errorf("unexpected second cluster: %+v", resp.Clusters[1]) + } +} + +// TestHubClustersNotLoggedIn verifies the failure path when no credentials +// are stored. +func TestHubClustersNotLoggedIn(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + code, out, errOut := runCli(t, "hub clusters", []string{"SILTA_CONFIG_DIR=" + t.TempDir()}) + if code == 0 { + t.Fatalf("expected non-zero exit when not logged in, got 0 (stdout: %q)", out) + } + if !strings.Contains(errOut, "not logged in") { + t.Errorf("expected stderr to mention 'not logged in', got: %q", errOut) + } +} + +// TestHubKubeconfigPrunesStale verifies 'silta hub kubeconfig' removes silta-* +// contexts the user can no longer access, while preserving the clusters they +// still have and any unrelated entries. +func TestHubKubeconfigPrunesStale(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + kubeDir := t.TempDir() + kubePath := filepath.Join(kubeDir, "config") + kubeconfig := `apiVersion: v1 +kind: Config +current-context: silta-stale +clusters: +- name: silta-stale + cluster: + server: https://stale.example.com +- name: silta-keep + cluster: + server: https://keep.example.com +- name: unrelated + cluster: + server: https://unrelated.example.com +contexts: +- name: silta-stale + context: + cluster: silta-stale + user: silta-stale + namespace: old-ns +- name: silta-keep + context: + cluster: silta-keep + user: silta-keep + namespace: keep-ns +- name: unrelated + context: + cluster: unrelated + user: unrelated +users: +- name: silta-stale + user: + token: stale +- name: silta-keep + user: + token: keep +- name: unrelated + user: + token: other +` + if err := os.WriteFile(kubePath, []byte(kubeconfig), 0600); err != nil { + t.Fatalf("failed to write kubeconfig: %v", err) + } + + // The hub reports access to 'keep' only; 'stale' must be pruned. + keepJSON := `{"username":"test-user","clusters":[ + {"id":"keep","server":"https://keep.example.com","namespace":"keep-ns","kubernetesVersion":"v1.31.0"} + ]}` + srv := fakeHubServer(t, keepJSON) + env := append(hubCredentialEnv(t, srv.URL, ""), "KUBECONFIG="+kubePath) + + code, out, errOut := runCli(t, "hub kubeconfig", env) + if code != 0 { + t.Fatalf("expected exit 0, got %d (stdout: %q, stderr: %q)", code, out, errOut) + } + if !strings.Contains(out, "silta-keep") { + t.Errorf("expected stdout to list silta-keep, got: %s", out) + } + + data, err := os.ReadFile(kubePath) + if err != nil { + t.Fatalf("failed to read updated kubeconfig: %v", err) + } + updated := string(data) + if strings.Contains(updated, "silta-stale") { + t.Errorf("expected stale context to be pruned, but it remains:\n%s", updated) + } + for _, want := range []string{"silta-keep", "unrelated"} { + if !strings.Contains(updated, want) { + t.Errorf("expected %q to be preserved, got:\n%s", want, updated) + } + } + // The current context pointed at the pruned entry and must be cleared. + if strings.Contains(updated, "current-context: silta-stale") { + t.Errorf("expected current-context to be cleared after pruning silta-stale, got:\n%s", updated) + } +} diff --git a/tests/hub_test.go b/tests/hub_test.go new file mode 100644 index 0000000..611fd3a --- /dev/null +++ b/tests/hub_test.go @@ -0,0 +1,145 @@ +package cmd_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// hubCredentialEnv writes a silta credentials file into a fresh temp directory +// and returns the environment pointing SILTA_CONFIG_DIR at it. Pass an empty +// expiresAt to omit the expires_at field. +func hubCredentialEnv(t *testing.T, hubURL, expiresAt string) []string { + t.Helper() + dir := t.TempDir() + creds := "hub_url: " + hubURL + "\ntoken: test-token-123\nusername: test-user\n" + if expiresAt != "" { + creds += "expires_at: \"" + expiresAt + "\"\n" + } + if err := os.WriteFile(filepath.Join(dir, "credentials"), []byte(creds), 0600); err != nil { + t.Fatalf("failed to write credentials: %v", err) + } + return []string{"SILTA_CONFIG_DIR=" + dir} +} + +// runCli runs a silta command and returns the exit code, stdout and stderr. +// Non-zero exit codes are returned, not fatal. +func runCli(t *testing.T, command string, environment []string) (int, string, string) { + t.Helper() + cmd := exec.Command("bash", "-c", cliBinaryName+" "+command) + mergedEnv := os.Environ() + for _, e := range environment { + mergedEnv = append(mergedEnv, e) + } + cmd.Env = mergedEnv + var out, errOut bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errOut + err := cmd.Run() + code := 0 + if err != nil { + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("failed to run '%s': %v", command, err) + } + code = exitErr.ExitCode() + } + return code, out.String(), errOut.String() +} + +// TestHubCliCredentialValid checks that a healthy session emits a valid +// ExecCredential for kubectl. +func TestHubCliCredentialValid(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + future := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) + code, out, errOut := runCli(t, "hub cli-credential", hubCredentialEnv(t, "https://hub.example.com", future)) + if code != 0 { + t.Fatalf("expected exit 0, got %d (stdout: %q, stderr: %q)", code, out, errOut) + } + for _, want := range []string{ + `"apiVersion":"client.authentication.k8s.io/v1"`, + `"kind":"ExecCredential"`, + `"token":"test-token-123"`, + `"expirationTimestamp":"`, + } { + if !strings.Contains(out, want) { + t.Errorf("expected stdout to contain %s, got: %s", want, out) + } + } +} + +// TestHubCliCredentialExpired checks that an expired session fails with a +// relogin hint instead of emitting a stale token. +func TestHubCliCredentialExpired(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + past := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + env := hubCredentialEnv(t, "https://hub.example.com", past) + code, out, errOut := runCli(t, "hub cli-credential", env) + if code == 0 { + t.Fatalf("expected non-zero exit for expired credentials, got 0 (stdout: %q)", out) + } + for _, want := range []string{"session has expired", "silta hub login"} { + if !strings.Contains(errOut, want) { + t.Errorf("expected stderr to contain %q, got: %q", want, errOut) + } + } + if strings.Contains(out, "ExecCredential") { + t.Errorf("expected no credential output for expired credentials, got: %q", out) + } + + // kubectl re-fetched credentials within the warning window (it re-runs this + // plugin on every discovery retry); the hint must not print again. + code2, _, errOut2 := runCli(t, "hub cli-credential", env) + if code2 == 0 { + t.Fatalf("expected non-zero exit on repeat invocation, got 0") + } + if strings.Contains(errOut2, "session has expired") { + t.Errorf("expected hint to be suppressed on repeat invocation, got: %q", errOut2) + } +} + +// TestHubLoginPersistsHubURL checks that --hub-url is saved to the +// configuration so later 'silta hub login' runs do not need the flag. The +// login itself fails (nothing listens on the test URL), which is fine. +func TestHubLoginPersistsHubURL(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + dir := t.TempDir() + _, _, errOut := runCli(t, "hub login --hub-url http://127.0.0.1:9", []string{"SILTA_CONFIG_DIR=" + dir}) + + data, err := os.ReadFile(filepath.Join(dir, "config.yaml")) + if err != nil { + t.Fatalf("expected hub URL to be persisted to config.yaml: %v (stderr: %q)", err, errOut) + } + if !strings.Contains(string(data), "http://127.0.0.1:9") { + t.Errorf("expected config.yaml to contain the hub URL, got: %s", data) + } +} + +// TestHubCliCredentialNotLoggedIn checks the hint when no credentials exist. +func TestHubCliCredentialNotLoggedIn(t *testing.T) { + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir("..") + + code, _, errOut := runCli(t, "hub cli-credential", []string{"SILTA_CONFIG_DIR=" + t.TempDir()}) + if code == 0 { + t.Fatalf("expected non-zero exit when not logged in") + } + want := "not logged in: run 'silta hub login' first" + if !strings.Contains(errOut, want) { + t.Errorf("expected stderr to contain %q, got: %q", want, errOut) + } +}