diff --git a/cluster/gce/config-default.sh b/cluster/gce/config-default.sh index 15a581ea64..7ff2c4e139 100755 --- a/cluster/gce/config-default.sh +++ b/cluster/gce/config-default.sh @@ -31,7 +31,7 @@ MASTER_TAG="${INSTANCE_PREFIX}-master" MINION_TAG="${INSTANCE_PREFIX}-minion" MINION_NAMES=($(eval echo ${INSTANCE_PREFIX}-minion-{1..${NUM_MINIONS}})) MINION_IP_RANGES=($(eval echo "10.244.{1..${NUM_MINIONS}}.0/24")) -MINION_SCOPES="compute-rw" +MINION_SCOPES="storage-ro,compute-rw" # Increase the sleep interval value if concerned about API rate limits. 3, in seconds, is the default. POLL_SLEEP_INTERVAL=3 PORTAL_NET="10.0.0.0/16" diff --git a/cmd/kubelet/plugins.go b/cmd/kubelet/plugins.go new file mode 100644 index 0000000000..de10a7d65c --- /dev/null +++ b/cmd/kubelet/plugins.go @@ -0,0 +1,24 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 main + +// This file exists to force the desired plugin implementations to be linked. +// This should probably be part of some configuration fed into the build for a +// given binary target. +import ( + _ "github.com/GoogleCloudPlatform/kubernetes/pkg/credentialprovider/gcp" +) diff --git a/hack/e2e-suite/private.sh b/hack/e2e-suite/private.sh new file mode 100755 index 0000000000..1401ed69d5 --- /dev/null +++ b/hack/e2e-suite/private.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# Copyright 2014 Google Inc. All rights reserved. +# +# 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. + +# Launches a container and verifies it can be reached. Assumes that +# we're being called by hack/e2e-test.sh (we use some env vars it sets up). + +set -o errexit +set -o nounset +set -o pipefail + +if [[ "${KUBERNETES_PROVIDER:-gce}" != "gce" ]]; then + echo WARNING: Skipping private.sh for cloud provider: $KUBERNETES_PROVIDER. + exit 0 +fi + +KUBE_ROOT=$(dirname "${BASH_SOURCE}")/../.. +source "${KUBE_ROOT}/cluster/kube-env.sh" +source "${KUBE_ROOT}/cluster/$KUBERNETES_PROVIDER/util.sh" + +# Launch some pods. +num_pods=2 +$KUBECFG -p 8080:9376 run container.cloud.google.com/_b_k8s_test/serve_hostname ${num_pods} my-hostname + +function teardown() { + echo "Cleaning up test artifacts" + $KUBECFG stop my-hostname + $KUBECFG rm my-hostname +} + +trap "teardown" EXIT + +pod_id_list=$($KUBECFG '-template={{range.items}}{{.id}} {{end}}' -l replicationController=my-hostname list pods) +# Pod turn up on a clean cluster can take a while for the docker image pull. +all_running=0 +for i in $(seq 1 24); do + echo "Waiting for pods to come up." + sleep 5 + all_running=1 + for id in $pod_id_list; do + current_status=$($KUBECFG -template '{{.currentState.status}}' get pods/$id) || true + if [[ "$current_status" != "Running" ]]; then + all_running=0 + break + fi + done + if [[ "${all_running}" == 1 ]]; then + break + fi +done +if [[ "${all_running}" == 0 ]]; then + echo "Pods did not come up in time" + exit 1 +fi + +# Get minion IP addresses +detect-minions + +# let images stabilize +echo "Letting images stabilize" +sleep 5 + +# Verify that something is listening. +for id in ${pod_id_list}; do + ip=$($KUBECFG -template '{{.currentState.hostIP}}' get pods/$id) + echo "Trying to reach server that should be running at ${ip}:8080..." + ok=0 + for i in $(seq 1 5); do + curl --connect-timeout 1 "http://${ip}:8080" >/dev/null 2>&1 && ok=1 && break + sleep 2 + done +done + +exit 0 diff --git a/pkg/kubelet/dockertools/config.go b/pkg/credentialprovider/config.go similarity index 60% rename from pkg/kubelet/dockertools/config.go rename to pkg/credentialprovider/config.go index f52351cb6f..f188029187 100644 --- a/pkg/kubelet/dockertools/config.go +++ b/pkg/credentialprovider/config.go @@ -14,26 +14,38 @@ See the License for the specific language governing permissions and limitations under the License. */ -package dockertools +package credentialprovider import ( "encoding/base64" "encoding/json" "fmt" "io/ioutil" - "net/url" + "net/http" "path/filepath" "strings" - "github.com/fsouza/go-dockerclient" "github.com/golang/glog" ) +// 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 + +type DockerConfigEntry struct { + Username string + Password string + Email string +} + const ( dockerConfigFileLocation = ".dockercfg" ) -func readDockerConfigFile() (cfg dockerConfig, err error) { +func ReadDockerConfigFile() (cfg DockerConfig, err error) { + // TODO(mattmoor): This causes the Kubelet to read /.dockercfg, + // which is incorrect. It should come from $HOME/.dockercfg. absDockerConfigFileLocation, err := filepath.Abs(dockerConfigFileLocation) if err != nil { glog.Errorf("while trying to canonicalize %s: %v", dockerConfigFileLocation, err) @@ -45,42 +57,58 @@ func readDockerConfigFile() (cfg dockerConfig, err error) { glog.Errorf("while trying to read %s: %v", absDockerConfigFileLocation, err) return nil, err } + + return readDockerConfigFileFromBytes(contents) +} + +func ReadUrl(url string, client *http.Client, header *http.Header) (body []byte, err error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + glog.Errorf("while creating request to read %s: %v", url, err) + return nil, err + } + if header != nil { + req.Header = *header + } + resp, err := client.Do(req) + if err != nil { + glog.Errorf("while trying to read %s: %v", url, err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("http status code: %d while fetching url: %v", resp.StatusCode) + glog.Errorf("while trying to read %s: %v", url, err) + glog.V(2).Infof("body of failing http response: %v", resp.Body) + return nil, err + } + + contents, err := ioutil.ReadAll(resp.Body) + if err != nil { + glog.Errorf("while trying to read %s: %v", url, err) + return nil, err + } + + return contents, nil +} + +func ReadDockerConfigFileFromUrl(url string, client *http.Client, header *http.Header) (cfg DockerConfig, err error) { + if contents, err := ReadUrl(url, client, header); err != nil { + return nil, err + } else { + return readDockerConfigFileFromBytes(contents) + } +} + +func readDockerConfigFileFromBytes(contents []byte) (cfg DockerConfig, err error) { if err = json.Unmarshal(contents, &cfg); err != nil { - glog.Errorf("while trying to parse %s: %v", absDockerConfigFileLocation, err) + glog.Errorf("while trying to parse blob %q: %v", contents, err) return nil, err } return } -// 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 - -func (dc dockerConfig) addToKeyring(dk *dockerKeyring) { - for loc, ident := range dc { - creds := docker.AuthConfiguration{ - Username: ident.Username, - Password: ident.Password, - Email: ident.Email, - } - - parsed, err := url.Parse(loc) - if err != nil { - glog.Errorf("Entry %q in dockercfg invalid (%v), ignoring", loc, err) - continue - } - - dk.add(parsed.Host+parsed.Path, creds) - } -} - -type dockerConfigEntry struct { - Username string - Password string - Email string -} - // dockerConfigEntryWithAuth is used solely for deserializing the Auth field // into a dockerConfigEntry during JSON deserialization. type dockerConfigEntryWithAuth struct { @@ -90,7 +118,7 @@ type dockerConfigEntryWithAuth struct { Auth string } -func (ident *dockerConfigEntry) UnmarshalJSON(data []byte) error { +func (ident *DockerConfigEntry) UnmarshalJSON(data []byte) error { var tmp dockerConfigEntryWithAuth err := json.Unmarshal(data, &tmp) if err != nil { diff --git a/pkg/kubelet/dockertools/config_test.go b/pkg/credentialprovider/config_test.go similarity index 76% rename from pkg/kubelet/dockertools/config_test.go rename to pkg/credentialprovider/config_test.go index 9b1a75e48b..2508552e16 100644 --- a/pkg/kubelet/dockertools/config_test.go +++ b/pkg/credentialprovider/config_test.go @@ -14,20 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -package dockertools +package credentialprovider import ( "encoding/json" "reflect" "testing" - - "github.com/fsouza/go-dockerclient" ) func TestDockerConfigJSONDecode(t *testing.T) { 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{ + expect := DockerConfig(map[string]DockerConfigEntry{ "http://foo.example.com": { Username: "foo", Password: "bar", @@ -40,7 +38,7 @@ func TestDockerConfigJSONDecode(t *testing.T) { }, }) - var output dockerConfig + var output DockerConfig err := json.Unmarshal(input, &output) if err != nil { t.Errorf("Received unexpected error: %v", err) @@ -54,13 +52,13 @@ func TestDockerConfigJSONDecode(t *testing.T) { func TestDockerConfigEntryJSONDecode(t *testing.T) { tests := []struct { input []byte - expect dockerConfigEntry + expect DockerConfigEntry fail bool }{ // simple case, just decode the fields { input: []byte(`{"username": "foo", "password": "bar", "email": "foo@example.com"}`), - expect: dockerConfigEntry{ + expect: DockerConfigEntry{ Username: "foo", Password: "bar", Email: "foo@example.com", @@ -71,7 +69,7 @@ func TestDockerConfigEntryJSONDecode(t *testing.T) { // auth field decodes to username & password { input: []byte(`{"auth": "Zm9vOmJhcg==", "email": "foo@example.com"}`), - expect: dockerConfigEntry{ + expect: DockerConfigEntry{ Username: "foo", Password: "bar", Email: "foo@example.com", @@ -82,7 +80,7 @@ func TestDockerConfigEntryJSONDecode(t *testing.T) { // auth field overrides username & password { input: []byte(`{"username": "foo", "password": "bar", "auth": "cGluZzpwb25n", "email": "foo@example.com"}`), - expect: dockerConfigEntry{ + expect: DockerConfigEntry{ Username: "ping", Password: "pong", Email: "foo@example.com", @@ -93,7 +91,7 @@ func TestDockerConfigEntryJSONDecode(t *testing.T) { // poorly-formatted auth causes failure { input: []byte(`{"auth": "pants", "email": "foo@example.com"}`), - expect: dockerConfigEntry{ + expect: DockerConfigEntry{ Username: "", Password: "", Email: "foo@example.com", @@ -104,7 +102,7 @@ func TestDockerConfigEntryJSONDecode(t *testing.T) { // invalid JSON causes failure { input: []byte(`{"email": false}`), - expect: dockerConfigEntry{ + expect: DockerConfigEntry{ Username: "", Password: "", Email: "", @@ -114,7 +112,7 @@ func TestDockerConfigEntryJSONDecode(t *testing.T) { } for i, tt := range tests { - var output dockerConfigEntry + 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) @@ -168,41 +166,3 @@ func TestDecodeDockerConfigFieldAuth(t *testing.T) { } } } - -func TestDockerKeyringFromConfig(t *testing.T) { - cfg := dockerConfig(map[string]dockerConfigEntry{ - "http://foo.example.com": { - Username: "foo", - Password: "bar", - Email: "foo@example.com", - }, - "https://bar.example.com": { - Username: "bar", - Password: "baz", - Email: "bar@example.com", - }, - }) - - dk := newDockerKeyring() - cfg.addToKeyring(dk) - - expect := newDockerKeyring() - expect.add("foo.example.com", - docker.AuthConfiguration{ - Username: "foo", - Password: "bar", - Email: "foo@example.com", - }, - ) - expect.add("bar.example.com", - docker.AuthConfiguration{ - Username: "bar", - Password: "baz", - Email: "bar@example.com", - }, - ) - - if !reflect.DeepEqual(expect, dk) { - t.Errorf("Received unexpected output. Expected %#v, got %#v", expect, dk) - } -} diff --git a/pkg/credentialprovider/doc.go b/pkg/credentialprovider/doc.go new file mode 100644 index 0000000000..22bb33e0bd --- /dev/null +++ b/pkg/credentialprovider/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 supplies interfaces and implementations for +// docker registry providers to expose their authentication scheme. +package credentialprovider diff --git a/pkg/credentialprovider/gcp/doc.go b/pkg/credentialprovider/gcp/doc.go new file mode 100644 index 0000000000..4f5c7d5ebd --- /dev/null +++ b/pkg/credentialprovider/gcp/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 gcp_credentials contains implementations of DockerConfigProvider +// for Google Cloud Platform. +package gcp_credentials diff --git a/pkg/credentialprovider/gcp/metadata.go b/pkg/credentialprovider/gcp/metadata.go new file mode 100644 index 0000000000..dd7149c42a --- /dev/null +++ b/pkg/credentialprovider/gcp/metadata.go @@ -0,0 +1,190 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 gcp_credentials + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/credentialprovider" + "github.com/golang/glog" +) + +const ( + metadataUrl = "http://metadata.google.internal./computeMetadata/v1/" + metadataAttributes = metadataUrl + "instance/attributes/" + dockerConfigKey = metadataAttributes + "google-dockercfg" + dockerConfigUrlKey = metadataAttributes + "google-dockercfg-url" + metadataScopes = metadataUrl + "instance/service-accounts/default/scopes" + metadataToken = metadataUrl + "instance/service-accounts/default/token" + metadataEmail = metadataUrl + "instance/service-accounts/default/email" + storageScopePrefix = "https://www.googleapis.com/auth/devstorage" +) + +var containerRegistryUrls = []string{"container.cloud.google.com"} + +var metadataHeader = &http.Header{ + "Metadata-Flavor": []string{"Google"}, +} + +// A DockerConfigProvider that reads its configuration from Google +// Compute Engine metadata. +type metadataProvider struct { + Client *http.Client +} + +// A DockerConfigProvider that reads its configuration from a specific +// Google Compute Engine metadata key: 'google-dockercfg'. +type dockerConfigKeyProvider struct { + metadataProvider +} + +// A DockerConfigProvider that reads its configuration from a URL read from +// a specific Google Compute Engine metadata key: 'google-dockercfg-url'. +type dockerConfigUrlKeyProvider struct { + metadataProvider +} + +// A DockerConfigProvider that provides a dockercfg with: +// Username: "_token" +// Password: "{access token from metadata}" +type containerRegistryProvider struct { + metadataProvider +} + +// init registers the various means by which credentials may +// be resolved on GCP. +func init() { + credentialprovider.RegisterCredentialProvider("google-dockercfg", + &credentialprovider.CachingDockerConfigProvider{ + Provider: &dockerConfigKeyProvider{ + metadataProvider{Client: http.DefaultClient}, + }, + Lifetime: 60 * time.Second, + }) + + credentialprovider.RegisterCredentialProvider("google-dockercfg-url", + &credentialprovider.CachingDockerConfigProvider{ + Provider: &dockerConfigUrlKeyProvider{ + metadataProvider{Client: http.DefaultClient}, + }, + Lifetime: 60 * time.Second, + }) + + credentialprovider.RegisterCredentialProvider("google-container-registry", + // Never cache this. The access token is already + // cached by the metadata service. + &containerRegistryProvider{ + metadataProvider{Client: http.DefaultClient}, + }) +} + +// Enabled implements DockerConfigProvider for all of the Google implementations. +func (g *metadataProvider) Enabled() bool { + _, err := credentialprovider.ReadUrl(metadataUrl, g.Client, metadataHeader) + return err == nil +} + +// Provide implements DockerConfigProvider +func (g *dockerConfigKeyProvider) Provide() credentialprovider.DockerConfig { + // Read the contents of the google-dockercfg metadata key and + // parse them as an alternate .dockercfg + if cfg, err := credentialprovider.ReadDockerConfigFileFromUrl(dockerConfigKey, g.Client, metadataHeader); err == nil { + return cfg + } + + return credentialprovider.DockerConfig{} +} + +// Provide implements DockerConfigProvider +func (g *dockerConfigUrlKeyProvider) Provide() credentialprovider.DockerConfig { + // Read the contents of the google-dockercfg-url key and load a .dockercfg from there + if url, err := credentialprovider.ReadUrl(dockerConfigUrlKey, g.Client, metadataHeader); err == nil { + if strings.HasPrefix(string(url), "http") { + if cfg, err := credentialprovider.ReadDockerConfigFileFromUrl(string(url), g.Client, nil); err == nil { + return cfg + } + } else { + // TODO(mattmoor): support reading alternate scheme URLs (e.g. gs:// or s3://) + glog.Errorf("Unsupported URL scheme: %s", string(url)) + } + } + + return credentialprovider.DockerConfig{} +} + +// Enabled implements a special metadata-based check, which verifies the +// storage scope is available on the GCE VM. +func (g *containerRegistryProvider) Enabled() bool { + value, err := credentialprovider.ReadUrl(metadataScopes+"?alt=json", g.Client, metadataHeader) + if err != nil { + return false + } + var scopes []string + if err := json.Unmarshal([]byte(value), &scopes); err != nil { + return false + } + + for _, v := range scopes { + if strings.HasPrefix(v, storageScopePrefix) { + return true + } + } + glog.Warningf("Google container registry is disabled, no storage scope is available: %s", value) + return false +} + +// tokenBlob is used to decode the JSON blob containing an access token +// that is returned by GCE metadata. +type tokenBlob struct { + AccessToken string `json:"access_token"` +} + +// Provide implements DockerConfigProvider +func (g *containerRegistryProvider) Provide() credentialprovider.DockerConfig { + cfg := credentialprovider.DockerConfig{} + + tokenJsonBlob, err := credentialprovider.ReadUrl(metadataToken, g.Client, metadataHeader) + if err != nil { + return cfg + } + + email, err := credentialprovider.ReadUrl(metadataEmail, g.Client, metadataHeader) + if err != nil { + return cfg + } + + var parsedBlob tokenBlob + if err := json.Unmarshal([]byte(tokenJsonBlob), &parsedBlob); err != nil { + glog.Errorf("while parsing json blob %s: %v", tokenJsonBlob, err) + return cfg + } + + entry := credentialprovider.DockerConfigEntry{ + Username: "_token", + Password: parsedBlob.AccessToken, + Email: string(email), + } + + // Add our entry for each of the supported container registry URLs + for _, k := range containerRegistryUrls { + cfg[k] = entry + } + return cfg +} diff --git a/pkg/credentialprovider/gcp/metadata_test.go b/pkg/credentialprovider/gcp/metadata_test.go new file mode 100644 index 0000000000..e363e53a0d --- /dev/null +++ b/pkg/credentialprovider/gcp/metadata_test.go @@ -0,0 +1,293 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 gcp_credentials + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/credentialprovider" +) + +func TestDockerKeyringFromGoogleDockerConfigMetadata(t *testing.T) { + registryUrl := "hello.kubernetes.io" + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, registryUrl, email, auth) + + const probeEndpoint = "/computeMetadata/v1/" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only serve the one metadata key. + if probeEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + } else if strings.HasSuffix(dockerConfigKey, r.URL.Path) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, sampleDockerConfig) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Make a transport that reroutes all traffic to the example server + transport := &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL + req.URL.Path) + }, + } + + keyring := &credentialprovider.BasicDockerKeyring{} + provider := &dockerConfigKeyProvider{ + metadataProvider{Client: &http.Client{Transport: transport}}, + } + + if !provider.Enabled() { + t.Errorf("Provider is unexpectedly disabled") + } + + keyring.Add(provider.Provide()) + + val, ok := keyring.Lookup(registryUrl) + if !ok { + t.Errorf("Didn't find expected URL: %s", registryUrl) + } + + if username != val.Username { + t.Errorf("Unexpected username value, want: %s, got: %s", username, val.Username) + } + if password != val.Password { + t.Errorf("Unexpected password value, want: %s, got: %s", password, val.Password) + } + if email != val.Email { + t.Errorf("Unexpected email value, want: %s, got: %s", email, val.Email) + } +} + +func TestDockerKeyringFromGoogleDockerConfigMetadataUrl(t *testing.T) { + registryUrl := "hello.kubernetes.io" + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, registryUrl, email, auth) + + const probeEndpoint = "/computeMetadata/v1/" + const valueEndpoint = "/my/value" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only serve the URL key and the value endpoint + if probeEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + } else if valueEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, sampleDockerConfig) + } else if strings.HasSuffix(dockerConfigUrlKey, r.URL.Path) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/text") + fmt.Fprint(w, "http://foo.bar.com"+valueEndpoint) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Make a transport that reroutes all traffic to the example server + transport := &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL + req.URL.Path) + }, + } + + keyring := &credentialprovider.BasicDockerKeyring{} + provider := &dockerConfigUrlKeyProvider{ + metadataProvider{Client: &http.Client{Transport: transport}}, + } + + if !provider.Enabled() { + t.Errorf("Provider is unexpectedly disabled") + } + + keyring.Add(provider.Provide()) + + val, ok := keyring.Lookup(registryUrl) + if !ok { + t.Errorf("Didn't find expected URL: %s", registryUrl) + } + + if username != val.Username { + t.Errorf("Unexpected username value, want: %s, got: %s", username, val.Username) + } + if password != val.Password { + t.Errorf("Unexpected password value, want: %s, got: %s", password, val.Password) + } + if email != val.Email { + t.Errorf("Unexpected email value, want: %s, got: %s", email, val.Email) + } +} + +func TestContainerRegistryBasics(t *testing.T) { + registryUrl := "container.cloud.google.com" + email := "1234@project.gserviceaccount.com" + token := &tokenBlob{AccessToken: "ya26.lots-of-indiscernible-garbage"} + + const ( + defaultEndpoint = "/computeMetadata/v1/instance/service-accounts/default/" + scopeEndpoint = defaultEndpoint + "scopes" + emailEndpoint = defaultEndpoint + "email" + tokenEndpoint = defaultEndpoint + "token" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only serve the URL key and the value endpoint + if scopeEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `["%s.read_write"]`, storageScopePrefix) + } else if emailEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, email) + } else if tokenEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + bytes, err := json.Marshal(token) + if err != nil { + t.Fatalf("unexpected error: %+v", err) + } + fmt.Fprintln(w, string(bytes)) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Make a transport that reroutes all traffic to the example server + transport := &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL + req.URL.Path) + }, + } + + keyring := &credentialprovider.BasicDockerKeyring{} + provider := &containerRegistryProvider{ + metadataProvider{Client: &http.Client{Transport: transport}}, + } + + if !provider.Enabled() { + t.Errorf("Provider is unexpectedly disabled") + } + + keyring.Add(provider.Provide()) + + val, ok := keyring.Lookup(registryUrl) + if !ok { + t.Errorf("Didn't find expected URL: %s", registryUrl) + } + + if "_token" != val.Username { + t.Errorf("Unexpected username value, want: %s, got: %s", "_token", val.Username) + } + if token.AccessToken != val.Password { + t.Errorf("Unexpected password value, want: %s, got: %s", token.AccessToken, val.Password) + } + if email != val.Email { + t.Errorf("Unexpected email value, want: %s, got: %s", email, val.Email) + } +} + +func TestContainerRegistryNoStorageScope(t *testing.T) { + const ( + defaultEndpoint = "/computeMetadata/v1/instance/service-accounts/default/" + scopeEndpoint = defaultEndpoint + "scopes" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only serve the URL key and the value endpoint + if scopeEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `["https://www.googleapis.com/auth/compute.read_write"]`) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Make a transport that reroutes all traffic to the example server + transport := &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL + req.URL.Path) + }, + } + + provider := &containerRegistryProvider{ + metadataProvider{Client: &http.Client{Transport: transport}}, + } + + if provider.Enabled() { + t.Errorf("Provider is unexpectedly enabled") + } +} + +func TestAllProvidersNoMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + // Make a transport that reroutes all traffic to the example server + transport := &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL + req.URL.Path) + }, + } + + providers := []credentialprovider.DockerConfigProvider{ + &dockerConfigKeyProvider{ + metadataProvider{Client: &http.Client{Transport: transport}}, + }, + &dockerConfigUrlKeyProvider{ + metadataProvider{Client: &http.Client{Transport: transport}}, + }, + &containerRegistryProvider{ + metadataProvider{Client: &http.Client{Transport: transport}}, + }, + } + + for _, provider := range providers { + if provider.Enabled() { + t.Errorf("Provider %s is unexpectedly enabled", reflect.TypeOf(provider).String()) + } + } +} diff --git a/pkg/credentialprovider/keyring.go b/pkg/credentialprovider/keyring.go new file mode 100644 index 0000000000..518cacb4ae --- /dev/null +++ b/pkg/credentialprovider/keyring.go @@ -0,0 +1,134 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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/url" + "sort" + "strings" + + docker "github.com/fsouza/go-dockerclient" + "github.com/golang/glog" +) + +// 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) (docker.AuthConfiguration, bool) +} + +// BasicDockerKeyring is a trivial map-backed implementation of DockerKeyring +type BasicDockerKeyring struct { + index []string + creds map[string]docker.AuthConfiguration +} + +// lazyDockerKeyring is an implementation of DockerKeyring that lazily +// materializes its dockercfg based on a set of dockerConfigProviders. +type lazyDockerKeyring struct { + Providers []DockerConfigProvider +} + +func (dk *BasicDockerKeyring) Add(cfg DockerConfig) { + if dk.index == nil { + dk.index = make([]string, 0) + dk.creds = make(map[string]docker.AuthConfiguration) + } + for loc, ident := range cfg { + creds := docker.AuthConfiguration{ + Username: ident.Username, + Password: ident.Password, + Email: ident.Email, + } + + parsed, err := url.Parse(loc) + if err != nil { + glog.Errorf("Entry %q in dockercfg invalid (%v), ignoring", loc, err) + continue + } + + registry := parsed.Host + parsed.Path + dk.creds[registry] = creds + dk.index = append(dk.index, registry) + } + + // 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 "quay.io/coreos/etcd", + // credentials for "quay.io/coreos" should match before "quay.io". + sort.Sort(sort.Reverse(sort.StringSlice(dk.index))) +} + +const defaultRegistryHost = "index.docker.io/v1/" + +// 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) == 1 { + // e.g. library/ubuntu + 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], ".:") +} + +// Lookup implements the DockerKeyring method for fetching credentials +// based on image name. +func (dk *BasicDockerKeyring) Lookup(image string) (docker.AuthConfiguration, bool) { + // range over the index as iterating over a map does not provide + // a predictable ordering + for _, k := range dk.index { + // NOTE: prefix is a sufficient check because while scheme is allowed, + // it is stripped as part of 'Add' + if !strings.HasPrefix(image, k) { + continue + } + + return dk.creds[k], true + } + + // Use credentials for the default registry if provided, and appropriate + if auth, ok := dk.creds[defaultRegistryHost]; ok && isDefaultRegistryMatch(image) { + return auth, true + } + + return docker.AuthConfiguration{}, false +} + +// Lookup implements the DockerKeyring method for fetching credentials +// based on image name. +func (dk *lazyDockerKeyring) Lookup(image string) (docker.AuthConfiguration, bool) { + keyring := &BasicDockerKeyring{} + + for _, p := range dk.Providers { + keyring.Add(p.Provide()) + } + + return keyring.Lookup(image) +} diff --git a/pkg/credentialprovider/keyring_test.go b/pkg/credentialprovider/keyring_test.go new file mode 100644 index 0000000000..e4835bc6d5 --- /dev/null +++ b/pkg/credentialprovider/keyring_test.go @@ -0,0 +1,261 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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" + "fmt" + "testing" +) + +func TestDockerKeyringFromBytes(t *testing.T) { + url := "hello.kubernetes.io" + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, url, email, auth) + + keyring := &BasicDockerKeyring{} + if cfg, err := readDockerConfigFileFromBytes([]byte(sampleDockerConfig)); err != nil { + t.Errorf("Error processing json blob %q, %v", sampleDockerConfig, err) + } else { + keyring.Add(cfg) + } + + val, ok := keyring.Lookup(url + "/foo/bar") + if !ok { + t.Errorf("Didn't find expected URL: %s", url) + } + + if username != val.Username { + t.Errorf("Unexpected username value, want: %s, got: %s", username, val.Username) + } + if password != val.Password { + t.Errorf("Unexpected password value, want: %s, got: %s", password, val.Password) + } + if email != val.Email { + t.Errorf("Unexpected email value, want: %s, got: %s", email, val.Email) + } +} + +func TestKeyringMiss(t *testing.T) { + url := "hello.kubernetes.io" + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, url, email, auth) + + keyring := &BasicDockerKeyring{} + if cfg, err := readDockerConfigFileFromBytes([]byte(sampleDockerConfig)); err != nil { + t.Errorf("Error processing json blob %q, %v", sampleDockerConfig, err) + } else { + keyring.Add(cfg) + } + + val, ok := keyring.Lookup("world.mesos.org/foo/bar") + if ok { + t.Errorf("Found unexpected credential: %+v", val) + } +} + +func TestKeyringMissWithDockerHubCredentials(t *testing.T) { + url := defaultRegistryHost + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, url, email, auth) + + keyring := &BasicDockerKeyring{} + if cfg, err := readDockerConfigFileFromBytes([]byte(sampleDockerConfig)); err != nil { + t.Errorf("Error processing json blob %q, %v", sampleDockerConfig, err) + } else { + keyring.Add(cfg) + } + + val, ok := keyring.Lookup("world.mesos.org/foo/bar") + if ok { + t.Errorf("Found unexpected credential: %+v", val) + } +} + +func TestKeyringHitWithUnqualifiedDockerHub(t *testing.T) { + url := defaultRegistryHost + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, url, email, auth) + + keyring := &BasicDockerKeyring{} + if cfg, err := readDockerConfigFileFromBytes([]byte(sampleDockerConfig)); err != nil { + t.Errorf("Error processing json blob %q, %v", sampleDockerConfig, err) + } else { + keyring.Add(cfg) + } + + val, ok := keyring.Lookup("google/docker-registry") + if !ok { + t.Errorf("Didn't find expected URL: %s", url) + } + + if username != val.Username { + t.Errorf("Unexpected username value, want: %s, got: %s", username, val.Username) + } + if password != val.Password { + t.Errorf("Unexpected password value, want: %s, got: %s", password, val.Password) + } + if email != val.Email { + t.Errorf("Unexpected email value, want: %s, got: %s", email, val.Email) + } +} + +func TestKeyringHitWithUnqualifiedLibraryDockerHub(t *testing.T) { + url := defaultRegistryHost + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, url, email, auth) + + keyring := &BasicDockerKeyring{} + if cfg, err := readDockerConfigFileFromBytes([]byte(sampleDockerConfig)); err != nil { + t.Errorf("Error processing json blob %q, %v", sampleDockerConfig, err) + } else { + keyring.Add(cfg) + } + + val, ok := keyring.Lookup("jenkins") + if !ok { + t.Errorf("Didn't find expected URL: %s", url) + } + + if username != val.Username { + t.Errorf("Unexpected username value, want: %s, got: %s", username, val.Username) + } + if password != val.Password { + t.Errorf("Unexpected password value, want: %s, got: %s", password, val.Password) + } + if email != val.Email { + t.Errorf("Unexpected email value, want: %s, got: %s", email, val.Email) + } +} + +func TestKeyringHitWithQualifiedDockerHub(t *testing.T) { + url := defaultRegistryHost + email := "foo@bar.baz" + username := "foo" + password := "bar" + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + sampleDockerConfig := fmt.Sprintf(`{ + "https://%s": { + "email": %q, + "auth": %q + } +}`, url, email, auth) + + keyring := &BasicDockerKeyring{} + if cfg, err := readDockerConfigFileFromBytes([]byte(sampleDockerConfig)); err != nil { + t.Errorf("Error processing json blob %q, %v", sampleDockerConfig, err) + } else { + keyring.Add(cfg) + } + + val, ok := keyring.Lookup(url + "/google/docker-registry") + if !ok { + t.Errorf("Didn't find expected URL: %s", url) + } + + if username != val.Username { + t.Errorf("Unexpected username value, want: %s, got: %s", username, val.Username) + } + if password != val.Password { + t.Errorf("Unexpected password value, want: %s, got: %s", password, val.Password) + } + if email != val.Email { + t.Errorf("Unexpected email value, want: %s, got: %s", email, val.Email) + } +} + +type testProvider struct { + Count int +} + +// Enabled implements dockerConfigProvider +func (d *testProvider) Enabled() bool { + return true +} + +// Provide implements dockerConfigProvider +func (d *testProvider) Provide() DockerConfig { + d.Count += 1 + return DockerConfig{} +} + +func TestLazyKeyring(t *testing.T) { + provider := &testProvider{ + Count: 0, + } + lazy := &lazyDockerKeyring{ + Providers: []DockerConfigProvider{ + provider, + }, + } + + if provider.Count != 0 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } + lazy.Lookup("foo") + if provider.Count != 1 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } + lazy.Lookup("foo") + if provider.Count != 2 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } + lazy.Lookup("foo") + if provider.Count != 3 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } +} diff --git a/pkg/credentialprovider/plugins.go b/pkg/credentialprovider/plugins.go new file mode 100644 index 0000000000..d92570ca8e --- /dev/null +++ b/pkg/credentialprovider/plugins.go @@ -0,0 +1,62 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 ( + "sync" + + "github.com/golang/glog" +) + +// All registered credential providers. +var providersMutex sync.Mutex +var providers = make(map[string]DockerConfigProvider) + +// RegisterCredentialProvider is called by provider implementations on +// initialization to register themselves, like so: +// func init() { +// RegisterCredentialProvider("name", &myProvider{...}) +// } +func RegisterCredentialProvider(name string, provider DockerConfigProvider) { + providersMutex.Lock() + defer providersMutex.Unlock() + _, found := providers[name] + if found { + glog.Fatalf("Credential provider %q was registered twice", name) + } + glog.V(1).Infof("Registered credential provider %q", name) + providers[name] = provider +} + +// NewDockerKeyring creates a DockerKeyring to use for resolving credentials, +// which lazily draws from the set of registered credential providers. +func NewDockerKeyring() DockerKeyring { + keyring := &lazyDockerKeyring{ + Providers: make([]DockerConfigProvider, 0), + } + + // TODO(mattmoor): iterating over the map is non-deterministic. We should + // introduce the notion of priorities for conflict resolution. + for name, provider := range providers { + if provider.Enabled() { + glog.Infof("Registering credential provider: %v", name) + keyring.Providers = append(keyring.Providers, provider) + } + } + + return keyring +} diff --git a/pkg/credentialprovider/provider.go b/pkg/credentialprovider/provider.go new file mode 100644 index 0000000000..9ce5e93a55 --- /dev/null +++ b/pkg/credentialprovider/provider.go @@ -0,0 +1,95 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 ( + "os" + "reflect" + "sync" + "time" + + "github.com/golang/glog" +) + +// DockerConfigProvider is the interface that registered extensions implement +// to materialize 'dockercfg' credentials. +type DockerConfigProvider interface { + Enabled() bool + Provide() DockerConfig +} + +// A DockerConfigProvider that simply reads the .dockersfg file +type defaultDockerConfigProvider struct{} + +// init registers our default provider, which simply reads the .dockercfg file. +func init() { + RegisterCredentialProvider(".dockercfg", + &CachingDockerConfigProvider{ + Provider: &defaultDockerConfigProvider{}, + Lifetime: 5 * time.Minute, + }) +} + +// CachingDockerConfigProvider implements DockerConfigProvider by composing +// with another DockerConfigProvider and caching the DockerConfig it provides +// for a pre-specified lifetime. +type CachingDockerConfigProvider struct { + Provider DockerConfigProvider + Lifetime time.Duration + + // cache fields + cacheDockerConfig DockerConfig + expiration time.Time + mu sync.Mutex +} + +// Enabled implements dockerConfigProvider +func (d *defaultDockerConfigProvider) Enabled() bool { + return true +} + +// Provide implements dockerConfigProvider +func (d *defaultDockerConfigProvider) Provide() DockerConfig { + // Read the standard Docker credentials from .dockercfg + if cfg, err := ReadDockerConfigFile(); err == nil { + return cfg + } else if !os.IsNotExist(err) { + glog.V(1).Infof("Unable to parse Docker config file: %v", err) + } + return DockerConfig{} +} + +// Enabled implements dockerConfigProvider +func (d *CachingDockerConfigProvider) Enabled() bool { + return d.Provider.Enabled() +} + +// Provide implements dockerConfigProvider +func (d *CachingDockerConfigProvider) Provide() DockerConfig { + d.mu.Lock() + defer d.mu.Unlock() + + // If the cache hasn't expired, return our cache + if time.Now().Before(d.expiration) { + return d.cacheDockerConfig + } + + glog.Infof("Refreshing cache for provider: %v", reflect.TypeOf(d.Provider).String()) + d.cacheDockerConfig = d.Provider.Provide() + d.expiration = time.Now().Add(d.Lifetime) + return d.cacheDockerConfig +} diff --git a/pkg/credentialprovider/provider_test.go b/pkg/credentialprovider/provider_test.go new file mode 100644 index 0000000000..0c279232ca --- /dev/null +++ b/pkg/credentialprovider/provider_test.go @@ -0,0 +1,62 @@ +/* +Copyright 2014 Google Inc. All rights reserved. + +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 ( + "testing" + "time" +) + +func TestCachingProvider(t *testing.T) { + provider := &testProvider{ + Count: 0, + } + + cache := &CachingDockerConfigProvider{ + Provider: provider, + Lifetime: 1 * time.Second, + } + + if provider.Count != 0 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } + cache.Provide() + cache.Provide() + cache.Provide() + cache.Provide() + if provider.Count != 1 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } + + time.Sleep(cache.Lifetime) + cache.Provide() + cache.Provide() + cache.Provide() + cache.Provide() + if provider.Count != 2 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } + + time.Sleep(cache.Lifetime) + cache.Provide() + cache.Provide() + cache.Provide() + cache.Provide() + if provider.Count != 3 { + t.Errorf("Unexpected number of Provide calls: %v", provider.Count) + } +} diff --git a/pkg/kubelet/dockertools/docker.go b/pkg/kubelet/dockertools/docker.go index 481ee7096e..f14cb61616 100644 --- a/pkg/kubelet/dockertools/docker.go +++ b/pkg/kubelet/dockertools/docker.go @@ -25,15 +25,14 @@ import ( "io" "io/ioutil" "math/rand" - "os" "os/exec" - "sort" "strconv" "strings" "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/credentialprovider" "github.com/GoogleCloudPlatform/kubernetes/pkg/util" - "github.com/fsouza/go-dockerclient" + docker "github.com/fsouza/go-dockerclient" "github.com/golang/glog" ) @@ -65,7 +64,7 @@ type DockerPuller interface { // dockerPuller is the default implementation of DockerPuller. type dockerPuller struct { client DockerInterface - keyring *dockerKeyring + keyring credentialprovider.DockerKeyring } type throttledDockerPuller struct { @@ -77,19 +76,9 @@ type throttledDockerPuller struct { func NewDockerPuller(client DockerInterface, qps float32, burst int) DockerPuller { dp := dockerPuller{ client: client, - keyring: newDockerKeyring(), + keyring: credentialprovider.NewDockerKeyring(), } - cfg, err := readDockerConfigFile() - if err == nil { - cfg.addToKeyring(dp.keyring) - } else if !os.IsNotExist(err) { - glog.V(1).Infof("Unable to parse Docker config file: %v", err) - } - - if dp.keyring.count() == 0 { - glog.V(1).Infof("Continuing with empty Docker keyring") - } if qps == 0.0 { return dp } @@ -218,7 +207,7 @@ func (p dockerPuller) Pull(image string) error { Tag: tag, } - creds, ok := p.keyring.lookup(image) + creds, ok := p.keyring.Lookup(image) if !ok { glog.V(1).Infof("Pulling image %s without credentials", image) } @@ -608,62 +597,3 @@ func parseImageName(image string) (string, string) { type ContainerCommandRunner interface { RunInContainer(containerID string, cmd []string) ([]byte, error) } - -// 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 struct { - index []string - creds map[string]docker.AuthConfiguration -} - -func newDockerKeyring() *dockerKeyring { - return &dockerKeyring{ - index: make([]string, 0), - creds: make(map[string]docker.AuthConfiguration), - } -} - -func (dk *dockerKeyring) add(registry string, creds docker.AuthConfiguration) { - dk.creds[registry] = creds - - dk.index = append(dk.index, registry) - dk.reindex() -} - -// reindex updates 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 "quay.io/coreos/etcd", -// credentials for "quay.io/coreos" should match before "quay.io". -func (dk *dockerKeyring) reindex() { - sort.Sort(sort.Reverse(sort.StringSlice(dk.index))) -} - -const defaultRegistryHost = "index.docker.io/v1/" - -func (dk *dockerKeyring) lookup(image string) (docker.AuthConfiguration, bool) { - // range over the index as iterating over a map does not provide - // a predictable ordering - for _, k := range dk.index { - if !strings.HasPrefix(image, k) { - continue - } - - return dk.creds[k], true - } - - // use credentials for the default registry if provided - if auth, ok := dk.creds[defaultRegistryHost]; ok { - return auth, true - } - - return docker.AuthConfiguration{}, false -} - -func (dk dockerKeyring) count() int { - return len(dk.creds) -} diff --git a/pkg/kubelet/dockertools/docker_test.go b/pkg/kubelet/dockertools/docker_test.go index f7a814dce1..dda9ae34bd 100644 --- a/pkg/kubelet/dockertools/docker_test.go +++ b/pkg/kubelet/dockertools/docker_test.go @@ -23,7 +23,8 @@ import ( "testing" "github.com/GoogleCloudPlatform/kubernetes/pkg/api" - "github.com/fsouza/go-dockerclient" + "github.com/GoogleCloudPlatform/kubernetes/pkg/credentialprovider" + docker "github.com/fsouza/go-dockerclient" ) func verifyCalls(t *testing.T, fakeDocker *FakeDockerClient, calls []string) { @@ -213,9 +214,19 @@ func TestDockerKeyringLookup(t *testing.T) { Email: "grace@example.com", } - dk := newDockerKeyring() - dk.add("bar.example.com/pong", grace) - dk.add("bar.example.com", ada) + dk := &credentialprovider.BasicDockerKeyring{} + dk.Add(credentialprovider.DockerConfig{ + "bar.example.com/pong": credentialprovider.DockerConfigEntry{ + Username: grace.Username, + Password: grace.Password, + Email: grace.Email, + }, + "bar.example.com": credentialprovider.DockerConfigEntry{ + Username: ada.Username, + Password: ada.Password, + Email: ada.Email, + }, + }) tests := []struct { image string @@ -243,7 +254,7 @@ func TestDockerKeyringLookup(t *testing.T) { } for i, tt := range tests { - match, ok := dk.lookup(tt.image) + match, ok := dk.Lookup(tt.image) if tt.ok != ok { t.Errorf("case %d: expected ok=%t, got %t", i, tt.ok, ok) } diff --git a/pkg/standalone/standalone.go b/pkg/standalone/standalone.go index bf8bf85c7a..839276ed15 100644 --- a/pkg/standalone/standalone.go +++ b/pkg/standalone/standalone.go @@ -38,7 +38,7 @@ import ( "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/scheduler" "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/scheduler/factory" - "github.com/fsouza/go-dockerclient" + docker "github.com/fsouza/go-dockerclient" "github.com/golang/glog" )