Skip to content

Commit

Permalink
feat: add saucectl docker push cmd (#860)
Browse files Browse the repository at this point in the history
* feat: add saucectl docker push cmd

* refine

* refine

* make it work: login also requires repo name

* mv region to login-region to reduce confusion

* rename

* Update internal/cmd/docker/cmd.go

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>

* Update internal/cmd/docker/push.go

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>

* Update internal/cmd/docker/cmd.go

Co-authored-by: Mike Han <56001373+mhan83@users.noreply.github.com>

* update according to comments

* Update internal/cmd/docker/push.go

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>

* Update internal/http/docker.go

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>

* move docker client into imagerunner

* use regex to verify image name and extract repo name

* give more time

* 🔪

* Update internal/cmd/docker/push.go

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>

* still use repo args and unify commands prompt

* add repo name check back

* remove default value in description

* extract repo name

* 🔪

* update err msg

---------

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>
Co-authored-by: Mike Han <56001373+mhan83@users.noreply.github.com>
  • Loading branch information
3 people authored Dec 1, 2023
1 parent b0864c3 commit b5c3b6f
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 1 deletion.
2 changes: 2 additions & 0 deletions cmd/saucectl/saucectl.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/saucelabs/saucectl/internal/cmd/artifacts"
"github.com/saucelabs/saucectl/internal/cmd/completion"
"github.com/saucelabs/saucectl/internal/cmd/configure"
"github.com/saucelabs/saucectl/internal/cmd/docker"
"github.com/saucelabs/saucectl/internal/cmd/imagerunner"
"github.com/saucelabs/saucectl/internal/cmd/ini"
"github.com/saucelabs/saucectl/internal/cmd/jobs"
Expand Down Expand Up @@ -68,6 +69,7 @@ func main() {
jobs.Command(cmd.PersistentPreRun),
imagerunner.Command(cmd.PersistentPreRun),
apit.Command(cmd.PersistentPreRun),
docker.Command(cmd.PersistentPreRun),
)

if err := cmd.Execute(); err != nil {
Expand Down
12 changes: 11 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,27 @@ require (
github.com/spf13/viper v1.14.0
github.com/stretchr/testify v1.8.1
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
golang.org/x/mod v0.6.0
golang.org/x/mod v0.8.0
gopkg.in/segmentio/analytics-go.v3 v3.1.0
gopkg.in/yaml.v2 v2.4.0
gotest.tools/v3 v3.0.3
)

require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
golang.org/x/tools v0.6.0 // indirect
)

require (
Expand All @@ -48,6 +57,7 @@ require (
require (
github.com/Masterminds/semver/v3 v3.1.1
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/docker v24.0.7+incompatible
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.0.0 // indirect
Expand Down
21 changes: 21 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
Expand Down Expand Up @@ -79,6 +81,16 @@ 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/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
Expand Down Expand Up @@ -113,6 +125,7 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
Expand Down Expand Up @@ -285,6 +298,10 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
Expand Down Expand Up @@ -441,6 +458,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
Expand Down Expand Up @@ -639,6 +658,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
51 changes: 51 additions & 0 deletions internal/cmd/docker/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package docker

import (
"errors"
"time"

"github.com/saucelabs/saucectl/internal/http"
"github.com/saucelabs/saucectl/internal/region"
"github.com/spf13/cobra"
)

var (
imageRunnerService http.ImageRunner
imageRunnerServiceTimeout = 1 * time.Minute
)

func Command(preRun func(cmd *cobra.Command, args []string)) *cobra.Command {
var regio string

cmd := &cobra.Command{
Use: "docker",
Short: "Interact with Sauce Container Registry",
SilenceUsage: true,
TraverseChildren: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if preRun != nil {
preRun(cmd, args)
}

reg := region.FromString(regio)
if reg == region.None {
return errors.New("invalid region: must be one of [us-west-1, eu-central-1]")
}

creds := reg.Credentials()
url := reg.APIBaseURL()
imageRunnerService = http.NewImageRunner(url, creds, imageRunnerServiceTimeout)

return nil
},
}

flags := cmd.PersistentFlags()
flags.StringVarP(&regio, "region", "r", "us-west-1", "The Sauce Labs region to login. Options: us-west-1, eu-central-1.")

cmd.AddCommand(
PushCommand(),
)

return cmd
}
120 changes: 120 additions & 0 deletions internal/cmd/docker/push.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package docker

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
cmds "github.com/saucelabs/saucectl/internal/cmd"
"github.com/saucelabs/saucectl/internal/segment"
"github.com/saucelabs/saucectl/internal/usage"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

func PushCommand() *cobra.Command {
var timeout time.Duration
var quiet bool

cmd := &cobra.Command{
Use: "push <image_name>",
Short: "Push a Docker image to the Sauce Labs Container Registry.",
SilenceUsage: true,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" {
return errors.New("no docker image specified")
}

return nil
},
PreRun: func(cmd *cobra.Command, args []string) {
tracker := segment.DefaultTracker

go func() {
tracker.Collect(
cases.Title(language.English).String(cmds.FullName(cmd)),
usage.Properties{}.SetFlags(cmd.Flags()),
)
_ = tracker.Close()
}()
},
RunE: func(cmd *cobra.Command, args []string) error {
image := args[0]
repo, err := extractRepo(image)
if err != nil {
return err
}
auth, err := imageRunnerService.RegistryLogin(context.Background(), repo)
if err != nil {
return fmt.Errorf("failed to fetch auth token: %v", err)
}
return pushDockerImage(image, auth.Username, auth.Password, timeout, quiet)
},
}

flags := cmd.PersistentFlags()
flags.DurationVar(&timeout, "timeout", 5*time.Minute, "Configure the timeout duration for docker push.")
flags.BoolVar(&quiet, "quiet", false, "Run silently, suppressing output messages.")

return cmd
}

func pushDockerImage(imageName, username, password string, timeout time.Duration, quiet bool) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return fmt.Errorf("failed to create docker client: %v", err)
}

