Files
werkdock/internal/store/store_test.go
miandmhoennig 4cf7604e8f Pull (#2)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #2
2026-09-10 19:26:55 +02:00

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)
}
}