diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e00ce195..1e6532e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,10 @@ name: CI # on runner.environment == 'self-hosted', so parking `e2e` back on macos-15 # degrades its VM-boot steps to no-ops there -- `unit` needs no self-hosted # capability at all, so it keeps running the suite either way. +# +# The Linux port is the exception to all of the above. `unit-linux` runs the +# root module's tests natively on a Linux runner, which is the only place the +# linux-tagged files in cmd/hull are tested. on: push: @@ -150,13 +154,21 @@ jobs: # Everything above type-checks for darwin, and the unit job runs on a # Mac, so a cmd/hull file that uses a darwin-only symbol without the # build tag would break the Linux build with every check still green. - # The recipe sets GOOS=linux itself. The vet step overrides the job's - # GOOS inline so it also type-checks the test files for Linux. + # The recipe sets GOOS=linux itself. - name: Build the Linux CLI run: make linux - - name: Vet for Linux - run: GOOS=linux GOARCH=amd64 go vet ./... + # The golangci-lint step above lints for darwin, the job's GOOS, where + # the linux-tagged files in cmd/hull do not build. This lints them for + # Linux, test files included. Its govet runs every analyzer `go vet` + # does, so there is no separate vet step. + - name: golangci-lint (linux) + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 + env: + GOOS: linux + GOARCH: amd64 + with: + version: v2.12.2 telemetry: name: telemetry module (linux) @@ -198,6 +210,75 @@ jobs: version: v2.12.2 working-directory: pkg/telemetry + unit-linux: + name: unit tests (linux) + # `unit` runs the Go suite on a Mac, where the linux-tagged files in + # cmd/hull do not build, and `lint` only builds and lints for Linux. + # This runs the root module's suite natively on Linux, under the race + # detector: the internal/ and pkg/ packages, the untagged files in + # cmd/hull, and the linux-tagged ones. Like `unit`, it is cheap and runs + # on every diff. + runs-on: ubuntu-latest + env: + # A test that needs a containerd daemon fails without one, instead of + # skipping, so this job cannot pass without having run them. + HULL_TEST_REQUIRE_CONTAINERD: "1" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + + # On Linux hull is a containerd client, and some of its tests talk to a + # daemon. Run the release the brig-standalone-linux bundle ships, as + # root, on a socket of its own that this job's user owns: the tests need + # no sudo, and the runner's own containerd, which Docker uses, is never + # touched. The checksum is the one containerd publishes beside the + # tarball. + - name: Start containerd + env: + CONTAINERD_VERSION: 2.3.5 + CONTAINERD_SHA256: 2f0a095a71e3262d0d91ff0e50e2e4ae73c3866c4d1ff6a15f341097fba3dd44 + run: | + set -euo pipefail + dir="$RUNNER_TEMP/containerd" + mkdir -p "$dir" + curl -fsSL -o "$dir/containerd.tar.gz" \ + "https://github.com/containerd/containerd/releases/download/v${CONTAINERD_VERSION}/containerd-${CONTAINERD_VERSION}-linux-amd64.tar.gz" + echo "${CONTAINERD_SHA256} $dir/containerd.tar.gz" | sha256sum -c - + tar -C "$dir" -xzf "$dir/containerd.tar.gz" + cat > "$dir/config.toml" < "$dir/containerd.log" 2>&1 & + for _ in $(seq 100); do + [ -S "$dir/containerd.sock" ] && break + sleep 0.1 + done + "$dir/bin/ctr" --address "$dir/containerd.sock" version + echo "HULL_TEST_CONTAINERD_ADDRESS=$dir/containerd.sock" >> "$GITHUB_ENV" + + - name: Unit tests + run: go test -count=1 -race ./... + + - name: containerd log + if: failure() + run: cat "$RUNNER_TEMP/containerd/containerd.log" || true + build: name: build (go + swift, ad-hoc signed) # Fork pull requests run on a GitHub-hosted macOS runner; pushes and diff --git a/cmd/hull/imagecache_test.go b/cmd/hull/imagecache_test.go index 1f789035..f9d6e0bb 100644 --- a/cmd/hull/imagecache_test.go +++ b/cmd/hull/imagecache_test.go @@ -28,11 +28,6 @@ import ( "github.com/brig-sh/hull/pkg/store" ) -const ( - cacheTestRef = "ghcr.io/nofireai/urunc-ubuntu:aarch64" - cacheTestDigest = "sha256:c408baae42f5c74c0661fbc20a289fd23d4322988e52c88cd54108e5c4c74893" -) - // seedImage writes the default test image into the store. withRootfs=false // reproduces what an interrupted pull leaves behind: metadata present, rootfs // gone. @@ -317,11 +312,6 @@ func TestCachedDigestHonorsPlatform(t *testing.T) { } } -const ( - cacheTestRepo = "ghcr.io/nofireai/urunc-ubuntu" - cacheTestIndexDigest = "sha256:3333333333333333333333333333333333333333333333333333333333333333" -) - // seedMetadata writes one image record into the store, with the rootfs and the // layout stamp a good pull leaves behind unless withRootfs says otherwise. func seedMetadata(t *testing.T, s *store.Store, metadata *store.ImageMetadata, withRootfs bool) { diff --git a/cmd/hull/imagelist.go b/cmd/hull/imagelist.go new file mode 100644 index 00000000..587af592 --- /dev/null +++ b/cmd/hull/imagelist.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/brig-sh/hull/pkg/store" +) + +// writeImageJSON prints the stored records as they are, one per image, so a +// reference that resolved to two platforms shows both. +// +// The table cuts the manifest digest short and never shows the index digest, +// and the index digest is the one a caller comparing against a registry +// needs: a multi-arch tag resolves to it, while the store is keyed by the +// per-platform manifest digest, a different object. Here every digest is +// whole. indexDigest and platform are left out of a record that has none +// rather than printed empty, so "single-arch" and "pulled before it was +// recorded" read differently from a record that says so. +// +// Labels come out of the image config, so this goes through printJSON, which +// keeps a C1 control in one from reaching the terminal. +func writeImageJSON(out io.Writer, images []*store.ImageMetadata) error { + if images == nil { + // A machine reading the listing gets a list either way. + images = []*store.ImageMetadata{} + } + return printJSON(out, images) +} + +// writeImageTable prints one row per stored image. +// +// The repository and tag come from the store's own split of the reference the +// image was pulled under. Scanning the reference backwards for a colon, as +// this used to, landed on the one inside `@sha256:` for an image pulled by +// digest, and printed a repository ending in `@sha256` with the hex as its +// tag. Such an image has no tag, and says so, the way docker does. +func writeImageTable(out io.Writer, images []*store.ImageMetadata, now time.Time) error { + w := tabwriter.NewWriter(out, 4, 8, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "REPOSITORY\tTAG\tDIGEST\tSIZE\tCREATED") + + for _, img := range images { + ref := store.ParseReference(img.Ref) + tag := ref.Tag + switch { + case ref.Digest != "": + tag = "" + case tag == "": + tag = "latest" + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + ref.Repository, + tag, + shortDigest(img.Digest), + formatSize(img.Size), + formatAge(now.Sub(img.PulledAt)), + ) + } + + return w.Flush() +} + +// shortDigest is how a digest is named where the whole thing would be noise: +// the algorithm and the first twelve hex characters. +// +// It is also what `hull rmi` accepts as a prefix, and that is not a +// coincidence -- this listing is where a user gets the digest they then paste +// into a command. +func shortDigest(digest string) string { + const shortLen = len("sha256:") + 12 + if len(digest) <= shortLen { + return digest + } + return digest[:shortLen] +} + +func formatAge(age time.Duration) string { + switch { + case age < time.Minute: + return "just now" + case age < time.Hour: + return fmt.Sprintf("%dm ago", int(age.Minutes())) + case age < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(age.Hours())) + default: + return fmt.Sprintf("%dd ago", int(age.Hours()/24)) + } +} + +func formatSize(bytes int64) string { + units := []string{"B", "KB", "MB", "GB"} + size := float64(bytes) + for _, unit := range units { + if size < 1024.0 { + return fmt.Sprintf("%.1f %s", size, unit) + } + size /= 1024.0 + } + return fmt.Sprintf("%.1f TB", size) +} diff --git a/cmd/hull/images.go b/cmd/hull/images.go index b53692c4..b6518613 100644 --- a/cmd/hull/images.go +++ b/cmd/hull/images.go @@ -19,12 +19,9 @@ package main import ( "context" "fmt" - "io" "os" - "text/tabwriter" "time" - "github.com/brig-sh/hull/pkg/store" "github.com/urfave/cli/v3" ) @@ -66,96 +63,3 @@ func listImages(ctx context.Context, cmd *cli.Command) error { return writeImageTable(os.Stdout, images, time.Now()) } - -// writeImageJSON prints the stored records as they are, one per image, so a -// reference that resolved to two platforms shows both. -// -// The table cuts the manifest digest short and never shows the index digest, -// and the index digest is the one a caller comparing against a registry -// needs: a multi-arch tag resolves to it, while the store is keyed by the -// per-platform manifest digest, a different object. Here every digest is -// whole. indexDigest and platform are left out of a record that has none -// rather than printed empty, so "single-arch" and "pulled before it was -// recorded" read differently from a record that says so. -// -// Labels come out of the image config, so this goes through printJSON, which -// keeps a C1 control in one from reaching the terminal. -func writeImageJSON(out io.Writer, images []*store.ImageMetadata) error { - if images == nil { - // A machine reading the listing gets a list either way. - images = []*store.ImageMetadata{} - } - return printJSON(out, images) -} - -// writeImageTable prints one row per stored image. -// -// The repository and tag come from the store's own split of the reference the -// image was pulled under. Scanning the reference backwards for a colon, as -// this used to, landed on the one inside `@sha256:` for an image pulled by -// digest, and printed a repository ending in `@sha256` with the hex as its -// tag. Such an image has no tag, and says so, the way docker does. -func writeImageTable(out io.Writer, images []*store.ImageMetadata, now time.Time) error { - w := tabwriter.NewWriter(out, 4, 8, 2, ' ', 0) - _, _ = fmt.Fprintln(w, "REPOSITORY\tTAG\tDIGEST\tSIZE\tCREATED") - - for _, img := range images { - ref := store.ParseReference(img.Ref) - tag := ref.Tag - switch { - case ref.Digest != "": - tag = "" - case tag == "": - tag = "latest" - } - - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - ref.Repository, - tag, - shortDigest(img.Digest), - formatSize(img.Size), - formatAge(now.Sub(img.PulledAt)), - ) - } - - return w.Flush() -} - -// shortDigest is how a digest is named where the whole thing would be noise: -// the algorithm and the first twelve hex characters. -// -// It is also what `hull rmi` accepts as a prefix, and that is not a -// coincidence -- this listing is where a user gets the digest they then paste -// into a command. -func shortDigest(digest string) string { - const shortLen = len("sha256:") + 12 - if len(digest) <= shortLen { - return digest - } - return digest[:shortLen] -} - -func formatAge(age time.Duration) string { - switch { - case age < time.Minute: - return "just now" - case age < time.Hour: - return fmt.Sprintf("%dm ago", int(age.Minutes())) - case age < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(age.Hours())) - default: - return fmt.Sprintf("%dd ago", int(age.Hours()/24)) - } -} - -func formatSize(bytes int64) string { - units := []string{"B", "KB", "MB", "GB"} - size := float64(bytes) - for _, unit := range units { - if size < 1024.0 { - return fmt.Sprintf("%.1f %s", size, unit) - } - size /= 1024.0 - } - return fmt.Sprintf("%.1f TB", size) -} diff --git a/cmd/hull/images_linux.go b/cmd/hull/images_linux.go new file mode 100644 index 00000000..85cdf9d7 --- /dev/null +++ b/cmd/hull/images_linux.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package main + +import ( + "context" + "fmt" + "time" + + "github.com/brig-sh/hull/internal/containerd" + "github.com/brig-sh/hull/pkg/store" + "github.com/containerd/platforms" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +func imagesCommand() *cli.Command { + return &cli.Command{ + Name: "images", + Usage: "list pulled images", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "print the image records as JSON, with full digests", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return listImages(ctx, cmd) + }, + } +} + +// listImages prints the images in hull's containerd namespace, in the +// same table and JSON form as on macOS. +func listImages(ctx context.Context, cmd *cli.Command) error { + c, err := connectContainerd(ctx, cmd) + if err != nil { + return err + } + defer func() { _ = c.Close() }() + + imgs, err := c.ImageService().List(ctx) + if err != nil { + return fmt.Errorf("list images: %w", err) + } + // An image whose content cannot be read is still listed, under its name + // and the digest DescribeImage returns with the error, rather than + // dropped from the listing. + records := make([]*store.ImageMetadata, 0, len(imgs)) + for _, img := range imgs { + described, err := containerd.DescribeImage(ctx, c.ContentStore(), img, platforms.Default()) + if err != nil { + logrus.Warnf("image %s: %v", img.Name, err) + } + records = append(records, imageRecord(described)) + } + + if cmd.Bool("json") { + return writeImageJSON(cmd.Writer, records) + } + if len(records) == 0 { + _, err = fmt.Fprintln(cmd.Writer, "No images found") + return err + } + return writeImageTable(cmd.Writer, records, time.Now()) +} + +// imageRecord turns a containerd image into the record the macOS store keeps, +// so both listings print the same fields with the same meaning. PulledAt is +// when the name was last pointed at its image, the nearest containerd has to +// a pull time. +func imageRecord(img containerd.Image) *store.ImageMetadata { + return &store.ImageMetadata{ + Ref: img.Name, + Digest: img.Digest, + IndexDigest: img.IndexDigest, + Platform: img.Platform, + Size: img.Size, + Labels: img.Labels, + PulledAt: img.UpdatedAt, + } +} diff --git a/cmd/hull/images_linux_test.go b/cmd/hull/images_linux_test.go new file mode 100644 index 00000000..41c78468 --- /dev/null +++ b/cmd/hull/images_linux_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package main + +import ( + "bytes" + "context" + "encoding/json" + "regexp" + "testing" + + "github.com/brig-sh/hull/internal/containerd" + "github.com/brig-sh/hull/internal/containerd/containerdtest" + "github.com/brig-sh/hull/pkg/store" + "github.com/containerd/platforms" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// hull images through the command tree a user runs, against a live daemon: +// the global flags, connectContainerd, the listing, and the printed JSON +// and table. The image is written for the host's platform, the one +// listImages prefers, so the expected output holds on any host. +func TestImagesCommandAgainstDaemon(t *testing.T) { + addr := containerdtest.DaemonAddress(t) + const ns = "hull-test-cli" + c, err := containerd.Connect(context.Background(), addr, ns) + if err != nil { + t.Fatal(err) + } + // Closed by a cleanup, not a defer: the cleanups that delete the test's + // image and lease run after the test returns and still need the client. + t.Cleanup(func() { _ = c.Close() }) + ctx := containerdtest.Lease(context.Background(), t, c) + cs := c.ContentStore() + + host := platforms.DefaultSpec() + manifest := containerdtest.WriteImage(ctx, t, cs, host, map[string]string{"org.example": "yes"}, true) + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{manifest}, + }) + const name = "example.com/hull/cli:1" + containerdtest.CreateImage(ctx, t, c, name, index) + + run := func(namespace string, args ...string) string { + t.Helper() + var out bytes.Buffer + app := newApp() + app.Writer = &out + argv := append([]string{"hull", "--containerd-address", addr, "--namespace", namespace}, args...) + err := app.Run(context.Background(), argv) + if err != nil { + t.Fatalf("hull %v: %v", args, err) + } + return out.String() + } + + var recs []store.ImageMetadata + err = json.Unmarshal([]byte(run(ns, "images", "--json")), &recs) + if err != nil { + t.Fatal(err) + } + if len(recs) != 1 { + t.Fatalf("images --json listed %d records, want 1: %+v", len(recs), recs) + } + rec := recs[0] + if rec.Ref != name || rec.Digest != manifest.Digest.String() || rec.IndexDigest != index.Digest.String() { + t.Errorf("record = %+v; want %s at %s through index %s", rec, name, manifest.Digest, index.Digest) + } + if rec.Platform != platforms.Format(host) || rec.Size != 1024 || rec.Labels["org.example"] != "yes" { + t.Errorf("record = %+v; want %s, 1024 bytes, label org.example=yes", rec, platforms.Format(host)) + } + + table := run(ns, "images") + row := regexp.MustCompile(`(?m)^example\.com/hull/cli +1 +` + regexp.QuoteMeta(manifest.Digest.String()[:19]) + ` +1\.0 KB +`) + if !row.MatchString(table) { + t.Errorf("table has no row for %s:\n%s", name, table) + } + + got := run(ns+"-empty", "images") + if got != "No images found\n" { + t.Errorf("another namespace printed %q, want No images found", got) + } +} diff --git a/cmd/hull/images_test.go b/cmd/hull/images_test.go index 9912c9b5..856faf30 100644 --- a/cmd/hull/images_test.go +++ b/cmd/hull/images_test.go @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build darwin - package main import ( @@ -26,6 +24,16 @@ import ( "github.com/brig-sh/hull/pkg/store" ) +// The test image the image-store tests share. The tests that use them run on +// every OS, so the constants live here rather than beside the darwin-only +// cache tests. +const ( + cacheTestRef = "ghcr.io/nofireai/urunc-ubuntu:aarch64" + cacheTestRepo = "ghcr.io/nofireai/urunc-ubuntu" + cacheTestDigest = "sha256:c408baae42f5c74c0661fbc20a289fd23d4322988e52c88cd54108e5c4c74893" + cacheTestIndexDigest = "sha256:3333333333333333333333333333333333333333333333333333333333333333" +) + // The JSON form from #14: what the store knows, with nothing cut. The table // truncates the manifest digest and never shows the index digest, which is // the one a caller comparing against a registry needs. diff --git a/cmd/hull/jsonout.go b/cmd/hull/jsonout.go index 183bdbc3..69e10393 100644 --- a/cmd/hull/jsonout.go +++ b/cmd/hull/jsonout.go @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build darwin - package main import ( diff --git a/cmd/hull/jsonout_test.go b/cmd/hull/jsonout_test.go index e3260495..ee003560 100644 --- a/cmd/hull/jsonout_test.go +++ b/cmd/hull/jsonout_test.go @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build darwin - package main import ( diff --git a/cmd/hull/main_linux.go b/cmd/hull/main_linux.go index 927291f3..a4af5d96 100644 --- a/cmd/hull/main_linux.go +++ b/cmd/hull/main_linux.go @@ -16,10 +16,75 @@ package main -import "fmt" +import ( + "context" + "fmt" + "os" + + "github.com/brig-sh/hull/internal/buildinfo" + "github.com/brig-sh/hull/internal/containerd" + "github.com/containerd/containerd/v2/client" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +// On Linux, hull is a containerd client: images, containers and tasks live in +// containerd, in the namespace --namespace names, not in a store directory as +// on macOS. Only the verbs that work on Linux are registered. + +var build = buildinfo.Read() -// The Linux port starts here. Nothing in the darwin CLI is wired up yet; this -// entry point only proves the command builds and runs on a Linux host. func main() { - fmt.Println("hello from hull on linux") + err := newApp().Run(context.Background(), os.Args) + if err != nil { + fatal(err) + } +} + +// newApp builds the command tree. It is separate from main so tests can run +// the commands as a user would, flags included. +func newApp() *cli.Command { + return &cli.Command{ + Name: "hull", + Usage: "run OCI images as VMs through containerd and urunc", + Version: build.String(), + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: "enable debug logging", + }, + &cli.StringFlag{ + Name: "namespace", + Usage: "containerd namespace for hull's images and instances; another namespace sees none of them " + + "(default: $" + containerd.NamespaceEnv + ", else " + containerd.DefaultNamespace + ")", + }, + &cli.StringFlag{ + Name: "containerd-address", + Usage: "containerd socket (default: $" + containerd.AddressEnv + + ", else the system socket for root and $XDG_RUNTIME_DIR/containerd/containerd.sock for other users)", + }, + }, + Before: func(_ context.Context, cmd *cli.Command) (context.Context, error) { + if cmd.Bool("debug") { + logrus.SetLevel(logrus.DebugLevel) + } + return nil, nil + }, + Commands: []*cli.Command{ + imagesCommand(), + }, + } +} + +// connectContainerd opens a client to the containerd daemon the global flags +// name, scoped to hull's namespace. The caller closes it. +func connectContainerd(ctx context.Context, cmd *cli.Command) (*client.Client, error) { + address := containerd.ResolveAddress(cmd.String("containerd-address")) + namespace := containerd.ResolveNamespace(cmd.String("namespace")) + return containerd.Connect(ctx, address, namespace) +} + +func fatal(err error) { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) } diff --git a/docs/build.md b/docs/build.md index 1dff54f5..a7365222 100644 --- a/docs/build.md +++ b/docs/build.md @@ -14,7 +14,7 @@ two runners must be signed before they can start a VM. ## Prerequisites -- An Apple Silicon Mac. Linux builds only a stub of the CLI so far; see +- An Apple Silicon Mac. On Linux the CLI has only `hull images` so far; see [Building on Linux](#building-on-linux). - **Go** 1.26 or later. `go.mod` declares 1.26.4. - **Xcode command line tools**, which supply Swift. `vz-runner` declares @@ -100,15 +100,39 @@ other work, cap them rather than letting each build assume it owns the box. ### Building on Linux -A Linux port has started, and it does not run anything yet. `make linux`, or -plain `make` on a Linux host, builds a stub `hull` that prints a greeting and -exits. It builds no runners and signs nothing. +A Linux port has started, and it cannot run anything yet. `make linux`, or +plain `make` on a Linux host, builds a `hull` whose only command is +`hull images`. It builds no runners and signs nothing. ```bash make # dist/hull_linux_ make linux LINUX_ARCH=amd64 # cross-build for another architecture ``` +On Linux, hull is a containerd client, and images live in containerd. +`hull images` lists them in the same table and JSON form as on macOS. It +needs a running containerd, which hull does not install or start. hull looks +for its socket in this order: + +1. `--containerd-address` +2. `$HULL_CONTAINERD_ADDRESS` +3. `/run/containerd/containerd.sock` when run as root, and + `$XDG_RUNTIME_DIR/containerd/containerd.sock`, where a rootless containerd + puts it, for any other user + +There is no store directory on Linux, and no `--store-dir`. Isolation comes +from the containerd namespace instead: `--namespace`, then +`$HULL_NAMESPACE`, then `hull`. Two namespaces see none of each other's +images, and `ctr -n hull images ls` shows what hull sees. + +containerd keeps one record per image name, and it may hold several +platforms of that image. The listing describes one: the host's platform if +all its layers are present, otherwise a platform whose layers are. A pull of +another platform therefore lists as that platform, even though `ctr` also +fetches the host platform's manifest along the way. Entries of an index that +are not images, such as build attestations and signatures, are never listed +as the image. + `make test` runs on Linux too. The `internal/` and `pkg/` packages build and test natively. Of `cmd/hull`, only the files with no darwin dependency compile there; the rest carry `//go:build darwin`. diff --git a/go.mod b/go.mod index 43fba3f8..35648cd3 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,17 @@ require ( github.com/brig-sh/hull/pkg/telemetry v0.0.0-00010101000000-000000000000 github.com/compose-spec/compose-go/v2 v2.14.0 github.com/containerd/containerd v1.7.36 + github.com/containerd/containerd/v2 v2.3.5 + github.com/containerd/errdefs v1.0.0 + github.com/containerd/platforms v1.0.0-rc.5 github.com/containers/gvisor-tap-vsock v0.8.9 github.com/google/go-containerregistry v0.20.6 github.com/hashicorp/go-version v1.9.0 github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048 github.com/miekg/dns v1.1.72 - github.com/opencontainers/runtime-spec v1.2.1 + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.1 + github.com/opencontainers/runtime-spec v1.3.0 github.com/sirupsen/logrus v1.9.4 github.com/urfave/cli/v3 v3.10.1 github.com/urunc-dev/urunc v0.8.1-0.20261006162953-6c062f39c5bb @@ -24,19 +29,23 @@ require ( require ( cyphar.com/go-pathrs v0.2.5 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.13.0 // indirect + github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect + github.com/Microsoft/hcsshim v0.15.0-rc.1 // indirect github.com/apparentlymart/go-cidr v1.1.1 // indirect github.com/cavaliergopher/cpio v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/checkpoint-restore/go-criu/v6 v6.3.0 // indirect github.com/cilium/ebpf v0.22.0 // indirect - github.com/containerd/cgroups/v3 v3.1.0 // indirect + github.com/containerd/cgroups/v3 v3.1.3 // indirect github.com/containerd/console v1.0.5 // indirect + github.com/containerd/containerd/api v1.11.1 // indirect github.com/containerd/continuity v0.5.0 // indirect - github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/fifo v1.1.0 // indirect github.com/containerd/log v0.1.0 // indirect + github.com/containerd/plugin v1.1.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/containerd/ttrpc v1.2.9 // indirect github.com/containerd/typeurl/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cyphar/filepath-securejoin v0.7.0 // indirect @@ -47,6 +56,9 @@ require ( github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/elastic/go-seccomp-bpf v1.6.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -59,16 +71,16 @@ require ( github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moby/locker v1.0.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/signal v0.7.1 // indirect github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/mrunalp/fileutils v0.5.1 // indirect github.com/nubificus/hedge_cli v0.0.3 // indirect github.com/opencontainers/cgroups v0.0.4 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.3.6 // indirect github.com/opencontainers/selinux v1.13.1 // indirect github.com/pierrec/lz4/v4 v4.1.14 // indirect @@ -82,13 +94,18 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/crypto v0.56.0 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.41.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/grpc v1.83.2 // indirect diff --git a/go.sum b/go.sum index b0186b98..946006a2 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,23 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik= cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA= -github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= +github.com/Microsoft/hcsshim v0.15.0-rc.1 h1:FbbwtQmiD+BVHynGkx5S65JkLyhkEiiTP8nrpmg2SZw= +github.com/Microsoft/hcsshim v0.15.0-rc.1/go.mod h1:HWvvUPIy9HF6LotILj1G4VyS065rcLQ6tqj6tMUdOfI= github.com/apparentlymart/go-cidr v1.1.1 h1:oEEk8CE0HP0YpHxsegk/TaOtR2FLHdWv4p3eM4ceUwg= github.com/apparentlymart/go-cidr v1.1.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/checkpoint-restore/go-criu/v6 v6.3.0 h1:mIdrSO2cPNWQY1truPg6uHLXyKHk3Z5Odx4wjKOASzA= github.com/checkpoint-restore/go-criu/v6 v6.3.0/go.mod h1:rrRTN/uSwY2X+BPRl/gkulo9gsKOSAeVp9/K2tv7xZI= github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= @@ -22,22 +26,34 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/compose-spec/compose-go/v2 v2.14.0 h1:uaJeo5B3+OVlu+Rx2qLBcAdXPEUUzm5nQrRiGJafRAQ= github.com/compose-spec/compose-go/v2 v2.14.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= -github.com/containerd/cgroups/v3 v3.1.0 h1:azxYVj+91ZgSnIBp2eI3k9y2iYQSR/ZQIgh9vKO+HSY= -github.com/containerd/cgroups/v3 v3.1.0/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= +github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= +github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/containerd v1.7.36 h1:HyMsOG5kmp1LQsGzqwI6Ts06T4VpYZbszfDZYB0bW5w= github.com/containerd/containerd v1.7.36/go.mod h1:ozI//0TomTCLPhQREnx0IXDIQMg+Fk7yTtg9fNvU8EQ= +github.com/containerd/containerd/api v1.11.1 h1:h8nfoDW9+fNsC/9TwiAHj8B1GzXKtR4eFtkhi/X5RLU= +github.com/containerd/containerd/api v1.11.1/go.mod h1:CaQFRu+N1MtbgL6JDOJLUB1hCKESU1lD6MuTJhgtdlw= +github.com/containerd/containerd/v2 v2.3.5 h1:9MYlI81gUcOZ0WsCkSMtvOU7rTR3hqAoa2eCzhoLlkA= +github.com/containerd/containerd/v2 v2.3.5/go.mod h1:RXDyLPaI3zoO7dFdAW9/54W4cix+z3A6larieufC9mg= github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= +github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v1.0.0-rc.5 h1:vXd569rDrz8LeMXzAnBsy6LADV5YtsD8oyaRarxdmSU= +github.com/containerd/platforms v1.0.0-rc.5/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A= +github.com/containerd/plugin v1.1.0 h1:O+7lczNJVMy8rz0YNx3xGB8tTf5qY4i5abF041Ew19U= +github.com/containerd/plugin v1.1.0/go.mod h1:qBTum+A8lJ6lO44A19Eo7y1OlcLj4OWFH1DA/vnHmcc= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/containerd/ttrpc v1.2.9 h1:ha0ak962T0s3CA/RoZ6S6xiWZQF24GrBaEpiGX1uihg= +github.com/containerd/ttrpc v1.2.9/go.mod h1:jjtQRwXm4DL3KsHKW8vDiUOV6wO0hi6IPhmJhxU7aEs= github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= github.com/containers/gvisor-tap-vsock v0.8.9 h1:6b7pqxFcKJ0EycBt1V4zPo3FQtgLLgs50AYkbFIb9eU= @@ -71,6 +87,15 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erofs/go-erofs v0.3.0 h1:o/W5ABAA3sHYl97WL93dacKEfeDpJhdFf3c2snAti7I= +github.com/erofs/go-erofs v0.3.0/go.mod h1:XkSeN9MHszGd4+3gcEjadJLYHCQpWzJ7/8yznzMuzJs= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -110,6 +135,8 @@ github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -146,12 +173,16 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= +github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= @@ -168,8 +199,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runc v1.3.6 h1:SLGIymCtsk80iNPWgbc8dtjI30r+5mTVV+4dN8/17Sk= github.com/opencontainers/runc v1.3.6/go.mod h1:o1wyv76EDlTkcf0KTFgN8bMWLPvgF/HfX709lDv+rr4= -github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= -github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= @@ -180,6 +211,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= @@ -219,6 +252,20 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -265,8 +312,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -277,6 +324,8 @@ golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -304,8 +353,9 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/containerd/containerd.go b/internal/containerd/containerd.go new file mode 100644 index 00000000..8ec1d461 --- /dev/null +++ b/internal/containerd/containerd.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +// Package containerd connects hull to a containerd daemon. +// +// On Linux, hull is a containerd client: images, containers and tasks live in +// containerd, not in a store directory as on macOS. This package finds the +// daemon's socket, checks that the daemon answers, and scopes the client to +// one containerd namespace. +// +// The namespace is hull's isolation boundary on Linux, the role --store-dir +// plays on macOS. Two namespaces on one daemon see none of each other's +// images and instances, and `ctr -n ` shows what hull sees. +package containerd + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/pkg/identifiers" +) + +// AddressEnv names the environment variable that overrides the socket +// address when no address is passed explicitly. +const AddressEnv = "HULL_CONTAINERD_ADDRESS" + +// NamespaceEnv names the environment variable that overrides the namespace +// when no namespace is passed explicitly. +const NamespaceEnv = "HULL_NAMESPACE" + +// DefaultNamespace is the namespace hull uses when nothing names another. +const DefaultNamespace = "hull" + +// rootfulAddress is where a system containerd listens. It is also the right +// path for a rootless containerd when hull runs inside rootlesskit's +// namespaces: containerd-rootless.sh bind-mounts +// $XDG_RUNTIME_DIR/containerd over /run/containerd there. +const rootfulAddress = "/run/containerd/containerd.sock" + +// probeTimeout bounds the check that the daemon answers. A local daemon +// replies in milliseconds; the bound only matters when it is wedged. +const probeTimeout = 5 * time.Second + +// Overridable in tests. +var ( + geteuid = os.Geteuid + getuid = os.Getuid +) + +// ResolveAddress picks the containerd socket address: the explicit address if +// one was given (a command-line flag), then $HULL_CONTAINERD_ADDRESS, then +// the default for the current user. +// +// The default for root is the system socket, /run/containerd/containerd.sock. +// For any other user it is the socket a rootless containerd started by +// containerd-rootless.sh exposes on the host, +// $XDG_RUNTIME_DIR/containerd/containerd.sock. +func ResolveAddress(explicit string) string { + if explicit != "" { + return explicit + } + env := os.Getenv(AddressEnv) + if env != "" { + return env + } + if geteuid() == 0 { + return rootfulAddress + } + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = filepath.Join("/run/user", strconv.Itoa(getuid())) + } + return filepath.Join(runtimeDir, "containerd", "containerd.sock") +} + +// ResolveNamespace picks the containerd namespace: the explicit namespace if +// one was given (a command-line flag), then $HULL_NAMESPACE, then "hull". +func ResolveNamespace(explicit string) string { + if explicit != "" { + return explicit + } + env := os.Getenv(NamespaceEnv) + if env != "" { + return env + } + return DefaultNamespace +} + +// Connect opens a client to the containerd daemon at address, scoped to +// namespace, and checks that the daemon answers. address is the socket's +// path, with or without a unix:// scheme. The caller closes the client. +// +// The client sends the namespace on every request whose context does not +// carry one already; DefaultNamespace reports it. +func Connect(ctx context.Context, address, namespace string) (*client.Client, error) { + // Refuse a name containerd would refuse, before dialing, so a typo in + // --namespace fails the same way whether or not the daemon is up. + err := identifiers.Validate(namespace) + if err != nil { + return nil, fmt.Errorf("invalid containerd namespace: %w", err) + } + // containerd's dialer puts unix:// in front of the address itself, and an + // address that already has the scheme then fails to parse. + address = strings.TrimPrefix(address, "unix://") + // containerd's dialer retries a missing socket until the timeout, in case + // the daemon is still starting, and then reports only the timeout. Name + // the missing socket at once instead. Any other stat error, such as + // permission denied, is left for the dial to report. + _, err = os.Stat(address) + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("cannot reach containerd: no socket at %s\n"+ + "Start containerd, or point hull at its socket with --containerd-address or %s", address, AddressEnv) + } + c, err := client.New(address, client.WithDefaultNamespace(namespace)) + if err != nil { + return nil, fmt.Errorf("connect to containerd at %s: %w", address, err) + } + // client.New does not dial; the first request does. Make one now, so an + // absent or wedged daemon fails here with the address in the message, + // rather than in the middle of whatever the caller does next. + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + _, err = c.Version(probeCtx) + if err != nil { + _ = c.Close() + return nil, fmt.Errorf("cannot reach containerd at %s: %w\n"+ + "Start containerd, or point hull at its socket with --containerd-address or %s", address, err, AddressEnv) + } + return c, nil +} diff --git a/internal/containerd/containerd_test.go b/internal/containerd/containerd_test.go new file mode 100644 index 00000000..9d26de52 --- /dev/null +++ b/internal/containerd/containerd_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package containerd + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/brig-sh/hull/internal/containerd/containerdtest" + "github.com/containerd/containerd/v2/pkg/identifiers" +) + +func fakeIDs(t *testing.T, euid, uid int) { + t.Helper() + origEUID, origUID := geteuid, getuid + geteuid = func() int { return euid } + getuid = func() int { return uid } + t.Cleanup(func() { geteuid, getuid = origEUID, origUID }) +} + +func TestResolveAddress(t *testing.T) { + cases := []struct { + name string + explicit string + env string + runtimeDir string + euid int + want string + }{ + {name: "explicit wins over env", explicit: "/a.sock", env: "/b.sock", euid: 1000, want: "/a.sock"}, + {name: "env wins over default", env: "/b.sock", euid: 0, want: "/b.sock"}, + {name: "root uses the system socket", euid: 0, runtimeDir: "/run/user/0", want: "/run/containerd/containerd.sock"}, + {name: "user uses the rootless socket", euid: 1000, runtimeDir: "/run/user/1000", want: "/run/user/1000/containerd/containerd.sock"}, + {name: "user without XDG_RUNTIME_DIR", euid: 1000, want: "/run/user/1000/containerd/containerd.sock"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fakeIDs(t, tc.euid, 1000) + t.Setenv(AddressEnv, tc.env) + t.Setenv("XDG_RUNTIME_DIR", tc.runtimeDir) + got := ResolveAddress(tc.explicit) + if got != tc.want { + t.Fatalf("ResolveAddress(%q) = %q, want %q", tc.explicit, got, tc.want) + } + }) + } +} + +func TestResolveNamespace(t *testing.T) { + cases := []struct { + name string + explicit string + env string + want string + }{ + {name: "explicit wins over env", explicit: "a", env: "b", want: "a"}, + {name: "env wins over default", env: "b", want: "b"}, + {name: "default", want: "hull"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(NamespaceEnv, tc.env) + got := ResolveNamespace(tc.explicit) + if got != tc.want { + t.Fatalf("ResolveNamespace(%q) = %q, want %q", tc.explicit, got, tc.want) + } + }) + } +} + +// The default must be a namespace containerd accepts, or every request in it +// is refused. +func TestDefaultNamespaceIsValid(t *testing.T) { + err := identifiers.Validate(DefaultNamespace) + if err != nil { + t.Fatal(err) + } +} + +// A namespace containerd would refuse fails before any dial, so it fails the +// same way with no daemon behind the address. +func TestConnectRefusesAnInvalidNamespace(t *testing.T) { + addr := filepath.Join(t.TempDir(), "containerd.sock") + for _, ns := range []string{"", "has space", "-leading", "a/b"} { + c, err := Connect(context.Background(), addr, ns) + if err == nil { + _ = c.Close() + t.Fatalf("Connect accepted namespace %q", ns) + } + if !strings.Contains(err.Error(), "namespace") { + t.Errorf("namespace %q: error %q does not say the namespace is the problem", ns, err) + } + } +} + +// With no daemon behind the address, Connect must fail promptly and name the +// address and the override. +func TestConnectFailsClearlyWithNoDaemon(t *testing.T) { + addr := filepath.Join(t.TempDir(), "containerd.sock") + start := time.Now() + c, err := Connect(context.Background(), addr, DefaultNamespace) + if err == nil { + _ = c.Close() + t.Fatal("Connect succeeded with no daemon") + } + elapsed := time.Since(start) + if elapsed > probeTimeout+time.Second { + t.Errorf("Connect took %v to fail", elapsed) + } + for _, want := range []string{addr, AddressEnv} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +// TestConnectToDaemon checks that Connect reaches a live daemon and scopes the +// client to the namespace it was given. +func TestConnectToDaemon(t *testing.T) { + addr := containerdtest.DaemonAddress(t) + c, err := Connect(context.Background(), addr, "hull-test") + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.Close() }() + got := c.DefaultNamespace() + if got != "hull-test" { + t.Fatalf("client namespace = %q, want hull-test", got) + } +} + +// The same daemon is reachable with its address written as a unix:// URL. +func TestConnectToDaemonWithUnixScheme(t *testing.T) { + addr := containerdtest.DaemonAddress(t) + c, err := Connect(context.Background(), "unix://"+addr, "hull-test") + if err != nil { + t.Fatal(err) + } + _ = c.Close() +} diff --git a/internal/containerd/containerdtest/containerdtest.go b/internal/containerd/containerdtest/containerdtest.go new file mode 100644 index 00000000..e9eaafaf --- /dev/null +++ b/internal/containerd/containerdtest/containerdtest.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +// Package containerdtest holds helpers for tests that run against +// containerd, for use from any package. +package containerdtest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/errdefs" + "github.com/containerd/platforms" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// DaemonAddress returns the socket of the containerd daemon a test runs +// against, which HULL_TEST_CONTAINERD_ADDRESS names. Without it the test +// skips, unless HULL_TEST_REQUIRE_CONTAINERD is set: then it fails. CI sets +// that, so a daemon that went missing fails the job rather than passing it +// with the test skipped. +func DaemonAddress(t testing.TB) string { + t.Helper() + addr := os.Getenv("HULL_TEST_CONTAINERD_ADDRESS") + if addr == "" { + if os.Getenv("HULL_TEST_REQUIRE_CONTAINERD") != "" { + t.Fatal("HULL_TEST_REQUIRE_CONTAINERD is set, but HULL_TEST_CONTAINERD_ADDRESS names no daemon") + } + t.Skip("HULL_TEST_CONTAINERD_ADDRESS not set") + } + return addr +} + +// WriteBlob stores v as JSON in cs and returns its descriptor. +func WriteBlob(ctx context.Context, t testing.TB, cs content.Ingester, mediaType string, v any) ocispec.Descriptor { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + desc := ocispec.Descriptor{MediaType: mediaType, Digest: digest.FromBytes(data), Size: int64(len(data))} + err = content.WriteBlob(ctx, cs, desc.Digest.String(), bytes.NewReader(data), desc) + if err != nil { + t.Fatal(err) + } + return desc +} + +// WriteImage stores a config and a manifest for platform p, with two layers +// of 1000 and 24 bytes, and returns the manifest's descriptor as an index +// would list it. The layers are stored only when withLayers is set; without +// them the store holds what a pull for another platform leaves of this one. +func WriteImage(ctx context.Context, t testing.TB, cs content.Ingester, p ocispec.Platform, labels map[string]string, withLayers bool) ocispec.Descriptor { + t.Helper() + config := WriteBlob(ctx, t, cs, ocispec.MediaTypeImageConfig, ocispec.Image{ + Platform: p, + Config: ocispec.ImageConfig{Labels: labels}, + }) + var layers []ocispec.Descriptor + for i, size := range []int{1000, 24} { + // Distinct per platform and per layer, so platforms share no blob. + data := make([]byte, size) + copy(data, fmt.Sprintf("%d %s", i, platforms.Format(p))) + desc := ocispec.Descriptor{MediaType: ocispec.MediaTypeImageLayerGzip, Digest: digest.FromBytes(data), Size: int64(size)} + if withLayers { + err := content.WriteBlob(ctx, cs, desc.Digest.String(), bytes.NewReader(data), desc) + if err != nil { + t.Fatal(err) + } + } + layers = append(layers, desc) + } + desc := WriteBlob(ctx, t, cs, ocispec.MediaTypeImageManifest, ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: config, + Layers: layers, + }) + desc.Platform = &p + return desc +} + +// Lease returns ctx under a containerd lease that ends with the test, and +// content written with that context is kept until then. Without a lease the +// daemon may collect it at once: an image record references only its +// target, and the blobs under an index or manifest are kept by labels that +// a pull sets and WriteBlob does not. +func Lease(ctx context.Context, t testing.TB, c *client.Client) context.Context { + t.Helper() + ctx, done, err := c.WithLease(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = done(ctx) + }) + return ctx +} + +// CreateImage records name as an image that points at target, replacing a +// record an earlier run left behind, and deletes it when the test ends. +func CreateImage(ctx context.Context, t testing.TB, c *client.Client, name string, target ocispec.Descriptor) images.Image { + t.Helper() + is := c.ImageService() + err := is.Delete(ctx, name) + if err != nil && !errdefs.IsNotFound(err) { + t.Fatal(err) + } + img, err := is.Create(ctx, images.Image{Name: name, Target: target}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = is.Delete(ctx, name, images.SynchronousDelete()) + }) + return img +} diff --git a/internal/containerd/images.go b/internal/containerd/images.go new file mode 100644 index 00000000..fe91944f --- /dev/null +++ b/internal/containerd/images.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package containerd + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "time" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/platforms" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// Image is an image containerd holds under a name, described by the +// manifest of one platform. +type Image struct { + // Name is the reference the image is stored under. + Name string + // Digest is the digest of the platform manifest. + Digest string + // IndexDigest is the digest of the multi-platform index the name points + // at, or empty when the name points straight at a manifest. + IndexDigest string + // Platform is os/arch[/variant] from the image config, or empty when the + // config names no OS. + Platform string + // Size is the sum of the manifest's compressed layer sizes. + Size int64 + // Labels are the labels in the image config. + Labels map[string]string + // UpdatedAt is when the name was last pointed at its target. + UpdatedAt time.Time +} + +// DescribeImage reads img through to the manifest of one platform, chosen by +// pickManifest when the name points at an index, and describes the image by +// that manifest and its config. +// +// On error the returned Image still carries the name, UpdatedAt and a +// digest: the one the name points at, or the platform manifest's once one is +// chosen. A caller can still show an image whose content is damaged or +// missing. +func DescribeImage(ctx context.Context, cs content.Store, img images.Image, pref platforms.MatchComparer) (Image, error) { + out := Image{ + Name: img.Name, + Digest: img.Target.Digest.String(), + UpdatedAt: img.UpdatedAt, + } + manifestDesc := img.Target + if images.IsIndexType(img.Target.MediaType) { + var idx ocispec.Index + err := readJSON(ctx, cs, img.Target, &idx) + if err != nil { + return out, fmt.Errorf("read index: %w", err) + } + picked, err := pickManifest(ctx, cs, idx.Manifests, pref) + if err != nil { + return out, err + } + manifestDesc = picked + out.IndexDigest = img.Target.Digest.String() + out.Digest = picked.Digest.String() + } + if !images.IsManifestType(manifestDesc.MediaType) { + return out, fmt.Errorf("unsupported media type %s", manifestDesc.MediaType) + } + + var manifest ocispec.Manifest + err := readJSON(ctx, cs, manifestDesc, &manifest) + if err != nil { + return out, fmt.Errorf("read manifest: %w", err) + } + for _, layer := range manifest.Layers { + out.Size += layer.Size + } + + var config ocispec.Image + err = readJSON(ctx, cs, manifest.Config, &config) + if err != nil { + return out, fmt.Errorf("read config: %w", err) + } + out.Labels = config.Config.Labels + if config.OS != "" { + out.Platform = platforms.Format(config.Platform) + } + return out, nil +} + +// pickManifest chooses, among an index's manifests, the one pref ranks +// highest of those present in the content store. +// +// A manifest being present is not enough. ctr and the transfer service fetch +// the manifest and config of every platform in an index, but the layers of +// only the platform asked for, so a pull of linux/arm64 on an amd64 host +// leaves an amd64 manifest behind with nothing under it. A platform is +// complete when its config and every layer are present too, and complete +// platforms rank first. +// +// After that, platforms that match pref rank before those that don't, so an +// image pulled for another platform than the host's is described as that +// platform. A manifest that is present but incomplete is a candidate only +// when no platform is complete, which is what a containerd that discards +// layers once it has unpacked them leaves behind. Entries that are not +// images, which isImage tells apart, are never candidates. +// +// images.Check reports missing content as unavailable, not as an error, so +// an error means the entry could not be checked: its manifest is malformed, +// or the store failed to answer. Either way that entry is skipped, so one bad +// entry does not stop the rest of the index from being described. If no +// entry is usable, the first such error is returned rather than a claim that +// the content is missing: a daemon that fails every check is then reported +// as failing. A context that is done fails every later check too, so it ends +// the search at once. +func pickManifest(ctx context.Context, cs content.Store, manifests []ocispec.Descriptor, pref platforms.MatchComparer) (ocispec.Descriptor, error) { + type candidate struct { + desc ocispec.Descriptor + complete bool + matches bool + } + var present []candidate + var checkErr error + for _, m := range manifests { + if !images.IsManifestType(m.MediaType) || !isImage(m) { + continue + } + available, _, _, missing, err := images.Check(ctx, cs, m, platforms.All) + if err != nil { + if ctx.Err() != nil { + return ocispec.Descriptor{}, ctx.Err() + } + if checkErr == nil { + checkErr = err + } + continue + } + if !available { + continue + } + present = append(present, candidate{ + desc: m, + complete: len(missing) == 0, + matches: m.Platform != nil && pref.Match(*m.Platform), + }) + } + if len(present) == 0 { + if checkErr != nil { + return ocispec.Descriptor{}, checkErr + } + return ocispec.Descriptor{}, fmt.Errorf("no image manifest of the index is in the content store") + } + sort.SliceStable(present, func(i, j int) bool { + a, b := present[i], present[j] + if a.complete != b.complete { + return a.complete + } + if a.matches != b.matches { + return a.matches + } + return a.matches && pref.Less(*a.desc.Platform, *b.desc.Platform) + }) + return present[0].desc, nil +} + +// isImage reports whether an index entry is an image, rather than metadata +// stored beside the images in the same index. BuildKit stores each +// platform's provenance and SBOM as an image manifest of its own, with the +// platform unknown/unknown and a vnd.docker.reference.type annotation, and +// an OCI 1.1 artifact such as a signature sets artifactType. Their layers +// are never unpacked, so a containerd that discards unpacked layers keeps +// theirs and drops the images', and pickManifest would rank them as the only +// complete platforms. +func isImage(m ocispec.Descriptor) bool { + if m.ArtifactType != "" { + return false + } + if m.Annotations["vnd.docker.reference.type"] != "" { + return false + } + return m.Platform == nil || m.Platform.OS != "unknown" +} + +func readJSON(ctx context.Context, cs content.Store, desc ocispec.Descriptor, v any) error { + data, err := content.ReadBlob(ctx, cs, desc) + if err != nil { + return err + } + return json.Unmarshal(data, v) +} diff --git a/internal/containerd/images_test.go b/internal/containerd/images_test.go new file mode 100644 index 00000000..5f2f1fd4 --- /dev/null +++ b/internal/containerd/images_test.go @@ -0,0 +1,407 @@ +// Copyright (c) 2026, NOFire AI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package containerd + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/brig-sh/hull/internal/containerd/containerdtest" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/containerd/errdefs" + "github.com/containerd/platforms" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func newContentStore(t *testing.T) content.Store { + t.Helper() + cs, err := local.NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return cs +} + +var ( + linuxAMD64 = ocispec.Platform{OS: "linux", Architecture: "amd64"} + linuxARM64 = ocispec.Platform{OS: "linux", Architecture: "arm64"} +) + +// A single-platform image: the name points straight at the manifest, so +// there is no index digest. +func TestDescribeImageOfAManifest(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + manifest := containerdtest.WriteImage(ctx, t, cs, linuxAMD64, map[string]string{"org.example": "yes"}, true) + manifest.Platform = nil + updated := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) + + rec, err := DescribeImage(ctx, cs, images.Image{ + Name: "docker.io/library/alpine:latest", + Target: manifest, + UpdatedAt: updated, + }, platforms.Only(linuxAMD64)) + if err != nil { + t.Fatal(err) + } + + if rec.Name != "docker.io/library/alpine:latest" { + t.Errorf("Name = %q", rec.Name) + } + if rec.Digest != manifest.Digest.String() || rec.IndexDigest != "" { + t.Errorf("Digest, IndexDigest = %q, %q; want %q and none", rec.Digest, rec.IndexDigest, manifest.Digest) + } + if rec.Size != 1024 { + t.Errorf("Size = %d, want the layer sum 1024", rec.Size) + } + if rec.Platform != "linux/amd64" { + t.Errorf("Platform = %q", rec.Platform) + } + if rec.Labels["org.example"] != "yes" { + t.Errorf("Labels = %v", rec.Labels) + } + if !rec.UpdatedAt.Equal(updated) { + t.Errorf("UpdatedAt = %v, want %v", rec.UpdatedAt, updated) + } +} + +// A multi-arch image: Digest is the platform manifest pref ranks highest, and +// the index the name points at goes in IndexDigest. +func TestDescribeImagePicksThePreferredPlatform(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + amd := containerdtest.WriteImage(ctx, t, cs, linuxAMD64, nil, true) + arm := containerdtest.WriteImage(ctx, t, cs, linuxARM64, nil, true) + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{amd, arm}, + }) + + for _, tc := range []struct { + pref ocispec.Platform + want ocispec.Descriptor + }{ + {linuxAMD64, amd}, + {linuxARM64, arm}, + } { + rec, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: index}, platforms.Only(tc.pref)) + if err != nil { + t.Fatal(err) + } + if rec.Digest != tc.want.Digest.String() { + t.Errorf("pref %s: Digest = %q, want %q", platforms.Format(tc.pref), rec.Digest, tc.want.Digest) + } + if rec.IndexDigest != index.Digest.String() { + t.Errorf("pref %s: IndexDigest = %q, want %q", platforms.Format(tc.pref), rec.IndexDigest, index.Digest) + } + if rec.Platform != platforms.Format(tc.pref) { + t.Errorf("pref %s: Platform = %q", platforms.Format(tc.pref), rec.Platform) + } + } +} + +// An image pulled for another platform holds only that platform's manifest. +// It is described as that platform, rather than as a broken image. +func TestDescribeImageFallsBackToThePlatformPresent(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + arm := containerdtest.WriteImage(ctx, t, cs, linuxARM64, nil, true) + missing := linuxAMD64 + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ + {MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromString("absent"), Size: 10, Platform: &missing}, + arm, + }, + }) + + rec, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: index}, platforms.Only(linuxAMD64)) + if err != nil { + t.Fatal(err) + } + if rec.Digest != arm.Digest.String() || rec.Platform != "linux/arm64" { + t.Errorf("Digest, Platform = %q, %q; want %q, linux/arm64", rec.Digest, rec.Platform, arm.Digest) + } +} + +// An image whose manifest is gone is an error, but the Image returned with it +// still carries the name and the digest the name points at, so a caller can +// still show it. +func TestDescribeImageOfADamagedImage(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + target := ocispec.Descriptor{MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromString("gone"), Size: 10} + + rec, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: target}, platforms.Only(linuxAMD64)) + if err == nil { + t.Fatal("DescribeImage of an image with no manifest succeeded") + } + if rec.Name != "x" || rec.Digest != target.Digest.String() || rec.Size != 0 { + t.Errorf("image = %+v; want the name and target digest, no size", rec) + } +} + +// What ctr leaves behind after `ctr images pull --platform linux/arm64` on an +// amd64 host: the manifest and config of every platform, but the layers of +// arm64 only. The arm64 image that was pulled must be described, not the +// amd64 one the host prefers but has no layers for. +func TestDescribeImageSkipsAPlatformWithoutLayers(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + amd := containerdtest.WriteImage(ctx, t, cs, linuxAMD64, nil, false) + arm := containerdtest.WriteImage(ctx, t, cs, linuxARM64, nil, true) + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{amd, arm}, + }) + + rec, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: index}, platforms.Only(linuxAMD64)) + if err != nil { + t.Fatal(err) + } + if rec.Digest != arm.Digest.String() || rec.Platform != "linux/arm64" { + t.Errorf("Digest, Platform = %q, %q; want %q, linux/arm64", rec.Digest, rec.Platform, arm.Digest) + } +} + +// A containerd that discards layers once it has unpacked them leaves no +// platform complete. The platform pref ranks highest is still described. +func TestDescribeImageWithNoCompletePlatform(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + amd := containerdtest.WriteImage(ctx, t, cs, linuxAMD64, nil, false) + arm := containerdtest.WriteImage(ctx, t, cs, linuxARM64, nil, false) + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{arm, amd}, + }) + + rec, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: index}, platforms.Only(linuxAMD64)) + if err != nil { + t.Fatal(err) + } + if rec.Digest != amd.Digest.String() || rec.Platform != "linux/amd64" { + t.Errorf("Digest, Platform = %q, %q; want %q, linux/amd64", rec.Digest, rec.Platform, amd.Digest) + } +} + +// An index can hold entries that are not images: BuildKit's attestation +// manifests and OCI artifacts such as signatures. Their layers are never +// unpacked, so on a containerd that discards unpacked layers they can be the +// only complete entries. They must still never be described as the image. +func TestDescribeImageSkipsEntriesThatAreNotImages(t *testing.T) { + unknown := ocispec.Platform{OS: "unknown", Architecture: "unknown"} + for _, tc := range []struct { + name string + entry func(ocispec.Descriptor) ocispec.Descriptor + }{ + {"attestation annotation", func(d ocispec.Descriptor) ocispec.Descriptor { + d.Platform = &linuxAMD64 + d.Annotations = map[string]string{"vnd.docker.reference.type": "attestation-manifest"} + return d + }}, + {"unknown platform", func(d ocispec.Descriptor) ocispec.Descriptor { + return d + }}, + {"artifact type", func(d ocispec.Descriptor) ocispec.Descriptor { + d.Platform = &linuxAMD64 + d.ArtifactType = "application/vnd.example.signature" + return d + }}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + amd := containerdtest.WriteImage(ctx, t, cs, linuxAMD64, nil, false) + arm := containerdtest.WriteImage(ctx, t, cs, linuxARM64, nil, false) + other := tc.entry(containerdtest.WriteImage(ctx, t, cs, unknown, nil, true)) + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{other, arm, amd}, + }) + + rec, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: index}, platforms.Only(linuxAMD64)) + if err != nil { + t.Fatal(err) + } + if rec.Digest != amd.Digest.String() || rec.Platform != "linux/amd64" { + t.Errorf("Digest, Platform = %q, %q; want %q, linux/amd64", rec.Digest, rec.Platform, amd.Digest) + } + }) + } +} + +// The other tests read from an in-process content store. hull reads through +// the client's, which goes over gRPC to the daemon and maps its errors back, +// and it finds images through the daemon's image service. Images written and +// recorded there must describe the same way. The second image has lost the +// layers of every platform, so it is described only if a missing blob comes +// back from the daemon as not found rather than as some other error. +func TestDescribeImageThroughDaemon(t *testing.T) { + addr := containerdtest.DaemonAddress(t) + c, err := Connect(context.Background(), addr, "hull-test-describe") + if err != nil { + t.Fatal(err) + } + // Closed by a cleanup, not a defer: the cleanups that delete the test's + // image and lease run after the test returns and still need the client. + t.Cleanup(func() { _ = c.Close() }) + ctx := containerdtest.Lease(context.Background(), t, c) + cs := c.ContentStore() + + riscv := ocispec.Platform{OS: "linux", Architecture: "riscv64"} + s390x := ocispec.Platform{OS: "linux", Architecture: "s390x"} + amd := containerdtest.WriteImage(ctx, t, cs, linuxAMD64, map[string]string{"org.example": "yes"}, true) + arm := containerdtest.WriteImage(ctx, t, cs, linuxARM64, nil, false) + riscvDesc := containerdtest.WriteImage(ctx, t, cs, riscv, nil, false) + s390xDesc := containerdtest.WriteImage(ctx, t, cs, s390x, nil, false) + for _, tc := range []struct { + name string + manifests []ocispec.Descriptor + pref ocispec.Platform + want ocispec.Descriptor + }{ + {"example.com/hull/complete:1", []ocispec.Descriptor{arm, amd}, linuxAMD64, amd}, + {"example.com/hull/discarded:1", []ocispec.Descriptor{s390xDesc, riscvDesc}, riscv, riscvDesc}, + } { + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: tc.manifests, + }) + containerdtest.CreateImage(ctx, t, c, tc.name, index) + + img, err := c.ImageService().Get(ctx, tc.name) + if err != nil { + t.Fatal(err) + } + rec, err := DescribeImage(ctx, cs, img, platforms.Only(tc.pref)) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if rec.Digest != tc.want.Digest.String() || rec.IndexDigest != index.Digest.String() { + t.Errorf("%s: Digest, IndexDigest = %q, %q; want %q, %q", tc.name, rec.Digest, rec.IndexDigest, tc.want.Digest, index.Digest) + } + if rec.Platform != platforms.Format(tc.pref) || rec.Size != 1024 { + t.Errorf("%s: image = %+v; want %s, 1024 bytes", tc.name, rec, platforms.Format(tc.pref)) + } + } + + imgs, err := c.ImageService().List(ctx) + if err != nil { + t.Fatal(err) + } + if len(imgs) != 2 { + t.Errorf("image service lists %d images, want the 2 written", len(imgs)) + } +} + +// failingStore fails reads the way a daemon's content store does when its +// RPCs fail: reads of the blobs in fail return err, and every read fails once +// the context is done. reads counts the attempts. +type failingStore struct { + content.Store + fail map[digest.Digest]bool + err error + reads int +} + +func (s *failingStore) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content.ReaderAt, error) { + s.reads++ + if ctx.Err() != nil { + return nil, ctx.Err() + } + if s.fail[desc.Digest] { + return nil, s.err + } + return s.Store.ReaderAt(ctx, desc) +} + +// An entry whose manifest is not even JSON cannot be checked. It is skipped +// like a missing one, and the rest of the index is described as usual. +func TestDescribeImageSkipsAMalformedEntry(t *testing.T) { + ctx := context.Background() + cs := newContentStore(t) + amd := containerdtest.WriteImage(ctx, t, cs, linuxAMD64, nil, true) + junk := []byte("not a manifest") + bad := ocispec.Descriptor{MediaType: ocispec.MediaTypeImageManifest, Digest: digest.FromBytes(junk), Size: int64(len(junk)), Platform: &linuxARM64} + err := content.WriteBlob(ctx, cs, bad.Digest.String(), bytes.NewReader(junk), bad) + if err != nil { + t.Fatal(err) + } + index := containerdtest.WriteBlob(ctx, t, cs, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{bad, amd}, + }) + + rec, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: index}, platforms.Only(linuxAMD64)) + if err != nil { + t.Fatal(err) + } + if rec.Digest != amd.Digest.String() { + t.Errorf("Digest = %q, want the amd64 manifest %q", rec.Digest, amd.Digest) + } +} + +// When the store fails every check, as a daemon that has stopped answering +// does, the error it returned is what comes back, not a claim that the +// content is missing. +func TestDescribeImageReportsAFailingStore(t *testing.T) { + ctx := context.Background() + local := newContentStore(t) + amd := containerdtest.WriteImage(ctx, t, local, linuxAMD64, nil, true) + arm := containerdtest.WriteImage(ctx, t, local, linuxARM64, nil, true) + index := containerdtest.WriteBlob(ctx, t, local, ocispec.MediaTypeImageIndex, ocispec.Index{ + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{amd, arm}, + }) + cs := &failingStore{ + Store: local, + fail: map[digest.Digest]bool{amd.Digest: true, arm.Digest: true}, + err: errdefs.ErrUnavailable, + } + + _, err := DescribeImage(ctx, cs, images.Image{Name: "x", Target: index}, platforms.Only(linuxAMD64)) + if !errors.Is(err, errdefs.ErrUnavailable) { + t.Fatalf("error = %v, want the store's %v", err, errdefs.ErrUnavailable) + } +} + +// A context that is done fails every later check too, so the search ends at +// the first one instead of trying each entry in turn. +func TestPickManifestStopsWhenTheContextIsDone(t *testing.T) { + ctx := context.Background() + local := newContentStore(t) + amd := containerdtest.WriteImage(ctx, t, local, linuxAMD64, nil, true) + arm := containerdtest.WriteImage(ctx, t, local, linuxARM64, nil, true) + cs := &failingStore{Store: local} + + done, cancel := context.WithCancel(ctx) + cancel() + _, err := pickManifest(done, cs, []ocispec.Descriptor{amd, arm}, platforms.Only(linuxAMD64)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want %v", err, context.Canceled) + } + if cs.reads != 1 { + t.Errorf("store was read %d times, want 1", cs.reads) + } +}