Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for registry mirrors #8244

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/docs/configuration/others.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,23 @@ The following example will fail when a critical vulnerability is found or the OS
```
$ trivy image --exit-code 1 --exit-on-eol 1 --severity CRITICAL alpine:3.16.3
```

## Mirrors support
DmitriyLewen marked this conversation as resolved.
Show resolved Hide resolved

!!! warning "EXPERIMENTAL"
This feature might change without preserving backwards compatibility.

Trivy supports mirrors for [remote container images](../target/container_image.md#container-registry) and [databases](./db.md).

To configure them, add a list of mirrors along with the host to the [trivy config file](../references/configuration/config-file.md#registry-options).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should explain how it works with some examples. How about orders? When does Trivy fall back into the next mirror? Is the original registry used? And so on.

Copy link
Contributor Author

@DmitriyLewen DmitriyLewen Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added order and example - d082591 + f3247f1


!!! note
Use the `index.docker.io` host for images from `Docker Hub`, even if you don't use that prefix.

Example for `index.docker.io`:
```yaml
registry:
mirrors:
index.docker.io:
- mirror.gcr.io
```
5 changes: 5 additions & 0 deletions docs/docs/references/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,11 @@ pkg:

```yaml
registry:
mirrors:
index.docker.io:
- harbor.example.com/docker.io
- mirror.gcr.io
Comment on lines +464 to +467
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, this page shows the default values. I'm wondering if it confuses users. Since the document describes an example, isn't it enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not adding flags support, so we need to include something in the config file docs.
But honestly, I added this example because I could come up with good lines for an empty map[string][]string

This is a yaml file, and the go (map[string][]string) syntax can be confusing to users.

I can use <..> in the example. eg:

mirrors:
  <origin_registry>:
    - <first_mirror>
    - <second_mirror>

let me know if you have another idea.


# Same as '--password'
password: []

Expand Down
24 changes: 23 additions & 1 deletion magefiles/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func writeFlags(group flag.FlagGroup, w *os.File) {
}
w.WriteString(ind + parts[i] + ":")
if isLastPart {
writeFlagValue(flg.GetDefaultValue(), ind, w)
writeFlagValue(value(flg), ind, w)
}
w.WriteString("\n")
}
Expand All @@ -147,9 +147,31 @@ func writeFlagValue(val any, ind string, w *os.File) {
} else {
w.WriteString(" []\n")
}
case map[string][]string:
w.WriteString("\n")
for k, vv := range v {
fmt.Fprintf(w, "%s %s:\n", ind, k)
for _, vvv := range vv {
fmt.Fprintf(w, " %s - %s\n", ind, vvv)
}
}
case string:
fmt.Fprintf(w, " %q\n", v)
default:
fmt.Fprintf(w, " %v\n", v)
}
}

var registryMirrorsExample = map[string][]string{
"index.docker.io": {
"harbor.example.com/docker.io",
"mirror.gcr.io",
},
}

func value(flg flag.Flagger) any {
if flg.GetConfigName() == flag.RegistryMirrorsFlag.ConfigName {
return registryMirrorsExample
}
return flg.GetDefaultValue()
}
3 changes: 3 additions & 0 deletions pkg/fanal/types/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ type RegistryOptions struct {
// RegistryToken is a bearer token to be sent to a registry
RegistryToken string

// RegistryMirrors is a map of hosts with mirrors for them
RegistryMirrors map[string][]string

// SSL/TLS
Insecure bool

Expand Down
15 changes: 9 additions & 6 deletions pkg/flag/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
)

type FlagType interface {
int | string | []string | bool | time.Duration | float64
int | string | []string | bool | time.Duration | float64 | map[string][]string
}

type Flag[T FlagType] struct {
Expand Down Expand Up @@ -161,6 +161,8 @@ func (f *Flag[T]) cast(val any) any {
return cast.ToFloat64(val)
case time.Duration:
return cast.ToDuration(val)
case map[string][]string:
return cast.ToStringMapStringSlice(val)
Comment on lines +164 to +165
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm impressed it works!

case []string:
if s, ok := val.(string); ok && strings.Contains(s, ",") {
// Split environmental variables by comma as it is not done by viper.
Expand Down Expand Up @@ -467,11 +469,12 @@ func (o *Options) ScanOpts() types.ScanOptions {
// RegistryOpts returns options for OCI registries
func (o *Options) RegistryOpts() ftypes.RegistryOptions {
return ftypes.RegistryOptions{
Credentials: o.Credentials,
RegistryToken: o.RegistryToken,
Insecure: o.Insecure,
Platform: o.Platform,
AWSRegion: o.AWSOptions.Region,
Credentials: o.Credentials,
RegistryToken: o.RegistryToken,
Insecure: o.Insecure,
Platform: o.Platform,
AWSRegion: o.AWSOptions.Region,
RegistryMirrors: o.RegistryMirrors,
}
}

Expand Down
33 changes: 21 additions & 12 deletions pkg/flag/registry_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,33 @@ var (
ConfigName: "registry.token",
Usage: "registry token",
}
RegistryMirrorsFlag = Flag[map[string][]string]{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smart 👏

ConfigName: "registry.mirrors",
Usage: "map of hosts and registries for them.",
}
)

type RegistryFlagGroup struct {
Username *Flag[[]string]
Password *Flag[[]string]
PasswordStdin *Flag[bool]
RegistryToken *Flag[string]
Username *Flag[[]string]
Password *Flag[[]string]
PasswordStdin *Flag[bool]
RegistryToken *Flag[string]
RegistryMirrors *Flag[map[string][]string]
}

type RegistryOptions struct {
Credentials []types.Credential
RegistryToken string
Credentials []types.Credential
RegistryToken string
RegistryMirrors map[string][]string
}

func NewRegistryFlagGroup() *RegistryFlagGroup {
return &RegistryFlagGroup{
Username: UsernameFlag.Clone(),
Password: PasswordFlag.Clone(),
PasswordStdin: PasswordStdinFlag.Clone(),
RegistryToken: RegistryTokenFlag.Clone(),
Username: UsernameFlag.Clone(),
Password: PasswordFlag.Clone(),
PasswordStdin: PasswordStdinFlag.Clone(),
RegistryToken: RegistryTokenFlag.Clone(),
RegistryMirrors: RegistryMirrorsFlag.Clone(),
}
}

Expand All @@ -64,6 +71,7 @@ func (f *RegistryFlagGroup) Flags() []Flagger {
f.Password,
f.PasswordStdin,
f.RegistryToken,
f.RegistryMirrors,
}
}

Expand Down Expand Up @@ -97,7 +105,8 @@ func (f *RegistryFlagGroup) ToOptions() (RegistryOptions, error) {
}

return RegistryOptions{
Credentials: credentials,
RegistryToken: f.RegistryToken.Value(),
Credentials: credentials,
RegistryToken: f.RegistryToken.Value(),
RegistryMirrors: f.RegistryMirrors.Value(),
}, nil
}
112 changes: 75 additions & 37 deletions pkg/remote/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"strings"
"time"

"github.com/google/go-containerregistry/pkg/authn"
Expand Down Expand Up @@ -36,39 +37,46 @@ func Get(ctx context.Context, ref name.Reference, option types.RegistryOptions)
}

var errs error
// Try each authentication method until it succeeds
for _, authOpt := range authOptions(ctx, ref, option) {
remoteOpts := []remote.Option{
remote.WithTransport(tr),
authOpt,
}

if option.Platform.Platform != nil {
p, err := resolvePlatform(ref, option.Platform, remoteOpts)
if err != nil {
return nil, xerrors.Errorf("platform error: %w", err)
// Try each mirrors/host until it succeeds
for _, r := range append(registryMirrors(ref, option), ref) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to make sure that container runtimes work in the same way. Even if it fails due to an authentication error, do they try the next mirror?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added testcase - d7a1b7d

full error trace:

Messages:   	6 errors occurred:
* GET http://127.0.0.1:52934/token?scope=repository%3Alibrary%2Falpine%3Apull&service=: unexpected status code 401 Unauthorized: {"message":"invalid username/password"}

* GET http://127.0.0.1:52934/token?scope=repository%3Alibrary%2Falpine%3Apull&service=: unexpected status code 401 Unauthorized: {"message":"invalid Authorization header"}

* GET http://127.0.0.1:52934/token?scope=repository%3Alibrary%2Falpine%3Apull&service=: unexpected status code 401 Unauthorized: {"message":"invalid username/password"}

* GET http://127.0.0.1:52934/token?scope=repository%3Alibrary%2Falpine%3Apull&service=: unexpected status code 401 Unauthorized: {"message":"invalid Authorization header"}

* GET http://127.0.0.1:52934/token?scope=repository%3Alibrary%2Falpine%3Apull&service=: unexpected status code 401 Unauthorized: {"message":"invalid username/password"}

* GET http://127.0.0.1:52934/token?scope=repository%3Alibrary%2Falpine%3Apull&service=: unexpected status code 401 Unauthorized: {"message":"invalid Authorization header"}

// Try each authentication method until it succeeds
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of nesting it, I prefer adding another method, like tryRef, tryImage or something like that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated in ad56617

for _, authOpt := range authOptions(ctx, r, option) {
remoteOpts := []remote.Option{
remote.WithTransport(tr),
authOpt,
}
// Don't pass platform when the specified image is single-arch.
if p.Platform != nil {
remoteOpts = append(remoteOpts, remote.WithPlatform(*p.Platform))

if option.Platform.Platform != nil {
p, err := resolvePlatform(r, option.Platform, remoteOpts)
if err != nil {
return nil, xerrors.Errorf("platform error: %w", err)
}
// Don't pass platform when the specified image is single-arch.
if p.Platform != nil {
remoteOpts = append(remoteOpts, remote.WithPlatform(*p.Platform))
}
}
}

desc, err := remote.Get(ref, remoteOpts...)
if err != nil {
errs = multierror.Append(errs, err)
continue
}
var desc *remote.Descriptor
desc, err = remote.Get(r, remoteOpts...)
if err != nil {
errs = multierror.Append(errs, err)
continue
}

if option.Platform.Force {
if err = satisfyPlatform(desc, lo.FromPtr(option.Platform.Platform)); err != nil {
return nil, err
if option.Platform.Force {
if err = satisfyPlatform(desc, lo.FromPtr(option.Platform.Platform)); err != nil {
return nil, err
}
}
if ref.Context().RegistryStr() != r.Context().RegistryStr() {
log.WithPrefix("remote").Info("Mirror was used to get remote image", log.String("image", ref.String()), log.String("mirror", r.Context().RegistryStr()))
}
return desc, nil
}
return desc, nil
}

// No authentication succeeded
// No authentication for mirrors/host succeeded
return nil, errs
}

Expand All @@ -81,21 +89,28 @@ func Image(ctx context.Context, ref name.Reference, option types.RegistryOptions
}

var errs error
// Try each authentication method until it succeeds
for _, authOpt := range authOptions(ctx, ref, option) {
remoteOpts := []remote.Option{
remote.WithTransport(tr),
authOpt,
}
index, err := remote.Image(ref, remoteOpts...)
if err != nil {
errs = multierror.Append(errs, err)
continue
// Try each mirrors/host until it succeeds
for _, r := range append(registryMirrors(ref, option), ref) {
// Try each authentication method until it succeeds
for _, authOpt := range authOptions(ctx, r, option) {
remoteOpts := []remote.Option{
remote.WithTransport(tr),
authOpt,
}
index, err := remote.Image(r, remoteOpts...)
if err != nil {
errs = multierror.Append(errs, err)
continue
}

if ref.Context().RegistryStr() != r.Context().RegistryStr() {
log.WithPrefix("remote").Info("Mirror was used to get remote image", log.String("image", ref.String()), log.String("mirror", r.Context().RegistryStr()))
}
return index, nil
}
return index, nil
}

// No authentication succeeded
// No authentication for mirrors/host succeeded
return nil, errs
}

Expand Down Expand Up @@ -126,6 +141,29 @@ func Referrers(ctx context.Context, d name.Digest, option types.RegistryOptions)
return nil, errs
}

func registryMirrors(hostRef name.Reference, option types.RegistryOptions) []name.Reference {
var mirrors []name.Reference

ctx := hostRef.Context()
reg := ctx.RegistryStr()
DmitriyLewen marked this conversation as resolved.
Show resolved Hide resolved
if ms, ok := option.RegistryMirrors[reg]; ok {
for _, m := range ms {
var nameOpts []name.Option
if option.Insecure {
nameOpts = append(nameOpts, name.Insecure)
}
mirrorImageName := strings.Replace(hostRef.Name(), reg, m, 1)
ref, err := name.ParseReference(mirrorImageName, nameOpts...)
if err != nil {
log.WithPrefix("remote").Warn("Unable to parse mirror of image", log.String("mirror", mirrorImageName))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means users specified the wrong registry. I think we should return an error. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It make sense. Thanks!
Updated in 8d269d2

continue
}
mirrors = append(mirrors, ref)
}
}
return mirrors
}

func httpTransport(option types.RegistryOptions) (http.RoundTripper, error) {
d := &net.Dialer{
Timeout: 10 * time.Minute,
Expand Down
Loading
Loading