Fix implicit GCR auth (#1856)

* Fix implicit GCR auth

* boilerplate
This commit is contained in:
Jason Hall
2021-12-28 20:34:32 -05:00
committed by GitHub
parent 129df249c9
commit 633f555c5c
12 changed files with 693 additions and 60 deletions
@@ -0,0 +1,7 @@
# `google`
[![GoDoc](https://godoc.org/github.com/google/go-containerregistry/pkg/v1/google?status.svg)](https://godoc.org/github.com/google/go-containerregistry/pkg/v1/google)
The `google` package provides:
* Some google-specific authentication methods.
* Some [GCR](gcr.io)-specific listing methods.
+180
View File
@@ -0,0 +1,180 @@
// Copyright 2018 Google LLC 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 google
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"time"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/logs"
"golang.org/x/oauth2"
googauth "golang.org/x/oauth2/google"
)
const cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
// GetGcloudCmd is exposed so we can test this.
var GetGcloudCmd = func() *exec.Cmd {
// This is odd, but basically what docker-credential-gcr does.
//
// config-helper is undocumented, but it's purportedly the only supported way
// of accessing tokens (`gcloud auth print-access-token` is discouraged).
//
// --force-auth-refresh means we are getting a token that is valid for about
// an hour (we reuse it until it's expired).
return exec.Command("gcloud", "config", "config-helper", "--force-auth-refresh", "--format=json(credential)")
}
// NewEnvAuthenticator returns an authn.Authenticator that generates access
// tokens from the environment we're running in.
//
// See: https://godoc.org/golang.org/x/oauth2/google#FindDefaultCredentials
func NewEnvAuthenticator() (authn.Authenticator, error) {
ts, err := googauth.DefaultTokenSource(context.Background(), cloudPlatformScope)
if err != nil {
return nil, err
}
token, err := ts.Token()
if err != nil {
return nil, err
}
return &tokenSourceAuth{oauth2.ReuseTokenSource(token, ts)}, nil
}
// NewGcloudAuthenticator returns an oauth2.TokenSource that generates access
// tokens by shelling out to the gcloud sdk.
func NewGcloudAuthenticator() (authn.Authenticator, error) {
if _, err := exec.LookPath("gcloud"); err != nil {
// gcloud is not available, fall back to anonymous
logs.Warn.Println("gcloud binary not found")
return authn.Anonymous, nil
}
ts := gcloudSource{GetGcloudCmd()}
// Attempt to fetch a token to ensure gcloud is installed and we can run it.
token, err := ts.Token()
if err != nil {
return nil, err
}
return &tokenSourceAuth{oauth2.ReuseTokenSource(token, ts)}, nil
}
// NewJSONKeyAuthenticator returns a Basic authenticator which uses Service Account
// as a way of authenticating with Google Container Registry.
// More information: https://cloud.google.com/container-registry/docs/advanced-authentication#json_key_file
func NewJSONKeyAuthenticator(serviceAccountJSON string) authn.Authenticator {
return &authn.Basic{
Username: "_json_key",
Password: serviceAccountJSON,
}
}
// NewTokenAuthenticator returns an oauth2.TokenSource that generates access
// tokens by using the Google SDK to produce JWT tokens from a Service Account.
// More information: https://godoc.org/golang.org/x/oauth2/google#JWTAccessTokenSourceFromJSON
func NewTokenAuthenticator(serviceAccountJSON string, scope string) (authn.Authenticator, error) {
ts, err := googauth.JWTAccessTokenSourceFromJSON([]byte(serviceAccountJSON), string(scope))
if err != nil {
return nil, err
}
return &tokenSourceAuth{oauth2.ReuseTokenSource(nil, ts)}, nil
}
// NewTokenSourceAuthenticator converts an oauth2.TokenSource into an authn.Authenticator.
func NewTokenSourceAuthenticator(ts oauth2.TokenSource) authn.Authenticator {
return &tokenSourceAuth{ts}
}
// tokenSourceAuth turns an oauth2.TokenSource into an authn.Authenticator.
type tokenSourceAuth struct {
oauth2.TokenSource
}
// Authorization implements authn.Authenticator.
func (tsa *tokenSourceAuth) Authorization() (*authn.AuthConfig, error) {
token, err := tsa.Token()
if err != nil {
return nil, err
}
return &authn.AuthConfig{
Username: "_token",
Password: token.AccessToken,
}, nil
}
// gcloudOutput represents the output of the gcloud command we invoke.
//
// `gcloud config config-helper --format=json(credential)` looks something like:
//
// {
// "credential": {
// "access_token": "ya29.abunchofnonsense",
// "token_expiry": "2018-12-02T04:08:13Z"
// }
// }
type gcloudOutput struct {
Credential struct {
AccessToken string `json:"access_token"`
TokenExpiry string `json:"token_expiry"`
} `json:"credential"`
}
type gcloudSource struct {
// This is passed in so that we mock out gcloud and test Token.
cmd *exec.Cmd
}
// Token implements oauath2.TokenSource.
func (gs gcloudSource) Token() (*oauth2.Token, error) {
cmd := gs.cmd
var out bytes.Buffer
cmd.Stdout = &out
// Don't attempt to interpret stderr, just pass it through.
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("error executing `gcloud config config-helper`: %v", err)
}
creds := gcloudOutput{}
if err := json.Unmarshal(out.Bytes(), &creds); err != nil {
return nil, fmt.Errorf("failed to parse `gcloud config config-helper` output: %v", err)
}
expiry, err := time.Parse(time.RFC3339, creds.Credential.TokenExpiry)
if err != nil {
return nil, fmt.Errorf("failed to parse gcloud token expiry: %v", err)
}
token := oauth2.Token{
AccessToken: creds.Credential.AccessToken,
Expiry: expiry,
}
return &token, nil
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright 2018 Google LLC 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 google provides facilities for listing images in gcr.io.
package google
@@ -0,0 +1,81 @@
// Copyright 2018 Google LLC 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 google
import (
"fmt"
"strings"
"sync"
"github.com/google/go-containerregistry/pkg/authn"
)
// Keychain exports an instance of the google Keychain.
var Keychain authn.Keychain = &googleKeychain{}
type googleKeychain struct {
once sync.Once
auth authn.Authenticator
err error
}
// Resolve implements authn.Keychain a la docker-credential-gcr.
//
// This behaves similarly to the GCR credential helper, but reuses tokens until
// they expire.
//
// We can't easily add this behavior to our credential helper implementation
// of authn.Authenticator because the credential helper protocol doesn't include
// expiration information, see here:
// https://godoc.org/github.com/docker/docker-credential-helpers/credentials#Credentials
//
// In addition to being a performance optimization, the reuse of these access
// tokens works around a bug in gcloud. It appears that attempting to invoke
// `gcloud config config-helper` multiple times too quickly will fail:
// https://github.com/GoogleCloudPlatform/docker-credential-gcr/issues/54
//
// We could upstream this behavior into docker-credential-gcr by parsing
// gcloud's output and persisting its tokens across invocations, but then
// we have to deal with invalidating caches across multiple runs (no fun).
//
// In general, we don't worry about that here because we expect to use the same
// gcloud configuration in the scope of this one process.
func (gk *googleKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) {
// Only authenticate GCR and AR so it works with authn.NewMultiKeychain to fallback.
host := target.RegistryStr()
if host != "gcr.io" && !strings.HasSuffix(host, ".gcr.io") && !strings.HasSuffix(host, ".pkg.dev") {
return authn.Anonymous, nil
}
gk.once.Do(func() {
gk.auth, gk.err = resolve()
})
return gk.auth, gk.err
}
func resolve() (authn.Authenticator, error) {
auth, envErr := NewEnvAuthenticator()
if envErr == nil {
return auth, nil
}
auth, gErr := NewGcloudAuthenticator()
if gErr == nil {
return auth, nil
}
return nil, fmt.Errorf("failed to create token source from env: %v or gcloud: %v", envErr, gErr)
}
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2018 Google LLC 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 google
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/logs"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
)
// ListerOption is a functional option for List and Walk.
// TODO: Can we somehow reuse the remote options here?
type ListerOption func(*lister) error
type lister struct {
auth authn.Authenticator
transport http.RoundTripper
repo name.Repository
client *http.Client
ctx context.Context
userAgent string
}
func newLister(repo name.Repository, options ...ListerOption) (*lister, error) {
l := &lister{
auth: authn.Anonymous,
transport: http.DefaultTransport,
repo: repo,
ctx: context.Background(),
}
for _, option := range options {
if err := option(l); err != nil {
return nil, err
}
}
// Wrap the transport in something that logs requests and responses.
// It's expensive to generate the dumps, so skip it if we're writing
// to nothing.
if logs.Enabled(logs.Debug) {
l.transport = transport.NewLogger(l.transport)
}
// Wrap the transport in something that can retry network flakes.
l.transport = transport.NewRetry(l.transport)
// Wrap this last to prevent transport.New from double-wrapping.
if l.userAgent != "" {
l.transport = transport.NewUserAgent(l.transport, l.userAgent)
}
scopes := []string{repo.Scope(transport.PullScope)}
tr, err := transport.NewWithContext(l.ctx, repo.Registry, l.auth, l.transport, scopes)
if err != nil {
return nil, err
}
l.client = &http.Client{Transport: tr}
return l, nil
}
func (l *lister) list(repo name.Repository) (*Tags, error) {
uri := url.URL{
Scheme: repo.Registry.Scheme(),
Host: repo.Registry.RegistryStr(),
Path: fmt.Sprintf("/v2/%s/tags/list", repo.RepositoryStr()),
}
req, err := http.NewRequestWithContext(l.ctx, http.MethodGet, uri.String(), nil)
if err != nil {
return nil, err
}
resp, err := l.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := transport.CheckError(resp, http.StatusOK); err != nil {
return nil, err
}
tags := Tags{}
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return nil, err
}
return &tags, nil
}
type rawManifestInfo struct {
Size string `json:"imageSizeBytes"`
MediaType string `json:"mediaType"`
Created string `json:"timeCreatedMs"`
Uploaded string `json:"timeUploadedMs"`
Tags []string `json:"tag"`
}
// ManifestInfo is a Manifests entry is the output of List and Walk.
type ManifestInfo struct {
Size uint64 `json:"imageSizeBytes"`
MediaType string `json:"mediaType"`
Created time.Time `json:"timeCreatedMs"`
Uploaded time.Time `json:"timeUploadedMs"`
Tags []string `json:"tag"`
}
func fromUnixMs(ms int64) time.Time {
sec := ms / 1000
ns := (ms % 1000) * 1000000
return time.Unix(sec, ns)
}
func toUnixMs(t time.Time) string {
return strconv.FormatInt(t.UnixNano()/1000000, 10)
}
// MarshalJSON implements json.Marshaler
func (m ManifestInfo) MarshalJSON() ([]byte, error) {
return json.Marshal(rawManifestInfo{
Size: strconv.FormatUint(m.Size, 10),
MediaType: m.MediaType,
Created: toUnixMs(m.Created),
Uploaded: toUnixMs(m.Uploaded),
Tags: m.Tags,
})
}
// UnmarshalJSON implements json.Unmarshaler
func (m *ManifestInfo) UnmarshalJSON(data []byte) error {
raw := rawManifestInfo{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if raw.Size != "" {
size, err := strconv.ParseUint(string(raw.Size), 10, 64)
if err != nil {
return err
}
m.Size = size
}
if raw.Created != "" {
created, err := strconv.ParseInt(string(raw.Created), 10, 64)
if err != nil {
return err
}
m.Created = fromUnixMs(created)
}
if raw.Uploaded != "" {
uploaded, err := strconv.ParseInt(string(raw.Uploaded), 10, 64)
if err != nil {
return err
}
m.Uploaded = fromUnixMs(uploaded)
}
m.MediaType = raw.MediaType
m.Tags = raw.Tags
return nil
}
// Tags is the result of List and Walk.
type Tags struct {
Children []string `json:"child"`
Manifests map[string]ManifestInfo `json:"manifest"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
// List calls /tags/list for the given repository.
func List(repo name.Repository, options ...ListerOption) (*Tags, error) {
l, err := newLister(repo, options...)
if err != nil {
return nil, err
}
return l.list(repo)
}
// WalkFunc is the type of the function called for each repository visited by
// Walk. This implements a similar API to filepath.Walk.
//
// The repo argument contains the argument to Walk as a prefix; that is, if Walk
// is called with "gcr.io/foo", which is a repository containing the repository
// "bar", the walk function will be called with argument "gcr.io/foo/bar".
// The tags and error arguments are the result of calling List on repo.
//
// TODO: Do we want a SkipDir error, as in filepath.WalkFunc?
type WalkFunc func(repo name.Repository, tags *Tags, err error) error
func walk(repo name.Repository, tags *Tags, walkFn WalkFunc, options ...ListerOption) error {
if tags == nil {
// This shouldn't happen.
return fmt.Errorf("tags nil for %q", repo)
}
if err := walkFn(repo, tags, nil); err != nil {
return err
}
for _, path := range tags.Children {
child, err := name.NewRepository(fmt.Sprintf("%s/%s", repo, path), name.StrictValidation)
if err != nil {
// We don't expect this ever, so don't pass it through to walkFn.
return fmt.Errorf("unexpected path failure: %v", err)
}
childTags, err := List(child, options...)
if err != nil {
if err := walkFn(child, nil, err); err != nil {
return err
}
} else {
if err := walk(child, childTags, walkFn, options...); err != nil {
return err
}
}
}
// We made it!
return nil
}
// Walk recursively descends repositories, calling walkFn.
func Walk(root name.Repository, walkFn WalkFunc, options ...ListerOption) error {
tags, err := List(root, options...)
if err != nil {
return walkFn(root, nil, err)
}
return walk(root, tags, walkFn, options...)
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2018 Google LLC 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 google
import (
"context"
"net/http"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/logs"
)
// WithTransport is a functional option for overriding the default transport
// on a remote image
func WithTransport(t http.RoundTripper) ListerOption {
return func(l *lister) error {
l.transport = t
return nil
}
}
// WithAuth is a functional option for overriding the default authenticator
// on a remote image
func WithAuth(auth authn.Authenticator) ListerOption {
return func(l *lister) error {
l.auth = auth
return nil
}
}
// WithAuthFromKeychain is a functional option for overriding the default
// authenticator on a remote image using an authn.Keychain
func WithAuthFromKeychain(keys authn.Keychain) ListerOption {
return func(l *lister) error {
auth, err := keys.Resolve(l.repo.Registry)
if err != nil {
return err
}
if auth == authn.Anonymous {
logs.Warn.Printf("No matching credentials were found for %q, falling back on anonymous", l.repo.Registry)
}
l.auth = auth
return nil
}
}
// WithContext is a functional option for overriding the default
// context.Context for HTTP request to list remote images
func WithContext(ctx context.Context) ListerOption {
return func(l *lister) error {
l.ctx = ctx
return nil
}
}
// WithUserAgent adds the given string to the User-Agent header for any HTTP
// requests. This header will also include "go-containerregistry/${version}".
//
// If you want to completely overwrite the User-Agent header, use WithTransport.
func WithUserAgent(ua string) ListerOption {
return func(l *lister) error {
l.userAgent = ua
return nil
}
}