authConfig := registry.AuthConfig{
Username: username,
Password: password,
}

authBytes, err := json.Marshal(authConfig)
if err != nil {
return fmt.Errorf("failed to marshal docker auth: %v", err)
}
authBase64 := base64.URLEncoding.EncodeToString(authBytes)

// Push the image to the registry
pushOptions := types.ImagePushOptions{RegistryAuth: authBase64}
out, err := cli.ImagePush(ctx, imageName, pushOptions)
if err != nil {
return fmt.Errorf("failed to push image: %v", err)
}
defer out.Close()

if quiet {
return nil
}

// Print the push output
_, err = io.Copy(os.Stdout, out)
if err != nil {
return fmt.Errorf("docker output: %v", err)
}

return nil
}

func extractRepo(input string) (string, error) {
// Example: us-west4-docker.pkg.dev/sauce-hto-p-jy6b/sauce-devx-team-sauce/ubuntu:experiment
items := strings.Split(input, "/")
if len(items) >= 3 {
return items[2], nil
}
return "", fmt.Errorf("unable to extract repo name from docker image")
}
39 changes: 39 additions & 0 deletions internal/http/imagerunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ type ImageRunner struct {
Creds iam.Credentials
}

type AuthToken struct {
ExpiresAt time.Time `json:"expires_at"`
Username string `json:"username"`
Password string `json:"password"`
}

func NewImageRunner(url string, creds iam.Credentials, timeout time.Duration) ImageRunner {
return ImageRunner{
Client: NewRetryableClient(timeout),
Expand Down Expand Up @@ -241,3 +247,36 @@ func (c *ImageRunner) newServerError(status int, short string, body []byte) erro

return &se
}

func (c *ImageRunner) RegistryLogin(ctx context.Context, repo string) (AuthToken, error) {
url := fmt.Sprintf("%s/v1alpha1/hosted/container-registry/%s/authorization-token", c.URL, repo)

var authToken AuthToken
req, err := NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return authToken, err
}
req.SetBasicAuth(c.Creds.Username, c.Creds.AccessKey)

r, err := retryablehttp.FromRequest(req)
if err != nil {
return authToken, err
}

resp, err := c.Client.Do(r)
if err != nil {
return authToken, err
}
defer resp.Body.Close()

data, err := io.ReadAll(resp.Body)
if err != nil {
return authToken, err
}
if resp.StatusCode != 200 {
return authToken, fmt.Errorf("unexpected status code: %d, response: %s", resp.StatusCode, string(data))
}

err = json.Unmarshal(data, &authToken)
return authToken, err
}

0 comments on commit b5c3b6f

Please sign in to comment.