blob: ecf92cd3c8b683ea3f2768923ef8f16e91d54ebe [file] [log] [blame] [edit]
// Client for interacting with the Google Container Registry.
//
// The Docker v2 API is protected by OAuth2, but it doesn't look
// exactly like Google OAuth2, so we have to create our own token
// source.
//
// Go implementation of the bash commands from:
//
// https://stackoverflow.com/questions/34037256/does-google-container-registry-support-docker-remote-api-v2/34046435#34046435
//
// I.e.:
//
// $ export NAME=project-id/image
// $ export BEARER=$(curl -u _token:$(gcloud auth print-access-token) https://gcr.io/v2/token?scope=repository:$NAME:pull | cut -d'"' -f 10)
// $ curl -H "Authorization: Bearer $BEARER" https://gcr.io/v2/$NAME/tags/list
package gcr
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/util"
"golang.org/x/oauth2"
)
const (
Server = "gcr.io"
)
var (
// DockerTagRegex is used to parse a Docker image tag as set by our
// infrastructure, which uses the following format:
//
// ${datetime}-${user}-${git_hash:0:7}-${repo_state}
//
// Where datetime is a UTC timestamp following the format:
//
// +%Y-%m-%dT%H_%M_%SZ
//
// User is the username of the person who built the image, git_hash is the
// abbreviated Git commit hash at which the image was built, and repo_state
// is either "clean" or "dirty", depending on whether there were local
// changes to the checkout at the time when the image was built.
DockerTagRegex = regexp.MustCompile(`(\d{4}-\d{2}-\d{2}T\d{2}_\d{2}_\d{2}Z)-(\w+)-([a-f0-9]+)-(\w+)`)
)
// gcrTokenSource it an oauth2.TokenSource that works with the Google Container Registry API.
type gcrTokenSource struct {
// client is an authorized client that has access to the GCS bucket where gcr stores docker images.
client *http.Client
// projectId - The Google Cloud project name, e.g. 'skia-public'.
projectId string
// imageName - The name of the image.
imageName string
}
func (g *gcrTokenSource) Token() (*oauth2.Token, error) {
// Use the authorized client to get a gcr.io specific oauth token.
resp, err := g.client.Get(fmt.Sprintf("https://%s/v2/token?scope=repository:%s/%s:pull", Server, g.projectId, g.imageName))
if err != nil {
return nil, err
}
defer util.Close(resp.Body)
if resp.StatusCode != 200 {
return nil, skerr.Fmt("Got unexpected status: %s", resp.Status)
}
var res struct {
AccessToken string `json:"token"`
ExpiresInSec int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, skerr.Wrapf(err, "Invalid token JSON from metadata: %v", err)
}
if res.ExpiresInSec == 0 || res.AccessToken == "" {
return nil, skerr.Fmt("Incomplete token received from metadata: %#v", res)
}
return &oauth2.Token{
AccessToken: res.AccessToken,
TokenType: "Bearer",
Expiry: time.Now().Add(time.Duration(res.ExpiresInSec) * time.Second),
}, nil
}
// Client talks to the Google Cloud Registry that supports the v2 Docker API.
type Client struct {
client *http.Client
// projectId - The Google Cloud project name, e.g. 'skia-public'.
projectId string
// imageName - The name of the image.
imageName string
}
// NewClient creates a Client that retrieves information about the docker images store under 'projectID'/'image'.
//
// tokenSource - An oauth2.TokenSource that Has read access to the bucket that the docker images are stored in.
// projectId - The Google Cloud project name, e.g. 'skia-public'.
// imageName - The name of the image, e.g. docserver.
func NewClient(tokenSource oauth2.TokenSource, projectId, imageName string) *Client {
gcrTokenSource := &gcrTokenSource{
client: httputils.DefaultClientConfig().WithTokenSource(tokenSource).With2xxOnly().Client(),
projectId: projectId,
imageName: imageName,
}
return &Client{
client: httputils.DefaultClientConfig().WithTokenSource(gcrTokenSource).With2xxOnly().Client(),
projectId: projectId,
imageName: imageName,
}
}
// TagsResponse is the response returned by Tags().
type TagsResponse struct {
Manifest map[string]struct {
ImageSizeBytes string `json:"imageSizeBytes"`
LayerID string `json:"layerId"`
Tags []string `json:"tag"`
TimeCreatedMs string `json:"timeCreatedMs"`
TimeUploadedMs string `json:"timeUploadedMs"`
} `json:"manifest"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
// Tags returns all of the tags for all versions of the image.
func (c *Client) Tags(ctx context.Context) (*TagsResponse, error) {
var rv *TagsResponse
const batchSize = 100
url := fmt.Sprintf("https://%s/v2/%s/%s/tags/list?n=%d", Server, c.projectId, c.imageName, batchSize)
for {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, skerr.Wrapf(err, "failed to create HTTP request")
}
req = req.WithContext(ctx)
req.Header.Add("Accept", "*")
resp, err := c.client.Do(req)
if err != nil {
return nil, skerr.Wrapf(err, "failed to request tags")
}
defer util.Close(resp.Body)
if resp.StatusCode != 200 {
return nil, skerr.Fmt("Got unexpected response: %s", resp.Status)
}
response := new(TagsResponse)
if err := json.NewDecoder(resp.Body).Decode(response); err != nil {
return nil, skerr.Wrapf(err, "could not decode response")
}
if rv == nil {
rv = response
} else {
rv.Tags = append(rv.Tags, response.Tags...)
for k, v := range response.Manifest {
rv.Manifest[k] = v
}
}
nextUrl, ok := resp.Header["Link"]
if !ok {
break
}
url = strings.Split(nextUrl[0], ";")[0]
}
return rv, nil
}