diff --git a/pkg/credentialprovider/gcp/metadata.go b/pkg/credentialprovider/gcp/metadata.go index 7496ae917c..5d0016f0aa 100644 --- a/pkg/credentialprovider/gcp/metadata.go +++ b/pkg/credentialprovider/gcp/metadata.go @@ -32,15 +32,18 @@ const ( metadataAttributes = metadataUrl + "instance/attributes/" dockerConfigKey = metadataAttributes + "google-dockercfg" dockerConfigUrlKey = metadataAttributes + "google-dockercfg-url" + serviceAccounts = metadataUrl + "instance/service-accounts/" 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" cloudPlatformScopePrefix = "https://www.googleapis.com/auth/cloud-platform" googleProductName = "Google" + defaultServiceAccount = "default/" ) -// A variable to enable testing +// Product file path that contains the cloud service name. +// This is a variable instead of a const to enable testing. var gceProductNameFile = "/sys/class/dmi/id/product_name" // For these urls, the parts of the host name can be glob, for example '*.gcr.io" will match @@ -103,7 +106,8 @@ func init() { }) } -// Returns true if it finds a local GCE VM +// Returns true if it finds a local GCE VM. +// Looks at a product file that is an undocumented API. func onGCEVM() bool { data, err := ioutil.ReadFile(gceProductNameFile) if err != nil { @@ -162,36 +166,74 @@ func (g *dockerConfigUrlKeyProvider) Provide() credentialprovider.DockerConfig { 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 { - if !onGCEVM() { - return false - } - // Given that we are on GCE, we should keep retrying until the metadata server responds. - var ( - value []byte - err error - backoff = time.Millisecond - ) +// runcWithBackoff runs input function `f` with an exponential backoff. +// Note that this method can block indefinitely. +func runWithBackoff(f func() ([]byte, error)) []byte { + var backoff = 100 * time.Millisecond const maxBackoff = time.Minute for { - value, err = credentialprovider.ReadUrl(metadataScopes+"?alt=json", g.Client, metadataHeader) + value, err := f() if err == nil { - break + return value } - glog.Errorf("failed to Get %q: %v", metadataScopes+"?alt=json", err) time.Sleep(backoff) backoff = backoff * 2 if backoff > maxBackoff { backoff = maxBackoff } } - var scopes []string - if err := json.Unmarshal(value, &scopes); err != nil { +} + +// Enabled implements a special metadata-based check, which verifies the +// storage scope is available on the GCE VM. +// If running on a GCE VM, check if 'default' service account exists. +// If it does not exist, assume that registry is not enabled. +// If default service account exists, check if relevant scopes exist in the default service account. +// The metadata service can become temporarily inaccesible. Hence all requests to the metadata +// service will be retried until the metadata server returns a `200`. +// It is expected that "http://metadata.google.internal./computeMetadata/v1/instance/service-accounts/" will return a `200` +// and "http://metadata.google.internal./computeMetadata/v1/instance/service-accounts/default/scopes" will also return `200`. +// More information on metadata service can be found here - https://cloud.google.com/compute/docs/storing-retrieving-metadata +func (g *containerRegistryProvider) Enabled() bool { + if !onGCEVM() { + return false + } + // Given that we are on GCE, we should keep retrying until the metadata server responds. + value := runWithBackoff(func() ([]byte, error) { + value, err := credentialprovider.ReadUrl(serviceAccounts, g.Client, metadataHeader) + if err != nil { + glog.V(2).Infof("Failed to Get service accounts from gce metadata server: %v", err) + } + return value, err + }) + // We expect the service account to return a list of account directories separated by newlines, e.g., + // sv-account-name1/ + // sv-account-name2/ + // ref: https://cloud.google.com/compute/docs/storing-retrieving-metadata + defaultServiceAccountExists := false + for _, sa := range strings.Split(string(value), "\n") { + if strings.TrimSpace(sa) == defaultServiceAccount { + defaultServiceAccountExists = true + break + } + } + if !defaultServiceAccountExists { + glog.V(2).Infof("'default' service account does not exist. Found following service accounts: %q", string(value)) + return false + } + url := metadataScopes + "?alt=json" + value = runWithBackoff(func() ([]byte, error) { + value, err := credentialprovider.ReadUrl(url, g.Client, metadataHeader) + if err != nil { + glog.V(2).Infof("Failed to Get scopes in default service account from gce metadata server: %v", err) + } + return value, err + }) + var scopes []string + if err := json.Unmarshal(value, &scopes); err != nil { + glog.Errorf("Failed to unmarshal scopes: %v", err) return false } - for _, v := range scopes { // cloudPlatformScope implies storage scope. if strings.HasPrefix(v, storageScopePrefix) || strings.HasPrefix(v, cloudPlatformScopePrefix) { diff --git a/pkg/credentialprovider/gcp/metadata_test.go b/pkg/credentialprovider/gcp/metadata_test.go index 832da7285d..2a5a219bf7 100644 --- a/pkg/credentialprovider/gcp/metadata_test.go +++ b/pkg/credentialprovider/gcp/metadata_test.go @@ -198,10 +198,11 @@ func TestContainerRegistryBasics(t *testing.T) { 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" + serviceAccountsEndpoint = "/computeMetadata/v1/instance/service-accounts/" + defaultEndpoint = "/computeMetadata/v1/instance/service-accounts/default/" + scopeEndpoint = defaultEndpoint + "scopes" + emailEndpoint = defaultEndpoint + "email" + tokenEndpoint = defaultEndpoint + "token" ) var err error gceProductNameFile, err = createProductNameFile() @@ -227,6 +228,9 @@ func TestContainerRegistryBasics(t *testing.T) { t.Fatalf("unexpected error: %v", err) } fmt.Fprintln(w, string(bytes)) + } else if serviceAccountsEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "default/\ncustom") } else { w.WriteHeader(http.StatusNotFound) } @@ -272,10 +276,54 @@ func TestContainerRegistryBasics(t *testing.T) { } } +func TestContainerRegistryNoServiceAccount(t *testing.T) { + const ( + serviceAccountsEndpoint = "/computeMetadata/v1/instance/service-accounts/" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only serve the URL key and the value endpoint + if serviceAccountsEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + bytes, err := json.Marshal([]string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + fmt.Fprintln(w, string(bytes)) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + var err error + gceProductNameFile, err = createProductNameFile() + if err != nil { + t.Errorf("failed to create gce product name file: %v", err) + } + defer os.Remove(gceProductNameFile) + + // Make a transport that reroutes all traffic to the example server + transport := utilnet.SetTransportDefaults(&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 TestContainerRegistryNoStorageScope(t *testing.T) { const ( - defaultEndpoint = "/computeMetadata/v1/instance/service-accounts/default/" - scopeEndpoint = defaultEndpoint + "scopes" + serviceAccountsEndpoint = "/computeMetadata/v1/instance/service-accounts/" + 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 @@ -283,6 +331,9 @@ func TestContainerRegistryNoStorageScope(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `["https://www.googleapis.com/auth/compute.read_write"]`) + } else if serviceAccountsEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "default/\ncustom") } else { w.WriteHeader(http.StatusNotFound) } @@ -314,8 +365,9 @@ func TestContainerRegistryNoStorageScope(t *testing.T) { func TestComputePlatformScopeSubstitutesStorageScope(t *testing.T) { const ( - defaultEndpoint = "/computeMetadata/v1/instance/service-accounts/default/" - scopeEndpoint = defaultEndpoint + "scopes" + serviceAccountsEndpoint = "/computeMetadata/v1/instance/service-accounts/" + 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 @@ -323,6 +375,10 @@ func TestComputePlatformScopeSubstitutesStorageScope(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `["https://www.googleapis.com/auth/compute.read_write","https://www.googleapis.com/auth/cloud-platform.read-only"]`) + } else if serviceAccountsEndpoint == r.URL.Path { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, "default/\ncustom") } else { w.WriteHeader(http.StatusNotFound) } diff --git a/pkg/credentialprovider/provider.go b/pkg/credentialprovider/provider.go index 52f4045b81..cb93bd7fb2 100644 --- a/pkg/credentialprovider/provider.go +++ b/pkg/credentialprovider/provider.go @@ -29,7 +29,11 @@ import ( // 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. Provide() DockerConfig // LazyProvide() gets called after URL matches have been performed, so the // location used as the key in DockerConfig would be redundant.