diff --git a/go.mod b/go.mod index 8642fa2..7b6ba9c 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,20 @@ module github.com/signadot/cli go 1.18 require ( + github.com/docker/go-units v0.4.0 github.com/go-openapi/runtime v0.24.1 github.com/go-openapi/strfmt v0.21.2 - github.com/signadot/go-sdk v0.1.2 + github.com/signadot/go-sdk v0.1.3 github.com/spf13/cobra v1.4.0 github.com/spf13/viper v1.11.0 + github.com/theckman/yacspin v0.13.12 golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 sigs.k8s.io/yaml v1.3.0 ) require ( github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect + github.com/fatih/color v1.13.0 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/go-openapi/analysis v0.21.3 // indirect github.com/go-openapi/errors v0.20.2 // indirect @@ -29,11 +32,15 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/magiconair/properties v1.8.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/pelletier/go-toml/v2 v2.0.0-beta.8 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/afero v1.8.2 // indirect github.com/spf13/cast v1.4.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect diff --git a/go.sum b/go.sum index 07fcf72..9a066e0 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -63,6 +64,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -217,6 +220,14 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -241,12 +252,14 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/signadot/go-sdk v0.1.2 h1:ucdmm2YfFu8hOY9QZ/kFHT8gN2ObXwYUtIcDBKdXop0= -github.com/signadot/go-sdk v0.1.2/go.mod h1:rdSPPZzJmv4JpdSTXm2Dm1BIwM0esNi9cViCmjDHZVU= +github.com/signadot/go-sdk v0.1.3 h1:0pE+iT80HIVTjyWtrzkz3YyjbvNMy57U3GCdVCXIjVw= +github.com/signadot/go-sdk v0.1.3/go.mod h1:rdSPPZzJmv4JpdSTXm2Dm1BIwM0esNi9cViCmjDHZVU= github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -276,6 +289,8 @@ github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMT github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= @@ -413,6 +428,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -436,6 +452,7 @@ golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/command/cluster/list.go b/internal/command/cluster/list.go index b6fcf47..6cddb05 100644 --- a/internal/command/cluster/list.go +++ b/internal/command/cluster/list.go @@ -1,15 +1,13 @@ package cluster import ( - "encoding/json" "fmt" "io" "github.com/signadot/cli/internal/config" - "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/cli/internal/print" "github.com/signadot/go-sdk/client/cluster" "github.com/spf13/cobra" - "sigs.k8s.io/yaml" ) func newList(cluster *config.Cluster) *cobra.Command { @@ -27,12 +25,6 @@ func newList(cluster *config.Cluster) *cobra.Command { return cmd } -type tableRow struct { - Name string `sdtab:"NAME"` - Created string `sdtab:"CREATED"` - Version string `sdtab:"OPERATOR VERSION"` -} - func list(cfg *config.ClusterList, out io.Writer) error { if err := cfg.InitAPIConfig(); err != nil { return err @@ -45,36 +37,12 @@ func list(cfg *config.ClusterList, out io.Writer) error { switch cfg.OutputFormat { case config.OutputFormatDefault: - t := sdtab.New[tableRow](out) - t.AddHeader() - for _, cluster := range clusters { - row := tableRow{ - Name: cluster.Name, - Created: cluster.CreatedAt, - Version: cluster.OperatorVersion, - } - t.AddRow(row) - } - if err := t.Flush(); err != nil { - return err - } + return printClusterTable(out, clusters) case config.OutputFormatJSON: - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - if err := enc.Encode(clusters); err != nil { - return err - } + return print.RawJSON(out, clusters) case config.OutputFormatYAML: - data, err := yaml.Marshal(clusters) - if err != nil { - return err - } - if _, err := out.Write(data); err != nil { - return err - } + return print.RawYAML(out, clusters) default: return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) } - - return nil } diff --git a/internal/command/cluster/printers.go b/internal/command/cluster/printers.go new file mode 100644 index 0000000..6686f99 --- /dev/null +++ b/internal/command/cluster/printers.go @@ -0,0 +1,27 @@ +package cluster + +import ( + "io" + + "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/go-sdk/models" +) + +type clusterRow struct { + Name string `sdtab:"NAME"` + Created string `sdtab:"CREATED"` + Version string `sdtab:"OPERATOR VERSION"` +} + +func printClusterTable(out io.Writer, clusters []*models.Cluster) error { + t := sdtab.New[clusterRow](out) + t.AddHeader() + for _, cluster := range clusters { + t.AddRow(clusterRow{ + Name: cluster.Name, + Created: cluster.CreatedAt, + Version: cluster.OperatorVersion, + }) + } + return t.Flush() +} diff --git a/internal/command/sandbox/command.go b/internal/command/sandbox/command.go index 7a85667..72c0604 100644 --- a/internal/command/sandbox/command.go +++ b/internal/command/sandbox/command.go @@ -19,6 +19,7 @@ func New(api *config.Api) *cobra.Command { newList(cfg), newCreate(cfg), newDelete(cfg), + newGetStatus(cfg), ) return cmd diff --git a/internal/command/sandbox/create.go b/internal/command/sandbox/create.go index c50ffe2..7f0c048 100644 --- a/internal/command/sandbox/create.go +++ b/internal/command/sandbox/create.go @@ -7,6 +7,8 @@ import ( "github.com/signadot/cli/internal/clio" "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/poll" + "github.com/signadot/cli/internal/spinner" "github.com/signadot/go-sdk/client/sandboxes" "github.com/signadot/go-sdk/models" "github.com/spf13/cobra" @@ -59,24 +61,52 @@ func create(cfg *config.SandboxCreate, out io.Writer) error { if cfg.Wait { // Wait for the sandbox to be ready. - fmt.Fprintln(out, "Waiting for sandbox to be ready...") - - params := sandboxes.NewGetSandboxReadyParams().WithOrgName(cfg.Org).WithSandboxID(resp.SandboxID) - - // We use a hot loop because the server implements rate-limiting for us. - for { - result, err := cfg.Client.Sandboxes.GetSandboxReady(params, nil) - if err != nil { - return err - } - if result.Payload.Ready { - break - } - // TODO: Show status message when it's added to the API response. + if err := waitForReady(cfg, out, resp.SandboxID); err != nil { + fmt.Fprintf(out, "\nThe sandbox was created, but it may not be ready yet. To check status, run:\n\n") + fmt.Fprintf(out, " signadot sandbox get-status %v\n\n", req.Name) + return err } + } + + // Print info on how to access the sandbox. + sbURL := cfg.SandboxDashboardURL(resp.SandboxID) + fmt.Fprintf(out, "\nDashboard page: %v\n\n", sbURL) - fmt.Fprintln(out, "Sandbox is ready.") + if len(resp.PreviewEndpoints) > 0 { + if err := printEndpointTable(out, resp.PreviewEndpoints); err != nil { + return err + } } return nil } + +func waitForReady(cfg *config.SandboxCreate, out io.Writer, sandboxID string) error { + fmt.Fprintf(out, "Waiting (up to --wait-timeout=%v) for sandbox to be ready...\n", cfg.WaitTimeout) + + params := sandboxes.NewGetSandboxStatusByIDParams().WithOrgName(cfg.Org).WithSandboxID(sandboxID) + + spin := spinner.Start(out, "Sandbox status") + defer spin.Stop() + + err := poll.Until(cfg.WaitTimeout, func() bool { + result, err := cfg.Client.Sandboxes.GetSandboxStatusByID(params, nil) + if err != nil { + // Keep retrying in case it's a transient error. + spin.Messagef("error: %v", err) + return false + } + status := result.Payload.Status + if !status.Ready { + spin.Messagef("Not Ready: %s", status.Message) + return false + } + spin.StopMessagef("Ready: %s", status.Message) + return true + }) + if err != nil { + spin.StopFail() + return err + } + return nil +} diff --git a/internal/command/sandbox/delete.go b/internal/command/sandbox/delete.go index 6a7c5d2..1c1817e 100644 --- a/internal/command/sandbox/delete.go +++ b/internal/command/sandbox/delete.go @@ -4,9 +4,12 @@ import ( "errors" "fmt" "io" + "strings" "github.com/signadot/cli/internal/clio" "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/poll" + "github.com/signadot/cli/internal/spinner" "github.com/signadot/go-sdk/client/sandboxes" "github.com/signadot/go-sdk/models" "github.com/spf13/cobra" @@ -79,7 +82,53 @@ func delete(cfg *config.SandboxDelete, out io.Writer, args []string) error { return err } - fmt.Fprintf(out, "Deleted sandbox %q.\n", name) + fmt.Fprintf(out, "Deleted sandbox %q.\n\n", name) + if cfg.Wait { + // Wait for the API server to completely reflect deletion. + if err := waitForDeleted(cfg, out, id); err != nil { + fmt.Fprintf(out, "\nDeletion was initiated, but the sandbox may still exist in a terminating state. To check status, run:\n\n") + fmt.Fprintf(out, " signadot sandbox get-status %v\n\n", name) + return err + } + } + + return nil +} + +func waitForDeleted(cfg *config.SandboxDelete, out io.Writer, sandboxID string) error { + fmt.Fprintf(out, "Waiting (up to --wait-timeout=%v) for sandbox to finish terminating...\n", cfg.WaitTimeout) + + params := sandboxes.NewGetSandboxStatusByIDParams().WithOrgName(cfg.Org).WithSandboxID(sandboxID) + + spin := spinner.Start(out, "Sandbox status") + defer spin.Stop() + + err := poll.Until(cfg.WaitTimeout, func() bool { + result, err := cfg.Client.Sandboxes.GetSandboxStatusByID(params, nil) + if err != nil { + // If it's a "not found" error, that's what we wanted. + // TODO: Pass through an error code so we don't have to rely on the error message. + if strings.Contains(err.Error(), "can't get sandbox status: not found") { + spin.StopMessage("Terminated") + return true + } + + // Otherwise, keep retrying in case it's a transient error. + spin.Messagef("error: %v", err) + return false + } + status := result.Payload.Status + if status.Ready { + spin.Message("Waiting for sandbox to terminate") + return false + } + spin.Messagef("%s: %s", status.Reason, status.Message) + return false + }) + if err != nil { + spin.StopFail() + return err + } return nil } diff --git a/internal/command/sandbox/get.go b/internal/command/sandbox/get.go index 1adfa1c..8267ee1 100644 --- a/internal/command/sandbox/get.go +++ b/internal/command/sandbox/get.go @@ -1,16 +1,14 @@ package sandbox import ( - "encoding/json" "fmt" "io" "github.com/signadot/cli/internal/config" - "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/cli/internal/print" "github.com/signadot/go-sdk/client/sandboxes" "github.com/signadot/go-sdk/models" "github.com/spf13/cobra" - "sigs.k8s.io/yaml" ) func newGet(sandbox *config.Sandbox) *cobra.Command { @@ -50,35 +48,12 @@ func get(cfg *config.SandboxGet, out io.Writer, name string) error { switch cfg.OutputFormat { case config.OutputFormatDefault: - t := sdtab.New[tableRow](out) - t.AddHeader() - row := tableRow{ - Name: sb.Name, - Description: sb.Description, - Cluster: sb.ClusterName, - Created: sb.CreatedAt, - } - t.AddRow(row) - if err := t.Flush(); err != nil { - return err - } + return printSandboxDetails(cfg.Sandbox, out, sb) case config.OutputFormatJSON: - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - if err := enc.Encode(sb); err != nil { - return err - } + return print.RawJSON(out, sb) case config.OutputFormatYAML: - data, err := yaml.Marshal(sb) - if err != nil { - return err - } - if _, err := out.Write(data); err != nil { - return err - } + return print.RawYAML(out, sb) default: return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) } - - return nil } diff --git a/internal/command/sandbox/get_status.go b/internal/command/sandbox/get_status.go new file mode 100644 index 0000000..0b223cf --- /dev/null +++ b/internal/command/sandbox/get_status.go @@ -0,0 +1,72 @@ +package sandbox + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + "github.com/signadot/go-sdk/client/sandboxes" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newGetStatus(sandbox *config.Sandbox) *cobra.Command { + cfg := &config.SandboxGetStatus{Sandbox: sandbox} + + cmd := &cobra.Command{ + Use: "get-status NAME", + Short: "Get sandbox status", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return getStatus(cfg, cmd.OutOrStdout(), args[0]) + }, + } + + return cmd +} + +func getStatus(cfg *config.SandboxGetStatus, out io.Writer, name string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + // TODO: Use GetSandboxByName when it's available. + resp, err := cfg.Client.Sandboxes.GetSandboxes(sandboxes.NewGetSandboxesParams().WithOrgName(cfg.Org), nil) + if err != nil { + return err + } + var sb *models.SandboxInfo + for _, sbinfo := range resp.Payload.Sandboxes { + if sbinfo.Name == name { + sb = sbinfo + break + } + } + if sb == nil { + return fmt.Errorf("Sandbox %q not found", name) + } + + // Get sandbox status. + params := sandboxes.NewGetSandboxStatusByIDParams().WithOrgName(cfg.Org).WithSandboxID(sb.ID) + statusResp, err := cfg.Client.Sandboxes.GetSandboxStatusByID(params, nil) + if err != nil { + return err + } + status := statusResp.Payload.Status + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + ready := "Not Ready" + if status.Ready { + ready = "Ready" + } + fmt.Fprintf(out, "%s: %s\n", ready, status.Message) + return nil + case config.OutputFormatJSON: + return print.RawJSON(out, status) + case config.OutputFormatYAML: + return print.RawYAML(out, status) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/sandbox/list.go b/internal/command/sandbox/list.go index 290a10c..6d796d8 100644 --- a/internal/command/sandbox/list.go +++ b/internal/command/sandbox/list.go @@ -1,15 +1,13 @@ package sandbox import ( - "encoding/json" "fmt" "io" "github.com/signadot/cli/internal/config" - "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/cli/internal/print" "github.com/signadot/go-sdk/client/sandboxes" "github.com/spf13/cobra" - "sigs.k8s.io/yaml" ) func newList(sandbox *config.Sandbox) *cobra.Command { @@ -27,13 +25,6 @@ func newList(sandbox *config.Sandbox) *cobra.Command { return cmd } -type tableRow struct { - Name string `sdtab:"NAME"` - Description string `sdtab:"DESCRIPTION,trunc"` - Cluster string `sdtab:"CLUSTER"` - Created string `sdtab:"CREATED"` -} - func list(cfg *config.SandboxList, out io.Writer) error { if err := cfg.InitAPIConfig(); err != nil { return err @@ -46,37 +37,12 @@ func list(cfg *config.SandboxList, out io.Writer) error { switch cfg.OutputFormat { case config.OutputFormatDefault: - t := sdtab.New[tableRow](out) - t.AddHeader() - for _, sbinfo := range sbs { - row := tableRow{ - Name: sbinfo.Name, - Description: sbinfo.Description, - Cluster: sbinfo.ClusterName, - Created: sbinfo.CreatedAt, - } - t.AddRow(row) - } - if err := t.Flush(); err != nil { - return err - } + return printSandboxTable(out, sbs) case config.OutputFormatJSON: - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - if err := enc.Encode(sbs); err != nil { - return err - } + return print.RawJSON(out, sbs) case config.OutputFormatYAML: - data, err := yaml.Marshal(sbs) - if err != nil { - return err - } - if _, err := out.Write(data); err != nil { - return err - } + return print.RawYAML(out, sbs) default: return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) } - - return nil } diff --git a/internal/command/sandbox/printers.go b/internal/command/sandbox/printers.go new file mode 100644 index 0000000..db19be2 --- /dev/null +++ b/internal/command/sandbox/printers.go @@ -0,0 +1,90 @@ +package sandbox + +import ( + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/docker/go-units" + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/go-sdk/models" +) + +type sandboxRow struct { + Name string `sdtab:"NAME"` + Description string `sdtab:"DESCRIPTION,trunc"` + Cluster string `sdtab:"CLUSTER"` + Created string `sdtab:"CREATED"` +} + +func printSandboxTable(out io.Writer, sbs []*models.SandboxInfo) error { + t := sdtab.New[sandboxRow](out) + t.AddHeader() + for _, sbinfo := range sbs { + t.AddRow(sandboxRow{ + Name: sbinfo.Name, + Description: sbinfo.Description, + Cluster: sbinfo.ClusterName, + Created: sbinfo.CreatedAt, + }) + } + return t.Flush() +} + +func printSandboxDetails(cfg *config.Sandbox, out io.Writer, sb *models.SandboxInfo) error { + tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + + fmt.Fprintf(tw, "ID:\t%s\n", sb.ID) + fmt.Fprintf(tw, "Name:\t%s\n", sb.Name) + fmt.Fprintf(tw, "Description:\t%s\n", sb.Description) + fmt.Fprintf(tw, "Cluster:\t%s\n", sb.ClusterName) + fmt.Fprintf(tw, "Created:\t%s\n", formatTimestamp(sb.CreatedAt)) + fmt.Fprintf(tw, "Dashboard page:\t%s\n", cfg.SandboxDashboardURL(sb.ID)) + + if err := tw.Flush(); err != nil { + return err + } + + if len(sb.PreviewEndpoints) > 0 { + fmt.Fprintln(out) + if err := printEndpointTable(out, sb.PreviewEndpoints); err != nil { + return err + } + } + + return nil +} + +func formatTimestamp(in string) string { + t, err := time.Parse(time.RFC3339, in) + if err != nil { + return in + } + elapsed := units.HumanDuration(time.Since(t)) + local := t.Local().Format(time.RFC1123) + + return fmt.Sprintf("%s (%s ago)", local, elapsed) +} + +type endpointRow struct { + Desc string `sdtab:"PREVIEW ENDPOINT"` + URL string `sdtab:"URL"` +} + +func printEndpointTable(out io.Writer, endpoints []*models.PreviewEndpoint) error { + t := sdtab.New[endpointRow](out) + t.AddHeader() + for _, ep := range endpoints { + desc := ep.Name + if ep.ForkOf != nil { + desc = fmt.Sprintf("Fork of %s/%s", *ep.ForkOf.Namespace, *ep.ForkOf.Name) + } + t.AddRow(endpointRow{ + Desc: desc, + URL: ep.PreviewURL, + }) + } + return t.Flush() +} diff --git a/internal/config/root.go b/internal/config/root.go index 4e8f40d..cb37aeb 100644 --- a/internal/config/root.go +++ b/internal/config/root.go @@ -1,7 +1,10 @@ package config import ( + "fmt" + "net/url" "os" + "path" "path/filepath" "github.com/spf13/cobra" @@ -9,6 +12,9 @@ import ( ) type Root struct { + // Config file values + DashboardURL *url.URL + // Flags Debug bool ConfigFile string @@ -54,5 +60,24 @@ func (c *Root) init() error { c.Debug = viper.GetBool("debug") + if dashURL := viper.GetString("dashboard_url"); dashURL != "" { + u, err := url.Parse(dashURL) + if err != nil { + return fmt.Errorf("invalid dashboard_url: %w", err) + } + c.DashboardURL = u + } else { + c.DashboardURL = &url.URL{ + Scheme: "https", + Host: "app.signadot.com", + } + } + return nil } + +func (c *Root) SandboxDashboardURL(id string) *url.URL { + u := *c.DashboardURL + u.Path = path.Join(u.Path, "sandbox", "id", id) + return &u +} diff --git a/internal/config/sandbox.go b/internal/config/sandbox.go index 82c7d2c..d9563f9 100644 --- a/internal/config/sandbox.go +++ b/internal/config/sandbox.go @@ -1,6 +1,10 @@ package config -import "github.com/spf13/cobra" +import ( + "time" + + "github.com/spf13/cobra" +) type Sandbox struct { *Api @@ -10,13 +14,15 @@ type SandboxCreate struct { *Sandbox // Flags - Filename string - Wait bool + Filename string + Wait bool + WaitTimeout time.Duration } func (c *SandboxCreate) AddFlags(cmd *cobra.Command) { cmd.Flags().StringVarP(&c.Filename, "filename", "f", "", "YAML or JSON file containing the sandbox creation request") cmd.Flags().BoolVar(&c.Wait, "wait", true, "wait for the sandbox status to be Ready before returning") + cmd.Flags().DurationVar(&c.WaitTimeout, "wait-timeout", 5*time.Minute, "timeout when waiting for the sandbox to be Ready") cmd.MarkFlagRequired("filename") } @@ -24,17 +30,25 @@ type SandboxDelete struct { *Sandbox // Flags - Filename string + Filename string + Wait bool + WaitTimeout time.Duration } func (c *SandboxDelete) AddFlags(cmd *cobra.Command) { cmd.Flags().StringVarP(&c.Filename, "filename", "f", "", "optional YAML or JSON file containing the original sandbox creation request") + cmd.Flags().BoolVar(&c.Wait, "wait", true, "wait for the sandbox to finish terminating before returning") + cmd.Flags().DurationVar(&c.WaitTimeout, "wait-timeout", 5*time.Minute, "timeout when waiting for the sandbox to finish terminating") } type SandboxGet struct { *Sandbox } +type SandboxGetStatus struct { + *Sandbox +} + type SandboxList struct { *Sandbox } diff --git a/internal/poll/poll.go b/internal/poll/poll.go new file mode 100644 index 0000000..9c69f18 --- /dev/null +++ b/internal/poll/poll.go @@ -0,0 +1,28 @@ +// Package poll is an opinionated library for polling in the Signadot CLI. +package poll + +import ( + "fmt" + "time" +) + +const ( + pollDelay = 1 * time.Second +) + +// Until polls until the given function returns true, or the timeout expires. +func Until(timeout time.Duration, fn func() bool) error { + start := time.Now() + + for { + if time.Since(start) >= timeout { + return fmt.Errorf("timed out after %v", timeout) + } + + if fn() { + return nil + } + + time.Sleep(pollDelay) + } +} diff --git a/internal/print/raw.go b/internal/print/raw.go new file mode 100644 index 0000000..a8b6b2a --- /dev/null +++ b/internal/print/raw.go @@ -0,0 +1,23 @@ +package print + +import ( + "encoding/json" + "io" + + "sigs.k8s.io/yaml" +) + +func RawJSON(out io.Writer, v any) error { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func RawYAML(out io.Writer, v any) error { + data, err := yaml.Marshal(v) + if err != nil { + return err + } + _, err = out.Write(data) + return err +} diff --git a/internal/sdtab/table.go b/internal/sdtab/table.go index 59b7a78..c3c3b60 100644 --- a/internal/sdtab/table.go +++ b/internal/sdtab/table.go @@ -29,7 +29,7 @@ func (c *column) format(row any) string { return fmt.Sprint(val) } -func truncate(v string, maxWidth int) string { +func Truncate(v string, maxWidth int) string { if utf8.RuneCountInString(v) <= maxWidth { return v } @@ -154,7 +154,7 @@ func (t *T[R]) writeRow(row []string, colWidth []int) error { // Truncate the value, if necessary, and then print it. cw := colWidth[i] - v = truncate(v, cw) + v = Truncate(v, cw) if _, err := fmt.Fprint(t.out, v); err != nil { return err } diff --git a/internal/sdtab/table_test.go b/internal/sdtab/table_test.go index 8b0e592..646766b 100644 --- a/internal/sdtab/table_test.go +++ b/internal/sdtab/table_test.go @@ -61,7 +61,7 @@ func FuzzTruncate(f *testing.F) { wantLen = inLen } - out := truncate(in, truncLen) + out := Truncate(in, truncLen) outLen := utf8.RuneCountInString(out) if outLen > truncLen { t.Errorf("RuneCountInString() = %v; want %v", outLen, wantLen) diff --git a/internal/spinner/spinner.go b/internal/spinner/spinner.go new file mode 100644 index 0000000..27d596f --- /dev/null +++ b/internal/spinner/spinner.go @@ -0,0 +1,95 @@ +// Package spinner displays a progress indicator with a live-updating status message. +// +// If the destination io.Writer is a terminal, the spinner will use color and +// will display progress updates on a single, animated line. Otherwise, color +// and animation will be disabled and each update will be printed on a new line. +package spinner + +import ( + "fmt" + "io" + "os" + "time" + "unicode/utf8" + + "github.com/signadot/cli/internal/sdtab" + "github.com/theckman/yacspin" + "golang.org/x/term" +) + +type T struct { + *yacspin.Spinner + + config *yacspin.Config + // prefixWidth is the width of the fixed prefix before each message line. + prefixWidth int +} + +func New(out io.Writer, title string) *T { + cfg := yacspin.Config{ + Writer: out, + Frequency: 100 * time.Millisecond, + CharSet: yacspin.CharSets[14], + StopCharacter: "✓", + StopColors: []string{"fgGreen"}, + StopFailCharacter: "✗", + StopFailColors: []string{"fgRed"}, + Message: "...", + Suffix: fmt.Sprintf(" %s: ", title), + } + s, err := yacspin.New(cfg) + if err != nil { + panic(err) + } + return &T{ + Spinner: s, + config: &cfg, + prefixWidth: utf8.RuneCountInString(cfg.CharSet[0] + cfg.Suffix), + } +} + +// Start creates a new spinner and starts it immediately. +func Start(out io.Writer, title string) *T { + s := New(out, title) + if err := s.Start(); err != nil { + panic(err) + } + return s +} + +func (t *T) Message(msg string) { + // Whenever we update the message, also set that value as the one to display + // if we fail. That way we default to showing the most recent value on error. + t.Spinner.StopFailMessage(msg) + + // See if we need to truncate the message to avoid wrapping to a second line. + // The spinner currently doesn't support such wrapping. + termWidth := t.termWidth() + if termWidth > t.prefixWidth { + msg = sdtab.Truncate(msg, termWidth-t.prefixWidth) + } + + // Set the latest message in the underlying spinner. + t.Spinner.Message(msg) +} + +func (t *T) Messagef(format string, args ...interface{}) { + t.Message(fmt.Sprintf(format, args...)) +} + +func (t *T) StopMessagef(format string, args ...interface{}) { + t.StopMessage(fmt.Sprintf(format, args...)) +} + +// termWidth returns the terminal width, if known, or 0 if unknown. +func (t *T) termWidth() int { + file, ok := t.config.Writer.(*os.File) + if !ok { + return 0 + } + width, _, err := term.GetSize(int(file.Fd())) + if err != nil { + return 0 + } + return width +}