Files
buildkit/client/llb/source.go
Tonis Tiigi 9a0c5cb021 git: add git bundle source support
Support importing git sources from OCI or registry-backed bundle blobs
and exporting resolved checkouts as single-file git bundles.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
2026-04-27 14:30:11 +02:00

1117 lines
28 KiB
Go

package llb
import (
"context"
_ "crypto/sha256" // for opencontainers/go-digest
"encoding/json"
"os"
"path"
"slices"
"strconv"
"strings"
"github.com/distribution/reference"
"github.com/moby/buildkit/client/llb/sourceresolver"
"github.com/moby/buildkit/solver/pb"
"github.com/moby/buildkit/util/apicaps"
"github.com/moby/buildkit/util/gitutil"
"github.com/moby/buildkit/util/sshutil"
digest "github.com/opencontainers/go-digest"
"github.com/pkg/errors"
)
type SourceOp struct {
cache MarshalCache
id string
attrs map[string]string
output Output
constraints Constraints
err error
}
func NewSource(id string, attrs map[string]string, c Constraints) *SourceOp {
s := &SourceOp{
id: id,
attrs: attrs,
constraints: c,
}
s.output = &output{vertex: s, platform: c.Platform}
return s
}
func (s *SourceOp) Validate(ctx context.Context, c *Constraints) error {
if s.err != nil {
return s.err
}
if s.id == "" {
return errors.Errorf("source identifier can't be empty")
}
return nil
}
func (s *SourceOp) Marshal(ctx context.Context, constraints *Constraints) (digest.Digest, []byte, *pb.OpMetadata, []*SourceLocation, error) {
cache := s.cache.Acquire()
defer cache.Release()
if dgst, dt, md, srcs, err := cache.Load(constraints); err == nil {
return dgst, dt, md, srcs, nil
}
if err := s.Validate(ctx, constraints); err != nil {
return "", nil, nil, nil, err
}
if strings.HasPrefix(s.id, "local://") {
if _, hasSession := s.attrs[pb.AttrLocalSessionID]; !hasSession {
uid := s.constraints.LocalUniqueID
if uid == "" {
uid = constraints.LocalUniqueID
}
s.attrs[pb.AttrLocalUniqueID] = uid
addCap(&s.constraints, pb.CapSourceLocalUnique)
}
}
proto, md := MarshalConstraints(constraints, &s.constraints)
proto.Op = &pb.Op_Source{
Source: &pb.SourceOp{Identifier: s.id, Attrs: s.attrs},
}
if !platformSpecificSource(s.id) {
proto.Platform = nil
}
dt, err := deterministicMarshal(proto)
if err != nil {
return "", nil, nil, nil, err
}
return cache.Store(dt, md, s.constraints.SourceLocations, constraints)
}
func (s *SourceOp) Output() Output {
return s.output
}
func (s *SourceOp) Inputs() []Output {
return nil
}
type ImageBlobInfo struct {
constraintsWrapper
fileinfoWrapper
sessionID string
storeID string
}
type ImageBlobOption interface {
SetImageBlobOption(*ImageBlobInfo)
}
type FileInfoOption interface {
HTTPOption
ImageBlobOption
}
func ImageBlob(ref string, opts ...ImageBlobOption) State {
bi := &ImageBlobInfo{}
for _, o := range opts {
o.SetImageBlobOption(bi)
}
attrs := map[string]string{}
if bi.Filename != "" {
attrs[pb.AttrHTTPFilename] = bi.Filename
}
if bi.Perm != 0 {
attrs[pb.AttrHTTPPerm] = "0" + strconv.FormatInt(int64(bi.Perm), 8)
}
if bi.UID != 0 {
attrs[pb.AttrHTTPUID] = strconv.Itoa(bi.UID)
}
if bi.GID != 0 {
attrs[pb.AttrHTTPGID] = strconv.Itoa(bi.GID)
}
addCap(&bi.Constraints, pb.CapSourceImageBlob)
var digested reference.Digested
r, err := reference.ParseNormalizedNamed(ref)
if err == nil {
if _, tagged := r.(reference.Tagged); tagged {
err = errors.Errorf("tagged image reference not allowed for blob reference")
} else if ref, ok := r.(reference.Digested); !ok {
err = errors.Errorf("checksum required in blob reference")
} else {
digested = ref
}
}
repoName := "invalid"
if digested != nil {
repoName = digested.String()
}
source := NewSource("docker-image+blob://"+repoName, attrs, bi.Constraints)
if err != nil {
source.err = err
}
return NewState(source.Output())
}
// OCILayoutBlob returns a state that represents a single digest-addressed blob from an OCI layout store.
func OCILayoutBlob(ref string, opts ...ImageBlobOption) State {
bi := &ImageBlobInfo{}
for _, o := range opts {
o.SetImageBlobOption(bi)
}
attrs := map[string]string{}
if bi.Filename != "" {
attrs[pb.AttrHTTPFilename] = bi.Filename
}
if bi.Perm != 0 {
attrs[pb.AttrHTTPPerm] = "0" + strconv.FormatInt(int64(bi.Perm), 8)
}
if bi.UID != 0 {
attrs[pb.AttrHTTPUID] = strconv.Itoa(bi.UID)
}
if bi.GID != 0 {
attrs[pb.AttrHTTPGID] = strconv.Itoa(bi.GID)
}
if bi.sessionID != "" {
attrs[pb.AttrOCILayoutSessionID] = bi.sessionID
}
if bi.storeID != "" {
attrs[pb.AttrOCILayoutStoreID] = bi.storeID
}
addCap(&bi.Constraints, pb.CapSourceImageBlob)
var digested reference.Digested
r, err := reference.ParseNormalizedNamed(ref)
if err == nil {
if _, tagged := r.(reference.Tagged); tagged {
err = errors.Errorf("tagged image reference not allowed for blob reference")
} else if ref, ok := r.(reference.Digested); !ok {
err = errors.Errorf("checksum required in blob reference")
} else {
digested = ref
}
}
repoName := "invalid"
if digested != nil {
repoName = digested.String()
}
source := NewSource("oci-layout+blob://"+repoName, attrs, bi.Constraints)
if err != nil {
source.err = err
}
return NewState(source.Output())
}
// Image returns a state that represents a docker image in a registry.
// Example:
//
// st := llb.Image("busybox:latest")
func Image(ref string, opts ...ImageOption) State {
r, err := reference.ParseNormalizedNamed(ref)
if err == nil {
r = reference.TagNameOnly(r)
ref = r.String()
}
var info ImageInfo
for _, opt := range opts {
opt.SetImageOption(&info)
}
addCap(&info.Constraints, pb.CapSourceImage)
attrs := map[string]string{}
if info.resolveMode != 0 {
attrs[pb.AttrImageResolveMode] = info.resolveMode.String()
if info.resolveMode == ResolveModeForcePull {
addCap(&info.Constraints, pb.CapSourceImageResolveMode) // only require cap for security enforced mode
}
}
if info.RecordType != "" {
attrs[pb.AttrImageRecordType] = info.RecordType
}
if ll := info.layerLimit; ll != nil {
attrs[pb.AttrImageLayerLimit] = strconv.FormatInt(int64(*ll), 10)
addCap(&info.Constraints, pb.CapSourceImageLayerLimit)
}
if info.checksum != "" {
attrs[pb.AttrImageChecksum] = info.checksum.String()
addCap(&info.Constraints, pb.CapSourceImageChecksum)
}
src := NewSource("docker-image://"+ref, attrs, info.Constraints) // controversial
if err != nil {
src.err = err
} else if info.metaResolver != nil {
if _, ok := r.(reference.Digested); ok || !info.resolveDigest {
return NewState(src.Output()).Async(func(ctx context.Context, st State, c *Constraints) (State, error) {
p := info.Platform
if p == nil {
p = c.Platform
}
_, _, dt, err := info.metaResolver.ResolveImageConfig(ctx, ref, sourceresolver.Opt{
ImageOpt: &sourceresolver.ResolveImageOpt{
Platform: p,
ResolveMode: info.resolveMode.String(),
},
})
if err != nil {
return State{}, err
}
return st.WithImageConfig(dt)
})
}
return Scratch().Async(func(ctx context.Context, _ State, c *Constraints) (State, error) {
p := info.Platform
if p == nil {
p = c.Platform
}
ref, dgst, dt, err := info.metaResolver.ResolveImageConfig(context.TODO(), ref, sourceresolver.Opt{
ImageOpt: &sourceresolver.ResolveImageOpt{
Platform: p,
ResolveMode: info.resolveMode.String(),
},
})
if err != nil {
return State{}, err
}
r, err := reference.ParseNormalizedNamed(ref)
if err != nil {
return State{}, err
}
if dgst != "" {
r, err = reference.WithDigest(r, dgst)
if err != nil {
return State{}, err
}
}
return NewState(NewSource("docker-image://"+r.String(), attrs, info.Constraints).Output()).WithImageConfig(dt)
})
}
return NewState(src.Output())
}
type ImageOption interface {
SetImageOption(*ImageInfo)
}
type imageOptionFunc func(*ImageInfo)
func (fn imageOptionFunc) SetImageOption(ii *ImageInfo) {
fn(ii)
}
type imageBlobOptionFunc func(*ImageBlobInfo)
func (fn imageBlobOptionFunc) SetImageBlobOption(ib *ImageBlobInfo) {
fn(ib)
}
var MarkImageInternal = imageOptionFunc(func(ii *ImageInfo) {
ii.RecordType = "internal"
})
type ResolveMode int
const (
ResolveModeDefault ResolveMode = iota
ResolveModeForcePull
ResolveModePreferLocal
)
func (r ResolveMode) SetImageOption(ii *ImageInfo) {
ii.resolveMode = r
}
func (r ResolveMode) String() string {
switch r {
case ResolveModeDefault:
return pb.AttrImageResolveModeDefault
case ResolveModeForcePull:
return pb.AttrImageResolveModeForcePull
case ResolveModePreferLocal:
return pb.AttrImageResolveModePreferLocal
default:
return ""
}
}
type ImageInfo struct {
constraintsWrapper
metaResolver ImageMetaResolver
resolveDigest bool
resolveMode ResolveMode
layerLimit *int
checksum digest.Digest
RecordType string
}
const (
GitAuthHeaderKey = "GIT_AUTH_HEADER"
GitAuthTokenKey = "GIT_AUTH_TOKEN"
)
// Git returns a state that represents a git repository.
// Example:
//
// st := llb.Git("https://github.com/moby/buildkit.git", "v0.11.6")
//
// The example fetches the v0.11.6 tag of the buildkit repository.
// You can also use a commit hash or a branch name.
//
// Other URL formats are supported such as "git@github.com:moby/buildkit.git", "git://...", "ssh://..."
// Formats that utilize SSH may need to supply credentials as a [GitOption].
// You may need to check the source code for a full list of supported formats.
//
// Fragment can be used to pass ref:subdir format that can set in (old-style)
// Docker Git URL format after # . This is provided for backwards compatibility.
// It is recommended to leave it empty and call GitRef(), GitSubdir() options instead.
//
// By default the git repository is cloned with `--depth=1` to reduce the amount of data downloaded.
// Additionally the ".git" directory is removed after the clone, you can keep ith with the [KeepGitDir] [GitOption].
func Git(url, fragment string, opts ...GitOption) State {
remote, err := gitutil.ParseURL(url)
if errors.Is(err, gitutil.ErrUnknownProtocol) {
url = "https://" + url
remote, err = gitutil.ParseURL(url)
}
if remote != nil {
url = remote.Remote
}
gi := &GitInfo{
AuthHeaderSecret: GitAuthHeaderKey,
AuthTokenSecret: GitAuthTokenKey,
}
ref, subdir, ok := strings.Cut(fragment, ":")
subdir = path.Join("/", subdir)
subdir = strings.TrimPrefix(subdir, "/")
if ref != "" {
GitRef(ref).SetGitOption(gi)
}
if ok && subdir != "" {
GitSubDir(subdir).SetGitOption(gi)
}
for _, o := range opts {
o.SetGitOption(gi)
}
var id string
if err != nil {
// If we can't parse the URL, just use the full URL as the ID. The git
// operation will fail later on.
id = url
} else {
// We construct the ID manually here, so that we can create the same ID
// for different protocols (e.g. https and ssh) that have the same
// host/path/fragment combination.
id = remote.Host + path.Join("/", remote.Path)
if gi.Ref != "" || gi.SubDir != "" {
id += "#" + gi.Ref
if gi.SubDir != "" {
id += ":" + gi.SubDir
}
}
}
attrs := map[string]string{}
if gi.KeepGitDir {
attrs[pb.AttrKeepGitDir] = "true"
addCap(&gi.Constraints, pb.CapSourceGitKeepDir)
}
if url != "" {
attrs[pb.AttrFullRemoteURL] = url
addCap(&gi.Constraints, pb.CapSourceGitFullURL)
}
if gi.AuthTokenSecret != "" {
attrs[pb.AttrAuthTokenSecret] = gi.AuthTokenSecret
if gi.addAuthCap {
addCap(&gi.Constraints, pb.CapSourceGitHTTPAuth)
}
}
if gi.AuthHeaderSecret != "" {
attrs[pb.AttrAuthHeaderSecret] = gi.AuthHeaderSecret
if gi.addAuthCap {
addCap(&gi.Constraints, pb.CapSourceGitHTTPAuth)
}
}
if remote != nil && remote.Scheme == gitutil.SSHProtocol {
if gi.KnownSSHHosts != "" {
attrs[pb.AttrKnownSSHHosts] = gi.KnownSSHHosts
} else {
keyscan, err := sshutil.SSHKeyScan(remote.Host)
if err == nil {
// best effort
attrs[pb.AttrKnownSSHHosts] = keyscan
}
}
addCap(&gi.Constraints, pb.CapSourceGitKnownSSHHosts)
if gi.MountSSHSock == "" {
attrs[pb.AttrMountSSHSock] = "default"
} else {
attrs[pb.AttrMountSSHSock] = gi.MountSSHSock
}
addCap(&gi.Constraints, pb.CapSourceGitMountSSHSock)
}
checksum := gi.Checksum
if checksum != "" {
attrs[pb.AttrGitChecksum] = checksum
addCap(&gi.Constraints, pb.CapSourceGitChecksum)
}
if gi.SkipSubmodules {
attrs[pb.AttrGitSkipSubmodules] = "true"
addCap(&gi.Constraints, pb.CapSourceGitSkipSubmodules)
}
if gi.MTime != "" {
attrs[pb.AttrGitMTime] = gi.MTime
addCap(&gi.Constraints, pb.CapSourceGitMTime)
}
if gi.FetchByCommit {
attrs[pb.AttrGitFetchByCommit] = "true"
addCap(&gi.Constraints, pb.CapSourceGitFetchByCommit)
}
if gi.Bundle != "" {
attrs[pb.AttrGitBundle] = gi.Bundle
addCap(&gi.Constraints, pb.CapSourceGitBundle)
}
if gi.BundleOCISessionID != "" {
attrs[pb.AttrOCILayoutSessionID] = gi.BundleOCISessionID
}
if gi.BundleOCIStoreID != "" {
attrs[pb.AttrOCILayoutStoreID] = gi.BundleOCIStoreID
}
if gi.CheckoutBundle {
attrs[pb.AttrGitCheckoutBundle] = "true"
addCap(&gi.Constraints, pb.CapSourceGitCheckoutBundle)
}
addCap(&gi.Constraints, pb.CapSourceGit)
source := NewSource("git://"+id, attrs, gi.Constraints)
return NewState(source.Output())
}
type GitOption interface {
SetGitOption(*GitInfo)
}
type gitOptionFunc func(*GitInfo)
func (fn gitOptionFunc) SetGitOption(gi *GitInfo) {
fn(gi)
}
type GitInfo struct {
constraintsWrapper
KeepGitDir bool
AuthTokenSecret string
AuthHeaderSecret string
addAuthCap bool
KnownSSHHosts string
MountSSHSock string
Checksum string
Ref string
SubDir string
SkipSubmodules bool
MTime string
Bundle string
BundleOCISessionID string
BundleOCIStoreID string
CheckoutBundle bool
FetchByCommit bool
}
func GitRef(v string) GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.Ref = v
})
}
func GitSubDir(v string) GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.SubDir = v
})
}
func GitSkipSubmodules() GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.SkipSubmodules = true
})
}
// GitMTimeCommit sets file modification times to the commit timestamp
// of the resolved commit, rather than the checkout time.
func GitMTimeCommit() GitOption {
return GitMTime("commit")
}
// GitMTime sets the file modification time policy for git sources.
// Valid values are "checkout" (default) and "commit".
func GitMTime(v string) GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.MTime = v
})
}
func KeepGitDir() GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.KeepGitDir = true
})
}
func AuthTokenSecret(v string) GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.AuthTokenSecret = v
gi.addAuthCap = true
})
}
func KnownSSHHosts(key string) GitOption {
key = strings.TrimSuffix(key, "\n")
return gitOptionFunc(func(gi *GitInfo) {
gi.KnownSSHHosts = gi.KnownSSHHosts + key + "\n"
})
}
func MountSSHSock(sshID string) GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.MountSSHSock = sshID
})
}
func GitChecksum(v string) GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.Checksum = v
})
}
// GitFetchByCommit makes the git source trust the provided checksum as the
// commit to fetch, without resolving the ref against the remote. The ref, if
// set, is applied locally after the commit is fetched so that cache keys
// still depend on it. This is useful when the remote ref may have moved
// since the original resolution.
//
// Unqualified ref names are canonicalized to "refs/heads/<name>" to match
// the normal path's cache keys for branches. Tags must be passed fully
// qualified ("refs/tags/<name>").
func GitFetchByCommit() GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.FetchByCommit = true
})
}
// GitBundleInfo carries scheme-specific configuration for [GitBundleURL].
// It is populated by [GitBundleOption] values and consumed by GitBundleURL.
type GitBundleInfo struct {
// OCISessionID pins the OCI-layout bundle fetch to a specific client
// session. Only meaningful when the locator uses the
// "oci-layout+blob://" scheme.
OCISessionID string
// OCIStoreID overrides the OCI-layout store name derived from the
// bundle locator body. Only meaningful when the locator uses the
// "oci-layout+blob://" scheme.
OCIStoreID string
}
// GitBundleOption configures bundle-specific behavior for [GitBundleURL].
type GitBundleOption interface {
SetGitBundleOption(*GitBundleInfo)
}
type gitBundleOptionFunc func(*GitBundleInfo)
func (fn gitBundleOptionFunc) SetGitBundleOption(bi *GitBundleInfo) {
fn(bi)
}
// GitBundleURL configures the git source to import the commit history from a
// pre-built git bundle instead of fetching from the upstream Git remote. The
// locator must use one of the following schemes:
//
// docker-image+blob://<registry-ref>@sha256:<digest>
// oci-layout+blob://<ref>@sha256:<digest>
//
// [GitChecksum] is required when GitBundleURL is used and the commit it
// identifies must be reachable from a ref inside the bundle (i.e. the bundle
// must contain that commit; BuildKit validates this after import). Auth and
// SSH options are ignored in bundle mode. Submodules, if present, still fetch
// from their own remotes. See [GitCheckoutBundle] for the checkout side; it
// is mutually exclusive with [KeepGitDir] and [GitSubDir].
//
// Scheme-specific behavior can be tuned via [GitBundleOption] values, e.g.
// [GitBundleOCIStore] for the "oci-layout+blob://" scheme.
func GitBundleURL(locator string, opts ...GitBundleOption) GitOption {
bi := &GitBundleInfo{}
for _, o := range opts {
o.SetGitBundleOption(bi)
}
return gitOptionFunc(func(gi *GitInfo) {
gi.Bundle = locator
gi.BundleOCISessionID = bi.OCISessionID
gi.BundleOCIStoreID = bi.OCIStoreID
})
}
// GitBundleOCIStore configures the OCI-layout session and store id used to
// resolve a bundle locator with the "oci-layout+blob://" scheme. When unset,
// the locator body (the part before "@digest") is used as the store id and no
// session is pinned. This mirrors [ImageBlobOCIStore] for regular OCI-layout
// blobs.
//
// Only meaningful when passed to [GitBundleURL] together with a locator that
// uses the "oci-layout+blob://" scheme.
func GitBundleOCIStore(sessionID, storeID string) GitBundleOption {
return gitBundleOptionFunc(func(bi *GitBundleInfo) {
bi.OCISessionID = sessionID
bi.OCIStoreID = storeID
})
}
// GitCheckoutBundle produces a single-file git bundle at the checkout mount
// root (filename "bundle") containing the resolved commit, instead of a
// worktree. Mutually exclusive with [KeepGitDir] and [GitSubDir]. Submodules
// are not included in the bundle. Pair with [GitBundleURL] to re-import the
// bundle back into a later build.
func GitCheckoutBundle() GitOption {
return gitOptionFunc(func(gi *GitInfo) {
gi.CheckoutBundle = true
})
}
// AuthOption can be used with either HTTP or Git sources.
type AuthOption interface {
GitOption
HTTPOption
}
// AuthHeaderSecret returns an AuthOption that defines the name of a
// secret to use for HTTP based authentication.
func AuthHeaderSecret(secretName string) AuthOption {
return struct {
GitOption
HTTPOption
}{
GitOption: gitOptionFunc(func(gi *GitInfo) {
gi.AuthHeaderSecret = secretName
gi.addAuthCap = true
}),
HTTPOption: httpOptionFunc(func(hi *HTTPInfo) {
hi.AuthHeaderSecret = secretName
}),
}
}
// Scratch returns a state that represents an empty filesystem.
func Scratch() State {
return NewState(nil)
}
// Local returns a state that represents a directory local to the client.
func Local(name string, opts ...LocalOption) State {
gi := &LocalInfo{}
for _, o := range opts {
o.SetLocalOption(gi)
}
attrs := map[string]string{}
if gi.SessionID != "" {
attrs[pb.AttrLocalSessionID] = gi.SessionID
addCap(&gi.Constraints, pb.CapSourceLocalSessionID)
}
if gi.IncludePatterns != "" {
attrs[pb.AttrIncludePatterns] = gi.IncludePatterns
addCap(&gi.Constraints, pb.CapSourceLocalIncludePatterns)
}
if gi.FollowPaths != "" {
attrs[pb.AttrFollowPaths] = gi.FollowPaths
addCap(&gi.Constraints, pb.CapSourceLocalFollowPaths)
}
if gi.ExcludePatterns != "" {
attrs[pb.AttrExcludePatterns] = gi.ExcludePatterns
addCap(&gi.Constraints, pb.CapSourceLocalExcludePatterns)
}
if gi.SharedKeyHint != "" {
attrs[pb.AttrSharedKeyHint] = gi.SharedKeyHint
addCap(&gi.Constraints, pb.CapSourceLocalSharedKeyHint)
}
if gi.Differ.Type != "" {
attrs[pb.AttrLocalDiffer] = string(gi.Differ.Type)
if gi.Differ.Required {
addCap(&gi.Constraints, pb.CapSourceLocalDiffer)
}
}
if gi.MetadataOnlyCollector {
attrs[pb.AttrMetadataTransfer] = "true"
if gi.MetadataOnlyExceptions != "" {
attrs[pb.AttrMetadataTransferExclude] = gi.MetadataOnlyExceptions
}
addCap(&gi.Constraints, pb.CapSourceMetadataTransfer)
}
addCap(&gi.Constraints, pb.CapSourceLocal)
source := NewSource("local://"+name, attrs, gi.Constraints)
return NewState(source.Output())
}
type LocalOption interface {
SetLocalOption(*LocalInfo)
}
type localOptionFunc func(*LocalInfo)
func (fn localOptionFunc) SetLocalOption(li *LocalInfo) {
fn(li)
}
func SessionID(id string) LocalOption {
return localOptionFunc(func(li *LocalInfo) {
li.SessionID = id
})
}
func IncludePatterns(p []string) LocalOption {
return localOptionFunc(func(li *LocalInfo) {
if len(p) == 0 {
li.IncludePatterns = ""
return
}
dt, _ := json.Marshal(p) // empty on error
li.IncludePatterns = string(dt)
})
}
func FollowPaths(p []string) LocalOption {
return localOptionFunc(func(li *LocalInfo) {
if len(p) == 0 {
li.FollowPaths = ""
return
}
dt, _ := json.Marshal(p) // empty on error
li.FollowPaths = string(dt)
})
}
func ExcludePatterns(p []string) LocalOption {
return localOptionFunc(func(li *LocalInfo) {
if len(p) == 0 {
li.ExcludePatterns = ""
return
}
dt, _ := json.Marshal(p) // empty on error
li.ExcludePatterns = string(dt)
})
}
func SharedKeyHint(h string) LocalOption {
return localOptionFunc(func(li *LocalInfo) {
li.SharedKeyHint = h
})
}
func Differ(t DiffType, required bool) LocalOption {
return localOptionFunc(func(li *LocalInfo) {
li.Differ = DifferInfo{
Type: t,
Required: required,
}
})
}
func MetadataOnlyTransfer(exceptions []string) LocalOption {
return localOptionFunc(func(li *LocalInfo) {
li.MetadataOnlyCollector = true
if len(exceptions) == 0 {
li.MetadataOnlyExceptions = ""
} else {
dt, _ := json.Marshal(exceptions) // empty on error
li.MetadataOnlyExceptions = string(dt)
}
})
}
func OCILayout(ref string, opts ...OCILayoutOption) State {
gi := &OCILayoutInfo{}
for _, o := range opts {
o.SetOCILayoutOption(gi)
}
attrs := map[string]string{}
if gi.sessionID != "" {
attrs[pb.AttrOCILayoutSessionID] = gi.sessionID
}
if gi.storeID != "" {
attrs[pb.AttrOCILayoutStoreID] = gi.storeID
}
if gi.layerLimit != nil {
attrs[pb.AttrOCILayoutLayerLimit] = strconv.FormatInt(int64(*gi.layerLimit), 10)
}
addCap(&gi.Constraints, pb.CapSourceOCILayout)
source := NewSource("oci-layout://"+ref, attrs, gi.Constraints)
return NewState(source.Output())
}
type OCILayoutOption interface {
SetOCILayoutOption(*OCILayoutInfo)
}
type ociLayoutOptionFunc func(*OCILayoutInfo)
func (fn ociLayoutOptionFunc) SetOCILayoutOption(li *OCILayoutInfo) {
fn(li)
}
func OCIStore(sessionID string, storeID string) OCILayoutOption {
return ociLayoutOptionFunc(func(oi *OCILayoutInfo) {
oi.sessionID = sessionID
oi.storeID = storeID
})
}
// ImageBlobOCIStore returns an [ImageBlobOption] that configures the OCI layout session/store used by [OCILayoutBlob].
func ImageBlobOCIStore(sessionID string, storeID string) ImageBlobOption {
return imageBlobOptionFunc(func(ib *ImageBlobInfo) {
ib.sessionID = sessionID
ib.storeID = storeID
})
}
func OCILayerLimit(limit int) OCILayoutOption {
return ociLayoutOptionFunc(func(oi *OCILayoutInfo) {
oi.layerLimit = &limit
})
}
func OCIChecksum(dgst digest.Digest) OCILayoutOption {
return ociLayoutOptionFunc(func(oi *OCILayoutInfo) {
oi.checksum = dgst
})
}
type OCILayoutInfo struct {
constraintsWrapper
sessionID string
storeID string
layerLimit *int
checksum digest.Digest
}
type DiffType string
const (
// DiffNone will do no file comparisons, all files in the Local source will
// be retransmitted.
DiffNone DiffType = pb.AttrLocalDifferNone
// DiffMetadata will compare file metadata (size, modified time, mode, owner,
// group, device and link name) to determine if the files in the Local source need
// to be retransmitted. This is the default behavior.
DiffMetadata DiffType = pb.AttrLocalDifferMetadata
)
type DifferInfo struct {
Type DiffType
Required bool
}
type LocalInfo struct {
constraintsWrapper
SessionID string
IncludePatterns string
ExcludePatterns string
FollowPaths string
SharedKeyHint string
Differ DifferInfo
MetadataOnlyCollector bool
MetadataOnlyExceptions string
}
func HTTP(url string, opts ...HTTPOption) State {
hi := &HTTPInfo{}
for _, o := range opts {
o.SetHTTPOption(hi)
}
attrs := map[string]string{}
if hi.Checksum != "" {
attrs[pb.AttrHTTPChecksum] = hi.Checksum.String()
addCap(&hi.Constraints, pb.CapSourceHTTPChecksum)
}
if hi.Filename != "" {
attrs[pb.AttrHTTPFilename] = hi.Filename
}
if hi.Perm != 0 {
attrs[pb.AttrHTTPPerm] = "0" + strconv.FormatInt(int64(hi.Perm), 8)
addCap(&hi.Constraints, pb.CapSourceHTTPPerm)
}
if hi.UID != 0 {
attrs[pb.AttrHTTPUID] = strconv.Itoa(hi.UID)
addCap(&hi.Constraints, pb.CapSourceHTTPUIDGID)
}
if hi.GID != 0 {
attrs[pb.AttrHTTPGID] = strconv.Itoa(hi.GID)
addCap(&hi.Constraints, pb.CapSourceHTTPUIDGID)
}
if hi.AuthHeaderSecret != "" {
attrs[pb.AttrHTTPAuthHeaderSecret] = hi.AuthHeaderSecret
addCap(&hi.Constraints, pb.CapSourceHTTPAuth)
}
if hi.Header != nil {
hi.Header.setAttrs(attrs)
addCap(&hi.Constraints, pb.CapSourceHTTPHeader)
}
if hi.Signature != nil {
if len(hi.Signature.PubKey) > 0 {
attrs[pb.AttrHTTPSignatureVerifyPubKey] = string(hi.Signature.PubKey)
}
if len(hi.Signature.Signature) > 0 {
attrs[pb.AttrHTTPSignatureVerify] = string(hi.Signature.Signature)
}
addCap(&hi.Constraints, pb.CapSourceHTTPSignatureVerify)
}
addCap(&hi.Constraints, pb.CapSourceHTTP)
source := NewSource(url, attrs, hi.Constraints)
return NewState(source.Output())
}
type fileInfo struct {
Filename string
Perm int
UID int
GID int
}
type fileinfoWrapper struct {
fileInfo
}
type fileInfoOptFunc func(f *fileInfo)
func (fn fileInfoOptFunc) SetHTTPOption(hi *HTTPInfo) {
fn(&hi.fileInfo)
}
func (fn fileInfoOptFunc) SetImageBlobOption(ib *ImageBlobInfo) {
fn(&ib.fileInfo)
}
// HTTPSignatureInfo configures detached-signature verification for HTTP
// sources. The current implementation uses inline armored signatures.
type HTTPSignatureInfo struct {
PubKey []byte
// Signature is an inline detached armored OpenPGP signature.
Signature []byte
}
type HTTPInfo struct {
constraintsWrapper
fileinfoWrapper
Checksum digest.Digest
AuthHeaderSecret string
Header *HTTPHeader
Signature *HTTPSignatureInfo
}
type HTTPOption interface {
SetHTTPOption(*HTTPInfo)
}
type httpOptionFunc func(*HTTPInfo)
func (fn httpOptionFunc) SetHTTPOption(hi *HTTPInfo) {
fn(hi)
}
func Checksum(dgst digest.Digest) HTTPOption {
return httpOptionFunc(func(hi *HTTPInfo) {
hi.Checksum = dgst
})
}
func Chmod(perm os.FileMode) FileInfoOption {
return fileInfoOptFunc(func(fi *fileInfo) {
fi.Perm = int(perm) & 0777
})
}
func Filename(name string) FileInfoOption {
return fileInfoOptFunc(func(fi *fileInfo) {
fi.Filename = name
})
}
func Chown(uid, gid int) FileInfoOption {
return fileInfoOptFunc(func(fi *fileInfo) {
fi.UID = uid
fi.GID = gid
})
}
// VerifyPGPSignature returns an [HTTPOption] for detached OpenPGP signature
// verification of the downloaded HTTP payload.
func VerifyPGPSignature(info HTTPSignatureInfo) HTTPOption {
return httpOptionFunc(func(hi *HTTPInfo) {
hi.Signature = &HTTPSignatureInfo{
PubKey: slices.Clone(info.PubKey),
Signature: slices.Clone(info.Signature),
}
})
}
// Header returns an [HTTPOption] that ensures additional request headers will
// be sent when retrieving the HTTP source.
func Header(header HTTPHeader) HTTPOption {
return httpOptionFunc(func(hi *HTTPInfo) {
hi.Header = &header
})
}
type HTTPHeader struct {
Accept string
UserAgent string
}
func (hh *HTTPHeader) setAttrs(attrs map[string]string) {
if hh.Accept != "" {
attrs[hh.attr("accept")] = hh.Accept
}
if hh.UserAgent != "" {
attrs[hh.attr("user-agent")] = hh.UserAgent
}
}
func (hh *HTTPHeader) attr(name string) string {
return pb.AttrHTTPHeaderPrefix + name
}
func platformSpecificSource(id string) bool {
return strings.HasPrefix(id, "docker-image://") || strings.HasPrefix(id, "oci-layout://")
}
func addCap(c *Constraints, id apicaps.CapID) {
if c.Metadata.Caps == nil {
c.Metadata.Caps = make(map[apicaps.CapID]bool)
}
c.Metadata.Caps[id] = true
}