Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

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-linux has "an end-to-end step [that] lists an image pulled from ghcr.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.yml returns nothing.

Failure: nothing in CI runs hull images against a daemon holding a real image. The daemon tests that do run (TestConnectToDaemon, TestConnectToDaemonWithUnixScheme) only call Version and read DefaultNamespace; DescribeImage is tested only against a local content store, never the proxy store client.ContentStore() returns, and listImages has no test at all. A regression in the ImageService().List -> DescribeImage -> imageRecord path 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 fetch the image, then HULL_CONTAINERD_ADDRESS=... hull images --json), or drop the sentence from the description.

Copy link
Copy Markdown
Collaborator Author

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should_fix: Still open since bb72d34. The description says unit-linux has "an end-to-end step [that] lists an image pulled from ghcr.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.yml returns nothing.

Failure: nothing in CI runs hull images against a daemon holding a real image. The daemon tests that do run (TestConnectToDaemon, TestConnectToDaemonWithUnixScheme) only call Version and read DefaultNamespace; DescribeImage is tested only against a local content store, never the proxy store client.ContentStore() returns, and listImages has no test at all. A regression in the ImageService().List -> DescribeImage -> imageRecord path 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 fetch the image, then HULL_CONTAINERD_ADDRESS=... hull images --json), or drop the sentence from the description.

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
Expand Down
10 changes: 0 additions & 10 deletions cmd/hull/imagecache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
117 changes: 117 additions & 0 deletions cmd/hull/imagelist.go
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)
}
96 changes: 0 additions & 96 deletions cmd/hull/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 = "<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)
}
Loading
Loading