fix: validate volume mount source paths before spawning detached process#3932
fix: validate volume mount source paths before spawning detached process#3932stakeswky wants to merge 2 commits intostacklok:mainfrom
Conversation
Add pre-flight validation in processVolumeMounts() to check that source paths exist on the host filesystem before proceeding. This catches invalid volume mounts early with a clear error message, instead of silently failing in the detached process where the error is only visible in log files. The validation: - Uses os.Stat() (follows symlinks) to check path existence - Resolves relative paths to absolute before checking - Skips validation for Kubernetes operator context (paths are container-relative) - Skips validation for resource:// URIs Fixes stacklok#2485
The volume path validation added in the previous commit checks that source paths exist on the host filesystem. Tests using hardcoded paths like /host and /host/read fail on CI runners where those paths don't exist. Use t.TempDir() to create real temporary directories instead.
JAORMX
left a comment
There was a problem hiding this comment.
Thanks for the contribution! The overall approach is solid and correctly follows Option 1 from the issue. The operator/CLI context split is handled well.
Two must-fix items before this can merge:
-
Incorrect resource URI check:
strings.HasPrefix(source, "resource://")will not match actual resource URIs likevolume://mydata.MountDeclarationalready providesIsResourceURI()which handles all URI schemes correctly. Use!mount.IsResourceURI()instead. -
Misleading error on non-
ENOENTfailures: Ifos.Statfails for a reason other than "not exists" (e.g., permission denied), the error still says "does not exist". Useos.IsNotExist(err)to distinguish the two cases, as the issue itself suggested.
Additionally, a couple of should-fix items around error message clarity (showing resolved absolute paths for relative inputs) and missing test coverage for the operator context and resource URI skip paths. See inline comments for details.
| return fmt.Errorf("invalid volume format: %s (%w)", volume, err) | ||
| } | ||
|
|
||
| // Validate source path exists on the host filesystem (CLI context only). |
There was a problem hiding this comment.
Must-fix: The resource:// prefix check is incorrect. MountDeclaration.Parse() returns source as scheme + "://" + resource — where scheme is the actual URI scheme (e.g., volume, tmpfs), not the literal string resource. So a mount like volume://mydata:/app returns source = "volume://mydata", which does not start with resource:// and would incorrectly be stat'd on the filesystem.
MountDeclaration already has an IsResourceURI() method that handles this correctly. Suggested fix:
| // Validate source path exists on the host filesystem (CLI context only). | |
| if b.buildContext != BuildContextOperator && !mount.IsResourceURI() { |
This also makes the code self-documenting — it reads as "skip if this is a resource URI" rather than checking a raw string prefix.
| return fmt.Errorf("failed to resolve volume mount source path %q: %w", source, err) | ||
| } | ||
| } |
There was a problem hiding this comment.
Must-fix: All os.Stat errors are reported as "does not exist", but the path could exist and be inaccessible (e.g., permission denied). This would send users debugging in the wrong direction.
The issue itself suggested the correct pattern. Distinguish os.IsNotExist from other errors:
| return fmt.Errorf("failed to resolve volume mount source path %q: %w", source, err) | |
| } | |
| } | |
| if _, err := os.Stat(absSource); err != nil { | |
| if os.IsNotExist(err) { | |
| return fmt.Errorf("volume mount source path does not exist: %s", absSource) | |
| } | |
| return fmt.Errorf("failed to access volume mount source path %s: %w", absSource, err) | |
| } |
Note: this also switches to absSource in the "does not exist" message (see next comment).
| absSource, err = filepath.Abs(absSource) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to resolve volume mount source path %q: %w", source, err) | ||
| } |
There was a problem hiding this comment.
Should-fix: When a relative path is used (e.g., ./data:/app), the error shows the original relative path (./data) rather than the resolved absolute path. If the user's working directory isn't what they expect, this makes debugging harder.
Consider showing absSource instead of source in the error message so the user can see exactly which path was checked.
| name: "Non-existent source path", | ||
| builderOptions: []RunConfigBuilderOption{ | ||
| WithVolumes([]string{"/nonexistent/path/that/does/not/exist:/container"}), | ||
| WithPermissionProfile(permissions.BuiltinNoneProfile()), |
There was a problem hiding this comment.
Should-fix: A couple of test cases are missing that would strengthen confidence in the guard conditions:
-
Operator context with non-existent path: A test using
NewOperatorRunConfigBuilderwithWithVolumes([]string{"/nonexistent/path:/container"})that asserts no error. This proves theBuildContextOperatorskip actually works. Without this, a regression that removes the operator guard would go undetected. -
Resource URI skips validation: A test with a resource URI volume (e.g.,
volume://mydata:/app) that asserts no error. This would have caught theresource://prefix bug noted in the other comment.
Summary
Fixes #2485
When running
thv run --volume <source>:<target>with a non-existent source path, the command previously appeared to succeed but the workload would silently enter an error state. The actual error was only visible in the proxy log file.Changes
pkg/runner/config_builder.go— Added pre-flight validation inprocessVolumeMounts():os.Stat()(follows symlinks)resource://URIsvolume mount source path does not exist: /pathpkg/runner/config_builder_test.go— Updated existing tests and added coverage:t.TempDir()for real source paths instead of hardcoded/hostpathsApproach
Implemented Option 1 from the issue (validate in
processVolumeMounts()) because:config_builder.goBefore / After
Before:
After:
$ thv run --volume /nonexistent/path:/app playwright Error: volume mount source path does not exist: /nonexistent/path