diff --git a/ci/integration/devurls_test.go b/ci/integration/devurls_test.go new file mode 100644 index 00000000..09f1c0e4 --- /dev/null +++ b/ci/integration/devurls_test.go @@ -0,0 +1,94 @@ +package integration + +import ( + "context" + "testing" + "time" + + "cdr.dev/coder-cli/ci/tcli" + "cdr.dev/slog/sloggers/slogtest/assert" +) + +func TestDevURLCLI(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + defer cancel() + + c, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ + Image: "codercom/enterprise-dev", + Name: "coder-cli-devurl-tests", + BindMounts: map[string]string{ + binpath: "/bin/coder", + }, + }) + assert.Success(t, "new run container", err) + defer c.Close() + + c.Run(ctx, "which coder").Assert(t, + tcli.Success(), + tcli.StdoutMatches("/usr/sbin/coder"), + tcli.StderrEmpty(), + ) + + c.Run(ctx, "coder urls ls").Assert(t, + tcli.Error(), + ) + + // The following cannot be enabled nor verified until either the + // integration testing dogfood target has environments created, or + // we implement the 'env create' command for coder-cli to create our + // own here. + + // If we were to create an env ourselves ... we could test devurls something like + + // // == Login + // headlessLogin(ctx, t, c) + + // // == urls ls should fail w/o supplying an envname + // c.Run(ctx, "coder urls ls").Assert(t, + // tcli.Error(), + // ) + + // // == env creation should succeed + // c.Run(ctx, "coder envs create env1 --from image1 --cores 1 --ram 2gb --disk 10gb --nogpu").Assert(t, + // tcli.Success()) + + // // == urls ls should succeed for a newly-created environment + // var durl entclient.DevURL + // c.Run(ctx, `coder urls ls -o json`).Assert(t, + // tcli.Success(), + // jsonUnmarshals(&durl), // though if a new env, durl should be empty + // ) + + // // == devurl creation w/default PRIVATE access + // c.Run(ctx, `coder urls create env1 3000`).Assert(t, + // tcli.Success()) + + // // == devurl create w/access == AUTHED + // c.Run(ctx, `coder urls create env1 3001 --access=AUTHED`).Assert(t, + // tcli.Success()) + + // // == devurl create with name + // c.Run(ctx, `coder urls create env1 3002 --access=PUBLIC --name=foobar`).Assert(t, + // tcli.Success()) + + // // == devurl ls should return well-formed entries incl. one with AUTHED access + // c.Run(ctx, `coder urls ls env1 -o json | jq -c '.[] | select( .access == "AUTHED")'`).Assert(t, + // tcli.Success(), + // jsonUnmarshals(&durl)) + + // // == devurl ls should return well-formed entries incl. one with name 'foobar' + // c.Run(ctx, `coder urls ls env1 -o json | jq -c '.[] | select( .name == "foobar")'`).Assert(t, + // tcli.Success(), + // jsonUnmarshals(&durl)) + + // // == devurl rm should function + // c.Run(ctx, `coder urls rm env1 3002`).Assert(t, + // tcli.Success()) + + // // == removed devurl should no longer be there + // c.Run(ctx, `coder urls ls env1 -o json | jq -c '.[] | select( .name == "foobar")'`).Assert(t, + // tcli.Error(), + // jsonUnmarshals(&durl)) + +} diff --git a/cmd/coder/urls.go b/cmd/coder/urls.go index 7626ae93..582fbdb1 100644 --- a/cmd/coder/urls.go +++ b/cmd/coder/urls.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "os" + "regexp" "strconv" "strings" "text/tabwriter" @@ -17,12 +18,33 @@ import ( type urlsCmd struct{} +func (cmd *urlsCmd) Subcommands() []cli.Command { + return []cli.Command{ + &listSubCmd{}, + &createSubCmd{}, + &delSubCmd{}, + } +} + +func (cmd urlsCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "urls", + Usage: "[subcommand] ", + Desc: "interact with environment devurls", + } +} + +func (cmd urlsCmd) Run(fl *pflag.FlagSet) { + exitUsage(fl) +} + // DevURL is the parsed json response record for a devURL from cemanager type DevURL struct { ID string `json:"id"` URL string `json:"url"` - Port string `json:"port"` + Port int `json:"port"` Access string `json:"access"` + Name string `json:"name"` } var urlAccessLevel = map[string]string{ @@ -33,55 +55,110 @@ var urlAccessLevel = map[string]string{ "PUBLIC": "Anyone on the internet can access this link", } -func portIsValid(port string) bool { +func validatePort(port string) (int, error) { p, err := strconv.ParseUint(port, 10, 16) + if err != nil { + flog.Error("Invalid port") + return 0, err + } if p < 1 { // port 0 means 'any free port', which we don't support err = strconv.ErrRange + flog.Error("Port must be > 0") + return 0, err } - if err != nil { - fmt.Println("Invalid port") - } - return err == nil + return int(p), nil } func accessLevelIsValid(level string) bool { _, ok := urlAccessLevel[level] if !ok { - fmt.Println("Invalid access level") + flog.Error("Invalid access level") } return ok } +type listSubCmd struct { + outputFmt string +} + +// Run gets the list of active devURLs from the cemanager for the +// specified environment and outputs info to stdout. +func (sub listSubCmd) Run(fl *pflag.FlagSet) { + envName := fl.Arg(0) + devURLs := urlList(envName) + + if len(devURLs) == 0 { + return + } + + switch sub.outputFmt { + case "human": + w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) + for _, devURL := range devURLs { + fmt.Fprintf(w, "%s\t%d\t%s\n", devURL.URL, devURL.Port, devURL.Access) + } + err := w.Flush() + requireSuccess(err, "failed to flush writer: %v", err) + case "json": + err := json.NewEncoder(os.Stdout).Encode(devURLs) + requireSuccess(err, "failed to encode devurls to json: %v", err) + default: + exitUsage(fl) + } +} + +func (sub *listSubCmd) RegisterFlags(fl *pflag.FlagSet) { + fl.StringVarP(&sub.outputFmt, "output", "o", "human", "output format (human | json)") +} + +func (sub *listSubCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "ls", + Usage: " ", + Desc: "list all devurls for a given environment", + } +} + type createSubCmd struct { - access string + access string + urlname string } func (sub *createSubCmd) RegisterFlags(fl *pflag.FlagSet) { fl.StringVarP(&sub.access, "access", "a", "private", "[private | org | authed | public] set devurl access") + fl.StringVarP(&sub.urlname, "name", "n", "", "devurl name") } func (sub createSubCmd) Spec() cli.CommandSpec { return cli.CommandSpec{ - Name: "create", - Usage: " [--access ]", - Desc: "create/update a devurl for external access", + Name: "create", + Usage: " [--access ] [--name ]", + Aliases: []string{"edit"}, + Desc: "create or update a devurl for external access", } } +// devURLNameValidRx is the regex used to validate devurl names specified +// via the --name subcommand. Named devurls must begin with a letter, and +// consist solely of letters and digits, with a max length of 64 chars. +var devURLNameValidRx = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9]{0,63}$") + // Run creates or updates a devURL, specified by env ID and port // (fl.Arg(0) and fl.Arg(1)), with access level (fl.Arg(2)) on // the cemanager. func (sub createSubCmd) Run(fl *pflag.FlagSet) { envName := fl.Arg(0) port := fl.Arg(1) - access := fl.Arg(2) + name := fl.Arg(2) + access := fl.Arg(3) if envName == "" { exitUsage(fl) } - if !portIsValid(port) { + portNum, err := validatePort(port) + if err != nil { exitUsage(fl) } @@ -90,20 +167,24 @@ func (sub createSubCmd) Run(fl *pflag.FlagSet) { exitUsage(fl) } + name = sub.urlname + if name != "" && !devURLNameValidRx.MatchString(name) { + flog.Fatal("update devurl: name must be < 64 chars in length, begin with a letter and only contain letters or digits.") + return + } entClient := requireAuth() env := findEnv(entClient, envName) - _, found := devURLID(port, urlList(envName)) + urlID, found := devURLID(portNum, urlList(envName)) if found { - fmt.Printf("Updating devurl for port %v\n", port) + flog.Info("Updating devurl for port %v", port) + err := entClient.UpdateDevURL(env.ID, urlID, portNum, name, access) + requireSuccess(err, "update devurl: %s", err) } else { - fmt.Printf("Adding devurl for port %v\n", port) - } - - err := entClient.UpsertDevURL(env.ID, port, access) - if err != nil { - flog.Error("upsert devurl: %s", err.Error()) + flog.Info("Adding devurl for port %v", port) + err := entClient.InsertDevURL(env.ID, portNum, name, access) + requireSuccess(err, "insert devurl: %s", err) } } @@ -111,15 +192,16 @@ type delSubCmd struct{} func (sub delSubCmd) Spec() cli.CommandSpec { return cli.CommandSpec{ - Name: "del", + Name: "rm", Usage: " ", - Desc: "delete a devurl", + Desc: "remove a devurl", } } -// devURLID returns the ID of a devURL, given the env name and port. +// devURLID returns the ID of a devURL, given the env name and port +// from a list of DevURL records. // ("", false) is returned if no match is found. -func devURLID(port string, urls []DevURL) (string, bool) { +func devURLID(port int, urls []DevURL) (string, bool) { for _, url := range urls { if url.Port == port { return url.ID, true @@ -137,33 +219,23 @@ func (sub delSubCmd) Run(fl *pflag.FlagSet) { exitUsage(fl) } - if !portIsValid(port) { + portNum, err := validatePort(port) + if err != nil { exitUsage(fl) } entClient := requireAuth() - env := findEnv(entClient, envName) - urlID, found := devURLID(port, urlList(envName)) + urlID, found := devURLID(portNum, urlList(envName)) if found { - fmt.Printf("Deleting devurl for port %v\n", port) + flog.Info("Deleting devurl for port %v", port) } else { flog.Fatal("No devurl found for port %v", port) } - err := entClient.DelDevURL(env.ID, urlID) - if err != nil { - flog.Error("delete devurl: %s", err.Error()) - } -} - -func (cmd urlsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "urls", - Usage: "", - Desc: "get all development urls for external access", - } + err = entClient.DelDevURL(env.ID, urlID) + requireSuccess(err, "delete devurl: %s", err) } // urlList returns the list of active devURLs from the cemanager. @@ -175,9 +247,7 @@ func urlList(envName string) []DevURL { reqURL := fmt.Sprintf(reqString, entClient.BaseURL, env.ID, entClient.Token) resp, err := http.Get(reqURL) - if err != nil { - flog.Fatal("%v", err) - } + requireSuccess(err, "%v", err) defer resp.Body.Close() if resp.StatusCode != 200 { @@ -188,33 +258,7 @@ func urlList(envName string) []DevURL { devURLs := make([]DevURL, 0) err = dec.Decode(&devURLs) - if err != nil { - flog.Fatal("%v", err) - } - - if len(devURLs) == 0 { - fmt.Printf("no dev urls were found for environment: %s\n", envName) - } + requireSuccess(err, "%v", err) return devURLs } - -// Run gets the list of active devURLs from the cemanager for the -// specified environment and outputs info to stdout. -func (cmd urlsCmd) Run(fl *pflag.FlagSet) { - envName := fl.Arg(0) - devURLs := urlList(envName) - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) - for _, devURL := range devURLs { - fmt.Fprintf(w, "%s\t%s\t%s\n", devURL.URL, devURL.Port, devURL.Access) - } - w.Flush() -} - -func (cmd *urlsCmd) Subcommands() []cli.Command { - return []cli.Command{ - &createSubCmd{}, - &delSubCmd{}, - } -} diff --git a/go.mod b/go.mod index 4f496165..71f90aa2 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 github.com/spf13/pflag v1.0.5 - go.coder.com/cli v0.4.0 + go.coder.com/cli v0.5.0 go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 golang.org/x/crypto v0.0.0-20200422194213-44a606286825 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a diff --git a/go.sum b/go.sum index 37756a28..cc2cac0b 100644 --- a/go.sum +++ b/go.sum @@ -163,6 +163,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= @@ -171,6 +173,8 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= go.coder.com/cli v0.4.0 h1:PruDGwm/CPFndyK/eMowZG3vzg5CgohRWeXWCTr3zi8= go.coder.com/cli v0.4.0/go.mod h1:hRTOURCR3LJF1FRW9arecgrzX+AHG7mfYMwThPIgq+w= +go.coder.com/cli v0.5.0 h1:7W9ECtZdVKaAc0Oe2uk5J/c0LCtsWufQz4NeX6YwP0g= +go.coder.com/cli v0.5.0/go.mod h1:h6091Eox0VdgJw2CDBvTyx7SnhduTm8qYM2bR2pewls= go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 h1:DjCS6dRQh+1PlfiBmnabxfdrzenb0tAwJqFxDEH/s9g= go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512/go.mod h1:83JsYgXYv0EOaXjIMnaZ1Fl6ddNB3fJnDZ/8845mUJ8= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -300,6 +304,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/entclient/devurl.go b/internal/entclient/devurl.go index 9260dcce..b2d34025 100644 --- a/internal/entclient/devurl.go +++ b/internal/entclient/devurl.go @@ -5,18 +5,33 @@ import ( "net/http" ) +// DevURL is the parsed json response record for a devURL from cemanager +type DevURL struct { + ID string `json:"id"` + URL string `json:"url"` + Port int `json:"port"` + Access string `json:"access"` + Name string `json:"name"` +} + +type delDevURLRequest struct { + EnvID string `json:"environment_id"` + DevURLID string `json:"url_id"` +} + // DelDevURL deletes the specified devurl func (c Client) DelDevURL(envID, urlID string) error { reqString := "/api/environments/%s/devurls/%s" reqURL := fmt.Sprintf(reqString, envID, urlID) - res, err := c.request("DELETE", reqURL, map[string]string{ - "environment_id": envID, - "url_id": urlID, + res, err := c.request("DELETE", reqURL, delDevURLRequest{ + EnvID: envID, + DevURLID: urlID, }) if err != nil { return err } + defer res.Body.Close() if res.StatusCode != http.StatusOK { return bodyError(res) @@ -25,19 +40,53 @@ func (c Client) DelDevURL(envID, urlID string) error { return nil } -// UpsertDevURL upserts the specified devurl for the authenticated user -func (c Client) UpsertDevURL(envID, port, access string) error { +type createDevURLRequest struct { + EnvID string `json:"environment_id"` + Port int `json:"port"` + Access string `json:"access"` + Name string `json:"name"` +} + +// InsertDevURL inserts a new devurl for the authenticated user +func (c Client) InsertDevURL(envID string, port int, name, access string) error { reqString := "/api/environments/%s/devurls" reqURL := fmt.Sprintf(reqString, envID) - res, err := c.request("POST", reqURL, map[string]string{ - "environment_id": envID, - "port": port, - "access": access, + res, err := c.request("POST", reqURL, createDevURLRequest{ + EnvID: envID, + Port: port, + Access: access, + Name: name, + }) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return bodyError(res) + } + + return nil +} + +type updateDevURLRequest createDevURLRequest + +// UpdateDevURL updates an existing devurl for the authenticated user +func (c Client) UpdateDevURL(envID, urlID string, port int, name, access string) error { + reqString := "/api/environments/%s/devurls/%s" + reqURL := fmt.Sprintf(reqString, envID, urlID) + + res, err := c.request("PUT", reqURL, updateDevURLRequest{ + EnvID: envID, + Port: port, + Access: access, + Name: name, }) if err != nil { return err } + defer res.Body.Close() if res.StatusCode != http.StatusOK { return bodyError(res) diff --git a/internal/entclient/error.go b/internal/entclient/error.go index 877085f2..9170efa9 100644 --- a/internal/entclient/error.go +++ b/internal/entclient/error.go @@ -18,7 +18,7 @@ type apiError struct { } func bodyError(resp *http.Response) error { - byt, err := httputil.DumpResponse(resp, false) + byt, err := httputil.DumpResponse(resp, true) if err != nil { return xerrors.Errorf("dump response: %w", err) }