Files
buildkit/frontend/dockerui/context.go
Tonis Tiigi 8100e1edab source/git: add fetch-by-commit support
Allow git sources to fetch a pinned commit without resolving the ref
against the remote tip, while preserving cache keys for canonical
branch refs and covering the behavior with tests.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
2026-04-16 16:10:30 -07:00

362 lines
8.8 KiB
Go

package dockerui
import (
"archive/tar"
"bytes"
"context"
"io"
"maps"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/client/llb/sourceresolver"
"github.com/moby/buildkit/frontend/dockerfile/dfgitutil"
"github.com/moby/buildkit/frontend/gateway/client"
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/solver/pb"
"github.com/moby/buildkit/util/gitutil/gitobject"
archivecompression "github.com/moby/go-archive/compression"
"github.com/pkg/errors"
)
const (
DefaultLocalNameContext = "context"
DefaultLocalNameDockerfile = "dockerfile"
DefaultDockerfileName = "Dockerfile"
DefaultDockerignoreName = ".dockerignore"
EmptyImageName = "scratch"
)
const (
keyFilename = "filename"
keyContextSubDir = "contextsubdir"
keyNameContext = "contextkey"
keyNameDockerfile = "dockerfilekey"
)
var httpPrefix = regexp.MustCompile(`^https?://`)
type buildContext struct {
context *llb.State // set if not local
dockerfile *llb.State // override remoteContext if set
contextRef client.Reference
contextLocalName string
dockerfileLocalName string
filename string
forceLocalDockerfile bool
sourceOp *pb.SourceOp
httpContextIsArchive bool
httpContextFilename string
}
func (bc *Client) marshalOpts() []llb.ConstraintsOpt {
return []llb.ConstraintsOpt{llb.WithCaps(bc.bopts.Caps)}
}
func (bc *Client) initContext(ctx context.Context) (*buildContext, error) {
opts := bc.bopts.Opts
gwcaps := bc.bopts.Caps
localNameContext := DefaultLocalNameContext
if v, ok := opts[keyNameContext]; ok {
localNameContext = v
}
bctx := &buildContext{
contextLocalName: DefaultLocalNameContext,
dockerfileLocalName: DefaultLocalNameDockerfile,
filename: DefaultDockerfileName,
}
if v, ok := opts[keyFilename]; ok {
bctx.filename = v
}
if v, ok := opts[keyNameDockerfile]; ok {
bctx.forceLocalDockerfile = true
bctx.dockerfileLocalName = v
}
var keepGit *bool
if v, err := strconv.ParseBool(opts[keyContextKeepGitDirArg]); err == nil {
keepGit = &v
}
var extraGitOpts []llb.GitOption
if opts[buildArgPrefix+"SOURCE_DATE_EPOCH"] != "" {
extraGitOpts = append(extraGitOpts, llb.GitMTimeCommit())
}
if st, ok, err := DetectGitContext(opts[localNameContext], keepGit, extraGitOpts...); ok {
if err != nil {
return nil, err
}
sourceOp, err := sourceOpFromState(ctx, st, bc.marshalOpts()...)
if err != nil {
return nil, errors.Wrapf(err, "failed to derive git source op")
}
bctx.context = st
bctx.dockerfile = st
bctx.sourceOp = sourceOp
} else if st, filename, ok := DetectHTTPContext(opts[localNameContext]); ok {
sourceOp, err := sourceOpFromState(ctx, st, bc.marshalOpts()...)
if err != nil {
return nil, errors.Wrapf(err, "failed to derive http source op")
}
def, err := st.Marshal(ctx, bc.marshalOpts()...)
if err != nil {
return nil, errors.Wrapf(err, "failed to marshal httpcontext")
}
res, err := bc.client.Solve(ctx, client.SolveRequest{
Definition: def.ToPB(),
})
if err != nil {
return nil, errors.Wrapf(err, "failed to resolve httpcontext")
}
ref, err := res.SingleRef()
if err != nil {
return nil, err
}
dt, err := ref.ReadFile(ctx, client.ReadRequest{
Filename: filename,
Range: &client.FileRange{
Length: 1024,
},
})
if err != nil {
return nil, errors.Wrapf(err, "failed to read downloaded context")
}
if isArchive(dt) {
bc := llb.Scratch().File(llb.Copy(*st, filepath.Join("/", filename), "/", &llb.CopyInfo{
AttemptUnpack: true,
}))
bctx.context = &bc
bctx.httpContextIsArchive = true
} else {
bctx.filename = filename
bctx.context = st
}
bctx.contextRef = ref
bctx.dockerfile = bctx.context
bctx.sourceOp = sourceOp
bctx.httpContextFilename = filename
} else if (&gwcaps).Supports(gwpb.CapFrontendInputs) == nil {
inputs, err := bc.client.Inputs(ctx)
if err != nil {
return nil, errors.Wrapf(err, "failed to get frontend inputs")
}
if !bctx.forceLocalDockerfile {
inputDockerfile, ok := inputs[bctx.dockerfileLocalName]
if ok {
bctx.dockerfile = &inputDockerfile
}
}
inputCtx, ok := inputs[DefaultLocalNameContext]
if ok {
bctx.context = &inputCtx
}
}
if bctx.context != nil {
if sub, ok := opts[keyContextSubDir]; ok {
bctx.context = scopeToSubDir(bctx.context, sub)
}
}
return bctx, nil
}
func (bc *Client) ResolveMainContextSourceDateEpoch(ctx context.Context) (*time.Time, error) {
bctx, err := bc.buildContext(ctx)
if err != nil {
return nil, err
}
if bctx.sourceOp == nil {
return nil, nil
}
opt := sourceresolver.Opt{
LogName: "[internal] resolve main build context metadata",
}
if strings.HasPrefix(bctx.sourceOp.Identifier, "git://") {
opt.GitOpt = &sourceresolver.ResolveGitOpt{ReturnObject: true}
}
md, err := bc.client.ResolveSourceMetadata(ctx, cloneSourceOp(bctx.sourceOp), opt)
if err != nil {
return nil, err
}
if md.Git != nil && len(md.Git.CommitObject) > 0 {
obj, err := gitobject.Parse(md.Git.CommitObject)
if err != nil {
return nil, err
}
commit, err := obj.ToCommit()
if err != nil {
return nil, err
}
return commit.Committer.When, nil
}
if md.HTTP != nil {
if md.HTTP.LastModified != nil {
return md.HTTP.LastModified, nil
}
if bctx.httpContextIsArchive {
return archiveMaxTimeFromHTTPArchive(ctx, bctx)
}
}
return nil, nil
}
func archiveMaxTimeFromHTTPArchive(ctx context.Context, bctx *buildContext) (*time.Time, error) {
if bctx.contextRef == nil || bctx.httpContextFilename == "" {
return nil, nil
}
dt, err := bctx.contextRef.ReadFile(ctx, client.ReadRequest{
Filename: bctx.httpContextFilename,
})
if err != nil {
return nil, err
}
rc, err := archivecompression.DecompressStream(bytes.NewReader(dt))
if err != nil {
return nil, err
}
defer rc.Close()
tr := tar.NewReader(rc)
var maxTime *time.Time
for {
hdr, err := tr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
return maxTime, nil
}
return nil, err
}
if !hdr.FileInfo().Mode().IsRegular() {
continue
}
tm := hdr.ModTime.UTC()
if maxTime == nil || tm.After(*maxTime) {
maxTime = &tm
}
}
}
func cloneSourceOp(op *pb.SourceOp) *pb.SourceOp {
if op == nil {
return nil
}
return &pb.SourceOp{
Identifier: op.Identifier,
Attrs: maps.Clone(op.Attrs),
}
}
func sourceOpFromState(ctx context.Context, st *llb.State, opts ...llb.ConstraintsOpt) (*pb.SourceOp, error) {
if st == nil {
return nil, nil
}
def, err := st.Marshal(ctx, opts...)
if err != nil {
return nil, err
}
dt := def.ToPB().Def
var src *pb.SourceOp
for _, d := range dt {
var op pb.Op
if err := op.Unmarshal(d); err != nil {
return nil, err
}
opSrc := op.GetSource()
if opSrc == nil {
continue
}
if src != nil {
return nil, errors.New("state marshaled to multiple source ops")
}
src = opSrc
}
if src == nil {
return nil, errors.New("state did not marshal to a source op")
}
return cloneSourceOp(src), nil
}
func DetectGitContext(ref string, keepGit *bool, opts ...llb.GitOption) (*llb.State, bool, error) {
g, isGit, err := dfgitutil.ParseGitRef(ref)
if err != nil {
return nil, isGit, err
}
gitOpts := slices.Concat(opts, []llb.GitOption{
llb.GitRef(g.Ref),
WithInternalName("load git source " + ref),
})
if g.KeepGitDir != nil && *g.KeepGitDir {
gitOpts = append(gitOpts, llb.KeepGitDir())
}
if keepGit != nil && *keepGit {
gitOpts = append(gitOpts, llb.KeepGitDir())
}
if g.SubDir != "" {
gitOpts = append(gitOpts, llb.GitSubDir(g.SubDir))
}
if g.Checksum != "" {
gitOpts = append(gitOpts, llb.GitChecksum(g.Checksum))
}
if g.Submodules != nil && !*g.Submodules {
gitOpts = append(gitOpts, llb.GitSkipSubmodules())
}
if g.MTime != "" {
gitOpts = append(gitOpts, llb.GitMTime(g.MTime))
}
if g.FetchByCommit {
gitOpts = append(gitOpts, llb.GitFetchByCommit())
}
st := llb.Git(g.Remote, "", gitOpts...)
return &st, true, nil
}
func DetectHTTPContext(ref string) (*llb.State, string, bool) {
filename := "context"
if httpPrefix.MatchString(ref) {
st := llb.HTTP(ref, llb.Filename(filename), WithInternalName("load remote build context"))
return &st, filename, true
}
return nil, "", false
}
func isArchive(header []byte) bool {
for _, m := range [][]byte{
{0x42, 0x5A, 0x68}, // bzip2
{0x1F, 0x8B, 0x08}, // gzip
{0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00}, // xz
} {
if len(header) < len(m) {
continue
}
if bytes.Equal(m, header[:len(m)]) {
return true
}
}
r := tar.NewReader(bytes.NewBuffer(header))
_, err := r.Next()
return err == nil
}
func scopeToSubDir(c *llb.State, dir string) *llb.State {
bc := llb.Scratch().File(llb.Copy(*c, dir, "/", &llb.CopyInfo{
CopyDirContentsOnly: true,
}))
return &bc
}