File: shortnames.go

package info (click to toggle)
golang-github-containers-image 5.28.0-4
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 5,104 kB
  • sloc: sh: 194; makefile: 73
file content (475 lines) | stat: -rw-r--r-- 16,224 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
package shortnames

import (
	"errors"
	"fmt"
	"os"
	"strings"

	"github.com/containers/image/v5/docker/reference"
	"github.com/containers/image/v5/pkg/sysregistriesv2"
	"github.com/containers/image/v5/types"
	"github.com/manifoldco/promptui"
	"github.com/opencontainers/go-digest"
	"golang.org/x/term"
)

// IsShortName returns true if the specified input is a "short name".  A "short
// name" refers to a container image without a fully-qualified reference, and
// is hence missing a registry (or domain).  Names including a digest are not
// short names.
//
// Examples:
//   - short names: "image:tag", "library/fedora"
//   - not short names: "quay.io/image", "localhost/image:tag",
//     "server.org:5000/lib/image", "image@sha256:..."
func IsShortName(input string) bool {
	isShort, _, _ := parseUnnormalizedShortName(input)
	return isShort
}

// parseUnnormalizedShortName parses the input and returns if it's short name,
// the unnormalized reference.Named, and a parsing error.
func parseUnnormalizedShortName(input string) (bool, reference.Named, error) {
	ref, err := reference.Parse(input)
	if err != nil {
		return false, nil, fmt.Errorf("cannot parse input: %q: %w", input, err)
	}

	named, ok := ref.(reference.Named)
	if !ok {
		return true, nil, fmt.Errorf("%q is not a named reference", input)
	}

	registry := reference.Domain(named)
	if strings.ContainsAny(registry, ".:") || registry == "localhost" {
		// A final parse to make sure that docker.io references are correctly
		// normalized (e.g., docker.io/alpine to docker.io/library/alpine.
		named, err = reference.ParseNormalizedNamed(input)
		if err != nil {
			return false, nil, fmt.Errorf("cannot normalize input: %q: %w", input, err)
		}
		return false, named, nil
	}

	return true, named, nil
}

// splitUserInput parses the user-specified reference.  Namely, it strips off
// the tag or digest and stores it in the return values so that both can be
// re-added to a possible resolved alias' or USRs at a later point.
func splitUserInput(named reference.Named) (isTagged bool, isDigested bool, normalized reference.Named, tag string, digest digest.Digest) {
	tagged, isT := named.(reference.NamedTagged)
	if isT {
		isTagged = true
		tag = tagged.Tag()
	}

	digested, isD := named.(reference.Digested)
	if isD {
		isDigested = true
		digest = digested.Digest()
	}

	// Strip off tag/digest if present.
	normalized = reference.TrimNamed(named)

	return
}

// Add records the specified name-value pair as a new short-name alias to the
// user-specific aliases.conf.  It may override an existing alias for `name`.
func Add(ctx *types.SystemContext, name string, value reference.Named) error {
	isShort, _, err := parseUnnormalizedShortName(name)
	if err != nil {
		return err
	}
	if !isShort {
		return fmt.Errorf("%q is not a short name", name)
	}
	return sysregistriesv2.AddShortNameAlias(ctx, name, value.String())
}

// Remove clears the short-name alias for the specified name.  It throws an
// error in case name does not exist in the machine-generated
// short-name-alias.conf.  In such case, the alias must be specified in one of
// the registries.conf files, which is the users' responsibility.
func Remove(ctx *types.SystemContext, name string) error {
	isShort, _, err := parseUnnormalizedShortName(name)
	if err != nil {
		return err
	}
	if !isShort {
		return fmt.Errorf("%q is not a short name", name)
	}
	return sysregistriesv2.RemoveShortNameAlias(ctx, name)
}

// Resolved encapsulates all data for a resolved image name.
type Resolved struct {
	PullCandidates []PullCandidate

	userInput         reference.Named
	systemContext     *types.SystemContext
	rationale         rationale
	originDescription string
}

func (r *Resolved) addCandidate(named reference.Named) {
	named = reference.TagNameOnly(named) // Make sure to add ":latest" if needed
	r.PullCandidates = append(r.PullCandidates, PullCandidate{named, false, r})
}

