diff --git a/docs/tasks.md b/docs/tasks.md index 0d8d8d8e6ff..afc50853244 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -391,6 +391,10 @@ this with `mountPath`. The value at `mountPath` can be anywhere on your pod's fi The path will be available via [variable substitution](#variable-substitution) with `$(workspaces.myworkspace.path)`. +A task can declare that it will not write to the volume by adding `readOnly: true` +to the workspace declaration. This will in turn mark the volumeMount as `readOnly` +on the Task's underlying pod. + The actual volumes must be provided at runtime [in the `TaskRun`](taskruns.md#workspaces). In a future iteration ([#1438](https://github.com/tektoncd/pipeline/issues/1438)) diff --git a/examples/taskruns/workspace-readonly.yaml b/examples/taskruns/workspace-readonly.yaml new file mode 100644 index 00000000000..b50619e3766 --- /dev/null +++ b/examples/taskruns/workspace-readonly.yaml @@ -0,0 +1,47 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-pvc-2 +spec: + resources: + requests: + storage: 5Gi + volumeMode: Filesystem + accessModes: + - ReadWriteOnce +--- +apiVersion: tekton.dev/v1alpha1 +kind: TaskRun +metadata: + generateName: workspaces-readonly- +spec: + workspaces: + - name: write-allowed + persistentVolumeClaim: + claimName: my-pvc-2 + - name: write-disallowed + persistentVolumeClaim: + claimName: my-pvc-2 + taskSpec: + workspaces: + - name: write-allowed + - name: write-disallowed + readOnly: true + steps: + - name: write-allowed + image: ubuntu + script: echo "hello" > $(workspaces.write-allowed.path)/foo + - name: read-allowed + image: ubuntu + script: cat $(workspaces.write-allowed.path)/foo | grep "hello" + - name: write-disallowed + image: ubuntu + script: + echo "goodbye" > $(workspaces.write-disallowed.path)/foo || touch write-failed.txt + test -f write-failed.txt + - name: read-again + # We should get "hello" when reading again because writing "goodbye" to + # the file should have been disallowed. + image: ubuntu + script: + cat $(workspaces.write-disallowed.path)/foo | grep "hello" diff --git a/pkg/apis/pipeline/v1alpha1/workspace_types.go b/pkg/apis/pipeline/v1alpha1/workspace_types.go index 6c61dae2997..a10fa6483b4 100644 --- a/pkg/apis/pipeline/v1alpha1/workspace_types.go +++ b/pkg/apis/pipeline/v1alpha1/workspace_types.go @@ -33,6 +33,9 @@ type WorkspaceDeclaration struct { // MountPath overrides the directory that the volume will be made available at. // +optional MountPath string `json:"mountPath,omitempty"` + // ReadOnly dictates whether a mounted volume is writable. By default this + // field is false and so mounted volumes are writable. + ReadOnly bool `json:"readOnly,omitempty"` } // GetMountPath returns the mountPath for w which is the MountPath if provided or the diff --git a/pkg/workspace/apply.go b/pkg/workspace/apply.go index f2df9f619a9..1755c0dab27 100644 --- a/pkg/workspace/apply.go +++ b/pkg/workspace/apply.go @@ -86,6 +86,7 @@ func Apply(ts v1alpha1.TaskSpec, wb []v1alpha1.WorkspaceBinding) (*v1alpha1.Task Name: vv.Name, MountPath: w.GetMountPath(), SubPath: wb[i].SubPath, + ReadOnly: w.ReadOnly, }) // Only add this volume if it hasn't already been added diff --git a/pkg/workspace/apply_test.go b/pkg/workspace/apply_test.go index 9fd1f4057e7..12c34614fea 100644 --- a/pkg/workspace/apply_test.go +++ b/pkg/workspace/apply_test.go @@ -413,8 +413,44 @@ func TestApply(t *testing.T) { MountPath: "/my/fancy/mount/path", }}, }, - }, - } { + }, { + name: "readOnly true marks volume mount readOnly", + ts: v1alpha1.TaskSpec{ + Workspaces: []v1alpha1.WorkspaceDeclaration{{ + Name: "custom", + MountPath: "/my/fancy/mount/path", + ReadOnly: true, + }}, + }, + workspaces: []v1alpha1.WorkspaceBinding{{ + Name: "custom", + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: "mypvc", + }, + }}, + expectedTaskSpec: v1alpha1.TaskSpec{ + StepTemplate: &corev1.Container{ + VolumeMounts: []corev1.VolumeMount{{ + Name: "ws-twkr2", + MountPath: "/my/fancy/mount/path", + ReadOnly: true, + }}, + }, + Volumes: []corev1.Volume{{ + Name: "ws-twkr2", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: "mypvc", + }, + }, + }}, + Workspaces: []v1alpha1.WorkspaceDeclaration{{ + Name: "custom", + MountPath: "/my/fancy/mount/path", + ReadOnly: true, + }}, + }, + }} { t.Run(tc.name, func(t *testing.T) { ts, err := workspace.Apply(tc.ts, tc.workspaces) if err != nil { diff --git a/test/builder/step.go b/test/builder/step.go index 2b5ec0b6151..1ac47b16131 100644 --- a/test/builder/step.go +++ b/test/builder/step.go @@ -74,6 +74,12 @@ func StepVolumeMount(name, mountPath string, ops ...VolumeMountOp) StepOp { } } +func StepScript(script string) StepOp { + return func(step *v1alpha1.Step) { + step.Script = script + } +} + // StepResources adds ResourceRequirements to the Container (step). func StepResources(ops ...ResourceRequirementsOp) StepOp { return func(step *v1alpha1.Step) { diff --git a/test/builder/task.go b/test/builder/task.go index 5586c4c72f1..03ab160340e 100644 --- a/test/builder/task.go +++ b/test/builder/task.go @@ -168,12 +168,13 @@ func Sidecar(name, image string, ops ...ContainerOp) TaskSpecOp { } // TaskWorkspace adds a workspace declaration. -func TaskWorkspace(name, desc, mountPath string) TaskSpecOp { +func TaskWorkspace(name, desc, mountPath string, readOnly bool) TaskSpecOp { return func(spec *v1alpha1.TaskSpec) { spec.Workspaces = append(spec.Workspaces, v1alpha1.WorkspaceDeclaration{ Name: name, Description: desc, MountPath: mountPath, + ReadOnly: readOnly, }) } } diff --git a/test/builder/task_test.go b/test/builder/task_test.go index b57d3da267b..72310cef541 100644 --- a/test/builder/task_test.go +++ b/test/builder/task_test.go @@ -55,13 +55,14 @@ func TestTask(t *testing.T) { tb.Step("mycontainer", "myimage", tb.StepCommand("/mycmd"), tb.StepArgs( "--my-other-arg=$(inputs.resources.workspace.url)", )), + tb.Step("mycontainer2", "myimage2", tb.StepScript("echo foo")), tb.TaskVolume("foo", tb.VolumeSource(corev1.VolumeSource{ HostPath: &corev1.HostPathVolumeSource{Path: "/foo/bar"}, })), tb.TaskStepTemplate( tb.EnvVar("FRUIT", "BANANA"), ), - tb.TaskWorkspace("bread", "kind of bread", "/bread/path"), + tb.TaskWorkspace("bread", "kind of bread", "/bread/path", false), )) expectedTask := &v1alpha1.Task{ ObjectMeta: metav1.ObjectMeta{Name: "test-task", Namespace: "foo"}, @@ -71,6 +72,9 @@ func TestTask(t *testing.T) { Image: "myimage", Command: []string{"/mycmd"}, Args: []string{"--my-other-arg=$(inputs.resources.workspace.url)"}, + }}, {Script: "echo foo", Container: corev1.Container{ + Name: "mycontainer2", + Image: "myimage2", }}}, Inputs: &v1alpha1.Inputs{ Resources: []v1alpha1.TaskResource{{ @@ -125,6 +129,7 @@ func TestTask(t *testing.T) { Name: "bread", Description: "kind of bread", MountPath: "/bread/path", + ReadOnly: false, }}, }, } diff --git a/test/workspace_test.go b/test/workspace_test.go new file mode 100644 index 00000000000..2756c9f232d --- /dev/null +++ b/test/workspace_test.go @@ -0,0 +1,85 @@ +// +build e2e + +/* +Copyright 2019 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "strings" + "testing" + + tb "github.com/tektoncd/pipeline/test/builder" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + knativetest "knative.dev/pkg/test" +) + +func TestWorkspaceReadOnlyDisallowsWrite(t *testing.T) { + c, namespace := setup(t) + + taskName := "write-disallowed" + taskRunName := "write-disallowed-tr" + + knativetest.CleanupOnInterrupt(func() { tearDown(t, c, namespace) }, t.Logf) + defer tearDown(t, c, namespace) + + task := tb.Task(taskName, namespace, tb.TaskSpec( + tb.Step("attempt-write", "alpine", tb.StepScript("echo foo > /workspace/test/file")), + tb.TaskWorkspace("test", "test workspace", "/workspace/test", true), + )) + if _, err := c.TaskClient.Create(task); err != nil { + t.Fatalf("Failed to create Task: %s", err) + } + + taskRun := tb.TaskRun(taskRunName, namespace, tb.TaskRunSpec( + tb.TaskRunTaskRef(taskName), tb.TaskRunServiceAccountName("default"), + tb.TaskRunWorkspaceEmptyDir("test", ""), + )) + if _, err := c.TaskRunClient.Create(taskRun); err != nil { + t.Fatalf("Failed to create TaskRun: %s", err) + } + + t.Logf("Waiting for TaskRun in namespace %s to finish", namespace) + if err := WaitForTaskRunState(c, taskRunName, TaskRunFailed(taskRunName), "error"); err != nil { + t.Errorf("Error waiting for TaskRun to finish with error: %s", err) + } + + tr, err := c.TaskRunClient.Get(taskRunName, metav1.GetOptions{}) + if err != nil { + t.Errorf("Error retrieving taskrun: %s", err) + } + if tr.Status.PodName == "" { + t.Fatal("Error getting a PodName (empty)") + } + p, err := c.KubeClient.Kube.CoreV1().Pods(namespace).Get(tr.Status.PodName, metav1.GetOptions{}) + + if err != nil { + t.Fatalf("Error getting pod `%s` in namespace `%s`", tr.Status.PodName, namespace) + } + for _, stat := range p.Status.ContainerStatuses { + if strings.Contains(stat.Name, "step-attempt-write") { + req := c.KubeClient.Kube.CoreV1().Pods(namespace).GetLogs(p.Name, &corev1.PodLogOptions{Container: stat.Name}) + logContent, err := req.Do().Raw() + if err != nil { + t.Fatalf("Error getting pod logs for pod `%s` and container `%s` in namespace `%s`", tr.Status.PodName, stat.Name, namespace) + } + if !strings.Contains(string(logContent), "Read-only file system") { + t.Fatalf("Expected read-only file system error but received %v", logContent) + } + } + } +}