From fdd3ddb8383af3276568488b85dfb40466df0bcd Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:22:33 -0700 Subject: [PATCH] feat(cloud): teploy cloud commands for Teploy Cloud managed servers Adds internal/cloud (HTTP client for cloud.teploy.com /api/v1: bearer key + X-Organization-Id, 30s per request, both list shapes, error envelope with hints, https-only base URL except localhost) and the `teploy cloud` group: login/logout/status, servers, regions, sizes, server create, server delete. server create registers the local public key once, confirms the monthly price, posts with one Idempotency-Key across retries, polls to active, adds the server via config.AddServer (refusing an existing name) and runs the runSetup path after waiting for SSH, unless --no-setup. Co-Authored-By: Claude --- CHANGELOG.md | 14 + README.md | 13 + docs/cloud.md | 21 + internal/cli/cloud.go | 880 ++++++++++++++++++++++++++++++++++ internal/cli/cloud_test.go | 452 +++++++++++++++++ internal/cli/root.go | 1 + internal/cloud/client.go | 397 +++++++++++++++ internal/cloud/client_test.go | 177 +++++++ internal/cloud/credentials.go | 92 ++++ 9 files changed, 2047 insertions(+) create mode 100644 docs/cloud.md create mode 100644 internal/cli/cloud.go create mode 100644 internal/cli/cloud_test.go create mode 100644 internal/cloud/client.go create mode 100644 internal/cloud/client_test.go create mode 100644 internal/cloud/credentials.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 71016aa..fddc49a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ## [Unreleased] +### Added + +- `teploy cloud`: connects the CLI to Teploy Cloud (https://cloud.teploy.com). + `login` (API key read hidden or with `--key-stdin`, `--org`; checked, then stored in + `~/.teploy/cloud.yml` with mode 0600), `logout`, `status`, `servers`, `regions`, + `sizes`, `server create` and `server delete`. `server create` registers the local SSH + public key if Cloud does not have it, shows the monthly price and asks before buying + (`--yes` skips), sends one `Idempotency-Key` across retries, waits for the server to + become active, adds it to servers.yml as `root@` (refusing an existing name) and + runs `teploy setup` unless `--no-setup`. `server delete` asks first and removes the + servers.yml entry only when its host is the deleted server's address. + `TEPLOY_CLOUD_URL` selects another instance; plain http is refused except to + localhost. + ## [0.1.55] - 2026-11-06 ### Added diff --git a/README.md b/README.md index 6d20c30..8fe7df9 100644 --- a/README.md +++ b/README.md @@ -573,6 +573,18 @@ teploy network setup # VPN mesh (tailscale, headscale, netbird) teploy network status / join # VPN management ``` +### Teploy Cloud servers + +`teploy cloud` buys and deletes managed servers on [Teploy Cloud](https://cloud.teploy.com) with an organization API key. A created server is added to `~/.teploy/servers.yml` and set up with `teploy setup`, so it is ready for `teploy deploy`. See [docs/cloud.md](docs/cloud.md). + +``` +teploy cloud login # API key (hidden) + organization id, stored in ~/.teploy/cloud.yml +teploy cloud sizes --provider vultr # what can be bought, with monthly prices +teploy cloud server create web1 --provider vultr --region ewr --size vc2-1c-1gb +teploy cloud servers # the organization's servers +teploy cloud server delete web1 +``` + ### Domains ```bash @@ -1240,6 +1252,7 @@ One app runs one image; multi-image stacks are refused, not approximated — see - [CI/CD](docs/ci-deploy.md) — push-to-deploy with Forgejo/GitHub Actions - [Secrets scanning](docs/secrets-scanning.md) — Gitleaks recipe - [Resilience](docs/resilience.md) — surviving server loss (topology + runbook) +- [Teploy Cloud](docs/cloud.md) — buying managed servers from the CLI ## Comparison diff --git a/docs/cloud.md b/docs/cloud.md new file mode 100644 index 0000000..0e6dfbb --- /dev/null +++ b/docs/cloud.md @@ -0,0 +1,21 @@ +# Teploy Cloud + +`teploy cloud` talks to Teploy Cloud (https://cloud.teploy.com), which sells managed servers. It needs an API key and the id of the organization the key belongs to, both from the Teploy Cloud dashboard; `teploy cloud login` checks them and stores them in `~/.teploy/cloud.yml` (mode 0600). `teploy cloud server create` buys a server, adds it to `~/.teploy/servers.yml` and runs `teploy setup` on it, so the next step is `server: ` in `teploy.yml` and `teploy deploy`. + +``` +teploy cloud login --org # prompts for the key without echoing it +printf '%s' "$KEY" | teploy cloud login --key-stdin --org +teploy cloud status # URL, organization, whether the key works +teploy cloud regions --provider vultr +teploy cloud sizes --provider vultr --region ewr +teploy cloud server create web1 --provider vultr --region ewr --size vc2-1c-1gb [--ssh-key ~/.ssh/id_ed25519.pub] [--no-setup] [--yes] +teploy cloud servers [--json] +teploy cloud server delete web1 [--yes] +teploy cloud logout +``` + +`server create` bills the organization monthly and asks before buying unless `--yes` is given. The SSH public key is `--ssh-key`, else `~/.ssh/id_ed25519.pub`, else `~/.ssh/id_rsa.pub`; it is registered with Teploy Cloud only if no key with the same public key is there. If setup fails after the server is active, the server stays bought and in servers.yml; re-run `teploy setup --name `. + +`server delete` destroys the server and everything on it. The servers.yml entry of the same name is removed only when its host is the deleted server's address. + +`TEPLOY_CLOUD_URL` points the commands at another Teploy Cloud instance. Plain http is refused except to localhost and 127.0.0.1. diff --git a/internal/cli/cloud.go b/internal/cli/cloud.go new file mode 100644 index 0000000..2355a71 --- /dev/null +++ b/internal/cli/cloud.go @@ -0,0 +1,880 @@ +package cli + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "github.com/useteploy/teploy/internal/cloud" + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/ssh" + cryptossh "golang.org/x/crypto/ssh" + "golang.org/x/term" +) + +// cloudEnv carries the side effects of the `teploy cloud` commands so tests +// can replace the terminal, the SSH wait and the server setup. +type cloudEnv struct { + in io.Reader + out io.Writer + stdinIsTerminal func() bool + // readHidden prompts and reads one line without echo. + readHidden func(prompt string) (string, error) + confirm func(prompt string) bool + // waitSSH blocks until host accepts SSH connections as root. + waitSSH func(ctx context.Context, host, keyPath string) error + // setup runs the `teploy setup` code path against host, naming the + // servers.yml entry name. + setup func(flags *Flags, host, name string, yes bool) error + sleep func(ctx context.Context, d time.Duration) error + pollInterval time.Duration + provisionTimeout time.Duration + createAttempts int + retryDelay time.Duration +} + +const ( + cloudPollInterval = 5 * time.Second + cloudProvisionTimeout = 10 * time.Minute + cloudSSHWaitTimeout = 2 * time.Minute +) + +func defaultCloudEnv() *cloudEnv { + return &cloudEnv{ + in: os.Stdin, + out: os.Stdout, + stdinIsTerminal: func() bool { return term.IsTerminal(int(os.Stdin.Fd())) }, + readHidden: func(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + return string(b), err + }, + confirm: func(prompt string) bool { return confirm(os.Stdout, prompt) }, + waitSSH: waitForSSH, + setup: cloudRunSetup, + sleep: sleepCtx, + pollInterval: cloudPollInterval, + provisionTimeout: cloudProvisionTimeout, + createAttempts: 3, + retryDelay: 3 * time.Second, + } +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +func newCloudCmd(flags *Flags) *cobra.Command { + return newCloudCmdWithEnv(flags, defaultCloudEnv()) +} + +func newCloudCmdWithEnv(flags *Flags, env *cloudEnv) *cobra.Command { + cmd := &cobra.Command{ + Use: "cloud", + Short: "Buy and manage servers on Teploy Cloud", + Long: "Connect to Teploy Cloud (" + cloud.DefaultBaseURL + "), list what can be bought,\n" + + "and create or delete managed servers. A created server is added to\n" + + "~/.teploy/servers.yml and set up for `teploy deploy`.\n\n" + + "Credentials are stored in ~/.teploy/cloud.yml (mode 0600). Set\n" + + cloud.BaseURLEnv + " to use another Teploy Cloud instance.", + } + cmd.AddCommand(newCloudLoginCmd(flags, env)) + cmd.AddCommand(newCloudLogoutCmd(env)) + cmd.AddCommand(newCloudStatusCmd(flags, env)) + cmd.AddCommand(newCloudServersCmd(flags, env)) + cmd.AddCommand(newCloudRegionsCmd(flags, env)) + cmd.AddCommand(newCloudSizesCmd(flags, env)) + cmd.AddCommand(newCloudServerCmd(flags, env)) + return cmd +} + +func cloudCredentialsPath() (string, error) { + serversPath, err := config.DefaultServersPath() + if err != nil { + return "", fmt.Errorf("determining config directory: %w", err) + } + return filepath.Join(filepath.Dir(serversPath), cloud.FileName), nil +} + +func loadCloudClient() (*cloud.Client, error) { + path, err := cloudCredentialsPath() + if err != nil { + return nil, err + } + creds, err := cloud.LoadCredentials(path) + if err != nil { + return nil, err + } + base, err := cloud.ResolveBaseURL(creds.BaseURL) + if err != nil { + return nil, err + } + return cloud.New(base, creds.APIKey, creds.OrgID) +} + +func newCloudLoginCmd(flags *Flags, env *cloudEnv) *cobra.Command { + var ( + org string + keyStdin bool + ) + cmd := &cobra.Command{ + Use: "login", + Short: "Store a Teploy Cloud API key and organization id", + Long: "Ask for an API key (input hidden) and an organization id, check them\n" + + "against Teploy Cloud, and store them in ~/.teploy/cloud.yml (mode 0600).\n" + + "Create the key in the Teploy Cloud dashboard. The key is never accepted\n" + + "as a command-line value; use --key-stdin to pipe it in.\n\n" + + "Examples:\n" + + " teploy cloud login --org org_123\n" + + " printf '%s' \"$TEPLOY_CLOUD_KEY\" | teploy cloud login --key-stdin --org org_123", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.Key != "" { + return errors.New("--key is the SSH private key path and is not used by login: the API key is prompted for (hidden) or read with --key-stdin") + } + return runCloudLogin(cmd.Context(), env, org, keyStdin) + }, + } + cmd.Flags().StringVar(&org, "org", "", "organization id (prompted when omitted)") + cmd.Flags().BoolVar(&keyStdin, "key-stdin", false, "read the API key from stdin") + return cmd +} + +func runCloudLogin(ctx context.Context, env *cloudEnv, org string, keyStdin bool) error { + if ctx == nil { + ctx = context.Background() + } + reader := bufio.NewReader(env.in) + var key string + switch { + case keyStdin: + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("reading API key from stdin: %w", err) + } + key = strings.TrimSpace(line) + case env.stdinIsTerminal(): + k, err := env.readHidden("API key: ") + if err != nil { + return fmt.Errorf("reading API key: %w", err) + } + key = strings.TrimSpace(k) + default: + return errors.New("stdin is not a terminal: pass --key-stdin and pipe the API key in") + } + if key == "" { + return errors.New("no API key given") + } + + org = strings.TrimSpace(org) + if org == "" { + if keyStdin || !env.stdinIsTerminal() { + return errors.New("--org is required with --key-stdin") + } + fmt.Fprint(env.out, "Organization id: ") + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("reading organization id: %w", err) + } + org = strings.TrimSpace(line) + if org == "" { + return errors.New("no organization id given") + } + } + + base, err := cloud.ResolveBaseURL("") + if err != nil { + return err + } + client, err := cloud.New(base, key, org) + if err != nil { + return err + } + if _, err := client.ListServers(ctx); err != nil { + return fmt.Errorf("checking the API key: %w", err) + } + + path, err := cloudCredentialsPath() + if err != nil { + return err + } + creds := &cloud.Credentials{OrgID: org, APIKey: key} + if base != cloud.DefaultBaseURL { + creds.BaseURL = base + } + if err := cloud.SaveCredentials(path, creds); err != nil { + return err + } + fmt.Fprintf(env.out, "Logged in to %s (organization %s). Credentials stored in %s\n", base, org, path) + return nil +} + +func newCloudLogoutCmd(env *cloudEnv) *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove the stored Teploy Cloud credentials", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := cloudCredentialsPath() + if err != nil { + return err + } + removed, err := cloud.RemoveCredentials(path) + if err != nil { + return err + } + if removed { + fmt.Fprintf(env.out, "Removed %s\n", path) + } else { + fmt.Fprintln(env.out, "Not logged in to Teploy Cloud") + } + return nil + }, + } +} + +type cloudStatusDTO struct { + BaseURL string `json:"base_url"` + LoggedIn bool `json:"logged_in"` + OrgID string `json:"org_id,omitempty"` + KeyWorks bool `json:"key_works"` + Error string `json:"error,omitempty"` +} + +func newCloudStatusCmd(flags *Flags, env *cloudEnv) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the Teploy Cloud URL, organization, and whether the key works", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + path, err := cloudCredentialsPath() + if err != nil { + return err + } + st := cloudStatusDTO{} + creds, err := cloud.LoadCredentials(path) + var checkErr error + switch { + case errors.Is(err, cloud.ErrNotLoggedIn): + if st.BaseURL, err = cloud.ResolveBaseURL(""); err != nil { + return err + } + case err != nil: + return err + default: + st.LoggedIn = true + st.OrgID = creds.OrgID + if st.BaseURL, err = cloud.ResolveBaseURL(creds.BaseURL); err != nil { + return err + } + client, err := cloud.New(st.BaseURL, creds.APIKey, creds.OrgID) + if err != nil { + return err + } + if _, checkErr = client.ListServers(ctx); checkErr == nil { + st.KeyWorks = true + } else { + st.Error = checkErr.Error() + } + } + + if flags.JSON { + enc := json.NewEncoder(env.out) + enc.SetIndent("", " ") + if err := enc.Encode(st); err != nil { + return err + } + } else { + fmt.Fprintf(env.out, "URL: %s\n", st.BaseURL) + if !st.LoggedIn { + fmt.Fprintln(env.out, "Logged in: no (run `teploy cloud login`)") + return nil + } + fmt.Fprintf(env.out, "Organization: %s\n", st.OrgID) + if st.KeyWorks { + fmt.Fprintln(env.out, "API key: works") + } else { + fmt.Fprintln(env.out, "API key: does not work") + } + } + if checkErr != nil { + return checkErr + } + return nil + }, + } +} + +func formatCents(c int64) string { + return fmt.Sprintf("$%d.%02d", c/100, c%100) +} + +func writeJSONTo(out io.Writer, v any) error { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func newCloudServersCmd(flags *Flags, env *cloudEnv) *cobra.Command { + return &cobra.Command{ + Use: "servers", + Short: "List the organization's Teploy Cloud servers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := loadCloudClient() + if err != nil { + return err + } + servers, err := client.ListServers(cmdContext(cmd)) + if err != nil { + return fmt.Errorf("listing servers: %w", err) + } + if flags.JSON { + return writeJSONTo(env.out, servers) + } + if len(servers) == 0 { + fmt.Fprintln(env.out, "No servers. Create one with `teploy cloud server create`.") + return nil + } + tw := tabwriter.NewWriter(env.out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tSTATUS\tIP\tPROVIDER\tREGION\tSIZE\tMONTHLY") + for _, s := range servers { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", s.Name, s.Status, dash(s.IPAddress), + s.Provider, s.ProviderRegion, s.ProviderSize, formatCents(s.PriceMonthlyCents)) + } + return tw.Flush() + }, + } +} + +func dash(s string) string { + if s == "" { + return "-" + } + return s +} + +func cmdContext(cmd *cobra.Command) context.Context { + if ctx := cmd.Context(); ctx != nil { + return ctx + } + return context.Background() +} + +func newCloudRegionsCmd(flags *Flags, env *cloudEnv) *cobra.Command { + var provider string + cmd := &cobra.Command{ + Use: "regions", + Short: "List the regions a provider offers", + Long: "List the regions a Teploy Cloud provider offers. Without --provider,\n" + + "list the providers instead.\n\n" + + "Example:\n teploy cloud regions --provider vultr", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := loadCloudClient() + if err != nil { + return err + } + ctx := cmdContext(cmd) + if provider == "" { + providers, err := client.ListProviders(ctx) + if err != nil { + return fmt.Errorf("listing providers: %w", err) + } + if flags.JSON { + return writeJSONTo(env.out, providers) + } + tw := tabwriter.NewWriter(env.out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "PROVIDER\tNAME\tAVAILABLE") + for _, p := range providers { + fmt.Fprintf(tw, "%s\t%s\t%s\n", p.Type, p.Name, yesNo(p.ManagedAvailable)) + } + if err := tw.Flush(); err != nil { + return err + } + fmt.Fprintln(env.out, "\nPass --provider to list a provider's regions.") + return nil + } + regions, err := client.ListRegions(ctx, provider) + if err != nil { + return fmt.Errorf("listing regions: %w", err) + } + if flags.JSON { + return writeJSONTo(env.out, regions) + } + tw := tabwriter.NewWriter(env.out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "REGION\tNAME\tCOUNTRY\tAVAILABLE") + for _, r := range regions { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", r.ID, r.Name, r.Country, yesNo(r.Available)) + } + return tw.Flush() + }, + } + cmd.Flags().StringVar(&provider, "provider", "", "provider (e.g. vultr)") + return cmd +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} + +func newCloudSizesCmd(flags *Flags, env *cloudEnv) *cobra.Command { + var provider, region string + cmd := &cobra.Command{ + Use: "sizes", + Short: "List the server sizes a provider offers, with monthly prices", + Long: "List the server sizes a Teploy Cloud provider offers and what each costs\n" + + "per month. --region limits the list to sizes sold in that region.\n\n" + + "Example:\n teploy cloud sizes --provider vultr --region ewr", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if provider == "" { + return errors.New("--provider is required (list providers with `teploy cloud regions`)") + } + client, err := loadCloudClient() + if err != nil { + return err + } + sizes, err := client.ListSizes(cmdContext(cmd), provider, region) + if err != nil { + return fmt.Errorf("listing sizes: %w", err) + } + if flags.JSON { + return writeJSONTo(env.out, sizes) + } + tw := tabwriter.NewWriter(env.out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "SIZE\tVCPUS\tMEMORY\tDISK\tMONTHLY") + for _, s := range sizes { + fmt.Fprintf(tw, "%s\t%d\t%d MB\t%d GB\t%s\n", s.ID, s.VCPUs, s.Memory, s.Disk, formatCents(s.PriceMonthlyCents)) + } + return tw.Flush() + }, + } + cmd.Flags().StringVar(&provider, "provider", "", "provider (e.g. vultr)") + cmd.Flags().StringVar(®ion, "region", "", "only sizes sold in this region") + return cmd +} + +func newCloudServerCmd(flags *Flags, env *cloudEnv) *cobra.Command { + cmd := &cobra.Command{ + Use: "server", + Short: "Create or delete Teploy Cloud servers", + } + cmd.AddCommand(newCloudServerCreateCmd(flags, env)) + cmd.AddCommand(newCloudServerDeleteCmd(env)) + return cmd +} + +type cloudCreateOpts struct { + name, provider, region, size, sshKey string + noSetup, yes bool +} + +func newCloudServerCreateCmd(flags *Flags, env *cloudEnv) *cobra.Command { + var o cloudCreateOpts + cmd := &cobra.Command{ + Use: "create ", + Short: "Buy a managed server, add it to servers.yml, and set it up", + Long: "Buy a server on Teploy Cloud. This bills your organization monthly.\n\n" + + "The local SSH public key (--ssh-key, else ~/.ssh/id_ed25519.pub, else\n" + + "~/.ssh/id_rsa.pub) is registered with Teploy Cloud if it is not there yet.\n" + + "After the server is active it is added to ~/.teploy/servers.yml as\n" + + "root@ under , and `teploy setup` runs against it unless\n" + + "--no-setup is given.\n\n" + + "Examples:\n" + + " teploy cloud sizes --provider vultr --region ewr\n" + + " teploy cloud server create web1 --provider vultr --region ewr --size vc2-1c-1gb", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + o.name = args[0] + ctx, cancel := signal.NotifyContext(cmdContext(cmd), os.Interrupt) + defer cancel() + return runCloudServerCreate(ctx, flags, env, o) + }, + } + cmd.Flags().StringVar(&o.provider, "provider", "", "provider (e.g. vultr)") + cmd.Flags().StringVar(&o.region, "region", "", "region id (list them with: teploy cloud regions --provider P)") + cmd.Flags().StringVar(&o.size, "size", "", "size id (list them with: teploy cloud sizes --provider P --region R)") + cmd.Flags().StringVar(&o.sshKey, "ssh-key", "", "path to the SSH public key (.pub) to install") + cmd.Flags().BoolVar(&o.noSetup, "no-setup", false, "skip running teploy setup after the server is active") + cmd.Flags().BoolVarP(&o.yes, "yes", "y", false, "skip the purchase confirmation") + return cmd +} + +// localPublicKey picks the public key to install: path, else the first of +// ~/.ssh/id_ed25519.pub and ~/.ssh/id_rsa.pub. It returns the key line and +// the path it came from. +func localPublicKey(path string) (string, string, error) { + candidates := []string{path} + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("finding home directory: %w", err) + } + candidates = []string{ + filepath.Join(home, ".ssh", "id_ed25519.pub"), + filepath.Join(home, ".ssh", "id_rsa.pub"), + } + } + for _, p := range candidates { + data, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) && path == "" { + continue + } + if err != nil { + return "", "", fmt.Errorf("reading SSH public key: %w", err) + } + line := strings.TrimSpace(string(data)) + if _, _, _, _, err := cryptossh.ParseAuthorizedKey([]byte(line)); err != nil { + return "", "", fmt.Errorf("%s is not an SSH public key: %w", p, err) + } + return line, p, nil + } + return "", "", errors.New("no SSH public key found in ~/.ssh (id_ed25519.pub, id_rsa.pub): pass --ssh-key") +} + +// samePublicKey compares two authorized_keys lines by key type and data, +// ignoring the comment. +func samePublicKey(a, b string) bool { + fa, fb := strings.Fields(a), strings.Fields(b) + return len(fa) >= 2 && len(fb) >= 2 && fa[0] == fb[0] && fa[1] == fb[1] +} + +func publicKeyName(line, path string) string { + if f := strings.Fields(line); len(f) >= 3 { + return strings.Join(f[2:], " ") + } + host, _ := os.Hostname() + if host == "" { + return "teploy-cli " + filepath.Base(path) + } + return "teploy-cli " + host +} + +func runCloudServerCreate(ctx context.Context, flags *Flags, env *cloudEnv, o cloudCreateOpts) error { + if o.provider == "" || o.region == "" || o.size == "" { + return errors.New("--provider, --region and --size are required (see `teploy cloud regions` and `teploy cloud sizes`)") + } + serversPath, err := config.DefaultServersPath() + if err != nil { + return fmt.Errorf("determining servers path: %w", err) + } + if err := refuseExistingServer(serversPath, o.name); err != nil { + return err + } + client, err := loadCloudClient() + if err != nil { + return err + } + + sizes, err := client.ListSizes(ctx, o.provider, o.region) + if err != nil { + return fmt.Errorf("looking up size %q: %w", o.size, err) + } + var size *cloud.Size + for i := range sizes { + if sizes[i].ID == o.size || (sizes[i].Slug != "" && sizes[i].Slug == o.size) { + size = &sizes[i] + break + } + } + if size == nil { + return fmt.Errorf("size %q is not sold by %s in %s (see `teploy cloud sizes --provider %s --region %s`)", + o.size, o.provider, o.region, o.provider, o.region) + } + + // a. The SSH key. + pubKey, pubPath, err := localPublicKey(o.sshKey) + if err != nil { + return err + } + keys, err := client.ListSSHKeys(ctx) + if err != nil { + return fmt.Errorf("listing SSH keys: %w", err) + } + var keyID string + for _, k := range keys { + if samePublicKey(k.PublicKey, pubKey) { + keyID = k.ID + break + } + } + if keyID == "" { + k, err := client.CreateSSHKey(ctx, publicKeyName(pubKey, pubPath), pubKey) + if err != nil { + return fmt.Errorf("registering SSH key %s: %w", pubPath, err) + } + keyID = k.ID + fmt.Fprintf(env.out, "Registered SSH key %s with Teploy Cloud\n", pubPath) + } + + // b. The price. + fmt.Fprintf(env.out, "Server %q: %s, region %s, size %s, %s per month.\n", + o.name, o.provider, o.region, size.ID, formatCents(size.PriceMonthlyCents)) + if !o.yes && !env.confirm("Buy this server? Your organization is billed monthly until it is deleted.") { + return errors.New("aborted: no server was created") + } + + // c. The purchase, one idempotency key across retries. + idemKey, err := cloud.NewIdempotencyKey() + if err != nil { + return err + } + req := cloud.CreateServerRequest{Provider: o.provider, Name: o.name, Region: o.region, Size: size.ID, SSHKeyID: keyID} + attempts := env.createAttempts + if attempts < 1 { + attempts = 1 + } + var srv *cloud.Server + for attempt := 1; ; attempt++ { + srv, err = client.CreateServer(ctx, req, idemKey) + if err == nil { + break + } + if attempt >= attempts || !cloud.IsRetryable(err) || ctx.Err() != nil { + return fmt.Errorf("creating server: %w", err) + } + fmt.Fprintf(env.out, "Create request failed (%v); retrying\n", err) + if err := env.sleep(ctx, env.retryDelay); err != nil { + return fmt.Errorf("creating server: %w", err) + } + } + fmt.Fprintf(env.out, "Created server %s (id %s); waiting for it to become active\n", srv.Name, srv.ID) + + // d. Wait for active with an address. + srv, err = waitForActive(ctx, env, client, srv) + if err != nil { + return err + } + fmt.Fprintf(env.out, "Server %s is active at %s\n", srv.Name, srv.IPAddress) + + // e. servers.yml, through the `teploy server add` path. + if err := refuseExistingServer(serversPath, o.name); err != nil { + return fmt.Errorf("%w; the server exists on Teploy Cloud at %s", err, srv.IPAddress) + } + if err := config.AddServer(serversPath, o.name, srv.IPAddress, "root", "", ""); err != nil { + return fmt.Errorf("adding server: %w", err) + } + fmt.Fprintf(env.out, "Added server %q (root@%s) to %s\n", o.name, srv.IPAddress, serversPath) + + // f. `teploy setup`. + if o.noSetup { + fmt.Fprintf(env.out, "\nSkipped setup. Run `teploy setup %s --name %s` before the first deploy.\n", srv.IPAddress, o.name) + } else { + setupFlags := *flags + setupFlags.User = "root" + if setupFlags.Key == "" && o.sshKey != "" { + if priv := strings.TrimSuffix(o.sshKey, ".pub"); priv != o.sshKey { + if _, err := os.Stat(priv); err == nil { + setupFlags.Key = priv + } + } + } + fmt.Fprintf(env.out, "Waiting for SSH on %s\n", srv.IPAddress) + if err := env.waitSSH(ctx, srv.IPAddress, setupFlags.Key); err != nil { + return fmt.Errorf("server %s is active but SSH is not reachable: %w (retry with `teploy setup %s --name %s`)", + o.name, err, srv.IPAddress, o.name) + } + if err := env.setup(&setupFlags, srv.IPAddress, o.name, o.yes); err != nil { + return fmt.Errorf("setting up %s: %w (retry with `teploy setup %s --name %s`)", o.name, err, srv.IPAddress, o.name) + } + } + + // g. Next step. + fmt.Fprintf(env.out, "\nNext: put the server in teploy.yml and deploy:\n\n server: %s\n\n teploy deploy\n", o.name) + return nil +} + +func refuseExistingServer(serversPath, name string) error { + servers, err := config.ListServers(serversPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("reading servers.yml: %w", err) + } + if existing, ok := servers[name]; ok { + return fmt.Errorf("server %q already exists in servers.yml (host %s): pick another name or remove it with `teploy server remove %s`", + name, existing.Host, name) + } + return nil +} + +func waitForActive(ctx context.Context, env *cloudEnv, client *cloud.Client, srv *cloud.Server) (*cloud.Server, error) { + ctx, cancel := context.WithTimeout(ctx, env.provisionTimeout) + defer cancel() + last := "" + for { + if srv.Status == "active" && srv.IPAddress != "" { + return srv, nil + } + if srv.Status == "error" { + return nil, fmt.Errorf("server %s (id %s) failed to provision: status error (see the Teploy Cloud dashboard, then `teploy cloud server delete %s`)", + srv.Name, srv.ID, srv.ID) + } + if srv.Status != last { + fmt.Fprintf(env.out, " status: %s\n", srv.Status) + last = srv.Status + } + if err := env.sleep(ctx, env.pollInterval); err != nil { + return nil, fmt.Errorf("server %s (id %s) is not active after %s (still %s): check `teploy cloud servers`", + srv.Name, srv.ID, env.provisionTimeout, srv.Status) + } + next, err := client.GetServer(ctx, srv.ID) + if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("server %s (id %s) is not active after %s: check `teploy cloud servers`", + srv.Name, srv.ID, env.provisionTimeout) + } + var ae *cloud.APIError + if errors.As(err, &ae) && ae.Status < 500 { + return nil, fmt.Errorf("checking server %s: %w", srv.ID, err) + } + fmt.Fprintf(env.out, " status check failed (%v); retrying\n", err) + continue + } + srv = next + } +} + +// waitForSSH retries an SSH connection as root until it runs a command or +// cloudSSHWaitTimeout passes. A new server's host key is accepted, as +// `teploy setup` does. +func waitForSSH(ctx context.Context, host, keyPath string) error { + ctx, cancel := context.WithTimeout(ctx, cloudSSHWaitTimeout) + defer cancel() + var lastErr error + for { + attemptCtx, attemptCancel := context.WithTimeout(ctx, 15*time.Second) + exec, err := ssh.Connect(attemptCtx, ssh.ConnectConfig{Host: host, User: "root", KeyPath: keyPath, AcceptNewHost: true}) + if err == nil { + _, err = exec.Run(attemptCtx, "true") + exec.Close() + } + attemptCancel() + if err == nil { + return nil + } + lastErr = err + if sleepCtx(ctx, cloudPollInterval) != nil { + return fmt.Errorf("no SSH after %s: %w", cloudSSHWaitTimeout, lastErr) + } + } +} + +// cloudRunSetup is the `teploy setup --name ` code path. +func cloudRunSetup(flags *Flags, host, name string, yes bool) error { + return runSetup(flags, host, name, false, "", "", false, yes) +} + +func newCloudServerDeleteCmd(env *cloudEnv) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a Teploy Cloud server", + Long: "Destroy a Teploy Cloud server and stop its billing. Everything on the\n" + + "server is lost. The servers.yml entry of the same name is removed only\n" + + "when its host is the deleted server's address.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runCloudServerDelete(cmdContext(cmd), env, args[0], yes) + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation") + return cmd +} + +func runCloudServerDelete(ctx context.Context, env *cloudEnv, ref string, yes bool) error { + client, err := loadCloudClient() + if err != nil { + return err + } + servers, err := client.ListServers(ctx) + if err != nil { + return fmt.Errorf("listing servers: %w", err) + } + var target *cloud.Server + var byName []cloud.Server + for i := range servers { + if servers[i].ID == ref { + target = &servers[i] + break + } + if servers[i].Name == ref { + byName = append(byName, servers[i]) + } + } + if target == nil { + switch len(byName) { + case 0: + return fmt.Errorf("no Teploy Cloud server named or with id %q (see `teploy cloud servers`)", ref) + case 1: + target = &byName[0] + default: + ids := make([]string, len(byName)) + for i, s := range byName { + ids[i] = s.ID + } + return fmt.Errorf("%d servers are named %q: delete by id (%s)", len(byName), ref, strings.Join(ids, ", ")) + } + } + + if !yes && !env.confirm(fmt.Sprintf("Delete server %s (%s, id %s)? It is destroyed with everything on it.", + target.Name, dash(target.IPAddress), target.ID)) { + return errors.New("aborted: nothing was deleted") + } + if err := client.DeleteServer(ctx, target.ID); err != nil { + return fmt.Errorf("deleting server: %w", err) + } + fmt.Fprintf(env.out, "Deleted server %s (id %s)\n", target.Name, target.ID) + + serversPath, err := config.DefaultServersPath() + if err != nil { + return fmt.Errorf("determining servers path: %w", err) + } + local, err := config.ListServers(serversPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("reading servers.yml: %w", err) + } + entry, ok := local[target.Name] + if !ok { + return nil + } + if target.IPAddress == "" || entry.Host != target.IPAddress { + fmt.Fprintf(env.out, "Left servers.yml entry %q in place: its host %s is not the deleted server's address\n", target.Name, entry.Host) + return nil + } + if err := config.RemoveServer(serversPath, target.Name); err != nil { + return fmt.Errorf("removing server from servers.yml: %w", err) + } + fmt.Fprintf(env.out, "Removed server %q from servers.yml\n", target.Name) + return nil +} diff --git a/internal/cli/cloud_test.go b/internal/cli/cloud_test.go new file mode 100644 index 0000000..810ec10 --- /dev/null +++ b/internal/cli/cloud_test.go @@ -0,0 +1,452 @@ +package cli + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/useteploy/teploy/internal/cloud" + "github.com/useteploy/teploy/internal/config" + cryptossh "golang.org/x/crypto/ssh" +) + +const cloudTestKey = "tp_test_abcdef0123456789TOPSECRET" + +// fakeCloud is an in-memory Teploy Cloud /api/v1. +type fakeCloud struct { + mu sync.Mutex + servers map[string]map[string]any + keys []map[string]any + keyPosts int + createPosts int + idemKeys []string + failFirstPost bool + pollsToActive int + gets int + deleted []string +} + +func (f *fakeCloud) handler(t *testing.T) http.Handler { + mux := http.NewServeMux() + ok := func(w http.ResponseWriter, status int, data any) { + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data}) + } + auth := func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+cloudTestKey || r.Header.Get("X-Organization-Id") != "org_1" { + w.WriteHeader(401) + w.Write([]byte(`{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}}`)) + return + } + f.mu.Lock() + defer f.mu.Unlock() + next(w, r) + } + } + mux.HandleFunc("GET /api/v1/servers", auth(func(w http.ResponseWriter, r *http.Request) { + list := []map[string]any{} + for _, s := range f.servers { + list = append(list, s) + } + ok(w, 200, map[string]any{"items": list, "total": len(list)}) + })) + mux.HandleFunc("GET /api/v1/servers/{id}", auth(func(w http.ResponseWriter, r *http.Request) { + s := f.servers[r.PathValue("id")] + f.gets++ + if f.gets >= f.pollsToActive { + s["status"] = "active" + s["ipAddress"] = "203.0.113.7" + } + ok(w, 200, s) + })) + mux.HandleFunc("POST /api/v1/servers", auth(func(w http.ResponseWriter, r *http.Request) { + f.createPosts++ + f.idemKeys = append(f.idemKeys, r.Header.Get("Idempotency-Key")) + if f.failFirstPost && f.createPosts == 1 { + w.WriteHeader(503) + w.Write([]byte(`{"success":false,"error":{"code":"SERVICE_UNAVAILABLE","message":"try again"}}`)) + return + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + s := map[string]any{"id": "srv_1", "name": body["name"], "status": "provisioning", "ipAddress": nil, + "provider": body["provider"], "providerRegion": body["region"], "providerSize": body["size"], + "priceMonthlyCents": 600, "sshKeyId": body["sshKeyId"]} + f.servers["srv_1"] = s + ok(w, 201, s) + })) + mux.HandleFunc("DELETE /api/v1/servers/{id}", auth(func(w http.ResponseWriter, r *http.Request) { + f.deleted = append(f.deleted, r.PathValue("id")) + delete(f.servers, r.PathValue("id")) + ok(w, 200, nil) + })) + mux.HandleFunc("GET /api/v1/providers/{provider}/sizes", auth(func(w http.ResponseWriter, r *http.Request) { + ok(w, 200, []map[string]any{{"id": "vc2-1c-1gb", "vcpus": 1, "memory": 1024, "disk": 25, "priceMonthlyCents": 600}}) + })) + mux.HandleFunc("GET /api/v1/providers/{provider}/regions", auth(func(w http.ResponseWriter, r *http.Request) { + ok(w, 200, []map[string]any{{"id": "ewr", "name": "New Jersey", "country": "US", "available": true}}) + })) + mux.HandleFunc("GET /api/v1/ssh-keys", auth(func(w http.ResponseWriter, r *http.Request) { + ok(w, 200, f.keys) + })) + mux.HandleFunc("POST /api/v1/ssh-keys", auth(func(w http.ResponseWriter, r *http.Request) { + f.keyPosts++ + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + k := map[string]any{"id": fmt.Sprintf("key_%d", f.keyPosts), "name": body["name"], "publicKey": body["publicKey"]} + f.keys = append(f.keys, k) + ok(w, 201, k) + })) + return mux +} + +type cloudHarness struct { + t *testing.T + fake *fakeCloud + home string + out *bytes.Buffer + env *cloudEnv + flags *Flags + confirms []string + answer bool + setups []string + sshWaits []string + pubKey string +} + +func newCloudHarness(t *testing.T) *cloudHarness { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + fake := &fakeCloud{servers: map[string]map[string]any{}, pollsToActive: 2} + srv := httptest.NewServer(fake.handler(t)) + t.Cleanup(srv.Close) + t.Setenv(cloud.BaseURLEnv, srv.URL) + + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshPub, err := cryptossh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + line := strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(sshPub))) + " me@laptop" + if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".ssh", "id_ed25519.pub"), []byte(line+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + h := &cloudHarness{t: t, fake: fake, home: home, out: &bytes.Buffer{}, flags: &Flags{}, answer: true, pubKey: line} + h.env = &cloudEnv{ + in: strings.NewReader(""), + out: h.out, + stdinIsTerminal: func() bool { return false }, + readHidden: func(string) (string, error) { return "", io.EOF }, + confirm: func(p string) bool { + h.confirms = append(h.confirms, p) + return h.answer + }, + waitSSH: func(_ context.Context, host, _ string) error { + h.sshWaits = append(h.sshWaits, host) + return nil + }, + setup: func(f *Flags, host, name string, _ bool) error { + h.setups = append(h.setups, f.User+"@"+host+" "+name) + return nil + }, + sleep: func(ctx context.Context, d time.Duration) error { return ctx.Err() }, + pollInterval: time.Millisecond, + provisionTimeout: time.Minute, + createAttempts: 3, + } + return h +} + +func (h *cloudHarness) run(args ...string) error { + cmd := newCloudCmdWithEnv(h.flags, h.env) + cmd.SetArgs(args) + cmd.SetOut(h.out) + cmd.SetErr(h.out) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + return cmd.Execute() +} + +func (h *cloudHarness) login() { + h.t.Helper() + h.env.in = strings.NewReader(cloudTestKey + "\n") + if err := h.run("login", "--key-stdin", "--org", "org_1"); err != nil { + h.t.Fatalf("login: %v", err) + } + h.out.Reset() +} + +func TestCloudLoginStoresPrivateFileAndNeverEchoesKey(t *testing.T) { + h := newCloudHarness(t) + h.env.in = strings.NewReader(cloudTestKey + "\n") + if err := h.run("login", "--key-stdin", "--org", "org_1"); err != nil { + t.Fatal(err) + } + path := filepath.Join(h.home, ".teploy", "cloud.yml") + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("cloud.yml mode %o, want 600", fi.Mode().Perm()) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), cloudTestKey) || !strings.Contains(string(data), "org_1") { + t.Errorf("cloud.yml missing credentials: %s", data) + } + if strings.Contains(h.out.String(), cloudTestKey) { + t.Errorf("login output echoes the key: %s", h.out) + } + + h.out.Reset() + if err := h.run("status"); err != nil { + t.Fatal(err) + } + if strings.Contains(h.out.String(), cloudTestKey) || !strings.Contains(h.out.String(), "works") || !strings.Contains(h.out.String(), "org_1") { + t.Errorf("status output: %s", h.out) + } + + h.out.Reset() + if err := h.run("logout"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("logout left %s", path) + } +} + +func TestCloudLoginRejectsBadKeyWithoutStoring(t *testing.T) { + h := newCloudHarness(t) + h.env.in = strings.NewReader("tp_test_wrongwrongwrong\n") + err := h.run("login", "--key-stdin", "--org", "org_1") + if err == nil || !strings.Contains(err.Error(), "teploy cloud login") { + t.Fatalf("got %v", err) + } + if strings.Contains(err.Error(), "wrongwrong") { + t.Errorf("error echoes the key: %v", err) + } + if _, err := os.Stat(filepath.Join(h.home, ".teploy", "cloud.yml")); !os.IsNotExist(err) { + t.Error("a rejected key was stored") + } +} + +func TestCloudLoginRefusesKeyFlag(t *testing.T) { + h := newCloudHarness(t) + h.flags.Key = cloudTestKey + err := h.run("login", "--org", "org_1") + if err == nil || strings.Contains(err.Error(), cloudTestKey) { + t.Fatalf("got %v", err) + } +} + +func TestCloudServersTableAndJSON(t *testing.T) { + h := newCloudHarness(t) + h.login() + h.fake.servers["s1"] = map[string]any{"id": "s1", "name": "web", "status": "active", "ipAddress": "203.0.113.9", + "provider": "vultr", "providerRegion": "ewr", "providerSize": "vc2-1c-1gb", "priceMonthlyCents": 600} + if err := h.run("servers"); err != nil { + t.Fatal(err) + } + for _, want := range []string{"NAME", "web", "active", "203.0.113.9", "vultr", "ewr", "vc2-1c-1gb", "$6.00"} { + if !strings.Contains(h.out.String(), want) { + t.Errorf("table missing %q:\n%s", want, h.out) + } + } + h.out.Reset() + h.flags.JSON = true + if err := h.run("servers"); err != nil { + t.Fatal(err) + } + var got []cloud.Server + if err := json.Unmarshal(h.out.Bytes(), &got); err != nil || len(got) != 1 || got[0].Name != "web" { + t.Fatalf("json: %v %s", err, h.out) + } +} + +func TestCloudSizesAndRegions(t *testing.T) { + h := newCloudHarness(t) + h.login() + if err := h.run("sizes", "--provider", "vultr", "--region", "ewr"); err != nil { + t.Fatal(err) + } + if !strings.Contains(h.out.String(), "vc2-1c-1gb") || !strings.Contains(h.out.String(), "$6.00") { + t.Errorf("sizes: %s", h.out) + } + h.out.Reset() + if err := h.run("regions", "--provider", "vultr"); err != nil { + t.Fatal(err) + } + if !strings.Contains(h.out.String(), "ewr") || !strings.Contains(h.out.String(), "New Jersey") { + t.Errorf("regions: %s", h.out) + } +} + +func TestCloudServerCreateFullFlow(t *testing.T) { + h := newCloudHarness(t) + h.login() + h.fake.failFirstPost = true + + err := h.run("server", "create", "web1", "--provider", "vultr", "--region", "ewr", "--size", "vc2-1c-1gb") + if err != nil { + t.Fatalf("create: %v\n%s", err, h.out) + } + if h.fake.keyPosts != 1 { + t.Errorf("ssh key posts = %d, want 1", h.fake.keyPosts) + } + if got := h.fake.keys[0]["publicKey"]; got != h.pubKey { + t.Errorf("registered key %q", got) + } + if len(h.confirms) != 1 || !strings.Contains(h.out.String(), "$6.00 per month") { + t.Errorf("confirmations %v, output %s", h.confirms, h.out) + } + if h.fake.createPosts != 2 || h.fake.idemKeys[0] == "" || h.fake.idemKeys[0] != h.fake.idemKeys[1] { + t.Errorf("create posts %d, idempotency keys %v", h.fake.createPosts, h.fake.idemKeys) + } + if h.fake.gets < 2 { + t.Errorf("polled %d times", h.fake.gets) + } + servers, err := config.ListServers(filepath.Join(h.home, ".teploy", "servers.yml")) + if err != nil { + t.Fatal(err) + } + if s := servers["web1"]; s.Host != "203.0.113.7" || s.User != "root" { + t.Errorf("servers.yml entry %+v", s) + } + if len(h.sshWaits) != 1 || len(h.setups) != 1 || h.setups[0] != "root@203.0.113.7 web1" { + t.Errorf("ssh waits %v, setups %v", h.sshWaits, h.setups) + } + if !strings.Contains(h.out.String(), "server: web1") { + t.Errorf("no next step in output: %s", h.out) + } + if strings.Contains(h.out.String(), cloudTestKey) { + t.Error("output contains the key") + } + + // A second create reuses the registered key and refuses the existing name. + err = h.run("server", "create", "web1", "--provider", "vultr", "--region", "ewr", "--size", "vc2-1c-1gb", "--yes") + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("second create: %v", err) + } + if h.fake.createPosts != 2 { + t.Errorf("refused create still posted") + } + if err := h.run("server", "create", "web2", "--provider", "vultr", "--region", "ewr", "--size", "vc2-1c-1gb", "--yes", "--no-setup"); err != nil { + t.Fatal(err) + } + if h.fake.keyPosts != 1 { + t.Errorf("key registered again: %d posts", h.fake.keyPosts) + } +} + +func TestCloudServerCreateNoSetupSkipsSetup(t *testing.T) { + h := newCloudHarness(t) + h.login() + if err := h.run("server", "create", "web1", "--provider", "vultr", "--region", "ewr", "--size", "vc2-1c-1gb", "--yes", "--no-setup"); err != nil { + t.Fatal(err) + } + if len(h.setups) != 0 || len(h.sshWaits) != 0 { + t.Errorf("setup ran: %v %v", h.setups, h.sshWaits) + } + if len(h.confirms) != 0 { + t.Errorf("--yes still asked: %v", h.confirms) + } + servers, _ := config.ListServers(filepath.Join(h.home, ".teploy", "servers.yml")) + if servers["web1"].Host != "203.0.113.7" { + t.Errorf("servers.yml: %+v", servers) + } +} + +func TestCloudServerCreateDeclinedBuysNothing(t *testing.T) { + h := newCloudHarness(t) + h.login() + h.answer = false + err := h.run("server", "create", "web1", "--provider", "vultr", "--region", "ewr", "--size", "vc2-1c-1gb") + if err == nil || !strings.Contains(err.Error(), "aborted") { + t.Fatalf("got %v", err) + } + if h.fake.createPosts != 0 { + t.Error("declined create posted") + } +} + +func TestCloudServerCreateErrorStatusFails(t *testing.T) { + h := newCloudHarness(t) + h.login() + h.fake.pollsToActive = 1 << 30 + h.env.sleep = func(ctx context.Context, d time.Duration) error { + h.fake.mu.Lock() + if s := h.fake.servers["srv_1"]; s != nil { + s["status"] = "error" + } + h.fake.mu.Unlock() + return nil + } + err := h.run("server", "create", "web1", "--provider", "vultr", "--region", "ewr", "--size", "vc2-1c-1gb", "--yes") + if err == nil || !strings.Contains(err.Error(), "failed to provision") { + t.Fatalf("got %v", err) + } + if _, err := os.Stat(filepath.Join(h.home, ".teploy", "servers.yml")); !os.IsNotExist(err) { + t.Error("failed server written to servers.yml") + } +} + +func TestCloudServerDelete(t *testing.T) { + h := newCloudHarness(t) + h.login() + h.fake.servers["srv_1"] = map[string]any{"id": "srv_1", "name": "web1", "status": "active", "ipAddress": "203.0.113.7"} + h.fake.servers["srv_2"] = map[string]any{"id": "srv_2", "name": "web2", "status": "active", "ipAddress": "203.0.113.8"} + path := filepath.Join(h.home, ".teploy", "servers.yml") + if err := config.AddServer(path, "web1", "203.0.113.7", "root", "", ""); err != nil { + t.Fatal(err) + } + if err := config.AddServer(path, "web2", "198.51.100.1", "root", "", ""); err != nil { + t.Fatal(err) + } + + h.answer = false + if err := h.run("server", "delete", "web1"); err == nil { + t.Fatal("declined delete succeeded") + } + if len(h.fake.deleted) != 0 || len(h.confirms) != 1 || !strings.Contains(h.confirms[0], "web1") { + t.Fatalf("deleted %v confirms %v", h.fake.deleted, h.confirms) + } + + h.answer = true + if err := h.run("server", "delete", "web1"); err != nil { + t.Fatal(err) + } + if err := h.run("server", "delete", "srv_2", "--yes"); err != nil { + t.Fatal(err) + } + if strings.Join(h.fake.deleted, ",") != "srv_1,srv_2" { + t.Errorf("deleted %v", h.fake.deleted) + } + servers, _ := config.ListServers(path) + if _, ok := servers["web1"]; ok { + t.Error("web1 entry kept although its host matched") + } + if _, ok := servers["web2"]; !ok { + t.Error("web2 entry removed although its host differs") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c80c595..7c934bc 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -148,6 +148,7 @@ func NewRootCmd(version string) *cobra.Command { root.AddCommand(newTemplateCmd(flags)) root.AddCommand(newNetworkCmd(flags)) root.AddCommand(newServerCmd(flags)) + root.AddCommand(newCloudCmd(flags)) root.AddCommand(newScaleCmd(flags)) root.AddCommand(newAutoDeployCmd(flags)) root.AddCommand(newMaintenanceCmd(flags)) diff --git a/internal/cloud/client.go b/internal/cloud/client.go new file mode 100644 index 0000000..3be23e8 --- /dev/null +++ b/internal/cloud/client.go @@ -0,0 +1,397 @@ +// Package cloud is the HTTP client for the Teploy Cloud public API +// (/api/v1): managed servers, the provider catalogue and SSH keys. +// +// The API key is only ever placed in the Authorization header. It is never +// logged, printed, or included in an error string. +package cloud + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// DefaultBaseURL is the hosted Teploy Cloud. +const DefaultBaseURL = "https://cloud.teploy.com" + +// BaseURLEnv overrides the base URL (e.g. a local instance). +const BaseURLEnv = "TEPLOY_CLOUD_URL" + +// RequestTimeout bounds every request. +const RequestTimeout = 30 * time.Second + +const maxResponseBytes = 8 << 20 + +// Server is a managed server as the API returns it. +type Server struct { + ID string `json:"id"` + Name string `json:"name"` + IPAddress string `json:"ipAddress"` + Status string `json:"status"` + Provider string `json:"provider"` + ProviderRegion string `json:"providerRegion"` + ProviderSize string `json:"providerSize"` + PriceMonthlyCents int64 `json:"priceMonthlyCents"` +} + +// Provider is one entry of the provider catalogue. +type Provider struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + ManagedAvailable bool `json:"managedAvailable"` +} + +// Region is a provider region. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Country string `json:"country"` + Continent string `json:"continent"` + Available bool `json:"available"` +} + +// Size is a provider plan with the price Teploy Cloud charges for it. +type Size struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + VCPUs int `json:"vcpus"` + Memory int `json:"memory"` // MB + Disk int `json:"disk"` // GB + Bandwidth float64 `json:"bandwidth"` + PriceMonthlyCents int64 `json:"priceMonthlyCents"` + Available bool `json:"available"` + Regions []string `json:"regions,omitempty"` +} + +// SSHKey is an SSH public key registered with the organization. +type SSHKey struct { + ID string `json:"id"` + Name string `json:"name"` + PublicKey string `json:"publicKey"` +} + +// CreateServerRequest is the POST /api/v1/servers body. +type CreateServerRequest struct { + Provider string `json:"provider"` + Name string `json:"name"` + Region string `json:"region"` + Size string `json:"size"` + SSHKeyID string `json:"sshKeyId"` +} + +// APIError is a failure envelope ({"success":false,"error":{...}}) or a +// non-2xx response without one. +type APIError struct { + Status int + Code string + Message string +} + +func (e *APIError) Error() string { + switch e.Code { + case "trust_limit", "platform_capacity": + return e.Message + } + msg := e.Message + if msg == "" { + msg = http.StatusText(e.Status) + } + s := fmt.Sprintf("teploy cloud: %s (code %s, HTTP %d)", msg, e.Code, e.Status) + if hint := e.Hint(); hint != "" { + s += "\n" + hint + } + return s +} + +// Hint is the next step for the failures a user can fix themselves. +func (e *APIError) Hint() string { + low := strings.ToLower(e.Message) + switch { + case e.Status == http.StatusUnauthorized: + return "Run `teploy cloud login` with a valid API key." + case e.Status == http.StatusPaymentRequired || strings.Contains(low, "payment method"): + return "Add a card at " + DefaultBaseURL + " and try again." + } + return "" +} + +// Client talks to one Teploy Cloud organization. +type Client struct { + baseURL string + key string + orgID string + http *http.Client +} + +// ResolveBaseURL picks the base URL: $TEPLOY_CLOUD_URL, else stored, else +// the default, and validates it. +func ResolveBaseURL(stored string) (string, error) { + u := strings.TrimSpace(os.Getenv(BaseURLEnv)) + if u == "" { + u = strings.TrimSpace(stored) + } + if u == "" { + u = DefaultBaseURL + } + return ValidateBaseURL(u) +} + +// ValidateBaseURL refuses anything but https, except plain http to +// localhost or 127.0.0.1. It returns the URL without a trailing slash. +func ValidateBaseURL(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "", fmt.Errorf("invalid Teploy Cloud URL %q", raw) + } + switch u.Scheme { + case "https": + case "http": + h := u.Hostname() + if h != "localhost" && h != "127.0.0.1" { + return "", fmt.Errorf("refusing non-https Teploy Cloud URL %q (plain http is allowed only for localhost and 127.0.0.1)", raw) + } + default: + return "", fmt.Errorf("refusing Teploy Cloud URL %q: scheme must be https", raw) + } + if u.User != nil { + return "", fmt.Errorf("refusing Teploy Cloud URL with embedded credentials") + } + return strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/"), nil +} + +// New returns a client. baseURL must already be validated or it is +// validated here. +func New(baseURL, key, orgID string) (*Client, error) { + base, err := ValidateBaseURL(baseURL) + if err != nil { + return nil, err + } + if strings.TrimSpace(key) == "" { + return nil, errors.New("teploy cloud: API key is empty") + } + if strings.TrimSpace(orgID) == "" { + return nil, errors.New("teploy cloud: organization id is empty") + } + return &Client{ + baseURL: base, + key: strings.TrimSpace(key), + orgID: strings.TrimSpace(orgID), + http: &http.Client{Timeout: RequestTimeout}, + }, nil +} + +// BaseURL is the validated base URL the client talks to. +func (c *Client) BaseURL() string { return c.baseURL } + +// NewIdempotencyKey returns a random key for one logical create. +func NewIdempotencyKey() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generating idempotency key: %w", err) + } + return hex.EncodeToString(b[:]), nil +} + +type envelope struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func (c *Client) do(ctx context.Context, method, path string, body any, headers map[string]string, out any) error { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding request: %w", err) + } + rdr = bytes.NewReader(b) + } + ctx, cancel := context.WithTimeout(ctx, RequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.key) + req.Header.Set("X-Organization-Id", c.orgID) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := c.http.Do(req) + if err != nil { + // *url.Error carries the method and URL, never headers. + return fmt.Errorf("teploy cloud: %s %s: %w", method, path, unwrapURLError(err)) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("teploy cloud: reading response to %s %s: %w", method, path, err) + } + + var env envelope + jsonErr := json.Unmarshal(raw, &env) + if resp.StatusCode < 200 || resp.StatusCode > 299 || (jsonErr == nil && !env.Success) { + ae := &APIError{Status: resp.StatusCode} + if jsonErr == nil && env.Error != nil { + ae.Code, ae.Message = env.Error.Code, env.Error.Message + } + if ae.Code == "" { + ae.Code = "unknown" + } + return ae + } + if jsonErr != nil { + return fmt.Errorf("teploy cloud: %s %s: response is not a JSON envelope", method, path) + } + if out == nil || len(env.Data) == 0 || string(env.Data) == "null" { + return nil + } + if err := json.Unmarshal(env.Data, out); err != nil { + return fmt.Errorf("teploy cloud: decoding %s %s: %w", method, path, err) + } + return nil +} + +func unwrapURLError(err error) error { + var ue *url.Error + if errors.As(err, &ue) { + return ue.Err + } + return err +} + +// decodeList accepts "data" as an array or as {"items":[...],"total":n}. +func decodeList[T any](raw json.RawMessage) ([]T, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || string(trimmed) == "null" { + return []T{}, nil + } + var items []T + if trimmed[0] == '[' { + if err := json.Unmarshal(trimmed, &items); err != nil { + return nil, err + } + } else { + var wrapped struct { + Items []T `json:"items"` + } + if err := json.Unmarshal(trimmed, &wrapped); err != nil { + return nil, err + } + items = wrapped.Items + } + if items == nil { + items = []T{} + } + return items, nil +} + +func list[T any](ctx context.Context, c *Client, path string) ([]T, error) { + var raw json.RawMessage + if err := c.do(ctx, http.MethodGet, path, nil, nil, &raw); err != nil { + return nil, err + } + items, err := decodeList[T](raw) + if err != nil { + return nil, fmt.Errorf("teploy cloud: decoding GET %s: %w", path, err) + } + return items, nil +} + +// ListServers returns the organization's servers. +func (c *Client) ListServers(ctx context.Context) ([]Server, error) { + return list[Server](ctx, c, "/api/v1/servers") +} + +// GetServer returns one server. +func (c *Client) GetServer(ctx context.Context, id string) (*Server, error) { + var s Server + if err := c.do(ctx, http.MethodGet, "/api/v1/servers/"+url.PathEscape(id), nil, nil, &s); err != nil { + return nil, err + } + return &s, nil +} + +// CreateServer provisions a server. idempotencyKey must be the same on +// every retry of one logical create. +func (c *Client) CreateServer(ctx context.Context, r CreateServerRequest, idempotencyKey string) (*Server, error) { + var s Server + h := map[string]string{"Idempotency-Key": idempotencyKey} + if err := c.do(ctx, http.MethodPost, "/api/v1/servers", r, h, &s); err != nil { + return nil, err + } + return &s, nil +} + +// DeleteServer destroys a server. +func (c *Client) DeleteServer(ctx context.Context, id string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/servers/"+url.PathEscape(id), nil, nil, nil) +} + +// ListProviders returns the provider catalogue. +func (c *Client) ListProviders(ctx context.Context) ([]Provider, error) { + return list[Provider](ctx, c, "/api/v1/providers") +} + +// ListRegions returns the regions a provider offers. +func (c *Client) ListRegions(ctx context.Context, provider string) ([]Region, error) { + return list[Region](ctx, c, "/api/v1/providers/"+url.PathEscape(provider)+"/regions") +} + +// ListSizes returns the sizes a provider offers, optionally in one region. +func (c *Client) ListSizes(ctx context.Context, provider, region string) ([]Size, error) { + p := "/api/v1/providers/" + url.PathEscape(provider) + "/sizes" + if region != "" { + p += "?region=" + url.QueryEscape(region) + } + return list[Size](ctx, c, p) +} + +// ListSSHKeys returns the organization's registered SSH keys. +func (c *Client) ListSSHKeys(ctx context.Context) ([]SSHKey, error) { + return list[SSHKey](ctx, c, "/api/v1/ssh-keys") +} + +// CreateSSHKey registers a public key. +func (c *Client) CreateSSHKey(ctx context.Context, name, publicKey string) (*SSHKey, error) { + var k SSHKey + body := map[string]string{"name": name, "publicKey": publicKey} + if err := c.do(ctx, http.MethodPost, "/api/v1/ssh-keys", body, nil, &k); err != nil { + return nil, err + } + return &k, nil +} + +// IsRetryable reports whether a failed create can be retried with the same +// idempotency key: a transport failure or a 5xx response. +func IsRetryable(err error) bool { + if err == nil { + return false + } + var ae *APIError + if errors.As(err, &ae) { + return ae.Status >= 500 + } + return !errors.Is(err, context.Canceled) +} diff --git a/internal/cloud/client_test.go b/internal/cloud/client_test.go new file mode 100644 index 0000000..89bdc44 --- /dev/null +++ b/internal/cloud/client_test.go @@ -0,0 +1,177 @@ +package cloud + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +const testKey = "tp_test_0123456789abcdefSECRET" + +func newTestClient(t *testing.T, h http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + c, err := New(srv.URL, testKey, "org_1") + if err != nil { + t.Fatal(err) + } + return c +} + +func TestClientSendsAuthHeaders(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+testKey { + t.Errorf("Authorization = %q", got) + } + if got := r.Header.Get("X-Organization-Id"); got != "org_1" { + t.Errorf("X-Organization-Id = %q", got) + } + w.Write([]byte(`{"success":true,"data":[]}`)) + }) + if _, err := c.ListServers(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestClientAcceptsBothListShapes(t *testing.T) { + for name, body := range map[string]string{ + "array": `{"success":true,"data":[{"id":"s1","name":"web","ipAddress":"203.0.113.5","status":"active","priceMonthlyCents":600}]}`, + "items": `{"success":true,"data":{"items":[{"id":"s1","name":"web","ipAddress":"203.0.113.5","status":"active","priceMonthlyCents":600}],"total":1}}`, + } { + t.Run(name, func(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) }) + got, err := c.ListServers(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "s1" || got[0].IPAddress != "203.0.113.5" || got[0].PriceMonthlyCents != 600 { + t.Fatalf("got %+v", got) + } + }) + } +} + +func TestClientErrorEnvelope(t *testing.T) { + cases := []struct { + status int + body string + want, wantNot string + wantCode string + }{ + {401, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}}`, "teploy cloud login", "", "UNAUTHORIZED"}, + {402, `{"success":false,"error":{"code":"PAYMENT_REQUIRED","message":"No card"}}`, "Add a card at https://cloud.teploy.com", "", "PAYMENT_REQUIRED"}, + {400, `{"success":false,"error":{"code":"BAD_REQUEST","message":"Add a payment method first"}}`, "Add a card", "", "BAD_REQUEST"}, + {403, `{"success":false,"error":{"code":"trust_limit","message":"New accounts may run 2 servers."}}`, "New accounts may run 2 servers.", "code", "trust_limit"}, + {503, `{"success":false,"error":{"code":"platform_capacity","message":"No capacity right now."}}`, "No capacity right now.", "HTTP", "platform_capacity"}, + {502, `bad gateway`, "HTTP 502", "", "unknown"}, + } + for _, tc := range cases { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + w.Write([]byte(tc.body)) + }) + _, err := c.ListServers(context.Background()) + var ae *APIError + if !errors.As(err, &ae) { + t.Fatalf("%d: want APIError, got %v", tc.status, err) + } + if ae.Code != tc.wantCode || ae.Status != tc.status { + t.Errorf("%d: code %q status %d", tc.status, ae.Code, ae.Status) + } + msg := err.Error() + if !strings.Contains(msg, tc.want) { + t.Errorf("%d: %q does not contain %q", tc.status, msg, tc.want) + } + if tc.wantNot != "" && strings.Contains(msg, tc.wantNot) { + t.Errorf("%d: %q should be the message as is", tc.status, msg) + } + if strings.Contains(msg, testKey) { + t.Errorf("error leaks the key: %q", msg) + } + } +} + +func TestClientSuccessFalseIsError(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"success":false,"error":{"code":"X","message":"nope"}}`)) + }) + if _, err := c.ListServers(context.Background()); err == nil || !strings.Contains(err.Error(), "nope") { + t.Fatalf("got %v", err) + } +} + +func TestValidateBaseURL(t *testing.T) { + ok := []string{"https://cloud.teploy.com", "https://cloud.teploy.com/", "http://localhost:8080", "http://127.0.0.1:3000"} + for _, u := range ok { + if _, err := ValidateBaseURL(u); err != nil { + t.Errorf("%s: %v", u, err) + } + } + bad := []string{"http://cloud.teploy.com", "http://10.0.0.1", "ftp://x", "cloud.teploy.com", "https://u:p@cloud.teploy.com"} + for _, u := range bad { + if _, err := ValidateBaseURL(u); err == nil { + t.Errorf("%s accepted", u) + } + } + if _, err := New("http://example.com", testKey, "org"); err == nil { + t.Error("New accepted plain http to a remote host") + } +} + +func TestResolveBaseURLEnv(t *testing.T) { + t.Setenv(BaseURLEnv, "") + if u, _ := ResolveBaseURL(""); u != DefaultBaseURL { + t.Errorf("default = %q", u) + } + if u, _ := ResolveBaseURL("http://localhost:9000"); u != "http://localhost:9000" { + t.Errorf("stored = %q", u) + } + t.Setenv(BaseURLEnv, "http://evil.example") + if _, err := ResolveBaseURL(""); err == nil { + t.Error("env with non-https remote URL accepted") + } +} + +func TestCreateServerSendsIdempotencyKey(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Idempotency-Key") != "k1" { + t.Errorf("Idempotency-Key = %q", r.Header.Get("Idempotency-Key")) + } + w.WriteHeader(201) + w.Write([]byte(`{"success":true,"data":{"id":"s1","status":"provisioning"}}`)) + }) + s, err := c.CreateServer(context.Background(), CreateServerRequest{Name: "web"}, "k1") + if err != nil || s.ID != "s1" { + t.Fatalf("%v %+v", err, s) + } +} + +func TestCredentialsFileMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", FileName) + if err := SaveCredentials(path, &Credentials{OrgID: "o", APIKey: "k"}); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("mode %o", fi.Mode().Perm()) + } + got, err := LoadCredentials(path) + if err != nil || got.APIKey != "k" || got.OrgID != "o" { + t.Fatalf("%v %+v", err, got) + } + if removed, err := RemoveCredentials(path); !removed || err != nil { + t.Fatalf("%v %v", removed, err) + } + if _, err := LoadCredentials(path); !errors.Is(err, ErrNotLoggedIn) { + t.Fatalf("got %v", err) + } +} diff --git a/internal/cloud/credentials.go b/internal/cloud/credentials.go new file mode 100644 index 0000000..251b051 --- /dev/null +++ b/internal/cloud/credentials.go @@ -0,0 +1,92 @@ +package cloud + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// FileName is the credentials file, kept next to servers.yml. +const FileName = "cloud.yml" + +// Credentials are what `teploy cloud login` stores. +type Credentials struct { + BaseURL string `yaml:"base_url,omitempty"` + OrgID string `yaml:"org_id"` + APIKey string `yaml:"api_key"` +} + +// ErrNotLoggedIn means there is no credentials file. +var ErrNotLoggedIn = errors.New("not logged in to Teploy Cloud: run `teploy cloud login`") + +// LoadCredentials reads the credentials file at path. +func LoadCredentials(path string) (*Credentials, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotLoggedIn + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + var c Credentials + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if c.APIKey == "" || c.OrgID == "" { + return nil, fmt.Errorf("%s is incomplete: run `teploy cloud login`", path) + } + return &c, nil +} + +// SaveCredentials writes the credentials file atomically with mode 0600. +func SaveCredentials(path string, c *Credentials) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + out, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("encoding credentials: %w", err) + } + tmp, err := os.CreateTemp(dir, ".cloud-*") + if err != nil { + return fmt.Errorf("creating temp credentials file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return fmt.Errorf("setting credentials file permissions: %w", err) + } + if _, err := tmp.Write(out); err != nil { + tmp.Close() + return fmt.Errorf("writing credentials file: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("syncing credentials file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing credentials file: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replacing credentials file: %w", err) + } + return nil +} + +// RemoveCredentials deletes the credentials file. It reports whether a file +// was removed. +func RemoveCredentials(path string) (bool, error) { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("removing %s: %w", path, err) + } + return true, nil +}