func (r *Resolved) addCandidateToRecord(named reference.Named) {
	r.PullCandidates = append(r.PullCandidates, PullCandidate{named, true, r})
}

// Allows to reason over pull errors and add some context information.
// Used in (*Resolved).WrapPullError.
type rationale int

const (
	// No additional context.
	rationaleNone rationale = iota
	// Resolved value is a short-name alias.
	rationaleAlias
	// Resolved value has been completed with an Unqualified Search Registry.
	rationaleUSR
	// Resolved value has been selected by the user (via the prompt).
	rationaleUserSelection
	// Resolved value has been enforced to use Docker Hub (via SystemContext).
	rationaleEnforcedDockerHub
)

// Description returns a human-readable description about the resolution
// process (e.g., short-name alias, unqualified-search registries, etc.).
// It is meant to be printed before attempting to pull the pull candidates
// to make the short-name resolution more transparent to user.
//
// If the returned string is empty, it is not meant to be printed.
func (r *Resolved) Description() string {
	switch r.rationale {
	case rationaleAlias:
		return fmt.Sprintf("Resolved %q as an alias (%s)", r.userInput, r.originDescription)
	case rationaleUSR:
		return fmt.Sprintf("Resolving %q using unqualified-search registries (%s)", r.userInput, r.originDescription)
	case rationaleEnforcedDockerHub:
		return fmt.Sprintf("Resolving %q to docker.io (%s)", r.userInput, r.originDescription)
	case rationaleUserSelection, rationaleNone:
		fallthrough
	default:
		return ""
	}
}

// FormatPullErrors is a convenience function to format errors that occurred
// while trying to pull all of the resolved pull candidates.
//
// Note that nil is returned if len(pullErrors) == 0.  Otherwise, the amount of
// pull errors must equal the amount of pull candidates.
func (r *Resolved) FormatPullErrors(pullErrors []error) error {
	if len(pullErrors) > 0 && len(pullErrors) != len(r.PullCandidates) {
		pullErrors = append(pullErrors,
			fmt.Errorf("internal error: expected %d instead of %d errors for %d pull candidates",
				len(r.PullCandidates), len(pullErrors), len(r.PullCandidates)))
	}

	switch len(pullErrors) {
	case 0:
		return nil
	case 1:
		return pullErrors[0]
	default:
		var sb strings.Builder
		sb.WriteString(fmt.Sprintf("%d errors occurred while pulling:", len(pullErrors)))
		for _, e := range pullErrors {
			sb.WriteString("\n * ")
			sb.WriteString(e.Error())
		}
		return errors.New(sb.String())
	}
}

// PullCandidate is a resolved name.  Once the Value has been used
// successfully, users MUST call `(*PullCandidate).Record(..)` to possibly
// record it as a new short-name alias.
type PullCandidate struct {
	// Fully-qualified reference with tag or digest.
	Value reference.Named
	// Control whether to record it permanently as an alias.
	record bool

	// Backwards pointer to the Resolved "parent".
	resolved *Resolved
}

// Record may store a short-name alias for the PullCandidate.
func (c *PullCandidate) Record() error {
	if !c.record {
		return nil
	}

	// Strip off tags/digests from name/value.
	name := reference.TrimNamed(c.resolved.userInput)
	value := reference.TrimNamed(c.Value)

	if err := Add(c.resolved.systemContext, name.String(), value); err != nil {
		return fmt.Errorf("recording short-name alias (%q=%q): %w", c.resolved.userInput, c.Value, err)
	}
	return nil
}

