diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 80d85c4..71f1279 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -11,4 +11,8 @@ updates: schedule: interval: 'weekly' day: 'tuesday' - open-pull-requests-limit: 5 \ No newline at end of file + open-pull-requests-limit: 5 + groups: + k8s.io: + patterns: + - "k8s.io/*" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index fc91cc3..6348fda 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -36,6 +36,9 @@ jobs: - name: Build run: go build ./... + - name: Test + run: go test ./... + - name: golangci-lint uses: golangci/golangci-lint-action@v4 with: diff --git a/go.mod b/go.mod index f7bc8d3..8fa6048 100644 --- a/go.mod +++ b/go.mod @@ -7,60 +7,19 @@ toolchain go1.21.7 require ( github.com/spf13/cobra v1.8.0 google.golang.org/grpc v1.63.2 + k8s.io/apimachinery v0.29.3 k8s.io/cri-api v0.29.3 - k8s.io/kubernetes v1.29.3 -) - -// This set of replaces is needed to use k8s.io/kubernetes -// See https://github.com/kubernetes/kubernetes/issues/79384#issuecomment-505627280 -// for more background. -// TODO(porridge): upgrade to 1.30.x and document the process -replace ( - k8s.io/cli-runtime => k8s.io/cli-runtime v1.29.3 - k8s.io/cloud-provider => k8s.io/cloud-provider v1.29.3 - k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v1.29.3 - k8s.io/component-helpers => k8s.io/component-helpers v1.29.3 - k8s.io/controller-manager => k8s.io/controller-manager v1.29.3 - k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v1.29.3 - k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v1.29.3 - k8s.io/endpointslice => k8s.io/endpointslice v1.29.3 - k8s.io/kube-aggregator => k8s.io/kube-aggregator v1.29.3 - k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v1.29.3 - k8s.io/kube-proxy => k8s.io/kube-proxy v1.29.3 - k8s.io/kube-scheduler => k8s.io/kube-scheduler v1.29.3 - k8s.io/kubectl => k8s.io/kubectl v1.29.3 - k8s.io/kubelet => k8s.io/kubelet v1.29.3 - k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v1.29.3 - k8s.io/metrics => k8s.io/metrics v1.29.3 - k8s.io/mount-utils => k8s.io/mount-utils v1.29.3 - k8s.io/pod-security-admission => k8s.io/pod-security-admission v1.29.3 - k8s.io/sample-apiserver => k8s.io/sample-apiserver v1.29.3 + k8s.io/klog/v2 v2.110.1 ) require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/go-logr/logr v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/prometheus/client_golang v1.16.0 // indirect - github.com/prometheus/client_model v0.4.0 // indirect - github.com/prometheus/common v0.44.0 // indirect - github.com/prometheus/procfs v0.10.1 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/net v0.21.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiextensions-apiserver v0.29.3 // indirect - k8s.io/apimachinery v0.29.3 // indirect - k8s.io/apiserver v0.29.3 // indirect - k8s.io/component-base v0.29.3 // indirect - k8s.io/klog/v2 v2.110.1 // indirect ) diff --git a/go.sum b/go.sum index 53ee923..933c3da 100644 --- a/go.sum +++ b/go.sum @@ -1,51 +1,19 @@ -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= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= -github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= -github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= -github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= -github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= -github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= -github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -59,7 +27,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -87,25 +54,10 @@ google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDom google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apiextensions-apiserver v0.29.3 h1:9HF+EtZaVpFjStakF4yVufnXGPRppWFEQ87qnO91YeI= -k8s.io/apiextensions-apiserver v0.29.3/go.mod h1:po0XiY5scnpJfFizNGo6puNU6Fq6D70UJY2Cb2KwAVc= k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= -k8s.io/apiserver v0.29.3 h1:xR7ELlJ/BZSr2n4CnD3lfA4gzFivh0wwfNfz9L0WZcE= -k8s.io/apiserver v0.29.3/go.mod h1:hrvXlwfRulbMbBgmWRQlFru2b/JySDpmzvQwwk4GUOs= -k8s.io/component-base v0.29.3 h1:Oq9/nddUxlnrCuuR2K/jp6aflVvc0uDvxMzAWxnGzAo= -k8s.io/component-base v0.29.3/go.mod h1:Yuj33XXjuOk2BAaHsIGHhCKZQAgYKhqIxIjIr2UXYio= k8s.io/cri-api v0.29.3 h1:ppKSui+hhTJW774Mou6x+/ealmzt2jmTM0vsEQVWrjI= k8s.io/cri-api v0.29.3/go.mod h1:3X7EnhsNaQnCweGhQCJwKNHlH7wHEYuKQ19bRvXMoJY= k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= -k8s.io/kubernetes v1.29.3 h1:EuOAKN4zpiP+kBx/0e9yS5iBkPSyLml19juOqZxBtDw= -k8s.io/kubernetes v1.29.3/go.mod h1:CP+Z+S9haxyB7J+nV6ywYry4dqlphArPXjcc0CsBVXc= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/internal/credentialprovider/config.go b/internal/credentialprovider/config.go new file mode 100644 index 0000000..a7e4b72 --- /dev/null +++ b/internal/credentialprovider/config.go @@ -0,0 +1,116 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentialprovider + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// DockerConfigJSON represents ~/.docker/config.json file info +// see https://github.com/docker/docker/pull/12009 +type DockerConfigJSON struct { + Auths DockerConfig `json:"auths"` + // +optional + HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` +} + +// DockerConfig represents the config file used by the docker CLI. +// This config that represents the credentials that should be used +// when pulling images from specific image repositories. +type DockerConfig map[string]DockerConfigEntry + +// DockerConfigEntry wraps a docker config as a entry +type DockerConfigEntry struct { + Username string + Password string + Email string + Provider DockerConfigProvider +} + +// dockerConfigEntryWithAuth is used solely for deserializing the Auth field +// into a dockerConfigEntry during JSON deserialization. +type dockerConfigEntryWithAuth struct { + // +optional + Username string `json:"username,omitempty"` + // +optional + Password string `json:"password,omitempty"` + // +optional + Email string `json:"email,omitempty"` + // +optional + Auth string `json:"auth,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (ident *DockerConfigEntry) UnmarshalJSON(data []byte) error { + var tmp dockerConfigEntryWithAuth + err := json.Unmarshal(data, &tmp) + if err != nil { + return err + } + + ident.Username = tmp.Username + ident.Password = tmp.Password + ident.Email = tmp.Email + + if len(tmp.Auth) == 0 { + return nil + } + + ident.Username, ident.Password, err = decodeDockerConfigFieldAuth(tmp.Auth) + return err +} + +// decodeDockerConfigFieldAuth deserializes the "auth" field from dockercfg into a +// username and a password. The format of the auth field is base64(:). +func decodeDockerConfigFieldAuth(field string) (username, password string, err error) { + + var decoded []byte + + // StdEncoding can only decode padded string + // RawStdEncoding can only decode unpadded string + if strings.HasSuffix(strings.TrimSpace(field), "=") { + // decode padded data + decoded, err = base64.StdEncoding.DecodeString(field) + } else { + // decode unpadded data + decoded, err = base64.RawStdEncoding.DecodeString(field) + } + + if err != nil { + return + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + err = fmt.Errorf("unable to parse auth field, must be formatted as base64(username:password)") + return + } + + username = parts[0] + password = parts[1] + + return +} + +func encodeDockerConfigFieldAuth(username, password string) string { + fieldValue := username + ":" + password + + return base64.StdEncoding.EncodeToString([]byte(fieldValue)) +} diff --git a/internal/credentialprovider/config_test.go b/internal/credentialprovider/config_test.go new file mode 100644 index 0000000..7c06d54 --- /dev/null +++ b/internal/credentialprovider/config_test.go @@ -0,0 +1,243 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentialprovider + +import ( + "encoding/base64" + "encoding/json" + "reflect" + "testing" +) + +func TestDockerConfigJsonJSONDecode(t *testing.T) { + // Fake values for testing. + input := []byte(`{"auths": {"http://foo.example.com":{"username": "foo", "password": "bar", "email": "foo@example.com"}, "http://bar.example.com":{"username": "bar", "password": "baz", "email": "bar@example.com"}}}`) + + expect := DockerConfigJSON{ + Auths: DockerConfig(map[string]DockerConfigEntry{ + "http://foo.example.com": { + Username: "foo", + Password: "bar", + Email: "foo@example.com", + }, + "http://bar.example.com": { + Username: "bar", + Password: "baz", + Email: "bar@example.com", + }, + }), + } + + var output DockerConfigJSON + err := json.Unmarshal(input, &output) + if err != nil { + t.Errorf("Received unexpected error: %v", err) + } + + if !reflect.DeepEqual(expect, output) { + t.Errorf("Received unexpected output. Expected %#v, got %#v", expect, output) + } +} + +func TestDockerConfigJSONDecode(t *testing.T) { + // Fake values for testing. + input := []byte(`{"http://foo.example.com":{"username": "foo", "password": "bar", "email": "foo@example.com"}, "http://bar.example.com":{"username": "bar", "password": "baz", "email": "bar@example.com"}}`) + + expect := DockerConfig(map[string]DockerConfigEntry{ + "http://foo.example.com": { + Username: "foo", + Password: "bar", + Email: "foo@example.com", + }, + "http://bar.example.com": { + Username: "bar", + Password: "baz", + Email: "bar@example.com", + }, + }) + + var output DockerConfig + err := json.Unmarshal(input, &output) + if err != nil { + t.Errorf("Received unexpected error: %v", err) + } + + if !reflect.DeepEqual(expect, output) { + t.Errorf("Received unexpected output. Expected %#v, got %#v", expect, output) + } +} + +func TestDockerConfigEntryJSONDecode(t *testing.T) { + tests := []struct { + input []byte + expect DockerConfigEntry + fail bool + }{ + // simple case, just decode the fields + { + // Fake values for testing. + input: []byte(`{"username": "foo", "password": "bar", "email": "foo@example.com"}`), + expect: DockerConfigEntry{ + Username: "foo", + Password: "bar", + Email: "foo@example.com", + }, + fail: false, + }, + + // auth field decodes to username & password + { + input: []byte(`{"auth": "Zm9vOmJhcg==", "email": "foo@example.com"}`), + expect: DockerConfigEntry{ + Username: "foo", + Password: "bar", + Email: "foo@example.com", + }, + fail: false, + }, + + // auth field overrides username & password + { + // Fake values for testing. + input: []byte(`{"username": "foo", "password": "bar", "auth": "cGluZzpwb25n", "email": "foo@example.com"}`), + expect: DockerConfigEntry{ + Username: "ping", + Password: "pong", + Email: "foo@example.com", + }, + fail: false, + }, + + // poorly-formatted auth causes failure + { + input: []byte(`{"auth": "pants", "email": "foo@example.com"}`), + expect: DockerConfigEntry{ + Username: "", + Password: "", + Email: "foo@example.com", + }, + fail: true, + }, + + // invalid JSON causes failure + { + input: []byte(`{"email": false}`), + expect: DockerConfigEntry{ + Username: "", + Password: "", + Email: "", + }, + fail: true, + }, + } + + for i, tt := range tests { + var output DockerConfigEntry + err := json.Unmarshal(tt.input, &output) + if (err != nil) != tt.fail { + t.Errorf("case %d: expected fail=%t, got err=%v", i, tt.fail, err) + } + + if !reflect.DeepEqual(tt.expect, output) { + t.Errorf("case %d: expected output %#v, got %#v", i, tt.expect, output) + } + } +} + +func TestDecodeDockerConfigFieldAuth(t *testing.T) { + tests := []struct { + input string + username string + password string + fail bool + }{ + // auth field decodes to username & password + { + input: "Zm9vOmJhcg==", + username: "foo", + password: "bar", + }, + + // some test as before but with field not well padded + { + input: "Zm9vOmJhcg", + username: "foo", + password: "bar", + }, + + // some test as before but with new line characters + { + input: "Zm9vOm\nJhcg==\n", + username: "foo", + password: "bar", + }, + + // standard encoding (with padding) + { + input: base64.StdEncoding.EncodeToString([]byte("foo:bar")), + username: "foo", + password: "bar", + }, + + // raw encoding (without padding) + { + input: base64.RawStdEncoding.EncodeToString([]byte("foo:bar")), + username: "foo", + password: "bar", + }, + + // the input is encoded with encodeDockerConfigFieldAuth (standard encoding) + { + input: encodeDockerConfigFieldAuth("foo", "bar"), + username: "foo", + password: "bar", + }, + + // good base64 data, but no colon separating username & password + { + input: "cGFudHM=", + fail: true, + }, + + // only new line characters are ignored + { + input: "Zm9vOmJhcg== ", + fail: true, + }, + + // bad base64 data + { + input: "pants", + fail: true, + }, + } + + for i, tt := range tests { + username, password, err := decodeDockerConfigFieldAuth(tt.input) + if (err != nil) != tt.fail { + t.Errorf("case %d: expected fail=%t, got err=%v", i, tt.fail, err) + } + + if tt.username != username { + t.Errorf("case %d: expected username %q, got %q", i, tt.username, username) + } + + if tt.password != password { + t.Errorf("case %d: expected password %q, got %q", i, tt.password, password) + } + } +} diff --git a/internal/credentialprovider/doc.go b/internal/credentialprovider/doc.go new file mode 100644 index 0000000..754521b --- /dev/null +++ b/internal/credentialprovider/doc.go @@ -0,0 +1,11 @@ +// Package credentialprovider contains a copy of some of the files in +// https://github.com/kubernetes/kubernetes/tree/97332c1edca5be0082414d8a030a408f91bed003/pkg/credentialprovider +// +// The above library is supported nor recommended for use as a module/dependency, see +// https://github.com/kubernetes/kubernetes/issues/79384#issuecomment-505627280 and +// https://github.com/kubernetes/kubernetes/#to-start-using-k8s +// +// Therefore we have copy of the functionality necessary to use pull secrets the same way as kubernetes does. +// The files we copied do not change often upstream, but ideally we should check for changes every kubernetes release +// and update the permalink above to reflect the latest sync point. +package credentialprovider diff --git a/internal/credentialprovider/keyring.go b/internal/credentialprovider/keyring.go new file mode 100644 index 0000000..cf16f80 --- /dev/null +++ b/internal/credentialprovider/keyring.go @@ -0,0 +1,254 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentialprovider + +import ( + "net" + "net/url" + "path/filepath" + "sort" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/klog/v2" +) + +// DockerKeyring tracks a set of docker registry credentials, maintaining a +// reverse index across the registry endpoints. A registry endpoint is made +// up of a host (e.g. registry.example.com), but it may also contain a path +// (e.g. registry.example.com/foo) This index is important for two reasons: +// - registry endpoints may overlap, and when this happens we must find the +// most specific match for a given image +// - iterating a map does not yield predictable results +type DockerKeyring interface { + Lookup(image string) ([]AuthConfig, bool) +} + +// BasicDockerKeyring is a trivial map-backed implementation of DockerKeyring +type BasicDockerKeyring struct { + index []string + creds map[string][]AuthConfig +} + +// AuthConfig contains authorization information for connecting to a Registry +// This type mirrors "github.com/docker/docker/api/types.AuthConfig" +type AuthConfig struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Auth string `json:"auth,omitempty"` + + // Email is an optional value associated with the username. + // This field is deprecated and will be removed in a later + // version of docker. + Email string `json:"email,omitempty"` + + ServerAddress string `json:"serveraddress,omitempty"` + + // IdentityToken is used to authenticate the user and get + // an access token for the registry. + IdentityToken string `json:"identitytoken,omitempty"` + + // RegistryToken is a bearer token to be sent to a registry + RegistryToken string `json:"registrytoken,omitempty"` +} + +// Add add some docker config in basic docker keyring +func (dk *BasicDockerKeyring) Add(cfg DockerConfig) { + if dk.index == nil { + dk.index = make([]string, 0) + dk.creds = make(map[string][]AuthConfig) + } + for loc, ident := range cfg { + creds := AuthConfig{ + Username: ident.Username, + Password: ident.Password, + Email: ident.Email, + } + + value := loc + if !strings.HasPrefix(value, "https://") && !strings.HasPrefix(value, "http://") { + value = "https://" + value + } + parsed, err := url.Parse(value) + if err != nil { + klog.Errorf("Entry %q in dockercfg invalid (%v), ignoring", loc, err) + continue + } + + // The docker client allows exact matches: + // foo.bar.com/namespace + // Or hostname matches: + // foo.bar.com + // It also considers /v2/ and /v1/ equivalent to the hostname + // See ResolveAuthConfig in docker/registry/auth.go. + effectivePath := parsed.Path + if strings.HasPrefix(effectivePath, "/v2/") || strings.HasPrefix(effectivePath, "/v1/") { + effectivePath = effectivePath[3:] + } + var key string + if (len(effectivePath) > 0) && (effectivePath != "/") { + key = parsed.Host + effectivePath + } else { + key = parsed.Host + } + dk.creds[key] = append(dk.creds[key], creds) + dk.index = append(dk.index, key) + } + + eliminateDupes := sets.NewString(dk.index...) + dk.index = eliminateDupes.List() + + // Update the index used to identify which credentials to use for a given + // image. The index is reverse-sorted so more specific paths are matched + // first. For example, if for the given image "gcr.io/etcd-development/etcd", + // credentials for "quay.io/coreos" should match before "quay.io". + sort.Sort(sort.Reverse(sort.StringSlice(dk.index))) +} + +const ( + defaultRegistryHost = "index.docker.io" +) + +// isDefaultRegistryMatch determines whether the given image will +// pull from the default registry (DockerHub) based on the +// characteristics of its name. +func isDefaultRegistryMatch(image string) bool { + parts := strings.SplitN(image, "/", 2) + + if len(parts[0]) == 0 { + return false + } + + if len(parts) == 1 { + // e.g. library/ubuntu + return true + } + + if parts[0] == "docker.io" || parts[0] == "index.docker.io" { + // resolve docker.io/image and index.docker.io/image as default registry + return true + } + + // From: http://blog.docker.com/2013/07/how-to-use-your-own-registry/ + // Docker looks for either a “.” (domain separator) or “:” (port separator) + // to learn that the first part of the repository name is a location and not + // a user name. + return !strings.ContainsAny(parts[0], ".:") +} + +// ParseSchemelessURL parses a schemeless url and returns a url.URL +// url.Parse require a scheme, but ours don't have schemes. Adding a +// scheme to make url.Parse happy, then clear out the resulting scheme. +func ParseSchemelessURL(schemelessURL string) (*url.URL, error) { + parsed, err := url.Parse("https://" + schemelessURL) + if err != nil { + return nil, err + } + // clear out the resulting scheme + parsed.Scheme = "" + return parsed, nil +} + +// SplitURL splits the host name into parts, as well as the port +func SplitURL(url *url.URL) (parts []string, port string) { + host, port, err := net.SplitHostPort(url.Host) + if err != nil { + // could not parse port + host, port = url.Host, "" + } + return strings.Split(host, "."), port +} + +// URLsMatchStr is wrapper for URLsMatch, operating on strings instead of URLs. +func URLsMatchStr(glob string, target string) (bool, error) { + globURL, err := ParseSchemelessURL(glob) + if err != nil { + return false, err + } + targetURL, err := ParseSchemelessURL(target) + if err != nil { + return false, err + } + return URLsMatch(globURL, targetURL) +} + +// URLsMatch checks whether the given target url matches the glob url, which may have +// glob wild cards in the host name. +// +// Examples: +// +// globURL=*.docker.io, targetURL=blah.docker.io => match +// globURL=*.docker.io, targetURL=not.right.io => no match +// +// Note that we don't support wildcards in ports and paths yet. +func URLsMatch(globURL *url.URL, targetURL *url.URL) (bool, error) { + globURLParts, globPort := SplitURL(globURL) + targetURLParts, targetPort := SplitURL(targetURL) + if globPort != targetPort { + // port doesn't match + return false, nil + } + if len(globURLParts) != len(targetURLParts) { + // host name does not have the same number of parts + return false, nil + } + if !strings.HasPrefix(targetURL.Path, globURL.Path) { + // the path of the credential must be a prefix + return false, nil + } + for k, globURLPart := range globURLParts { + targetURLPart := targetURLParts[k] + matched, err := filepath.Match(globURLPart, targetURLPart) + if err != nil { + return false, err + } + if !matched { + // glob mismatch for some part + return false, nil + } + } + // everything matches + return true, nil +} + +// Lookup implements the DockerKeyring method for fetching credentials based on image name. +// Multiple credentials may be returned if there are multiple potentially valid credentials +// available. This allows for rotation. +func (dk *BasicDockerKeyring) Lookup(image string) ([]AuthConfig, bool) { + // range over the index as iterating over a map does not provide a predictable ordering + ret := []AuthConfig{} + for _, k := range dk.index { + // both k and image are schemeless URLs because even though schemes are allowed + // in the credential configurations, we remove them in Add. + if matched, _ := URLsMatchStr(k, image); matched { + ret = append(ret, dk.creds[k]...) + } + } + + if len(ret) > 0 { + return ret, true + } + + // Use credentials for the default registry if provided, and appropriate + if isDefaultRegistryMatch(image) { + if auth, ok := dk.creds[defaultRegistryHost]; ok { + return auth, true + } + } + + return []AuthConfig{}, false +} diff --git a/internal/credentialprovider/keyring_test.go b/internal/credentialprovider/keyring_test.go new file mode 100644 index 0000000..38309f0 --- /dev/null +++ b/internal/credentialprovider/keyring_test.go @@ -0,0 +1,263 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentialprovider + +import ( + "reflect" + "testing" +) + +func TestURLsMatch(t *testing.T) { + tests := []struct { + globURL string + targetURL string + matchExpected bool + }{ + // match when there is no path component + { + globURL: "*.kubernetes.io", + targetURL: "prefix.kubernetes.io", + matchExpected: true, + }, + { + globURL: "prefix.*.io", + targetURL: "prefix.kubernetes.io", + matchExpected: true, + }, + { + globURL: "prefix.kubernetes.*", + targetURL: "prefix.kubernetes.io", + matchExpected: true, + }, + { + globURL: "*-good.kubernetes.io", + targetURL: "prefix-good.kubernetes.io", + matchExpected: true, + }, + // match with path components + { + globURL: "*.kubernetes.io/blah", + targetURL: "prefix.kubernetes.io/blah", + matchExpected: true, + }, + { + globURL: "prefix.*.io/foo", + targetURL: "prefix.kubernetes.io/foo/bar", + matchExpected: true, + }, + // match with path components and ports + { + globURL: "*.kubernetes.io:1111/blah", + targetURL: "prefix.kubernetes.io:1111/blah", + matchExpected: true, + }, + { + globURL: "prefix.*.io:1111/foo", + targetURL: "prefix.kubernetes.io:1111/foo/bar", + matchExpected: true, + }, + // no match when number of parts mismatch + { + globURL: "*.kubernetes.io", + targetURL: "kubernetes.io", + matchExpected: false, + }, + { + globURL: "*.*.kubernetes.io", + targetURL: "prefix.kubernetes.io", + matchExpected: false, + }, + { + globURL: "*.*.kubernetes.io", + targetURL: "kubernetes.io", + matchExpected: false, + }, + { + globURL: "*kubernetes.io", + targetURL: "a.kubernetes.io", + matchExpected: false, + }, + // match when number of parts match + { + globURL: "*kubernetes.io", + targetURL: "kubernetes.io", + matchExpected: true, + }, + { + globURL: "*.*.*.kubernetes.io", + targetURL: "a.b.c.kubernetes.io", + matchExpected: true, + }, + // no match when some parts mismatch + { + globURL: "kubernetes.io", + targetURL: "kubernetes.com", + matchExpected: false, + }, + { + globURL: "k*.io", + targetURL: "quay.io", + matchExpected: false, + }, + // no match when ports mismatch + { + globURL: "*.kubernetes.io:1234/blah", + targetURL: "prefix.kubernetes.io:1111/blah", + matchExpected: false, + }, + { + globURL: "prefix.*.io/foo", + targetURL: "prefix.kubernetes.io:1111/foo/bar", + matchExpected: false, + }, + } + for _, test := range tests { + matched, _ := URLsMatchStr(test.globURL, test.targetURL) + if matched != test.matchExpected { + t.Errorf("Expected match result of %s and %s to be %t, but was %t", + test.globURL, test.targetURL, test.matchExpected, matched) + } + } +} + +func TestIsDefaultRegistryMatch(t *testing.T) { + samples := []map[bool]string{ + {true: "foo/bar"}, + {true: "docker.io/foo/bar"}, + {true: "index.docker.io/foo/bar"}, + {true: "foo"}, + {false: ""}, + {false: "registry.tld/foo/bar"}, + {false: "registry:5000/foo/bar"}, + {false: "myhostdocker.io/foo/bar"}, + } + for _, sample := range samples { + for expected, imageName := range sample { + if got := isDefaultRegistryMatch(imageName); got != expected { + t.Errorf("Expected '%s' to be %t, got %t", imageName, expected, got) + } + } + } +} + +func TestDockerKeyringLookup(t *testing.T) { + ada := AuthConfig{ + Username: "ada", + Password: "smash", // Fake value for testing. + Email: "ada@example.com", + } + + grace := AuthConfig{ + Username: "grace", + Password: "squash", // Fake value for testing. + Email: "grace@example.com", + } + + dk := &BasicDockerKeyring{} + dk.Add(DockerConfig{ + "bar.example.com/pong": DockerConfigEntry{ + Username: grace.Username, + Password: grace.Password, + Email: grace.Email, + }, + "bar.example.com": DockerConfigEntry{ + Username: ada.Username, + Password: ada.Password, + Email: ada.Email, + }, + }) + + tests := []struct { + image string + match []AuthConfig + ok bool + }{ + // direct match + {"bar.example.com", []AuthConfig{ada}, true}, + + // direct match deeper than other possible matches + {"bar.example.com/pong", []AuthConfig{grace, ada}, true}, + + // no direct match, deeper path ignored + {"bar.example.com/ping", []AuthConfig{ada}, true}, + + // match first part of path token + {"bar.example.com/pongz", []AuthConfig{grace, ada}, true}, + + // match regardless of sub-path + {"bar.example.com/pong/pang", []AuthConfig{grace, ada}, true}, + + // no host match + {"example.com", []AuthConfig{}, false}, + {"foo.example.com", []AuthConfig{}, false}, + } + + for i, tt := range tests { + match, ok := dk.Lookup(tt.image) + if tt.ok != ok { + t.Errorf("case %d: expected ok=%t, got %t", i, tt.ok, ok) + } + + if !reflect.DeepEqual(tt.match, match) { + t.Errorf("case %d: expected match=%#v, got %#v", i, tt.match, match) + } + } +} + +// This validates that dockercfg entries with a scheme and url path are properly matched +// by images that only match the hostname. +// NOTE: the above covers the case of a more specific match trumping just hostname. +func TestIssue3797(t *testing.T) { + rex := AuthConfig{ + Username: "rex", + Password: "tiny arms", // Fake value for testing. + Email: "rex@example.com", + } + + dk := &BasicDockerKeyring{} + dk.Add(DockerConfig{ + "https://quay.io/v1/": DockerConfigEntry{ + Username: rex.Username, + Password: rex.Password, + Email: rex.Email, + }, + }) + + tests := []struct { + image string + match []AuthConfig + ok bool + }{ + // direct match + {"quay.io", []AuthConfig{rex}, true}, + + // partial matches + {"quay.io/foo", []AuthConfig{rex}, true}, + {"quay.io/foo/bar", []AuthConfig{rex}, true}, + } + + for i, tt := range tests { + match, ok := dk.Lookup(tt.image) + if tt.ok != ok { + t.Errorf("case %d: expected ok=%t, got %t", i, tt.ok, ok) + } + + if !reflect.DeepEqual(tt.match, match) { + t.Errorf("case %d: expected match=%#v, got %#v", i, tt.match, match) + } + } +} diff --git a/internal/credentialprovider/provider.go b/internal/credentialprovider/provider.go new file mode 100644 index 0000000..8077ab5 --- /dev/null +++ b/internal/credentialprovider/provider.go @@ -0,0 +1,31 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentialprovider + +// DockerConfigProvider is the interface that registered extensions implement +// to materialize 'dockercfg' credentials. +type DockerConfigProvider interface { + // Enabled returns true if the config provider is enabled. + // Implementations can be blocking - e.g. metadata server unavailable. + Enabled() bool + // Provide returns docker configuration. + // Implementations can be blocking - e.g. metadata server unavailable. + // The image is passed in as context in the event that the + // implementation depends on information in the image name to return + // credentials; implementations are safe to ignore the image. + Provide(image string) DockerConfig +} diff --git a/internal/main.go b/internal/main.go index 05f67bd..4908a7b 100644 --- a/internal/main.go +++ b/internal/main.go @@ -9,10 +9,11 @@ import ( "sync" "time" + "github.com/stackrox/image-prefetcher/internal/credentialprovider" + "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" criV1 "k8s.io/cri-api/pkg/apis/runtime/v1" - "k8s.io/kubernetes/pkg/credentialprovider" ) type TimingConfig struct {