From c7f33342ea79f82a50c91bbc09a7a06bf86adea2 Mon Sep 17 00:00:00 2001 From: Richard LT <richard.le.terrier@gmail.com> Date: Fri, 27 Aug 2021 09:48:29 +0200 Subject: [PATCH] feat(hatchery:swarm): login on private registry (#5908) --- engine/config.go | 3 + engine/hatchery/swarm/swarm_util_pull.go | 68 ++++++++++++--- engine/hatchery/swarm/swarm_util_pull_test.go | 85 +++++++++++++++++++ engine/hatchery/swarm/types.go | 8 ++ engine/service/types.go | 2 +- 5 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 engine/hatchery/swarm/swarm_util_pull_test.go diff --git a/engine/config.go b/engine/config.go index 66e8e18825..ed4be4ae2d 100644 --- a/engine/config.go +++ b/engine/config.go @@ -123,6 +123,9 @@ func configBootstrap(args []string) Configuration { } conf.Hatchery.Swarm.Name = "cds-hatchery-swarm-" + namesgenerator.GetRandomNameCDS(0) conf.Hatchery.Swarm.HTTP.Port = 8086 + conf.Hatchery.Swarm.RegistryCredentials = []swarm.RegistryCredential{{ + Domain: "docker.io", + }} case sdk.TypeHatchery + ":vsphere": conf.Hatchery.VSphere = &vsphere.HatcheryConfiguration{} defaults.SetDefaults(conf.Hatchery.VSphere) diff --git a/engine/hatchery/swarm/swarm_util_pull.go b/engine/hatchery/swarm/swarm_util_pull.go index 72a0f2dae5..2a89ac9124 100644 --- a/engine/hatchery/swarm/swarm_util_pull.go +++ b/engine/hatchery/swarm/swarm_util_pull.go @@ -3,16 +3,18 @@ package swarm import ( "bytes" "encoding/base64" - "fmt" + "encoding/json" "io" "net/url" + "regexp" "time" - "github.com/ovh/cds/sdk" - "github.com/rockbears/log" - + "github.com/docker/distribution/reference" types "github.com/docker/docker/api/types" + "github.com/rockbears/log" context "golang.org/x/net/context" + + "github.com/ovh/cds/sdk" ) func (h *HatcherySwarm) pullImage(dockerClient *dockerClient, img string, timeout time.Duration, model sdk.Model) error { @@ -23,13 +25,13 @@ func (h *HatcherySwarm) pullImage(dockerClient *dockerClient, img string, timeou defer cancel() //Pull the worker image - opts := types.ImageCreateOptions{} + var authConfig *types.AuthConfig if model.ModelDocker.Private { registry := "index.docker.io" if model.ModelDocker.Registry != "" { - urlParsed, errParsed := url.Parse(model.ModelDocker.Registry) - if errParsed != nil { - return sdk.WrapError(errParsed, "cannot parse registry url %s", registry) + urlParsed, err := url.Parse(model.ModelDocker.Registry) + if err != nil { + return sdk.WrapError(err, "cannot parse registry url %q", registry) } if urlParsed.Host == "" { registry = urlParsed.Path @@ -37,9 +39,55 @@ func (h *HatcherySwarm) pullImage(dockerClient *dockerClient, img string, timeou registry = urlParsed.Host } } - auth := fmt.Sprintf(`{"username": "%s", "password": "%s", "serveraddress": "%s"}`, model.ModelDocker.Username, model.ModelDocker.Password, registry) - opts.RegistryAuth = base64.StdEncoding.EncodeToString([]byte(auth)) + authConfig = &types.AuthConfig{ + Username: model.ModelDocker.Username, + Password: model.ModelDocker.Password, + ServerAddress: registry, + } + } else { + ref, err := reference.ParseNormalizedNamed(img) + if err != nil { + return sdk.WithStack(err) + } + domain := reference.Domain(ref) + var credentials *RegistryCredential + // Check if credentials match current domain + for i := range h.Config.RegistryCredentials { + if h.Config.RegistryCredentials[i].Domain == domain { + credentials = &h.Config.RegistryCredentials[i] + break + } + } + if credentials == nil { + // Check if regex credentials match current domain + for i := range h.Config.RegistryCredentials { + reg := regexp.MustCompile(h.Config.RegistryCredentials[i].Domain) + if reg.MatchString(domain) { + credentials = &h.Config.RegistryCredentials[i] + break + } + } + } + if credentials != nil { + authConfig = &types.AuthConfig{ + Username: credentials.Username, + Password: credentials.Password, + ServerAddress: domain, + } + log.Debug(context.TODO(), "hatchery> swarm> pullImage> Found credentials %q to pull image %q", credentials.Domain, img) + } } + + opts := types.ImageCreateOptions{} + if authConfig != nil { + config, err := json.Marshal(authConfig) + if err != nil { + return sdk.WithStack(err) + } + opts.RegistryAuth = base64.StdEncoding.EncodeToString(config) + log.Debug(context.TODO(), "hatchery> swarm> pullImage> pulling image %q on %q with login on %q", img, dockerClient.name, authConfig.ServerAddress) + } + res, err := dockerClient.ImageCreate(ctx, img, opts) if err != nil { log.Warn(ctx, "hatchery> swarm> pullImage> Unable to pull image %s on %s: %s", img, dockerClient.name, err) diff --git a/engine/hatchery/swarm/swarm_util_pull_test.go b/engine/hatchery/swarm/swarm_util_pull_test.go new file mode 100644 index 0000000000..0370b54fc4 --- /dev/null +++ b/engine/hatchery/swarm/swarm_util_pull_test.go @@ -0,0 +1,85 @@ +package swarm + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/docker/docker/api/types" + "github.com/stretchr/testify/require" + "gopkg.in/h2non/gock.v1" + + "github.com/ovh/cds/sdk" +) + +func Test_pullImage(t *testing.T) { + t.Cleanup(gock.Off) + + h := InitTestHatcherySwarm(t) + + gock.New("https://lolcat.host").Post("/images/create").Times(5).AddMatcher(func(r *http.Request, rr *gock.Request) (bool, error) { + values := r.URL.Query() + + // Call 1 + if values.Get("fromImage") == "my-registry.lolcat.host/my-image-1" && values.Get("tag") == "my-tag" { + return true, nil + } + // Call 2 + if values.Get("fromImage") == "my-image-2" && values.Get("tag") == "my-tag" { + return true, nil + } + + buf, err := base64.StdEncoding.DecodeString(r.Header.Get("X-Registry-Auth")) + require.NoError(t, err) + var auth types.AuthConfig + require.NoError(t, json.Unmarshal(buf, &auth)) + + t.Log("Auth config", auth) + + // Call 3 + if values.Get("fromImage") == "my-first-registry.lolcat.host/my-image-3" && values.Get("tag") == "my-tag" && + auth.Username == "my-user" && auth.Password == "my-pass-1" && auth.ServerAddress == "my-first-registry.lolcat.host" { + return true, nil + } + // Call 4 + if values.Get("fromImage") == "my-second-registry.lolcat.host/my-image-4" && values.Get("tag") == "my-tag" && + auth.Username == "my-user" && auth.Password == "my-pass-2" && auth.ServerAddress == "my-second-registry.lolcat.host" { + return true, nil + } + // Call 5 + if values.Get("fromImage") == "my-image-5" && values.Get("tag") == "my-tag" && + auth.Username == "my-user" && auth.Password == "my-pass" && auth.ServerAddress == "docker.io" { + return true, nil + } + return false, nil + }).Reply(http.StatusOK) + + require.NoError(t, h.pullImage(h.dockerClients["default"], "my-registry.lolcat.host/my-image-1:my-tag", time.Minute, sdk.Model{})) + require.NoError(t, h.pullImage(h.dockerClients["default"], "my-image-2:my-tag", time.Minute, sdk.Model{})) + + h.Config.RegistryCredentials = []RegistryCredential{ + { + Domain: "docker.io", + Username: "my-user", + Password: "my-pass", + }, + { + Domain: "my-first-registry.lolcat.host", + Username: "my-user", + Password: "my-pass-1", + }, + { + Domain: "^*.lolcat.host$", + Username: "my-user", + Password: "my-pass-2", + }, + } + + require.NoError(t, h.pullImage(h.dockerClients["default"], "my-first-registry.lolcat.host/my-image-3:my-tag", time.Minute, sdk.Model{})) + require.NoError(t, h.pullImage(h.dockerClients["default"], "my-second-registry.lolcat.host/my-image-4:my-tag", time.Minute, sdk.Model{})) + require.NoError(t, h.pullImage(h.dockerClients["default"], "my-image-5:my-tag", time.Minute, sdk.Model{})) + + require.True(t, gock.IsDone()) +} diff --git a/engine/hatchery/swarm/types.go b/engine/hatchery/swarm/types.go index f6e5c978c2..a69274d0e3 100644 --- a/engine/hatchery/swarm/types.go +++ b/engine/hatchery/swarm/types.go @@ -31,6 +31,8 @@ type HatcheryConfiguration struct { NetworkEnableIPv6 bool `mapstructure:"networkEnableIPv6" toml:"networkEnableIPv6" default:"false" commented:"false" comment:"if true: hatchery creates private network between services with ipv6 enabled" json:"networkEnableIPv6"` DockerEngines map[string]DockerEngineConfiguration `mapstructure:"dockerEngines" toml:"dockerEngines" comment:"List of Docker Engines" json:"dockerEngines,omitempty"` + + RegistryCredentials []RegistryCredential `mapstructure:"registryCredentials" toml:"registryCredentials" commented:"true" comment:"List of Docker registry credentials" json:"-"` } // HatcherySwarm is a hatchery which can be connected to a remote to a docker remote api @@ -57,3 +59,9 @@ type DockerEngineConfiguration struct { APIVersion string `mapstructure:"APIVersion" toml:"APIVersion" comment:"DOCKER_API_VERSION" json:"APIVersion"` // DOCKER_API_VERSION MaxContainers int `mapstructure:"maxContainers" toml:"maxContainers" default:"10" commented:"false" comment:"Max Containers on Host managed by this Hatchery" json:"maxContainers"` } + +type RegistryCredential struct { + Domain string `mapstructure:"domain" default:"docker.io" commented:"true" toml:"domain" json:"-"` + Username string `mapstructure:"username" commented:"true" toml:"username" json:"-"` + Password string `mapstructure:"password" commented:"true" toml:"password" json:"-"` +} diff --git a/engine/service/types.go b/engine/service/types.go index f031a69783..4edd404078 100644 --- a/engine/service/types.go +++ b/engine/service/types.go @@ -53,7 +53,7 @@ type HatcheryCommonConfiguration struct { MaxConcurrentRegistering int `toml:"maxConcurrentRegistering" default:"2" comment:"Maximum allowed simultaneous workers registering. -1 to disable registering on this hatchery" json:"maxConcurrentRegistering"` RegisterFrequency int `toml:"registerFrequency" default:"60" comment:"Check if some worker model have to be registered each n Seconds" json:"registerFrequency"` Region string `toml:"region" default:"" comment:"region of this hatchery - optional. With a free text as 'myregion', user can set a prerequisite 'region' with value 'myregion' on CDS Job" json:"region"` - IgnoreJobWithNoRegion bool `toml:"ignoreJobWithNoRegion" default:"false" comment:"Ignore job without a region prerequisite if ignoreJobWithNoRegion=true"` + IgnoreJobWithNoRegion bool `toml:"ignoreJobWithNoRegion" default:"false" comment:"Ignore job without a region prerequisite if ignoreJobWithNoRegion=true" json:"ignoreJobWithNoRegion"` WorkerAPIHTTP struct { URL string `toml:"url" default:"http://localhost:8081" commented:"true" comment:"CDS API URL for worker, let empty or commented to use the same URL that is used by the Hatchery" json:"url"` Insecure bool `toml:"insecure" default:"false" commented:"true" comment:"sslInsecureSkipVerify, set to true if you use a self-signed SSL on CDS API" json:"insecure"`