diff --git a/Dockerfile b/Dockerfile index 7e3df1d9..a0d2a886 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,8 @@ WORKDIR /app/ RUN go mod download -x ARG TARGETOS TARGETARCH -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o ./bin/version-checker ./cmd/. +ENV CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH +RUN go build -o ./bin/version-checker ./cmd/. FROM alpine:3.22.0 diff --git a/cmd/app/app.go b/cmd/app/app.go index bff6a403..ccc01866 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -37,8 +37,10 @@ func NewCommand(ctx context.Context) *cobra.Command { Use: "version-checker", Short: helpOutput, Long: helpOutput, + PreRunE: func(cmd *cobra.Command, args []string) error { + return opts.complete() + }, RunE: func(_ *cobra.Command, _ []string) error { - opts.complete() logLevel, err := logrus.ParseLevel(opts.LogLevel) if err != nil { @@ -105,14 +107,14 @@ func NewCommand(ctx context.Context) *cobra.Command { metricsServer.RoundTripper, ) - client, err := client.New(ctx, log, opts.Client) + clientManager, err := client.NewManager(ctx, log, mgr.GetConfig(), opts.Client) if err != nil { return fmt.Errorf("failed to setup image registry clients: %s", err) } c := controller.NewPodReconciler(opts.CacheTimeout, metricsServer, - client, + clientManager, mgr.GetClient(), log, opts.RequeueDuration, diff --git a/cmd/app/options.go b/cmd/app/options.go index df27c425..ed5e1f8e 100644 --- a/cmd/app/options.go +++ b/cmd/app/options.go @@ -15,7 +15,6 @@ import ( "github.com/jetstack/version-checker/pkg/api" "github.com/jetstack/version-checker/pkg/client" - "github.com/jetstack/version-checker/pkg/client/selfhosted" ) const ( @@ -42,24 +41,13 @@ const ( envQuayToken = "QUAY_TOKEN" // #nosec G101 - envSelfhostedPrefix = "SELFHOSTED" - envSelfhostedUsername = "USERNAME" - envSelfhostedPassword = "PASSWORD" - envSelfhostedHost = "HOST" - envSelfhostedBearer = "TOKEN" // #nosec G101 - envSelfhostedTokenPath = "TOKEN_PATH" - envSelfhostedInsecure = "INSECURE" - envSelfhostedCAPath = "CA_PATH" -) - -var ( - selfhostedHostReg = regexp.MustCompile("^VERSION_CHECKER_SELFHOSTED_HOST_(.*)") - selfhostedUsernameReg = regexp.MustCompile("^VERSION_CHECKER_SELFHOSTED_USERNAME_(.*)") - selfhostedPasswordReg = regexp.MustCompile("^VERSION_CHECKER_SELFHOSTED_PASSWORD_(.*)") - selfhostedTokenPath = regexp.MustCompile("^VERSION_CHECKER_SELFHOSTED_TOKEN_PATH_(.*)") - selfhostedTokenReg = regexp.MustCompile("^VERSION_CHECKER_SELFHOSTED_TOKEN_(.*)") - selfhostedCAPath = regexp.MustCompile("^VERSION_CHECKER_SELFHOSTED_CA_PATH_(.*)") - selfhostedInsecureReg = regexp.MustCompile("^VERSION_CHECKER_SELFHOSTED_INSECURE_(.*)") + // Used for kubernetes Credential Discovery + envKeychainServiceAccountName = "AUTH_SERVICE_ACCOUNT_NAME" + envKeychainNamespace = "AUTH_SERVICE_ACCOUNT_NAMESPACE" + envKeychainImagePullSecrets = "AUTH_IMAGE_PULL_SECRETS" + envKeychainUseMountSecrets = "AUTH_USE_MOUNT_SECRETS" + // Duration in which to Refresh Credentials from Service Account + envKeychainRefreshDuration = "AUTH_REFRESH_DURATION" ) // Options is a struct to hold options for the version-checker. @@ -77,8 +65,7 @@ type Options struct { kubeConfigFlags *genericclioptions.ConfigFlags - selfhosted selfhosted.Options - Client client.Options + Client client.Options } type envMatcher struct { @@ -149,6 +136,40 @@ func (o *Options) addAppFlags(fs *pflag.FlagSet) { } func (o *Options) addAuthFlags(fs *pflag.FlagSet) { + + /// KEYCHAIN + fs.StringVar(&o.Client.KeyChain.Namespace, + "keychain-namespace", "", + fmt.Sprintf( + "Namespace inside of which service account and imagepullsecrets belong too (%s_%s).", + envPrefix, envKeychainNamespace, + )) + + fs.StringVar(&o.Client.KeyChain.ServiceAccountName, + "keychain-service-account", "", + fmt.Sprintf( + "ServiceAccount used to fetch Image Pull Secrets from (%s_%s).", + envPrefix, envKeychainServiceAccountName, + )) + + fs.StringSliceVar(&o.Client.KeyChain.ImagePullSecrets, + "keychain-image-pull-secrets", []string{}, + fmt.Sprintf( + "Set of image pull secrets to include during authentication (%s_%s).", + envPrefix, envKeychainImagePullSecrets, + )) + + fs.BoolVar(&o.Client.KeyChain.UseMountSecrets, + "keychain-use-mount-secrets", false, + fmt.Sprintf("Include Mount Secrets during discovery (%s_%s).", + envPrefix, envKeychainUseMountSecrets, + )) + fs.DurationVar(&o.Client.AuthRefreshDuration, + "keychain-refresh-duration", time.Hour, + fmt.Sprintf("Duration credentials are refreshed (%s_%s).", + envPrefix, envKeychainRefreshDuration, + )) + /// ACR fs.StringVar(&o.Client.ACR.Username, "acr-username", "", @@ -156,12 +177,14 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "Username to authenticate with azure container registry (%s_%s).", envPrefix, envACRUsername, )) + _ = fs.MarkDeprecated("acr-username", "use keychain instead") fs.StringVar(&o.Client.ACR.Password, "acr-password", "", fmt.Sprintf( "Password to authenticate with azure container registry (%s_%s).", envPrefix, envACRPassword, )) + _ = fs.MarkDeprecated("acr-password", "use keychain instead") fs.StringVar(&o.Client.ACR.RefreshToken, "acr-refresh-token", "", fmt.Sprintf( @@ -169,6 +192,7 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "username/password (%s_%s).", envPrefix, envACRRefreshToken, )) + _ = fs.MarkDeprecated("acr-refresh-token", "use keychain instead") fs.StringVar(&o.Client.ACR.JWKSURI, "acr-jwks-uri", "", fmt.Sprintf( @@ -184,12 +208,14 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "Username to authenticate with docker registry (%s_%s).", envPrefix, envDockerUsername, )) + _ = fs.MarkDeprecated("docker-username", "use keychain instead") fs.StringVar(&o.Client.Docker.Password, "docker-password", "", fmt.Sprintf( "Password to authenticate with docker registry (%s_%s).", envPrefix, envDockerPassword, )) + _ = fs.MarkDeprecated("docker-password", "use keychain instead") fs.StringVar(&o.Client.Docker.Token, "docker-token", "", fmt.Sprintf( @@ -197,6 +223,7 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "username/password (%s_%s).", envPrefix, envDockerToken, )) + _ = fs.MarkDeprecated("docker-token", "use keychain instead") /// /// ECR @@ -233,6 +260,7 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "Access token for read access to private GCR registries (%s_%s).", envPrefix, envGCRAccessToken, )) + _ = fs.MarkDeprecated("gcr-token", "use keychain instead") /// /// GHCR @@ -242,6 +270,7 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "Personal Access token for read access to GHCR releases (%s_%s).", envPrefix, envGHCRAccessToken, )) + _ = fs.MarkDeprecated("gchr-token", "use keychain instead") fs.StringVar(&o.Client.GHCR.Hostname, "gchr-hostname", "", fmt.Sprintf( @@ -257,6 +286,7 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "Access token for read access to private Quay registries (%s_%s).", envPrefix, envQuayToken, )) + _ = fs.MarkDeprecated("quay-token", "use keychain instead") /// /// Selfhosted @@ -266,12 +296,14 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "Username is authenticate with a selfhosted registry (%s_%s_%s).", envPrefix, envSelfhostedPrefix, envSelfhostedUsername, )) + _ = fs.MarkDeprecated("selfhosted-username", "use keychain instead") fs.StringVar(&o.selfhosted.Password, "selfhosted-password", "", fmt.Sprintf( "Password is authenticate with a selfhosted registry (%s_%s_%s).", envPrefix, envSelfhostedPrefix, envSelfhostedPassword, )) + _ = fs.MarkDeprecated("selfhosted-password", "use keychain instead") fs.StringVar(&o.selfhosted.Bearer, "selfhosted-token", "", fmt.Sprintf( @@ -279,6 +311,7 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "username/password (%s_%s_%s).", envPrefix, envSelfhostedPrefix, envSelfhostedBearer, )) + _ = fs.MarkDeprecated("selfhosted-token", "use keychain instead") fs.StringVar(&o.selfhosted.TokenPath, "selfhosted-token-path", "", fmt.Sprintf( @@ -305,12 +338,10 @@ func (o *Options) addAuthFlags(fs *pflag.FlagSet) { "THIS IS NOT RECOMMENDED AND IS INTENDED FOR DEBUGGING (%s_%s_%s)", envPrefix, envSelfhostedPrefix, envSelfhostedInsecure, )) - // if !validSelfHostedOpts(o) { - // panic(fmt.Errorf("invalid self hosted configuration")) - // } + fs.MarkDeprecated("selfhosted-insecure", "No longer supported, you MUST provide the CA Chain.") } -func (o *Options) complete() { +func (o *Options) complete() error { o.Client.Selfhosted = make(map[string]*selfhosted.Options) envs := os.Environ() @@ -338,6 +369,9 @@ func (o *Options) complete() { {envGHCRHostname, &o.Client.GHCR.Hostname}, {envQuayToken, &o.Client.Quay.Token}, + + {envKeychainNamespace, &o.Client.KeyChain.Namespace}, + {envKeychainServiceAccountName, &o.Client.KeyChain.ServiceAccountName}, } { for _, env := range envs { if o.assignEnv(env, opt.key, opt.assign) { @@ -346,7 +380,7 @@ func (o *Options) complete() { } } - o.assignSelfhosted(envs) + return o.assignSelfhosted(envs) } func (o *Options) assignEnv(env, key string, assign *string) bool { @@ -363,7 +397,24 @@ func (o *Options) assignEnv(env, key string, assign *string) bool { return false } -func (o *Options) assignSelfhosted(envs []string) { +// assignSelfhosted processes a list of environment variables and assigns +// self-hosted configuration options to the Options struct. It parses the +// environment variables using predefined regular expressions to extract +// self-hosted configuration details such as token path, bearer token, host, +// username, password, insecure flag, and CA path. +// +// The function ensures that each self-hosted configuration is initialized +// before assigning values. It also validates the self-hosted options after +// processing all environment variables. +// +// Parameters: +// - envs: A slice of strings representing environment variables in the +// format "KEY=VALUE". +// +// Returns: +// - error: An error if validation of the self-hosted options fails, or nil +// if the operation is successful. +func (o *Options) assignSelfhosted(envs []string) error { if o.Client.Selfhosted == nil { o.Client.Selfhosted = make(map[string]*selfhosted.Options) } @@ -451,26 +502,40 @@ func (o *Options) assignSelfhosted(envs []string) { o.Client.Selfhosted[o.selfhosted.Host] = &o.selfhosted } - if !validSelfHostedOpts(o) { - panic(fmt.Errorf("invalid self hosted configuration")) - } + return validateSelfHostedOpts(o) } -func validSelfHostedOpts(opts *Options) bool { +// validateSelfHostedOpts validates the self-hosted options provided in the +// Options struct. It checks both the options set using environment variables +// and those set using flags. +// +// For options set using environment variables, it iterates through the list +// of self-hosted options and ensures that each host is valid. +// +// For options set using flags, it validates the host in the selfhosted.Options +// struct. +// +// Returns an error if any of the self-hosted options contain an invalid host, +// otherwise returns nil. +func validateSelfHostedOpts(opts *Options) error { // opts set using env vars if opts.Client.Selfhosted != nil { - for _, selfHostedOpts := range opts.Client.Selfhosted { - return isValidOption(selfHostedOpts.Host, "") + for name, selfHostedOpts := range opts.Client.Selfhosted { + if err := isValidOption(selfHostedOpts.Host, ""); !err { + return fmt.Errorf("invalid self-hosted option for: %s", name) + } } } // opts set using flags if opts.selfhosted != (selfhosted.Options{}) { - return isValidOption(opts.selfhosted.Host, "") + if !isValidOption(opts.selfhosted.Host, "") { + return fmt.Errorf("invalid self-hosted option for host: %s", opts.selfhosted.Host) + } } - return true + return nil } -func isValidOption(option, invalid string) bool { +func isValidOption(option, invalid any) bool { return option != invalid } diff --git a/cmd/app/options_test.go b/cmd/app/options_test.go index d8a3c04a..16fecbb2 100644 --- a/cmd/app/options_test.go +++ b/cmd/app/options_test.go @@ -5,15 +5,15 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/jetstack/version-checker/pkg/client" "github.com/jetstack/version-checker/pkg/client/acr" - "github.com/jetstack/version-checker/pkg/client/docker" + "github.com/jetstack/version-checker/pkg/client/dockerhub" "github.com/jetstack/version-checker/pkg/client/ecr" "github.com/jetstack/version-checker/pkg/client/gcr" "github.com/jetstack/version-checker/pkg/client/ghcr" "github.com/jetstack/version-checker/pkg/client/quay" - "github.com/jetstack/version-checker/pkg/client/selfhosted" ) func TestComplete(t *testing.T) { @@ -22,10 +22,8 @@ func TestComplete(t *testing.T) { expOptions client.Options }{ "no envs should give no options": { - envs: [][2]string{}, - expOptions: client.Options{ - Selfhosted: make(map[string]*selfhosted.Options), - }, + envs: [][2]string{}, + expOptions: client.Options{}, }, "single host for all options should be included": { envs: [][2]string{ @@ -55,7 +53,7 @@ func TestComplete(t *testing.T) { RefreshToken: "acr-token", JWKSURI: "acr-jwks-uri", }, - Docker: docker.Options{ + Docker: dockerhub.Options{ Username: "docker-username", Password: "docker-password", Token: "docker-token", @@ -75,15 +73,15 @@ func TestComplete(t *testing.T) { Quay: quay.Options{ Token: "quay-token", }, - Selfhosted: map[string]*selfhosted.Options{ - "FOO": { - Host: "docker.joshvanl.com", - Username: "joshvanl", - Password: "password", - Bearer: "my-token", - Insecure: false, - }, - }, + // Selfhosted: map[string]*selfhosted.Options{ + // "FOO": { + // Host: "docker.joshvanl.com", + // Username: "joshvanl", + // Password: "password", + // Bearer: "my-token", + // Insecure: false, + // }, + // }, }, }, "multiple host for all options should be included": { @@ -125,7 +123,7 @@ func TestComplete(t *testing.T) { RefreshToken: "acr-token", JWKSURI: "acr-jwks-uri", }, - Docker: docker.Options{ + Docker: dockerhub.Options{ Username: "docker-username", Password: "docker-password", Token: "docker-token", @@ -145,29 +143,29 @@ func TestComplete(t *testing.T) { Quay: quay.Options{ Token: "quay-token", }, - Selfhosted: map[string]*selfhosted.Options{ - "FOO": { - Host: "docker.joshvanl.com", - Username: "joshvanl", - Password: "password", - Bearer: "my-token", - Insecure: true, - }, - "BAR": { - Host: "bar.docker.joshvanl.com", - Username: "bar.joshvanl", - Password: "bar-password", - Bearer: "my-bar-token", - Insecure: false, - }, - "BUZZ": { - Host: "buzz.docker.jetstack.io", - Username: "buzz.davidcollom", - Password: "buzz-password", - Bearer: "my-buzz-token", - Insecure: false, - CAPath: "/var/run/secrets/buzz/ca.crt", - }, + // Selfhosted: map[string]*selfhosted.Options{ + // "FOO": { + // Host: "docker.joshvanl.com", + // Username: "joshvanl", + // Password: "password", + // Bearer: "my-token", + // Insecure: true, + // }, + // "BAR": { + // Host: "bar.docker.joshvanl.com", + // Username: "bar.joshvanl", + // Password: "bar-password", + // Bearer: "my-bar-token", + // Insecure: false, + // }, + // "BUZZ": { + // Host: "buzz.docker.jetstack.io", + // Username: "buzz.davidcollom", + // Password: "buzz-password", + // Bearer: "my-buzz-token", + // Insecure: false, + // CAPath: "/var/run/secrets/buzz/ca.crt", + // }, }, }, }, @@ -180,14 +178,15 @@ func TestComplete(t *testing.T) { t.Setenv(env[0], env[1]) } o := new(Options) - o.complete() + err := o.complete() + require.NoError(t, err) assert.Exactly(t, test.expOptions, o.Client) }) } } -func TestInvalidSelfhostedPanic(t *testing.T) { +func TestInvalidSelfhostedEnv(t *testing.T) { tests := map[string]struct { envs []string }{ @@ -199,12 +198,8 @@ func TestInvalidSelfhostedPanic(t *testing.T) { } for name, test := range tests { t.Run(name, func(t *testing.T) { - defer func() { _ = recover() }() - o := new(Options) - o.assignSelfhosted(test.envs) - - t.Errorf("did not panic") + assert.Error(t, o.assignSelfhosted(test.envs)) }) } } @@ -221,7 +216,7 @@ func TestInvalidSelfhostedOpts(t *testing.T) { "no self hosted host provided": { opts: Options{ Client: client.Options{ - Selfhosted: map[string]*selfhosted.Options{"foo": &selfhosted.Options{ + Selfhosted: map[string]*selfhosted.Options{"foo": { Insecure: true, }}, }, @@ -232,9 +227,12 @@ func TestInvalidSelfhostedOpts(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - valid := validSelfHostedOpts(&test.opts) - - assert.Equal(t, test.valid, valid) + err := validateSelfHostedOpts(&test.opts) + if test.valid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } }) } } @@ -353,7 +351,8 @@ func TestAssignSelfhosted(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { o := new(Options) - o.assignSelfhosted(test.envs) + err := o.assignSelfhosted(test.envs) + require.NoError(t, err) assert.Exactly(t, test.expOptions.Selfhosted, o.Client.Selfhosted) }) diff --git a/deploy/charts/version-checker/README.md b/deploy/charts/version-checker/README.md index 5780b4ff..8f130bd8 100644 --- a/deploy/charts/version-checker/README.md +++ b/deploy/charts/version-checker/README.md @@ -39,10 +39,11 @@ A Helm chart for version-checker | gcr.token | string | `nil` | Access token for read access to private GCR registries | | ghcr.hostname | string | `nil` | Hostname for Github Enterprise to override the default ghcr domains. | | ghcr.token | string | `nil` | Personal Access token for read access to GHCR releases | -| image.imagePullSecret | string | `nil` | Pull secrects - name of existing secret | +| image.imagePullSecret | string | `nil` | Image Pull secrects required for version-checker to run. | | image.pullPolicy | string | `"IfNotPresent"` | Set the Image Pull Policy | | image.repository | string | `"quay.io/jetstack/version-checker"` | Repository of the container image | | image.tag | string | `""` | Override the chart version. Defaults to `appVersion` of the helm chart. | +| imagePullSecrets | list | `[]` | Existing Image Pull Secrets | | livenessProbe.enabled | bool | `true` | Enable/Disable the setting of a livenessProbe | | livenessProbe.httpGet.path | string | `"/readyz"` | Path to use for the livenessProbe | | livenessProbe.httpGet.port | int | `8080` | Port to use for the livenessProbe | diff --git a/deploy/charts/version-checker/templates/_helpers.tpl b/deploy/charts/version-checker/templates/_helpers.tpl index 1ca664e5..d28813a6 100644 --- a/deploy/charts/version-checker/templates/_helpers.tpl +++ b/deploy/charts/version-checker/templates/_helpers.tpl @@ -33,3 +33,57 @@ Common selector app.kubernetes.io/name: {{ include "version-checker.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} + + +{{/* + Converts values into a list of pullSecrets +*/}} +{{- define "version-checker.buildPullSecretsFromValues" -}} +{{- $secrets := list -}} +{{- range $key, $val := .myRegistrySecrets }} + {{- $registry := (dict "ghcr" "ghcr.io" "dockerhub" "index.docker.io") | get $key | default $key }} + {{- $entry := dict "registry" $registry "username" $val.username "password" $val.password "token" $val.token "email" $val.email }} + {{- $secrets = append $secrets $entry }} +{{- end }} +{{- $secrets | toJson }} +{{- end }} + + +{{/* + Usage: include "version-checker.dockerconfigjson" (dict "pullSecrets" ) + + Expected format: + pullSecrets: + - registry: ghcr.io + username: foo + password: bar + email: foo@example.com + - registry: index.docker.io + username: oauth2 + token: abcdef +*/}} +{{- define "version-checker.dockerconfigjson" -}} +{{- $auths := dict -}} +{{- range .pullSecrets }} + {{- $registry := .registry }} + {{- $username := .username }} + {{- $password := default "" .password }} + {{- $token := default "" .token }} + {{- $email := default "" .email }} + {{- $secret := ternary $token $password (ne $token "") }} + {{- if and $registry $username $secret }} + {{- $auth := printf "%s:%s" $username $secret | b64enc }} + {{- $entry := dict + "username" $username + "password" $secret + "email" $email + "auth" $auth + -}} + {{- $_ := set $auths $registry $entry }} + {{- else }} + {{- fail (printf "dockerconfigjson entry missing required fields: %#v" .) }} + {{- end }} +{{- end }} +{{- $dockerconfig := dict "auths" $auths | toJson }} +{{- $dockerconfig | b64enc }} +{{- end }} diff --git a/deploy/charts/version-checker/templates/secret.yaml b/deploy/charts/version-checker/templates/secret.yaml index 513858a4..43dab8d9 100644 --- a/deploy/charts/version-checker/templates/secret.yaml +++ b/deploy/charts/version-checker/templates/secret.yaml @@ -1,7 +1,93 @@ {{- if or .Values.acr.refreshToken .Values.acr.username .Values.acr.password .Values.docker.token .Values.ecr.accessKeyID .Values.ecr.secretAccessKey .Values.ecr.sessionToken .Values.docker.username .Values.docker.password .Values.gcr.token .Values.ghcr.token .Values.ghcr.hostname .Values.quay.token (not (eq (len .Values.selfhosted) 0)) }} --- apiVersion: v1 -data: +kind: Secret +metadata: + name: {{ include "version-checker.name" . }} + labels: +{{ include "version-checker.labels" . | indent 4 }} +type: kubernetes.io/dockerconfigjson + +stringData: + .dockerconfigjson: {{- $pulls := include "version-checker.buildPullSecretsFromValues" . | fromJson }} + {{- include "version-checker.dockerconfigjson" (dict "pullSecrets" $pulls) }} +{{//*}} + { + "auths": { + {{- if .Values.acr.refreshToken }} + " ": { + "refreshToken": "{{.Values.acr.refreshToken | b64enc }}" + }, + {{- end}} + {{- if .Values.docker.token }} + "docker": { + "token": "{{.Values.docker.token | b64enc }}" + }, + {{- end}} + {{- if .Values.ecr.accessKeyID }} + "ecr": { + "accessKeyID": "{{ .Values.ecr.accessKeyID | b64enc }}" + }, + {{- end}} + {{- if .Values.gcr.token }} + "gcr": { + "token": "{{ .Values.gcr.token | b64enc }}" + }, + {{- end}} + {{- if .Values.ghcr.token }} + "ghcr": { + "token": "{{ .Values.ghcr.token | b64enc }}" + }, + {{- end}} + {{- if .Values.ghcr.hostname }} + "ghcr": { + "hostname": "{{ .Values.ghcr.hostname | b64enc }}" + }, + {{- end}} + {{- if .Values.quay.token }} + "quay": { + "token": "{{ .Values.quay.token | b64enc }}" + }, + {{- end}} + {{- if .Values.selfhosted }} + "selfhosted": { + {{- range $index, $element := .Values.selfhosted }} + {{- if $element.host }} + "{{ $element.name }}": { + "host": "{{ $element.host | b64enc }}" + }, + {{- end }} + {{- if $element.username }} + "{{ $element.name }}": { + "username": "{{ $element.username | b64enc }}" + }, + {{- end }} + {{- if $element.password }} + "{{ $element.name }}": { + "password": "{{ $element.password | b64enc }}" + }, + {{- end }} + {{- if $element.token }} + "{{ $element.name }}": { + "token": "{{ $element.token | b64enc }}" + }, + {{- end }} + {{- if and (hasKey $element "insecure") $element.insecure }} + "{{ $element.name }}": { + "insecure": "{{ $element.insecure | b64enc }}" + }, + {{- end }} + {{- if and (hasKey $element "ca_path") $element.ca_path }} + "{{ $element.name }}": { + "ca_path": "{{ $element.ca_path | b64enc }}" + }, + {{- end }} + {{- end }} + }, + {{- end }} + } + } + # ACR {{- if .Values.acr.refreshToken }} acr.refreshToken: {{.Values.acr.refreshToken | b64enc }} @@ -74,11 +160,5 @@ data: selfhosted.{{ $element.name }}.token: {{ $element.ca_path | b64enc }} {{- end }} {{- end }} - -kind: Secret -metadata: - name: {{ include "version-checker.name" . }} - labels: -{{ include "version-checker.labels" . | indent 4 }} -type: Opaque +{{ END OF OLD SECRET TEMPLATE *// }} {{- end }} diff --git a/deploy/charts/version-checker/templates/serviceaccount.yaml b/deploy/charts/version-checker/templates/serviceaccount.yaml index 4dd4005c..6698dd7e 100644 --- a/deploy/charts/version-checker/templates/serviceaccount.yaml +++ b/deploy/charts/version-checker/templates/serviceaccount.yaml @@ -8,7 +8,11 @@ metadata: labels: {{ include "version-checker.labels" . | indent 4 }} name: {{ include "version-checker.name" . }} -{{- if .Values.image.imagePullSecret }} imagePullSecrets: - - name: {{ .Values.image.imagePullSecret }} +{{- if .Values.image.imagePullSecret }} +- name: {{ .Values.image.imagePullSecret }} +{{ end }} +{{- range .Values.imagePullSecrets }} +- name: {{ . }} {{- end }} +- name: {{ include "version-checker.name" . }} diff --git a/deploy/charts/version-checker/values.yaml b/deploy/charts/version-checker/values.yaml index 9ff718ee..f5a100d4 100644 --- a/deploy/charts/version-checker/values.yaml +++ b/deploy/charts/version-checker/values.yaml @@ -20,7 +20,7 @@ image: tag: "" # -- Set the Image Pull Policy pullPolicy: IfNotPresent - # -- Pull secrects - name of existing secret + # -- Image Pull secrects required for version-checker to run. imagePullSecret: # -- Configure tolerations @@ -49,6 +49,9 @@ versionChecker: # -- Enable/Disable the requirement for an enable.version-checker.io annotation on pods. testAllContainers: true +# -- Existing Image Pull Secrets +imagePullSecrets: [] + # Azure Container Registry Credentials Configuration acr: # -- (string) Username to authenticate with azure container registry diff --git a/go.mod b/go.mod index 2263841b..5a1bfe9f 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( k8s.io/cli-runtime v0.33.2 k8s.io/client-go v0.33.2 k8s.io/component-base v0.33.2 - k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect ) require ( @@ -35,8 +35,9 @@ require ( github.com/bombsimon/logrusr/v4 v4.1.0 github.com/go-chi/transport v0.5.0 github.com/gofri/go-github-ratelimit v1.1.1 - github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.6 + github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20250613215107-59a4b8593039 + github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20250613215107-59a4b8593039 github.com/google/go-github/v70 v70.0.0 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/jarcoal/httpmock v1.4.0 @@ -46,34 +47,42 @@ require ( ) require ( + cloud.google.com/go/compute/metadata v0.7.0 // indirect + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect github.com/Azure/go-autorest/logger v0.2.2 // indirect github.com/Azure/go-autorest/tracing v0.6.1 // indirect - github.com/MicahParks/jwkset v0.8.0 // indirect + github.com/MicahParks/jwkset v0.9.6 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect github.com/aws/smithy-go v1.22.4 // indirect + github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/docker/cli v28.2.2+incompatible // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/docker/cli v28.3.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.8.0 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -84,6 +93,7 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect @@ -106,35 +116,38 @@ require ( github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.63.0 // indirect - github.com/prometheus/procfs v0.16.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/sergi/go-diff v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.38.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/time v0.12.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.1.0 // indirect - k8s.io/apiextensions-apiserver v0.33.0 // indirect + gotest.tools/v3 v3.5.1 // indirect + k8s.io/apiextensions-apiserver v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/kube-openapi v0.0.0-20250610211856-8b98d1ed966a // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect + sigs.k8s.io/yaml v1.5.0 // indirect ) replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.16 diff --git a/go.sum b/go.sum index a43c58ed..8475768f 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,23 @@ +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= +github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= github.com/Azure/go-autorest/autorest/adal v0.9.24 h1:BHZfgGsGwdkHDyZdtQRQk1WeUdW0m2WPAwuHZwUi5i4= github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 h1:Q9R3utmFg9K1B4OYtAZ7ZUUvIUdzQt7G2MN5Hi/d670= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7/go.mod h1:bVrAueELJ0CKLBpUHDIvD516TwmHmzqwCpvONWRsw3s= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/autorest/date v0.3.1 h1:o9Z8Jyt+VJJTCZ/UORishuHOusBwolhjokt9s5k8I4w= github.com/Azure/go-autorest/autorest/date v0.3.1/go.mod h1:Dz/RDmXlfiFFS/eW+b/xMUSFs1tboPVy6UjgADToWDM= @@ -19,8 +30,8 @@ github.com/Azure/go-autorest/logger v0.2.2/go.mod h1:I5fg9K52o+iuydlWfa9T5K6WFos github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/Azure/go-autorest/tracing v0.6.1 h1:YUMSrC/CeD1ZnnXcNYU4a/fzsO35u2Fsful9L/2nyR0= github.com/Azure/go-autorest/tracing v0.6.1/go.mod h1:/3EgjbsjraOqiicERAeu3m7/z0x1TzjQGAwDrJrXGkc= -github.com/MicahParks/jwkset v0.8.0 h1:jHtclI38Gibmu17XMI6+6/UB59srp58pQVxePHRK5o8= -github.com/MicahParks/jwkset v0.8.0/go.mod h1:fVrj6TmG1aKlJEeceAz7JsXGTXEn72zP1px3us53JrA= +github.com/MicahParks/jwkset v0.9.6 h1:Tf8l2/MOby5Kh3IkrqzThPQKfLytMERoAsGZKlyYZxg= +github.com/MicahParks/jwkset v0.9.6/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= github.com/MicahParks/keyfunc/v3 v3.4.0 h1:g03TXq6NjhZyO/UkODl//abm4KiLLNRi0VhW7vGOHyg= github.com/MicahParks/keyfunc/v3 v3.4.0/go.mod h1:y6Ed3dMgNKTcpxbaQHD8mmrYDUZWJAxteddA6OQj+ag= github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= @@ -39,6 +50,8 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1 h1:Bwzh202Aq7/MYnAjXA9VawCf6u+hjwMdoYmZ4HYsdf8= github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1/go.mod h1:xZzWl9AXYa6zsLLH41HBFW8KRKJRIzlGmvSM0mVMIX4= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2 h1:XJ/AEFYj9VFPJdF+VFi4SUPEDfz1akHwxxm07JfZJcs= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2/go.mod h1:JUBHdhvKbbKmhaHjLsKJAWnQL80T6nURmhB/LEprV+4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI= @@ -51,6 +64,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1 github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1 h1:50sS0RWhGpW/yZx2KcDNEb1u1MANv5BMEkJgcieEDTA= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1/go.mod h1:ErZOtbzuHabipRTDTor0inoRlYwbsV1ovwSxjGs/uJo= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -59,6 +74,8 @@ github.com/bombsimon/logrusr/v4 v4.1.0 h1:uZNPbwusB0eUXlO8hIUwStE6Lr5bLN6IgYgG+7 github.com/bombsimon/logrusr/v4 v4.1.0/go.mod h1:pjfHC5e59CvjTBIU3V3sGhFWFAnsnhOR03TRc6im0l8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -68,8 +85,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= -github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/docker/cli v28.3.0+incompatible h1:s+ttruVLhB5ayeuf2BciwDVxYdKi+RoUlxmwNHV3Vfo= +github.com/docker/cli v28.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= @@ -82,8 +101,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/transport v0.5.0 h1:xpnYcIOpBRrduJD68gX9YxkJouRGIE1y+rK5yGYnMXE= @@ -107,6 +126,7 @@ github.com/gofri/go-github-ratelimit v1.1.1/go.mod h1:wGZlBbzHmIVjwDR3pZgKY7RBTV github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -117,12 +137,15 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20250613215107-59a4b8593039 h1:1d9SJvpHXjFuYBHAS5576memil93kLpgBZ5OjdtvW4I= +github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20250613215107-59a4b8593039/go.mod h1:AlUTqI/YtH9ckkhLo4ClTAccEOZz8EaLVxqrfv56OFg= +github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20250613215107-59a4b8593039 h1:R4HgQ4WUZ7D0GsJUqxfvaDz5tVuGq247xqwQbcB+yZ0= +github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20250613215107-59a4b8593039/go.mod h1:h/lvuHXwIBT9AtI/DDArmdoHWpdGr+8Me0Qk6qfT/1k= github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o= github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -156,8 +179,11 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -204,22 +230,21 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -228,6 +253,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -249,14 +275,18 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -272,8 +302,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -288,7 +318,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -305,8 +334,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -314,15 +343,14 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= @@ -336,21 +364,24 @@ gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuB google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.1.0 h1:rVV8Tcg/8jHUkPUorwjaMTtemIMVXfIPKiOqnhEhakk= -gotest.tools/v3 v3.1.0/go.mod h1:fHy7eyTmJFO5bQbUsEGQ1v4m2J3Jz9eWL54TP2/ZuYQ= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= -k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs= -k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc= +k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y= @@ -361,10 +392,10 @@ k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0= k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20250610211856-8b98d1ed966a h1:ZV3Zr+/7s7aVbjNGICQt+ppKWsF1tehxggNfbM7XnG8= +k8s.io/kube-openapi v0.0.0-20250610211856-8b98d1ed966a/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= @@ -376,7 +407,8 @@ sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/r sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= +sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/pkg/api/types.go b/pkg/api/types.go index 903745a1..b9a7aad8 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -3,6 +3,9 @@ package api import ( "context" "time" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/sirupsen/logrus" ) // ImageTag describes a container image tag. @@ -20,12 +23,9 @@ type Architecture string // ImageClient represents a image registry client that can list available tags // for image URLs. type ImageClient interface { - // Returns the name of the client - Name() string - // IsHost will return true if this client is appropriate for the given - // host. - IsHost(host string) bool + // Name returns the client name, can also have the URI in question + Name() string // RepoImage will return the registries repository and image, from a given // URL path. @@ -35,3 +35,17 @@ type ImageClient interface { // using that client. Tags(ctx context.Context, host, repo, image string) ([]ImageTag, error) } + +type ImageClientFactory interface { + // Name returns the client name, can also have the URI in question + Name() string + + // IsHost returns true if the client is configured for the given host. + IsHost(host string) bool + + // New creates an instance of said client, with authentication and logging. + NewClient(auth *authn.AuthConfig, log *logrus.Entry) (ImageClient, error) + + // Implementing the Resolve func to match that of a authn.Keychain + Resolve(res authn.Resource) (authn.Authenticator, error) +} diff --git a/pkg/client/README.md b/pkg/client/README.md new file mode 100644 index 00000000..aaa640f9 --- /dev/null +++ b/pkg/client/README.md @@ -0,0 +1,37 @@ +# pkg/client + +This package provides specialized clients for interacting with different container registries or artifact repositories, particularly where custom authentication and authorization mechanisms are required. + +## Philosophy + +Clients in this package are **only created where specific authentication or authorization is required**. For common use cases, the default client should be sufficient. This design helps minimize overhead and complexity for common cases while still allowing flexibility for secure or proprietary registries. + +## Standard Client: `OCI` + +The `OCI` client is the **standard client** and acts as the baseline fallback for all operations. All other clients should defer to `OCI` where possible to ensure consistent behavior and compatibility across registry interactions. + +## Custom Clients + +Custom clients are provided to support more advanced workflows, particularly where the registry: + +* Requires custom token exchange or API authentication +* Provides richer metadata or querying capabilities + +These clients often rely on internal APIs to **provide a more efficient way of interrogating versions/tags**, improving performance and accuracy over standard OCI calls. + +## Guidelines + +* Use the `OCI` client unless there is a clear need for custom auth or advanced metadata. +* Custom clients should implement or extend common interfaces to preserve interchangeability. +* Prefer internal APIs only where they offer real advantages (e.g., performance, data quality). + +## Future Direction + +All new client implementations should: + +* Fall back to `OCI` when internal or authenticated APIs are not available. +* Expose a consistent interface to allow consumers to switch between clients with minimal changes. + +--- + +This package is intended for internal use, but may be extended in the future to support user-defined plugins or external registry integrations. diff --git a/pkg/client/acr/acr.go b/pkg/client/acr/client.go similarity index 93% rename from pkg/client/acr/acr.go rename to pkg/client/acr/client.go index a2c0231f..18a4cb71 100644 --- a/pkg/client/acr/acr.go +++ b/pkg/client/acr/client.go @@ -7,10 +7,13 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" "github.com/MicahParks/keyfunc/v3" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/sirupsen/logrus" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/adal" @@ -25,9 +28,12 @@ const ( requiredScope = "repository:*:metadata_read" ) +var _ api.ImageClient = (*Client)(nil) + type Client struct { Keyfunc keyfunc.Keyfunc Options + logger *logrus.Entry cacheMu sync.Mutex cachedACRClient map[string]*acrClient @@ -57,7 +63,7 @@ type ManifestResponse struct { } `json:"manifests"` } -func New(opts Options) (*Client, error) { +func NewClient(opts Options, _ authn.Authenticator, log *logrus.Entry) (*Client, error) { if len(opts.RefreshToken) > 0 && (len(opts.Username) > 0 || len(opts.Password) > 0) { return nil, errors.New("cannot specify refresh token as well as username/password") @@ -74,6 +80,7 @@ func New(opts Options) (*Client, error) { return &Client{ Keyfunc: k, + logger: log, Options: opts, cachedACRClient: make(map[string]*acrClient), }, nil @@ -108,7 +115,7 @@ func (c *Client) Tags(ctx context.Context, host, repo, image string) ([]api.Imag SHA: manifest.Digest, Timestamp: manifest.CreatedTime, }) - + // Continue as we don't have any other "tags" continue } @@ -298,3 +305,13 @@ func (c *Client) getTokenExpiration(tokenString string) (time.Time, error) { return time.Time{}, fmt.Errorf("failed to find 'exp' claim in access token") } + +func (c *Client) RepoImageFromPath(path string) (string, string) { + lastIndex := strings.LastIndex(path, "/") + + if lastIndex == -1 { + return "", path + } + + return path[:lastIndex], path[lastIndex+1:] +} diff --git a/pkg/client/acr/client_test.go b/pkg/client/acr/client_test.go new file mode 100644 index 00000000..d79ad35a --- /dev/null +++ b/pkg/client/acr/client_test.go @@ -0,0 +1,39 @@ +package acr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepoImage(t *testing.T) { + tests := map[string]struct { + path string + expRepo, expImage string + }{ + "single image should return google-containers": { + path: "kube-scheduler", + expRepo: "", + expImage: "kube-scheduler", + }, + "two segments to path should return both": { + path: "jetstack-cre/version-checker", + expRepo: "jetstack-cre", + expImage: "version-checker", + }, + "multiple segments to path should return all in repo, last segment image": { + path: "k8s-artifacts-prod/ingress-nginx/nginx", + expRepo: "k8s-artifacts-prod/ingress-nginx", + expImage: "nginx", + }, + } + + handler := new(Client) + for name, test := range tests { + t.Run(name, func(t *testing.T) { + repo, image := handler.RepoImageFromPath(test.path) + assert.Equal(t, repo, test.expRepo) + assert.Equal(t, image, test.expImage) + }) + } +} diff --git a/pkg/client/acr/factory.go b/pkg/client/acr/factory.go new file mode 100644 index 00000000..8e6bc7a1 --- /dev/null +++ b/pkg/client/acr/factory.go @@ -0,0 +1,50 @@ +package acr + +import ( + "regexp" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +var _ api.ImageClientFactory = (*Factory)(nil) +var _ authn.Keychain = (*Factory)(nil) + +var ( + HostReg = regexp.MustCompile(`.*\.azurecr\.io|.*\.azurecr\.cn|.*\.azurecr\.de|.*\.azurecr\.us`) +) + +type Factory struct { + opts Options +} + +func NewFactory(opts Options) *Factory { + return &Factory{ + opts: opts, + } +} + +func (f *Factory) Name() string { + return "acr" +} + +func (f *Factory) IsHost(host string) bool { + return HostReg.MatchString(host) +} + +func (f *Factory) NewClient(_ *authn.AuthConfig, log *logrus.Entry) (api.ImageClient, error) { + return NewClient(f.opts, nil, log) +} + +func (f *Factory) Resolve(res authn.Resource) (authn.Authenticator, error) { + if !f.IsHost(res.RegistryStr()) { + return nil, nil + } + + return authn.FromConfig(authn.AuthConfig{ + Username: f.opts.Username, + Password: f.opts.Password, + RegistryToken: f.opts.RefreshToken, + }), nil +} diff --git a/pkg/client/acr/path_test.go b/pkg/client/acr/factory_test.go similarity index 53% rename from pkg/client/acr/path_test.go rename to pkg/client/acr/factory_test.go index d16e2dd6..adb6f08f 100644 --- a/pkg/client/acr/path_test.go +++ b/pkg/client/acr/factory_test.go @@ -53,45 +53,10 @@ func TestIsHost(t *testing.T) { }, } - handler := new(Client) + handler := new(Factory) for name, test := range tests { t.Run(name, func(t *testing.T) { - if isHost := handler.IsHost(test.host); isHost != test.expIs { - t.Errorf("%s: unexpected IsHost, exp=%t got=%t", - test.host, test.expIs, isHost) - } - }) - } -} - -func TestRepoImage(t *testing.T) { - tests := map[string]struct { - path string - expRepo, expImage string - }{ - "single image should return google-containers": { - path: "kube-scheduler", - expRepo: "", - expImage: "kube-scheduler", - }, - "two segments to path should return both": { - path: "jetstack-cre/version-checker", - expRepo: "jetstack-cre", - expImage: "version-checker", - }, - "multiple segments to path should return all in repo, last segment image": { - path: "k8s-artifacts-prod/ingress-nginx/nginx", - expRepo: "k8s-artifacts-prod/ingress-nginx", - expImage: "nginx", - }, - } - - handler := new(Client) - for name, test := range tests { - t.Run(name, func(t *testing.T) { - repo, image := handler.RepoImageFromPath(test.path) - assert.Equal(t, repo, test.expRepo) - assert.Equal(t, image, test.expImage) + assert.Equal(t, test.expIs, handler.IsHost(test.host)) }) } } diff --git a/pkg/client/acr/path.go b/pkg/client/acr/path.go deleted file mode 100644 index a416c688..00000000 --- a/pkg/client/acr/path.go +++ /dev/null @@ -1,24 +0,0 @@ -package acr - -import ( - "regexp" - "strings" -) - -var ( - reg = regexp.MustCompile(`.*\.azurecr\.io|.*\.azurecr\.cn|.*\.azurecr\.de|.*\.azurecr\.us`) -) - -func (c *Client) IsHost(host string) bool { - return reg.MatchString(host) -} - -func (c *Client) RepoImageFromPath(path string) (string, string) { - lastIndex := strings.LastIndex(path, "/") - - if lastIndex == -1 { - return "", path - } - - return path[:lastIndex], path[lastIndex+1:] -} diff --git a/pkg/client/client.go b/pkg/client/client.go deleted file mode 100644 index fd54cd08..00000000 --- a/pkg/client/client.go +++ /dev/null @@ -1,159 +0,0 @@ -package client - -import ( - "context" - "fmt" - "net/http" - "strings" - - "github.com/sirupsen/logrus" - - "github.com/jetstack/version-checker/pkg/api" - "github.com/jetstack/version-checker/pkg/client/acr" - "github.com/jetstack/version-checker/pkg/client/docker" - "github.com/jetstack/version-checker/pkg/client/ecr" - "github.com/jetstack/version-checker/pkg/client/fallback" - "github.com/jetstack/version-checker/pkg/client/gcr" - "github.com/jetstack/version-checker/pkg/client/ghcr" - "github.com/jetstack/version-checker/pkg/client/oci" - "github.com/jetstack/version-checker/pkg/client/quay" - "github.com/jetstack/version-checker/pkg/client/selfhosted" -) - -// Used for testing/mocking purposes -type ClientHandler interface { - Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) -} - -// Client is a container image registry client to list tags of given image -// URLs. -type Client struct { - clients []api.ImageClient - fallbackClient api.ImageClient - - log *logrus.Entry -} - -// Options used to configure client authentication. -type Options struct { - ACR acr.Options - ECR ecr.Options - GCR gcr.Options - GHCR ghcr.Options - Docker docker.Options - Quay quay.Options - OCI oci.Options - Selfhosted map[string]*selfhosted.Options - - Transport http.RoundTripper -} - -func New(ctx context.Context, log *logrus.Entry, opts Options) (*Client, error) { - log = log.WithField("component", "client") - // Setup Transporters for all remaining clients (if one is set) - if opts.Transport != nil { - opts.Quay.Transporter = opts.Transport - opts.ECR.Transporter = opts.Transport - opts.GHCR.Transporter = opts.Transport - opts.GCR.Transporter = opts.Transport - } - - acrClient, err := acr.New(opts.ACR) - if err != nil { - return nil, fmt.Errorf("failed to create acr client: %w", err) - } - dockerClient, err := docker.New(opts.Docker, log) - if err != nil { - return nil, fmt.Errorf("failed to create docker client: %w", err) - } - - var selfhostedClients []api.ImageClient - for _, sOpts := range opts.Selfhosted { - sClient, err := selfhosted.New(ctx, log, sOpts) - if err != nil { - return nil, fmt.Errorf("failed to create selfhosted client %q: %w", - sOpts.Host, err) - } - - selfhostedClients = append(selfhostedClients, sClient) - } - - // Create some of the fallback clients - ociclient, err := oci.New(&opts.OCI) - if err != nil { - return nil, fmt.Errorf("failed to create OCI client: %w", err) - } - anonSelfHosted, err := selfhosted.New(ctx, log, &selfhosted.Options{Transporter: opts.Transport}) - if err != nil { - return nil, fmt.Errorf("failed to create anonymous Selfhosted client: %w", err) - } - annonDocker, err := docker.New(docker.Options{Transporter: opts.Transport}, log) - if err != nil { - return nil, fmt.Errorf("failed to create anonymous docker client: %w", err) - } - fallbackClient, err := fallback.New(ctx, log, []api.ImageClient{ - anonSelfHosted, - annonDocker, - ociclient, - }) - if err != nil { - return nil, fmt.Errorf("failed to create fallback client: %w", err) - } - - c := &Client{ - // Append all the clients in order of which we want to check against - clients: append( - selfhostedClients, - acrClient, - ecr.New(opts.ECR), - dockerClient, - gcr.New(opts.GCR), - ghcr.New(opts.GHCR), - quay.New(opts.Quay, log), - ), - fallbackClient: fallbackClient, - log: log, - } - - for _, client := range append(c.clients, fallbackClient) { - log.WithField("client", client.Name()).Debugf("registered client") - } - - return c, nil -} - -// Tags returns the full list of image tags available, for a given image URL. -func (c *Client) Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) { - client, host, path := c.fromImageURL(imageURL) - - c.log.Debugf("using client %q for image URL %q", client.Name(), imageURL) - repo, image := client.RepoImageFromPath(path) - - return client.Tags(ctx, host, repo, image) -} - -// fromImageURL will return the appropriate registry client for a given -// image URL, and the host + path to search. -func (c *Client) fromImageURL(imageURL string) (api.ImageClient, string, string) { - var host, path string - - if strings.Contains(imageURL, ".") || strings.Contains(imageURL, ":") { - split := strings.SplitN(imageURL, "/", 2) - if len(split) < 2 { - path = imageURL - } else { - host, path = split[0], split[1] - } - } else { - path = imageURL - } - - for _, client := range c.clients { - if client.IsHost(host) { - return client, host, path - } - } - - // fall back to selfhosted with no path split - return c.fallbackClient, host, path -} diff --git a/pkg/client/clientmanager.go b/pkg/client/clientmanager.go new file mode 100644 index 00000000..ba524e8b --- /dev/null +++ b/pkg/client/clientmanager.go @@ -0,0 +1,216 @@ +package client + +import ( + "context" + "strings" + + "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" + + authn "github.com/google/go-containerregistry/pkg/authn" + k8sauthn "github.com/google/go-containerregistry/pkg/authn/kubernetes" + "github.com/google/go-containerregistry/pkg/name" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/jetstack/version-checker/pkg/api" + "github.com/jetstack/version-checker/pkg/client/acr" + "github.com/jetstack/version-checker/pkg/client/dockerhub" + "github.com/jetstack/version-checker/pkg/client/ecr" + "github.com/jetstack/version-checker/pkg/client/gcr" + "github.com/jetstack/version-checker/pkg/client/ghcr" + "github.com/jetstack/version-checker/pkg/client/oci" + "github.com/jetstack/version-checker/pkg/client/quay" +) + +// ClientManager is a container image registry client manager to list tags of +// given image URLs. +func NewManager(ctx context.Context, log *logrus.Entry, k8sconfig *rest.Config, opts Options) (*ClientManager, error) { + log = log.WithField("component", "client") + + // Create a list with a DefaultKeychain and prepend/append later. + var keychains = []authn.Keychain{authn.DefaultKeychain} + + // Setup Transporters for all remaining clients (if one is set) + if opts.Transport != nil { + opts.Quay.Transporter = opts.Transport + opts.ECR.Transporter = opts.Transport + opts.GHCR.Transporter = opts.Transport + opts.GCR.Transporter = opts.Transport + log.Debug("registered custom Transport for Clients") + } + + if k8sconfig != nil && opts.KeyChain.ServiceAccountName != "" && opts.KeyChain.Namespace != "" { + log.WithField("Keychain", map[string]interface{}{ + "KeychainNamespace": opts.KeyChain.Namespace, + "KeychainServiceAccountName": opts.KeyChain.ServiceAccountName, + "KeychainRefreshDuration": opts.AuthRefreshDuration, + }).Infof("Collecting Credentials") + + k8sclient, err := kubernetes.NewForConfig(k8sconfig) + if err != nil { + return nil, err + } + log.WithField("client", k8sclient).Debug("Successfully Created K8S Client") + + kc, err := k8sauthn.New(ctx, k8sclient, opts.KeyChain) + if err != nil { + return nil, err + } + log.WithField("opts", opts.KeyChain).Debug("Successfully Created K8S Keychain") + // Prepend the Refreshing Keychain to list of keychains + // We want a RefreshingKeychain as credentials in cluster could have rotated. + keychains = append([]authn.Keychain{authn.RefreshingKeychain(kc, opts.AuthRefreshDuration)}, keychains...) + } + + var factories = []api.ImageClientFactory{ + &dockerhub.Factory{}, + acr.NewFactory(opts.ACR), + dockerhub.NewFactory(opts.Docker), + ecr.NewFactory(opts.ECR), + gcr.NewFactory(opts.GCR), + ghcr.NewFactory(opts.GHCR), + quay.NewFactory(opts.Quay), + oci.NewFactory(opts.OCI), + // &selfhosted.Factory{}, + } + + for _, factory := range factories { + keychains = append(keychains, factory) + } + + manager := &ClientManager{ + keychain: authn.NewMultiKeychain(keychains...), + cache: cache.New(opts.AuthRefreshDuration, 3*opts.AuthRefreshDuration), + log: log, + factories: factories, + } + + for _, factory := range manager.factories { + log.WithField("factory", factory.Name()).Debugf("registered factories") + } + + return manager, nil +} + +// Tags returns the full list of image tags available, for a given image URL. +func (c *ClientManager) Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) { + client, host, path := c.fromImageURL(imageURL) + + c.log.Debugf("using client %q for image URL %q", client.Name(), imageURL) + repo, image := client.RepoImageFromPath(path) + + return client.Tags(ctx, host, repo, image) +} + +// fromImageURL will return the appropriate registry client for a given +// image URL, and the host + path to search. +func (c *ClientManager) fromImageURL(imageURL string) (api.ImageClient, string, string) { + var host, path string + + if imageURL == "" { + return nil, "", "" + } + + repo, err := name.NewRepository(imageURL, name.WeakValidation) + if err != nil { + c.log.Errorf("parsing repository: %s", err) + return nil, host, path + } + host = repo.RegistryStr() + path = strings.TrimPrefix( + strings.TrimPrefix(repo.String(), host), + "/", + ) + + auth, err := c.keychain.Resolve(repo) + if err != nil { + c.log.Errorf("Failed to resolve keychain for %q: %s", host, err) + return nil, host, path + } + authconfig, err := auth.Authorization() + if err != nil { + c.log.Errorf("Failed to resolve keychain for %q: %s", host, err) + return nil, host, path + } + + // Check if we have a cached client for this host + if cl, ok := c.cache.Get(host); ok { + c.log.Debugf("Using cached client for host %q", host) + return cl.(api.ImageClient), host, path + } + + cl, err := c.newClientForHost(host, authconfig) + if err != nil { + c.log.Errorf("Failed to create client for host %q: %v", host, err) + // We don't return an error here, as we want to fall back to the + // fallback client if no specific client is found. + } + + if cl != nil { + c.log.Debugf("Found client %q for host %q", cl.Name(), host) + c.cache.SetDefault(host, cl) + return cl, host, path + } + + // fall back to the fallback client if no specific client is found + return c.fallbackClient, host, path +} + +// func setupSelfHosted(ctx context.Context, log *logrus.Entry, opts Options) ([]api.ImageClient, error) { +// var selfhostedClients []api.ImageClient + +// for _, sOpts := range opts.Selfhosted { +// if keychain != nil { +// repo, _ := name.NewRepository("fake/image", name.WeakValidation, name.WithDefaultRegistry(sOpts.Host)) +// log.Debug("Repo:", repo) + +// kcauth, err := keychain.Resolve(repo) +// log.Debug("kcauth:", kcauth) + +// if kcauth == authn.Anonymous { +// log.Warnf("Using Anonymous Authentication for host %s", sOpts.Host) +// } +// if err == nil && kcauth != authn.Anonymous { +// authconfig, err := kcauth.Authorization() +// if err == nil { +// log.WithFields(logrus.Fields{ +// "client": "selfhosted", +// "host": sOpts.Host, +// "config": authconfig, +// }).Infof("Using Authentication from keychain") + +// sOpts.Username = authconfig.Username +// sOpts.Password = authconfig.Password +// sOpts.Bearer = authconfig.RegistryToken + +// // TODO: Remove Output when done +// // spew.Dump() +// // spew.Dump(authconfig) +// // spew.Dump(sOpts) +// } else { +// log.Errorf("Unable to retrieve authentication for host %s: %v", sOpts.Host, err) +// } +// } else { +// log.Errorf("Unable to resolve credentials for host %s: %v", sOpts.Host, err) +// } +// } else { +// log.Infof("Not using keychain for selfhosted client %s", sOpts.Host) +// } +// // If we don't have a prefix, lets assume https +// if !strings.HasPrefix(sOpts.Host, "http://") || !strings.HasPrefix(sOpts.Host, "https://") { +// sOpts.Host = "https://" + sOpts.Host +// } + +// sClient, err := selfhosted.New(ctx, log, sOpts) +// if err != nil { +// return nil, fmt.Errorf("failed to create selfhosted client %q: %w", +// sOpts.Host, err) +// } + +// selfhostedClients = append(selfhostedClients, sClient) +// } + +// return selfhostedClients, nil +// } diff --git a/pkg/client/client_test.go b/pkg/client/clientmanager_test.go similarity index 78% rename from pkg/client/client_test.go rename to pkg/client/clientmanager_test.go index 7e32945f..b5c8fa49 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/clientmanager_test.go @@ -2,36 +2,36 @@ package client import ( "context" - "reflect" "testing" + "github.com/google/go-containerregistry/pkg/name" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/sirupsen/logrus" "github.com/jetstack/version-checker/pkg/api" "github.com/jetstack/version-checker/pkg/client/acr" - "github.com/jetstack/version-checker/pkg/client/docker" + "github.com/jetstack/version-checker/pkg/client/dockerhub" "github.com/jetstack/version-checker/pkg/client/ecr" - "github.com/jetstack/version-checker/pkg/client/fallback" "github.com/jetstack/version-checker/pkg/client/gcr" "github.com/jetstack/version-checker/pkg/client/ghcr" + "github.com/jetstack/version-checker/pkg/client/oci" "github.com/jetstack/version-checker/pkg/client/quay" - "github.com/jetstack/version-checker/pkg/client/selfhosted" ) func TestFromImageURL(t *testing.T) { - handler, err := New(context.TODO(), logrus.NewEntry(logrus.New()), Options{ - Selfhosted: map[string]*selfhosted.Options{ - "yourdomain": { - Host: "https://docker.repositories.yourdomain.com", - }, - }, + handler, err := NewManager(context.TODO(), logrus.NewEntry(logrus.New()), nil, Options{ + // Selfhosted: map[string]*selfhosted.Options{ + // "yourdomain": { + // Host: "https://docker.repositories.yourdomain.com", + // }, + // }, GHCR: ghcr.Options{ Token: "test-token", }, }) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) tests := map[string]struct { url string @@ -39,55 +39,56 @@ func TestFromImageURL(t *testing.T) { expHost string expPath string }{ - "an empty image URL should be selfhosted": { + "an empty image URL should be nil": { url: "", - expClient: new(docker.Client), + expClient: nil, expHost: "", expPath: "", }, "single name should be docker": { url: "nginx", - expClient: new(docker.Client), - expHost: "", - expPath: "nginx", + expClient: new(dockerhub.Client), + expHost: name.DefaultRegistry, + expPath: "library/nginx", }, "two names should be docker": { url: "joshvanl/version-checker", - expClient: new(docker.Client), - expHost: "", + expClient: new(dockerhub.Client), + expHost: name.DefaultRegistry, expPath: "joshvanl/version-checker", }, "three names should be docker": { url: "jetstack/joshvanl/version-checker", - expClient: new(docker.Client), - expHost: "", + expClient: new(dockerhub.Client), + expHost: name.DefaultRegistry, expPath: "jetstack/joshvanl/version-checker", }, "docker.com should be docker": { url: "docker.com/joshvanl/version-checker", - expClient: new(docker.Client), + expClient: new(dockerhub.Client), expHost: "docker.com", expPath: "joshvanl/version-checker", }, "docker.io should be docker": { url: "docker.io/joshvanl/version-checker", - expClient: new(docker.Client), - expHost: "docker.io", + expClient: new(dockerhub.Client), + expHost: name.DefaultRegistry, expPath: "joshvanl/version-checker", }, "docker.com with sub should be docker": { url: "foo.docker.com/joshvanl/version-checker", - expClient: new(docker.Client), + expClient: new(dockerhub.Client), expHost: "foo.docker.com", expPath: "joshvanl/version-checker", }, "docker.io with sub should be docker": { url: "bar.docker.io/registry/joshvanl/version-checker", - expClient: new(docker.Client), + expClient: new(dockerhub.Client), expHost: "bar.docker.io", expPath: "registry/joshvanl/version-checker", }, + // ACR "versionchecker.azurecr.io should be acr": { url: "versionchecker.azurecr.io/jetstack-cre/version-checker", expClient: new(acr.Client), @@ -101,6 +102,7 @@ func TestFromImageURL(t *testing.T) { expPath: "version-checker", }, + // ECR "123.dkr.foo.amazon.com should be ecr": { url: "123.dkr.ecr.foo.amazonaws.com/version-checker", expClient: new(ecr.Client), @@ -114,6 +116,7 @@ func TestFromImageURL(t *testing.T) { expPath: "jetstack/joshvanl/version-checker", }, + // GCR / GAR "gcr.io should be gcr": { url: "gcr.io/jetstack-cre/version-checker", expClient: new(gcr.Client), @@ -139,6 +142,7 @@ func TestFromImageURL(t *testing.T) { expPath: "sig-storage/csi-node-driver-registrar", }, + // GHCR "ghcr.io should be ghcr": { url: "ghcr.io/jetstack/version-checker", expClient: new(ghcr.Client), @@ -152,6 +156,7 @@ func TestFromImageURL(t *testing.T) { expPath: "k8s-artifacts-prod/ingress-nginx/nginx", }, + // QUAY "quay.io should be quay": { url: "quay.io/jetstack/version-checker", expClient: new(quay.Client), @@ -164,15 +169,17 @@ func TestFromImageURL(t *testing.T) { expHost: "us.quay.io", expPath: "k8s-artifacts-prod/ingress-nginx/nginx", }, + + // OCI / Self Hosted "selfhosted should be selfhosted": { url: "docker.repositories.yourdomain.com/ingress-nginx/nginx", - expClient: new(selfhosted.Client), + expClient: new(oci.Client), expHost: "docker.repositories.yourdomain.com", expPath: "ingress-nginx/nginx", }, "selfhosted with different domain should be fallback": { url: "registry.opensource.zalan.do/teapot/external-dns", - expClient: new(fallback.Client), + expClient: new(oci.Client), expHost: "registry.opensource.zalan.do", expPath: "teapot/external-dns", }, @@ -181,20 +188,13 @@ func TestFromImageURL(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { client, host, path := handler.fromImageURL(test.url) - if reflect.TypeOf(client) != reflect.TypeOf(test.expClient) { - t.Errorf("unexpected client, exp=%v got=%v", - reflect.TypeOf(test.expClient), reflect.TypeOf(client)) - } - - if host != test.expHost { - t.Errorf("unexpected host, exp=%v got=%v", - test.expHost, host) - } - if path != test.expPath { - t.Errorf("unexpected path, exp=%s got=%s", - test.expPath, path) + if test.expClient != nil { + require.NotNil(t, client) } + require.IsType(t, test.expClient, client) + assert.Equal(t, test.expHost, host) + assert.Equal(t, test.expPath, path) }) } } diff --git a/pkg/client/docker/path.go b/pkg/client/docker/path.go deleted file mode 100644 index 636b150e..00000000 --- a/pkg/client/docker/path.go +++ /dev/null @@ -1,25 +0,0 @@ -package docker - -import ( - "regexp" - "strings" -) - -var ( - dockerReg = regexp.MustCompile(`(^(.*\.)?docker.com$)|(^(.*\.)?docker.io$)`) -) - -func (c *Client) IsHost(host string) bool { - return host == "" || dockerReg.MatchString(host) -} - -func (c *Client) RepoImageFromPath(path string) (string, string) { - split := strings.Split(path, "/") - - lenSplit := len(split) - if lenSplit == 1 { - return "library", split[0] - } - - return split[lenSplit-2], split[lenSplit-1] -} diff --git a/pkg/client/docker/docker.go b/pkg/client/dockerhub/client.go similarity index 83% rename from pkg/client/docker/docker.go rename to pkg/client/dockerhub/client.go index e3951bdd..6c2d3bfd 100644 --- a/pkg/client/docker/docker.go +++ b/pkg/client/dockerhub/client.go @@ -1,4 +1,4 @@ -package docker +package dockerhub import ( "context" @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/google/go-containerregistry/pkg/authn" "github.com/sirupsen/logrus" retryablehttp "github.com/hashicorp/go-retryablehttp" @@ -23,18 +24,23 @@ const ( ) type Options struct { - Username string - Password string + // Deprecated: Username should use new AuthConfig + Username string + // Deprecated: Password should use new AuthConfig + Password string + // Deprecated: Token should use new AuthConfig Token string Transporter http.RoundTripper } +var _ api.ImageClient = (*Client)(nil) + type Client struct { *http.Client Options } -func New(opts Options, log *logrus.Entry) (*Client, error) { +func NewClient(opts Options, auth *authn.AuthConfig, log *logrus.Entry) (*Client, error) { ctx := context.Background() retryclient := retryablehttp.NewClient() if opts.Transporter != nil { @@ -46,12 +52,12 @@ func New(opts Options, log *logrus.Entry) (*Client, error) { retryclient.RetryWaitMin = 1 * time.Second // This custom backoff will fail requests that have a max wait of the RetryWaitMax retryclient.Backoff = util.HTTPBackOff - retryclient.Logger = log.WithField("client", "docker") + retryclient.Logger = util.NewLeveledLogger(log.WithField("client", "docker")) client := retryclient.StandardClient() // Setup Auth if username and password used. - if len(opts.Username) > 0 || len(opts.Password) > 0 { - if len(opts.Token) > 0 { + if len(auth.Username) > 0 || len(auth.Password) > 0 { + if len(auth.RegistryToken) > 0 { return nil, errors.New("cannot specify Token as well as username/password") } @@ -72,7 +78,10 @@ func (c *Client) Name() string { return "dockerhub" } -func (c *Client) Tags(ctx context.Context, _, repo, image string) ([]api.ImageTag, error) { +func (c *Client) Tags(ctx context.Context, host, repo, image string) ([]api.ImageTag, error) { + if len(repo) == 0 && len(image) == 0 { + return nil, fmt.Errorf("unknown Image lookup: %s:%s/%s", host, repo, image) + } url := fmt.Sprintf(lookupURL, repo, image) var tags []api.ImageTag diff --git a/pkg/client/dockerhub/client_test.go b/pkg/client/dockerhub/client_test.go new file mode 100644 index 00000000..20c05da6 --- /dev/null +++ b/pkg/client/dockerhub/client_test.go @@ -0,0 +1,39 @@ +package dockerhub + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepoImage(t *testing.T) { + tests := map[string]struct { + path string + expRepo, expImage string + }{ + "single image should return library": { + path: "nginx", + expRepo: "library", + expImage: "nginx", + }, + "two segments to path should return both": { + path: "joshvanl/version-checker", + expRepo: "joshvanl", + expImage: "version-checker", + }, + "multiple segments to path should return last two": { + path: "registry/joshvanl/version-checker", + expRepo: "joshvanl", + expImage: "version-checker", + }, + } + + handler := new(Client) + for name, test := range tests { + t.Run(name, func(t *testing.T) { + repo, image := handler.RepoImageFromPath(test.path) + assert.Equal(t, test.expImage, image) + assert.Equal(t, test.expRepo, repo) + }) + } +} diff --git a/pkg/client/dockerhub/factory.go b/pkg/client/dockerhub/factory.go new file mode 100644 index 00000000..60f4f464 --- /dev/null +++ b/pkg/client/dockerhub/factory.go @@ -0,0 +1,59 @@ +package dockerhub + +import ( + "regexp" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +var _ api.ImageClientFactory = (*Factory)(nil) +var _ authn.Keychain = (*Factory)(nil) + +var ( + dockerReg = regexp.MustCompile(`(^(.*\.)?docker.com$)|(^(.*\.)?docker.io$)`) +) + +type Factory struct { + opts Options +} + +func NewFactory(opts Options) *Factory { + return &Factory{opts: opts} +} + +func (f *Factory) NewClient(auth *authn.AuthConfig, log *logrus.Entry) (api.ImageClient, error) { + return NewClient(f.opts, auth, log) +} + +func (f *Factory) Name() string { + return "dockerhub" +} + +func (f *Factory) IsHost(host string) bool { + return host == "" || dockerReg.MatchString(host) +} + +func (c *Client) RepoImageFromPath(path string) (string, string) { + split := strings.Split(path, "/") + + lenSplit := len(split) + if lenSplit == 1 { + return "library", split[0] + } + + return split[lenSplit-2], split[lenSplit-1] +} + +func (f *Factory) Resolve(res authn.Resource) (authn.Authenticator, error) { + if !f.IsHost(res.RegistryStr()) { + return authn.Anonymous, nil + } + return authn.FromConfig(authn.AuthConfig{ + Username: f.opts.Username, + Password: f.opts.Password, + RegistryToken: f.opts.Token, + }), nil +} diff --git a/pkg/client/docker/path_test.go b/pkg/client/dockerhub/factory_test.go similarity index 55% rename from pkg/client/docker/path_test.go rename to pkg/client/dockerhub/factory_test.go index ec1d3bc9..780eb8b2 100644 --- a/pkg/client/docker/path_test.go +++ b/pkg/client/dockerhub/factory_test.go @@ -1,6 +1,10 @@ -package docker +package dockerhub -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsHost(t *testing.T) { tests := map[string]struct { @@ -61,47 +65,10 @@ func TestIsHost(t *testing.T) { }, } - handler := new(Client) - for name, test := range tests { - t.Run(name, func(t *testing.T) { - if isHost := handler.IsHost(test.host); isHost != test.expIs { - t.Errorf("%s: unexpected IsHost, exp=%t got=%t", - test.host, test.expIs, isHost) - } - }) - } -} - -func TestRepoImage(t *testing.T) { - tests := map[string]struct { - path string - expRepo, expImage string - }{ - "single image should return library": { - path: "nginx", - expRepo: "library", - expImage: "nginx", - }, - "two segments to path should return both": { - path: "joshvanl/version-checker", - expRepo: "joshvanl", - expImage: "version-checker", - }, - "multiple segments to path should return last two": { - path: "registry/joshvanl/version-checker", - expRepo: "joshvanl", - expImage: "version-checker", - }, - } - - handler := new(Client) + handler := new(Factory) for name, test := range tests { t.Run(name, func(t *testing.T) { - repo, image := handler.RepoImageFromPath(test.path) - if repo != test.expRepo && image != test.expImage { - t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", - test.path, test.expRepo, test.expImage, repo, image) - } + assert.Equal(t, test.expIs, handler.IsHost(test.host)) }) } } diff --git a/pkg/client/docker/types.go b/pkg/client/dockerhub/types.go similarity index 96% rename from pkg/client/docker/types.go rename to pkg/client/dockerhub/types.go index bfeaa994..25218353 100644 --- a/pkg/client/docker/types.go +++ b/pkg/client/dockerhub/types.go @@ -1,4 +1,4 @@ -package docker +package dockerhub import "github.com/jetstack/version-checker/pkg/api" diff --git a/pkg/client/ecr/ecr.go b/pkg/client/ecr/client.go similarity index 84% rename from pkg/client/ecr/ecr.go rename to pkg/client/ecr/client.go index 3654fee4..c437c957 100644 --- a/pkg/client/ecr/ecr.go +++ b/pkg/client/ecr/client.go @@ -4,20 +4,26 @@ import ( "context" "fmt" "net/http" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/sirupsen/logrus" "github.com/jetstack/version-checker/pkg/api" "github.com/jetstack/version-checker/pkg/client/util" ) +var _ api.ImageClient = (*Client)(nil) + type Client struct { Config aws.Config Options + Logger *logrus.Entry } type Options struct { @@ -28,9 +34,10 @@ type Options struct { Transporter http.RoundTripper } -func New(opts Options) *Client { +func NewClient(opts Options, _ *authn.AuthConfig, logger *logrus.Entry) *Client { return &Client{ Options: opts, + Logger: logger, } } @@ -111,3 +118,13 @@ func (c *Client) createClient(ctx context.Context, region string) (*ecr.Client, } return ecr.NewFromConfig(cfg), nil } + +func (c *Client) RepoImageFromPath(path string) (string, string) { + lastIndex := strings.LastIndex(path, "/") + + if lastIndex == -1 { + return "", path + } + + return path[:lastIndex], path[lastIndex+1:] +} diff --git a/pkg/client/ecr/client_test.go b/pkg/client/ecr/client_test.go new file mode 100644 index 00000000..9655a2ee --- /dev/null +++ b/pkg/client/ecr/client_test.go @@ -0,0 +1,44 @@ +package ecr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepoImage(t *testing.T) { + tests := map[string]struct { + path string + expRepo, expImage string + }{ + "single image should return as image": { + path: "kube-scheduler", + expRepo: "", + expImage: "kube-scheduler", + }, + "two segments to path should return both": { + path: "jetstack-cre/version-checker", + expRepo: "jetstack-cre", + expImage: "version-checker", + }, + "multiple segments to path should return all in repo, last segment image": { + path: "k8s-artifacts-prod/ingress-nginx/nginx", + expRepo: "k8s-artifacts-prod/ingress-nginx", + expImage: "nginx", + }, + "region": { + path: "000000000000.dkr.ecr.eu-west-2.amazonaws.com/version-checker", + expRepo: "000000000000.dkr.ecr.eu-west-2.amazonaws.com", + expImage: "version-checker", + }, + } + + handler := new(Client) + for name, test := range tests { + t.Run(name, func(t *testing.T) { + repo, image := handler.RepoImageFromPath(test.path) + assert.Equal(t, repo, test.expRepo) + assert.Equal(t, image, test.expImage) + }) + } +} diff --git a/pkg/client/ecr/factory.go b/pkg/client/ecr/factory.go new file mode 100644 index 00000000..8aa3593f --- /dev/null +++ b/pkg/client/ecr/factory.go @@ -0,0 +1,42 @@ +package ecr + +import ( + "regexp" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +var ( + ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?$`) +) + +var _ api.ImageClientFactory = (*Factory)(nil) +var _ authn.Keychain = (*Factory)(nil) + +type Factory struct { + opts Options +} + +func NewFactory(opts Options) *Factory { + return &Factory{opts: opts} +} + +func (f *Factory) Name() string { + return "ecr" +} + +func (f *Factory) IsHost(host string) bool { + return ecrPattern.MatchString(host) +} + +func (f *Factory) NewClient(_ *authn.AuthConfig, log *logrus.Entry) (api.ImageClient, error) { + return NewClient(f.opts, nil, log), nil +} + +func (f *Factory) Resolve(res authn.Resource) (authn.Authenticator, error) { + // We don't support ECR authentication via keychain + // We'll use the default k8schain handler and ECR Helper + return nil, nil +} diff --git a/pkg/client/ecr/path_test.go b/pkg/client/ecr/factory_test.go similarity index 56% rename from pkg/client/ecr/path_test.go rename to pkg/client/ecr/factory_test.go index 79f08058..c772e4fc 100644 --- a/pkg/client/ecr/path_test.go +++ b/pkg/client/ecr/factory_test.go @@ -61,50 +61,10 @@ func TestIsHost(t *testing.T) { }, } - handler := new(Client) + handler := new(Factory) for name, test := range tests { t.Run(name, func(t *testing.T) { - if isHost := handler.IsHost(test.host); isHost != test.expIs { - t.Errorf("%s: unexpected IsHost, exp=%t got=%t", - test.host, test.expIs, isHost) - } - }) - } -} - -func TestRepoImage(t *testing.T) { - tests := map[string]struct { - path string - expRepo, expImage string - }{ - "single image should return as image": { - path: "kube-scheduler", - expRepo: "", - expImage: "kube-scheduler", - }, - "two segments to path should return both": { - path: "jetstack-cre/version-checker", - expRepo: "jetstack-cre", - expImage: "version-checker", - }, - "multiple segments to path should return all in repo, last segment image": { - path: "k8s-artifacts-prod/ingress-nginx/nginx", - expRepo: "k8s-artifacts-prod/ingress-nginx", - expImage: "nginx", - }, - "region": { - path: "000000000000.dkr.ecr.eu-west-2.amazonaws.com/version-checker", - expRepo: "000000000000.dkr.ecr.eu-west-2.amazonaws.com", - expImage: "version-checker", - }, - } - - handler := new(Client) - for name, test := range tests { - t.Run(name, func(t *testing.T) { - repo, image := handler.RepoImageFromPath(test.path) - assert.Equal(t, repo, test.expRepo) - assert.Equal(t, image, test.expImage) + assert.Equal(t, test.expIs, handler.IsHost(test.host)) }) } } diff --git a/pkg/client/ecr/path.go b/pkg/client/ecr/path.go deleted file mode 100644 index 6661b675..00000000 --- a/pkg/client/ecr/path.go +++ /dev/null @@ -1,24 +0,0 @@ -package ecr - -import ( - "regexp" - "strings" -) - -var ( - ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?$`) -) - -func (c *Client) IsHost(host string) bool { - return ecrPattern.MatchString(host) -} - -func (c *Client) RepoImageFromPath(path string) (string, string) { - lastIndex := strings.LastIndex(path, "/") - - if lastIndex == -1 { - return "", path - } - - return path[:lastIndex], path[lastIndex+1:] -} diff --git a/pkg/client/fallback/fallback.go b/pkg/client/fallback/fallback.go deleted file mode 100644 index 12fc0c52..00000000 --- a/pkg/client/fallback/fallback.go +++ /dev/null @@ -1,74 +0,0 @@ -package fallback - -import ( - "context" - "fmt" - "time" - - "github.com/jetstack/version-checker/pkg/api" - - "github.com/patrickmn/go-cache" - - "github.com/sirupsen/logrus" -) - -type Client struct { - clients []api.ImageClient - - log *logrus.Entry - hostCache *cache.Cache -} - -func New(ctx context.Context, log *logrus.Entry, clients []api.ImageClient) (*Client, error) { - return &Client{ - clients: clients, - hostCache: cache.New(5*time.Hour, 10*time.Hour), - log: log.WithField("client", "fallback"), - }, nil -} - -func (c *Client) Name() string { - return "fallback" -} - -func (c *Client) Tags(ctx context.Context, host, repo, image string) (tags []api.ImageTag, err error) { - // Check if we have a cached client for the host - if client, found := c.hostCache.Get(host); found { - c.log.Infof("Found client for host %s in cache", host) - if client, ok := client.(api.ImageClient); ok { - if tags, err := client.Tags(ctx, host, repo, image); err == nil { - return tags, nil - } - } else { - c.log.Errorf("Unable to fetch from cache for host %s...", host) - } - } - c.log.Debugf("no client for host %s in cache, continuing fallback", host) - - // Try clients, one by one until we have none left.. - for i, client := range c.clients { - if tags, err := client.Tags(ctx, host, repo, image); err == nil { - c.hostCache.SetDefault(host, client) - return tags, nil - } - - remaining := len(c.clients) - i - 1 - if remaining == 0 { - c.log.Debugf("failed to lookup via %q, Giving up, no more clients", client.Name()) - } else { - c.log.Debugf("failed to lookup via %q, continuing to search with %v clients remaining", client.Name(), remaining) - } - } - - // If both clients fail, return an error - return nil, fmt.Errorf("failed to fetch tags for host: %s, repo: %s, image: %s", host, repo, image) -} - -func (c *Client) IsHost(_ string) bool { - return true -} - -// Function only added to match ImageClient Interface -func (c *Client) RepoImageFromPath(path string) (string, string) { - return "", "" -} diff --git a/pkg/client/gcr/gcr.go b/pkg/client/gcr/client.go similarity index 82% rename from pkg/client/gcr/gcr.go rename to pkg/client/gcr/client.go index 15ebccc6..fe938c46 100644 --- a/pkg/client/gcr/gcr.go +++ b/pkg/client/gcr/client.go @@ -7,9 +7,12 @@ import ( "io" "net/http" "strconv" + "strings" "time" + "github.com/google/go-containerregistry/pkg/authn" "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" ) const ( @@ -21,23 +24,18 @@ type Options struct { Transporter http.RoundTripper } +var _ api.ImageClient = (*Client)(nil) + type Client struct { *http.Client Options + *logrus.Entry } -type Response struct { - Manifest map[string]ManifestItem `json:"manifest"` -} - -type ManifestItem struct { - Tag []string `json:"tag"` - TimeCreated string `json:"timeCreatedMs"` -} - -func New(opts Options) *Client { +func NewClient(opts Options, _ *authn.AuthConfig, log *logrus.Entry) *Client { return &Client{ Options: opts, + Entry: log.WithField("client", "gcr"), Client: &http.Client{ Timeout: time.Second * 5, Transport: opts.Transporter, @@ -125,3 +123,14 @@ func (c *Client) convertTimestamp(timeCreated string) (time.Time, error) { } return time.Unix(0, miliTimestamp*int64(1000000)), nil } + +func (c *Client) RepoImageFromPath(path string) (string, string) { + lastIndex := strings.LastIndex(path, "/") + + // If there's no slash, then its a "root" level image + if lastIndex == -1 { + return "", path + } + + return path[:lastIndex], path[lastIndex+1:] +} diff --git a/pkg/client/gcr/client_test.go b/pkg/client/gcr/client_test.go new file mode 100644 index 00000000..05c50a93 --- /dev/null +++ b/pkg/client/gcr/client_test.go @@ -0,0 +1,39 @@ +package gcr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRepoImage(t *testing.T) { + tests := map[string]struct { + path string + expRepo, expImage string + }{ + "single image should return google-containers": { + path: "kube-scheduler", + expRepo: "google-containers", + expImage: "kube-scheduler", + }, + "two segments to path should return both": { + path: "jetstack-cre/version-checker", + expRepo: "jetstack-cre", + expImage: "version-checker", + }, + "multiple segments to path should return all in repo, last segment image": { + path: "k8s-artifacts-prod/ingress-nginx/nginx", + expRepo: "k8s-artifacts-prod/ingress-nginx", + expImage: "nginx", + }, + } + + handler := new(Client) + for name, test := range tests { + t.Run(name, func(t *testing.T) { + repo, image := handler.RepoImageFromPath(test.path) + assert.Equal(t, test.expImage, image) + assert.Equal(t, test.expRepo, repo) + }) + } +} diff --git a/pkg/client/gcr/factory.go b/pkg/client/gcr/factory.go new file mode 100644 index 00000000..a83d24ea --- /dev/null +++ b/pkg/client/gcr/factory.go @@ -0,0 +1,46 @@ +package gcr + +import ( + "regexp" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +var ( + reg = regexp.MustCompile(`(^(.*\.)?gcr.io$|^(.*\.)?k8s.io$|^(.+)-docker.pkg.dev$)`) +) + +// Ensure that our Factory adhere to the ImageClientFactory interface +var _ api.ImageClientFactory = (*Factory)(nil) +var _ authn.Keychain = (*Factory)(nil) + +type Factory struct { + Options +} + +func NewFactory(opts Options) *Factory { + return &Factory{ + Options: opts, + } +} + +func (f *Factory) IsHost(host string) bool { + return reg.MatchString(host) +} + +func (f *Factory) Name() string { + return "gcr" +} + +func (f *Factory) NewClient(auth *authn.AuthConfig, log *logrus.Entry) (api.ImageClient, error) { + return NewClient(f.Options, auth, log), nil +} + +func (f *Factory) Resolve(res authn.Resource) (authn.Authenticator, error) { + if f.IsHost(res.RegistryStr()) { + return authn.FromConfig(authn.AuthConfig{IdentityToken: f.Token}), nil + } + return nil, nil +} diff --git a/pkg/client/gcr/path_test.go b/pkg/client/gcr/factory_test.go similarity index 50% rename from pkg/client/gcr/path_test.go rename to pkg/client/gcr/factory_test.go index 703c59a7..ec4f581b 100644 --- a/pkg/client/gcr/path_test.go +++ b/pkg/client/gcr/factory_test.go @@ -1,6 +1,10 @@ package gcr -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestIsHost(t *testing.T) { tests := map[string]struct { @@ -57,47 +61,10 @@ func TestIsHost(t *testing.T) { }, } - handler := new(Client) - for name, test := range tests { - t.Run(name, func(t *testing.T) { - if isHost := handler.IsHost(test.host); isHost != test.expIs { - t.Errorf("%s: unexpected IsHost, exp=%t got=%t", - test.host, test.expIs, isHost) - } - }) - } -} - -func TestRepoImage(t *testing.T) { - tests := map[string]struct { - path string - expRepo, expImage string - }{ - "single image should return google-containers": { - path: "kube-scheduler", - expRepo: "google-containers", - expImage: "kube-scheduler", - }, - "two segments to path should return both": { - path: "jetstack-cre/version-checker", - expRepo: "jetstack-cre", - expImage: "version-checker", - }, - "multiple segments to path should return all in repo, last segment image": { - path: "k8s-artifacts-prod/ingress-nginx/nginx", - expRepo: "k8s-artifacts-prod/ingress-nginx", - expImage: "nginx", - }, - } - - handler := new(Client) + handler := new(Factory) for name, test := range tests { t.Run(name, func(t *testing.T) { - repo, image := handler.RepoImageFromPath(test.path) - if repo != test.expRepo && image != test.expImage { - t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", - test.path, test.expRepo, test.expImage, repo, image) - } + assert.Equal(t, test.expIs, handler.IsHost(test.host)) }) } } diff --git a/pkg/client/gcr/path.go b/pkg/client/gcr/path.go deleted file mode 100644 index 69b678ea..00000000 --- a/pkg/client/gcr/path.go +++ /dev/null @@ -1,25 +0,0 @@ -package gcr - -import ( - "regexp" - "strings" -) - -var ( - reg = regexp.MustCompile(`(^(.*\.)?gcr.io$|^(.*\.)?k8s.io$|^(.+)-docker.pkg.dev$)`) -) - -func (c *Client) IsHost(host string) bool { - return reg.MatchString(host) -} - -func (c *Client) RepoImageFromPath(path string) (string, string) { - lastIndex := strings.LastIndex(path, "/") - - // If there's no slash, then its a "root" level image - if lastIndex == -1 { - return "", path - } - - return path[:lastIndex], path[lastIndex+1:] -} diff --git a/pkg/client/gcr/types.go b/pkg/client/gcr/types.go new file mode 100644 index 00000000..7bdf298a --- /dev/null +++ b/pkg/client/gcr/types.go @@ -0,0 +1,10 @@ +package gcr + +type Response struct { + Manifest map[string]ManifestItem `json:"manifest"` +} + +type ManifestItem struct { + Tag []string `json:"tag"` + TimeCreated string `json:"timeCreatedMs"` +} diff --git a/pkg/client/ghcr/ghcr.go b/pkg/client/ghcr/client.go similarity index 74% rename from pkg/client/ghcr/ghcr.go rename to pkg/client/ghcr/client.go index 40421d7e..7ed5870b 100644 --- a/pkg/client/ghcr/ghcr.go +++ b/pkg/client/ghcr/client.go @@ -8,38 +8,48 @@ import ( "strings" "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" "github.com/gofri/go-github-ratelimit/github_ratelimit" + "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-github/v70/github" ) type Options struct { - Token string + Token string + // Hostname is the hostname of the GitHub Enterprise instance, if empty it defaults to "ghcr.io". Hostname string Transporter http.RoundTripper } +var _ api.ImageClient = (*Client)(nil) + type Client struct { client *github.Client opts Options ownerTypes map[string]string } -func New(opts Options) *Client { +// New creates a new GitHub Container Registry client with the provided options and authenticator. +// It initializes the client with rate limiting and authentication, and sets up the necessary URLs for GitHub Enterprise if specified. +var New = NewClient + +func NewClient(opts Options, creds *authn.AuthConfig, log *logrus.Entry) *Client { + rateLimitDetection := func(ctx *github_ratelimit.CallbackContext) { - fmt.Printf("Hit Github Rate Limit, sleeping for %v", ctx.TotalSleepTime) + log.Warnf("Hit Github Rate Limit, sleeping for %v", ctx.TotalSleepTime) } ghRatelimitOpts := github_ratelimit.WithLimitDetectedCallback(rateLimitDetection) ghRateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(opts.Transporter, ghRatelimitOpts) if err != nil { - panic(err) + log.Fatalf("failed creating rate limiter client: %s", err) } - client := github.NewClient(ghRateLimiter).WithAuthToken(opts.Token) + client := github.NewClient(ghRateLimiter).WithAuthToken(creds.RegistryToken) if opts.Hostname != "" { client, err = client.WithEnterpriseURLs(fmt.Sprintf("https://%s/", opts.Hostname), fmt.Sprintf("https://%s/api/uploads/", opts.Hostname)) if err != nil { - panic(fmt.Errorf("failed setting enterprise URLs: %w", err)) + log.Fatalf("failed setting enterprise URLs: %s", err) } } @@ -50,6 +60,7 @@ func New(opts Options) *Client { } } +// Name returns the name of the client, adding suffix if using a custom Hostname. func (c *Client) Name() string { return "ghcr" } @@ -144,3 +155,15 @@ func (c *Client) ownerType(ctx context.Context, owner string) (string, error) { return ownerType, nil } + +func (c *Client) RepoImageFromPath(path string) (string, string) { + var owner, pkg string + parts := strings.SplitN(path, "/", 2) + if len(parts) > 0 { + owner = parts[0] + } + if len(parts) > 1 { + pkg = parts[1] + } + return owner, pkg +} diff --git a/pkg/client/ghcr/ghcr_test.go b/pkg/client/ghcr/client_test.go similarity index 73% rename from pkg/client/ghcr/ghcr_test.go rename to pkg/client/ghcr/client_test.go index 39c2f6b6..de868745 100644 --- a/pkg/client/ghcr/ghcr_test.go +++ b/pkg/client/ghcr/client_test.go @@ -2,11 +2,14 @@ package ghcr import ( "context" + "io" "net/http" "testing" + "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-github/v70/github" "github.com/jarcoal/httpmock" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) @@ -60,6 +63,9 @@ func registerTagResponders() { }) } +var testLogger = logrus.NewEntry(&logrus.Logger{Out: io.Discard}) +var anonAuth, _ = authn.Anonymous.Authorization() + func TestClient_Tags(t *testing.T) { setup() defer teardown() @@ -72,7 +78,7 @@ func TestClient_Tags(t *testing.T) { registerCommonResponders() registerTagResponders() - client := New(Options{}) + client := New(Options{}, anonAuth, testLogger) client.client = github.NewClient(nil) // Use the default HTTP client tags, err := client.Tags(ctx, host, "test-user-owner", "test-repo") @@ -89,7 +95,7 @@ func TestClient_Tags(t *testing.T) { return httpmock.NewStringResponse(404, `{"message": "Not Found"}`), nil }) - client := New(Options{}) + client := New(Options{}, anonAuth, testLogger) client.client = github.NewClient(nil) // Use the default HTTP client _, err := client.Tags(ctx, host, "test-user-owner", "test-repo") @@ -107,7 +113,7 @@ func TestClient_Tags(t *testing.T) { }) registerTagResponders() - client := New(Options{}) // No token provided + client := New(Options{}, anonAuth, testLogger) // No token provided client.client = github.NewClient(nil) _, err := client.Tags(ctx, host, "test-user-owner", "test-repo") @@ -129,7 +135,7 @@ func TestClient_Tags(t *testing.T) { registerTagResponders() - client := New(Options{Token: token}) + client := New(Options{}, &authn.AuthConfig{RegistryToken: token}, testLogger) _, err := client.Tags(ctx, host, "test-user-owner", "test-repo") assert.NoError(t, err) @@ -140,7 +146,7 @@ func TestClient_Tags(t *testing.T) { registerCommonResponders() registerTagResponders() - client := New(Options{}) + client := New(Options{}, anonAuth, testLogger) client.client = github.NewClient(nil) // Use the default HTTP client tags, err := client.Tags(ctx, host, "test-user-owner", "test-repo") @@ -155,7 +161,7 @@ func TestClient_Tags(t *testing.T) { registerCommonResponders() registerTagResponders() - client := New(Options{}) + client := New(Options{}, &authn.AuthConfig{}, testLogger) client.client = github.NewClient(nil) // Use the default HTTP client tags, err := client.Tags(ctx, host, "test-org-owner", "test-repo") @@ -165,3 +171,43 @@ func TestClient_Tags(t *testing.T) { assert.Equal(t, "tag2", tags[1].Tag) }) } + +func TestRepoImage(t *testing.T) { + tests := map[string]struct { + path string + expRepo, expImage string + }{ + "empty path should be interpreted as an empty repo and image": { + path: "", + expRepo: "", + expImage: "", + }, + "one segement should be interpreted as 'repo'": { + path: "jetstack-cre", + expRepo: "jetstack-cre", + expImage: "", + }, + "two segments to path should return both": { + path: "jetstack-cre/version-checker", + expRepo: "jetstack-cre", + expImage: "version-checker", + }, + "multiple segments to path should return first segment in repo, rest in image": { + path: "k8s-artifacts-prod/ingress-nginx/nginx", + expRepo: "k8s-artifacts-prod", + expImage: "ingress-nginx/nginx", + }, + } + + handler := new(Client) + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // handler.opts.Token = "fake-token" + repo, image := handler.RepoImageFromPath(test.path) + if repo != test.expRepo && image != test.expImage { + t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", + test.path, test.expRepo, test.expImage, repo, image) + } + }) + } +} diff --git a/pkg/client/ghcr/factory.go b/pkg/client/ghcr/factory.go new file mode 100644 index 00000000..aaf3e51a --- /dev/null +++ b/pkg/client/ghcr/factory.go @@ -0,0 +1,64 @@ +package ghcr + +import ( + "fmt" + "regexp" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +// Ensure that our Factory adhere to the ImageClientFactory interface +var _ api.ImageClientFactory = (*Factory)(nil) +var _ authn.Keychain = (*Factory)(nil) + +type Factory struct { + opts Options +} + +const ( + HostRegTempl = `^(containers\.[a-zA-Z0-9-]+\.ghe\.com|ghcr\.io)$` +) + +var HostReg = regexp.MustCompile(HostRegTempl) + +func NewFactory(opts Options) *Factory { + return &Factory{opts: opts} +} + +// Name returns the name of the client, adding suffix if using a custom Hostname. +func (f *Factory) Name() string { + str := "ghcr" + if f.opts.Hostname != "" { + str += ": " + f.opts.Hostname + } + return str +} + +// IsHost returns true if the client is configured for the given host. +func (f *Factory) IsHost(host string) bool { + // If we have a custom hostname. + if f.opts.Hostname != "" && f.opts.Hostname == host { + return true + } + return HostReg.MatchString(host) +} + +func (f *Factory) NewClient(auth *authn.AuthConfig, log *logrus.Entry) (api.ImageClient, error) { + // GHCR Requires authentication for the packages API + if (auth == nil || auth == &authn.AuthConfig{}) { + return nil, fmt.Errorf("unable to create a %s Client, client requires authentication, got %T", f.Name(), auth) + } + return NewClient(f.opts, auth, log), nil +} + +func (f *Factory) Resolve(res authn.Resource) (authn.Authenticator, error) { + if !f.IsHost(res.RegistryStr()) { + return nil, nil + } + // GHCR supports authentication via a token + return authn.FromConfig(authn.AuthConfig{ + RegistryToken: f.opts.Token, + }), nil +} diff --git a/pkg/client/ghcr/path_test.go b/pkg/client/ghcr/factory_test.go similarity index 64% rename from pkg/client/ghcr/path_test.go rename to pkg/client/ghcr/factory_test.go index 8c18ce1f..ac7ab4be 100644 --- a/pkg/client/ghcr/path_test.go +++ b/pkg/client/ghcr/factory_test.go @@ -80,12 +80,9 @@ func TestIsHost(t *testing.T) { }, } - handler := new(Client) + handler := new(Factory) for name, test := range tests { t.Run(name, func(t *testing.T) { - if test.token != "" { - handler.opts.Token = test.token - } if test.customhost != nil { handler.opts.Hostname = *test.customhost } @@ -100,43 +97,3 @@ func TestIsHost(t *testing.T) { func strPtr(str string) *string { return &str } - -func TestRepoImage(t *testing.T) { - tests := map[string]struct { - path string - expRepo, expImage string - }{ - "empty path should be interpreted as an empty repo and image": { - path: "", - expRepo: "", - expImage: "", - }, - "one segement should be interpreted as 'repo'": { - path: "jetstack-cre", - expRepo: "jetstack-cre", - expImage: "", - }, - "two segments to path should return both": { - path: "jetstack-cre/version-checker", - expRepo: "jetstack-cre", - expImage: "version-checker", - }, - "multiple segments to path should return first segment in repo, rest in image": { - path: "k8s-artifacts-prod/ingress-nginx/nginx", - expRepo: "k8s-artifacts-prod", - expImage: "ingress-nginx/nginx", - }, - } - - handler := new(Client) - for name, test := range tests { - t.Run(name, func(t *testing.T) { - handler.opts.Token = "fake-token" - repo, image := handler.RepoImageFromPath(test.path) - if repo != test.expRepo && image != test.expImage { - t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", - test.path, test.expRepo, test.expImage, repo, image) - } - }) - } -} diff --git a/pkg/client/ghcr/path.go b/pkg/client/ghcr/path.go deleted file mode 100644 index 270f93b2..00000000 --- a/pkg/client/ghcr/path.go +++ /dev/null @@ -1,37 +0,0 @@ -package ghcr - -import ( - "regexp" - "strings" -) - -const ( - HostRegTempl = `^(containers\.[a-zA-Z0-9-]+\.ghe\.com|ghcr\.io)$` -) - -var HostReg = regexp.MustCompile(HostRegTempl) - -func (c *Client) IsHost(host string) bool { - // Package API requires Authentication - // This forces the Client to use the fallback method - if c.opts.Token == "" { - return false - } - // If we're using a custom hostname. - if c.opts.Hostname != "" && c.opts.Hostname == host { - return true - } - return HostReg.MatchString(host) -} - -func (c *Client) RepoImageFromPath(path string) (string, string) { - var owner, pkg string - parts := strings.SplitN(path, "/", 2) - if len(parts) > 0 { - owner = parts[0] - } - if len(parts) > 1 { - pkg = parts[1] - } - return owner, pkg -} diff --git a/pkg/client/oci/client.go b/pkg/client/oci/client.go new file mode 100644 index 00000000..0cf808e6 --- /dev/null +++ b/pkg/client/oci/client.go @@ -0,0 +1,146 @@ +package oci + +import ( + "context" + "fmt" + "net/http" + "runtime" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sirupsen/logrus" + + "github.com/jetstack/version-checker/pkg/api" +) + +type Options struct { + Transporter http.RoundTripper +} + +var _ api.ImageClient = (*Client)(nil) + +// Client is a client for a registry compatible with the OCI Distribution Spec +type Client struct { + *Options + puller *remote.Puller + log *logrus.Entry +} + +// New returns a new client +func NewClient(opts *Options, auth *authn.AuthConfig, log *logrus.Entry) (*Client, error) { + pullOpts := []remote.Option{ + remote.WithJobs(runtime.NumCPU()), + remote.WithUserAgent("version-checker"), + remote.WithAuth( + // We need to convert it back to an Authenticator for the Puller + authn.FromConfig(*auth), + ), + } + if opts.Transporter != nil { + pullOpts = append(pullOpts, remote.WithTransport(opts.Transporter)) + } + + puller, err := remote.NewPuller(pullOpts...) + if err != nil { + return nil, fmt.Errorf("creating puller: %w", err) + } + + return &Client{ + puller: puller, + Options: opts, + log: log.WithField("client", "oci"), + }, nil +} + +// Name is the name of this client +func (c *Client) Name() string { + return "oci" +} + +// Tags lists all the tags in the specified repository +func (c *Client) Tags(ctx context.Context, host, repo, image string) ([]api.ImageTag, error) { + log := c.log.WithField("host", host) + reg, err := name.NewRegistry(host) + if err != nil { + return nil, fmt.Errorf("parsing registry host: %w", err) + } + + log.Debugf("Listing tags for %s/%s", repo, image) + bareTags, err := c.puller.List(ctx, reg.Repo(repo, image)) + if err != nil { + return nil, fmt.Errorf("listing tags: %w", err) + } + log.Debugf("Found %v Tags for %s/%s", len(bareTags), repo, image) + + var tags []api.ImageTag + for _, tag := range bareTags { + tlog := log.WithField("tag", tag) + tlog.Debug("Getting descriptor") + + imgref := reg.Repo(repo, image).Tag(tag) + desc, err := c.puller.Get(ctx, imgref) + if err != nil { + return nil, fmt.Errorf("getting descriptor for tag %s: %w", tag, err) + } + + // If we detect a OCI or Docker Manifest Index.. lets process as is + if desc.MediaType.IsIndex() { + tlog.Debug("Discovered image index") + imgidx, err := desc.ImageIndex() + if err != nil { + return nil, fmt.Errorf("getting imageIndex for tag %s: %w", tag, err) + } + tlog.Debug("Discovering IndexManifest") + idxMan, err := imgidx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("getting index manifest for tag %s: %w", tag, err) + } + + tlog.WithField("count", len(idxMan.Manifests)).Debug("Found Manifests") + for _, man := range idxMan.Manifests { + // We need to skip attestation-manifests.. + if man.Annotations["vnd.docker.reference.type"] == "attestation-manifest" || + man.Annotations["dev.cosignproject.sigstore/attestation-type"] != "" { + tlog.WithField("digest", man.Digest.String()).Debug("Skipping attestation-manifest") + continue + } + + tags = append(tags, api.ImageTag{ + Tag: tag, + SHA: man.Digest.String(), + Architecture: api.Architecture(man.Platform.Architecture), + OS: api.OS(man.Platform.OS), + }) + } + // We continue, as to not create duplicates + continue + } + + tags = append(tags, + api.ImageTag{ + Tag: tag, + SHA: desc.Digest.String(), + }) + } + c.log.WithField("tags", tags).Debugf("Tags Discovered...") + + return tags, nil +} + +// RepoImageFromPath splits a repository path into 'repo' and 'image' segments +func (c *Client) RepoImageFromPath(path string) (string, string) { + split := strings.Split(path, "/") + + lenSplit := len(split) + if lenSplit == 1 { + return "", split[0] + } + + if lenSplit > 1 { + return split[lenSplit-2], split[lenSplit-1] + } + + return path, "" +} diff --git a/pkg/client/oci/client_test.go b/pkg/client/oci/client_test.go new file mode 100644 index 00000000..b0e83ad9 --- /dev/null +++ b/pkg/client/oci/client_test.go @@ -0,0 +1,340 @@ +package oci + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +var logger = logrus.NewEntry(&logrus.Logger{Out: io.Discard}) +var anonAuth, _ = authn.Anonymous.Authorization() +var emptyDigest, _ = empty.Image.Digest() + +func TestClientTags(t *testing.T) { + ctx := context.Background() + + type testCase struct { + repo string + img string + wantTags []api.ImageTag + wantErr bool + } + testCases := map[string]func(t *testing.T, host string) *testCase{ + "should list expected tags": func(t *testing.T, host string) *testCase { + tc := &testCase{ + repo: "foo", + img: "bar", + wantTags: []api.ImageTag{ + { + Tag: "a", + SHA: emptyDigest.String(), + }, + { + Tag: "b", + SHA: emptyDigest.String(), + }, + { + Tag: "c", + SHA: emptyDigest.String(), + }, + }, + } + repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) + require.NoError(t, err) + + for _, tag := range tc.wantTags { + err := remote.Write(repo.Tag(tag.Tag), empty.Image) + require.NoError(t, err) + } + return tc + }, + "should list expected tags within a root repository": func(t *testing.T, host string) *testCase { + tc := &testCase{ + img: "foo", + wantTags: []api.ImageTag{ + { + Tag: "a", + SHA: emptyDigest.String(), + }, + { + Tag: "b", + SHA: emptyDigest.String(), + }, + }, + } + repo, err := name.NewRepository(fmt.Sprintf("%s/%s", host, tc.img)) + require.NoError(t, err) + + for _, tag := range tc.wantTags { + err := remote.Write(repo.Tag(tag.Tag), empty.Image) + require.NoError(t, err) + } + return tc + }, + "should list expected tags within a sub-repository": func(t *testing.T, host string) *testCase { + tc := &testCase{ + repo: "foo/bar", + img: "baz", + wantTags: []api.ImageTag{ + { + Tag: "a", + SHA: emptyDigest.String(), + }, + { + Tag: "indx", + SHA: emptyDigest.String(), + }, + }, + } + repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) + require.NoError(t, err) + + for _, tag := range tc.wantTags { + err := remote.Write(repo.Tag(tag.Tag), empty.Image) + require.NoError(t, err) + } + return tc + }, + "should return all Tags when Image contains Mutiple Arch/Os": func(t *testing.T, host string) *testCase { + tc := &testCase{ + repo: "foo/bar", + img: "baz", + wantTags: []api.ImageTag{ + { + Tag: "a", + SHA: emptyDigest.String(), + OS: "linux", + Architecture: "arm64", + }, + { + Tag: "a", + SHA: emptyDigest.String(), + OS: "linux", + Architecture: "amd64", + }, + }, + } + repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) + require.NoError(t, err) + + index := mutate.IndexMediaType(empty.Index, types.OCIImageIndex) + index = mutate.AppendManifests(index, + mutate.IndexAddendum{ + Add: empty.Image, + Descriptor: v1.Descriptor{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "arm64", + }, + }, + }, + mutate.IndexAddendum{ + Add: empty.Image, + Descriptor: v1.Descriptor{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", + }, + }, + }) + + imgTag := repo.Tag("a") + err = remote.Write(imgTag, empty.Image) + require.NoError(t, err) + + err = remote.WriteIndex(imgTag, index) + require.NoError(t, err) + + return tc + }, + "should return all Tags when Image contains attestations": func(t *testing.T, host string) *testCase { + tc := &testCase{ + repo: "foo/bar", + img: "baz", + wantTags: []api.ImageTag{ + { + Tag: "a", + SHA: emptyDigest.String(), + OS: "linux", + Architecture: "amd64", + }, + }, + } + repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) + require.NoError(t, err) + + for _, tag := range tc.wantTags { + // Lets push the base image.... + imgTag := repo.Tag(tag.Tag) + err := remote.Write(imgTag, empty.Image) + require.NoError(t, err) + + subjectDigest := emptyDigest + + payload := map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://example.com/custom-attestation", + "predicate": map[string]string{"builder": "test"}, + } + payloadBytes, _ := json.Marshal(payload) + attConfig := &v1.ConfigFile{ + OS: "unknown", + Architecture: "unknown", + Config: v1.Config{ + Labels: map[string]string{ + "dev.cosignproject.attestation": string(payloadBytes), + }, + }, + } + attImg, err := mutate.ConfigFile(empty.Image, attConfig) + require.NoError(t, err) + attImg = mutate.MediaType(attImg, types.OCIManifestSchema1) + + idx := mutate.AppendManifests(empty.Index, + mutate.IndexAddendum{ + Add: empty.Image, + Descriptor: v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", + }, + }, + }, + mutate.IndexAddendum{ + Add: attImg, + Descriptor: v1.Descriptor{ + MediaType: types.OCIManifestSchema1, + Annotations: map[string]string{ + "vnd.docker.reference.digest": subjectDigest.String(), + "vnd.docker.reference.type": "attestation-manifest", + }, + Platform: &v1.Platform{ + OS: "unknown", + Architecture: "unknown", + }, + }, + }, + ) + + err = remote.WriteIndex(imgTag, idx) + require.NoError(t, err) + + } + return tc + }, + "should return an empty list and no error for a repository with no tags": func(t *testing.T, host string) *testCase { + tc := &testCase{ + repo: "foo", + img: "bar", + } + repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) + require.NoError(t, err) + + // Write a tag but then delete it so the repository + // exists but it has no tags + err = remote.Write(repo.Tag("latest"), empty.Image) + require.NoError(t, err) + err = remote.Delete(repo.Tag("latest")) + require.NoError(t, err) + + return tc + }, + "should return an error when listing a repository that doesn't exist": func(t *testing.T, host string) *testCase { + return &testCase{ + repo: "foo", + img: "bar", + wantErr: true, + } + }, + } + + for testName, fn := range testCases { + t.Run(testName, func(t *testing.T) { + host := setupRegistry(t) + + c, err := NewClient(new(Options), anonAuth, logger) + require.NoError(t, err) + + tc := fn(t, host) + + gotTags, err := c.Tags(ctx, host, tc.repo, tc.img) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Exactly(t, tc.wantTags, gotTags) + }) + } +} + +func TestClientRepoImageFromPath(t *testing.T) { + tests := map[string]struct { + path string + expRepo, expImage string + }{ + "empty path should be interpreted as an empty repo and image": { + path: "", + expRepo: "", + expImage: "", + }, + "one segment should be interpreted as 'repo'": { + path: "jetstack-cre", + expRepo: "", + expImage: "jetstack-cre", + }, + "two segments to path should return both": { + path: "jetstack-cre/version-checker", + expRepo: "jetstack-cre", + expImage: "version-checker", + }, + "multiple segments to path should return first segments in repo, last segment in image": { + path: "k8s-artifacts-prod/ingress-nginx/nginx", + expRepo: "k8s-artifacts-prod/ingress-nginx", + expImage: "nginx", + }, + } + + c, err := NewClient(new(Options), anonAuth, logger) + if err != nil { + t.Fatalf("unexpected error creating client: %s", err) + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + repo, image := c.RepoImageFromPath(test.path) + if repo != test.expRepo && image != test.expImage { + t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", + test.path, test.expRepo, test.expImage, repo, image) + } + }) + } +} + +func setupRegistry(t *testing.T) string { + r := httptest.NewServer(registry.New()) + t.Cleanup(r.Close) + u, err := url.Parse(r.URL) + if err != nil { + t.Fatalf("unexpected error parsing registry url: %s", err) + } + return u.Host +} diff --git a/pkg/client/oci/factory.go b/pkg/client/oci/factory.go new file mode 100644 index 00000000..f1ce362c --- /dev/null +++ b/pkg/client/oci/factory.go @@ -0,0 +1,39 @@ +package oci + +import ( + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +// Ensure that our client adhere to the ImageClientFactory interface +var _ api.ImageClientFactory = (*Factory)(nil) +var _ authn.Keychain = (*Factory)(nil) + +type Factory struct { + opts *Options +} + +func NewFactory(opts Options) *Factory { + return &Factory{opts: &opts} +} + +// Name is the name of this client +func (f *Factory) Name() string { + return "oci" +} + +// IsHost always returns true because it supports any host following the OCI Spec +func (f *Factory) IsHost(_ string) bool { + return true +} + +func (f *Factory) NewClient(auth *authn.AuthConfig, log *logrus.Entry) (api.ImageClient, error) { + return NewClient(f.opts, auth, log) +} + +func (f *Factory) Resolve(res authn.Resource) (authn.Authenticator, error) { + // OCI Doesn't support Auth, right now... + // We'll likely use the main keychain anyway! + return nil, nil +} diff --git a/pkg/client/oci/oci.go b/pkg/client/oci/oci.go deleted file mode 100644 index e67ed669..00000000 --- a/pkg/client/oci/oci.go +++ /dev/null @@ -1,101 +0,0 @@ -package oci - -import ( - "context" - "fmt" - "net/http" - "runtime" - "strings" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/remote" - - "github.com/jetstack/version-checker/pkg/api" -) - -type Options struct { - Transporter http.RoundTripper - Auth *authn.AuthConfig -} - -func (o *Options) Authorization() (*authn.AuthConfig, error) { - if o.Auth != nil { - return o.Auth, nil - } - return authn.Anonymous.Authorization() -} - -// Client is a client for a registry compatible with the OCI Distribution Spec -type Client struct { - *Options - puller *remote.Puller -} - -// New returns a new client -func New(opts *Options) (*Client, error) { - pullOpts := []remote.Option{ - remote.WithJobs(runtime.NumCPU()), - remote.WithUserAgent("version-checker"), - remote.WithAuth(opts), - } - if opts.Transporter != nil { - pullOpts = append(pullOpts, remote.WithTransport(opts.Transporter)) - } - - puller, err := remote.NewPuller(pullOpts...) - if err != nil { - return nil, fmt.Errorf("creating puller: %w", err) - } - - return &Client{ - puller: puller, - Options: opts, - }, nil -} - -// Name is the name of this client -func (c *Client) Name() string { - return "oci" -} - -// Tags lists all the tags in the specified repository -func (c *Client) Tags(ctx context.Context, host, repo, image string) ([]api.ImageTag, error) { - reg, err := name.NewRegistry(host) - if err != nil { - return nil, fmt.Errorf("parsing registry host: %w", err) - } - - bareTags, err := c.puller.List(ctx, reg.Repo(repo, image)) - if err != nil { - return nil, fmt.Errorf("listing tags: %w", err) - } - - var tags []api.ImageTag - for _, t := range bareTags { - tags = append(tags, api.ImageTag{Tag: t}) - } - - return tags, nil -} - -// IsHost always returns true because it supports any host -func (c *Client) IsHost(_ string) bool { - return true -} - -// RepoImageFromPath splits a repository path into 'repo' and 'image' segments -func (c *Client) RepoImageFromPath(path string) (string, string) { - split := strings.Split(path, "/") - - lenSplit := len(split) - if lenSplit == 1 { - return "", split[0] - } - - if lenSplit > 1 { - return split[lenSplit-2], split[lenSplit-1] - } - - return path, "" -} diff --git a/pkg/client/oci/oci_test.go b/pkg/client/oci/oci_test.go deleted file mode 100644 index f21a2e17..00000000 --- a/pkg/client/oci/oci_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package oci - -import ( - "context" - "fmt" - "net/http/httptest" - "net/url" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/registry" - "github.com/google/go-containerregistry/pkg/v1/empty" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/jetstack/version-checker/pkg/api" -) - -func TestClientTags(t *testing.T) { - ctx := context.Background() - - type testCase struct { - repo string - img string - wantTags []api.ImageTag - wantErr bool - } - testCases := map[string]func(t *testing.T, host string) *testCase{ - "should list expected tags": func(t *testing.T, host string) *testCase { - tc := &testCase{ - repo: "foo", - img: "bar", - wantTags: []api.ImageTag{ - { - Tag: "a", - }, - { - Tag: "b", - }, - { - Tag: "c", - }, - }, - } - repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) - if err != nil { - t.Fatalf("unexpected error parsing repo: %s", err) - } - for _, tag := range tc.wantTags { - if err := remote.Write(repo.Tag(tag.Tag), empty.Image); err != nil { - t.Fatalf("unexpected error writing image to tag: %s", err) - } - } - return tc - }, - "should list expected tags within a root repository": func(t *testing.T, host string) *testCase { - tc := &testCase{ - img: "foo", - wantTags: []api.ImageTag{ - { - Tag: "a", - }, - { - Tag: "b", - }, - }, - } - repo, err := name.NewRepository(fmt.Sprintf("%s/%s", host, tc.img)) - if err != nil { - t.Fatalf("unexpected error parsing repo: %s", err) - } - for _, tag := range tc.wantTags { - if err := remote.Write(repo.Tag(tag.Tag), empty.Image); err != nil { - t.Fatalf("unexpected error writing image to tag: %s", err) - } - } - return tc - }, - "should list expected tags within a sub-repository": func(t *testing.T, host string) *testCase { - tc := &testCase{ - repo: "foo/bar", - img: "baz", - wantTags: []api.ImageTag{ - { - Tag: "a", - }, - }, - } - repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) - if err != nil { - t.Fatalf("unexpected error parsing repo: %s", err) - } - for _, tag := range tc.wantTags { - if err := remote.Write(repo.Tag(tag.Tag), empty.Image); err != nil { - t.Fatalf("unexpected error writing image to tag: %s", err) - } - } - return tc - }, - "should return an empty list and no error for a repository with no tags": func(t *testing.T, host string) *testCase { - tc := &testCase{ - repo: "foo", - img: "bar", - } - repo, err := name.NewRepository(fmt.Sprintf("%s/%s/%s", host, tc.repo, tc.img)) - if err != nil { - t.Fatalf("unexpected error parsing repo: %s", err) - } - - // Write a tag but then delete it so the repository - // exists but it has no tags - if err := remote.Write(repo.Tag("latest"), empty.Image); err != nil { - t.Fatalf("unexpected error writing image to tag: %s", err) - } - if err := remote.Delete(repo.Tag("latest")); err != nil { - t.Fatalf("unexpected error writing image to tag: %s", err) - } - return tc - }, - "should return an error when listing a repository that doesn't exist": func(t *testing.T, host string) *testCase { - return &testCase{ - repo: "foo", - img: "bar", - wantErr: true, - } - }, - } - - for testName, fn := range testCases { - t.Run(testName, func(t *testing.T) { - host := setupRegistry(t) - - c, err := New(new(Options)) - if err != nil { - t.Fatalf("unexpected error creating client: %s", err) - } - - tc := fn(t, host) - - gotTags, err := c.Tags(ctx, host, tc.repo, tc.img) - if tc.wantErr && err == nil { - t.Errorf("unexpected nil error listing tags") - } - if !tc.wantErr && err != nil { - t.Errorf("unexpected error listing tags: %s", err) - } - if diff := cmp.Diff(tc.wantTags, gotTags); diff != "" { - t.Errorf("unexpected tags:\n%s", diff) - } - }) - } -} - -func TestClientRepoImageFromPath(t *testing.T) { - tests := map[string]struct { - path string - expRepo, expImage string - }{ - "empty path should be interpreted as an empty repo and image": { - path: "", - expRepo: "", - expImage: "", - }, - "one segment should be interpreted as 'repo'": { - path: "jetstack-cre", - expRepo: "", - expImage: "jetstack-cre", - }, - "two segments to path should return both": { - path: "jetstack-cre/version-checker", - expRepo: "jetstack-cre", - expImage: "version-checker", - }, - "multiple segments to path should return first segments in repo, last segment in image": { - path: "k8s-artifacts-prod/ingress-nginx/nginx", - expRepo: "k8s-artifacts-prod/ingress-nginx", - expImage: "nginx", - }, - } - - c, err := New(new(Options)) - if err != nil { - t.Fatalf("unexpected error creating client: %s", err) - } - for name, test := range tests { - t.Run(name, func(t *testing.T) { - repo, image := c.RepoImageFromPath(test.path) - if repo != test.expRepo && image != test.expImage { - t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", - test.path, test.expRepo, test.expImage, repo, image) - } - }) - } -} - -func setupRegistry(t *testing.T) string { - r := httptest.NewServer(registry.New()) - t.Cleanup(r.Close) - u, err := url.Parse(r.URL) - if err != nil { - t.Fatalf("unexpected error parsing registry url: %s", err) - } - return u.Host -} diff --git a/pkg/client/quay/quay.go b/pkg/client/quay/client.go similarity index 86% rename from pkg/client/quay/quay.go rename to pkg/client/quay/client.go index 7130084a..7c650b04 100644 --- a/pkg/client/quay/quay.go +++ b/pkg/client/quay/client.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" + "github.com/google/go-containerregistry/pkg/authn" "github.com/hashicorp/go-retryablehttp" "github.com/sirupsen/logrus" @@ -24,9 +26,13 @@ type Options struct { Transporter http.RoundTripper } +var _ api.ImageClient = (*Client)(nil) + type Client struct { *retryablehttp.Client Options + + keychain *authn.Keychain } type responseTag struct { @@ -59,10 +65,11 @@ type responseManifestDataItem struct { } `json:"platform"` } -func New(opts Options, log *logrus.Entry) *Client { +func NewClient(opts Options, _ *authn.AuthConfig, log *logrus.Entry) *Client { + client := retryablehttp.NewClient() client.RetryMax = 10 - client.Logger = log.WithField("client", "quay") + client.Logger = util.NewLeveledLogger(log.WithField("client", "quay")) if opts.Transporter != nil { client.HTTPClient.Transport = opts.Transporter } @@ -88,6 +95,14 @@ func (c *Client) Tags(ctx context.Context, _, repo, image string) ([]api.ImageTa return p.tags, nil } +// TODO: Replace with real Keychain +func (c *Client) Keychain() authn.Keychain { + if c.keychain != nil { + return *c.keychain + } + return authn.DefaultKeychain +} + // fetchImageManifest will lookup all manifests for a tag, if it is a list. func (c *Client) fetchImageManifest(ctx context.Context, repo, image string, tag *responseTagItem) ([]api.ImageTag, error) { timestamp, err := time.Parse(time.RFC1123Z, tag.LastModified) @@ -153,6 +168,16 @@ func (c *Client) callManifests(ctx context.Context, timestamp time.Time, tag, ur return tags, nil } +func (c *Client) RepoImageFromPath(path string) (string, string) { + lastIndex := strings.LastIndex(path, "/") + + if lastIndex == -1 { + return path, "" + } + + return path[:lastIndex], path[lastIndex+1:] +} + // makeRequest will make a call and write the response to the object. // Implements backoff. func (c *Client) makeRequest(ctx context.Context, url string, obj interface{}) error { diff --git a/pkg/client/quay/path_test.go b/pkg/client/quay/client_test.go similarity index 50% rename from pkg/client/quay/path_test.go rename to pkg/client/quay/client_test.go index 5fff08e1..a823f128 100644 --- a/pkg/client/quay/path_test.go +++ b/pkg/client/quay/client_test.go @@ -1,52 +1,8 @@ package quay -import "testing" - -func TestIsHost(t *testing.T) { - tests := map[string]struct { - host string - expIs bool - }{ - "an empty host should be false": { - host: "", - expIs: false, - }, - "random string should be false": { - host: "foobar", - expIs: false, - }, - "random string with dots should be false": { - host: "foobar.foo", - expIs: false, - }, - "just quay.io should be true": { - host: "quay.io", - expIs: true, - }, - "quay.io with random sub domains should be true": { - host: "k8s.quay.io", - expIs: true, - }, - "foodquay.io should be false": { - host: "fooquay.io", - expIs: false, - }, - "quay.iofoo should be false": { - host: "quay.iofoo", - expIs: false, - }, - } - - handler := new(Client) - for name, test := range tests { - t.Run(name, func(t *testing.T) { - if isHost := handler.IsHost(test.host); isHost != test.expIs { - t.Errorf("%s: unexpected IsHost, exp=%t got=%t", - test.host, test.expIs, isHost) - } - }) - } -} +import ( + "testing" +) func TestRepoImage(t *testing.T) { tests := map[string]struct { diff --git a/pkg/client/quay/factory.go b/pkg/client/quay/factory.go new file mode 100644 index 00000000..4a225845 --- /dev/null +++ b/pkg/client/quay/factory.go @@ -0,0 +1,52 @@ +package quay + +import ( + "fmt" + "regexp" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +var _ api.ImageClientFactory = (*Factory)(nil) +var _ authn.Keychain = (*Factory)(nil) + +type Factory struct { + opts Options +} + +var ( + reg = regexp.MustCompile(`(^(.*\.)?quay.io$)`) +) + +func NewFactory(opts Options) *Factory { + return &Factory{opts: opts} +} + +func (f *Factory) Name() string { + return "quay" +} + +func (f *Factory) IsHost(host string) bool { + return reg.MatchString(host) +} + +func (f *Factory) NewClient(auth *authn.AuthConfig, log *logrus.Entry) (api.ImageClient, error) { + // Quay.io Requires authentication for their API - Anonymous is not allowed. + if (auth == nil || auth == &authn.AuthConfig{}) { + return nil, fmt.Errorf("client requires authentication, got %T", auth) + } + + return NewClient(f.opts, auth, log), nil +} + +func (f *Factory) Resolve(res authn.Resource) (authn.Authenticator, error) { + if !f.IsHost(res.RegistryStr()) { + return nil, nil + } + + return authn.FromConfig(authn.AuthConfig{ + RegistryToken: f.opts.Token, + }), nil +} diff --git a/pkg/client/quay/factory_test.go b/pkg/client/quay/factory_test.go new file mode 100644 index 00000000..e58e0a95 --- /dev/null +++ b/pkg/client/quay/factory_test.go @@ -0,0 +1,49 @@ +package quay + +import "testing" + +func TestIsHost(t *testing.T) { + tests := map[string]struct { + host string + expIs bool + }{ + "an empty host should be false": { + host: "", + expIs: false, + }, + "random string should be false": { + host: "foobar", + expIs: false, + }, + "random string with dots should be false": { + host: "foobar.foo", + expIs: false, + }, + "just quay.io should be true": { + host: "quay.io", + expIs: true, + }, + "quay.io with random sub domains should be true": { + host: "k8s.quay.io", + expIs: true, + }, + "foodquay.io should be false": { + host: "fooquay.io", + expIs: false, + }, + "quay.iofoo should be false": { + host: "quay.iofoo", + expIs: false, + }, + } + + handler := new(Factory) + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if isHost := handler.IsHost(test.host); isHost != test.expIs { + t.Errorf("%s: unexpected IsHost, exp=%t got=%t", + test.host, test.expIs, isHost) + } + }) + } +} diff --git a/pkg/client/quay/path.go b/pkg/client/quay/path.go deleted file mode 100644 index 0510a292..00000000 --- a/pkg/client/quay/path.go +++ /dev/null @@ -1,24 +0,0 @@ -package quay - -import ( - "regexp" - "strings" -) - -var ( - reg = regexp.MustCompile(`(^(.*\.)?quay.io$)`) -) - -func (c *Client) IsHost(host string) bool { - return reg.MatchString(host) -} - -func (c *Client) RepoImageFromPath(path string) (string, string) { - lastIndex := strings.LastIndex(path, "/") - - if lastIndex == -1 { - return path, "" - } - - return path[:lastIndex], path[lastIndex+1:] -} diff --git a/pkg/client/selfhosted/api_types.go b/pkg/client/selfhosted/api_types.go deleted file mode 100644 index af205d2c..00000000 --- a/pkg/client/selfhosted/api_types.go +++ /dev/null @@ -1,29 +0,0 @@ -package selfhosted - -import ( - "time" - - "github.com/jetstack/version-checker/pkg/api" -) - -type AuthResponse struct { - Token string `json:"token"` -} - -type TagResponse struct { - Tags []string `json:"tags"` -} - -type ManifestResponse struct { - Digest string - Architecture api.Architecture `json:"architecture"` - History []History `json:"history"` -} - -type History struct { - V1Compatibility string `json:"v1Compatibility"` -} - -type V1Compatibility struct { - Created time.Time `json:"created,omitempty"` -} diff --git a/pkg/client/selfhosted/errors/errors.go b/pkg/client/selfhosted/errors/errors.go deleted file mode 100644 index 4a194ef0..00000000 --- a/pkg/client/selfhosted/errors/errors.go +++ /dev/null @@ -1,22 +0,0 @@ -package errors - -type HTTPError struct { - Body []byte - StatusCode int -} - -func NewHTTPError(statusCode int, body []byte) *HTTPError { - return &HTTPError{ - StatusCode: statusCode, - Body: body, - } -} - -func (h *HTTPError) Error() string { - return string(h.Body) -} - -func IsHTTPError(err error) (*HTTPError, bool) { - httpError, ok := err.(*HTTPError) - return httpError, ok -} diff --git a/pkg/client/selfhosted/factory.go b/pkg/client/selfhosted/factory.go new file mode 100644 index 00000000..b07f1bb2 --- /dev/null +++ b/pkg/client/selfhosted/factory.go @@ -0,0 +1,51 @@ +package selfhosted + +import ( + "net/http" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" +) + +var _ api.ImageClientFactory = (*Factory)(nil) + +type Options struct { + Host string + Username string + Password string + Bearer string + TokenPath string + Insecure bool + CAPath string + Transporter http.RoundTripper +} + +type Factory struct { + opts Options +} + +func NewFactory(opts Options) api.ImageClientFactory { + return &Factory{opts: opts} + +} + +// We don't have a self-hosted image client, so we return nil for all methods. +func (f *Factory) IsHost(_ string) bool { + return false +} + +func (f *Factory) Name() string { + return "selfhosted" +} + +func (f *Factory) NewClient(_ *authn.AuthConfig, logger *logrus.Entry) (api.ImageClient, error) { + return nil, nil +} + +func (f *Factory) Resolve(image authn.Resource) (authn.Authenticator, error) { + return authn.FromConfig(authn.AuthConfig{ + Username: "", + Password: ""}, + ), nil +} diff --git a/pkg/client/selfhosted/path.go b/pkg/client/selfhosted/path.go deleted file mode 100644 index 3e444c00..00000000 --- a/pkg/client/selfhosted/path.go +++ /dev/null @@ -1,48 +0,0 @@ -package selfhosted - -import ( - "fmt" - "net/url" - "regexp" - "strings" -) - -const ( - // Regex template to be used to check "isHost". - hostRegTemplate = `^.*%s$` -) - -func (c *Client) IsHost(host string) bool { - return c.hostRegex.MatchString(host) -} - -func (c *Client) RepoImageFromPath(path string) (string, string) { - split := strings.Split(path, "/") - - lenSplit := len(split) - if lenSplit == 1 { - return "", split[0] - } - - if lenSplit > 1 { - return split[lenSplit-2], split[lenSplit-1] - } - - return path, "" -} - -func parseURL(rawurl string) (*regexp.Regexp, string, error) { - parsedURL, err := url.Parse(rawurl) - if err != nil { - return nil, "", fmt.Errorf("failed parsing host %q: %s", rawurl, err) - } - - hostRegTemplate := fmt.Sprintf(hostRegTemplate, parsedURL.Host) - hostRegex, err := regexp.Compile(hostRegTemplate) - if err != nil { - return nil, "", fmt.Errorf("failed to parse regex: %s for host %q: %s", - hostRegTemplate, parsedURL.Host, err) - } - - return hostRegex, parsedURL.Scheme, nil -} diff --git a/pkg/client/selfhosted/path_test.go b/pkg/client/selfhosted/path_test.go deleted file mode 100644 index 6824427f..00000000 --- a/pkg/client/selfhosted/path_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package selfhosted - -import ( - "context" - "testing" - - "github.com/sirupsen/logrus" -) - -func TestIsHost(t *testing.T) { - tests := map[string]struct { - host string - expIs bool - }{ - "an empty host should be false": { - host: "", - expIs: false, - }, - "random string should be false": { - host: "foobar", - expIs: false, - }, - "random string with dots should be false": { - host: "foobar.foo", - expIs: false, - }, - "just docker.io should be false": { - host: "docker.io", - expIs: false, - }, - "just docker.com should be false": { - host: "docker.com", - expIs: false, - }, - "docker.com with random sub domains should be false": { - host: "foo.bar.docker.com", - expIs: false, - }, - "docker.io with random sub domains should be false": { - host: "foo.bar.docker.io", - expIs: false, - }, - "docker.comfoo should be false": { - host: "docker.iofoo", - expIs: false, - }, - "docker.iofoo should be false": { - host: "ocker.iofoo", - expIs: false, - }, - "docker.repositories.yourdomain.ext should be true": { - host: "docker.repositories.yourdomain.ext", - expIs: true, - }, - "docker.repositories.yourdomain.ext with a wrong URL should be false": { - host: "foo.repositories.yourdomain.ext", - expIs: false, - }, - "docker.repositories.yourdomain.ext with a URL and PATH should be false": { - host: "docker.repositories.yourdomain.ext/hello-world", - expIs: false, - }, - "docker.repositories.yourdomain.ext with sub domain should be true": { - host: "foo.docker.repositories.yourdomain.ext", - expIs: true, - }, - } - - options := &Options{ - Host: "https://docker.repositories.yourdomain.ext", - } - - handler, err := New(context.TODO(), logrus.NewEntry(logrus.New()), options) - if err != nil { - t.Fatal(err) - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - if isHost := handler.IsHost(test.host); isHost != test.expIs { - t.Errorf("%s: unexpected IsHost, exp=%t got=%t", - test.host, test.expIs, isHost) - } - }) - } -} - -func TestRepoImage(t *testing.T) { - tests := map[string]struct { - path string - expRepo, expImage string - }{ - "single image should return error": { - path: "nginx", - expRepo: "", - expImage: "", - }, - "two segments to path should return both": { - path: "joshvanl/version-checker", - expRepo: "joshvanl", - expImage: "version-checker", - }, - "multiple segments to path should return last two": { - path: "registry/joshvanl/version-checker", - expRepo: "joshvanl", - expImage: "version-checker", - }, - } - - handler := new(Client) - for name, test := range tests { - t.Run(name, func(t *testing.T) { - repo, image := handler.RepoImageFromPath(test.path) - if repo != test.expRepo && image != test.expImage { - t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", - test.path, test.expRepo, test.expImage, repo, image) - } - }) - } -} diff --git a/pkg/client/selfhosted/selfhosted.go b/pkg/client/selfhosted/selfhosted.go deleted file mode 100644 index 47982684..00000000 --- a/pkg/client/selfhosted/selfhosted.go +++ /dev/null @@ -1,326 +0,0 @@ -package selfhosted - -import ( - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "regexp" - "strings" - "time" - - "github.com/sirupsen/logrus" - - "github.com/go-chi/transport" - "github.com/hashicorp/go-cleanhttp" - - "github.com/jetstack/version-checker/pkg/api" - selfhostederrors "github.com/jetstack/version-checker/pkg/client/selfhosted/errors" - "github.com/jetstack/version-checker/pkg/client/util" -) - -const ( - // {host}/v2/{repo/image}/tags/list?n=500 - tagsPath = "%s/v2/%s/tags/list?n=500" - // /v2/{repo/image}/manifests/{tag} - manifestPath = "%s/v2/%s/manifests/%s" - // Token endpoint - defaultTokenPath = "/v2/token" - - // HTTP headers to request API version - dockerAPIv1Header = "application/vnd.docker.distribution.manifest.v1+json" - dockerAPIv2Header = "application/vnd.docker.distribution.manifest.v2+json" -) - -type Options struct { - Host string - Username string - Password string - Bearer string - TokenPath string - Insecure bool - CAPath string - Transporter http.RoundTripper -} - -type Client struct { - *http.Client - *Options - - log *logrus.Entry - - hostRegex *regexp.Regexp - httpScheme string -} - -func New(ctx context.Context, log *logrus.Entry, opts *Options) (*Client, error) { - client := &Client{ - Client: &http.Client{ - Timeout: time.Second * 10, - Transport: cleanhttp.DefaultTransport(), - }, - Options: opts, - log: log.WithField("client", "selfhosted-"+opts.Host), - } - - if err := configureHost(ctx, client, opts); err != nil { - return nil, err - } - - if err := configureTransport(client, opts); err != nil { - return nil, err - } - - return client, nil -} - -func configureHost(ctx context.Context, client *Client, opts *Options) error { - if opts.Host == "" { - return nil - } - - hostRegex, scheme, err := parseURL(opts.Host) - if err != nil { - return fmt.Errorf("failed parsing url: %s", err) - } - client.hostRegex = hostRegex - client.httpScheme = scheme - - if err := configureAuth(ctx, client, opts); err != nil { - return err - } - - return nil -} - -func configureAuth(ctx context.Context, client *Client, opts *Options) error { - if len(opts.Username) == 0 && len(opts.Password) == 0 { - return nil - } - - if len(opts.Bearer) > 0 { - return errors.New("cannot specify Bearer token as well as username/password") - } - - tokenPath := opts.TokenPath - if tokenPath == "" { - tokenPath = defaultTokenPath - } - - token, err := client.setupBasicAuth(ctx, opts.Host, tokenPath) - if httpErr, ok := selfhostederrors.IsHTTPError(err); ok { - if httpErr.StatusCode == http.StatusNotFound { - client.log.Warnf("Token endpoint not found, using basic auth: %s%s %s", opts.Host, tokenPath, httpErr.Body) - } else { - return fmt.Errorf("failed to setup token auth (%d): %s", - httpErr.StatusCode, httpErr.Body) - } - } else if err != nil { - return fmt.Errorf("failed to setup token auth: %s", err) - } - client.Bearer = token - return nil -} - -func configureTransport(client *Client, opts *Options) error { - if client.httpScheme == "" { - client.httpScheme = "https" - } - baseTransport := cleanhttp.DefaultTransport() - baseTransport.Proxy = http.ProxyFromEnvironment - - if client.httpScheme == "https" { - tlsConfig, err := newTLSConfig(opts.Insecure, opts.CAPath) - if err != nil { - return err - } - baseTransport.TLSClientConfig = tlsConfig - } - - client.Transport = transport.Chain(baseTransport, - transport.If(logrus.IsLevelEnabled(logrus.DebugLevel), transport.LogRequests(transport.LogOptions{Concise: true})), - transport.If(opts.Transporter != nil, func(rt http.RoundTripper) http.RoundTripper { return opts.Transporter })) - return nil -} - -// Name returns the name of the host URL for the selfhosted client -func (c *Client) Name() string { - if len(c.Host) == 0 { - return "selfhosted" - } - - return c.Host -} - -// Tags will fetch the image tags from a given image URL. It must first query -// the tags that are available, then query the 2.1 and 2.2 API endpoints to -// gather the image digest and created time. -func (c *Client) Tags(ctx context.Context, host, repo, image string) ([]api.ImageTag, error) { - path := util.JoinRepoImage(repo, image) - tagURL := fmt.Sprintf(tagsPath, host, path) - - var tagResponse TagResponse - if _, err := c.doRequest(ctx, tagURL, "", &tagResponse); err != nil { - return nil, err - } - - var tags []api.ImageTag - for _, tag := range tagResponse.Tags { - manifestURL := fmt.Sprintf(manifestPath, host, path, tag) - - var manifestResponse ManifestResponse - _, err := c.doRequest(ctx, manifestURL, dockerAPIv1Header, &manifestResponse) - - if httpErr, ok := selfhostederrors.IsHTTPError(err); ok { - c.log.Errorf("%s: failed to get manifest response for tag, skipping (%d): %s", - manifestURL, httpErr.StatusCode, httpErr.Body) - continue - } - if err != nil { - return nil, err - } - - var timestamp time.Time - for _, v1History := range manifestResponse.History { - data := V1Compatibility{} - if err := json.Unmarshal([]byte(v1History.V1Compatibility), &data); err != nil { - return nil, err - } - - if !data.Created.IsZero() { - timestamp = data.Created - // Each layer has its own created timestamp. We just want a general reference. - // Take the first and step out the loop - break - } - } - - header, err := c.doRequest(ctx, manifestURL, dockerAPIv2Header, new(ManifestResponse)) - if httpErr, ok := selfhostederrors.IsHTTPError(err); ok { - c.log.Errorf("%s: failed to get manifest sha response for tag, skipping (%d): %s", - manifestURL, httpErr.StatusCode, httpErr.Body) - continue - } - if err != nil { - return nil, err - } - - tags = append(tags, api.ImageTag{ - Tag: tag, - SHA: header.Get("Docker-Content-Digest"), - Timestamp: timestamp, - Architecture: manifestResponse.Architecture, - }) - } - - return tags, nil -} - -func (c *Client) doRequest(ctx context.Context, url, header string, obj interface{}) (http.Header, error) { - url = fmt.Sprintf("%s://%s", c.httpScheme, url) - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - - req = req.WithContext(ctx) - if len(c.Bearer) > 0 { - req.Header.Add("Authorization", "Bearer "+c.Bearer) - } else if c.Username != "" && c.Password != "" { - req.SetBasicAuth(c.Username, c.Password) - } - - if len(header) > 0 { - req.Header.Set("Accept", header) - } - - resp, err := c.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get %q image: %s", c.Name(), err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, selfhostederrors.NewHTTPError(resp.StatusCode, body) - } - - if err := json.Unmarshal(body, obj); err != nil { - return nil, fmt.Errorf("unexpected %s response: %s", url, body) - } - - return resp.Header, nil -} - -func (c *Client) setupBasicAuth(ctx context.Context, url, tokenPath string) (string, error) { - upReader := strings.NewReader( - fmt.Sprintf(`{"username": "%s", "password": "%s"}`, - c.Username, c.Password, - ), - ) - - tokenURL := url + tokenPath - - req, err := http.NewRequest(http.MethodPost, tokenURL, upReader) - if err != nil { - return "", fmt.Errorf("failed to create basic auth request: %s", err) - } - - req.Header.Set("Content-Type", "application/json") - req = req.WithContext(ctx) - - resp, err := c.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send basic auth request %q: %s", - req.URL, err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - return "", selfhostederrors.NewHTTPError(resp.StatusCode, body) - } - - response := new(AuthResponse) - if err := json.Unmarshal(body, response); err != nil { - return "", err - } - - return response.Token, nil -} - -func newTLSConfig(insecure bool, CAPath string) (*tls.Config, error) { - // Load system CA Certs and/or create a new CertPool - rootCAs, _ := x509.SystemCertPool() - if rootCAs == nil { - rootCAs = x509.NewCertPool() - } - - if CAPath != "" { - certs, err := os.ReadFile(CAPath) - if err != nil { - return nil, fmt.Errorf("failed to append %q to RootCAs: %v", CAPath, err) - } - rootCAs.AppendCertsFromPEM(certs) - } - - return &tls.Config{ - Renegotiation: tls.RenegotiateOnceAsClient, - InsecureSkipVerify: insecure, // #nosec G402 - RootCAs: rootCAs, - }, nil -} diff --git a/pkg/client/selfhosted/selfhosted_test.go b/pkg/client/selfhosted/selfhosted_test.go deleted file mode 100644 index 223e6e6b..00000000 --- a/pkg/client/selfhosted/selfhosted_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package selfhosted - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - - "github.com/jetstack/version-checker/pkg/api" - selfhostederrors "github.com/jetstack/version-checker/pkg/client/selfhosted/errors" -) - -func TestNew(t *testing.T) { - log := logrus.NewEntry(logrus.New()) - ctx := context.Background() - - t.Run("successful client creation with username and password", func(t *testing.T) { - opts := &Options{ - Host: "https://testregistry.com", - Username: "testuser", - Password: "testpass", - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v2/token", r.URL.Path) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"token":"testtoken"}`)) - })) - defer server.Close() - - opts.Host = server.URL - client, err := New(ctx, log, opts) - - assert.NoError(t, err) - assert.Equal(t, "testtoken", client.Bearer) - }) - - t.Run("error on invalid URL", func(t *testing.T) { - opts := &Options{ - Host: "://invalid-url", - } - - client, err := New(ctx, log, opts) - - assert.Nil(t, client) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed parsing url") - }) - - t.Run("error on username/password and bearer token both specified", func(t *testing.T) { - opts := &Options{ - Host: "https://testregistry.com", - Username: "testuser", - Password: "testpass", - Bearer: "testtoken", - } - - client, err := New(ctx, log, opts) - - assert.Nil(t, client) - assert.EqualError(t, err, "cannot specify Bearer token as well as username/password") - }) - - t.Run("successful client creation with bearer token", func(t *testing.T) { - opts := &Options{ - Host: "https://testregistry.com", - Bearer: "testtoken", - } - - client, err := New(ctx, log, opts) - - assert.NoError(t, err) - assert.Equal(t, "testtoken", client.Bearer) - }) - - t.Run("error on invalid CA path", func(t *testing.T) { - opts := &Options{ - Host: "https://testregistry.com", - CAPath: "invalid/path", - Insecure: true, - } - - client, err := New(ctx, log, opts) - - assert.Nil(t, client) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to append") - }) -} - -func TestName(t *testing.T) { - log := logrus.NewEntry(logrus.New()) - client := &Client{ - Options: &Options{ - Host: "testhost", - }, - log: log, - } - - assert.Equal(t, "testhost", client.Name()) - - client.Host = "" - assert.Equal(t, "selfhosted", client.Name()) -} - -func TestTags(t *testing.T) { - log := logrus.NewEntry(logrus.New()) - ctx := context.Background() - - t.Run("successful Tags fetch", func(t *testing.T) { - client := &Client{ - Client: &http.Client{}, - log: log, - Options: &Options{ - Host: "testregistry.com", - }, - httpScheme: "http", - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v2/repo/image/tags/list": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"tags":["v1.0.0","v2.0.0"]}`)) - case "/v2/repo/image/manifests/v1.0.0": - w.Header().Add("Docker-Content-Digest", "sha256:abcdef") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"architecture":"amd64","history":[{"v1Compatibility":"{\"created\":\"2023-08-27T12:00:00Z\"}"}]}`)) - case "/v2/repo/image/manifests/v2.0.0": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{}`)) // Write some blank content - } - })) - defer server.Close() - - h, err := url.Parse(server.URL) - assert.NoError(t, err) - - tags, err := client.Tags(ctx, h.Host, "repo", "image") - - assert.NoError(t, err) - assert.Len(t, tags, 2) - assert.Equal(t, "v1.0.0", tags[0].Tag) - assert.Equal(t, api.Architecture("amd64"), tags[0].Architecture) - assert.Equal(t, "sha256:abcdef", tags[0].SHA) - assert.Equal(t, "v2.0.0", tags[1].Tag) - }) - - t.Run("error fetching tags", func(t *testing.T) { - client := &Client{ - Client: &http.Client{}, - log: log, - Options: &Options{ - Host: "https://testregistry.com", - }, - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - tags, err := client.Tags(ctx, server.URL, "repo", "image") - assert.Nil(t, tags) - assert.Error(t, err) - }) -} - -func TestDoRequest(t *testing.T) { - log := logrus.NewEntry(logrus.New()) - ctx := context.Background() - - client := &Client{ - Client: &http.Client{}, - Options: &Options{ - Host: "testhost", - }, - log: log, - httpScheme: "http", - } - - t.Run("successful request", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v2/repo/image/tags/list", r.URL.Path) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"tags":["v1","v2"]}`)) - })) - defer server.Close() - - h, err := url.Parse(server.URL) - assert.NoError(t, err) - - var tagResponse TagResponse - headers, err := client.doRequest(ctx, h.Host+"/v2/repo/image/tags/list", "", &tagResponse) - - assert.NoError(t, err) - assert.NotNil(t, headers) - assert.Equal(t, []string{"v1", "v2"}, tagResponse.Tags) - }) - - t.Run("error on non-200 status", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("not found")) - })) - defer server.Close() - - h, err := url.Parse(server.URL) - assert.NoError(t, err) - - var tagResponse TagResponse - headers, err := client.doRequest(ctx, h.Host+"/v2/repo/image/tags/list", "", &tagResponse) - - assert.Nil(t, headers) - assert.Error(t, err) - var httpErr *selfhostederrors.HTTPError - if errors.As(err, &httpErr) { - assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) - assert.Equal(t, "not found", string(httpErr.Body)) - } - }) - - t.Run("error on invalid json response", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("invalid json")) - })) - defer server.Close() - - h, err := url.Parse(server.URL) - assert.NoError(t, err) - - var tagResponse TagResponse - headers, err := client.doRequest(ctx, h.Host+"/v2/repo/image/tags/list", "", &tagResponse) - - assert.Nil(t, headers) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unexpected") - }) - - t.Run("use basic auth in request", func(t *testing.T) { - username := "foo" - password := "bar" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, r.Header.Get("Authorization"), fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(username+":"+password)))) - assert.Equal(t, "/v2/repo/image/tags/list", r.URL.Path) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"tags":["v1","v2"]}`)) - })) - defer server.Close() - - h, err := url.Parse(server.URL) - assert.NoError(t, err) - - client.Username = username - client.Password = password - - var tagResponse TagResponse - headers, err := client.doRequest(ctx, h.Host+"/v2/repo/image/tags/list", "", &tagResponse) - - assert.NoError(t, err) - assert.NotNil(t, headers) - assert.Equal(t, []string{"v1", "v2"}, tagResponse.Tags) - }) -} - -func TestSetupBasicAuth(t *testing.T) { - log := logrus.NewEntry(logrus.New()) - ctx := context.Background() - - client := &Client{ - Client: &http.Client{}, - Options: &Options{ - Username: "testuser", - Password: "testpass", - }, - log: log, - } - - t.Run("successful auth setup", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v2/token", r.URL.Path) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"token":"testtoken"}`)) - })) - defer server.Close() - - token, err := client.setupBasicAuth(ctx, server.URL, "/v2/token") - assert.NoError(t, err) - assert.Equal(t, "testtoken", token) - }) - - t.Run("error on invalid json response", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("invalid json")) - })) - defer server.Close() - - token, err := client.setupBasicAuth(ctx, server.URL, "/v2/token") - assert.Empty(t, token) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid character") - }) - - t.Run("error on non-200 status code", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("unauthorized")) - })) - defer server.Close() - - token, err := client.setupBasicAuth(ctx, server.URL, "/v2/token") - assert.Empty(t, token) - assert.Error(t, err) - var httpErr *selfhostederrors.HTTPError - if errors.As(err, &httpErr) { - assert.Equal(t, http.StatusUnauthorized, httpErr.StatusCode) - assert.Equal(t, "unauthorized", string(httpErr.Body)) - } - }) - - t.Run("error on request creation failure", func(t *testing.T) { - client := &Client{ - Client: &http.Client{}, - Options: &Options{ - Username: "testuser", - Password: "testpass", - }, - log: log, - } - - token, err := client.setupBasicAuth(ctx, "localhost:999999", "/v2/token") - assert.Empty(t, token) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to send basic auth request") - }) -} - -func TestNewTLSConfig(t *testing.T) { - t.Run("successful TLS config creation with valid CA path", func(t *testing.T) { - caFile, err := os.CreateTemp("", "ca.pem") - assert.NoError(t, err) - defer func() { assert.NoError(t, os.Remove(caFile.Name())) }() - - _, err = caFile.WriteString(`-----BEGIN CERTIFICATE----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwf3Kq/BnEePvM6rSGPP6 -6uUbzIAdx0+EjHRJ1yqCqk8MzY+m5OncEjgpG0FDDpdqYPOUE4EzjjIlNInxG8Vi -DfWmi8csEQYrtyNzzlF+bWwWv/1U+UuRgZqtwFZxC4DLIE1Bke4isr7g91DU5B8G -b+6eGHjql0zPz9bL7s5er8kpDp1o6ZZtGPE3F18LPS48pZyRIN/T4vPz4uA/Zay/ -aEB8E+yoI8dw48LUVZDjDN3mthBb8k68ngLqBaIgF+1EQpe2I1a/nZBQTu9yn8Z1 -Y7nG8XdxKAr5e+CZ8x8NUvydF1DZDSV1Mf1GriMEwLkA5P4oY8EbOxDJTuJrAXjZ -tQIDAQAB ------END CERTIFICATE-----`) - assert.NoError(t, err) - err = caFile.Close() - assert.NoError(t, err) - - tlsConfig, err := newTLSConfig(false, caFile.Name()) - assert.NoError(t, err) - assert.NotNil(t, tlsConfig) - assert.False(t, tlsConfig.InsecureSkipVerify) - }) - - t.Run("successful TLS config creation with empty CA path", func(t *testing.T) { - tlsConfig, err := newTLSConfig(true, "") - assert.NoError(t, err) - assert.NotNil(t, tlsConfig) - assert.True(t, tlsConfig.InsecureSkipVerify) - }) - - t.Run("error on invalid CA path", func(t *testing.T) { - tlsConfig, err := newTLSConfig(false, "/invalid/path") - assert.Nil(t, tlsConfig) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to append") - }) -} diff --git a/pkg/client/types.go b/pkg/client/types.go new file mode 100644 index 00000000..b9bdc3d9 --- /dev/null +++ b/pkg/client/types.go @@ -0,0 +1,69 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "time" + + authn "github.com/google/go-containerregistry/pkg/authn" + k8sauthn "github.com/google/go-containerregistry/pkg/authn/kubernetes" + + "github.com/jetstack/version-checker/pkg/api" + "github.com/sirupsen/logrus" + + "github.com/patrickmn/go-cache" + + "github.com/jetstack/version-checker/pkg/client/acr" + "github.com/jetstack/version-checker/pkg/client/dockerhub" + "github.com/jetstack/version-checker/pkg/client/ecr" + "github.com/jetstack/version-checker/pkg/client/gcr" + "github.com/jetstack/version-checker/pkg/client/ghcr" + "github.com/jetstack/version-checker/pkg/client/oci" + "github.com/jetstack/version-checker/pkg/client/quay" +) + +// Used for testing/mocking purposes +type ClientHandler interface { + Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) +} + +// Client is a container image registry client to list tags of given image +// URLs. +type ClientManager struct { + keychain authn.Keychain + factories []api.ImageClientFactory + + fallbackClient api.ImageClient + + cache *cache.Cache + + log *logrus.Entry +} + +// Options used to configure client authentication. +type Options struct { + ACR acr.Options + ECR ecr.Options + GCR gcr.Options + GHCR ghcr.Options + Docker dockerhub.Options + Quay quay.Options + OCI oci.Options + // Selfhosted map[string]*selfhosted.Options + + // Kubernetes Authentication Options + KeyChain k8sauthn.Options + AuthRefreshDuration time.Duration + + Transport http.RoundTripper +} + +func (m *ClientManager) newClientForHost(host string, authcfg *authn.AuthConfig) (api.ImageClient, error) { + for _, factory := range m.factories { + if factory.IsHost(host) { + return factory.NewClient(authcfg, m.log) + } + } + return nil, fmt.Errorf("no client found for host: %s", host) +} diff --git a/pkg/client/util/leveled_logrus.go b/pkg/client/util/leveled_logrus.go new file mode 100644 index 00000000..29b992b6 --- /dev/null +++ b/pkg/client/util/leveled_logrus.go @@ -0,0 +1,42 @@ +package util + +import ( + "fmt" + + "github.com/sirupsen/logrus" +) + +func NewLeveledLogger(entry *logrus.Entry) *LeveledLogrus { + return &LeveledLogrus{Entry: entry} +} + +type LeveledLogrus struct { + *logrus.Entry +} + +func (l *LeveledLogrus) Error(msg string, keysAndValues ...interface{}) { + l.WithFields(fields(keysAndValues)).Error(msg) +} + +func (l *LeveledLogrus) Info(msg string, keysAndValues ...interface{}) { + l.WithFields(fields(keysAndValues)).Info(msg) +} +func (l *LeveledLogrus) Debug(msg string, keysAndValues ...interface{}) { + l.WithFields(fields(keysAndValues)).Debug(msg) +} + +func (l *LeveledLogrus) Warn(msg string, keysAndValues ...interface{}) { + l.WithFields(fields(keysAndValues)).Warn(msg) +} + +func fields(keysAndValues []interface{}) map[string]interface{} { + fields := make(map[string]interface{}) + + for i := 0; i < len(keysAndValues)-1; i += 2 { + // turn *any* key into its string representation + keyStr := fmt.Sprint(keysAndValues[i]) + fields[keyStr] = keysAndValues[i+1] + } + + return fields +} diff --git a/pkg/client/util/leveled_logrus_test.go b/pkg/client/util/leveled_logrus_test.go new file mode 100644 index 00000000..1162d2e5 --- /dev/null +++ b/pkg/client/util/leveled_logrus_test.go @@ -0,0 +1,59 @@ +package util + +import ( + "reflect" + "testing" +) + +func TestFields(t *testing.T) { + tests := []struct { + name string + keysAndValues []interface{} + expectedFields map[string]interface{} + }{ + { + name: "empty input", + keysAndValues: []interface{}{}, + expectedFields: map[string]interface{}{}, + }, + { + name: "single key no value", + keysAndValues: []interface{}{"key"}, + expectedFields: map[string]interface{}{}, + }, + { + name: "one key-value pair", + keysAndValues: []interface{}{"key", "value"}, + expectedFields: map[string]interface{}{"key": "value"}, + }, + { + name: "two key-value pairs", + keysAndValues: []interface{}{"key1", 123, "key2", true}, + expectedFields: map[string]interface{}{"key1": 123, "key2": true}, + }, + { + name: "odd number of elements", + keysAndValues: []interface{}{"key1", 123, "key2"}, + expectedFields: map[string]interface{}{"key1": 123}, + }, + { + name: "non-string key", + keysAndValues: []interface{}{42, "answer"}, + expectedFields: map[string]interface{}{"42": "answer"}, + }, + { + name: "mixed key types", + keysAndValues: []interface{}{42, "answer", true, "bool"}, + expectedFields: map[string]interface{}{"42": "answer", "true": "bool"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fields(tt.keysAndValues) + if !reflect.DeepEqual(got, tt.expectedFields) { + t.Errorf("fields(%v) = %v, want %v", tt.keysAndValues, got, tt.expectedFields) + } + }) + } +} diff --git a/pkg/controller/options/options.go b/pkg/controller/options/options.go index f5264450..20ae0798 100644 --- a/pkg/controller/options/options.go +++ b/pkg/controller/options/options.go @@ -65,12 +65,14 @@ func (b *Builder) Options(name string) (*api.Options, error) { return &opts, nil } + func (b *Builder) handleSHAOption(name string, opts *api.Options, setNonSha *bool, errs *[]string) error { if useSHA, ok := b.ans[b.index(name, api.UseSHAAnnotationKey)]; ok && useSHA == "true" { opts.UseSHA = true } return nil } + func (b *Builder) handleSHAToTagOption(name string, opts *api.Options, setNonSha *bool, errs *[]string) error { if ResolveSHAToTags, ok := b.ans[b.index(name, api.ResolveSHAToTagsKey)]; ok && ResolveSHAToTags == "true" { opts.ResolveSHAToTags = true diff --git a/pkg/controller/pod_controller.go b/pkg/controller/pod_controller.go index 60a8bd6d..0bbbeb68 100644 --- a/pkg/controller/pod_controller.go +++ b/pkg/controller/pod_controller.go @@ -40,7 +40,7 @@ type PodReconciler struct { func NewPodReconciler( cacheTimeout time.Duration, metrics *metrics.Metrics, - imageClient *client.Client, + imageClient *client.ClientManager, kubeClient k8sclient.Client, log *logrus.Entry, requeueDuration time.Duration, @@ -95,10 +95,18 @@ func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { LeaderElect := false return ctrl.NewControllerManagedBy(mgr). For(&corev1.Pod{}, builder.OnlyMetadata). - WithOptions(controller.Options{MaxConcurrentReconciles: numWorkers, NeedLeaderElection: &LeaderElect}). + WithOptions(controller.Options{ + MaxConcurrentReconciles: numWorkers, + NeedLeaderElection: &LeaderElect, + }). WithEventFilter(predicate.Funcs{ CreateFunc: func(_ event.TypedCreateEvent[k8sclient.Object]) bool { return true }, - UpdateFunc: func(_ event.TypedUpdateEvent[k8sclient.Object]) bool { return true }, + UpdateFunc: func(e event.TypedUpdateEvent[k8sclient.Object]) bool { + // old := options.New(e.ObjectOld.GetAnnotations()) + // new := options.New(e.ObjectNew.GetAnnotations()) + // return old.Hash() != new.Hash() + return true + }, DeleteFunc: func(e event.TypedDeleteEvent[k8sclient.Object]) bool { r.Log.Infof("Pod deleted: %s/%s", e.Object.GetNamespace(), e.Object.GetName()) r.Metrics.RemovePod(e.Object.GetNamespace(), e.Object.GetName()) diff --git a/pkg/controller/pod_controller_test.go b/pkg/controller/pod_controller_test.go index 0828f1fc..0fd70e7f 100644 --- a/pkg/controller/pod_controller_test.go +++ b/pkg/controller/pod_controller_test.go @@ -39,7 +39,7 @@ func TestNewController(t *testing.T) { prometheus.NewRegistry(), kubeClient, ) - imageClient := &client.Client{} + imageClient := &client.ClientManager{} controller := NewPodReconciler(5*time.Minute, metrics, imageClient, kubeClient, testLogger, time.Hour, true) @@ -49,7 +49,7 @@ func TestNewController(t *testing.T) { assert.NotNil(t, controller.VersionChecker) } func TestReconcile(t *testing.T) { - imageClient := &client.Client{} + imageClient := &client.ClientManager{} tests := []struct { name string @@ -121,7 +121,7 @@ func TestSetupWithManager(t *testing.T) { prometheus.NewRegistry(), kubeClient, ) - imageClient := &client.Client{} + imageClient := &client.ClientManager{} controller := NewPodReconciler(5*time.Minute, metrics, imageClient, kubeClient, testLogger, time.Hour, true) mgr, err := manager.New(&rest.Config{}, manager.Options{LeaderElectionConfig: nil}) diff --git a/pkg/controller/pod_sync_test.go b/pkg/controller/pod_sync_test.go index ff214d8b..6d223659 100644 --- a/pkg/controller/pod_sync_test.go +++ b/pkg/controller/pod_sync_test.go @@ -32,7 +32,7 @@ func TestController_Sync(t *testing.T) { prometheus.NewRegistry(), fake.NewFakeClient(), ) - imageClient := &client.Client{} + imageClient := &client.ClientManager{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) checker := checker.New(searcher) @@ -67,7 +67,7 @@ func TestController_SyncContainer(t *testing.T) { t.Parallel() log := logrus.NewEntry(logrus.New()) metrics := metrics.New(log, prometheus.NewRegistry(), fake.NewFakeClient()) - imageClient := &client.Client{} + imageClient := &client.ClientManager{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) checker := checker.New(searcher) @@ -99,7 +99,7 @@ func TestController_CheckContainer(t *testing.T) { t.Parallel() log := logrus.NewEntry(logrus.New()) metrics := metrics.New(log, prometheus.NewRegistry(), fake.NewFakeClient()) - imageClient := &client.Client{} + imageClient := &client.ClientManager{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) checker := checker.New(searcher) @@ -129,7 +129,7 @@ func TestController_SyncContainer_NoVersionFound(t *testing.T) { log := logrus.NewEntry(logrus.New()) metrics := metrics.New(log, prometheus.NewRegistry(), fake.NewFakeClient()) - imageClient := &client.Client{} + imageClient := &client.ClientManager{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) checker := checker.New(searcher) diff --git a/pkg/keychains/helpers.go b/pkg/keychains/helpers.go new file mode 100644 index 00000000..26a9e48f --- /dev/null +++ b/pkg/keychains/helpers.go @@ -0,0 +1,36 @@ +package keychains + +import ( + "slices" + + v1 "k8s.io/api/core/v1" +) + +// The `pullSecrets` function takes a slice of `LocalObjectReference` objects, +// extracts their names, sorts them alphabetically, removes duplicates and returns a slice of strings. +func pullSecrets(secrets []v1.LocalObjectReference) (pullSecrets []string) { + for _, sec := range secrets { + pullSecrets = append(pullSecrets, sec.Name) + } + // Sort the list of Secrets + slices.Sort(pullSecrets) + // Remove duplicates + uniquePullSecrets := make([]string, 0, len(pullSecrets)) + seen := make(map[string]struct{}) + for _, secret := range pullSecrets { + if _, ok := seen[secret]; !ok { + seen[secret] = struct{}{} + uniquePullSecrets = append(uniquePullSecrets, secret) + } + } + return uniquePullSecrets +} + +// The function `saName` returns the input string if it is not empty, otherwise it +// returns "default". +func saName(saName string) string { + if saName == "" { + return "default" + } + return saName +} diff --git a/pkg/keychains/helpers_test.go b/pkg/keychains/helpers_test.go new file mode 100644 index 00000000..d12f62a1 --- /dev/null +++ b/pkg/keychains/helpers_test.go @@ -0,0 +1,65 @@ +package keychains + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" +) + +func TestPullSecrets(t *testing.T) { + tests := []struct { + name string + secrets []v1.LocalObjectReference + expected []string + }{ + { + name: "No secrets", + secrets: []v1.LocalObjectReference{}, + expected: []string{}, + }, + { + name: "Single secret", + secrets: []v1.LocalObjectReference{ + {Name: "secret-a"}, + }, + expected: []string{"secret-a"}, + }, + { + name: "Multiple secrets with no duplicates", + secrets: []v1.LocalObjectReference{ + {Name: "secret-b"}, + {Name: "secret-a"}, + {Name: "secret-c"}, + }, + expected: []string{"secret-a", "secret-b", "secret-c"}, + }, + { + name: "Multiple secrets with duplicates", + secrets: []v1.LocalObjectReference{ + {Name: "secret-a"}, + {Name: "secret-b"}, + {Name: "secret-a"}, + {Name: "secret-c"}, + {Name: "secret-b"}, + }, + expected: []string{"secret-a", "secret-b", "secret-c"}, + }, + { + name: "Secrets already sorted", + secrets: []v1.LocalObjectReference{ + {Name: "secret-a"}, + {Name: "secret-b"}, + {Name: "secret-c"}, + }, + expected: []string{"secret-a", "secret-b", "secret-c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pullSecrets(tt.secrets) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/keychains/keychains.go b/pkg/keychains/keychains.go new file mode 100644 index 00000000..188b9322 --- /dev/null +++ b/pkg/keychains/keychains.go @@ -0,0 +1,33 @@ +package keychains + +import ( + "log" + + "github.com/sirupsen/logrus" + + "github.com/patrickmn/go-cache" + + cntreglog "github.com/google/go-containerregistry/pkg/logs" + "k8s.io/client-go/kubernetes" +) + +// New initializes a new keychain manager with given ttl and cleanup interval. +func New(logger *logrus.Entry, clientset kubernetes.Interface, opts *ManagerOpts) (obj Manager) { + cache := cache.New(opts.CachingTTL, opts.CachingTTL*2) + + // We need to ensure that we set this for Pod and ServiceAccountKeychains + cntreglog.Warn = log.New(logger.WriterLevel(logrus.WarnLevel), "", 0) + cntreglog.Debug = log.New(logger.WriterLevel(logrus.DebugLevel), "", 0) + + switch opts.Mode { + default: + case ManualMode: + obj = &ManualKeychain{} + case PodMode: + obj = &PodKeychain{client: clientset, log: logger, opts: opts, cache: cache} + case ServiceAccountMode: + obj = &ServiceAccountKeychain{client: clientset, log: logger, opts: opts, cache: cache} + } + + return obj +} diff --git a/pkg/keychains/keychains_test.go b/pkg/keychains/keychains_test.go new file mode 100644 index 00000000..81e7b5ad --- /dev/null +++ b/pkg/keychains/keychains_test.go @@ -0,0 +1,77 @@ +package keychains + +import ( + "testing" + "time" + + "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "k8s.io/client-go/kubernetes/fake" +) + +func TestNew(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + clientset := fake.NewSimpleClientset() + + tests := []struct { + name string + opts *ManagerOpts + expected Manager + }{ + { + name: "ManualMode", + opts: &ManagerOpts{ + Mode: ManualMode, + CachingTTL: 5 * time.Minute, + }, + expected: &ManualKeychain{}, + }, + { + name: "PodMode", + opts: &ManagerOpts{ + Mode: PodMode, + CachingTTL: 5 * time.Minute, + }, + expected: &PodKeychain{ + client: clientset, + log: log, + opts: &ManagerOpts{Mode: PodMode, CachingTTL: 5 * time.Minute}, + cache: cache.New(5*time.Minute, 10*time.Minute), + }, + }, + { + name: "ServiceAccountMode", + opts: &ManagerOpts{ + Mode: ServiceAccountMode, + CachingTTL: 5 * time.Minute, + }, + expected: &ServiceAccountKeychain{ + client: clientset, + log: log, + opts: &ManagerOpts{Mode: ServiceAccountMode, CachingTTL: 5 * time.Minute}, + cache: cache.New(5*time.Minute, 10*time.Minute), + }, + }, + { + name: "DefaultMode", + opts: &ManagerOpts{ + Mode: 0, + CachingTTL: 5 * time.Minute, + }, + expected: &ManualKeychain{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := New(log, clientset, tt.opts) + + if tt.expected == nil { + assert.Nil(t, manager) + } else { + assert.IsType(t, tt.expected, manager) + } + }) + } +} diff --git a/pkg/keychains/manual.go b/pkg/keychains/manual.go new file mode 100644 index 00000000..f152d5cb --- /dev/null +++ b/pkg/keychains/manual.go @@ -0,0 +1,18 @@ +package keychains + +import ( + "context" + + "github.com/google/go-containerregistry/pkg/authn" + corev1 "k8s.io/api/core/v1" +) + +type ManualKeychain struct{} + +func (mk *ManualKeychain) Get(ctx context.Context, pod *corev1.Pod, imageURL string) (authn.Keychain, error) { + return nil, nil +} + +func (mk *ManualKeychain) cacheKey(_ *corev1.Pod) string { + return "" +} diff --git a/pkg/keychains/manual_test.go b/pkg/keychains/manual_test.go new file mode 100644 index 00000000..3f0830fa --- /dev/null +++ b/pkg/keychains/manual_test.go @@ -0,0 +1,88 @@ +package keychains + +import ( + "context" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestManualKeychain_Get(t *testing.T) { + mk := &ManualKeychain{} + + tests := []struct { + name string + pod *corev1.Pod + imageURL string + wantKey authn.Keychain + wantError bool + }{ + { + name: "nil pod and empty imageURL", + pod: nil, + imageURL: "", + wantKey: nil, + wantError: false, + }, + { + name: "non-nil pod and valid imageURL", + pod: &corev1.Pod{}, + imageURL: "example.com/image", + wantKey: nil, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotKey, err := mk.Get(context.Background(), tt.pod, tt.imageURL) + if (err != nil) != tt.wantError { + t.Errorf("ManualKeychain.Get() error = %v, wantError %v", err, tt.wantError) + return + } + if gotKey != tt.wantKey { + t.Errorf("ManualKeychain.Get() = %v, want %v", gotKey, tt.wantKey) + } + }) + } +} +func TestManualKeychain_cacheKey(t *testing.T) { + mk := &ManualKeychain{} + + tests := []struct { + name string + pod *corev1.Pod + want string + }{ + { + name: "nil pod", + pod: nil, + want: "", + }, + { + name: "empty pod", + pod: &corev1.Pod{}, + want: "", + }, + { + name: "pod with name and namespace", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + }, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mk.cacheKey(tt.pod); got != tt.want { + t.Errorf("ManualKeychain.cacheKey() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/keychains/pod.go b/pkg/keychains/pod.go new file mode 100644 index 00000000..4b908e70 --- /dev/null +++ b/pkg/keychains/pod.go @@ -0,0 +1,66 @@ +package keychains + +import ( + "context" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/authn/k8schain" + + "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +type PodKeychain struct { + log *logrus.Entry + restConfig *rest.Config + + client kubernetes.Interface + + opts *ManagerOpts + cache *cache.Cache +} + +// Get retrieves or creates a cached keychain securely. +func (pk *PodKeychain) Get(ctx context.Context, pod *corev1.Pod, imageURL string) (authn.Keychain, error) { + cacheKey := pk.cacheKey(pod) + + if cached, found := pk.cache.Get(cacheKey); found { + pk.log.WithField("cache", cacheKey).Infof("Using cached keychain for %s/%s", pod.Namespace, pod.Name) + return cached.(authn.Keychain), nil + } + pullSecrets := pullSecrets(pod.Spec.ImagePullSecrets) + pk.log.WithField("pullSecrets", pullSecrets).Warnf("Creating new keychain for %s", cacheKey) + + keychain, err := k8schain.New(ctx, pk.Client(), k8schain.Options{ + Namespace: pod.Namespace, + ServiceAccountName: pod.Spec.ServiceAccountName, + ImagePullSecrets: pullSecrets, + UseMountSecrets: pk.opts.UseMountSecrets, + }) + if err != nil { + return nil, err + } + + // Add to the Cache, using the cache instances' defaultExpiration Field + pk.cache.Set(cacheKey, keychain, cache.DefaultExpiration) + + return keychain, nil +} + +// Get a cache key based off the Namespace, ServiceAccountNamer and Image Pull Secrets from the Pod +func (pk *PodKeychain) cacheKey(pod *corev1.Pod) string { + return pod.Namespace + "/" + saName(pod.Spec.ServiceAccountName) + "/" + strings.Join(pullSecrets(pod.Spec.ImagePullSecrets), "/") +} + +func (pk *PodKeychain) Client() kubernetes.Interface { + if pk.client != nil { + return pk.client + } + pk.client, _ = kubernetes.NewForConfig(pk.restConfig) + return pk.client +} diff --git a/pkg/keychains/pod_test.go b/pkg/keychains/pod_test.go new file mode 100644 index 00000000..c1db64ac --- /dev/null +++ b/pkg/keychains/pod_test.go @@ -0,0 +1,128 @@ +package keychains + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + + "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stesting "k8s.io/client-go/testing" +) + +func TestPodKeychain_Get_CachedKeychain(t *testing.T) { + mockCache := cache.New(5*time.Minute, 10*time.Minute) + mockLog := logrus.NewEntry(logrus.New()) + k8sclient := fake.NewSimpleClientset() + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-pod", + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "default", + ImagePullSecrets: []corev1.LocalObjectReference{{Name: "test-secret"}}, + }, + } + + expectedKeychain := authn.NewMultiKeychain() + mockCache.Set("test-namespace/default/test-secret", expectedKeychain, cache.DefaultExpiration) + + pk := &PodKeychain{ + log: mockLog, + client: k8sclient, + opts: &ManagerOpts{}, + cache: mockCache, + } + + keychain, err := pk.Get(context.Background(), pod, "test-image") + require.NoError(t, err) + assert.Equal(t, expectedKeychain, keychain) +} + +func TestPodKeychain_Get_NewKeychain(t *testing.T) { + mockCache := cache.New(5*time.Minute, 10*time.Minute) + mockLog := logrus.NewEntry(logrus.New()) + client := fake.NewSimpleClientset() + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-pod", + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "default", + ImagePullSecrets: []corev1.LocalObjectReference{{Name: "test-secret"}}, + }, + } + + pk := &PodKeychain{ + log: mockLog, + client: client, + opts: &ManagerOpts{}, + cache: mockCache, + } + + keychain, err := pk.Get(context.Background(), pod, "test-image") + assert.NoError(t, err) + assert.NotNil(t, keychain) + + cacheKey := "test-namespace/default/test-secret" + cachedKeychain, found := mockCache.Get(cacheKey) + assert.True(t, found) + assert.Equal(t, keychain, cachedKeychain) +} + +func TestPodKeychain_Get_SecretDeniedCreatingKeychain(t *testing.T) { + mockCache := cache.New(5*time.Minute, 10*time.Minute) + mockLog := logrus.NewEntry(logrus.New()) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-pod", + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "default", + ImagePullSecrets: []corev1.LocalObjectReference{{Name: "test-secret"}}, + }, + } + + resources := []runtime.Object{pod} + client := fake.NewSimpleClientset(resources...) + + // Simulate an RBAC 403 Forbidden when trying to list pods + client.PrependReactor("get", "secrets", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewForbidden( + schema.GroupResource{Group: "", Resource: "secrets"}, + "", + errors.New("rbac: access denied"), + ) + }) + + pk := &PodKeychain{ + log: mockLog, + client: client, + opts: &ManagerOpts{}, + cache: mockCache, + } + + keychain, err := pk.Get(context.Background(), pod, "test-image") + assert.Error(t, err) + assert.Nil(t, keychain) +} diff --git a/pkg/keychains/service_account.go b/pkg/keychains/service_account.go new file mode 100644 index 00000000..cf97aaec --- /dev/null +++ b/pkg/keychains/service_account.go @@ -0,0 +1,60 @@ +package keychains + +import ( + "context" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/authn/k8schain" + + "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" +) + +type ServiceAccountKeychain struct { + log *logrus.Entry + client kubernetes.Interface + + opts *ManagerOpts + cache *cache.Cache +} + +// Get retrieves or creates a cached keychain securely. +func (sk *ServiceAccountKeychain) Get(ctx context.Context, _ *corev1.Pod, imageURL string) (authn.Keychain, error) { + if sk.opts.Mode == ManualMode { + // If we're running in Manual Mode, then we fall back to the original version-checker + return nil, nil + } + + cacheKey := sk.cacheKey(nil) + if cached, found := sk.cache.Get(cacheKey); found { + sk.log.WithField("cache", cacheKey).Info("Using cached keychain") + return cached.(authn.Keychain), nil + } + sk.log.Warnf("Creating new keychain for %s\n", cacheKey) + + keychain, err := k8schain.New(ctx, sk.client, k8schain.Options{ + Namespace: *sk.opts.ServiceAccountNamespace, + ServiceAccountName: *sk.opts.ServiceAccountName, + ImagePullSecrets: *sk.opts.AdditionalImagePullSecrets, + UseMountSecrets: sk.opts.UseMountSecrets, + }) + if err != nil { + return nil, err + } + + // Add to the Cache, using the cache instances' defaultExpiration Field + sk.cache.Set(cacheKey, keychain, cache.DefaultExpiration) + + return keychain, nil +} + +// Get a cache key based off the Namespace, ServiceAccountNamer and Image Pull Secrets from the Pod +func (sk *ServiceAccountKeychain) cacheKey(pod *corev1.Pod) string { + return strings.Join( + append([]string{*sk.opts.ServiceAccountNamespace, *sk.opts.ServiceAccountName}, *sk.opts.AdditionalImagePullSecrets...), + "-") +} diff --git a/pkg/keychains/service_account_test.go b/pkg/keychains/service_account_test.go new file mode 100644 index 00000000..2e72043f --- /dev/null +++ b/pkg/keychains/service_account_test.go @@ -0,0 +1,85 @@ +package keychains + +import ( + "context" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + // "github.com/google/go-containerregistry/pkg/authn/k8schain" + + "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" + + "k8s.io/client-go/kubernetes/fake" +) + +func TestServiceAccountKeychain_Get_CachedKeychain(t *testing.T) { + ctx := context.Background() + mockClient := fake.NewSimpleClientset() + mockCache := cache.New(cache.NoExpiration, cache.NoExpiration) + mockLog := logrus.NewEntry(logrus.New()) + + opts := &ManagerOpts{ + Mode: ServiceAccountMode, + ServiceAccountNamespace: ptr("default"), + ServiceAccountName: ptr("default"), + AdditionalImagePullSecrets: &[]string{"secret1", "secret2"}, + } + + keychain := &ServiceAccountKeychain{ + log: mockLog, + client: mockClient, + opts: opts, + cache: mockCache, + } + + cacheKey := keychain.cacheKey(nil) + mockCache.Set(cacheKey, authn.NewMultiKeychain(), cache.DefaultExpiration) + + result, err := keychain.Get(ctx, nil, "example.com/image") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result == nil { + t.Fatal("expected a cached keychain, got nil") + } +} + +func TestServiceAccountKeychain_Get_NewKeychain(t *testing.T) { + ctx := context.Background() + mockClient := fake.NewSimpleClientset() + mockCache := cache.New(cache.NoExpiration, cache.NoExpiration) + mockLog := logrus.NewEntry(logrus.New()) + + opts := &ManagerOpts{ + Mode: ServiceAccountMode, + ServiceAccountNamespace: ptr("default"), + ServiceAccountName: ptr("default"), + AdditionalImagePullSecrets: &[]string{"secret1", "secret2"}, + UseMountSecrets: false, + } + + keychain := &ServiceAccountKeychain{ + log: mockLog, + client: mockClient, + opts: opts, + cache: mockCache, + } + + result, err := keychain.Get(ctx, nil, "example.com/image") + require.NoError(t, err) + require.NotNil(t, result) + + cacheKey := keychain.cacheKey(nil) + res, found := mockCache.Get(cacheKey) + assert.True(t, found) + assert.NotNil(t, res) +} + +func ptr(s string) *string { + return &s +} diff --git a/pkg/keychains/types.go b/pkg/keychains/types.go new file mode 100644 index 00000000..b620a876 --- /dev/null +++ b/pkg/keychains/types.go @@ -0,0 +1,46 @@ +package keychains + +import ( + "context" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + corev1 "k8s.io/api/core/v1" +) + +// Main interface to what all CredentailManagers should implement. +type Manager interface { + Get(ctx context.Context, pod *corev1.Pod, imageURL string) (authn.Keychain, error) + cacheKey(pod *corev1.Pod) string +} + +type CredentialsMode int + +const ( + // Manual Mode is the existing version-checker mode, which takes a global set of values for each client. + ManualMode CredentialsMode = iota + // PodMode takes the Identity of the synced pod to discover the registry's credentials. + PodMode + // ServiceAccountMode uses a static ServiceAccount's Identity and uses this for ALL Credentials. + // By Default, this will be the ServiceAccount of which version-checker is running under. + ServiceAccountMode +) + +type Options = ManagerOpts + +type ManagerOpts struct { + + // ServiceAccountName is the name of the service account to use in ServiceAccountMode. + // If not set, the service account of the pod will be used. + ServiceAccountName *string + // ServiceAccountNamespace is the namespace of the service account to use in ServiceAccountMode. + // If not set, the namespace of the pod will be used. + ServiceAccountNamespace *string + // AdditionalImagePullSecrets is the image pull secrets to use in ServiceAccountMode. + // If not set, the image pull secrets of the pod will be used. + AdditionalImagePullSecrets *[]string + Mode CredentialsMode + CachingTTL time.Duration + + UseMountSecrets bool +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index c9324c14..4d59a18c 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -126,7 +126,12 @@ func (m *Metrics) RemoveImage(namespace, pod, container, containerType string) { total += m.containerImageChecked.DeletePartialMatch(labels) total += m.containerImageErrors.DeletePartialMatch(labels) - m.log.Infof("Removed %d metrics for image %s/%s/%s (%s)", total, namespace, pod, container, containerType) + m.log.WithFields(logrus.Fields{ + "namespace": namespace, + "pod": pod, + "container": container, + "type": containerType, + }).Infof("Removed %d metrics for image", total) } func (m *Metrics) RemovePod(namespace, pod string) { diff --git a/pkg/version/filters_test.go b/pkg/version/filters_test.go new file mode 100644 index 00000000..f6182d7d --- /dev/null +++ b/pkg/version/filters_test.go @@ -0,0 +1,105 @@ +package version + +import ( + "regexp" + "testing" + + "github.com/jetstack/version-checker/pkg/api" + "github.com/jetstack/version-checker/pkg/version/semver" + "github.com/stretchr/testify/assert" +) + +func TestShouldSkipTag(t *testing.T) { + tests := map[string]struct { + opts *api.Options + semVer *semver.SemVer + expSkipped bool + }{ + "skip tag with metadata when UseMetaData is false": { + opts: &api.Options{ + UseMetaData: false, + }, + semVer: semver.Parse("1.2.3-meta"), + expSkipped: true, + }, + "do not skip tag with metadata when UseMetaData is true": { + opts: &api.Options{ + UseMetaData: true, + }, + semVer: semver.Parse("1.2.3-meta"), + expSkipped: false, + }, + "skip tag when major version does not match PinMajor": { + opts: &api.Options{ + PinMajor: int64p(2), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: true, + }, + "do not skip tag when major version matches PinMajor": { + opts: &api.Options{ + PinMajor: int64p(1), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: false, + }, + "skip tag when minor version does not match PinMinor": { + opts: &api.Options{ + PinMinor: int64p(3), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: true, + }, + "do not skip tag when minor version matches PinMinor": { + opts: &api.Options{ + PinMinor: int64p(2), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: false, + }, + "skip tag when patch version does not match PinPatch": { + opts: &api.Options{ + PinPatch: int64p(4), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: true, + }, + "do not skip tag when patch version matches PinPatch": { + opts: &api.Options{ + PinPatch: int64p(3), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: false, + }, + "skip tag when RegexMatcher does not match": { + opts: &api.Options{ + RegexMatcher: regexp.MustCompile(`^v2\..*`), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: true, + }, + "do not skip tag when RegexMatcher matches": { + opts: &api.Options{ + RegexMatcher: regexp.MustCompile(`^v1\..*`), + }, + semVer: semver.Parse("1.2.3"), + expSkipped: false, + }, + "do not skip tag when no options are set": { + opts: &api.Options{}, + semVer: semver.Parse("1.2.3"), + expSkipped: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + skipped := shouldSkipTag(test.opts, test.semVer) + assert.Equal(t, test.expSkipped, skipped) + }) + } +} + +func int64p(i int64) *int64 { + return &i +}