// Resolve resolves the specified name to either one or more fully-qualified
// image references that the short name may be *pulled* from.  If the specified
// name is already a fully-qualified reference (i.e., not a short name), it is
// returned as is.  In case, it's a short name, it's resolved according to the
// ShortNameMode in the SystemContext (if specified) or in the registries.conf.
//
// Note that tags and digests are stripped from the specified name before
// looking up an alias. Stripped off tags and digests are later on appended to
// all candidates.  If neither tag nor digest is specified, candidates are
// normalized with the "latest" tag.  An error is returned if there is no
// matching alias and no unqualified-search registries are configured.
//
// Note that callers *must* call `(PullCandidate).Record` after a returned
// item has been pulled successfully; this callback will record a new
// short-name alias (depending on the specified short-name mode).
//
// Furthermore, before attempting to pull callers *should* call
// `(Resolved).Description` and afterwards use
// `(Resolved).FormatPullErrors` in case of pull errors.
func Resolve(ctx *types.SystemContext, name string) (*Resolved, error) {
	resolved := &Resolved{}

	// Create a copy of the system context to make it usable beyond this
	// function call.
	if ctx != nil {
		copy := *ctx
		ctx = &copy
	}
	resolved.systemContext = ctx

	// Detect which mode we're running in.
	mode, err := sysregistriesv2.GetShortNameMode(ctx)
	if err != nil {
		return nil, err
	}

	// Sanity check the short-name mode.
	switch mode {
	case types.ShortNameModeDisabled, types.ShortNameModePermissive, types.ShortNameModeEnforcing:
		// We're good.
	default:
		return nil, fmt.Errorf("unsupported short-name mode (%v)", mode)
	}

	isShort, shortRef, err := parseUnnormalizedShortName(name)
	if err != nil {
		return nil, err
	}
	if !isShort { // no short name
		resolved.addCandidate(shortRef)
		return resolved, nil
	}

	// Resolve to docker.io only if enforced by the caller (e.g., Podman's
	// Docker-compatible REST API).
	if ctx != nil && ctx.PodmanOnlyShortNamesIgnoreRegistriesConfAndForceDockerHub {
		named, err := reference.ParseNormalizedNamed(name)
		if err != nil {
			return nil, fmt.Errorf("cannot normalize input: %q: %w", name, err)
		}
		resolved.addCandidate(named)
		resolved.rationale = rationaleEnforcedDockerHub
		resolved.originDescription = "enforced by caller"
		return resolved, nil
	}

	// Strip off the tag to normalize the short name for looking it up in
	// the config files.
	isTagged, isDigested, shortNameRepo, tag, digest := splitUserInput(shortRef)
	resolved.userInput = shortNameRepo

	// If there's already an alias, use it.
	namedAlias, aliasOriginDescription, err := sysregistriesv2.ResolveShortNameAlias(ctx, shortNameRepo.String())
	if err != nil {
		return nil, err
	}

	// Always use an alias if present.
	if namedAlias != nil {
		if isTagged {
			namedAlias, err = reference.WithTag(namedAlias, tag)
			if err != nil {
				return nil, err
			}
		}
		if isDigested {
			namedAlias, err = reference.WithDigest(namedAlias, digest)
			if err != nil {
				return nil, err
			}
		}
		resolved.addCandidate(namedAlias)
		resolved.rationale = rationaleAlias
		resolved.originDescription = aliasOriginDescription
		return resolved, nil
	}

	resolved.rationale = rationaleUSR

	// Query the registry for unqualified-search registries.
	unqualifiedSearchRegistries, usrConfig, err := sysregistriesv2.UnqualifiedSearchRegistriesWithOrigin(ctx)
	if err != nil {
		return nil, err
	}
	// Error out if there's no matching alias and no search registries.
	if len(unqualifiedSearchRegistries) == 0 {
		if usrConfig != "" {
			return nil, fmt.Errorf("short-name %q did not resolve to an alias and no unqualified-search registries are defined in %q", name, usrConfig)
		}
		return nil, fmt.Errorf("short-name %q did not resolve to an alias and no containers-registries.conf(5) was found", name)
	}
	resolved.originDescription = usrConfig

	for _, reg := range unqualifiedSearchRegistries {
		named, err := reference.ParseNormalizedNamed(fmt.Sprintf("%s/%s", reg, name))
		if err != nil {
			return nil, fmt.Errorf("creating reference with unqualified-search registry %q: %w", reg, err)
		}
		resolved.addCandidate(named)
	}

	// If we're running in disabled, return the candidates without
	// prompting (and without recording).
	if mode == types.ShortNameModeDisabled {
		return resolved, nil
	}

	// If we have only one candidate, there's no ambiguity.
	if len(resolved.PullCandidates) == 1 {
		return resolved, nil
	}

	// If we don't have a TTY, act according to the mode.
	if !term.IsTerminal(int(os.Stdout.Fd())) || !term.IsTerminal(int(os.Stdin.Fd())) {
		switch mode {
		case types.ShortNameModePermissive:
			// Permissive falls back to using all candidates.
			return resolved, nil
		case types.ShortNameModeEnforcing:
			// Enforcing errors out without a prompt.
			return nil, errors.New("short-name resolution enforced but cannot prompt without a TTY")
		default:
			// We should not end up here.
			return nil, fmt.Errorf("unexpected short-name mode (%v) during resolution", mode)
		}
	}

	// We have a TTY, and can prompt the user with a selection of all
	// possible candidates.
	strCandidates := []string{}
	for _, candidate := range resolved.PullCandidates {
		strCandidates = append(strCandidates, candidate.Value.String())
	}
	prompt := promptui.Select{
		Label:    "Please select an image",
		Items:    strCandidates,
		HideHelp: true, // do not show navigation help
	}

	_, selection, err := prompt.Run()
	if err != nil {
		return nil, err
	}

	named, err := reference.ParseNormalizedNamed(selection)
	if err != nil {
		return nil, fmt.Errorf("selection %q is not a valid reference: %w", selection, err)
	}

	resolved.PullCandidates = nil
	resolved.addCandidateToRecord(named)
	resolved.rationale = rationaleUserSelection

	return resolved, nil
}

