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"`