Repository: numaproj/numaflow
Pull request #1349 (2023-11-06 18:53:03)

feat: validate patched data for pipelines
Explain what this PR does.

Validate patched data for Numaflow pipelines for v1.1 APIs.

<!--

Before you push your changes:

* Run `make pre-push -B` to fix codegen and lint problems.

Then, you MUST:

* Sign-off your commit (otherwise the DCO check will fail).
* Use [a conventional commit message](https://www.conventionalcommits.org/en/v1.0.0/) (otherwise the commit message check will fail).

If you did not do this, reset all your commit and replace them with a single commit:

```
git reset HEAD~1 ;# change 1 to how many commits you made
git commit --signoff -m 'feat: my feat. Fixes #1234'
```

When creating your PR: 

* Make sure that "Fixes #" or "Closes #" is in both the PR title (for release notes) and description (to automatically link and close the issue).
* Say how you tested your changes. If you changed the UI, attach screenshots.
* Set your PR as a draft initially.
* Your PR needs to pass the required checks before it can be approved. 
* Once required tests have passed, mark your PR "Ready for review".

If changes were requested, once you've made them, you MUST dismiss the review to get it reviewed again.

-->



Patch:
diff --git a/server/apis/v1/handler.go b/server/apis/v1/handler.go
index e2c5f4a4..7f53b340 100644
--- a/server/apis/v1/handler.go
+++ b/server/apis/v1/handler.go
@@ -24,6 +24,7 @@ import (
 	"io"
 	"math"
 	"net/http"
+	"reflect"
 	"sort"
 	"strconv"
 	"strings"
@@ -442,7 +443,12 @@ func (h *handler) PatchPipeline(c *gin.Context) {
 		return
 	}
 
-	// TODO: validate the patched data as well, e.g. only allow lifecycle to be patched
+	err = validatePipelinePatch(patchSpec)
+	if err != nil {
+		h.respondWithError(c, fmt.Sprintf("Failed to patch pipeline %q, %s", pipeline, err.Error()))
+		return
+	}
+
 	if _, err := h.numaflowClient.Pipelines(ns).Patch(context.Background(), pipeline, types.MergePatchType, patchSpec, metav1.PatchOptions{}); err != nil {
 		h.respondWithError(c, fmt.Sprintf("Failed to patch pipeline %q, %s", pipeline, err.Error()))
 		return
@@ -992,6 +998,26 @@ func validateNamespace(h *handler, pipeline *dfv1.Pipeline, ns string) error {
 	return nil
 }
 
+// validatePipelinePatch is used to validate the patch for a pipeline
+func validatePipelinePatch(patch []byte) error {
+
+	var patchSpec dfv1.Pipeline
+	var emptySpec dfv1.Pipeline
+
+	// check that patch is correctly formatted for pipeline
+	if err := json.Unmarshal(patch, &patchSpec); err != nil {
+		return err
+	}
+
+	// compare patch to empty pipeline spec to check that only lifecycle is being patched
+	patchSpec.Spec.Lifecycle = dfv1.Lifecycle{}
+	if !reflect.DeepEqual(patchSpec, emptySpec) {
+		return fmt.Errorf("only spec.lifecycle is allowed for patching")
+	}
+
+	return nil
+}
+
 func daemonSvcAddress(ns, pipeline string) string {
 	return fmt.Sprintf("%s.%s.svc:%d", fmt.Sprintf("%s-daemon-svc", pipeline), ns, dfv1.DaemonServicePort)
 }


Test patch:
diff --git a/server/apis/v1/handler_test.go b/server/apis/v1/handler_test.go
new file mode 100644
index 00000000..2fa52a77
--- /dev/null
+++ b/server/apis/v1/handler_test.go
@@ -0,0 +1,39 @@
+/*
+Copyright 2022 The Numaproj 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 v1
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+var (
+	validPatchSpec   = `{"spec": {"lifecycle": {"desiredPhase": "Paused"}}}`
+	invalidPatchSpec = `{"spec": {"limits": {"readTimeout": "5s"}}}`
+)
+
+func TestValidatePipelinePatch(t *testing.T) {
+
+	err := validatePipelinePatch([]byte(validPatchSpec))
+	assert.NoError(t, err)
+
+	err = validatePipelinePatch([]byte(invalidPatchSpec))
+	assert.Error(t, err)
+	assert.Equal(t, "only spec.lifecycle is allowed for patching", err.Error())
+
+}
diff --git a/test/api-e2e/api_test.go b/test/api-e2e/api_test.go
index b369736e..f945d0c9 100644
--- a/test/api-e2e/api_test.go
+++ b/test/api-e2e/api_test.go
@@ -172,6 +172,17 @@ func (s *APISuite) TestPipeline0() {
 	assert.Contains(s.T(), createPipeline1, createPipelineSuccessExpect)
 	assert.Contains(s.T(), createPipeline2, createPipelineSuccessExpect)
 
+	var patchPipelineSuccessExpect = `{"data":null}`
+	pausePipeline1 := HTTPExpect(s.T(), "https://localhost:8443").PATCH(fmt.Sprintf("/api/v1/namespaces/%s/pipelines/%s", Namespace, testPipeline1Name)).WithBytes(testPipeline1Pause).
+		Expect().
+		Status(200).Body().Raw()
+	assert.Contains(s.T(), pausePipeline1, patchPipelineSuccessExpect)
+
+	resumePipeline1 := HTTPExpect(s.T(), "https://localhost:8443").PATCH(fmt.Sprintf("/api/v1/namespaces/%s/pipelines/%s", Namespace, testPipeline1Name)).WithBytes([]byte(testPipeline1Resume)).
+		Expect().
+		Status(200).Body().Raw()
+	assert.Contains(s.T(), resumePipeline1, patchPipelineSuccessExpect)
+
 	clusterSummaryBody := HTTPExpect(s.T(), "https://localhost:8443").GET("/api/v1/cluster-summary").
 		Expect().
 		Status(200).Body().Raw()
diff --git a/test/api-e2e/testdata.go b/test/api-e2e/testdata.go
index 5e09324c..24623c54 100644
--- a/test/api-e2e/testdata.go
+++ b/test/api-e2e/testdata.go
@@ -51,8 +51,10 @@ var (
         ]
     }
 }`)
-	testPipeline2Name = "test-pipeline-2"
-	testPipeline2     = []byte(`
+	testPipeline1Pause  = []byte(`{"spec": {"lifecycle": {"desiredPhase": "Paused"}}}`)
+	testPipeline1Resume = []byte(`{"spec": {"lifecycle": {"desiredPhase": "Running"}}}`)
+	testPipeline2Name   = "test-pipeline-2"
+	testPipeline2       = []byte(`
 {
     "apiVersion": "numaflow.numaproj.io/v1alpha1",
     "kind": "Pipeline",