| // 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 |
| } |