// ResolveLocally resolves the specified name to either one or more local
// images.  If the specified name is already a fully-qualified reference (i.e.,
// not a short name), it is returned as is.  In case, it's a short name, the
// returned slice of named references looks as follows:
//
//  1. If present, the short-name alias
//  2. "localhost/" as used by many container engines such as Podman and Buildah
//  3. Unqualified-search registries from the registries.conf files
//
// Note that tags and digests are stripped from the specified name before
// looking up an alias. Stripped off tags and digests are later on appended to
// all candidates.  If neither tag nor digest is specified, candidates are
// normalized with the "latest" tag. The returned slice contains at least one
// item.
func ResolveLocally(ctx *types.SystemContext, name string) ([]reference.Named, error) {
	isShort, shortRef, err := parseUnnormalizedShortName(name)
	if err != nil {
		return nil, err
	}
	if !isShort { // no short name
		named := reference.TagNameOnly(shortRef) // Make sure to add ":latest" if needed
		return []reference.Named{named}, nil
	}

	var candidates []reference.Named

	// Complete the candidates with the specified registries.
	completeCandidates := func(registries []string) ([]reference.Named, error) {
		for _, reg := range registries {
			named, err := reference.ParseNormalizedNamed(fmt.Sprintf("%s/%s", reg, name))
			if err != nil {
				return nil, fmt.Errorf("creating reference with unqualified-search registry %q: %w", reg, err)
			}
			named = reference.TagNameOnly(named) // Make sure to add ":latest" if needed
			candidates = append(candidates, named)
		}
		return candidates, nil
	}

	if ctx != nil && ctx.PodmanOnlyShortNamesIgnoreRegistriesConfAndForceDockerHub {
		return completeCandidates([]string{"docker.io"})
	}

	// Strip off the tag to normalize the short name for looking it up in
	// the config files.
	isTagged, isDigested, shortNameRepo, tag, digest := splitUserInput(shortRef)

	// If there's already an alias, use it.
	namedAlias, _, err := sysregistriesv2.ResolveShortNameAlias(ctx, shortNameRepo.String())
	if err != nil {
		return nil, err
	}
	if namedAlias != nil {
		if isTagged {
			namedAlias, err = reference.WithTag(namedAlias, tag)
			if err != nil {
				return nil, err
			}
		}
		if isDigested {
			namedAlias, err = reference.WithDigest(namedAlias, digest)
			if err != nil {
				return nil, err
			}
		}
		namedAlias = reference.TagNameOnly(namedAlias) // Make sure to add ":latest" if needed
		candidates = append(candidates, namedAlias)
	}

	// Query the registry for unqualified-search registries.
	unqualifiedSearchRegistries, err := sysregistriesv2.UnqualifiedSearchRegistries(ctx)
	if err != nil {
		return nil, err
	}

	// Note that "localhost" has precedence over the unqualified-search registries.
	return completeCandidates(append([]string{"localhost"}, unqualifiedSearchRegistries...))
}