Skip to content
Open
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
18 changes: 18 additions & 0 deletions pkg/runner/config_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"log/slog"
"maps"
"net/url"
"os"
"path/filepath"
"slices"
"strings"

Expand Down Expand Up @@ -1042,6 +1044,22 @@ func (b *runConfigBuilder) processVolumeMounts() error {
return fmt.Errorf("invalid volume format: %s (%w)", volume, err)
}

// Validate source path exists on the host filesystem (CLI context only).
Copy link
Collaborator

Choose a reason for hiding this comment

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

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:

Suggested change
// 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.

// Skip for Kubernetes operator context where paths are container-relative,
// and for resource URIs which are not filesystem paths.
if b.buildContext != BuildContextOperator && !strings.HasPrefix(source, "resource://") {
absSource := source
if !filepath.IsAbs(absSource) {
absSource, err = filepath.Abs(absSource)
if err != nil {
return fmt.Errorf("failed to resolve volume mount source path %q: %w", source, err)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

}
Comment on lines +1055 to +1057
Copy link
Collaborator

Choose a reason for hiding this comment

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

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:

Suggested change
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).

if _, err := os.Stat(absSource); err != nil {
return fmt.Errorf("volume mount source path does not exist: %s", source)
}
}

// Check for duplicate mount target
if existingSource, isDuplicate := existingMounts[target]; isDuplicate {
slog.Warn("Skipping duplicate mount target", "target", target, "existing_source", existingSource)
Expand Down
25 changes: 19 additions & 6 deletions pkg/runner/config_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ func TestRunConfigBuilder_Build_WithVolumeMounts(t *testing.T) {
// Create a mock environment variable validator
mockValidator := &mockEnvVarValidator{}

// Create temporary directories for volume mount source paths
tmpDir1 := t.TempDir()
tmpDir2 := t.TempDir()
tmpDir3 := t.TempDir()

testCases := []struct {
name string
builderOptions []RunConfigBuilderOption
Expand All @@ -263,7 +268,7 @@ func TestRunConfigBuilder_Build_WithVolumeMounts(t *testing.T) {
{
name: "Volumes without permission profile but with profile name",
builderOptions: []RunConfigBuilderOption{
WithVolumes([]string{"/host:/container"}),
WithVolumes([]string{tmpDir1 + ":/container"}),
WithPermissionProfileNameOrPath(permissions.ProfileNone),
},
expectError: false,
Expand All @@ -273,7 +278,7 @@ func TestRunConfigBuilder_Build_WithVolumeMounts(t *testing.T) {
{
name: "Read-only volume with existing profile",
builderOptions: []RunConfigBuilderOption{
WithVolumes([]string{"/host:/container:ro"}),
WithVolumes([]string{tmpDir1 + ":/container:ro"}),
WithPermissionProfile(permissions.BuiltinNoneProfile()),
},
expectError: false,
Expand All @@ -283,7 +288,7 @@ func TestRunConfigBuilder_Build_WithVolumeMounts(t *testing.T) {
{
name: "Read-write volume with existing profile",
builderOptions: []RunConfigBuilderOption{
WithVolumes([]string{"/host:/container"}),
WithVolumes([]string{tmpDir1 + ":/container"}),
WithPermissionProfile(permissions.BuiltinNoneProfile()),
},
expectError: false,
Expand All @@ -294,9 +299,9 @@ func TestRunConfigBuilder_Build_WithVolumeMounts(t *testing.T) {
name: "Multiple volumes with existing profile",
builderOptions: []RunConfigBuilderOption{
WithVolumes([]string{
"/host1:/container1:ro",
"/host2:/container2",
"/host3:/container3:ro",
tmpDir1 + ":/container1:ro",
tmpDir2 + ":/container2",
tmpDir3 + ":/container3:ro",
}),
WithPermissionProfile(permissions.BuiltinNoneProfile()),
},
Expand All @@ -312,6 +317,14 @@ func TestRunConfigBuilder_Build_WithVolumeMounts(t *testing.T) {
},
expectError: true,
},
{
name: "Non-existent source path",
builderOptions: []RunConfigBuilderOption{
WithVolumes([]string{"/nonexistent/path/that/does/not/exist:/container"}),
WithPermissionProfile(permissions.BuiltinNoneProfile()),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should-fix: A couple of test cases are missing that would strengthen confidence in the guard conditions:

  1. Operator context with non-existent path: A test using NewOperatorRunConfigBuilder with WithVolumes([]string{"/nonexistent/path:/container"}) that asserts no error. This proves the BuildContextOperator skip actually works. Without this, a regression that removes the operator guard would go undetected.

  2. Resource URI skips validation: A test with a resource URI volume (e.g., volume://mydata:/app) that asserts no error. This would have caught the resource:// prefix bug noted in the other comment.

},
expectError: true,
},
}

for _, tc := range testCases {
Expand Down
9 changes: 6 additions & 3 deletions pkg/runner/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,8 @@ func TestRunConfigBuilder(t *testing.T) {
}
host := localhostStr
debug := true
volumes := []string{"/host:/container"}
tmpVolumeDir := t.TempDir()
volumes := []string{tmpVolumeDir + ":/container"}
secretsList := []string{"secret1,target=ENV_VAR1"}
authzConfigPath := "" // Empty to skip loading the authorization configuration
permissionProfile := permissions.ProfileNone
Expand Down Expand Up @@ -1304,9 +1305,11 @@ func TestRunConfigBuilder_VolumeProcessing(t *testing.T) {
runtime := &runtimemocks.MockRuntime{}
validator := &mockEnvVarValidator{}

tmpReadDir := t.TempDir()
tmpWriteDir := t.TempDir()
volumes := []string{
"/host/read:/container/read:ro",
"/host/write:/container/write",
tmpReadDir + ":/container/read:ro",
tmpWriteDir + ":/container/write",
}

config, err := NewRunConfigBuilder(context.Background(), nil, nil, validator,
Expand Down