diff --git a/README.md b/README.md index 56df622..46ed2c7 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,53 @@ mkctr \ [--target=] \ # e.g. flyio, local [--user=1000:1000] \ # user (uid[:gid]) to run the container as [--push] \ + [--output=image.oci.tar] \ [--] [...] ``` +### Output modes + +Every mode requires `--base` and at least one of `--gopaths` or `--files`. +`--repos` and `--tags` accept comma-separated lists; when required, both must +be set. Each repository receives each tag when publishing or loading locally. + +| Mode | Flags | Result | +| --- | --- | --- | +| Build only (default) | `--repos` and `--tags`, without `--push` or `--output` | Builds the image but does not publish, load, or save it. | +| Archive only | `--output=image.oci.tar`, without `--push` | Writes an OCI image layout tar archive. `--repos` and `--tags` are optional, but if either is set, both are required. | +| Publish | `--push`, `--repos`, and `--tags` | Pushes the image or multi-platform index to the specified registries. | +| Load locally | `--target=local`, `--push`, `--repos`, and `--tags` | Builds for the host architecture and loads the image into the local Docker daemon under the specified image references. | + +`--output` can also be combined with `--push` to save an archive and publish +or load the same image. The archive is written before publishing or loading. +Without `--push`, `--target=local` only selects the host architecture; it does +not load the image into Docker. + +Archives contain an [OCI image layout](https://github.com/opencontainers/image-spec/blob/main/image-layout.md), +with an `oci-layout` file, an `index.json`, and content-addressed blobs. + +A single selected platform is stored as an image; multiple selected platforms +are stored as an image index. For format details, see the OCI +[image manifest](https://github.com/opencontainers/image-spec/blob/main/manifest.md) +and [image index](https://github.com/opencontainers/image-spec/blob/main/image-index.md) +specifications. Archives do not include tags from `--repos` or `--tags`. +Archive-only builds still need access to the base image registry, but do not +write to a registry or need a Docker daemon. + +For example, to save an archive without publishing: + +```bash +mkctr \ + --base="alpine:latest" \ + --gopaths="./cmd/server:/usr/local/bin/server" \ + --output="server.oci.tar" +``` + +To save the same archive and publish it, add `--push`, +`--repos="example.com/my/server"`, and `--tags="latest"`. + +### Container configuration + By default the container runs as the base image's user (for most base images, root). Use `--user` to set the `User` in the image config, e.g. `--user=1000:1000` to run as UID 1000, GID 1000. Note that the image's @@ -31,6 +75,5 @@ paths (such as `/tmp`) or volumes mounted at runtime. `mkctr` auto discovers `GOOS`/`GOARCH` from the specified base image. If the base image supports multiple platforms, binaries are compiled for each platform as long as it's one of `linux/amd64`, `linux/386`, `linux/arm`, `linux/arm64`. Multi-arch base image must be either an [OCI image index](https://github.com/opencontainers/image-spec/blob/main/image-index.md) or [Docker manifest list](https://github.com/openshift/docker-distribution/blob/master/docs/spec/manifest-v2-2.md#manifest-list). `mkctr` produces image of the same media type as the base image and uses the media type of the base image, or of the individual image references in case of a multi-arch image, to determine the media type of the layer it builds. - ## Maturity This is under active development. While Tailscale uses it, backwards compatability is not guaranteed, and some functionality is missing. diff --git a/mkctr.go b/mkctr.go index 86a3882..ad16e53 100644 --- a/mkctr.go +++ b/mkctr.go @@ -89,6 +89,7 @@ type buildParams struct { staticFiles map[string]string imageRefs []name.Tag publish bool + output string // If non-empty, the OCI image archive output path ldflags string gotags string goarch []string @@ -111,6 +112,7 @@ func main() { ldflagsArg = flag.String("ldflags", "", "the --ldflags value to pass to go") gotags = flag.String("gotags", "", "the --tags value to pass to go") push = flag.Bool("push", false, "publish the image") + output = flag.String("output", "", "write an OCI image archive to this path (does not require --push)") target = flag.String("target", "", `build for a specific env (options: "", "flyio", "local")`) goarch = flag.String("goarch", "arm,arm64,amd64,386", "comma-separated list of architectures to build (if supported by --base image)") verbose = flag.Bool("v", false, "verbose build output") @@ -122,11 +124,15 @@ func main() { user = flag.String("user", "", `user to run the container as, in "uid" or "uid:gid" form; sets the image config User. If unset, the base image's user (often root) is retained`) ) flag.Parse() - if *tagArg == "" { - log.Fatal("--tags must be set") - } - if *repos == "" { - log.Fatal("--repos must be set") + // Only archive-only builds without image references can omit --repos and --tags. + requireImageRefs := *push || *output == "" || *repos != "" || *tagArg != "" + if requireImageRefs { + if *tagArg == "" { + log.Fatal("--tags must be set") + } + if *repos == "" { + log.Fatal("--repos must be set") + } } if *baseImage == "" { log.Fatal("--base must be set") @@ -136,9 +142,13 @@ func main() { default: log.Fatalf("unsupported target %q", *target) } - refs, err := parseRepos(strings.Split(*repos, ","), strings.Split(*tagArg, ",")) - if err != nil { - log.Fatal(err) + var refs []name.Tag + if *repos != "" { + var err error + refs, err = parseRepos(strings.Split(*repos, ","), strings.Split(*tagArg, ",")) + if err != nil { + log.Fatal(err) + } } paths, err := parseFiles(*gopaths) if err != nil { @@ -167,6 +177,7 @@ func main() { staticFiles: staticFiles, imageRefs: refs, publish: *push, + output: *output, ldflags: *ldflagsArg, gotags: *gotags, target: *target, @@ -274,13 +285,17 @@ func fetchAndBuild(bp *buildParams) error { if err != nil { return err } + img = mutate.Annotations(img, bp.annotations).(v1.Image) // OCI annotations + if bp.output != "" { + if err := writeImageArchive(bp.output, img, p); err != nil { + return err + } + } if !bp.publish { logf("not pushing") return nil } - img = mutate.Annotations(img, bp.annotations).(v1.Image) // OCI annotations - for _, r := range bp.imageRefs { if bp.target == "local" { if err := loadLocalImage(logf, r, img); err != nil { @@ -382,6 +397,9 @@ func fetchAndBuild(bp *buildParams) error { } switch len(adds) { case 0: + if bp.output != "" { + return fmt.Errorf("no images for requested architectures %q", bp.goarch) + } logf("no images") return nil case 1: @@ -392,6 +410,11 @@ func fetchAndBuild(bp *buildParams) error { return err } logf("image digest: %v", d) + if bp.output != "" { + if err := writeImageArchive(bp.output, img, *adds[0].Platform); err != nil { + return err + } + } if !bp.publish { logf("not pushing") return nil @@ -418,15 +441,20 @@ func fetchAndBuild(bp *buildParams) error { // at this point the base was either a Dokcer manifest list or an OCI // image index- make sure the new manifest of that type. idx := mutate.AppendManifests(mutate.IndexMediaType(empty.Index, baseDesc.MediaType), adds...) - d, err := idx.Digest() - if err != nil { - return err - } // Add any provided OCI annotations to the image index. idx = mutate.Annotations(idx, bp.annotations).(v1.ImageIndex) + d, err := idx.Digest() + if err != nil { + return err + } logf("index digest: %v", d) + if bp.output != "" { + if err := writeIndexArchive(bp.output, idx); err != nil { + return err + } + } if !bp.publish { logf("not pushing") return nil diff --git a/output.go b/output.go new file mode 100644 index 0000000..20b0f8d --- /dev/null +++ b/output.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "archive/tar" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" +) + +func writeImageArchive(path string, img v1.Image, platform v1.Platform) error { + return writeArchive(path, func(lp layout.Path) error { + return lp.AppendImage(img, layout.WithPlatform(platform)) + }) +} + +func writeIndexArchive(path string, idx v1.ImageIndex) error { + return writeArchive(path, func(lp layout.Path) error { + return lp.AppendIndex(idx) + }) +} + +// writeArchive writes an OCI image layout as a tar archive. The output path +// is replaced only after the archive is complete. +func writeArchive(path string, appendContent func(layout.Path) error) error { + dir, err := os.MkdirTemp("", "mkctr-output-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + lp, err := layout.Write(dir, empty.Index) + if err != nil { + return err + } + if err := appendContent(lp); err != nil { + return fmt.Errorf("writing OCI layout: %w", err) + } + out, err := os.CreateTemp(filepath.Dir(path), ".mkctr-output-*") + if err != nil { + return err + } + defer os.Remove(out.Name()) + tw := tar.NewWriter(out) + // Use fixed tar metadata for reproducible archives. AddFS would copy + // timestamps, permissions, and ownership from the host filesystem. + err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{ + Name: filepath.ToSlash(rel), + Mode: 0644, + Size: info.Size(), + }); err != nil { + return err + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + _, err = io.Copy(tw, in) + return err + }) + if closeErr := tw.Close(); err == nil { + err = closeErr + } + if closeErr := out.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(out.Name(), path) +} diff --git a/output_test.go b/output_test.go new file mode 100644 index 0000000..867c430 --- /dev/null +++ b/output_test.go @@ -0,0 +1,253 @@ +// Copyright (c) 2026 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "archive/tar" + "bytes" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +func TestArchiveBuild(t *testing.T) { + // Use an empty Docker config so host credential helpers are not called. + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("DOCKER_CONFIG", configDir) + + for _, mode := range []string{"image", "index", "variants", "publish"} { + t.Run(mode, func(t *testing.T) { + var logs bytes.Buffer + oldOutput := log.Writer() + log.SetOutput(&logs) + defer log.SetOutput(oldOutput) + var writes atomic.Int32 + handler := registry.New() + reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" && r.Method != "HEAD" && strings.HasPrefix(r.URL.Path, "/v2/built/") { + writes.Add(1) + } + handler.ServeHTTP(w, r) + })) + defer reg.Close() + repo := strings.TrimPrefix(reg.URL, "http://") + base, err := name.NewTag(repo+"/base:latest", name.Insecure) + if err != nil { + t.Fatal(err) + } + arch := "amd64" + platforms := []v1.Platform{{ + OS: "linux", + Architecture: arch, + }} + if mode == "variants" { + arch = "arm" + platforms = []v1.Platform{ + {OS: "linux", Architecture: arch, Variant: "v6"}, + {OS: "linux", Architecture: arch, Variant: "v7"}, + } + } + var adds []mutate.IndexAddendum + for _, p := range platforms { + img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{ + OS: p.OS, + Architecture: p.Architecture, + Variant: p.Variant, + }) + if err != nil { + t.Fatal(err) + } + adds = append(adds, mutate.IndexAddendum{ + Add: img, + Descriptor: v1.Descriptor{ + Platform: &p, + }, + }) + } + if mode == "image" { + err = remote.Write(base, adds[0].Add.(v1.Image)) + } else { + err = remote.WriteIndex(base, mutate.AppendManifests(empty.Index, adds...)) + } + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + staticFile := filepath.Join(dir, "file") + if err := os.WriteFile(staticFile, []byte("test content"), 0644); err != nil { + t.Fatal(err) + } + bp := &buildParams{ + baseImage: base.String(), + staticFiles: map[string]string{staticFile: "/srv/file"}, + goarch: []string{arch}, + output: filepath.Join(dir, "image.oci.tar"), + annotations: map[string]string{"test.annotation": "test value"}, + } + built, err := name.NewTag(repo+"/built:test", name.Insecure) + if err != nil { + t.Fatal(err) + } + if mode == "publish" { + bp.publish = true + bp.imageRefs = []name.Tag{built} + } + if err := fetchAndBuild(bp); err != nil { + t.Fatal(err) + } + lp := readArchive(t, bp.output) + idx, err := lp.ImageIndex() + if err != nil { + t.Fatal(err) + } + manifest, err := idx.IndexManifest() + if err != nil || len(manifest.Manifests) != 1 { + t.Fatalf("archive index = %+v, %v", manifest, err) + } + root := manifest.Manifests[0] + if mode == "variants" { + idx, err = idx.ImageIndex(root.Digest) + if err != nil { + t.Fatal(err) + } + manifest, err = idx.IndexManifest() + if err != nil || len(manifest.Manifests) != 2 { + t.Fatalf("variant index = %+v, %v", manifest, err) + } + if manifest.Annotations["test.annotation"] != "test value" { + t.Fatalf("index annotations = %+v", manifest.Annotations) + } + if !strings.Contains(logs.String(), "index digest: "+root.Digest.String()) { + t.Fatalf("logged digest does not match archived index %v:\n%s", root.Digest, logs.String()) + } + } + for i, desc := range manifest.Manifests { + if desc.Platform == nil || !reflect.DeepEqual(*desc.Platform, platforms[i]) { + t.Fatalf("platform = %+v; want %+v", desc.Platform, platforms[i]) + } + img, err := idx.Image(desc.Digest) + if err != nil { + t.Fatal(err) + } + im, err := img.Manifest() + if err != nil || im.Annotations["test.annotation"] != "test value" { + t.Fatalf("image annotations = %+v, %v", im, err) + } + layers, err := img.Layers() + if err != nil || len(layers) != 1 { + t.Fatalf("layers = %v, %v", layers, err) + } + r, err := layers[0].Uncompressed() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(r) + r.Close() + if err != nil || !bytes.Contains(data, []byte("test content")) { + t.Fatalf("missing file content in layer: %v", err) + } + } + if mode == "publish" { + desc, err := remote.Get(built) + if err != nil || desc.Digest != root.Digest { + t.Fatalf("published image does not match archive: %v", err) + } + if writes.Load() == 0 { + t.Fatal("no registry writes with --push") + } + } else if writes.Load() != 0 { + t.Fatal("archive-only build wrote to the output registry") + } + bp.goarch = []string{"unsupported"} + bp.output = filepath.Join(dir, "missing.oci.tar") + if err := fetchAndBuild(bp); err == nil { + t.Fatal("build succeeded with no matching architecture") + } + if _, err := os.Stat(bp.output); !os.IsNotExist(err) { + t.Fatalf("failed build produced output: %v", err) + } + }) + } +} + +func readArchive(t *testing.T, path string) layout.Path { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + dir := t.TempDir() + tr := tar.NewReader(f) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if !filepath.IsLocal(h.Name) || h.Typeflag != tar.TypeReg { + t.Fatalf("unexpected archive entry: %+v", h) + } + path := filepath.Join(dir, h.Name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + } + lp, err := layout.FromPath(dir) + if err != nil { + t.Fatal(err) + } + return lp +} + +func TestArchiveFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "existing.oci.tar") + const original = "original content" + if err := os.WriteFile(path, []byte(original), 0644); err != nil { + t.Fatal(err) + } + wantErr := errors.New("failed to write image") + err := writeArchive(path, func(layout.Path) error { + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v; want %v", err, wantErr) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != original { + t.Fatalf("failed export changed existing file: %q, %v", got, err) + } + if err := writeIndexArchive(filepath.Join(path, "invalid.oci.tar"), empty.Index); err == nil { + t.Fatal("write succeeded with invalid output path") + } +}