| package docker |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "net/http" |
| "regexp" |
| "strconv" |
| "time" |
| |
| "go.skia.org/infra/go/auth" |
| "go.skia.org/infra/go/httputils" |
| "go.skia.org/infra/go/skerr" |
| "go.skia.org/infra/go/util" |
| "golang.org/x/oauth2/google" |
| ) |
| |
| const ( |
| blobURLTemplate = "https://%s/v2/%s/blobs/%s" |
| catalogURLTemplate = "https://%s/v2/_catalog?n=%d" |
| listTagsURLTemplate = "https://%s/v2/%s/tags/list?n=%d" |
| manifestURLTemplate = "https://%s/v2/%s/manifests/%s" |
| acceptHeader = "Accept" |
| contentTypeHeader = "Content-Type" |
| manifestContentType = "application/vnd.docker.distribution.manifest.v2+json" |
| digestHeader = "Docker-Content-Digest" |
| pageSize = 100 |
| ) |
| |
| var ( |
| dockerImageRegex = regexp.MustCompile(`^([0-9a-zA-Z_\.-]+)/([0-9a-zA-Z_\.\/-]+)(:([0-9a-zA-Z_\.-]+)|@(sha256:[0-9a-f]{64})|)$`) |
| ) |
| |
| // SplitImage splits a full Docker image path into registry, repository, and |
| // tag or digest components. It is not an error for the tag or digest to be |
| // missing. |
| func SplitImage(image string) (registry, repository, tagOrDigest string, err error) { |
| m := dockerImageRegex.FindStringSubmatch(image) |
| if len(m) != 6 { |
| return "", "", "", skerr.Fmt("invalid Docker image format; matched: %v", m) |
| } |
| registry = m[1] |
| repository = m[2] |
| tagOrDigest = m[4] |
| if tagOrDigest == "" { |
| tagOrDigest = m[5] |
| } |
| return |
| } |
| |
| // Client is used for interacting with a Docker registry. |
| type Client interface { |
| // GetManifest retrieves the manifest for the given image. The reference may |
| // be a tag or a digest. |
| GetManifest(ctx context.Context, registry, repository, reference string) (*Manifest, error) |
| // GetConfig retrieves an image config based on the config.digest from its |
| // Manifest. |
| GetConfig(ctx context.Context, registry, repository, configDigest string) (*ImageConfig, error) |
| // ListRepositories lists all repositories in the given registry. Because there |
| // may be many results, this can be quite slow. |
| ListRepositories(ctx context.Context, registry string) ([]string, error) |
| // ListInstances lists all image instances for the given repository, keyed by |
| // their digests. Because there may be many results, this can be quite slow. |
| ListInstances(ctx context.Context, registry, repository string) (map[string]*ImageInstance, error) |
| // ListTags lists all known tags for the given repository. Because there may be |
| // many results, this can be quite slow. |
| ListTags(ctx context.Context, registry, repository string) ([]string, error) |
| // SetTag sets the given tag on the image. |
| SetTag(ctx context.Context, registry, repository, reference, newTag string) error |
| } |
| |
| // ClientImpl implements Client. |
| type ClientImpl struct { |
| client *http.Client |
| } |
| |
| // NewClient returns a Client instance which interacts with the given registry. |
| // The passed-in http.Client should handle redirects. |
| func NewClient(ctx context.Context) (*ClientImpl, error) { |
| ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| httpClient := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxAnd3xx().Client() |
| return &ClientImpl{ |
| client: httpClient, |
| }, nil |
| } |
| |
| type MediaConfig struct { |
| MediaType string `json:"mediaType"` |
| Size int `json:"size"` |
| Digest string `json:"digest"` |
| } |
| |
| // Manifest represents a Docker image manifest. |
| type Manifest struct { |
| SchemaVersion int `json:"schemaVersion"` |
| MediaType string `json:"mediaType"` |
| Digest string `json:"-"` |
| Config MediaConfig `json:"config"` |
| Layers []MediaConfig `json:"layers"` |
| } |
| |
| // GetManifest implements Client. |
| func (c *ClientImpl) GetManifest(ctx context.Context, registry, repository, reference string) (*Manifest, error) { |
| url := fmt.Sprintf(manifestURLTemplate, registry, repository, reference) |
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| req.Header.Set(acceptHeader, manifestContentType) |
| resp, err := c.client.Do(req) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| defer util.Close(resp.Body) |
| var rv Manifest |
| if err := json.NewDecoder(resp.Body).Decode(&rv); err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| digestVals := resp.Header[digestHeader] |
| if len(digestVals) != 1 { |
| return nil, skerr.Fmt("found %d values for %s header", len(digestVals), digestHeader) |
| } |
| rv.Digest = digestVals[0] |
| return &rv, nil |
| } |
| |
| // paginatedResults has the necessary field, "Next", needed to paginate requests |
| // to the API as well as all of the fields used by callers of "paginate". |
| type paginatedResults struct { |
| Next string `json:"next"` |
| Repositories []string `json:"repositories"` |
| Manifest map[string]*decodedImageInstance `json:"manifest"` |
| Tags []string `json:"tags"` |
| } |
| |
| // paginate is a helper function for paginating API requests. |
| func (c *ClientImpl) paginate(ctx context.Context, url string, cb func(paginatedResults) error) error { |
| var header string |
| for { |
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| if header != "" { |
| req.Header.Set("Link", header) |
| } |
| resp, err := c.client.Do(req) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| var results paginatedResults |
| err = json.NewDecoder(resp.Body).Decode(&results) |
| util.Close(resp.Body) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| if err := cb(results); err != nil { |
| return skerr.Wrap(err) |
| } |
| if results.Next != "" { |
| url = results.Next |
| header = fmt.Sprintf(`<%s>; rel="next"`, url) |
| } else { |
| return nil |
| } |
| } |
| } |
| |
| // ListRepositories implements Client. |
| func (c *ClientImpl) ListRepositories(ctx context.Context, registry string) ([]string, error) { |
| url := fmt.Sprintf(catalogURLTemplate, registry, pageSize) |
| var rv []string |
| if err := c.paginate(ctx, url, func(results paginatedResults) error { |
| rv = append(rv, results.Repositories...) |
| return nil |
| }); err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| return rv, nil |
| } |
| |
| // ImageInstance describes one instance of an image. |
| type ImageInstance struct { |
| Digest string `json:"digest"` |
| SizeBytes int64 `json:"imageSizeBytes"` |
| Tags []string `json:"tag"` |
| Created time.Time `json:"timeCreatedMs"` |
| Uploaded time.Time `json:"timeUploadedMs"` |
| } |
| |
| // decodedImageInstance is used for parsing an ImageInstance from JSON. |
| type decodedImageInstance struct { |
| ImageSizeBytes string `json:"imageSizeBytes"` |
| Tags []string `json:"tag"` |
| TimeCreatedMs string `json:"timeCreatedMs"` |
| TimeUploadedMs string `json:"timeUploadedMs"` |
| } |
| |
| // imageInstance creates an ImageInstance from this decodedImageInstance. |
| func (i decodedImageInstance) imageInstance(digest string) (*ImageInstance, error) { |
| sizeBytes, err := strconv.ParseInt(i.ImageSizeBytes, 10, 64) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| createdMs, err := strconv.ParseInt(i.TimeCreatedMs, 10, 64) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| uploadedMs, err := strconv.ParseInt(i.TimeUploadedMs, 10, 64) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| return &ImageInstance{ |
| Digest: digest, |
| SizeBytes: sizeBytes, |
| Tags: i.Tags, |
| Created: time.UnixMilli(createdMs), |
| Uploaded: time.UnixMilli(uploadedMs), |
| }, nil |
| } |
| |
| // ImageInstanceSlice implements sort.Interface. |
| type ImageInstanceSlice []*ImageInstance |
| |
| // Less implements sort.Interface. |
| func (s ImageInstanceSlice) Less(i, j int) bool { |
| // First, sort by Created timestamp. |
| if s[i].Created.Before(s[j].Created) { |
| return true |
| } else if s[i].Created.After(s[j].Created) { |
| return false |
| } |
| // Next, sort by Uploaded timestamp. |
| if s[i].Uploaded.Before(s[j].Uploaded) { |
| return true |
| } else if s[i].Uploaded.After(s[j].Uploaded) { |
| return false |
| } |
| // Next, sort by size. |
| if s[i].SizeBytes < s[j].SizeBytes { |
| return true |
| } else if s[i].SizeBytes > s[j].SizeBytes { |
| return false |
| } |
| // Finally, sort by digest. |
| return s[i].Digest < s[j].Digest |
| } |
| |
| // Len implements sort.Interface. |
| func (s ImageInstanceSlice) Len() int { |
| return len(s) |
| } |
| |
| // Swap implements sort.Interface. |
| func (s ImageInstanceSlice) Swap(i, j int) { |
| s[i], s[j] = s[j], s[i] |
| } |
| |
| // ListInstances implements Client. |
| func (c *ClientImpl) ListInstances(ctx context.Context, registry, repository string) (map[string]*ImageInstance, error) { |
| url := fmt.Sprintf(listTagsURLTemplate, registry, repository, pageSize) |
| rv := map[string]*ImageInstance{} |
| if err := c.paginate(ctx, url, func(results paginatedResults) error { |
| for digest, instance := range results.Manifest { |
| inst, err := instance.imageInstance(digest) |
| if err != nil { |
| return err |
| } |
| rv[inst.Digest] = inst |
| } |
| return nil |
| }); err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| return rv, nil |
| } |
| |
| // ListTags implements Client. |
| func (c *ClientImpl) ListTags(ctx context.Context, registry, repository string) ([]string, error) { |
| url := fmt.Sprintf(listTagsURLTemplate, registry, repository, pageSize) |
| var rv []string |
| if err := c.paginate(ctx, url, func(results paginatedResults) error { |
| rv = append(rv, results.Tags...) |
| return nil |
| }); err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| return rv, nil |
| } |
| |
| // ImageConfig is the configuration blob for a Docker image instance. |
| type ImageConfig struct { |
| Architecture string `json:"architecture"` |
| Author string `json:"author"` |
| Config ImageConfig_Config `json:"config"` |
| Container string `json:"container"` |
| Created time.Time `json:"created"` |
| DockerVersion string `json:"docker_version"` |
| History []ImageConfig_History `json:"history"` |
| OS string `json:"os"` |
| RootFS ImageConfig_RootFS `json:"rootfs"` |
| } |
| |
| type ImageConfig_History struct { |
| Author string `json:"author"` |
| Created time.Time `json:"created"` |
| CreatedBy string `json:"created_by"` |
| EmptyLayer bool `json:"empty_layer"` |
| } |
| |
| type ImageConfig_Config struct { |
| AttachStderr bool `json:"AttachStderr"` |
| AttachStdout bool `json:"AttachStdout"` |
| Cmd []string `json:"Cmd"` |
| Entrypoint []string `json:"Entrypoint"` |
| Env []string `json:"Env"` |
| Hostname string `json:"Hostname"` |
| Image string `json:"Image"` |
| User string `json:"User"` |
| } |
| |
| type ImageConfig_RootFS struct { |
| DiffIDs []string `json:"diff_ids"` |
| Type string `json:"type"` |
| } |
| |
| // GetConfig implements Client. |
| func (c *ClientImpl) GetConfig(ctx context.Context, registry, repository, configDigest string) (*ImageConfig, error) { |
| url := fmt.Sprintf(blobURLTemplate, registry, repository, configDigest) |
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| resp, err := c.client.Do(req) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| defer util.Close(resp.Body) |
| rv := new(ImageConfig) |
| if err := json.NewDecoder(resp.Body).Decode(&rv); err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| return rv, nil |
| } |
| |
| // SetTag implements Client. |
| func (c *ClientImpl) SetTag(ctx context.Context, registry, repository, reference, newTag string) error { |
| // Retrieve the existing manifest. This duplicates code from GetManifest, |
| // but the server seems to directly take the SHA256 sum of the provided |
| // content, without removing whitespace. Returning the manifest exactly as |
| // we received it (rather than decoding it and re-encoding it) ensures that |
| // we end up with the same SHA256. |
| var manifestBytes []byte |
| { |
| url := fmt.Sprintf(manifestURLTemplate, registry, repository, reference) |
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| req.Header.Set(acceptHeader, manifestContentType) |
| resp, err := c.client.Do(req) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| defer util.Close(resp.Body) |
| manifestBytes, err = io.ReadAll(resp.Body) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| } |
| |
| // PUT the manifest using the new tag. |
| { |
| url := fmt.Sprintf(manifestURLTemplate, registry, repository, newTag) |
| req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, nil) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| req.Header.Set(contentTypeHeader, manifestContentType) |
| req.Body = io.NopCloser(bytes.NewReader(manifestBytes)) |
| req.Header.Set(acceptHeader, manifestContentType) |
| if _, err := c.client.Do(req); err != nil { |
| return skerr.Wrap(err) |
| } |
| } |
| return nil |
| } |
| |
| // Assert that ClientImpl implements Client. |
| var _ Client = &ClientImpl{} |
| |
| // GetConfig retrieves an image config. It is a shortcut for Client.GetManifest |
| // and Client.GetConfig. |
| func GetConfig(ctx context.Context, c *ClientImpl, registry, repository, reference string) (*ImageConfig, error) { |
| manifest, err := c.GetManifest(ctx, registry, repository, reference) |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| return c.GetConfig(ctx, registry, repository, manifest.Config.Digest) |
| } |
| |
| // GetDigest retrieves the digest for the given image from the registry. It is a |
| // shortcut for Client.GetManifest and Manifest.Digest. |
| // |
| // Note: This could instead be part of Client, and we could send a HEAD request |
| // to the manifests URL. This would be a little more efficient in that it would |
| // send less data over the network. |
| func GetDigest(ctx context.Context, c Client, registry, repository, tag string) (string, error) { |
| manifest, err := c.GetManifest(ctx, registry, repository, tag) |
| if err != nil { |
| return "", skerr.Wrap(err) |
| } |
| return manifest.Digest, nil |
| } |