Repository navigation
feat: List images through containerd on Linux #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
106e895
bb2ba0d
e18028b
ea38aea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,10 @@ name: CI | |
| # on runner.environment == 'self-hosted', so parking `e2e` back on macos-15 | ||
| # degrades its VM-boot steps to no-ops there -- `unit` needs no self-hosted | ||
| # capability at all, so it keeps running the suite either way. | ||
| # | ||
| # The Linux port is the exception to all of the above. `unit-linux` runs the | ||
| # root module's tests natively on a Linux runner, which is the only place the | ||
| # linux-tagged files in cmd/hull are tested. | ||
|
|
||
| on: | ||
| push: | ||
|
|
@@ -150,13 +154,21 @@ jobs: | |
| # Everything above type-checks for darwin, and the unit job runs on a | ||
| # Mac, so a cmd/hull file that uses a darwin-only symbol without the | ||
| # build tag would break the Linux build with every check still green. | ||
| # The recipe sets GOOS=linux itself. The vet step overrides the job's | ||
| # GOOS inline so it also type-checks the test files for Linux. | ||
| # The recipe sets GOOS=linux itself. | ||
| - name: Build the Linux CLI | ||
| run: make linux | ||
|
|
||
| - name: Vet for Linux | ||
| run: GOOS=linux GOARCH=amd64 go vet ./... | ||
| # The golangci-lint step above lints for darwin, the job's GOOS, where | ||
| # the linux-tagged files in cmd/hull do not build. This lints them for | ||
| # Linux, test files included. Its govet runs every analyzer `go vet` | ||
| # does, so there is no separate vet step. | ||
| - name: golangci-lint (linux) | ||
| uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 | ||
| env: | ||
| GOOS: linux | ||
| GOARCH: amd64 | ||
| with: | ||
| version: v2.12.2 | ||
|
|
||
| telemetry: | ||
| name: telemetry module (linux) | ||
|
|
@@ -198,6 +210,75 @@ jobs: | |
| version: v2.12.2 | ||
| working-directory: pkg/telemetry | ||
|
|
||
| unit-linux: | ||
| name: unit tests (linux) | ||
| # `unit` runs the Go suite on a Mac, where the linux-tagged files in | ||
| # cmd/hull do not build, and `lint` only builds and lints for Linux. | ||
| # This runs the root module's suite natively on Linux, under the race | ||
| # detector: the internal/ and pkg/ packages, the untagged files in | ||
| # cmd/hull, and the linux-tagged ones. Like `unit`, it is cheap and runs | ||
| # on every diff. | ||
| runs-on: ubuntu-latest | ||
| env: | ||
| # A test that needs a containerd daemon fails without one, instead of | ||
| # skipping, so this job cannot pass without having run them. | ||
| HULL_TEST_REQUIRE_CONTAINERD: "1" | ||
| steps: | ||
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
|
|
||
| - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | ||
| with: | ||
| go-version-file: go.mod | ||
|
|
||
| # On Linux hull is a containerd client, and some of its tests talk to a | ||
| # daemon. Run the release the brig-standalone-linux bundle ships, as | ||
| # root, on a socket of its own that this job's user owns: the tests need | ||
| # no sudo, and the runner's own containerd, which Docker uses, is never | ||
| # touched. The checksum is the one containerd publishes beside the | ||
| # tarball. | ||
| - name: Start containerd | ||
| env: | ||
| CONTAINERD_VERSION: 2.3.5 | ||
| CONTAINERD_SHA256: 2f0a095a71e3262d0d91ff0e50e2e4ae73c3866c4d1ff6a15f341097fba3dd44 | ||
| run: | | ||
| set -euo pipefail | ||
| dir="$RUNNER_TEMP/containerd" | ||
| mkdir -p "$dir" | ||
| curl -fsSL -o "$dir/containerd.tar.gz" \ | ||
| "https://github.com/containerd/containerd/releases/download/v${CONTAINERD_VERSION}/containerd-${CONTAINERD_VERSION}-linux-amd64.tar.gz" | ||
| echo "${CONTAINERD_SHA256} $dir/containerd.tar.gz" | sha256sum -c - | ||
| tar -C "$dir" -xzf "$dir/containerd.tar.gz" | ||
| cat > "$dir/config.toml" <<EOF | ||
| version = 3 | ||
| root = "$dir/root" | ||
| state = "$dir/state" | ||
| disabled_plugins = ["io.containerd.grpc.v1.cri"] | ||
| [grpc] | ||
| address = "$dir/containerd.sock" | ||
| uid = $(id -u) | ||
| gid = $(id -g) | ||
| [ttrpc] | ||
| address = "$dir/containerd.sock.ttrpc" | ||
| uid = $(id -u) | ||
| gid = $(id -g) | ||
| [debug] | ||
| address = "" | ||
| EOF | ||
| sudo nohup "$dir/bin/containerd" --config "$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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should_fix: Still open since bb72d34. The description says Failure: nothing in CI runs Evidence: .github/workflows/ci.yml:214-280 is the whole job; internal/containerd/containerd_test.go:145-173 are the only daemon tests; internal/containerd/images_test.go:88-96 uses Either add the step (I ran the equivalent by hand and it works: |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = "<none>" | ||
| 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) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should_fix: The description says
unit-linuxhas "an end-to-end step [that] lists an image pulled fromghcr.io/containerd/busybox, which keeps Docker Hub's rate limits out of CI". No such step is in the diff: the job is checkout, setup-go, Start containerd,go test -count=1 -race ./..., and the failure-only log dump.grep -n busybox .github/workflows/ci.ymlreturns nothing.Failure: nothing in CI runs
hull imagesagainst a daemon holding a real image. The daemon tests that do run (TestConnectToDaemon,TestConnectToDaemonWithUnixScheme) only callVersionand readDefaultNamespace;DescribeImageis tested only against a local content store, never the proxy storeclient.ContentStore()returns, andlistImageshas no test at all. A regression in theImageService().List->DescribeImage->imageRecordpath would land green.Evidence: .github/workflows/ci.yml:214-280 is the whole job; internal/containerd/containerd_test.go:145-173 are the only daemon tests; internal/containerd/images_test.go:88-96 uses
plugins/content/local.Either add the step (I ran the equivalent by hand and it works:
ctr -n hull content fetchthe image, thenHULL_CONTAINERD_ADDRESS=... hull images --json), or drop the sentence from the description.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Created tests and fixed text.