Repository: score-spec/score-compose
Pull request #92 (2024-03-20 14:52:05)

feat: support container file mode
Up until now we were ignoring the container file mode bits. This PR fixes that, we now allow the file mode to be set on the file as an octal number.

The restrictions are:

1. must always be readable by the owner (> 0400)
2. if the owner does not have write permissions, we enable read only on the bind mount itself
3. otherwise any other file mode that works is accepted.
4. cannot be > 0777


Patch:
diff --git a/README.md b/README.md
index 3c492df..4a0e45f 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,6 @@ The `score.yaml` specification file can be executed against a _Score Implementat
 |---------------------------------------------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | `containers.*.resources.limits` / `containers.*.resources.requests` | none    | **Limits will be validated but ignored.** While the compose specification has some support for this, it is requires particular Docker versions that cannot be relied on. *This should have no impact on Workload execution*.                                                                                                                        |
 | `containers.*.livenessProbe` / `containers.*.readinessProbe`        | none    | **Probes will be validated but ignored.** The Score specification only details K8s-like HTTP probes, but the compose specification only supports direct command execution. We cannot convert between the two reliably. *This should have no impact on Workload execution*. Tracked in [#86](https://github.com/score-spec/score-compose/issues/86). |
-| `containers.*.files[*].mode`                                        | none    | **Mode will be ignored.** We haven't implemented this yet. Tracked in [#88](https://github.com/score-spec/score-compose/issues/88).                                                                                                                                                                                                                 |
 | `containers.*.volumes[*].path`                                      | none    | **Volume mounts with `path` will be rejected.** Docker compose doesn't support sub-path mounts for Docker volumes.                                                                                                                                                                                                                                  |
 
 ## Resource support
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index c55a13f..b280c25 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -21,6 +21,7 @@ import (
 	"os"
 	"path/filepath"
 	"sort"
+	"strconv"
 	"strings"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
@@ -203,14 +204,35 @@ func convertFilesIntoVolumes(workloadName string, containerName string, input []
 		}
 		newName := fmt.Sprintf("%s-files-%d-%s", workloadName, idx, strings.Trim(filepath.Base(file.Target), string(filepath.Separator)))
 		slog.Debug(fmt.Sprintf("Writing %d bytes of content for %s containers.%s.files[%d] to %s", len(content), workloadName, containerName, idx, filepath.Join(filesDir, newName)))
-		if err := os.WriteFile(filepath.Join(filesDir, newName), content, 0644); err != nil {
+
+		// Parse and correct the file mode of the mount. If the user permissions do not allow write, then we enable the read only flag
+		// on the bind mount so that we can still remove the file from disk on the outside without sudo.
+		readOnly := false
+		fileMode := os.FileMode(0644)
+		if file.Mode != nil {
+			newMode, err := strconv.ParseInt(*file.Mode, 8, 32)
+			if err != nil {
+				return nil, fmt.Errorf("containers.%s.files[%d]: failed to parse '%s' as octal", containerName, idx, *file.Mode)
+			} else if newMode > 0777 {
+				return nil, fmt.Errorf("containers.%s.files[%d]: mode must be <= 0777", containerName, idx)
+			} else if newMode&0400 != 0400 {
+				return nil, fmt.Errorf("containers.%s.files[%d]: mode must be at least 0400", containerName, idx)
+			} else if newMode&0600 != 0600 {
+				newMode = newMode | 0600
+				readOnly = true
+			}
+			fileMode = os.FileMode(newMode)
+		}
+
+		if err := os.WriteFile(filepath.Join(filesDir, newName), content, fileMode); err != nil {
 			return nil, fmt.Errorf("containers.%s.files[%d]: failed to write to disk: %w", containerName, idx, err)
 		}
 
 		output = append(output, compose.ServiceVolumeConfig{
-			Type:   "bind",
-			Source: filepath.Join(filesDir, newName),
-			Target: file.Target,
+			Type:     "bind",
+			Source:   filepath.Join(filesDir, newName),
+			Target:   file.Target,
+			ReadOnly: readOnly,
 		})
 	}
 


Test patch:
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index 7bbdedd..c35574c 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -396,3 +396,35 @@ func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 	)
 	assert.EqualError(t, err, "containers.my-container.files[0]: failed to substitute in content: unknown key")
 }
+
+func TestConvertFiles_with_mode(t *testing.T) {
+	td := t.TempDir()
+	out, err := convertFilesIntoVolumes(
+		"my-workload", "my-container",
+		[]score.ContainerFilesElem{
+			{Target: "/ant.txt", Content: util.Ref("stuff")},
+			{Target: "/bat.txt", Content: util.Ref("stuff"), Mode: util.Ref("0600")},
+			{Target: "/cat.txt", Content: util.Ref("stuff"), Mode: util.Ref("0755")},
+			{Target: "/dog.txt", Content: util.Ref("stuff"), Mode: util.Ref("0444")},
+		}, td, func(s string) (string, error) {
+			return "", fmt.Errorf("unknown key")
+		},
+	)
+	assert.NoError(t, err)
+	st, err := os.Stat(filepath.Join(td, "files/my-workload-files-0-ant.txt"))
+	assert.NoError(t, err)
+	assert.Equal(t, 0644, int(st.Mode()))
+	assert.False(t, out[0].ReadOnly)
+	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-1-bat.txt"))
+	assert.NoError(t, err)
+	assert.Equal(t, 0600, int(st.Mode()))
+	assert.False(t, out[1].ReadOnly)
+	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-2-cat.txt"))
+	assert.NoError(t, err)
+	assert.Equal(t, 0755, int(st.Mode()))
+	assert.False(t, out[2].ReadOnly)
+	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-3-dog.txt"))
+	assert.NoError(t, err)
+	assert.Equal(t, 0644, int(st.Mode()))
+	assert.True(t, out[3].ReadOnly)
+}