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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion cli/tui/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const (
dockerNodeModeArg = "--run-node=true"
dockerNodeLogMount = "/spt-node-logs"
dockerNodeLogResultsDirName = ".node-home"
itemFileCreateMode = 0o600
itemFileMountMode = 0o444
itemFileStagingDirPattern = "spt-items-*"
nodeLogDirPattern = "spt-node-logs-*"
)

Expand Down Expand Up @@ -80,6 +83,7 @@ type DockerManager struct {
diagnosticsRec *diagnosticsRecord
diagnosticsStopDone bool
fileMounts []scenario.FileMount
itemFileStagingDir string
}

func skipImagePull(image string) bool {
Expand Down Expand Up @@ -111,10 +115,74 @@ func (dm *DockerManager) SetFileMounts(mounts []scenario.FileMount) error {
if dm.remote != nil {
return dm.remote.SetFileMounts(mounts)
}
dm.fileMounts = append([]scenario.FileMount(nil), mounts...)
dm.cleanupItemFileStagingDir()
if len(mounts) == 0 {
return nil
}

stagingDir, err := os.MkdirTemp("", itemFileStagingDirPattern)
if err != nil {
return fmt.Errorf("create local item file staging directory: %w", err)
}
stagingRoot, err := os.OpenRoot(stagingDir)
if err != nil {
_ = os.RemoveAll(stagingDir)
return fmt.Errorf("open local item file staging directory: %w", err)
}
defer func() { _ = stagingRoot.Close() }()
stagedMounts := make([]scenario.FileMount, 0, len(mounts))
for _, mount := range mounts {
stagedName := filepath.Base(mount.ContainerPath)
stagedPath := filepath.Join(stagingDir, stagedName)
if err := stageItemFile(mount.HostPath, stagingRoot, stagedName); err != nil {
_ = os.RemoveAll(stagingDir)
return fmt.Errorf("stage item file %q: %w", mount.HostPath, err)
}
stagedMounts = append(stagedMounts, scenario.FileMount{
HostPath: stagedPath,
ContainerPath: mount.ContainerPath,
})
}
dm.fileMounts = stagedMounts
dm.itemFileStagingDir = stagingDir
return nil
}

func stageItemFile(sourcePath string, stagingRoot *os.Root, stagedName string) error {
source, err := os.Open(sourcePath) // #nosec G304 -- user-selected item input file
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer func() { _ = source.Close() }()

staged, err := stagingRoot.OpenFile(stagedName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, itemFileCreateMode)
if err != nil {
return fmt.Errorf("create staged copy: %w", err)
}
if _, err = io.Copy(staged, source); err != nil {
_ = staged.Close()
return fmt.Errorf("copy staged file: %w", err)
}
if err = staged.Close(); err != nil {
return fmt.Errorf("close staged file: %w", err)
}
if err = stagingRoot.Chmod(stagedName, itemFileMountMode); err != nil {
return fmt.Errorf("make staged file container-readable: %w", err)
}
return nil
}

func (dm *DockerManager) cleanupItemFileStagingDir() {
dm.fileMounts = nil
if dm.itemFileStagingDir == "" {
return
}
if err := os.RemoveAll(dm.itemFileStagingDir); err != nil {
logging.LogWarn("docker", "failed to remove item file staging directory", "path", dm.itemFileStagingDir, "error", err.Error())
}
dm.itemFileStagingDir = ""
}

func (dm *DockerManager) itemFileBindSpecs() []string {
if len(dm.fileMounts) == 0 {
return nil
Expand Down Expand Up @@ -1094,6 +1162,7 @@ func (dm *DockerManager) Cleanup() error {
}
defer dm.cleanupNodeLogDir()
defer dm.cleanupDiagnosticsDir()
defer dm.cleanupItemFileStagingDir()
if dm.containerID == "" {
return nil
}
Expand Down Expand Up @@ -1178,6 +1247,7 @@ func isNoSuchContainer(err error) bool {

// Close cleans up resources
func (dm *DockerManager) Close() {
dm.cleanupItemFileStagingDir()
if dm.cancel != nil {
dm.cancel()
}
Expand Down
101 changes: 98 additions & 3 deletions cli/tui/docker_client_shim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,86 @@ func TestEnsureImageAvailable_DevImagePresent(t *testing.T) {
}
}

func TestStartContainerInNodeModeMountsItemFiles(t *testing.T) {
func TestSetFileMountsStagesContainerReadableCopy(t *testing.T) {
sourcePath := filepath.Join(t.TempDir(), "items.csv")
content := []byte("item-1\nitem-2\n")
if err := os.WriteFile(sourcePath, content, 0o600); err != nil {
t.Fatalf("write source items file: %v", err)
}

dm := &DockerManager{client: &fakeDockerClient{}, ctx: context.Background()}
mount := scenario.FileMount{HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"}
if err := dm.SetFileMounts([]scenario.FileMount{mount}); err != nil {
t.Fatalf("SetFileMounts() error = %v", err)
}
if len(dm.fileMounts) != 1 {
t.Fatalf("staged mounts = %d, want 1", len(dm.fileMounts))
}
stagedPath := dm.fileMounts[0].HostPath
if stagedPath == sourcePath {
t.Fatal("SetFileMounts() retained restrictive source path instead of staging a copy")
}
if dm.fileMounts[0].ContainerPath != mount.ContainerPath {
t.Fatalf("container path = %q, want %q", dm.fileMounts[0].ContainerPath, mount.ContainerPath)
}
got, err := os.ReadFile(stagedPath)
if err != nil {
t.Fatalf("read staged items file: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("staged content = %q, want %q", got, content)
}
stagedInfo, err := os.Stat(stagedPath)
if err != nil {
t.Fatalf("stat staged items file: %v", err)
}
if gotMode := stagedInfo.Mode().Perm(); gotMode != itemFileMountMode {
t.Fatalf("staged mode = %04o, want %04o", gotMode, itemFileMountMode)
}
sourceInfo, err := os.Stat(sourcePath)
if err != nil {
t.Fatalf("stat source items file: %v", err)
}
if gotMode := sourceInfo.Mode().Perm(); gotMode != 0o600 {
t.Fatalf("source mode changed to %04o, want 0600", gotMode)
}

stagingDir := dm.itemFileStagingDir
if err := dm.Cleanup(); err != nil {
t.Fatalf("Cleanup() error = %v", err)
}
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
t.Fatalf("staging directory still exists after cleanup: %v", err)
}
}

func TestSetFileMountsFailureDoesNotRetainPartialState(t *testing.T) {
sourcePath := filepath.Join(t.TempDir(), "items.csv")
if err := os.WriteFile(sourcePath, []byte("item-1\n"), 0o600); err != nil {
t.Fatalf("write source items file: %v", err)
}
dm := &DockerManager{client: &fakeDockerClient{}, ctx: context.Background()}
mounts := []scenario.FileMount{
{HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"},
{HostPath: filepath.Join(t.TempDir(), "missing.csv"), ContainerPath: "/spt-input/items/mixed-read-items.csv"},
}
if err := dm.SetFileMounts(mounts); err == nil {
t.Fatal("SetFileMounts() error = nil, want staging failure")
}
if dm.itemFileStagingDir != "" || len(dm.fileMounts) != 0 {
t.Fatalf("partial staging state retained: dir=%q mounts=%v", dm.itemFileStagingDir, dm.fileMounts)
}
}

func TestStartContainerInNodeModeMountsStagedItemFile(t *testing.T) {
sourcePath := filepath.Join(t.TempDir(), "items.csv")
if err := os.WriteFile(sourcePath, []byte("item-1\n"), 0o600); err != nil {
t.Fatalf("write source items file: %v", err)
}
f := &fakeDockerClient{}
dm := &DockerManager{client: f, ctx: context.Background()}
mounts := []scenario.FileMount{{HostPath: "/host/items.csv", ContainerPath: "/spt-input/items/read-items.csv"}}
t.Cleanup(dm.Close)
mounts := []scenario.FileMount{{HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"}}
if err := dm.SetFileMounts(mounts); err != nil {
t.Fatalf("SetFileMounts() error = %v", err)
}
Expand All @@ -122,10 +198,29 @@ func TestStartContainerInNodeModeMountsItemFiles(t *testing.T) {
if f.createHostConfig == nil {
t.Fatal("ContainerCreate host config was nil")
}
want := "/host/items.csv:/spt-input/items/read-items.csv:ro"
want := dm.fileMounts[0].HostPath + ":/spt-input/items/read-items.csv:ro"
if !containsString(f.createHostConfig.Binds, want) {
t.Fatalf("host binds = %v, want %q", f.createHostConfig.Binds, want)
}
if strings.Contains(want, sourcePath+":") {
t.Fatalf("container bind used restrictive source directly: %q", want)
}
}

func TestCloseCleansItemFileStagingDir(t *testing.T) {
sourcePath := filepath.Join(t.TempDir(), "items.csv")
if err := os.WriteFile(sourcePath, []byte("item-1\n"), 0o600); err != nil {
t.Fatalf("write source items file: %v", err)
}
dm := &DockerManager{client: &fakeDockerClient{}, ctx: context.Background()}
if err := dm.SetFileMounts([]scenario.FileMount{{HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"}}); err != nil {
t.Fatalf("SetFileMounts() error = %v", err)
}
stagingDir := dm.itemFileStagingDir
dm.Close()
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
t.Fatalf("staging directory still exists after close: %v", err)
}
}

func TestEnsureImageAvailable_DevImageMissing(t *testing.T) {
Expand Down
Loading