1package pull
2
3import (
4	"context"
5	"fmt"
6	"regexp"
7	"strings"
8	"sync"
9
10	cli "github.com/docker/cli/cli/config/types"
11	"github.com/docker/docker/api/types"
12
13	"gitlab.com/gitlab-org/gitlab-runner/common"
14	"gitlab.com/gitlab-org/gitlab-runner/helpers/docker"
15	"gitlab.com/gitlab-org/gitlab-runner/helpers/docker/auth"
16)
17
18type Manager interface {
19	GetDockerImage(imageName string) (*types.ImageInspect, error)
20}
21
22type ManagerConfig struct {
23	DockerConfig *common.DockerConfig
24	AuthConfig   string
25	ShellUser    string
26	Credentials  []common.Credentials
27}
28
29type pullLogger interface {
30	Debugln(args ...interface{})
31	Infoln(args ...interface{})
32	Warningln(args ...interface{})
33	Println(args ...interface{})
34}
35
36type manager struct {
37	usedImages     map[string]string
38	usedImagesLock sync.RWMutex
39
40	context             context.Context
41	config              ManagerConfig
42	client              docker.Client
43	onPullImageHookFunc func()
44
45	logger pullLogger
46}
47
48func NewManager(
49	ctx context.Context,
50	logger pullLogger,
51	config ManagerConfig,
52	client docker.Client,
53	onPullImageHookFunc func(),
54) Manager {
55	return &manager{
56		context:             ctx,
57		client:              client,
58		config:              config,
59		logger:              logger,
60		onPullImageHookFunc: onPullImageHookFunc,
61	}
62}
63
64func (m *manager) GetDockerImage(imageName string) (*types.ImageInspect, error) {
65	pullPolicies, err := m.config.DockerConfig.GetPullPolicies()
66	if err != nil {
67		return nil, err
68	}
69
70	var imageErr error
71	for idx, pullPolicy := range pullPolicies {
72		attempt := 1 + idx
73		if attempt > 1 {
74			m.logger.Infoln(fmt.Sprintf("Attempt #%d: Trying %q pull policy", attempt, pullPolicy))
75		}
76
77		var img *types.ImageInspect
78		img, imageErr = m.getImageUsingPullPolicy(imageName, pullPolicy)
79		if imageErr != nil {
80			m.logger.Warningln(fmt.Sprintf("Failed to pull image with policy %q: %v", pullPolicy, imageErr))
81			continue
82		}
83
84		m.markImageAsUsed(imageName, img)
85
86		return img, nil
87	}
88
89	return nil, fmt.Errorf(
90		"failed to pull image %q with specified policies %v: %w",
91		imageName,
92		pullPolicies,
93		imageErr,
94	)
95}
96
97func (m *manager) wasImageUsed(imageName, imageID string) bool {
98	m.usedImagesLock.RLock()
99	defer m.usedImagesLock.RUnlock()
100
101	return m.usedImages[imageName] == imageID
102}
103
104func (m *manager) markImageAsUsed(imageName string, image *types.ImageInspect) {
105	m.usedImagesLock.Lock()
106	defer m.usedImagesLock.Unlock()
107
108	if m.usedImages == nil {
109		m.usedImages = make(map[string]string)
110	}
111	m.usedImages[imageName] = image.ID
112
113	if imageName == image.ID {
114		return
115	}
116
117	if len(image.RepoDigests) > 0 {
118		m.logger.Println("Using docker image", image.ID, "for", imageName, "with digest", image.RepoDigests[0], "...")
119	} else {
120		m.logger.Println("Using docker image", image.ID, "for", imageName, "...")
121	}
122}
123
124func (m *manager) getImageUsingPullPolicy(
125	imageName string,
126	pullPolicy common.DockerPullPolicy,
127) (*types.ImageInspect, error) {
128	m.logger.Debugln("Looking for image", imageName, "...")
129	existingImage, _, err := m.client.ImageInspectWithRaw(m.context, imageName)
130
131	// Return early if we already used that image
132	if err == nil && m.wasImageUsed(imageName, existingImage.ID) {
133		return &existingImage, nil
134	}
135
136	// If never is specified then we return what inspect did return
137	if pullPolicy == common.PullPolicyNever {
138		return &existingImage, err
139	}
140
141	if err == nil {
142		// Don't pull image that is passed by ID
143		if existingImage.ID == imageName {
144			return &existingImage, nil
145		}
146
147		// If not-present is specified
148		if pullPolicy == common.PullPolicyIfNotPresent {
149			m.logger.Println(fmt.Sprintf("Using locally found image version due to %q pull policy", pullPolicy))
150			return &existingImage, err
151		}
152	}
153
154	authConfig, err := m.resolveAuthConfigForImage(imageName)
155	if err != nil {
156		return nil, err
157	}
158
159	return m.pullDockerImage(imageName, authConfig)
160}
161
162func (m *manager) resolveAuthConfigForImage(imageName string) (*cli.AuthConfig, error) {
163	registryInfo, err := auth.ResolveConfigForImage(
164		imageName,
165		m.config.AuthConfig,
166		m.config.ShellUser,
167		m.config.Credentials,
168	)
169	if err != nil {
170		return nil, err
171	}
172
173	if registryInfo == nil {
174		m.logger.Debugln(fmt.Sprintf("No credentials found for %v", imageName))
175		return nil, nil
176	}
177
178	authConfig := &registryInfo.AuthConfig
179	m.logger.Println(fmt.Sprintf("Authenticating with credentials from %v", registryInfo.Source))
180	m.logger.Debugln(fmt.Sprintf(
181		"Using %v to connect to %v in order to resolve %v...",
182		authConfig.Username,
183		authConfig.ServerAddress,
184		imageName,
185	))
186	return authConfig, nil
187}
188
189func (m *manager) pullDockerImage(imageName string, ac *cli.AuthConfig) (*types.ImageInspect, error) {
190	if m.onPullImageHookFunc != nil {
191		m.onPullImageHookFunc()
192	}
193	m.logger.Println("Pulling docker image", imageName, "...")
194
195	ref := imageName
196	// Add :latest to limit the download results
197	if !strings.ContainsAny(ref, ":@") {
198		ref += ":latest"
199	}
200
201	options := types.ImagePullOptions{}
202	options.RegistryAuth, _ = auth.EncodeConfig(ac)
203
204	errorRegexp := regexp.MustCompile("(repository does not exist|not found)")
205	if err := m.client.ImagePullBlocking(m.context, ref, options); err != nil {
206		if errorRegexp.MatchString(err.Error()) {
207			return nil, &common.BuildError{Inner: err, FailureReason: common.ScriptFailure}
208		}
209		return nil, err
210	}
211
212	image, _, err := m.client.ImageInspectWithRaw(m.context, imageName)
213	return &image, err
214}
215