Skip to content

Add volume mount for emulator persistence#122

Merged
silv-io merged 3 commits intomainfrom
silv-io/flc-473
Mar 17, 2026
Merged

Add volume mount for emulator persistence#122
silv-io merged 3 commits intomainfrom
silv-io/flc-473

Conversation

@silv-io
Copy link
Copy Markdown
Member

@silv-io silv-io commented Mar 17, 2026

Mount a host directory into /var/lib/localstack for caching and opt-in persistence (PERSISTENCE=1).

Changes

  • Add Volume field to ContainerConfig and VolumeDir() method for resolving the host path
  • Default path: os.UserCacheDir()/lstk/volume/<container-name> (e.g. ~/Library/Caches/lstk/volume/localstack-aws)
  • Configurable via volume field in [[containers]] config
  • Include volume in default config written on first run
  • Create directory automatically (os.MkdirAll) before container start
  • Add bind mount volumeDir:/var/lib/localstack

Tests

  • Integration subtest in TestStartCommandSetsUpContainerCorrectly verifying the volume bind mount targets /var/lib/localstack

Related

@silv-io silv-io marked this pull request as ready for review March 17, 2026 11:14
Copy link
Copy Markdown
Contributor

@anisaoshafi anisaoshafi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 💯

// If Volume is set in the config, it is returned as-is. Otherwise, a default is computed
// from os.UserCacheDir()/lstk/volume/<container-name>.
func (c *ContainerConfig) VolumeDir() (string, error) {
if c.Volume != "" {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: isn't this never empty since we set a default value in internal/config/config.go line 27? Maybe let's remove the viper default so we generate the default path here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can be empty if someone does a custom config and forgets to set it. I wanted it to be set in the default config though so that it's more discoverable. I can also remove it if needed though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove it for now and then we can revisit later. Maybe discoverability is better solved by just having a big all options example config file.

Copy link
Copy Markdown
Collaborator

@carole-lavillonniere carole-lavillonniere left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just one question

Base automatically changed from silv-io/flc-501 to main March 17, 2026 13:04
@silv-io silv-io closed this Mar 17, 2026
@silv-io silv-io reopened this Mar 17, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

This pull request introduces volume mounting support for containerized environments. The changes add a configurable Volume field to ContainerConfig with a VolumeDir() method that determines the host directory for persistence, integrate volume mounting into container startup, and add corresponding test assertions and cleanup logic.

Changes

Cohort / File(s) Summary
Configuration & Runtime
internal/config/containers.go, internal/container/start.go
Added Volume field and VolumeDir() method to ContainerConfig for resolving host volume paths (custom or cache-based). Integrated volume directory creation and bind mounting at container startup, mounting to /var/lib/localstack.
Integration Tests
test/integration/awsconfig_test.go, test/integration/start_test.go
Added test cleanup hook to remove volume directories (using Docker) before TempDir cleanup finishes, addressing root-owned files. Added test assertion validating volume mount binding in container HostConfig.

Sequence Diagram

sequenceDiagram
    participant Config as ContainerConfig
    participant Start as Container Start
    participant FS as File System
    participant Docker as Docker Engine
    
    Start->>Config: Read Volume field
    Start->>Config: Call VolumeDir()
    Config->>Config: Check if Volume is set
    alt Volume set
        Config->>Config: Return Volume path
    else Volume not set
        Config->>FS: Get user cache directory
        Config->>Config: Construct default path
    end
    Config-->>Start: Return volumeDir
    Start->>FS: Create volume directory (MkdirAll)
    FS-->>Start: Directory created
    Start->>Docker: Mount volumeDir to /var/lib/localstack
    Docker-->>Start: Container ready with volume
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a volume mount capability for emulator persistence, which is the primary objective of the changeset.
Description check ✅ Passed The description is directly related to the changeset, providing clear details about volume mounting, configuration, directory creation, bind mounts, and tests that align with the actual changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch silv-io/flc-473
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can get early access to new features in CodeRabbit.

Enable the early_access setting to enable early access features such as new models, tools, and more.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/container/start.go`:
- Around line 100-108: The code is creating the volume directory (calling
c.VolumeDir() and os.MkdirAll) before containers are filtered by
selectContainersToStart, which can mutate disk or fail even when no start should
occur; move the volume dir resolution and os.MkdirAll call into the actual start
path executed after selectContainersToStart returns (e.g., inside the start
routine that handles selected containers), and only call c.VolumeDir(),
os.MkdirAll and append the runtime.BindMount once you have confirmed the
container is selected to start; update references where binds is constructed so
it is built post-selection rather than in the pre-selection block.

In `@test/integration/awsconfig_test.go`:
- Around line 25-34: The cleanup Docker command in the t.Cleanup closure can
leave dotfiles (hidden files) behind because it runs rm -rf /d/*; update the
cleanup to remove all entries including hidden ones by changing the container
command invoked in the t.Cleanup function that references volumeDir (the
exec.Command call) to use a safe pattern that deletes both dotfiles and normal
files (for example use a shell delete that targets both * and .*, or use find /d
-mindepth 1 -delete) so the root-owned hidden files are removed before Go's
TempDir cleanup runs.

In `@test/integration/start_test.go`:
- Around line 186-189: The test's bind-path check is brittle because
hasBindTarget currently splits bind strings on ":" which breaks on Windows
drive-letter paths; update hasBindTarget to parse bind specs from the end (e.g.,
split on ":" but treat the last segment as optional mode and the second-to-last
segment as the container target, or use strings.LastIndex logic) so it correctly
extracts the container path even when the host path contains ":"; ensure this
logic is used by the volume mount test in start_test.go and that hasBindTarget
still handles both "host:container" and "host:container:mode" forms.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 7c176cd3-8bd2-4a87-ae41-579b2b500303

📥 Commits

Reviewing files that changed from the base of the PR and between f28e1c4 and 5fe22df.

📒 Files selected for processing (4)
  • internal/config/containers.go
  • internal/container/start.go
  • test/integration/awsconfig_test.go
  • test/integration/start_test.go

Comment on lines +100 to +108
volumeDir, err := c.VolumeDir()
if err != nil {
return err
}
if err := os.MkdirAll(volumeDir, 0755); err != nil {
return fmt.Errorf("failed to create volume directory %s: %w", volumeDir, err)
}
binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Volume directory creation happens before start eligibility is decided.

Lines 100-106 run before selectContainersToStart, so lstk start can fail or mutate disk even when the emulator is already running (or port checks fail).

💡 Suggested fix (defer mkdir to actual start path)
-		volumeDir, err := c.VolumeDir()
-		if err != nil {
-			return err
-		}
-		if err := os.MkdirAll(volumeDir, 0755); err != nil {
-			return fmt.Errorf("failed to create volume directory %s: %w", volumeDir, err)
-		}
-		binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"})
+		volumeDir, err := c.VolumeDir()
+		if err != nil {
+			return err
+		}
+		binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"})
 func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error {
 	for _, c := range containers {
+		for _, b := range c.Binds {
+			if b.ContainerPath == "/var/lib/localstack" {
+				if err := os.MkdirAll(b.HostPath, 0755); err != nil {
+					return fmt.Errorf("failed to create volume directory %s: %w", b.HostPath, err)
+				}
+			}
+		}
 		output.EmitStatus(sink, "starting", c.Name, "")
 		containerID, err := rt.Start(ctx, c)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/container/start.go` around lines 100 - 108, The code is creating the
volume directory (calling c.VolumeDir() and os.MkdirAll) before containers are
filtered by selectContainersToStart, which can mutate disk or fail even when no
start should occur; move the volume dir resolution and os.MkdirAll call into the
actual start path executed after selectContainersToStart returns (e.g., inside
the start routine that handles selected containers), and only call
c.VolumeDir(), os.MkdirAll and append the runtime.BindMount once you have
confirmed the container is selected to start; update references where binds is
constructed so it is built post-selection rather than in the pre-selection
block.

Comment on lines +25 to +34
// Runs before t.TempDir() cleanup (LIFO order). The emulator runs as root
// inside the container, so files it writes into the volume are root-owned on
// Linux. Go's TempDir cleanup can't delete them, so we use a Docker container
// to remove them first.
t.Cleanup(func() {
volumeDir := filepath.Join(tmpHome, ".cache", "lstk", "volume")
if _, err := os.Stat(volumeDir); err == nil {
_ = exec.Command("docker", "run", "--rm", "-v", volumeDir+":/d", "alpine", "sh", "-c", "rm -rf /d/*").Run()
}
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cleanup command can leave hidden files behind.

At Line 32, rm -rf /d/* won’t remove dotfiles; hidden root-owned files can still cause temp-dir cleanup failures.

💡 Suggested fix
 	t.Cleanup(func() {
 		volumeDir := filepath.Join(tmpHome, ".cache", "lstk", "volume")
 		if _, err := os.Stat(volumeDir); err == nil {
-			_ = exec.Command("docker", "run", "--rm", "-v", volumeDir+":/d", "alpine", "sh", "-c", "rm -rf /d/*").Run()
+			cmd := exec.Command("docker", "run", "--rm", "-v", volumeDir+":/d", "alpine", "sh", "-c", "find /d -mindepth 1 -delete")
+			if out, err := cmd.CombinedOutput(); err != nil {
+				t.Errorf("failed to cleanup volume dir %s: %v (%s)", volumeDir, err, strings.TrimSpace(string(out)))
+			}
 		}
 	})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Runs before t.TempDir() cleanup (LIFO order). The emulator runs as root
// inside the container, so files it writes into the volume are root-owned on
// Linux. Go's TempDir cleanup can't delete them, so we use a Docker container
// to remove them first.
t.Cleanup(func() {
volumeDir := filepath.Join(tmpHome, ".cache", "lstk", "volume")
if _, err := os.Stat(volumeDir); err == nil {
_ = exec.Command("docker", "run", "--rm", "-v", volumeDir+":/d", "alpine", "sh", "-c", "rm -rf /d/*").Run()
}
})
// Runs before t.TempDir() cleanup (LIFO order). The emulator runs as root
// inside the container, so files it writes into the volume are root-owned on
// Linux. Go's TempDir cleanup can't delete them, so we use a Docker container
// to remove them first.
t.Cleanup(func() {
volumeDir := filepath.Join(tmpHome, ".cache", "lstk", "volume")
if _, err := os.Stat(volumeDir); err == nil {
cmd := exec.Command("docker", "run", "--rm", "-v", volumeDir+":/d", "alpine", "sh", "-c", "find /d -mindepth 1 -delete")
if out, err := cmd.CombinedOutput(); err != nil {
t.Errorf("failed to cleanup volume dir %s: %v (%s)", volumeDir, err, strings.TrimSpace(string(out)))
}
}
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/awsconfig_test.go` around lines 25 - 34, The cleanup Docker
command in the t.Cleanup closure can leave dotfiles (hidden files) behind
because it runs rm -rf /d/*; update the cleanup to remove all entries including
hidden ones by changing the container command invoked in the t.Cleanup function
that references volumeDir (the exec.Command call) to use a safe pattern that
deletes both dotfiles and normal files (for example use a shell delete that
targets both * and .*, or use find /d -mindepth 1 -delete) so the root-owned
hidden files are removed before Go's TempDir cleanup runs.

Comment on lines +186 to +189
t.Run("volume mount", func(t *testing.T) {
assert.True(t, hasBindTarget(inspect.HostConfig.Binds, "/var/lib/localstack"),
"expected volume bind mount to /var/lib/localstack, got: %v", inspect.HostConfig.Binds)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Volume-mount assertion is brittle with Windows-style bind paths.

At Line 187, this relies on hasBindTarget, which splits bind strings on ":"; that can misparse drive-letter host paths and produce false negatives.

💡 Suggested fix (make bind parsing tolerant to host-path colons)
 func hasBindTarget(binds []string, containerPath string) bool {
 	for _, b := range binds {
 		parts := strings.Split(b, ":")
-		if len(parts) >= 2 && parts[1] == containerPath {
+		if len(parts) < 2 {
+			continue
+		}
+		// host:container or host:container:options; host may itself contain ":" (e.g. Windows drive letter)
+		if parts[len(parts)-1] == containerPath || parts[len(parts)-2] == containerPath {
 			return true
 		}
 	}
 	return false
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/start_test.go` around lines 186 - 189, The test's bind-path
check is brittle because hasBindTarget currently splits bind strings on ":"
which breaks on Windows drive-letter paths; update hasBindTarget to parse bind
specs from the end (e.g., split on ":" but treat the last segment as optional
mode and the second-to-last segment as the container target, or use
strings.LastIndex logic) so it correctly extracts the container path even when
the host path contains ":"; ensure this logic is used by the volume mount test
in start_test.go and that hasBindTarget still handles both "host:container" and
"host:container:mode" forms.

@silv-io silv-io merged commit 8c39de8 into main Mar 17, 2026
8 checks passed
@silv-io silv-io deleted the silv-io/flc-473 branch March 17, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants