Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net"
"net/http"
"os"
"path"
"path/filepath"
"runtime"
"strconv"
Expand All @@ -25,6 +26,7 @@ import (

"github.com/containerd/containerd"
"github.com/containerd/containerd/content"
"github.com/containerd/containerd/content/local"
ctderrdefs "github.com/containerd/containerd/errdefs"
"github.com/containerd/containerd/images"
"github.com/containerd/containerd/namespaces"
Expand Down Expand Up @@ -142,6 +144,7 @@ func TestIntegration(t *testing.T) {
testFileOpInputSwap,
testRelativeMountpoint,
testLocalSourceDiffer,
testOCILayoutSource,
testBuildExportZstd,
testPullZstdImage,
testMergeOp,
Expand Down Expand Up @@ -1594,6 +1597,106 @@ func testLocalSourceWithDiffer(t *testing.T, sb integration.Sandbox, d llb.DiffT
}
}

func testOCILayoutSource(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
c, err := New(context.TODO(), sb.Address())
require.NoError(t, err)
defer c.Close()

// create a tempdir where we will store the OCI layout
dir, err := os.MkdirTemp("", "buildkit-oci-layout")
require.NoError(t, err)
defer os.RemoveAll(dir)

// make an image that is exported there
busybox := llb.Image("busybox:latest")
st := llb.Scratch()

run := func(cmd string) {
st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st)
}

run(`sh -c "echo -n first > foo"`)
run(`sh -c "echo -n second > bar"`)

def, err := st.Marshal(sb.Context())
require.NoError(t, err)

outW := bytes.NewBuffer(nil)
attrs := map[string]string{}
_, err = c.Solve(sb.Context(), def, SolveOpt{
Exports: []ExportEntry{
{
Type: ExporterOCI,
Attrs: attrs,
Output: fixedWriteCloser(nopWriteCloser{outW}),
},
},
}, nil)
require.NoError(t, err)

// extract the tar stream to the directory as OCI layout
m, err := testutil.ReadTarToMap(outW.Bytes(), false)
require.NoError(t, err)

for filename, content := range m {
fullFilename := path.Join(dir, filename)
err = os.MkdirAll(path.Dir(fullFilename), 0755)
require.NoError(t, err)
if content.Header.FileInfo().IsDir() {
err = os.MkdirAll(fullFilename, 0755)
require.NoError(t, err)
} else {
err = os.WriteFile(fullFilename, content.Data, 0644)
require.NoError(t, err)
}
}

var index ocispecs.Index
err = json.Unmarshal(m["index.json"].Data, &index)
require.NoError(t, err)
require.Equal(t, 1, len(index.Manifests))
digest := index.Manifests[0].Digest

store, err := local.NewStore(dir)
require.NoError(t, err)

// reference the OCI Layout in a build
// note that the key does not need to be the directory name, just something unique.
// since we are doing just one build with one remote here, we can give it any old ID,
// even something really imaginative, like "one"
csID := "one"
st = llb.OCILayout(csID, digest)

def, err = st.Marshal(context.TODO())
require.NoError(t, err)

destDir, err := os.MkdirTemp("", "buildkit")
require.NoError(t, err)
defer os.RemoveAll(destDir)

_, err = c.Solve(context.TODO(), def, SolveOpt{
Exports: []ExportEntry{
{
Type: ExporterLocal,
OutputDir: destDir,
},
},
OCIStores: map[string]content.Store{
csID: store,
},
}, nil)
require.NoError(t, err)

dt, err := os.ReadFile(filepath.Join(destDir, "foo"))
require.NoError(t, err)
require.Equal(t, []byte("first"), dt)

dt, err = os.ReadFile(filepath.Join(destDir, "bar"))
require.NoError(t, err)
require.Equal(t, []byte("second"), dt)
}

