diff --git a/cmd/saucectl/saucectl.go b/cmd/saucectl/saucectl.go index 0629c631e..09e214d50 100644 --- a/cmd/saucectl/saucectl.go +++ b/cmd/saucectl/saucectl.go @@ -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" @@ -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 { diff --git a/go.mod b/go.mod index 3c5f56971..13734aa35 100644 --- a/go.mod +++ b/go.mod @@ -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 ( @@ -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 diff --git a/go.sum b/go.sum index 692a6c857..18225b3d5 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= diff --git a/internal/cmd/docker/cmd.go b/internal/cmd/docker/cmd.go new file mode 100644 index 000000000..59b9736df --- /dev/null +++ b/internal/cmd/docker/cmd.go @@ -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(®io, "region", "r", "us-west-1", "The Sauce Labs region to login. Options: us-west-1, eu-central-1.") + + cmd.AddCommand( + PushCommand(), + ) + + return cmd +} diff --git a/internal/cmd/docker/push.go b/internal/cmd/docker/push.go new file mode 100644 index 000000000..562ca942f --- /dev/null +++ b/internal/cmd/docker/push.go @@ -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 ", + 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") +} diff --git a/internal/http/imagerunner.go b/internal/http/imagerunner.go index 9005c3152..ce5d52cd5 100644 --- a/internal/http/imagerunner.go +++ b/internal/http/imagerunner.go @@ -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), @@ -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 +}