diff --git a/ci/integration/tags_test.go b/ci/integration/tags_test.go new file mode 100644 index 00000000..2fb7dfe0 --- /dev/null +++ b/ci/integration/tags_test.go @@ -0,0 +1,36 @@ +package integration + +import ( + "context" + "testing" + + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/pkg/tcli" + "cdr.dev/slog/sloggers/slogtest/assert" +) + +func TestTags(t *testing.T) { + t.Parallel() + t.Skip("wait for dedicated test cluster so we can create an org") + run(t, "tags-cli-tests", func(t *testing.T, ctx context.Context, c *tcli.ContainerRunner) { + headlessLogin(ctx, t, c) + client := cleanupClient(ctx, t) + + ensureImageImported(ctx, t, client, "ubuntu") + + c.Run(ctx, "coder tags ls").Assert(t, + tcli.Error(), + ) + c.Run(ctx, "coder tags ls --image ubuntu --org default").Assert(t, + tcli.Success(), + ) + var tags []coder.ImageTag + c.Run(ctx, "coder tags ls --image ubuntu --org default --output json").Assert(t, + tcli.Success(), + tcli.StdoutJSONUnmarshal(&tags), + ) + assert.True(t, "> 0 tags", len(tags) > 0) + + // TODO(@cmoog) add create and rm integration tests + }) +} diff --git a/coder-sdk/tags.go b/coder-sdk/tags.go new file mode 100644 index 00000000..8c7282a9 --- /dev/null +++ b/coder-sdk/tags.go @@ -0,0 +1,68 @@ +package coder + +import ( + "context" + "net/http" + "time" +) + +// ImageTag is a Docker image tag. +type ImageTag struct { + ImageID string `json:"image_id" table:"-"` + Tag string `json:"tag" table:"Tag"` + LatestHash string `json:"latest_hash" table:"-"` + HashLastUpdatedAt time.Time `json:"hash_last_updated_at" table:"-"` + OSRelease *OSRelease `json:"os_release" table:"OS"` + Environments []*Environment `json:"environments" table:"-"` + UpdatedAt time.Time `json:"updated_at" table:"UpdatedAt"` + CreatedAt time.Time `json:"created_at" table:"-"` +} + +// OSRelease is the marshalled /etc/os-release file. +type OSRelease struct { + ID string `json:"id"` + PrettyName string `json:"pretty_name"` + HomeURL string `json:"home_url"` +} + +func (o OSRelease) String() string { + return o.PrettyName +} + +// CreateImageTagReq defines the request parameters for creating a new image tag. +type CreateImageTagReq struct { + Tag string `json:"tag"` + Default bool `json:"default"` +} + +// CreateImageTag creates a new image tag resource. +func (c Client) CreateImageTag(ctx context.Context, imageID string, req CreateImageTagReq) (*ImageTag, error) { + var tag ImageTag + if err := c.requestBody(ctx, http.MethodPost, "/api/images/"+imageID+"/tags", req, tag); err != nil { + return nil, err + } + return &tag, nil +} + +// DeleteImageTag deletes an image tag resource. +func (c Client) DeleteImageTag(ctx context.Context, imageID, tag string) error { + return c.requestBody(ctx, http.MethodDelete, "/api/images/"+imageID+"/tags/"+tag, nil, nil) +} + +// ImageTags fetch all image tags. +func (c Client) ImageTags(ctx context.Context, imageID string) ([]ImageTag, error) { + var tags []ImageTag + if err := c.requestBody(ctx, http.MethodGet, "/api/images/"+imageID+"/tags", nil, &tags); err != nil { + return nil, err + } + return tags, nil +} + +// ImageTagByID fetch an image tag by ID. +func (c Client) ImageTagByID(ctx context.Context, imageID, tagID string) (*ImageTag, error) { + var tag ImageTag + if err := c.requestBody(ctx, http.MethodGet, "/api/images/"+imageID+"/tags/"+tagID, nil, &tag); err != nil { + return nil, err + } + return &tag, nil +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 937c0558..e7b8aa63 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -26,6 +26,7 @@ func Make() *cobra.Command { logoutCmd(), shCmd(), usersCmd(), + tagsCmd(), configSSHCmd(), secretsCmd(), envsCmd(), diff --git a/internal/cmd/tags.go b/internal/cmd/tags.go new file mode 100644 index 00000000..ad77932d --- /dev/null +++ b/internal/cmd/tags.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "encoding/json" + "os" + + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/pkg/clog" + "cdr.dev/coder-cli/pkg/tablewriter" + "github.com/spf13/cobra" + "golang.org/x/xerrors" +) + +func tagsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tags", + Hidden: true, + Short: "operate on Coder image tags", + } + + cmd.AddCommand( + tagsLsCmd(), + tagsCreateCmd(), + tagsRmCmd(), + ) + return cmd +} + +func tagsCreateCmd() *cobra.Command { + var ( + orgName string + imageName string + defaultTag bool + ) + cmd := &cobra.Command{ + Use: "create [tag]", + Short: "add an image tag", + Long: "allow users to create environments with this image tag", + Example: `coder tags create latest --image ubuntu --org default`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + img, err := findImg(ctx, client, findImgConf{ + orgName: orgName, + imgName: imageName, + email: coder.Me, + }) + if err != nil { + return xerrors.Errorf("find image: %w", err) + } + + _, err = client.CreateImageTag(ctx, img.ID, coder.CreateImageTagReq{ + Tag: args[0], + Default: defaultTag, + }) + if err != nil { + return xerrors.Errorf("create image tag: %w", err) + } + clog.LogSuccess("created new tag") + + return nil + }, + } + + cmd.Flags().StringVarP(&imageName, "image", "i", "", "image name") + cmd.Flags().StringVarP(&orgName, "org", "o", "", "organization name") + cmd.Flags().BoolVar(&defaultTag, "default", false, "make this tag the default for its image") + _ = cmd.MarkFlagRequired("org") + _ = cmd.MarkFlagRequired("image") + return cmd +} + +func tagsLsCmd() *cobra.Command { + var ( + orgName string + imageName string + outputFmt string + ) + cmd := &cobra.Command{ + Use: "ls", + Example: `coder tags ls --image ubuntu --org default --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + + img, err := findImg(ctx, client, findImgConf{ + email: coder.Me, + orgName: orgName, + imgName: imageName, + }) + if err != nil { + return err + } + + tags, err := client.ImageTags(ctx, img.ID) + if err != nil { + return err + } + + switch outputFmt { + case humanOutput: + err = tablewriter.WriteTable(len(tags), func(i int) interface{} { return tags[i] }) + if err != nil { + return err + } + case jsonOutput: + err := json.NewEncoder(os.Stdout).Encode(tags) + if err != nil { + return err + } + default: + return clog.Error("unknown --output value") + } + + return nil + }, + } + cmd.Flags().StringVar(&orgName, "org", "", "organization by name") + cmd.Flags().StringVarP(&imageName, "image", "i", "", "image by name") + cmd.Flags().StringVar(&outputFmt, "output", humanOutput, "output format (human|json)") + _ = cmd.MarkFlagRequired("image") + _ = cmd.MarkFlagRequired("org") + return cmd +} + +func tagsRmCmd() *cobra.Command { + var ( + imageName string + orgName string + ) + cmd := &cobra.Command{ + Use: "rm [tag]", + Short: "remove an image tag", + Example: `coder tags rm latest --image ubuntu --org default`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient(ctx) + if err != nil { + return err + } + + img, err := findImg(ctx, client, findImgConf{ + email: coder.Me, + imgName: imageName, + orgName: orgName, + }) + if err != nil { + return err + } + + if err = client.DeleteImageTag(ctx, img.ID, args[0]); err != nil { + return err + } + clog.LogSuccess("removed tag") + + return nil + }, + } + cmd.Flags().StringVarP(&orgName, "org", "o", "", "organization by name") + cmd.Flags().StringVarP(&imageName, "image", "i", "", "image by name") + _ = cmd.MarkFlagRequired("image") + _ = cmd.MarkFlagRequired("org") + return cmd +}