Files
buildkit/source/http/httpsource.go
Erik Sipsma a9f1980ebb Refactor cache metadata interface.
There are a few goals with this refactor:
1. Remove external access to fields that no longer make sense and/or
   won't make sense soon due to other potential changes. For example,
   there can now be multiple blobs associated with a ref (for different
   compression types), so the fact that you could access the "Blob"
   field from the Info method on Ref incorrectly implied there was just
   a single blob for the ref. This is on top of the fact that there is
   no need for external access to blob digests.
2. Centralize use of cache metadata inside the cache package.
   Previously, many parts of the code outside the cache package could
   obtain the bolt storage item for any ref and read/write it directly.
   This made it hard to understand what fields are used and when. Now,
   the Metadata method has been removed from the Ref interface and
   replaced with getters+setters for metadata fields we want to expose
   outside the package, which makes it much easier to track and
   understand. Similar changes have been made to the metadata search
   interface.
3. Use a consistent getter+setter interface for metadata, replacing
   the mix of interfaces like Metadata(), Size(), Info() and other
   inconsistencies.

Signed-off-by: Erik Sipsma <erik@sipsma.dev>
2021-08-25 19:15:09 +00:00

473 lines
11 KiB
Go

package http
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/docker/docker/pkg/idtools"
"github.com/moby/buildkit/cache"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/snapshot"
"github.com/moby/buildkit/solver"
"github.com/moby/buildkit/source"
"github.com/moby/buildkit/util/tracing"
"github.com/moby/locker"
digest "github.com/opencontainers/go-digest"
"github.com/pkg/errors"
)
type Opt struct {
CacheAccessor cache.Accessor
Transport http.RoundTripper
}
type httpSource struct {
cache cache.Accessor
locker *locker.Locker
transport http.RoundTripper
}
func NewSource(opt Opt) (source.Source, error) {
transport := opt.Transport
if transport == nil {
transport = tracing.DefaultTransport
}
hs := &httpSource{
cache: opt.CacheAccessor,
locker: locker.New(),
transport: transport,
}
return hs, nil
}
func (hs *httpSource) ID() string {
return source.HTTPSScheme
}
type httpSourceHandler struct {
*httpSource
src source.HTTPIdentifier
refID string
cacheKey digest.Digest
sm *session.Manager
}
func (hs *httpSource) Resolve(ctx context.Context, id source.Identifier, sm *session.Manager, _ solver.Vertex) (source.SourceInstance, error) {
httpIdentifier, ok := id.(*source.HTTPIdentifier)
if !ok {
return nil, errors.Errorf("invalid http identifier %v", id)
}
return &httpSourceHandler{
src: *httpIdentifier,
httpSource: hs,
sm: sm,
}, nil
}
func (hs *httpSourceHandler) client(g session.Group) *http.Client {
return &http.Client{Transport: newTransport(hs.transport, hs.sm, g)}
}
// urlHash is internal hash the etag is stored by that doesn't leak outside
// this package.
func (hs *httpSourceHandler) urlHash() (digest.Digest, error) {
dt, err := json.Marshal(struct {
Filename string
Perm, UID, GID int
}{
Filename: getFileName(hs.src.URL, hs.src.Filename, nil),
Perm: hs.src.Perm,
UID: hs.src.UID,
GID: hs.src.GID,
})
if err != nil {
return "", err
}
return digest.FromBytes(dt), nil
}
func (hs *httpSourceHandler) formatCacheKey(filename string, dgst digest.Digest, lastModTime string) digest.Digest {
dt, err := json.Marshal(struct {
Filename string
Perm, UID, GID int
Checksum digest.Digest
LastModTime string `json:",omitempty"`
}{
Filename: filename,
Perm: hs.src.Perm,
UID: hs.src.UID,
GID: hs.src.GID,
Checksum: dgst,
LastModTime: lastModTime,
})
if err != nil {
return dgst
}
return digest.FromBytes(dt)
}
func (hs *httpSourceHandler) CacheKey(ctx context.Context, g session.Group, index int) (string, solver.CacheOpts, bool, error) {
if hs.src.Checksum != "" {
hs.cacheKey = hs.src.Checksum
return hs.formatCacheKey(getFileName(hs.src.URL, hs.src.Filename, nil), hs.src.Checksum, "").String(), nil, true, nil
}
uh, err := hs.urlHash()
if err != nil {
return "", nil, false, nil
}
// look up metadata(previously stored headers) for that URL
mds, err := searchHTTPURLDigest(ctx, hs.cache, uh)
if err != nil {
return "", nil, false, errors.Wrapf(err, "failed to search metadata for %s", uh)
}
req, err := http.NewRequest("GET", hs.src.URL, nil)
if err != nil {
return "", nil, false, err
}
req = req.WithContext(ctx)
m := map[string]cacheRefMetadata{}
// If we request a single ETag in 'If-None-Match', some servers omit the
// unambiguous ETag in their response.
// See: https://github.com/moby/buildkit/issues/905
var onlyETag string
if len(mds) > 0 {
for _, md := range mds {
// if metaDigest := getMetaDigest(si); metaDigest == hs.formatCacheKey("") {
if etag := md.getETag(); etag != "" {
if dgst := md.getHTTPChecksum(); dgst != "" {
m[etag] = md
}
}
// }
}
if len(m) > 0 {
etags := make([]string, 0, len(m))
for t := range m {
etags = append(etags, t)
}
req.Header.Add("If-None-Match", strings.Join(etags, ", "))
if len(etags) == 1 {
onlyETag = etags[0]
}
}
}
client := hs.client(g)
// Some servers seem to have trouble supporting If-None-Match properly even
// though they return ETag-s. So first, optionally try a HEAD request with
// manual ETag value comparison.
if len(m) > 0 {
req.Method = "HEAD"
resp, err := client.Do(req)
if err == nil {
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotModified {
respETag := resp.Header.Get("ETag")
// If a 304 is returned without an ETag and we had only sent one ETag,
// the response refers to the ETag we asked about.
if respETag == "" && onlyETag != "" && resp.StatusCode == http.StatusNotModified {
respETag = onlyETag
}
md, ok := m[respETag]
if ok {
hs.refID = md.ID()
dgst := md.getHTTPChecksum()
if dgst != "" {
modTime := md.getHTTPModTime()
resp.Body.Close()
return hs.formatCacheKey(getFileName(hs.src.URL, hs.src.Filename, resp), dgst, modTime).String(), nil, true, nil
}
}
}
resp.Body.Close()
}
req.Method = "GET"
}
resp, err := client.Do(req)
if err != nil {
return "", nil, false, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return "", nil, false, errors.Errorf("invalid response status %d", resp.StatusCode)
}
if resp.StatusCode == http.StatusNotModified {
respETag := resp.Header.Get("ETag")
if respETag == "" && onlyETag != "" {
respETag = onlyETag
// Set the missing ETag header on the response so that it's available
// to .save()
resp.Header.Set("ETag", onlyETag)
}
md, ok := m[respETag]
if !ok {
return "", nil, false, errors.Errorf("invalid not-modified ETag: %v", respETag)
}
hs.refID = md.ID()
dgst := md.getHTTPChecksum()
if dgst == "" {
return "", nil, false, errors.Errorf("invalid metadata change")
}
modTime := md.getHTTPModTime()
resp.Body.Close()
return hs.formatCacheKey(getFileName(hs.src.URL, hs.src.Filename, resp), dgst, modTime).String(), nil, true, nil
}
ref, dgst, err := hs.save(ctx, resp, g)
if err != nil {
return "", nil, false, err
}
ref.Release(context.TODO())
hs.cacheKey = dgst
return hs.formatCacheKey(getFileName(hs.src.URL, hs.src.Filename, resp), dgst, resp.Header.Get("Last-Modified")).String(), nil, true, nil
}
func (hs *httpSourceHandler) save(ctx context.Context, resp *http.Response, s session.Group) (ref cache.ImmutableRef, dgst digest.Digest, retErr error) {
newRef, err := hs.cache.New(ctx, nil, s, cache.CachePolicyRetain, cache.WithDescription(fmt.Sprintf("http url %s", hs.src.URL)))
if err != nil {
return nil, "", err
}
releaseRef := func() {
newRef.Release(context.TODO())
}
defer func() {
if retErr != nil && newRef != nil {
releaseRef()
}
}()
mount, err := newRef.Mount(ctx, false, s)
if err != nil {
return nil, "", err
}
lm := snapshot.LocalMounter(mount)
dir, err := lm.Mount()
if err != nil {
return nil, "", err
}
defer func() {
if retErr != nil && lm != nil {
lm.Unmount()
}
}()
perm := 0600
if hs.src.Perm != 0 {
perm = hs.src.Perm
}
fp := filepath.Join(dir, getFileName(hs.src.URL, hs.src.Filename, resp))
f, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(perm))
if err != nil {
return nil, "", err
}
defer func() {
if f != nil {
f.Close()
}
}()
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
return nil, "", err
}
if err := f.Close(); err != nil {
return nil, "", err
}
f = nil
uid := hs.src.UID
gid := hs.src.GID
if idmap := mount.IdentityMapping(); idmap != nil {
identity, err := idmap.ToHost(idtools.Identity{
UID: int(uid),
GID: int(gid),
})
if err != nil {
return nil, "", err
}
uid = identity.UID
gid = identity.GID
}
if gid != 0 || uid != 0 {
if err := os.Chown(fp, uid, gid); err != nil {
return nil, "", err
}
}
mTime := time.Unix(0, 0)
lastMod := resp.Header.Get("Last-Modified")
if lastMod != "" {
if parsedMTime, err := http.ParseTime(lastMod); err == nil {
mTime = parsedMTime
}
}
if err := os.Chtimes(fp, mTime, mTime); err != nil {
return nil, "", err
}
lm.Unmount()
lm = nil
ref, err = newRef.Commit(ctx)
if err != nil {
return nil, "", err
}
newRef = nil
md := cacheRefMetadata{ref}
hs.refID = ref.ID()
dgst = digest.NewDigest(digest.SHA256, h)
if respETag := resp.Header.Get("ETag"); respETag != "" {
if err := md.setETag(respETag); err != nil {
return nil, "", err
}
uh, err := hs.urlHash()
if err != nil {
return nil, "", err
}
if err := md.setHTTPChecksum(uh, dgst); err != nil {
return nil, "", err
}
}
if modTime := resp.Header.Get("Last-Modified"); modTime != "" {
if err := md.setHTTPModTime(modTime); err != nil {
return nil, "", err
}
}
return ref, dgst, nil
}
func (hs *httpSourceHandler) Snapshot(ctx context.Context, g session.Group) (cache.ImmutableRef, error) {
if hs.refID != "" {
ref, err := hs.cache.Get(ctx, hs.refID)
if err == nil {
return ref, nil
}
}
req, err := http.NewRequest("GET", hs.src.URL, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
client := hs.client(g)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
ref, dgst, err := hs.save(ctx, resp, g)
if err != nil {
return nil, err
}
if dgst != hs.cacheKey {
ref.Release(context.TODO())
return nil, errors.Errorf("digest mismatch %s: %s", dgst, hs.cacheKey)
}
return ref, nil
}
func getFileName(urlStr, manualFilename string, resp *http.Response) string {
if manualFilename != "" {
return manualFilename
}
if resp != nil {
if contentDisposition := resp.Header.Get("Content-Disposition"); contentDisposition != "" {
if _, params, err := mime.ParseMediaType(contentDisposition); err == nil {
if params["filename"] != "" && !strings.HasSuffix(params["filename"], "/") {
if filename := filepath.Base(filepath.FromSlash(params["filename"])); filename != "" {
return filename
}
}
}
}
}
u, err := url.Parse(urlStr)
if err == nil {
if base := path.Base(u.Path); base != "." && base != "/" {
return base
}
}
return "download"
}
func searchHTTPURLDigest(ctx context.Context, store cache.MetadataStore, dgst digest.Digest) ([]cacheRefMetadata, error) {
var results []cacheRefMetadata
mds, err := store.Search(ctx, string(dgst))
if err != nil {
return nil, err
}
for _, md := range mds {
results = append(results, cacheRefMetadata{md})
}
return results, nil
}
type cacheRefMetadata struct {
cache.RefMetadata
}
const keyHTTPChecksum = "http.checksum"
const keyETag = "etag"
const keyModTime = "http.modtime"
func (md cacheRefMetadata) getHTTPChecksum() digest.Digest {
return digest.Digest(md.GetString(keyHTTPChecksum))
}
func (md cacheRefMetadata) setHTTPChecksum(urlDgst digest.Digest, d digest.Digest) error {
return md.SetString(keyHTTPChecksum, d.String(), urlDgst.String())
}
func (md cacheRefMetadata) getETag() string {
return md.GetString(keyETag)
}
func (md cacheRefMetadata) setETag(s string) error {
return md.SetString(keyETag, s, "")
}
func (md cacheRefMetadata) getHTTPModTime() string {
return md.GetString(keyModTime)
}
func (md cacheRefMetadata) setHTTPModTime(s string) error {
return md.SetString(keyModTime, s, "")
}