From 106e8957c92419338c0e2202811f1097d5be7895 Mon Sep 17 00:00:00 2001 From: Charalampos Mainas Date: Tue, 20 Oct 2026 17:00:49 +0200 Subject: [PATCH 1/4] feat: Add the Linux command tree with an images verb The Linux build of hull was a stub that printed a greeting. It now has the command tree of the darwin CLI, with --debug, --version and errors printed as "error: ..." with exit status 1, so verbs can come over from the darwin CLI one at a time. There is no --store-dir. On Linux, images, containers and tasks are to live in containerd, which leaves hull no directory to own. `hull images` is registered with its macOS flags but refuses for now. On Linux it lists what containerd holds, and hull cannot reach containerd yet. No Linux runner ran the root module's tests, so neither the untagged tests in cmd/hull nor any linux-tagged code was tested on Linux, and golangci-lint only ran for darwin. A unit-linux job now runs the tests natively under the race detector, and the lint job lints for Linux as well. Its separate `go vet` for Linux goes, since golangci-lint's govet runs the same analyzers. Refs: #105 Signed-off-by: Charalampos Mainas --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++--- cmd/hull/images_linux.go | 40 ++++++++++++++++++++++++++++++ cmd/hull/main_linux.go | 53 +++++++++++++++++++++++++++++++++++++--- docs/build.md | 7 +++--- 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 cmd/hull/images_linux.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e00ce19..6a66d45 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,25 @@ 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 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + + - name: Unit tests + run: go test -count=1 -race ./... + 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/images_linux.go b/cmd/hull/images_linux.go new file mode 100644 index 0000000..2ee58f7 --- /dev/null +++ b/cmd/hull/images_linux.go @@ -0,0 +1,40 @@ +// 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" + "errors" + + "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(context.Context, *cli.Command) error { + return errors.New("hull images does not work on Linux yet: it needs the containerd connection") + }, + } +} diff --git a/cmd/hull/main_linux.go b/cmd/hull/main_linux.go index 927291f..67903af 100644 --- a/cmd/hull/main_linux.go +++ b/cmd/hull/main_linux.go @@ -16,10 +16,55 @@ package main -import "fmt" +import ( + "context" + "fmt" + "os" + + "github.com/brig-sh/hull/internal/buildinfo" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +// On Linux, hull will be a containerd client: images, containers and tasks +// will live in containerd, not in a store directory as on macOS. A verb +// registered here before it works on Linux refuses with a reason. + +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", + }, + }, + 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(), + }, + } +} + +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 1dff54f..1b13ef9 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. Linux builds only the start of the CLI 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 @@ -101,8 +101,9 @@ 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. +plain `make` on a Linux host, builds a `hull` with the start of the Linux +command tree. Its one verb, `hull images`, refuses with an explanation for +now. It builds no runners and signs nothing. ```bash make # dist/hull_linux_ From bb2ba0d595571b0b41ba6d21adce902d70d86d30 Mon Sep 17 00:00:00 2001 From: Charalampos Mainas Date: Tue, 20 Oct 2026 17:51:00 +0200 Subject: [PATCH 2/4] feat: Connect to containerd in a hull namespace On Linux, hull keeps its images, containers and tasks in containerd. Connect gives the Linux commands a client to it, scoped to one namespace. The package builds for Linux only: hull on macOS keeps its images in its own store and never talks to containerd. 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 state. ResolveNamespace takes an explicit name, then $HULL_NAMESPACE, then "hull". The name is used as given rather than derived, so `ctr -n hull` shows what hull sees. A name containerd would reject is refused before dialing. ResolveAddress takes an explicit socket, then $HULL_CONTAINERD_ADDRESS, then a default by user. Root gets /run/containerd/containerd.sock, which is also where a rootless containerd appears inside rootlesskit's namespaces. Other users get $XDG_RUNTIME_DIR/containerd/containerd.sock, where a rootless containerd puts it on the host. Connect takes the address with or without a unix:// scheme: containerd's dialer adds the scheme itself and fails to parse an address that already has it. client.New does not dial, so Connect asks the daemon for its version before returning, and a daemon that is down fails there, with the address in the error. containerd's dialer retries a socket that does not exist until its timeout and then reports only the timeout, so Connect checks for the socket first and names it. The containerd v2 client is a new dependency. Minimal version selection raises runtime-spec to v1.3.0, and a few indirect modules with it. The unit-linux job starts containerd v2.3.5, the version the brig-standalone-linux bundle ships, from its release tarball, on a socket the job's user owns. Tests that need a daemon skip without one unless HULL_TEST_REQUIRE_CONTAINERD is set. The job sets it, so it cannot pass with those tests skipped. containerdtest.DaemonAddress applies that rule, so tests in any package get it the same way. Refs: #105 Signed-off-by: Charalampos Mainas --- .github/workflows/ci.yml | 50 ++++++ go.mod | 27 ++- go.sum | 72 ++++++-- internal/containerd/containerd.go | 151 +++++++++++++++++ internal/containerd/containerd_test.go | 157 ++++++++++++++++++ .../containerdtest/containerdtest.go | 41 +++++ 6 files changed, 482 insertions(+), 16 deletions(-) create mode 100644 internal/containerd/containerd.go create mode 100644 internal/containerd/containerd_test.go create mode 100644 internal/containerd/containerdtest/containerdtest.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a66d45..1e6532e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,6 +219,10 @@ jobs: # 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 @@ -226,9 +230,55 @@ jobs: 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/go.mod b/go.mod index 43fba3f..f9d9ebe 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,13 @@ 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/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/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 +25,25 @@ 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/platforms v1.0.0-rc.5 // 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 +54,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,9 +69,11 @@ 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 @@ -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 b0186b9..946006a 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 0000000..c23c3cb --- /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 %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 %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 0000000..9d26de5 --- /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 0000000..54a040e --- /dev/null +++ b/internal/containerd/containerdtest/containerdtest.go @@ -0,0 +1,41 @@ +// 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 ( + "os" + "testing" +) + +// 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 +} From e18028b682c5ee2e0231b7a929fc4c9f579881f5 Mon Sep 17 00:00:00 2001 From: Charalampos Mainas Date: Tue, 20 Oct 2026 18:12:01 +0200 Subject: [PATCH 3/4] refactor: Share the image listing writers with Linux `hull images` on Linux should print exactly what it prints on macOS, but the table and JSON writers, and the digest, size and age formatters they use, lived in darwin-only files. imagelist.go and jsonout.go now hold them without a build tag, so both builds share one implementation and its tests run on Linux as well. Nothing changes on macOS. Refs: #105 Signed-off-by: Charalampos Mainas --- cmd/hull/imagecache_test.go | 10 --- cmd/hull/imagelist.go | 117 ++++++++++++++++++++++++++++++++++++ cmd/hull/images.go | 96 ----------------------------- cmd/hull/images_test.go | 12 +++- cmd/hull/jsonout.go | 2 - cmd/hull/jsonout_test.go | 2 - 6 files changed, 127 insertions(+), 112 deletions(-) create mode 100644 cmd/hull/imagelist.go diff --git a/cmd/hull/imagecache_test.go b/cmd/hull/imagecache_test.go index 1f78903..f9d6e0b 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 0000000..587af59 --- /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 b53692c..b651861 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_test.go b/cmd/hull/images_test.go index 9912c9b..856faf3 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 183bdbc..69e1039 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 e326049..ee00356 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 ( From ea38aeac75d635285c2e907c6ad92399a54b3bca Mon Sep 17 00:00:00 2001 From: Charalampos Mainas Date: Tue, 20 Oct 2026 18:37:12 +0200 Subject: [PATCH 4/4] feat: List images through containerd on Linux `hull images` on Linux lists the images in hull's containerd namespace, in the same table and JSON form as on macOS. The global --namespace and --containerd-address flags take precedence over HULL_NAMESPACE and HULL_CONTAINERD_ADDRESS. containerd keeps one record per image name, and the name may point at a multi-arch index, while each macOS record describes one platform's image. DescribeImage in internal/containerd therefore reads each record through to one platform manifest. Digest is that manifest's digest, IndexDigest the index's, Size the sum of its compressed layers, and Labels and Platform come from its config. It lives outside the CLI because it is containerd logic, not output, and other verbs that describe a stored image can use it. Choosing the platform needs care, because a manifest being present does not mean its image is. ctr and containerd's transfer service fetch the manifest and config of every platform in an index but the layers of only the platform requested, so pulling linux/arm64 on an amd64 host leaves an amd64 manifest with no layers. pickManifest ranks platforms whose layers are all present first, the host's ahead of the rest, and settles for an incomplete one only when none is complete, as on a containerd that discards layers once it has unpacked them. An image whose manifest cannot be read is still listed, under the digest its name points at and with a warning, rather than dropped from the listing. Entries of an index that are not images never become candidates: BuildKit's attestation manifests, which carry platform unknown/unknown and a vnd.docker.reference.type annotation, and OCI artifacts with an artifactType. Their layers are never unpacked, so on a containerd that discards unpacked layers they can be the only complete entries left, and the ranking above would otherwise pick one. images.Check reports missing content as unavailable, not as an error, so an error means an entry could not be checked at all: its manifest is malformed, or the daemon failed to answer. Such an entry is skipped, so one bad manifest does not stop the rest of the index from being described, but the first error is kept. If no entry is usable it is returned instead of "no image manifest", so a daemon failing every check is reported as failing, and a done context ends the search at once. The platform choice is tested against an in-process content store and against a live daemon. The daemon test writes images through the client and reads them back through the image service and the gRPC content store; one of them has lost the layers of every platform, so it is described only if a missing blob comes back from the daemon as not found. A second test runs `hull images` through newApp, flags included, and checks the JSON, the table and an empty namespace; listImages writes to the command's writer so the test can read what it prints. Refs: #105 Signed-off-by: Charalampos Mainas --- cmd/hull/images_linux.go | 62 ++- cmd/hull/images_linux_test.go | 98 +++++ cmd/hull/main_linux.go | 26 +- docs/build.md | 33 +- go.mod | 8 +- internal/containerd/containerd.go | 4 +- .../containerdtest/containerdtest.go | 96 +++++ internal/containerd/images.go | 206 +++++++++ internal/containerd/images_test.go | 407 ++++++++++++++++++ 9 files changed, 923 insertions(+), 17 deletions(-) create mode 100644 cmd/hull/images_linux_test.go create mode 100644 internal/containerd/images.go create mode 100644 internal/containerd/images_test.go diff --git a/cmd/hull/images_linux.go b/cmd/hull/images_linux.go index 2ee58f7..85cdf9d 100644 --- a/cmd/hull/images_linux.go +++ b/cmd/hull/images_linux.go @@ -18,8 +18,13 @@ package main import ( "context" - "errors" + "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" ) @@ -33,8 +38,59 @@ func imagesCommand() *cli.Command { Usage: "print the image records as JSON, with full digests", }, }, - Action: func(context.Context, *cli.Command) error { - return errors.New("hull images does not work on Linux yet: it needs the containerd connection") + 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 0000000..41c7846 --- /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/main_linux.go b/cmd/hull/main_linux.go index 67903af..a4af5d9 100644 --- a/cmd/hull/main_linux.go +++ b/cmd/hull/main_linux.go @@ -22,13 +22,15 @@ import ( "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 will be a containerd client: images, containers and tasks -// will live in containerd, not in a store directory as on macOS. A verb -// registered here before it works on Linux refuses with a reason. +// 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() @@ -51,6 +53,16 @@ func newApp() *cli.Command { 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") { @@ -64,6 +76,14 @@ func newApp() *cli.Command { } } +// 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 1b13ef9..a736522 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 the start 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,16 +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 `hull` with the start of the Linux -command tree. Its one verb, `hull images`, refuses with an explanation for -now. 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 f9d9ebe..35648cd 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,15 @@ require ( 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/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 @@ -36,11 +40,9 @@ require ( 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/platforms v1.0.0-rc.5 // 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 @@ -79,8 +81,6 @@ require ( 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 diff --git a/internal/containerd/containerd.go b/internal/containerd/containerd.go index c23c3cb..8ec1d46 100644 --- a/internal/containerd/containerd.go +++ b/internal/containerd/containerd.go @@ -130,7 +130,7 @@ func Connect(ctx context.Context, address, namespace string) (*client.Client, er _, 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 %s", address, AddressEnv) + "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 { @@ -145,7 +145,7 @@ func Connect(ctx context.Context, address, namespace string) (*client.Client, er 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 %s", address, err, AddressEnv) + "Start containerd, or point hull at its socket with --containerd-address or %s", address, err, AddressEnv) } return c, nil } diff --git a/internal/containerd/containerdtest/containerdtest.go b/internal/containerd/containerdtest/containerdtest.go index 54a040e..e9eaafa 100644 --- a/internal/containerd/containerdtest/containerdtest.go +++ b/internal/containerd/containerdtest/containerdtest.go @@ -19,8 +19,20 @@ 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 @@ -39,3 +51,87 @@ func DaemonAddress(t testing.TB) string { } 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 0000000..fe91944 --- /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 0000000..5f2f1fd --- /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) + } +}