func testFileOpRmWildcard(t *testing.T, sb integration.Sandbox) {
requiresLinux(t)
c, err := New(sb.Context(), sb.Address())
Expand Down
15 changes: 12 additions & 3 deletions client/llb/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ type ImageMetaResolver interface {
ResolveImageConfig(ctx context.Context, ref string, opt ResolveImageConfigOpt) (digest.Digest, []byte, error)
}

type ResolverType int

const (
ResolverTypeRegistry ResolverType = iota
ResolverTypeOCILayout
)

type ResolveImageConfigOpt struct {
Platform *ocispecs.Platform
ResolveMode string
LogName string
Platform *ocispecs.Platform
ResolveMode string
LogName string
ResolverType // default is ResolverTypeRegistry
SessionID string
}
62 changes: 58 additions & 4 deletions client/llb/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
_ "crypto/sha256" // for opencontainers/go-digest
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -132,8 +133,9 @@ func Image(ref string, opts ...ImageOption) State {
p = c.Platform
}
_, dt, err := info.metaResolver.ResolveImageConfig(ctx, ref, ResolveImageConfigOpt{
Platform: p,
ResolveMode: info.resolveMode.String(),
Platform: p,
ResolveMode: info.resolveMode.String(),
ResolverType: ResolverTypeRegistry,
})
if err != nil {
return State{}, err
Expand All @@ -147,8 +149,9 @@ func Image(ref string, opts ...ImageOption) State {
p = c.Platform
}
dgst, dt, err := info.metaResolver.ResolveImageConfig(context.TODO(), ref, ResolveImageConfigOpt{
Platform: p,
ResolveMode: info.resolveMode.String(),
Platform: p,
ResolveMode: info.resolveMode.String(),
ResolverType: ResolverTypeRegistry,
})
if err != nil {
return State{}, err
Expand Down Expand Up @@ -452,6 +455,57 @@ func Differ(t DiffType, required bool) LocalOption {
})
}

func OCILayout(contentStoreID string, dig digest.Digest, opts ...OCILayoutOption) State {
gi := &OCILayoutInfo{}

for _, o := range opts {
o.SetOCILayoutOption(gi)
}
attrs := map[string]string{}
if gi.sessionID != "" {
attrs[pb.AttrOCILayoutSessionID] = gi.sessionID
addCap(&gi.Constraints, pb.CapSourceOCILayoutSessionID)
}

if ll := gi.layerLimit; ll != nil {
attrs[pb.AttrOCILayoutLayerLimit] = strconv.FormatInt(int64(*ll), 10)
addCap(&gi.Constraints, pb.CapSourceOCILayoutLayerLimit)
}

addCap(&gi.Constraints, pb.CapSourceOCILayout)

source := NewSource(fmt.Sprintf("oci-layout://%s@%s", contentStoreID, dig), attrs, gi.Constraints)
return NewState(source.Output())
}

type OCILayoutOption interface {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a way to pass layer limit in this mode. Maybe it is possible to share the ImageOptions in here. Not all imageoptions are supported in here and SessionID is not supported for regular images. But some are the same(layerlimit etc) and want to make sure we don't drop any.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they don't quite overlap, then I can copy them over. It doesn't hurt.

What others are relevant? ImageInfo looks like:

type ImageInfo struct {
	constraintsWrapper
	metaResolver  ImageMetaResolver
	resolveDigest bool
	resolveMode   ResolveMode
	layerLimit    *int
	RecordType    string
}

We already have constraintsWrapper (as all do) and now layerLimit. Which others?

SetOCILayoutOption(*OCILayoutInfo)
}

type ociLayoutOptionFunc func(*OCILayoutInfo)

func (fn ociLayoutOptionFunc) SetOCILayoutOption(li *OCILayoutInfo) {
fn(li)
}

func OCISessionID(id string) OCILayoutOption {
return ociLayoutOptionFunc(func(oi *OCILayoutInfo) {
oi.sessionID = id
})
}

func OCILayerLimit(limit int) OCILayoutOption {
return ociLayoutOptionFunc(func(oi *OCILayoutInfo) {
oi.layerLimit = &limit
})
}

type OCILayoutInfo struct {
constraintsWrapper
sessionID string
layerLimit *int
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason one is public and other private?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. SessionID was taken from type LocalInfo where it is public, and layerLimit was take from type ImageInfo where is is private.

I will make them both private if vet is happy with it.

}

type DiffType string

const (
Expand Down
5 changes: 5 additions & 0 deletions client/llb/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ type ConstraintsOpt interface {
HTTPOption
ImageOption
GitOption
OCILayoutOption
}

type constraintsOptFunc func(m *Constraints)
Expand All @@ -471,6 +472,10 @@ func (fn constraintsOptFunc) SetLocalOption(li *LocalInfo) {
li.applyConstraints(fn)
}

func (fn constraintsOptFunc) SetOCILayoutOption(oi *OCILayoutInfo) {
oi.applyConstraints(fn)
}

func (fn constraintsOptFunc) SetHTTPOption(hi *HTTPInfo) {
hi.applyConstraints(fn)
}
Expand Down
24 changes: 22 additions & 2 deletions client/solve.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
type SolveOpt struct {
Exports []ExportEntry
LocalDirs map[string]string
OCIStores map[string]content.Store
SharedKey string
Frontend string
FrontendAttrs map[string]string
Expand Down Expand Up @@ -158,8 +159,27 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG
}
}

if len(cacheOpt.contentStores) > 0 {
s.Allow(sessioncontent.NewAttachable(cacheOpt.contentStores))
// this is a new map that contains both cacheOpt stores and OCILayout stores
contentStores := make(map[string]content.Store, len(cacheOpt.contentStores)+len(opt.OCIStores))
// copy over the stores references from cacheOpt
for key, store := range cacheOpt.contentStores {
contentStores[key] = store
}
// copy over the stores references from ociLayout opts
for key, store := range opt.OCIStores {
// conflicts are not allowed
if _, ok := contentStores[key]; ok {
// we probably should check if the store is identical, but given that
// https://pkg.go.dev/github.com/containerd/containerd/content#Store
// is just an interface, composing 4 others, that is rather hard to do.
// For a future iteration.
return nil, errors.Errorf("contentStore key %s exists in both cache and OCI layouts", key)
}
contentStores[key] = store
}

if len(contentStores) > 0 {
s.Allow(sessioncontent.NewAttachable(contentStores))
}

eg.Go(func() error {
Expand Down
6 changes: 6 additions & 0 deletions cmd/buildctl/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ func buildAction(clicontext *cli.Context) error {
// LocalDirs is set later
Frontend: clicontext.String("frontend"),
// FrontendAttrs is set later
// OCILayouts is set later
CacheExports: cacheExports,
CacheImports: cacheImports,
Session: attachable,
Expand All @@ -205,6 +206,11 @@ func buildAction(clicontext *cli.Context) error {
return errors.Wrap(err, "invalid local")
}

solveOpt.OCIStores, err = build.ParseOCILayout(clicontext.StringSlice("oci-layout"))
if err != nil {
return errors.Wrap(err, "invalid oci-layout")
}

var def *llb.Definition
if clicontext.String("frontend") == "" {
if fi, _ := os.Stdin.Stat(); (fi.Mode() & os.ModeCharDevice) != 0 {
Expand Down
27 changes: 27 additions & 0 deletions cmd/buildctl/build/ocilayout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package build

import (
"strings"

"github.com/containerd/containerd/content"
"github.com/containerd/containerd/content/local"
"github.com/pkg/errors"
)

// ParseOCILayout parses --oci-layout
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update README.md to explain the usage?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CC @deitch 👀

Sorry to bump this PR, but was trying to use this earlier and struggled to work out the right flags 🎉

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't be sorry @jedevc ; if it needs addressing, it should be raised. FWIW, I wrote this PR, and I still need to look back to recall what flags should be used where.

@AkihiroSuda I am fine opening a PR to update the README, but I am not sure where to put it. I don't see a section in the README that shows "here is where you add source contexts".

I also (despite having written this) don't remember the magical invocation. I think it was something like this. For Dockerfile:

FROM akihiro/abc:123
buildctl build --opt context:foo=oci-layout:///dir/to/layout

but something doesn't look right to me. Something is missing about mapping akihiro/abc:123 to context foo, so that foo can map to the oci-layout. Same thing if I were to replace it with docker-image://library/alpine:3.16 or something else.

Help me remember how to do this, and where it goes, and I am happy to add it to the README.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure where to put it

Doesn't look like we have anywhere for it atm, ideally we'd have a new section on "contexts" maybe right before Outputs? (so we'd cover local, docker-container, oci-layout, git, etc - I'm happy to take a look at the others if you could do the oci-layout).

Help me remember how to do this

No idea 😄 I'm at a bit of a loss.

I imagine something like this to work: --opt context:foo=oci-layout://path/to/oci@sha256:6f8bc6b53cc1f29d3ae7542bf7935762ccdff29ab8866e5d3fd85f6b190748f5 - but this gives:

error: failed to solve: unable to get info about digest: Unimplemented: unknown service containerd.services.content.v1.Content: unknown

I imagine this isn't working since it doesn't look like solveOpt.OCIStores is even populated, since the --oci-layout option isn't parsed (but --oci-layout is also never even created as a valid option). I'm kinda confused, it seems like something is definitely broken here (I'm not sure if it ever work with just buildctl, it only seems to have been tested using buildx, which has the right oci-layout parsing logic)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to take a look at the others if you could do the oci-layout).

I don't mind doing that. Why don't you get the others in, and then I can add, maybe on your PR for that?

No idea 😄 I'm at a bit of a loss.

Haha! That makes both of us.

since the --oci-layout option isn't parsed (but --oci-layout is also never even created as a valid option).

I don't think it ever was; wasn't it just for --opt and never a full-fledged --oci-layout? Or maybe it was? @tonistiigi walked me through the implementation step-by-step, as I really struggled to get my head around it.

The docker buildx extensions have a straight mapping of "image name" -> context, e.g. here, so I can do:

FROM akihiro/abc:123

and then

docker buildx build --build-context akihiro/abc:123=oci-layout:///path/to/layout@sha256:abcdefg11111222

But as I recall, the buildctl part was always two steps:

  1. Map the image name or AS alias in the dockerfile to a named context, e.g. akihiro/abc:123 --> foo
  2. Map the named context to a source, e.g. foo ---> oci-layout:///path/to/layout@sha256:abcdefg11111222

The --opt context:foo=... solves the second part, but not the first. I have no memory how to do that from CLI.

I'm not sure if it ever work with just buildctl, it only seems to have been tested using buildx, which has the right oci-layout parsing logic

I really do not recall. Tonis had us do the server-side logic first along with frontend, then buildx. I had though buildctl had the support, since it doesn't need to be explicitly listed (part 1 already works, and part 2 just gets passed to buildkitd), but maybe I am missing something?

I really thought we had run it back when this went in.

I imagine something like this to work... but this gives:

Can you post a sample? Here or on a gist? Let's have a common base to work with?

Copy link
Copy Markdown
Member

@jedevc jedevc Sep 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To create the OCI folder:

$ rm -rf dump; echo "FROM alpine:3.14" | buildx build -f - --output type=oci,dest=./out.tar .; mkdir dump; (cd dump; tar xf ../out.tar)

With the following Dockerfile:

FROM foo
RUN apk add git

And the following command with the appropriate sha (assuming a running buildkit built off of master listening on port 1234):

$ buildctl --addr tcp://localhost:1234 build --frontend=dockerfile.v0 --local context=. --local dockerfile=. --opt context:foo=oci-layout://dump@sha256:79a06bdbfbf27b365be61ca38646b07b451681475c284ad79deed506ab192917
...
------
 > [context foo] load metadata for dump@sha256:79a06bdbfbf27b365be61ca38646b07b451681475c284ad79deed506ab192917:
------
Dockerfile:1
--------------------
   1 | >>> FROM foo
   2 |     RUN apk add git
   3 |     
--------------------
error: failed to solve: unable to get info about digest: Unimplemented: unknown service containerd.services.content.v1.Content: unknown

Reading through the PR, it looks like we should need to pass --oci-layout here, but that logic isn't plumbed through to cobra properly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, will comment there.

func ParseOCILayout(layouts []string) (map[string]content.Store, error) {
contentStores := make(map[string]content.Store)
for _, idAndDir := range layouts {
parts := strings.SplitN(idAndDir, "=", 2)
if len(parts) != 2 {
return nil, errors.Errorf("oci-layout option must be 'id=path/to/layout', instead had invalid %s", idAndDir)
}
cs, err := local.NewStore(parts[1])
if err != nil {
return nil, errors.Wrapf(err, "oci-layout context at %s failed to initialize", parts[1])
}
contentStores[parts[0]] = cs
}

return contentStores, nil
}
Loading