picoclaw/pkg/agent/sandbox/container_test.go

528 lines
16 KiB
Go

package sandbox
import (
"archive/tar"
"bytes"
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestResolveContainerPath_Relative(t *testing.T) {
got, err := resolveContainerPath("foo/bar.txt")
if err != nil {
t.Fatalf("resolveContainerPath returned error: %v", err)
}
if got != "/workspace/foo/bar.txt" {
t.Fatalf("resolveContainerPath = %q, want %q", got, "/workspace/foo/bar.txt")
}
}
func TestResolveContainerPath_AbsoluteInWorkspace(t *testing.T) {
got, err := resolveContainerPath("/workspace/a/b.txt")
if err != nil {
t.Fatalf("resolveContainerPath returned error: %v", err)
}
if got != "/workspace/a/b.txt" {
t.Fatalf("resolveContainerPath = %q, want %q", got, "/workspace/a/b.txt")
}
}
func TestResolveContainerPath_RejectsEscape(t *testing.T) {
_, err := resolveContainerPath("../../etc/passwd")
if err == nil {
t.Fatal("expected error for path traversal")
}
if !strings.Contains(err.Error(), "outside container workspace") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestResolveContainerPath_RejectsAbsoluteOutsideWorkspace(t *testing.T) {
_, err := resolveContainerPath("/etc/passwd")
if err == nil {
t.Fatal("expected error for absolute path outside workspace")
}
if !strings.Contains(err.Error(), "outside container workspace") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseTopLevelDirEntriesFromTar_KeepImmediateChildren(t *testing.T) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
writeHeader := func(hdr *tar.Header, content []byte) {
t.Helper()
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header failed: %v", err)
}
if len(content) > 0 {
if _, err := tw.Write(content); err != nil {
t.Fatalf("write content failed: %v", err)
}
}
}
writeHeader(&tar.Header{Name: "it", Typeflag: tar.TypeDir, Mode: 0o755}, nil)
writeHeader(&tar.Header{Name: "it/write.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}, []byte("x"))
writeHeader(&tar.Header{Name: "it/sub", Typeflag: tar.TypeDir, Mode: 0o755}, nil)
writeHeader(&tar.Header{Name: "it/sub/nested.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}, []byte("y"))
if err := tw.Close(); err != nil {
t.Fatalf("tar close failed: %v", err)
}
entries, err := parseTopLevelDirEntriesFromTar(tar.NewReader(&buf))
if err != nil {
t.Fatalf("parseTopLevelDirEntriesFromTar failed: %v", err)
}
got := make(map[string]struct{}, len(entries))
for _, e := range entries {
got[e.Name()] = struct{}{}
}
for _, want := range []string{"write.txt", "sub"} {
if _, ok := got[want]; !ok {
t.Fatalf("missing top-level entry %q in %+v", want, got)
}
}
if _, ok := got["nested.txt"]; ok {
t.Fatalf("nested file should not appear in top-level entries: %+v", got)
}
}
func TestBuildExecCommand_DefaultShell(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{})
cmd, wd, err := sb.buildExecCommand(ExecRequest{
Command: "echo hi",
})
if err != nil {
t.Fatalf("buildExecCommand returned error: %v", err)
}
if wd != "/workspace" {
t.Fatalf("working dir = %q, want /workspace", wd)
}
if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" || cmd[2] != "echo hi" {
t.Fatalf("unexpected command: %#v", cmd)
}
}
func TestBuildExecCommand_WithArgs(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{})
cmd, wd, err := sb.buildExecCommand(ExecRequest{
Command: "ls",
Args: []string{"-la", "/workspace"},
})
if err != nil {
t.Fatalf("buildExecCommand returned error: %v", err)
}
if wd != "/workspace" {
t.Fatalf("working dir = %q, want /workspace", wd)
}
if len(cmd) != 3 || cmd[0] != "ls" || cmd[1] != "-la" || cmd[2] != "/workspace" {
t.Fatalf("unexpected command: %#v", cmd)
}
}
func TestBuildExecCommand_WorkingDirUsesResolvedDirectory(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{})
cmd, wd, err := sb.buildExecCommand(ExecRequest{
Command: "cat foo.txt",
WorkingDir: "subdir",
})
if err != nil {
t.Fatalf("buildExecCommand returned error: %v", err)
}
if wd != "/workspace/subdir" {
t.Fatalf("working dir = %q, want /workspace/subdir", wd)
}
if len(cmd) == 0 {
t.Fatal("expected command to be populated")
}
}
func TestContainerSandbox_FailClosedOnStartError(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{})
sb.startErr = errors.New("docker unavailable")
_, err := sb.Exec(context.Background(), ExecRequest{Command: "echo hi"})
if err == nil {
t.Fatal("expected exec error")
}
if !strings.Contains(err.Error(), "docker unavailable") {
t.Fatalf("unexpected exec error: %v", err)
}
_, err = sb.Fs().ReadFile(context.Background(), "a.txt")
if err == nil {
t.Fatal("expected fs read error")
}
if !strings.Contains(err.Error(), "docker unavailable") {
t.Fatalf("unexpected fs read error: %v", err)
}
err = sb.Fs().WriteFile(context.Background(), "a.txt", []byte("x"), true)
if err == nil {
t.Fatal("expected fs write error")
}
if !strings.Contains(err.Error(), "docker unavailable") {
t.Fatalf("unexpected fs write error: %v", err)
}
}
func TestHostDirForContainerPath_Workspace(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{Workspace: "/tmp/ws"})
got, ok := sb.hostDirForContainerPath("/workspace/a/b")
if !ok {
t.Fatal("expected workspace path to resolve")
}
if got != "/tmp/ws/a/b" {
t.Fatalf("host path = %q, want %q", got, "/tmp/ws/a/b")
}
}
func TestHostDirForContainerPath_OutsideWorkspace(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{Workspace: "/tmp/ws"})
if got, ok := sb.hostDirForContainerPath("/etc"); ok || got != "" {
t.Fatalf("expected outside path to be rejected, got (%q, %v)", got, ok)
}
}
func TestHostDirForContainerPath_ReadOnlyWorkspace(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{
Workspace: "/tmp/ws",
WorkspaceAccess: "ro",
})
if got, ok := sb.hostDirForContainerPath("/workspace/a"); ok || got != "" {
t.Fatalf("expected read-only workspace to disable host path mapping, got (%q, %v)", got, ok)
}
}
func TestComputeContainerConfigHash_EnvOrderInsensitive(t *testing.T) {
base := ContainerSandboxConfig{
Image: "img",
Workspace: "/tmp/ws",
Workdir: "/workspace",
Env: map[string]string{
"A": "1",
"B": "2",
},
}
left := computeContainerConfigHash(base)
base.Env = map[string]string{
"B": "2",
"A": "1",
}
right := computeContainerConfigHash(base)
if left != right {
t.Fatalf("expected hash to ignore env key order: %q != %q", left, right)
}
}
func TestComputeContainerConfigHash_ArrayOrderSensitive(t *testing.T) {
base := ContainerSandboxConfig{
Image: "img",
Workspace: "/tmp/ws",
Workdir: "/workspace",
DNS: []string{"1.1.1.1", "8.8.8.8"},
}
left := computeContainerConfigHash(base)
base.DNS = []string{"8.8.8.8", "1.1.1.1"}
right := computeContainerConfigHash(base)
if left == right {
t.Fatal("expected hash to change when array order changes")
}
}
func TestComputeContainerConfigHash_WorkspaceAccessAndRootAffectHash(t *testing.T) {
base := ContainerSandboxConfig{
Image: "img",
Workspace: "/tmp/ws",
Workdir: "/workspace",
WorkspaceAccess: "none",
WorkspaceRoot: "/tmp/sbx-a",
}
left := computeContainerConfigHash(base)
base.WorkspaceAccess = "ro"
if left == computeContainerConfigHash(base) {
t.Fatal("expected hash to change when workspace_access changes")
}
base.WorkspaceAccess = "none"
base.WorkspaceRoot = "/tmp/sbx-b"
if left == computeContainerConfigHash(base) {
t.Fatal("expected hash to change when workspace_root changes")
}
}
func TestBuildDockerUlimit_NumberValue(t *testing.T) {
value := int64(256)
ul, ok := buildDockerUlimit("nproc", config.AgentSandboxDockerUlimitValue{Value: &value})
if !ok || ul == nil {
t.Fatal("expected ulimit to be built")
}
if ul.Soft != 256 || ul.Hard != 256 {
t.Fatalf("unexpected ulimit values: soft=%d hard=%d", ul.Soft, ul.Hard)
}
}
func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) {
root := t.TempDir()
ro := NewContainerSandbox(ContainerSandboxConfig{
Workspace: filepath.Join(root, "ws-ro"),
WorkspaceAccess: "ro",
Workdir: "/workspace",
})
roBinds := ro.binds()
if len(roBinds) == 0 || !strings.HasSuffix(roBinds[0], ":/workspace:ro,Z") {
t.Fatalf("unexpected ro bind: %#v", roBinds)
}
rw := NewContainerSandbox(ContainerSandboxConfig{
Workspace: filepath.Join(root, "ws-rw"),
WorkspaceAccess: "rw",
Workdir: "/workspace",
})
rwBinds := rw.binds()
if len(rwBinds) == 0 || !strings.HasSuffix(rwBinds[0], ":/workspace:rw,Z") {
t.Fatalf("unexpected rw bind: %#v", rwBinds)
}
none := NewContainerSandbox(ContainerSandboxConfig{
Workspace: filepath.Join(root, "ws-none"),
WorkspaceAccess: "none",
Workdir: "/workspace",
ContainerName: "test-none",
})
noneBinds := none.binds()
if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace:rw,Z") {
t.Fatalf("expected none bind to isolated workspace, got: %#v", noneBinds)
}
}
func TestContainerSandbox_RegistryPath_UsesSandboxStateDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
sb := NewContainerSandbox(ContainerSandboxConfig{
Workspace: "/tmp/ws",
WorkspaceRoot: "/tmp/sbx",
})
want := filepath.Join(home, ".picoclaw", "sandboxes", "containers.json")
if got := sb.registryPath(); got != want {
t.Fatalf("registryPath = %q, want %q", got, want)
}
}
func TestContainerSandbox_RegistryPath_UsesPicoClawHomeOverride(t *testing.T) {
picoHome := t.TempDir()
t.Setenv("PICOCLAW_HOME", picoHome)
sb := NewContainerSandbox(ContainerSandboxConfig{})
want := filepath.Join(picoHome, "sandboxes", "containers.json")
if got := sb.registryPath(); got != want {
t.Fatalf("registryPath = %q, want %q", got, want)
}
}
func TestContainerSandbox_Start_BlockedSecurityConfig(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{
Network: "host",
})
err := sb.Start(context.Background())
if err == nil {
t.Fatal("expected start to fail for blocked network mode")
}
if !strings.Contains(err.Error(), "network mode") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestContainerSandbox_StartCreatesWorkspaceBeforeDockerPing(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "workspace")
workspaceRoot := filepath.Join(t.TempDir(), "sandbox-root")
sb := NewContainerSandbox(ContainerSandboxConfig{
Workspace: workspace,
WorkspaceRoot: workspaceRoot,
WorkspaceAccess: "none",
})
err := sb.Start(context.Background())
if err == nil {
_ = sb.Prune(context.Background())
t.Skip("docker daemon available in this environment; skip unavailable-path assertion")
}
if !strings.Contains(err.Error(), "docker daemon unavailable") {
t.Fatalf("Start() unexpected error: %v", err)
}
if _, stErr := os.Stat(workspace); stErr != nil {
t.Fatalf("workspace should be created before docker ping: %v", stErr)
}
if _, stErr := os.Stat(workspaceRoot); stErr != nil {
t.Fatalf("workspaceRoot should be created before docker ping: %v", stErr)
}
}
func TestContainerSandbox_NoopPruneWithoutClient(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{
PruneIdleHours: 1,
PruneMaxAgeDays: 0,
})
if err := sb.Prune(context.Background()); err != nil {
t.Fatalf("Prune() with nil client should be noop, got: %v", err)
}
}
func TestParseByteLimitAndHostConfig(t *testing.T) {
if got, err := parseByteLimit("1024"); err != nil || got != 1024 {
t.Fatalf("parseByteLimit numeric got (%d,%v), want (1024,nil)", got, err)
}
if got, err := parseByteLimit("1g"); err != nil || got <= 0 {
t.Fatalf("parseByteLimit unit got (%d,%v), want positive", got, err)
}
if _, err := parseByteLimit("not-a-size"); err == nil {
t.Fatal("expected parseByteLimit invalid input error")
}
soft := int64(256)
hard := int64(512)
sb := NewContainerSandbox(ContainerSandboxConfig{
Workspace: t.TempDir(),
Workdir: "/workspace",
ReadOnlyRoot: true,
Network: "none",
CapDrop: []string{"ALL"},
Tmpfs: []string{"/tmp:rw,noexec,nosuid", " ", "/run"},
PidsLimit: 123,
Memory: "1g",
MemorySwap: "2g",
Cpus: 1.5,
SeccompProfile: "sec-profile.json",
ApparmorProfile: "apparmor-profile",
Ulimits: map[string]config.AgentSandboxDockerUlimitValue{
"b": {Soft: &soft},
"a": {Hard: &hard},
},
})
hc, err := sb.hostConfig()
if err != nil {
t.Fatalf("hostConfig() error: %v", err)
}
if !hc.ReadonlyRootfs {
t.Fatal("expected readonly rootfs")
}
if hc.Resources.PidsLimit == nil || *hc.Resources.PidsLimit != 123 {
t.Fatalf("unexpected pids limit: %#v", hc.Resources.PidsLimit)
}
if hc.Memory <= 0 || hc.MemorySwap <= 0 || hc.NanoCPUs <= 0 {
t.Fatalf(
"expected memory/swap/cpu limits set, got mem=%d swap=%d cpu=%d",
hc.Memory,
hc.MemorySwap,
hc.NanoCPUs,
)
}
if len(hc.Tmpfs) != 2 || hc.Tmpfs["/run"] != "" {
t.Fatalf("unexpected tmpfs map: %#v", hc.Tmpfs)
}
if got := strings.Join(
hc.SecurityOpt,
",",
); !strings.Contains(got, "seccomp=sec-profile.json") ||
!strings.Contains(got, "apparmor=apparmor-profile") {
t.Fatalf("security options missing expected profiles: %v", hc.SecurityOpt)
}
if len(hc.Resources.Ulimits) != 2 {
t.Fatalf("expected 2 ulimits, got %d", len(hc.Resources.Ulimits))
}
gotNames := []string{hc.Resources.Ulimits[0].Name, hc.Resources.Ulimits[1].Name}
sorted := append([]string{}, gotNames...)
sort.Strings(sorted)
if gotNames[0] != sorted[0] || gotNames[1] != sorted[1] {
t.Fatalf("expected deterministic sorted ulimits, got %v", gotNames)
}
}
func TestHostConfigRejectsInvalidMemorySettings(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{
Memory: "bad",
})
if _, err := sb.hostConfig(); err == nil || !strings.Contains(err.Error(), "invalid docker.memory") {
t.Fatalf("expected invalid docker.memory error, got %v", err)
}
sb = NewContainerSandbox(ContainerSandboxConfig{
MemorySwap: "bad",
})
if _, err := sb.hostConfig(); err == nil || !strings.Contains(err.Error(), "invalid docker.memory_swap") {
t.Fatalf("expected invalid docker.memory_swap error, got %v", err)
}
}
func TestBuildDockerUlimitVariants(t *testing.T) {
if _, ok := buildDockerUlimit(" ", config.AgentSandboxDockerUlimitValue{}); ok {
t.Fatal("expected empty-name ulimit to be rejected")
}
if _, ok := buildDockerUlimit("nofile", config.AgentSandboxDockerUlimitValue{}); ok {
t.Fatal("expected empty ulimit value to be rejected")
}
soft := int64(10)
ul, ok := buildDockerUlimit("nofile", config.AgentSandboxDockerUlimitValue{Soft: &soft})
if !ok || ul == nil || ul.Soft != 10 || ul.Hard != 10 {
t.Fatalf("expected soft-only to mirror hard, got %#v ok=%v", ul, ok)
}
hard := int64(20)
ul, ok = buildDockerUlimit("nofile", config.AgentSandboxDockerUlimitValue{Hard: &hard})
if !ok || ul == nil || ul.Soft != 20 || ul.Hard != 20 {
t.Fatalf("expected hard-only to mirror soft, got %#v ok=%v", ul, ok)
}
}
func TestContainerHelpers(t *testing.T) {
if got := shellEscape("a'b"); got != "'a'\"'\"'b'" {
t.Fatalf("shellEscape() got %q", got)
}
if osTempDir() == "" {
t.Fatal("osTempDir() should not be empty")
}
sb := NewContainerSandbox(ContainerSandboxConfig{SetupCommand: " "})
if err := sb.runSetupCommand(context.Background()); err != nil {
t.Fatalf("runSetupCommand() empty command should be nil, got %v", err)
}
}
func TestWaitExecDoneContextCancel(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{})
ctx, cancel := context.WithCancel(context.Background())
cancel()
code, err := sb.waitExecDone(ctx, "unused")
if err == nil {
t.Fatal("expected context cancellation error")
}
if code != 1 {
t.Fatalf("unexpected exit code: %d", code)
}
}
func TestContainerSandbox_StopWithoutClient(t *testing.T) {
sb := NewContainerSandbox(ContainerSandboxConfig{
PruneIdleHours: 1,
PruneMaxAgeDays: 1,
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := sb.Prune(ctx); err != nil {
t.Fatalf("Prune() error: %v", err)
}
}