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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,53 @@ mkctr \
[--target=<target>] \ # e.g. flyio, local
[--user=1000:1000] \ # user (uid[:gid]) to run the container as
[--push] \
[--output=image.oci.tar] \
Comment thread
tomhjp marked this conversation as resolved.
[--] [<cmd>...]
```

### 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
Expand All @@ -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.
56 changes: 42 additions & 14 deletions mkctr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -167,6 +177,7 @@ func main() {
staticFiles: staticFiles,
imageRefs: refs,
publish: *push,
output: *output,
ldflags: *ldflagsArg,
gotags: *gotags,
target: *target,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
95 changes: 95 additions & 0 deletions output.go
Original file line number Diff line number Diff line change
@@ -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
})
Comment thread
tomhjp marked this conversation as resolved.
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)
}
Loading
Loading