366 lines
13 KiB
Go
366 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// writeTestArchive builds a minimal rootfs .tar.gz with the stdlib, so
|
|
// the tests need no zstd; Load unpacks it with the system tar.
|
|
func writeTestArchive(t *testing.T, path string) {
|
|
writeTestArchiveWith(t, path, "etc/hello", "hello from the rootfs\n")
|
|
}
|
|
|
|
// writeTestArchiveWith builds a .tar.gz holding one file.
|
|
func writeTestArchiveWith(t *testing.T, path, name, content string) {
|
|
t.Helper()
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gz := gzip.NewWriter(f)
|
|
tw := tar.NewWriter(gz)
|
|
if err := tw.WriteHeader(&tar.Header{Name: "etc/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tw.Write([]byte(content)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range []interface{ Close() error }{tw, gz, f} {
|
|
if err := c.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImportUnpacksArchiveIntoTheStore(t *testing.T) {
|
|
if _, err := exec.LookPath("tar"); err != nil {
|
|
t.Skip("tar not installed")
|
|
}
|
|
st := Store{Root: t.TempDir()}
|
|
archive := filepath.Join(t.TempDir(), "mini-rootfs.tar.gz")
|
|
writeTestArchive(t, archive)
|
|
if err := st.Import(archive, "mini", ImageConfig{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rootfs, err := st.RootFS("mini")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(rootfs, "etc", "hello"))
|
|
if err != nil || string(content) != "hello from the rootfs\n" {
|
|
t.Errorf("unpacked file: %q, %v", content, err)
|
|
}
|
|
metaRaw, err := os.ReadFile(filepath.Join(st.Root, "images", "mini", "latest", "image.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var meta ImageMeta
|
|
if err := json.Unmarshal(metaRaw, &meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if meta.Name != "mini" || meta.Source == "" || meta.CreatedAt.IsZero() || meta.Architecture == "" || meta.OS != "linux" {
|
|
t.Errorf("image.json incomplete: %+v", meta)
|
|
}
|
|
}
|
|
|
|
func TestImportStoresTheConfigAndMetaReadsIt(t *testing.T) {
|
|
if _, err := exec.LookPath("tar"); err != nil {
|
|
t.Skip("tar not installed")
|
|
}
|
|
st := Store{Root: t.TempDir()}
|
|
archive := filepath.Join(t.TempDir(), "mini.tar.gz")
|
|
writeTestArchive(t, archive)
|
|
config := ImageConfig{
|
|
Env: []string{"JAVA_HOME=/opt/jdk"},
|
|
Entrypoint: []string{"/bin/sh", "-c"},
|
|
Cmd: []string{"java -version"},
|
|
WorkingDir: "/work",
|
|
Labels: map[string]string{"org.opencontainers.image.title": "mini"},
|
|
}
|
|
if err := st.Import(archive, "mini:1", config); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta, err := st.Meta("mini:1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(meta.Config, config) {
|
|
t.Errorf("config round trip: got %+v, want %+v", meta.Config, config)
|
|
}
|
|
// A legacy image.json (no config) reads back with an empty config.
|
|
fakeImage(t, st, "legacy")
|
|
meta, err = st.Meta("legacy")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(meta.Config, ImageConfig{}) {
|
|
t.Errorf("legacy config: got %+v", meta.Config)
|
|
}
|
|
}
|
|
|
|
func TestImportRefusesAnExistingImageName(t *testing.T) {
|
|
if _, err := exec.LookPath("tar"); err != nil {
|
|
t.Skip("tar not installed")
|
|
}
|
|
st := Store{Root: t.TempDir()}
|
|
archive := filepath.Join(t.TempDir(), "mini.tar.gz")
|
|
writeTestArchive(t, archive)
|
|
if err := st.Import(archive, "mini", ImageConfig{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := st.Import(archive, "mini", ImageConfig{})
|
|
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
|
t.Errorf("got %v, want an already-exists refusal", err)
|
|
}
|
|
}
|
|
|
|
func TestImportLeavesNoHalfImageOnFailure(t *testing.T) {
|
|
if _, err := exec.LookPath("tar"); err != nil {
|
|
t.Skip("tar not installed")
|
|
}
|
|
st := Store{Root: t.TempDir()}
|
|
broken := filepath.Join(t.TempDir(), "broken.tar.gz")
|
|
if err := os.WriteFile(broken, []byte("this is not a tar archive"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Import(broken, "example.org/broken:v1", ImageConfig{}); err == nil {
|
|
t.Fatal("expected the import to fail")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(st.Root, "images", "example.org")); !os.IsNotExist(err) {
|
|
t.Errorf("expected no leftover repository directory, got %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(st.Root, "images")); err != nil {
|
|
t.Errorf("images/ itself must survive: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportRefusesDockerAndOCIImageArchives(t *testing.T) {
|
|
if _, err := exec.LookPath("tar"); err != nil {
|
|
t.Skip("tar not installed")
|
|
}
|
|
st := Store{Root: t.TempDir()}
|
|
saved := filepath.Join(t.TempDir(), "saved.tar.gz")
|
|
writeTestArchiveWith(t, saved, "manifest.json", "[]")
|
|
err := st.Import(saved, "saved", ImageConfig{})
|
|
if err == nil || !strings.Contains(err.Error(), "docker image archive") {
|
|
t.Errorf("got %v, want a docker-archive refusal", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(st.Root, "images", "saved")); !os.IsNotExist(err) {
|
|
t.Errorf("expected no image directory after the refusal, got %v", err)
|
|
}
|
|
// A rootfs that merely ships a manifest.json somewhere below the top
|
|
// level is still a rootfs.
|
|
nested := filepath.Join(t.TempDir(), "nested.tar.gz")
|
|
writeTestArchiveWith(t, nested, "opt/app/manifest.json", "{}")
|
|
if err := st.Import(nested, "nested", ImageConfig{}); err != nil {
|
|
t.Errorf("nested manifest.json must not be mistaken for an image archive: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRootFSValidation(t *testing.T) {
|
|
st := Store{Root: t.TempDir()}
|
|
if _, err := st.RootFS("no-such-image"); err == nil || !strings.Contains(err.Error(), "no such image") {
|
|
t.Errorf("got %v, want a no-such-image error", err)
|
|
}
|
|
if _, err := st.RootFS("../escape"); err == nil || !strings.Contains(err.Error(), "invalid image reference") {
|
|
t.Errorf("got %v, want an invalid-name error", err)
|
|
}
|
|
}
|
|
|
|
// fakeImage lays out an image directory (rootfs + image.json) under
|
|
// images/ without unpacking anything.
|
|
func fakeImage(t *testing.T, st Store, rel string) {
|
|
t.Helper()
|
|
dir := filepath.Join(st.Root, "images", filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Join(dir, "rootfs"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "image.json"), []byte("{}\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestListNamesLoadedImagesAndIgnoresTmpLeftovers(t *testing.T) {
|
|
st := Store{Root: t.TempDir()}
|
|
if names, err := st.List(); err != nil || names != nil {
|
|
t.Fatalf("empty store: got %v, %v", names, err)
|
|
}
|
|
for _, rel := range []string{
|
|
"beta/latest", // beta
|
|
"alpha", // legacy flat layout: alpha
|
|
"eclipse-temurin/21", // eclipse-temurin:21
|
|
"ghcr.io/example/build/2026.09", // registry/namespace/repo:tag
|
|
"a/b", // a:b ...
|
|
"a/b/latest", // ... and a/b, coexisting below it
|
|
"alpha/rootfs/x", // a stray directory inside a rootfs is not an image
|
|
"ghcr.io/example/build/2026.10.tmp", // interrupted load
|
|
"ghcr.io/example/build/2026.10.tmp/latest", // ... with anything below it
|
|
} {
|
|
fakeImage(t, st, rel)
|
|
}
|
|
names, err := st.List()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"a/b", "a:b", "alpha", "beta", "eclipse-temurin:21", "ghcr.io/example/build:2026.09"}
|
|
if strings.Join(names, " ") != strings.Join(want, " ") {
|
|
t.Errorf("got %v, want %v", names, want)
|
|
}
|
|
}
|
|
|
|
func TestRootFSResolvesTagsAndTheLegacyLayout(t *testing.T) {
|
|
st := Store{Root: t.TempDir()}
|
|
fakeImage(t, st, "legacy")
|
|
fakeImage(t, st, "a/b")
|
|
fakeImage(t, st, "a/b/latest")
|
|
fakeImage(t, st, "eclipse-temurin/21")
|
|
tests := []struct{ ref, want string }{
|
|
{"legacy", "legacy/rootfs"},
|
|
{"legacy:latest", "legacy/rootfs"},
|
|
{"a:b", "a/b/rootfs"},
|
|
{"a/b", "a/b/latest/rootfs"},
|
|
{"a/b:latest", "a/b/latest/rootfs"},
|
|
{"eclipse-temurin:21", "eclipse-temurin/21/rootfs"},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := st.RootFS(tt.ref)
|
|
if err != nil {
|
|
t.Errorf("RootFS(%q): %v", tt.ref, err)
|
|
continue
|
|
}
|
|
if want := filepath.Join(st.Root, "images", filepath.FromSlash(tt.want)); got != want {
|
|
t.Errorf("RootFS(%q) = %s, want %s", tt.ref, got, want)
|
|
}
|
|
}
|
|
// The legacy fallback must not resolve a namespaced repository to a
|
|
// tag directory of the same path.
|
|
if _, err := st.RootFS("eclipse-temurin"); err == nil || !strings.Contains(err.Error(), "no such image") {
|
|
t.Errorf("RootFS(eclipse-temurin) = %v, want no such image", err)
|
|
}
|
|
if _, err := st.RootFS("a:latest"); err == nil {
|
|
t.Error("RootFS(a:latest) resolved, but only a:b and a/b exist")
|
|
}
|
|
}
|
|
|
|
func TestImportPlacesTaggedImagesInTheNestedLayout(t *testing.T) {
|
|
if _, err := exec.LookPath("tar"); err != nil {
|
|
t.Skip("tar not installed")
|
|
}
|
|
st := Store{Root: t.TempDir()}
|
|
archive := filepath.Join(t.TempDir(), "mini.tar.gz")
|
|
writeTestArchive(t, archive)
|
|
if err := st.Import(archive, "ghcr.io/example/build:2026.09", ImageConfig{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isImageDir(filepath.Join(st.Root, "images", "ghcr.io", "example", "build", "2026.09")) {
|
|
t.Error("expected the image under images/ghcr.io/example/build/2026.09")
|
|
}
|
|
if err := st.Import(archive, "ghcr.io/example/build:2026.09", ImageConfig{}); err == nil || !strings.Contains(err.Error(), "already exists") {
|
|
t.Errorf("got %v, want an already-exists refusal", err)
|
|
}
|
|
if err := st.Import(archive, "Bad:Name", ImageConfig{}); err == nil || !strings.Contains(err.Error(), "invalid image reference") {
|
|
t.Errorf("got %v, want an invalid-reference refusal", err)
|
|
}
|
|
}
|
|
|
|
func TestParseReference(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
want Reference
|
|
wantErr string
|
|
}{
|
|
{"java21", Reference{"java21", "latest"}, ""},
|
|
{"eclipse-temurin:21", Reference{"eclipse-temurin", "21"}, ""},
|
|
{"postgres:17", Reference{"postgres", "17"}, ""},
|
|
{"werkator-build:latest", Reference{"werkator-build", "latest"}, ""},
|
|
{"ghcr.io/example/build-environment:2026.09", Reference{"ghcr.io/example/build-environment", "2026.09"}, ""},
|
|
{"localhost:5000/team/img", Reference{"localhost:5000/team/img", "latest"}, ""},
|
|
{"localhost:5000/team/img:v1", Reference{"localhost:5000/team/img", "v1"}, ""},
|
|
{"a_b.c-d:V1.2_x-y", Reference{"a_b.c-d", "V1.2_x-y"}, ""},
|
|
{"", Reference{}, "empty repository"},
|
|
{":tag", Reference{}, "empty repository"},
|
|
{"Upper", Reference{}, "repository component"},
|
|
{"../escape", Reference{}, "repository component"},
|
|
{"a//b", Reference{}, "repository component"},
|
|
{"img:", Reference{}, "tag"},
|
|
{"img:bad tag", Reference{}, "tag"},
|
|
{"img:-leading", Reference{}, "tag"},
|
|
{"img:v1.tmp", Reference{}, "reserved"},
|
|
{"img@sha256:abc", Reference{}, "digests"},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := ParseReference(tt.in)
|
|
if tt.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("ParseReference(%q) = %v, %v; want error containing %q", tt.in, got, err, tt.wantErr)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil || got != tt.want {
|
|
t.Errorf("ParseReference(%q) = %v, %v; want %v", tt.in, got, err, tt.want)
|
|
}
|
|
}
|
|
if s := (Reference{"x", "latest"}).Short(); s != "x" {
|
|
t.Errorf("Short() = %q", s)
|
|
}
|
|
if s := (Reference{"x", "latest"}).String(); s != "x:latest" {
|
|
t.Errorf("String() = %q", s)
|
|
}
|
|
}
|
|
|
|
func TestImageNameFromArchive(t *testing.T) {
|
|
tests := []struct{ in, want string }{
|
|
{"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"},
|
|
{"/path/to/Base.TAR.GZ", "base"},
|
|
{"rootfs.tgz", "rootfs"},
|
|
{"plain", "plain"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := ImageNameFromArchive(tt.in); got != tt.want {
|
|
t.Errorf("ImageNameFromArchive(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoveDeletesImageAndEmptyRepositoryDirs(t *testing.T) {
|
|
st := Store{Root: t.TempDir()}
|
|
fakeImage(t, st, "ghcr.io/team/tool/1.0")
|
|
fakeImage(t, st, "ghcr.io/team/other/latest")
|
|
fakeImage(t, st, "oldstyle")
|
|
|
|
removed, err := st.Remove("ghcr.io/team/tool:1.0")
|
|
if err != nil || removed != "ghcr.io/team/tool:1.0" {
|
|
t.Fatalf("got %q, %v", removed, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(st.Root, "images", "ghcr.io", "team", "tool")); !os.IsNotExist(err) {
|
|
t.Errorf("repository dir not pruned: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(st.Root, "images", "ghcr.io", "team", "other", "latest", "rootfs")); err != nil {
|
|
t.Errorf("sibling image touched: %v", err)
|
|
}
|
|
if removed, err := st.Remove("oldstyle"); err != nil || removed != "oldstyle" {
|
|
t.Fatalf("legacy layout: got %q, %v", removed, err)
|
|
}
|
|
if _, err := st.Remove("oldstyle"); err == nil || !strings.Contains(err.Error(), "no such image") {
|
|
t.Errorf("removing twice: got %v", err)
|
|
}
|
|
names, _ := st.List()
|
|
if !reflect.DeepEqual(names, []string{"ghcr.io/team/other"}) {
|
|
t.Errorf("remaining: %v", names)
|
|
}
|
|
if leftovers, _ := filepath.Glob(filepath.Join(st.Root, "images", "*.tmp")); len(leftovers) != 0 {
|
|
t.Errorf("tmp leftovers: %v", leftovers)
|
|
}
|
|
}
|