From 6187186c2c315c7fa74f766f3c8bba8c6855ade4 Mon Sep 17 00:00:00 2001 From: dfjxs Date: Fri, 19 Jun 2026 15:01:46 +1000 Subject: [PATCH 1/7] abstract execution --- explorers/podman/export.go | 4 +--- explorers/podman/podman.go | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/explorers/podman/export.go b/explorers/podman/export.go index f780673..0b533f5 100755 --- a/explorers/podman/export.go +++ b/explorers/podman/export.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "os" - "os/exec" "github.com/google/container-explorer/utils" log "github.com/sirupsen/logrus" @@ -75,8 +74,7 @@ func (e *explorer) ExportContainer(ctx context.Context, containerID string, outp // Defer unmount and cleanup of the mountpoint defer func() { log.Infof("cleaning up mountpoint %s for container %s", mountpoint, targetContainer.ID) - unmountCmd := exec.Command("umount", mountpoint) - unmountCmdOutput, unmountErr := unmountCmd.CombinedOutput() // Run and get output/error + unmountCmdOutput, unmountErr := utils.Runner.RunWithoutContext("umount", mountpoint) if unmountErr != nil { log.Warnf("failed to unmount %s: %v; output: %s", mountpoint, unmountErr, string(unmountCmdOutput)) } else { diff --git a/explorers/podman/podman.go b/explorers/podman/podman.go index 9e83ecd..d07e997 100755 --- a/explorers/podman/podman.go +++ b/explorers/podman/podman.go @@ -23,7 +23,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "strings" "time" @@ -651,8 +650,7 @@ func (e *explorer) mountContainer(_ context.Context, podmanRootDir string, conta mountOpt := fmt.Sprintf("ro,lowerdir=%s:%s", upperDir, lowerDir) mountArgs := []string{"-t", "overlay", "overlay", "-o", mountOpt, mountpoint} - cmd := exec.Command("mount", mountArgs...) - out, err := cmd.CombinedOutput() + out, err := utils.Runner.RunWithoutContext("mount", mountArgs...) if err != nil { log.Infof("mount command: mount %s", strings.Join(mountArgs, " ")) if string(out) != "" { From eaa69f36303ae161df09d81b75e270b5b8f8778f Mon Sep 17 00:00:00 2001 From: dfjxs Date: Fri, 19 Jun 2026 15:10:15 +1000 Subject: [PATCH 2/7] podman tests --- explorers/podman/podman_test.go | 785 ++++++++++++++++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 explorers/podman/podman_test.go diff --git a/explorers/podman/podman_test.go b/explorers/podman/podman_test.go new file mode 100644 index 0000000..5f50f86 --- /dev/null +++ b/explorers/podman/podman_test.go @@ -0,0 +1,785 @@ +/* +Copyright 2026 Google LLC + +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 + + https://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 podman + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/google/container-explorer/explorers" + "github.com/google/container-explorer/utils" + _ "github.com/mattn/go-sqlite3" +) + +// helper to populate passwd file +func createMockPasswd(t *testing.T, imageroot string, lines []string) { + passwdDir := filepath.Join(imageroot, "etc") + if err := os.MkdirAll(passwdDir, 0755); err != nil { + t.Fatalf("failed to create etc dir: %v", err) + } + data := []byte("") + for _, line := range lines { + data = append(data, []byte(line+"\n")...) + } + if err := os.WriteFile(filepath.Join(passwdDir, "passwd"), data, 0600); err != nil { + t.Fatalf("failed to write passwd file: %v", err) + } +} + +// helper to create mock SQLite db for tasks +func createMockSQLiteDB(t *testing.T, dbfile string, states map[string]string) { + if err := os.MkdirAll(filepath.Dir(dbfile), 0755); err != nil { + t.Fatalf("failed to create db directory: %v", err) + } + + db, err := sql.Open("sqlite3", dbfile) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + defer db.Close() + + _, err = db.Exec("CREATE TABLE ContainerState (ID TEXT PRIMARY KEY, JSON TEXT)") + if err != nil { + t.Fatalf("failed to create table: %v", err) + } + + for id, stateJSON := range states { + _, err = db.Exec("INSERT INTO ContainerState (ID, JSON) VALUES (?, ?)", id, stateJSON) + if err != nil { + t.Fatalf("failed to insert state: %v", err) + } + } +} + +func TestNewExplorer_NoRootDirs(t *testing.T) { + tmpDir := t.TempDir() + + // NewExplorer should succeed even if passwd is missing, but with empty podmanRootDirs + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + pDirs := exp.(*explorer).podmanRootDirs + if len(pDirs) != 0 { + t.Errorf("expected 0 podman root directories, got %d: %v", len(pDirs), pDirs) + } +} + +func TestNewExplorer_SuccessRootless(t *testing.T) { + tmpDir := t.TempDir() + + createMockPasswd(t, tmpDir, []string{ + "mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash", + }) + graphRoot := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + if err := os.MkdirAll(graphRoot, 0755); err != nil { + t.Fatalf("failed to create graphroot: %v", err) + } + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + if exp.Type() != "podman" { + t.Errorf("expected type 'podman', got '%s'", exp.Type()) + } + + pDirs := exp.(*explorer).podmanRootDirs + if len(pDirs) != 1 { + t.Fatalf("expected 1 podman root directory, got %d: %v", len(pDirs), pDirs) + } + + expectedDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers") + if pDirs[0] != expectedDir { + t.Errorf("expected podman root dir '%s', got '%s'", expectedDir, pDirs[0]) + } +} + +func TestListNamespacesAndSnapshots(t *testing.T) { + // These are not implemented/supported in Podman, verify they return nil, nil + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + graphRoot := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(graphRoot, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + nss, err := exp.ListNamespaces(context.Background()) + if err != nil || nss != nil { + t.Errorf("ListNamespaces expected nil, nil; got %v, %v", nss, err) + } + + snaps, err := exp.ListSnapshots(context.Background()) + if err != nil || snaps != nil { + t.Errorf("ListSnapshots expected nil, nil; got %v, %v", snaps, err) + } + + if exp.SnapshotRoot("overlayfs") != "" { + t.Errorf("SnapshotRoot expected empty string, got '%s'", exp.SnapshotRoot("overlayfs")) + } +} + +func TestListContainers(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + // Case 1: Empty containers list + ctrs, err := exp.ListContainers(context.Background()) + if err != nil { + t.Fatalf("ListContainers failed: %v", err) + } + if len(ctrs) != 0 { + t.Errorf("expected 0 containers, got %d", len(ctrs)) + } + + // Case 2: Populate containers.json + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + metadata := containerMetadata{ + ImageName: "docker.io/library/ubuntu:latest", + ImageID: "i1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + Name: "my-ubuntu-container", + CreatedAt: 1718818818, + } + metadataBytes, _ := json.Marshal(metadata) + + now := time.Now().UTC().Truncate(time.Second) + + configs := []containerConfig{ + { + ID: containerID, + Names: []string{"my-ubuntu-container"}, + Image: "i1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + Layer: "l1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + Metadata: string(metadataBytes), + Created: now.Format(time.RFC3339Nano), + }, + } + configsBytes, _ := json.Marshal(configs) + + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + if err := os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600); err != nil { + t.Fatalf("failed to write containers.json: %v", err) + } + + ctrs, err = exp.ListContainers(context.Background()) + if err != nil { + t.Fatalf("ListContainers failed: %v", err) + } + + if len(ctrs) != 1 { + t.Fatalf("expected 1 container, got %d", len(ctrs)) + } + + ctr := ctrs[0] + if ctr.ID != containerID { + t.Errorf("expected ID %s, got %s", containerID, ctr.ID) + } + if ctr.Name != "my-ubuntu-container" { + t.Errorf("expected Name 'my-ubuntu-container', got '%s'", ctr.Name) + } + if ctr.Image != "docker.io/library/ubuntu:latest" { + t.Errorf("expected Image 'docker.io/library/ubuntu:latest', got '%s'", ctr.Image) + } + if !ctr.CreatedAt.Equal(now) { + t.Errorf("expected CreatedAt %v, got %v", now, ctr.CreatedAt) + } +} + +func TestGetContainerByID(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + metadata := containerMetadata{ + ImageName: "ubuntu:latest", + Name: "my-ubuntu-container", + } + metadataBytes, _ := json.Marshal(metadata) + + configs := []containerConfig{ + { + ID: containerID, + Names: []string{"my-ubuntu-container"}, + Metadata: string(metadataBytes), + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + // Case 1: Search by ID + ctr, err := exp.GetContainerByID(context.Background(), containerID) + if err != nil { + t.Fatalf("GetContainerByID failed: %v", err) + } + if ctr.ID != containerID { + t.Errorf("expected ID %s, got %s", containerID, ctr.ID) + } + + // Case 2: Search by Name + ctr, err = exp.GetContainerByID(context.Background(), "my-ubuntu-container") + if err != nil { + t.Fatalf("GetContainerByID failed: %v", err) + } + if ctr.ID != containerID { + t.Errorf("expected ID %s, got %s", containerID, ctr.ID) + } + + // Case 3: Not found + _, err = exp.GetContainerByID(context.Background(), "non_existent") + if err == nil { + t.Errorf("GetContainerByID expected error for non-existent container, got nil") + } +} + +func TestListImages(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + // Populate images.json + imagesDir := filepath.Join(storageDir, "overlay-images") + if err := os.MkdirAll(imagesDir, 0755); err != nil { + t.Fatalf("failed to create images dir: %v", err) + } + + imageID := "i1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + now := time.Now().UTC().Truncate(time.Second) + + imagesData := []containerImage{ + { + ID: imageID, + Digest: "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + Names: []string{"docker.io/library/ubuntu:latest"}, + Created: now.Format(time.RFC3339Nano), + }, + } + imagesBytes, _ := json.Marshal(imagesData) + if err := os.WriteFile(filepath.Join(imagesDir, "images.json"), imagesBytes, 0600); err != nil { + t.Fatalf("failed to write images.json: %v", err) + } + + // Populate mock manifest + imageManifestDir := filepath.Join(imagesDir, imageID) + if err := os.MkdirAll(imageManifestDir, 0755); err != nil { + t.Fatalf("failed to create image manifest dir: %v", err) + } + mockManifest := struct { + MediaType string `json:"mediaType"` + }{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + } + manifestBytes, _ := json.Marshal(mockManifest) + if err := os.WriteFile(filepath.Join(imageManifestDir, "manifest"), manifestBytes, 0600); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + + imgs, err := exp.ListImages(context.Background()) + if err != nil { + t.Fatalf("ListImages failed: %v", err) + } + + if len(imgs) != 1 { + t.Fatalf("expected 1 image, got %d", len(imgs)) + } + + img := imgs[0] + if img.Name != "docker.io/library/ubuntu:latest" { + t.Errorf("expected Name 'docker.io/library/ubuntu:latest', got '%s'", img.Name) + } + if string(img.Target.Digest) != "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" { + t.Errorf("expected Digest 'sha256:abcd...', got '%s'", string(img.Target.Digest)) + } + if img.Target.MediaType != "application/vnd.oci.image.manifest.v1+json" { + t.Errorf("expected MediaType 'application/vnd.oci...', got '%s'", img.Target.MediaType) + } + if !img.CreatedAt.Equal(now) { + t.Errorf("expected CreatedAt %v, got %v", now, img.CreatedAt) + } +} + +func TestListTasks(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + // 1. Verify ListTasks returns empty when db.sql is missing + tasks, err := exp.ListTasks(context.Background()) + if err != nil { + t.Fatalf("ListTasks failed: %v", err) + } + if len(tasks) != 0 { + t.Errorf("expected 0 tasks, got %d", len(tasks)) + } + + // 2. Create mock SQLite database + dbFile := filepath.Join(storageDir, "db.sql") + + // State 3 corresponds to running, state 5 to paused + // {"state": 3, "pid": 1234} + // {"state": 5, "pid": 5678} + mockStates := map[string]string{ + "container_1": `{"state": 3, "pid": 1234}`, + "container_2": `{"state": 5, "pid": 5678}`, + } + createMockSQLiteDB(t, dbFile, mockStates) + + tasks, err = exp.ListTasks(context.Background()) + if err != nil { + t.Fatalf("ListTasks failed: %v", err) + } + + if len(tasks) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(tasks)) + } + + var t1, t2 explorers.Task + for _, task := range tasks { + if task.Name == "container_1" { + t1 = task + } + if task.Name == "container_2" { + t2 = task + } + } + + if t1.PID != 1234 || t1.Status != "running" { + t.Errorf("expected container_1 PID 1234 and Status 'running', got PID %d Status '%s'", t1.PID, t1.Status) + } + if t2.PID != 5678 || t2.Status != "paused" { + t.Errorf("expected container_2 PID 5678 and Status 'paused', got PID %d Status '%s'", t2.PID, t2.Status) + } +} + +func TestInfoContainer(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + metadata := containerMetadata{ + ImageName: "ubuntu:latest", + Name: "my-ubuntu-container", + } + metadataBytes, _ := json.Marshal(metadata) + + configs := []containerConfig{ + { + ID: containerID, + Names: []string{"my-ubuntu-container"}, + Metadata: string(metadataBytes), + Created: "2026-06-19T04:00:00Z", + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + // Create OCI Spec config.json + cDir := filepath.Join(overlayContainersDir, containerID, "userdata") + if err := os.MkdirAll(cDir, 0755); err != nil { + t.Fatalf("failed to create userdata dir: %v", err) + } + + mockSpec := struct { + Annotations map[string]string `json:"annotations"` + }{ + Annotations: map[string]string{ + "annotation-key": "annotation-value", + }, + } + specBytes, _ := json.Marshal(mockSpec) + if err := os.WriteFile(filepath.Join(cDir, "config.json"), specBytes, 0600); err != nil { + t.Fatalf("failed to write OCI config.json: %v", err) + } + + // Case 1: showSpec = false + info, err := exp.InfoContainer(context.Background(), containerID, false) + if err != nil { + t.Fatalf("InfoContainer failed: %v", err) + } + + // InfoContainer returns: + // struct { + // containers.Container + // Spec any + // } + v := reflect.ValueOf(info) + if v.Kind() != reflect.Struct { + t.Fatalf("expected struct, got %s", v.Kind()) + } + + ctrVal := v.FieldByName("Container") + if !ctrVal.IsValid() { + t.Fatalf("Container field not found") + } + + idVal := ctrVal.FieldByName("ID") + if idVal.String() != containerID { + t.Errorf("expected ID %s, got %s", containerID, idVal.String()) + } + + // Case 2: showSpec = true + infoSpec, err := exp.InfoContainer(context.Background(), containerID, true) + if err != nil { + t.Fatalf("InfoContainer failed: %v", err) + } + // When showSpec=true, it returns ociSpec directly + vSpec := reflect.ValueOf(infoSpec) + annotationsVal := vSpec.FieldByName("Annotations") + if !annotationsVal.IsValid() { + t.Fatalf("Annotations field not found in returned Spec") + } +} + +func TestContainerDrift(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + layerID := "layer-123" + configs := []containerConfig{ + { + ID: containerID, + Layer: layerID, + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + // Setup overlay layer link + overlayDir := filepath.Join(storageDir, "overlay") + layerDir := filepath.Join(overlayDir, layerID) + if err := os.MkdirAll(layerDir, 0755); err != nil { + t.Fatalf("failed to create layer dir: %v", err) + } + + // Write link file -> points to "link-name" + if err := os.WriteFile(filepath.Join(layerDir, "link"), []byte("link-name"), 0600); err != nil { + t.Fatalf("failed to write link file: %v", err) + } + + // Create upperdir: /l/link-name + upperDir := filepath.Join(overlayDir, "l", "link-name") + if err := os.MkdirAll(upperDir, 0755); err != nil { + t.Fatalf("failed to create upperDir: %v", err) + } + + // Write some drift files in upperdir + modifiedFile := filepath.Join(upperDir, "etc-config.conf") + if err := os.WriteFile(modifiedFile, []byte("new settings"), 0600); err != nil { + t.Fatalf("failed to write drift file: %v", err) + } + + drifts, err := exp.ContainerDrift(context.Background(), "", false, containerID) + if err != nil { + t.Fatalf("ContainerDrift failed: %v", err) + } + + if len(drifts) != 1 { + t.Fatalf("expected 1 drift, got %d", len(drifts)) + } + + drift := drifts[0] + if drift.ContainerID != containerID { + t.Errorf("expected ContainerID %s, got %s", containerID, drift.ContainerID) + } + + if len(drift.AddedOrModified) != 1 { + t.Fatalf("expected 1 added/modified file, got %d", len(drift.AddedOrModified)) + } + + expectedPath := "/etc-config.conf" + if drift.AddedOrModified[0].FullPath != expectedPath { + t.Errorf("expected drift path '%s', got '%s'", expectedPath, drift.AddedOrModified[0].FullPath) + } +} + +type mockCommandCall struct { + Name string + Args []string +} + +type mockCommandResponse struct { + Output []byte + Stdout string + Stderr string + Err error +} + +type mockCommandRunner struct { + Calls []mockCommandCall + Responses map[string]mockCommandResponse +} + +func (m *mockCommandRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func (m *mockCommandRunner) RunSeparate(_ context.Context, name string, args []string, stdout, stderr io.Writer) error { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + if r.Stdout != "" { + _, _ = stdout.Write([]byte(r.Stdout)) + } + if r.Stderr != "" { + _, _ = stderr.Write([]byte(r.Stderr)) + } + return r.Err + } + return nil +} + +func (m *mockCommandRunner) RunWithoutContext(name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func TestExportContainer(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + metadata := containerMetadata{ + ImageName: "ubuntu:latest", + Name: "my-ubuntu-container", + } + metadataBytes, _ := json.Marshal(metadata) + + configs := []containerConfig{ + { + ID: containerID, + Names: []string{"my-ubuntu-container"}, + Metadata: string(metadataBytes), + Layer: "layer-123", + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + // Setup overlay layer link and lower + overlayDir := filepath.Join(storageDir, "overlay") + layerDir := filepath.Join(overlayDir, "layer-123") + _ = os.MkdirAll(layerDir, 0755) + _ = os.WriteFile(filepath.Join(layerDir, "link"), []byte("link-name"), 0600) + _ = os.WriteFile(filepath.Join(layerDir, "lower"), []byte("lower-name"), 0600) + + // Override runner + origRunner := utils.Runner + mockRunner := &mockCommandRunner{ + Responses: map[string]mockCommandResponse{ + "losetup": {Stdout: "/dev/loop123\n", Err: nil}, + }, + } + utils.Runner = mockRunner + defer func() { utils.Runner = origRunner }() + + outputDir := filepath.Join(tmpDir, "output") + exportOptions := map[string]bool{ + "archive": true, + "image": true, + } + + err = exp.ExportContainer(context.Background(), containerID, outputDir, exportOptions) + if err != nil { + t.Fatalf("ExportContainer failed: %v", err) + } + + // Verify command calls + callNames := make([]string, len(mockRunner.Calls)) + for i, c := range mockRunner.Calls { + callNames[i] = c.Name + } + t.Logf("mockRunner calls: %v", callNames) + + hasMount := false + hasUmount := false + hasTar := false + for _, name := range callNames { + if name == "mount" { + hasMount = true + } + if name == "umount" { + hasUmount = true + } + if name == "tar" { + hasTar = true + } + } + + if !hasMount { + t.Errorf("expected 'mount' to be executed") + } + if !hasUmount { + t.Errorf("expected 'umount' to be executed") + } + if !hasTar { + t.Errorf("expected 'tar' to be executed") + } +} + +func TestExportAllContainers(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + metadata := containerMetadata{ + ImageName: "ubuntu:latest", + Name: "my-ubuntu-container", + } + metadataBytes, _ := json.Marshal(metadata) + + configs := []containerConfig{ + { + ID: containerID, + Names: []string{"my-ubuntu-container"}, + Metadata: string(metadataBytes), + Layer: "layer-123", + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + // Setup overlay layer link and lower + overlayDir := filepath.Join(storageDir, "overlay") + layerDir := filepath.Join(overlayDir, "layer-123") + _ = os.MkdirAll(layerDir, 0755) + _ = os.WriteFile(filepath.Join(layerDir, "link"), []byte("link-name"), 0600) + _ = os.WriteFile(filepath.Join(layerDir, "lower"), []byte("lower-name"), 0600) + + // Override runner + origRunner := utils.Runner + mockRunner := &mockCommandRunner{ + Responses: map[string]mockCommandResponse{ + "losetup": {Stdout: "/dev/loop123\n", Err: nil}, + }, + } + utils.Runner = mockRunner + defer func() { utils.Runner = origRunner }() + + outputDir := filepath.Join(tmpDir, "output") + exportOptions := map[string]bool{ + "archive": true, + } + + err = exp.ExportAllContainers(context.Background(), outputDir, exportOptions, nil, false) + if err != nil { + t.Fatalf("ExportAllContainers failed: %v", err) + } + + // Verify we attempted to export the container + exportedArchive := filepath.Join(outputDir, containerID+".tar.gz") + hasTar := false + for _, c := range mockRunner.Calls { + if c.Name == "tar" { + hasTar = true + foundOut := false + for _, arg := range c.Args { + if arg == exportedArchive { + foundOut = true + } + } + if !foundOut { + t.Errorf("expected tar command to export to '%s', args were: %v", exportedArchive, c.Args) + } + } + } + if !hasTar { + t.Errorf("expected 'tar' to be executed") + } +} From 079621eaaeb079ce4b8ba6096385b2f249716005 Mon Sep 17 00:00:00 2001 From: dfjxs Date: Fri, 19 Jun 2026 15:28:48 +1000 Subject: [PATCH 3/7] error path tests --- explorers/docker/docker_test.go | 106 ++++++++++++++ explorers/podman/podman_test.go | 244 ++++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+) diff --git a/explorers/docker/docker_test.go b/explorers/docker/docker_test.go index ac91a77..563acba 100644 --- a/explorers/docker/docker_test.go +++ b/explorers/docker/docker_test.go @@ -728,3 +728,109 @@ func TestListTasks_ErrorCases(t *testing.T) { t.Errorf("ListTasks expected error when config.v2.json has invalid JSON, got nil") } } + +func TestListImages_MalformedRepositoriesJSON(t *testing.T) { + tmpDir := t.TempDir() + dockerRoot := filepath.Join(tmpDir, "docker_root") + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(dockerRoot, 0755) + _ = os.Mkdir(containerdRoot, 0755) + + exp, err := NewExplorer("", containerdRoot, dockerRoot) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + overlay2Dir := filepath.Join(dockerRoot, "image", "overlay2") + _ = os.MkdirAll(overlay2Dir, 0755) + _ = os.WriteFile(filepath.Join(overlay2Dir, "repositories.json"), []byte("{malformed json"), 0600) + + imgs, err := exp.ListImages(context.Background()) + if err != nil { + t.Fatalf("ListImages failed: %v", err) + } + if len(imgs) != 0 { + t.Errorf("expected 0 images for malformed repositories.json, got %d", len(imgs)) + } +} + +func TestListImages_MalformedImageContentJSON(t *testing.T) { + tmpDir := t.TempDir() + dockerRoot := filepath.Join(tmpDir, "docker_root") + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(dockerRoot, 0755) + _ = os.Mkdir(containerdRoot, 0755) + + exp, err := NewExplorer("", containerdRoot, dockerRoot) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + overlay2Dir := filepath.Join(dockerRoot, "image", "overlay2") + _ = os.MkdirAll(overlay2Dir, 0755) + + repoData := ImageRepository{ + Repositories: map[string]ImageName{ + "ubuntu": { + "ubuntu:latest": "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + }, + }, + } + repoJSON, _ := json.Marshal(repoData) + _ = os.WriteFile(filepath.Join(overlay2Dir, "repositories.json"), repoJSON, 0600) + + // Create malformed image content file + imageContentDir := filepath.Join(overlay2Dir, "imagedb", "content", "sha256") + _ = os.MkdirAll(imageContentDir, 0755) + imageIDFilename := "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + _ = os.WriteFile(filepath.Join(imageContentDir, imageIDFilename), []byte("{malformed json"), 0600) + + imgs, err := exp.ListImages(context.Background()) + if err != nil { + t.Fatalf("ListImages failed: %v", err) + } + // Malformed image content logs error and skips, but image is still listed with zero CreatedAt + if len(imgs) != 1 { + t.Errorf("expected 1 image, got %d", len(imgs)) + } else if !imgs[0].CreatedAt.IsZero() { + t.Errorf("expected CreatedAt to be zero, got %v", imgs[0].CreatedAt) + } +} + +func TestListImages_InvalidImageDigest(t *testing.T) { + tmpDir := t.TempDir() + dockerRoot := filepath.Join(tmpDir, "docker_root") + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(dockerRoot, 0755) + _ = os.Mkdir(containerdRoot, 0755) + + exp, err := NewExplorer("", containerdRoot, dockerRoot) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + overlay2Dir := filepath.Join(dockerRoot, "image", "overlay2") + _ = os.MkdirAll(overlay2Dir, 0755) + + // Digest without "sha256:" prefix or colon (so Split len is 1) + repoData := ImageRepository{ + Repositories: map[string]ImageName{ + "ubuntu": { + "ubuntu:latest": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + }, + }, + } + repoJSON, _ := json.Marshal(repoData) + _ = os.WriteFile(filepath.Join(overlay2Dir, "repositories.json"), repoJSON, 0600) + + imgs, err := exp.ListImages(context.Background()) + if err != nil { + t.Fatalf("ListImages failed: %v", err) + } + // Invalid digest splits log error and skip, but image is still listed with zero CreatedAt + if len(imgs) != 1 { + t.Errorf("expected 1 image, got %d", len(imgs)) + } else if !imgs[0].CreatedAt.IsZero() { + t.Errorf("expected CreatedAt to be zero, got %v", imgs[0].CreatedAt) + } +} diff --git a/explorers/podman/podman_test.go b/explorers/podman/podman_test.go index 5f50f86..f256f07 100644 --- a/explorers/podman/podman_test.go +++ b/explorers/podman/podman_test.go @@ -783,3 +783,247 @@ func TestExportAllContainers(t *testing.T) { t.Errorf("expected 'tar' to be executed") } } + +func TestListContainers_MalformedMetadata(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + configs := []containerConfig{ + { + ID: "container-1", + Metadata: "{malformed json", + }, + { + ID: "container-2", + Metadata: `{"image-name":"ubuntu","name":"valid-container"}`, + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + ctrs, err := exp.ListContainers(context.Background()) + if err != nil { + t.Fatalf("ListContainers failed: %v", err) + } + + // Should skip container-1 and only return container-2 + if len(ctrs) != 1 { + t.Errorf("expected 1 container, got %d", len(ctrs)) + } else if ctrs[0].ID != "container-2" { + t.Errorf("expected container 'container-2', got '%s'", ctrs[0].ID) + } +} + +func TestListImages_MalformedImagesJSON(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + imagesDir := filepath.Join(storageDir, "overlay-images") + _ = os.MkdirAll(imagesDir, 0755) + _ = os.WriteFile(filepath.Join(imagesDir, "images.json"), []byte("{malformed json"), 0600) + + imgs, err := exp.ListImages(context.Background()) + if err != nil { + t.Fatalf("ListImages failed: %v", err) + } + // Should log error and skip, returning empty list + if len(imgs) != 0 { + t.Errorf("expected 0 images, got %d", len(imgs)) + } +} + +func TestListImages_MissingOrMalformedManifest(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + imagesDir := filepath.Join(storageDir, "overlay-images") + _ = os.MkdirAll(imagesDir, 0755) + + imagesData := []containerImage{ + { + ID: "image-1", + Digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111", + Names: []string{"ubuntu:latest"}, + }, + { + ID: "image-2", + Digest: "sha256:2222222222222222222222222222222222222222222222222222222222222222", + Names: []string{"alpine:latest"}, + }, + } + imagesBytes, _ := json.Marshal(imagesData) + _ = os.WriteFile(filepath.Join(imagesDir, "images.json"), imagesBytes, 0600) + + // image-1: missing manifest + // image-2: malformed manifest + image2ManifestDir := filepath.Join(imagesDir, "image-2") + _ = os.MkdirAll(image2ManifestDir, 0755) + _ = os.WriteFile(filepath.Join(image2ManifestDir, "manifest"), []byte("{malformed json"), 0600) + + imgs, err := exp.ListImages(context.Background()) + if err != nil { + t.Fatalf("ListImages failed: %v", err) + } + + // Should still return both images, but their media types will be empty + if len(imgs) != 2 { + t.Fatalf("expected 2 images, got %d", len(imgs)) + } + for _, img := range imgs { + if img.Target.MediaType != "" { + t.Errorf("expected empty media type, got '%s'", img.Target.MediaType) + } + } +} + +func TestListTasks_ErrorPaths(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + dbFile := filepath.Join(storageDir, "db.sql") + + // Case 1: Query failure (missing ContainerState table) + db, err := sql.Open("sqlite3", dbFile) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + // Create some other table + _, _ = db.Exec("CREATE TABLE Dummy (ID TEXT)") + db.Close() + + _, err = exp.ListTasks(context.Background()) + if err == nil { + t.Errorf("ListTasks expected query error, got nil") + } + + // Clean up db file + _ = os.Remove(dbFile) + + // Case 2: Malformed JSON in ContainerState table + mockStates := map[string]string{ + "container_1": `{malformed json`, + } + createMockSQLiteDB(t, dbFile, mockStates) + + _, err = exp.ListTasks(context.Background()) + if err == nil { + t.Errorf("ListTasks expected json parsing error, got nil") + } +} + +func TestInfoContainer_ErrorPaths(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + metadata := containerMetadata{ + ImageName: "ubuntu:latest", + Name: "my-ubuntu-container", + } + metadataBytes, _ := json.Marshal(metadata) + + configs := []containerConfig{ + { + ID: containerID, + Names: []string{"my-ubuntu-container"}, + Metadata: string(metadataBytes), + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + // Case 1: Missing spec file + _, err = exp.InfoContainer(context.Background(), containerID, true) + if err == nil { + t.Errorf("InfoContainer expected error for missing config.json, got nil") + } + + // Case 2: Malformed spec JSON + cDir := filepath.Join(overlayContainersDir, containerID, "userdata") + _ = os.MkdirAll(cDir, 0755) + _ = os.WriteFile(filepath.Join(cDir, "config.json"), []byte("{malformed json"), 0600) + + _, err = exp.InfoContainer(context.Background(), containerID, true) + if err == nil { + t.Errorf("InfoContainer expected error for malformed config.json, got nil") + } +} + +func TestContainerDrift_MissingLink(t *testing.T) { + tmpDir := t.TempDir() + createMockPasswd(t, tmpDir, []string{"mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash"}) + storageDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + _ = os.MkdirAll(storageDir, 0755) + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + containerID := "c1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" + layerID := "layer-123" + configs := []containerConfig{ + { + ID: containerID, + Layer: layerID, + }, + } + configsBytes, _ := json.Marshal(configs) + overlayContainersDir := filepath.Join(storageDir, "overlay-containers") + _ = os.MkdirAll(overlayContainersDir, 0755) + _ = os.WriteFile(filepath.Join(overlayContainersDir, "containers.json"), configsBytes, 0600) + + // Setup overlay layer directory without the link file + overlayDir := filepath.Join(storageDir, "overlay") + layerDir := filepath.Join(overlayDir, layerID) + _ = os.MkdirAll(layerDir, 0755) + + drifts, err := exp.ContainerDrift(context.Background(), "", false, containerID) + if err != nil { + t.Fatalf("ContainerDrift failed: %v", err) + } + + // ContainerDrift should log warning and continue, returning empty list of drifts + if len(drifts) != 0 { + t.Errorf("expected 0 drifts, got %d", len(drifts)) + } +} From 7c7ad41513dd5e6409d15299ca7c9a271b6d75c4 Mon Sep 17 00:00:00 2001 From: dfjxs Date: Fri, 19 Jun 2026 15:46:39 +1000 Subject: [PATCH 4/7] abstract execution --- explorers/containerd/containerd.go | 4 +--- explorers/containerd/export.go | 4 +--- explorers/docker/docker.go | 4 +--- explorers/docker/export.go | 4 +--- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/explorers/containerd/containerd.go b/explorers/containerd/containerd.go index 5f73ae2..831d2e4 100644 --- a/explorers/containerd/containerd.go +++ b/explorers/containerd/containerd.go @@ -23,7 +23,6 @@ import ( "fmt" "io/fs" "os" - "os/exec" "path/filepath" "strings" @@ -685,8 +684,7 @@ func (e *explorer) MountContainer(ctx context.Context, containerID string, mount log.Debug("container mount command ", mountArgs) - cmd := exec.Command("mount", mountArgs...) - out, err := cmd.CombinedOutput() + out, err := utils.Runner.RunWithoutContext("mount", mountArgs...) if err != nil { log.Errorf("running mount command: %v", err) diff --git a/explorers/containerd/export.go b/explorers/containerd/export.go index 6d8cb11..445049e 100644 --- a/explorers/containerd/export.go +++ b/explorers/containerd/export.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "os" - "os/exec" "github.com/containerd/containerd/namespaces" "github.com/google/container-explorer/explorers" @@ -108,8 +107,7 @@ func (e *explorer) ExportContainer(ctx context.Context, containerID string, outp // Defer unmount and cleanup of the mountpoint defer func() { log.Infof("cleaning up mountpoint %s for container %s", mountpoint, targetContainer.ID) - unmountCmd := exec.Command("umount", mountpoint) - unmountCmdOutput, unmountErr := unmountCmd.CombinedOutput() // Run and get output/error + unmountCmdOutput, unmountErr := utils.Runner.RunWithoutContext("umount", mountpoint) if unmountErr != nil { log.Warnf("failed to unmount %s: %v; output: %s", mountpoint, unmountErr, string(unmountCmdOutput)) } else { diff --git a/explorers/docker/docker.go b/explorers/docker/docker.go index 124eb69..7cf3110 100644 --- a/explorers/docker/docker.go +++ b/explorers/docker/docker.go @@ -23,7 +23,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "strings" "time" @@ -480,8 +479,7 @@ func (e *explorer) mountDockerV2Container(_ context.Context, container ConfigFil mountopts := fmt.Sprintf("ro,lowerdir=%s:%s", upperDir, lowerDir) mountargs := []string{"-t", "overlay", "overlay", "-o", mountopts, mountpoint} - cmd := exec.Command("mount", mountargs...) - out, err := cmd.CombinedOutput() + out, err := utils.Runner.RunWithoutContext("mount", mountargs...) if err != nil { log.Errorf("running mount command: %v", mountargs) diff --git a/explorers/docker/export.go b/explorers/docker/export.go index 91c7ff9..4067681 100644 --- a/explorers/docker/export.go +++ b/explorers/docker/export.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "os" - "os/exec" "github.com/containerd/containerd/namespaces" "github.com/google/container-explorer/explorers" @@ -108,8 +107,7 @@ func (e *explorer) ExportContainer(ctx context.Context, containerID string, outp // Defer unmount and cleanup of the mountpoint defer func() { log.Infof("cleaning up mountpoint %s for container %s", mountpoint, targetContainer.ID) - unmountCmd := exec.Command("umount", mountpoint) - unmountCmdOutput, unmountErr := unmountCmd.CombinedOutput() // Run and get output/error + unmountCmdOutput, unmountErr := utils.Runner.RunWithoutContext("umount", mountpoint) if unmountErr != nil { log.Warnf("failed to unmount %s: %v; output: %s", mountpoint, unmountErr, string(unmountCmdOutput)) } else { From 266aaa214135d165b8b59bc618cc40e98f00e447 Mon Sep 17 00:00:00 2001 From: dfjxs Date: Fri, 19 Jun 2026 15:51:44 +1000 Subject: [PATCH 5/7] export and drift tests --- explorers/containerd/containerd_test.go | 415 ++++++++++++++++++++++++ explorers/docker/docker_test.go | 313 ++++++++++++++++++ 2 files changed, 728 insertions(+) diff --git a/explorers/containerd/containerd_test.go b/explorers/containerd/containerd_test.go index 82a1d21..a84b626 100644 --- a/explorers/containerd/containerd_test.go +++ b/explorers/containerd/containerd_test.go @@ -20,6 +20,7 @@ import ( "context" "encoding/binary" "encoding/json" + "io" "os" "path/filepath" "testing" @@ -31,6 +32,7 @@ import ( "github.com/containerd/containerd/namespaces" "github.com/gogo/protobuf/types" "github.com/google/container-explorer/explorers" + "github.com/google/container-explorer/utils" ocispec "github.com/opencontainers/image-spec/specs-go/v1" oci "github.com/opencontainers/runtime-spec/specs-go" bolt "go.etcd.io/bbolt" @@ -767,3 +769,416 @@ func TestListSnapshots_InvalidKind(t *testing.T) { t.Errorf("ListSnapshots expected error when snapshot Kind is invalid (>255) in metadata.db, got nil. Snaps: %+v", snaps) } } + +type mockCommandCall struct { + Name string + Args []string +} + +type mockCommandResponse struct { + Output []byte + Stdout string + Stderr string + Err error +} + +type mockCommandRunner struct { + Calls []mockCommandCall + Responses map[string]mockCommandResponse +} + +func (m *mockCommandRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func (m *mockCommandRunner) RunSeparate(_ context.Context, name string, args []string, stdout, stderr io.Writer) error { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + if r.Stdout != "" { + _, _ = stdout.Write([]byte(r.Stdout)) + } + if r.Stderr != "" { + _, _ = stderr.Write([]byte(r.Stderr)) + } + return r.Err + } + return nil +} + +func (m *mockCommandRunner) RunWithoutContext(name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func TestExportContainer(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(containerdRoot, 0755) + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + + now := time.Now().UTC().Truncate(time.Second) + + _ = db.Update(func(tx *bolt.Tx) error { + nsStore := metadata.NewNamespaceStore(tx) + return nsStore.Create(context.Background(), "ns1", nil) + }) + + dbStore := metadata.NewDB(db, nil, nil) + cStore := metadata.NewContainerStore(dbStore) + + specObj := oci.Spec{ + Linux: &oci.Linux{ + CgroupsPath: "/default/container-1", + }, + Process: &oci.Process{ + Args: []string{"sleep", "10"}, + }, + } + specJSON, _ := json.Marshal(specObj) + anySpec := &types.Any{ + TypeUrl: "types.containerd.io/opencontainers/runtime-spec/1/Spec", + Value: specJSON, + } + + c := containers.Container{ + ID: "container-1", + Image: "ubuntu:latest", + Snapshotter: "overlayfs", + SnapshotKey: "snap1", + Runtime: containers.RuntimeInfo{ + Name: "io.containerd.runc.v2", + }, + Spec: anySpec, + } + _, err = cStore.Create(namespaces.WithNamespace(context.Background(), "ns1"), c) + if err != nil { + db.Close() + t.Fatalf("failed to create container: %v", err) + } + + _ = db.Update(func(tx *bolt.Tx) error { + // Create meta snapshots (need a parent so lowerdir is not empty) + _ = createMetaSnapshot(tx, "ns1", "overlayfs", "snap1", "snapshot-name-1", "snapshot-name-parent", now) + return createMetaSnapshot(tx, "ns1", "overlayfs", "snapshot-name-parent", "snapshot-name-parent", "", now) + }) + db.Close() + + // Open and populate snapshotter metadata.db + snapshotterDir := filepath.Join(containerdRoot, "io.containerd.snapshotter.v1.overlayfs") + _ = os.MkdirAll(snapshotterDir, 0755) + ssDBPath := filepath.Join(snapshotterDir, "metadata.db") + ssDB, err := bolt.Open(ssDBPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open snapshotter metadata.db: %v", err) + } + _ = ssDB.Update(func(tx *bolt.Tx) error { + _ = createOverlaySnapshot(tx, "snapshot-name-1", 42, 2, "snapshot-name-parent", 10240, now) + return createOverlaySnapshot(tx, "snapshot-name-parent", 41, 2, "", 10240, now) + }) + ssDB.Close() + + // Create directories on disk so Glob matches + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "work"), 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "fs"), 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "41", "fs"), 0755) + + sc, _ := explorers.NewSupportContainer("") + exp, err := NewExplorer("", containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + defer exp.Close() + + // Override runner + origRunner := utils.Runner + mockRunner := &mockCommandRunner{ + Responses: map[string]mockCommandResponse{ + "losetup": {Stdout: "/dev/loop123\n", Err: nil}, + }, + } + utils.Runner = mockRunner + defer func() { utils.Runner = origRunner }() + + outputDir := filepath.Join(tmpDir, "output") + exportOptions := map[string]bool{ + "archive": true, + } + + err = exp.ExportContainer(context.Background(), "container-1", outputDir, exportOptions) + if err != nil { + t.Fatalf("ExportContainer failed: %v", err) + } + + hasMount := false + hasUmount := false + hasTar := false + for _, c := range mockRunner.Calls { + if c.Name == "mount" { + hasMount = true + } + if c.Name == "umount" { + hasUmount = true + } + if c.Name == "tar" { + hasTar = true + } + } + + if !hasMount { + t.Errorf("expected 'mount' to be executed") + } + if !hasUmount { + t.Errorf("expected 'umount' to be executed") + } + if !hasTar { + t.Errorf("expected 'tar' to be executed") + } +} + +func TestExportAllContainers(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(containerdRoot, 0755) + + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + + now := time.Now().UTC().Truncate(time.Second) + + _ = db.Update(func(tx *bolt.Tx) error { + nsStore := metadata.NewNamespaceStore(tx) + return nsStore.Create(context.Background(), "ns1", nil) + }) + + dbStore := metadata.NewDB(db, nil, nil) + cStore := metadata.NewContainerStore(dbStore) + + specObj := oci.Spec{ + Linux: &oci.Linux{ + CgroupsPath: "/default/container-1", + }, + Process: &oci.Process{ + Args: []string{"sleep", "10"}, + }, + } + specJSON, _ := json.Marshal(specObj) + anySpec := &types.Any{ + TypeUrl: "types.containerd.io/opencontainers/runtime-spec/1/Spec", + Value: specJSON, + } + + c := containers.Container{ + ID: "container-1", + Image: "ubuntu:latest", + Snapshotter: "overlayfs", + SnapshotKey: "snap1", + Runtime: containers.RuntimeInfo{ + Name: "io.containerd.runc.v2", + }, + Spec: anySpec, + } + _, err = cStore.Create(namespaces.WithNamespace(context.Background(), "ns1"), c) + if err != nil { + db.Close() + t.Fatalf("failed to create container: %v", err) + } + + _ = db.Update(func(tx *bolt.Tx) error { + _ = createMetaSnapshot(tx, "ns1", "overlayfs", "snap1", "snapshot-name-1", "snapshot-name-parent", now) + return createMetaSnapshot(tx, "ns1", "overlayfs", "snapshot-name-parent", "snapshot-name-parent", "", now) + }) + db.Close() + + snapshotterDir := filepath.Join(containerdRoot, "io.containerd.snapshotter.v1.overlayfs") + _ = os.MkdirAll(snapshotterDir, 0755) + ssDBPath := filepath.Join(snapshotterDir, "metadata.db") + ssDB, err := bolt.Open(ssDBPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open snapshotter metadata.db: %v", err) + } + _ = ssDB.Update(func(tx *bolt.Tx) error { + _ = createOverlaySnapshot(tx, "snapshot-name-1", 42, 2, "snapshot-name-parent", 10240, now) + return createOverlaySnapshot(tx, "snapshot-name-parent", 41, 2, "", 10240, now) + }) + ssDB.Close() + + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "work"), 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "fs"), 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "41", "fs"), 0755) + + sc, _ := explorers.NewSupportContainer("") + exp, err := NewExplorer("", containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + defer exp.Close() + + // Override runner + origRunner := utils.Runner + mockRunner := &mockCommandRunner{ + Responses: map[string]mockCommandResponse{ + "losetup": {Stdout: "/dev/loop123\n", Err: nil}, + }, + } + utils.Runner = mockRunner + defer func() { utils.Runner = origRunner }() + + outputDir := filepath.Join(tmpDir, "output") + exportOptions := map[string]bool{ + "archive": true, + } + + err = exp.ExportAllContainers(namespaces.WithNamespace(context.Background(), "ns1"), outputDir, exportOptions, nil, false) + if err != nil { + t.Fatalf("ExportAllContainers failed: %v", err) + } + + exportedArchive := filepath.Join(outputDir, "container-1.tar.gz") + hasTar := false + for _, c := range mockRunner.Calls { + if c.Name == "tar" { + hasTar = true + foundOut := false + for _, arg := range c.Args { + if arg == exportedArchive { + foundOut = true + } + } + if !foundOut { + t.Errorf("expected tar command to export to '%s', args were: %v", exportedArchive, c.Args) + } + } + } + if !hasTar { + t.Errorf("expected 'tar' to be executed") + } +} + +func TestContainerDrift(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(containerdRoot, 0755) + + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + + now := time.Now().UTC().Truncate(time.Second) + + _ = db.Update(func(tx *bolt.Tx) error { + nsStore := metadata.NewNamespaceStore(tx) + return nsStore.Create(context.Background(), "ns1", nil) + }) + + dbStore := metadata.NewDB(db, nil, nil) + cStore := metadata.NewContainerStore(dbStore) + + specObj := oci.Spec{ + Linux: &oci.Linux{ + CgroupsPath: "/default/container-1", + }, + Process: &oci.Process{ + Args: []string{"sleep", "10"}, + }, + } + specJSON, _ := json.Marshal(specObj) + anySpec := &types.Any{ + TypeUrl: "types.containerd.io/opencontainers/runtime-spec/1/Spec", + Value: specJSON, + } + + c := containers.Container{ + ID: "container-1", + Image: "ubuntu:latest", + Snapshotter: "overlayfs", + SnapshotKey: "snap1", + Runtime: containers.RuntimeInfo{ + Name: "io.containerd.runc.v2", + }, + Spec: anySpec, + } + _, err = cStore.Create(namespaces.WithNamespace(context.Background(), "ns1"), c) + if err != nil { + db.Close() + t.Fatalf("failed to create container: %v", err) + } + + _ = db.Update(func(tx *bolt.Tx) error { + _ = createMetaSnapshot(tx, "ns1", "overlayfs", "snap1", "snapshot-name-1", "snapshot-name-parent", now) + return createMetaSnapshot(tx, "ns1", "overlayfs", "snapshot-name-parent", "snapshot-name-parent", "", now) + }) + db.Close() + + snapshotterDir := filepath.Join(containerdRoot, "io.containerd.snapshotter.v1.overlayfs") + _ = os.MkdirAll(snapshotterDir, 0755) + ssDBPath := filepath.Join(snapshotterDir, "metadata.db") + ssDB, err := bolt.Open(ssDBPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open snapshotter metadata.db: %v", err) + } + _ = ssDB.Update(func(tx *bolt.Tx) error { + _ = createOverlaySnapshot(tx, "snapshot-name-1", 42, 2, "snapshot-name-parent", 10240, now) + return createOverlaySnapshot(tx, "snapshot-name-parent", 41, 2, "", 10240, now) + }) + ssDB.Close() + + // Create directories on disk and put a drift file in the upperdir + upperDir := filepath.Join(snapshotterDir, "snapshots", "42", "fs") + _ = os.MkdirAll(upperDir, 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "work"), 0755) + driftFile := filepath.Join(upperDir, "etc", "test.conf") + _ = os.MkdirAll(filepath.Dir(driftFile), 0755) + _ = os.WriteFile(driftFile, []byte("some config change"), 0600) + + sc, _ := explorers.NewSupportContainer("") + exp, err := NewExplorer("", containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + defer exp.Close() + + drifts, err := exp.ContainerDrift(namespaces.WithNamespace(context.Background(), "ns1"), "", false, "container-1") + if err != nil { + t.Fatalf("ContainerDrift failed: %v", err) + } + + if len(drifts) != 1 { + t.Fatalf("expected 1 drift, got %d", len(drifts)) + } + + drift := drifts[0] + if drift.ContainerID != "container-1" { + t.Errorf("expected drift ContainerID 'container-1', got '%s'", drift.ContainerID) + } + + if len(drift.AddedOrModified) != 1 { + t.Fatalf("expected 1 added/modified file, got %d", len(drift.AddedOrModified)) + } + + expectedPath := "/etc/test.conf" + if drift.AddedOrModified[0].FullPath != expectedPath { + t.Errorf("expected drift path '%s', got '%s'", expectedPath, drift.AddedOrModified[0].FullPath) + } +} diff --git a/explorers/docker/docker_test.go b/explorers/docker/docker_test.go index 563acba..92fa16f 100644 --- a/explorers/docker/docker_test.go +++ b/explorers/docker/docker_test.go @@ -19,12 +19,14 @@ package docker import ( "context" "encoding/json" + "io" "os" "path/filepath" "testing" "time" "github.com/containerd/containerd/metadata" + "github.com/google/container-explorer/utils" bolt "go.etcd.io/bbolt" ) @@ -834,3 +836,314 @@ func TestListImages_InvalidImageDigest(t *testing.T) { t.Errorf("expected CreatedAt to be zero, got %v", imgs[0].CreatedAt) } } + +type mockCommandCall struct { + Name string + Args []string +} + +type mockCommandResponse struct { + Output []byte + Stdout string + Stderr string + Err error +} + +type mockCommandRunner struct { + Calls []mockCommandCall + Responses map[string]mockCommandResponse +} + +func (m *mockCommandRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func (m *mockCommandRunner) RunSeparate(_ context.Context, name string, args []string, stdout, stderr io.Writer) error { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + if r.Stdout != "" { + _, _ = stdout.Write([]byte(r.Stdout)) + } + if r.Stderr != "" { + _, _ = stderr.Write([]byte(r.Stderr)) + } + return r.Err + } + return nil +} + +func (m *mockCommandRunner) RunWithoutContext(name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func TestExportContainer(t *testing.T) { + tmpDir := t.TempDir() + dockerRoot := filepath.Join(tmpDir, "docker_root") + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(dockerRoot, 0755) + _ = os.Mkdir(containerdRoot, 0755) + + // Setup meta.db with namespaces because NewExplorer opens it and ExportContainer lists namespaces + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open bolt db: %v", err) + } + _ = db.Update(func(tx *bolt.Tx) error { + store := metadata.NewNamespaceStore(tx) + return store.Create(context.Background(), "ns1", nil) + }) + db.Close() + + exp, err := NewExplorer("", containerdRoot, dockerRoot) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + cID := "container-1" + containersDir := filepath.Join(dockerRoot, "containers") + cDir := filepath.Join(containersDir, cID) + _ = os.MkdirAll(cDir, 0755) + + dockerConfig := ConfigFile{ + ID: cID, + Name: "/my-test-container", + Driver: "overlay2", + State: State{ + Running: true, + Pid: 1234, + }, + } + data, _ := json.Marshal(dockerConfig) + _ = os.WriteFile(filepath.Join(cDir, "config.v2.json"), data, 0600) + + // Create overlay metadata files so MountContainer doesn't fail reading them + mountIDDir := filepath.Join(dockerRoot, "image", "overlay2", "layerdb", "mounts", cID) + _ = os.MkdirAll(mountIDDir, 0755) + _ = os.WriteFile(filepath.Join(mountIDDir, "mount-id"), []byte("mount-id-123"), 0600) + + mountDir := filepath.Join(dockerRoot, "overlay2", "mount-id-123") + _ = os.MkdirAll(mountDir, 0755) + _ = os.WriteFile(filepath.Join(mountDir, "lower"), []byte("l/layer1"), 0600) + _ = os.WriteFile(filepath.Join(mountDir, "link"), []byte("link-xyz"), 0600) + + // Override runner + origRunner := utils.Runner + mockRunner := &mockCommandRunner{ + Responses: map[string]mockCommandResponse{ + "losetup": {Stdout: "/dev/loop123\n", Err: nil}, + }, + } + utils.Runner = mockRunner + defer func() { utils.Runner = origRunner }() + + outputDir := filepath.Join(tmpDir, "output") + exportOptions := map[string]bool{ + "archive": true, + "image": true, + } + err = exp.ExportContainer(context.Background(), cID, outputDir, exportOptions) + if err != nil { + t.Fatalf("ExportContainer failed: %v", err) + } + + // Verify command calls + callNames := make([]string, len(mockRunner.Calls)) + for i, c := range mockRunner.Calls { + callNames[i] = c.Name + } + t.Logf("mockRunner calls: %v", callNames) + + hasMount := false + hasUmount := false + hasTar := false + for _, name := range callNames { + if name == "mount" { + hasMount = true + } + if name == "umount" { + hasUmount = true + } + if name == "tar" { + hasTar = true + } + } + + if !hasMount { + t.Errorf("expected 'mount' to be executed") + } + if !hasUmount { + t.Errorf("expected 'umount' to be executed") + } + if !hasTar { + t.Errorf("expected 'tar' to be executed") + } +} + +func TestExportAllContainers(t *testing.T) { + tmpDir := t.TempDir() + dockerRoot := filepath.Join(tmpDir, "docker_root") + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(dockerRoot, 0755) + _ = os.Mkdir(containerdRoot, 0755) + + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open bolt db: %v", err) + } + _ = db.Update(func(tx *bolt.Tx) error { + store := metadata.NewNamespaceStore(tx) + return store.Create(context.Background(), "ns1", nil) + }) + db.Close() + + exp, err := NewExplorer("", containerdRoot, dockerRoot) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + cID := "container-1" + containersDir := filepath.Join(dockerRoot, "containers") + cDir := filepath.Join(containersDir, cID) + _ = os.MkdirAll(cDir, 0755) + + dockerConfig := ConfigFile{ + ID: cID, + Name: "/my-test-container", + Driver: "overlay2", + } + data, _ := json.Marshal(dockerConfig) + _ = os.WriteFile(filepath.Join(cDir, "config.v2.json"), data, 0600) + + // Create overlay metadata files so MountContainer doesn't fail reading them + mountIDDir := filepath.Join(dockerRoot, "image", "overlay2", "layerdb", "mounts", cID) + _ = os.MkdirAll(mountIDDir, 0755) + _ = os.WriteFile(filepath.Join(mountIDDir, "mount-id"), []byte("mount-id-123"), 0600) + + mountDir := filepath.Join(dockerRoot, "overlay2", "mount-id-123") + _ = os.MkdirAll(mountDir, 0755) + _ = os.WriteFile(filepath.Join(mountDir, "lower"), []byte("l/layer1"), 0600) + _ = os.WriteFile(filepath.Join(mountDir, "link"), []byte("link-xyz"), 0600) + + // Override runner + origRunner := utils.Runner + mockRunner := &mockCommandRunner{ + Responses: map[string]mockCommandResponse{ + "losetup": {Stdout: "/dev/loop123\n", Err: nil}, + }, + } + utils.Runner = mockRunner + defer func() { utils.Runner = origRunner }() + + outputDir := filepath.Join(tmpDir, "output") + exportOptions := map[string]bool{ + "archive": true, + } + + err = exp.ExportAllContainers(context.Background(), outputDir, exportOptions, nil, false) + if err != nil { + t.Fatalf("ExportAllContainers failed: %v", err) + } + + exportedArchive := filepath.Join(outputDir, cID+".tar.gz") + hasTar := false + for _, c := range mockRunner.Calls { + if c.Name == "tar" { + hasTar = true + foundOut := false + for _, arg := range c.Args { + if arg == exportedArchive { + foundOut = true + } + } + if !foundOut { + t.Errorf("expected tar command to export to '%s', args were: %v", exportedArchive, c.Args) + } + } + } + if !hasTar { + t.Errorf("expected 'tar' to be executed") + } +} + +func TestContainerDrift(t *testing.T) { + tmpDir := t.TempDir() + dockerRoot := filepath.Join(tmpDir, "docker_root") + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(dockerRoot, 0755) + _ = os.Mkdir(containerdRoot, 0755) + + exp, err := NewExplorer("", containerdRoot, dockerRoot) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + cID := "container-1" + containersDir := filepath.Join(dockerRoot, "containers") + cDir := filepath.Join(containersDir, cID) + _ = os.MkdirAll(cDir, 0755) + + dockerConfig := ConfigFile{ + ID: cID, + Name: "/my-test-container", + Driver: "overlay2", + } + data, _ := json.Marshal(dockerConfig) + _ = os.WriteFile(filepath.Join(cDir, "config.v2.json"), data, 0600) + + // Create mount-id file: imageDirName/container.Driver/layerdb/mounts/containerID/mount-id + // Resolves to: dockerRoot/image/overlay2/layerdb/mounts/container-1/mount-id + mountIDDir := filepath.Join(dockerRoot, "image", "overlay2", "layerdb", "mounts", cID) + _ = os.MkdirAll(mountIDDir, 0755) + _ = os.WriteFile(filepath.Join(mountIDDir, "mount-id"), []byte("mount-id-123"), 0600) + + // Create link file: dockerRoot/overlay2/mount-id-123/link + mountDir := filepath.Join(dockerRoot, "overlay2", "mount-id-123") + _ = os.MkdirAll(mountDir, 0755) + _ = os.WriteFile(filepath.Join(mountDir, "link"), []byte("link-xyz"), 0600) + + // Create upperdir: dockerRoot/overlay2/l/link-xyz + upperDir := filepath.Join(dockerRoot, "overlay2", "l", "link-xyz") + _ = os.MkdirAll(upperDir, 0755) + + // Create a drift file in upperdir + driftFile := filepath.Join(upperDir, "etc", "test.conf") + _ = os.MkdirAll(filepath.Dir(driftFile), 0755) + _ = os.WriteFile(driftFile, []byte("some config change"), 0600) + + drifts, err := exp.ContainerDrift(context.Background(), "", false, cID) + if err != nil { + t.Fatalf("ContainerDrift failed: %v", err) + } + + if len(drifts) != 1 { + t.Fatalf("expected 1 drift, got %d", len(drifts)) + } + + drift := drifts[0] + if drift.ContainerID != cID { + t.Errorf("expected drift ContainerID '%s', got '%s'", cID, drift.ContainerID) + } + + if len(drift.AddedOrModified) != 1 { + t.Fatalf("expected 1 added/modified file, got %d", len(drift.AddedOrModified)) + } + + expectedPath := "/etc/test.conf" + if drift.AddedOrModified[0].FullPath != expectedPath { + t.Errorf("expected drift path '%s', got '%s'", expectedPath, drift.AddedOrModified[0].FullPath) + } +} From aa29a9dcf2c7fede45e2a244f1423e1777de7a28 Mon Sep 17 00:00:00 2001 From: dfjxs Date: Fri, 19 Jun 2026 15:55:57 +1000 Subject: [PATCH 6/7] containerd tests --- explorers/containerd/containerd_test.go | 152 ++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/explorers/containerd/containerd_test.go b/explorers/containerd/containerd_test.go index a84b626..c40a569 100644 --- a/explorers/containerd/containerd_test.go +++ b/explorers/containerd/containerd_test.go @@ -1182,3 +1182,155 @@ func TestContainerDrift(t *testing.T) { t.Errorf("expected drift path '%s', got '%s'", expectedPath, drift.AddedOrModified[0].FullPath) } } + +func TestListTasks(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(containerdRoot, 0755) + + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + + _ = db.Update(func(tx *bolt.Tx) error { + nsStore := metadata.NewNamespaceStore(tx) + return nsStore.Create(context.Background(), "ns1", nil) + }) + + dbStore := metadata.NewDB(db, nil, nil) + cStore := metadata.NewContainerStore(dbStore) + + specObj := oci.Spec{ + Linux: &oci.Linux{ + CgroupsPath: "/default/container-1", + }, + Process: &oci.Process{ + Args: []string{"sleep", "10"}, + }, + } + specJSON, _ := json.Marshal(specObj) + anySpec := &types.Any{ + TypeUrl: "types.containerd.io/opencontainers/runtime-spec/1/Spec", + Value: specJSON, + } + + c := containers.Container{ + ID: "container-1", + Image: "ubuntu:latest", + Snapshotter: "overlayfs", + SnapshotKey: "snap1", + Runtime: containers.RuntimeInfo{ + Name: "io.containerd.runc.v2", + }, + Spec: anySpec, + } + _, err = cStore.Create(namespaces.WithNamespace(context.Background(), "ns1"), c) + if err != nil { + db.Close() + t.Fatalf("failed to create container: %v", err) + } + db.Close() + + sc, _ := explorers.NewSupportContainer("") + // Set imageRoot to non-empty + exp, err := NewExplorer("some_image_root", containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + defer exp.Close() + + tasks, err := exp.ListTasks(namespaces.WithNamespace(context.Background(), "ns1")) + if err != nil { + t.Fatalf("ListTasks failed: %v", err) + } + + // Should return 1 task with UNKNOWN status and 0 PID because cgroups path doesn't exist + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + if tasks[0].Name != "container-1" { + t.Errorf("expected task name 'container-1', got '%s'", tasks[0].Name) + } + if tasks[0].Status != "UNKNOWN" { + t.Errorf("expected task status 'UNKNOWN', got '%s'", tasks[0].Status) + } +} + +func TestGetContainerByID(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(containerdRoot, 0755) + + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + + _ = db.Update(func(tx *bolt.Tx) error { + nsStore := metadata.NewNamespaceStore(tx) + return nsStore.Create(context.Background(), "ns1", nil) + }) + + dbStore := metadata.NewDB(db, nil, nil) + cStore := metadata.NewContainerStore(dbStore) + + specObj := oci.Spec{ + Linux: &oci.Linux{ + CgroupsPath: "/default/container-1", + }, + Process: &oci.Process{ + Args: []string{"sleep", "10"}, + }, + } + specJSON, _ := json.Marshal(specObj) + anySpec := &types.Any{ + TypeUrl: "types.containerd.io/opencontainers/runtime-spec/1/Spec", + Value: specJSON, + } + + c := containers.Container{ + ID: "container-1", + Image: "ubuntu:latest", + Snapshotter: "overlayfs", + SnapshotKey: "snap1", + Runtime: containers.RuntimeInfo{ + Name: "io.containerd.runc.v2", + }, + Spec: anySpec, + } + _, err = cStore.Create(namespaces.WithNamespace(context.Background(), "ns1"), c) + if err != nil { + db.Close() + t.Fatalf("failed to create container: %v", err) + } + db.Close() + + sc, _ := explorers.NewSupportContainer("") + exp, err := NewExplorer("some_image_root", containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + defer exp.Close() + + // Case 1: success path + ctr, err := exp.GetContainerByID(namespaces.WithNamespace(context.Background(), "ns1"), "container-1") + if err != nil { + t.Fatalf("GetContainerByID failed: %v", err) + } + if ctr.ID != "container-1" { + t.Errorf("expected container ID 'container-1', got '%s'", ctr.ID) + } + + // Case 2: container not found + _, err = exp.GetContainerByID(namespaces.WithNamespace(context.Background(), "ns1"), "non-existent") + if err == nil { + t.Errorf("expected error for non-existent container ID, got nil") + } +} From 5e384a8b938205bd64ca273201904d3b0459669d Mon Sep 17 00:00:00 2001 From: dfjxs Date: Mon, 22 Jun 2026 08:32:10 +1000 Subject: [PATCH 7/7] check that container is nil --- explorers/containerd/containerd_test.go | 5 ++++- explorers/docker/docker_test.go | 5 ++++- explorers/podman/podman_test.go | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/explorers/containerd/containerd_test.go b/explorers/containerd/containerd_test.go index c40a569..55cd890 100644 --- a/explorers/containerd/containerd_test.go +++ b/explorers/containerd/containerd_test.go @@ -1329,8 +1329,11 @@ func TestGetContainerByID(t *testing.T) { } // Case 2: container not found - _, err = exp.GetContainerByID(namespaces.WithNamespace(context.Background(), "ns1"), "non-existent") + ctr, err = exp.GetContainerByID(namespaces.WithNamespace(context.Background(), "ns1"), "non-existent") if err == nil { t.Errorf("expected error for non-existent container ID, got nil") } + if ctr != nil { + t.Errorf("expected container to be nil on error, got %+v", ctr) + } } diff --git a/explorers/docker/docker_test.go b/explorers/docker/docker_test.go index 92fa16f..9fd616a 100644 --- a/explorers/docker/docker_test.go +++ b/explorers/docker/docker_test.go @@ -663,10 +663,13 @@ func TestGetContainerByID(t *testing.T) { } // Case 2: Container not found - _, err = exp.GetContainerByID(context.Background(), "non_existent") + ctr, err = exp.GetContainerByID(context.Background(), "non_existent") if err == nil { t.Errorf("GetContainerByID expected error for non-existent container, got nil") } + if ctr != nil { + t.Errorf("expected container to be nil on error, got %+v", ctr) + } } func TestListImages_MissingRepositoriesDir(t *testing.T) { diff --git a/explorers/podman/podman_test.go b/explorers/podman/podman_test.go index f256f07..ae42a5e 100644 --- a/explorers/podman/podman_test.go +++ b/explorers/podman/podman_test.go @@ -268,10 +268,13 @@ func TestGetContainerByID(t *testing.T) { } // Case 3: Not found - _, err = exp.GetContainerByID(context.Background(), "non_existent") + ctr, err = exp.GetContainerByID(context.Background(), "non_existent") if err == nil { t.Errorf("GetContainerByID expected error for non-existent container, got nil") } + if ctr != nil { + t.Errorf("expected container to be nil on error, got %+v", ctr) + } } func TestListImages(t *testing.T) {