Explorar el Código

additive patch for partial app proto (#4410)

ianedwards hace 2 años
padre
commit
8e48d214c2

+ 1 - 1
go.mod

@@ -228,7 +228,7 @@ require (
 	github.com/docker/go-units v0.4.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
-	github.com/evanphx/json-patch v5.6.0+incompatible // indirect
+	github.com/evanphx/json-patch v5.9.0+incompatible
 	github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
 	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect

+ 2 - 2
go.sum

@@ -586,8 +586,8 @@ github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHj
 github.com/ettle/strcase v0.1.1/go.mod h1:hzDLsPC7/lwKyBOywSHEP89nt2pDgdy+No1NBA9o9VY=
 github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
-github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
-github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
+github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
 github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
 github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=

+ 82 - 0
internal/porter_app/test/patch_test.go

@@ -0,0 +1,82 @@
+package test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/matryer/is"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
+)
+
+func TestPatchApp(t *testing.T) {
+	tests := []struct {
+		haveFileName string
+		wantFileName string
+	}{
+		{"app_proto_prepatch", "app_proto_postpatch"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.haveFileName, func(t *testing.T) {
+			is := is.New(t)
+
+			wantBytes, err := os.ReadFile(fmt.Sprintf("../testdata/%s.json", tt.wantFileName))
+			is.NoErr(err) // no error expected reading test file
+
+			want := &porterv1.PorterApp{}
+			err = helpers.UnmarshalContractObject(wantBytes, want)
+			is.NoErr(err) // no error expected unmarshalling test file
+
+			inputBytes, err := os.ReadFile(fmt.Sprintf("../testdata/%s.json", tt.haveFileName))
+			is.NoErr(err) // no error expected reading test file
+
+			input := &porterv1.PorterApp{}
+			err = helpers.UnmarshalContractObject(inputBytes, input)
+			is.NoErr(err) // no error expected unmarshalling test file
+
+			flags := []v2.ApplyFlag{
+				v2.AttachBuildpacks{
+					Buildpacks: []string{"heroku/python"},
+				},
+				v2.SetBuildContext{
+					Context: "./app",
+				},
+				v2.SetBuildMethod{
+					Method: "docker",
+				},
+				v2.SetBuildDockerfile{
+					Dockerfile: "Dockerfile",
+				},
+				v2.SetBuilder{
+					Builder: "heroku/buildpacks:20",
+				},
+				v2.AttachEnvGroupsFlag{
+					EnvGroups: []string{"foo-group"},
+				},
+				v2.SetImageRepo{
+					Repo: "ghcr.io/porter-dev",
+				},
+				v2.SetImageTag{
+					Tag: "a-new-tag",
+				},
+				v2.SetName{
+					Name: "js-test-app1",
+				},
+			}
+
+			var opts []v2.PatchOperation
+			for _, flag := range flags {
+				opts = append(opts, flag.AsPatchOperations()...)
+			}
+
+			got, err := v2.PatchApp(context.Background(), input, opts)
+			is.NoErr(err) // no error expected patching app
+
+			diffProtoWithFailTest(t, is, want, got)
+		})
+	}
+}

+ 63 - 0
internal/porter_app/testdata/app_proto_postpatch.json

@@ -0,0 +1,63 @@
+{
+  "name": "js-test-app1",
+  "build": {
+    "context": "./app",
+    "method": "docker",
+    "dockerfile": "Dockerfile",
+    "builder": "heroku/buildpacks:20",
+    "buildpacks": [
+      "heroku/nodejs",
+      "heroku/python"
+    ]
+  },
+  "image": {
+    "repository": "ghcr.io/porter-dev",
+    "tag": "a-new-tag"
+  },
+  "envGroups": [
+    {
+      "name": "sponsor",
+      "version": "9"
+    },
+    {
+      "name": "2-230178",
+      "version": "5"
+    },
+    {
+      "name": "foo-group"
+    }
+  ],
+  "helmOverrides": {},
+  "serviceList": [
+    {
+      "run": "/cnb/lifecycle/launcher node index.js",
+      "instances": 1,
+      "port": 3000,
+      "cpuCores": 0.19,
+      "ramMegabytes": 400,
+      "webConfig": {
+        "autoscaling": {
+          "minInstances": 1,
+          "maxInstances": 10,
+          "cpuThresholdPercent": 50,
+          "memoryThresholdPercent": 50
+        },
+        "domains": [],
+        "healthCheck": {
+          "httpPath": "/healthz",
+          "timeoutSeconds": 1,
+          "initialDelaySeconds": 15
+        },
+        "private": false,
+        "disableTls": false
+      },
+      "type": "SERVICE_TYPE_WEB",
+      "smartOptimization": false,
+      "runOptional": "/cnb/lifecycle/launcher node index.js",
+      "name": "web",
+      "instancesOptional": 1,
+      "gpu": {},
+      "terminationGracePeriodSeconds": 30
+    }
+  ]
+}

+ 58 - 0
internal/porter_app/testdata/app_proto_prepatch.json

@@ -0,0 +1,58 @@
+{
+  "name": "js-test-app",
+  "build": {
+    "context": "./",
+    "method": "pack",
+    "builder": "heroku/buildpacks:18",
+    "buildpacks": [
+      "heroku/nodejs"
+    ]
+  },
+  "image": {
+    "repository": "1234567890.dkr.ecr.us-east-1.amazonaws.com/js-test-app",
+    "tag": "0cfc7dba6ab859d33309c8717a63f4eeac95669a"
+  },
+  "envGroups": [
+    {
+      "name": "sponsor",
+      "version": "9"
+    },
+    {
+      "name": "2-230178",
+      "version": "5"
+    }
+  ],
+  "helmOverrides": {},
+  "serviceList": [
+    {
+      "run": "/cnb/lifecycle/launcher node index.js",
+      "instances": 1,
+      "port": 3000,
+      "cpuCores": 0.19,
+      "ramMegabytes": 400,
+      "webConfig": {
+        "autoscaling": {
+          "minInstances": 1,
+          "maxInstances": 10,
+          "cpuThresholdPercent": 50,
+          "memoryThresholdPercent": 50
+        },
+        "domains": [],
+        "healthCheck": {
+          "httpPath": "/healthz",
+          "timeoutSeconds": 1,
+          "initialDelaySeconds": 15
+        },
+        "private": false,
+        "disableTls": false
+      },
+      "type": "SERVICE_TYPE_WEB",
+      "smartOptimization": false,
+      "runOptional": "/cnb/lifecycle/launcher node index.js",
+      "name": "web",
+      "instancesOptional": 1,
+      "gpu": {},
+      "terminationGracePeriodSeconds": 30
+    }
+  ]
+}

+ 214 - 0
internal/porter_app/v2/apply_flags.go

@@ -0,0 +1,214 @@
+package v2
+
+import (
+	"encoding/json"
+)
+
+// OperationType represents a JSON patch operation type
+type OperationType string
+
+const (
+	// AddOperation indicates that a value should be added to a JSON object or array
+	AddOperation OperationType = "add"
+	// RemoveOperation indicates that a value should be removed from a JSON object or array
+	RemoveOperation OperationType = "remove"
+	// ReplaceOperation indicates that a value should be replaced in a JSON object or array
+	ReplaceOperation OperationType = "replace"
+	// MoveOperation indicates that a value should be moved within a JSON object or array
+	MoveOperation OperationType = "move"
+	// CopyOperation indicates that a value should be copied within a JSON object or array
+	CopyOperation OperationType = "copy"
+)
+
+// PatchOperation represents a full JSON patch operation
+type PatchOperation struct {
+	// Operation is the type of operation to perform
+	Operation OperationType `json:"op"`
+	// Path is the JSON pointer to the value to be operated on
+	Path string `json:"path"`
+	// Value is the value to be added, replaced, or moved
+	Value interface{} `json:"value,omitempty"`
+}
+
+func (op PatchOperation) String() (string, error) {
+	var res string
+
+	by, err := json.Marshal(op)
+	if err != nil {
+		return res, err
+	}
+
+	return string(by), nil
+}
+
+// ApplyFlag is an interface that represents a flag that can be applied to a PorterApp
+// removal operations are handled separately
+type ApplyFlag interface {
+	AsPatchOperations() []PatchOperation
+}
+
+// SetName is a flag that can be applied to a PorterApp to set the name
+type SetName struct {
+	Name string
+}
+
+// AsPatchOperations returns the patch operations to set the name
+func (f SetName) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/name",
+			Value:     f.Name,
+		},
+	}
+}
+
+// AttachEnvGroupsFlag is a flag that can be applied to a PorterApp to attach environment groups
+type AttachEnvGroupsFlag struct {
+	EnvGroups []string
+}
+
+// envGroupWithoutVersion is a struct that represents an environment group without a version
+// the version will be auto added on hydrate
+type envGroupWithoutVersion struct {
+	Name string `json:"name"`
+}
+
+// AsPatchOperations returns the patch operations to attach the environment groups
+func (f AttachEnvGroupsFlag) AsPatchOperations() []PatchOperation {
+	var envGroups []envGroupWithoutVersion
+
+	for _, envGroup := range f.EnvGroups {
+		envGroups = append(envGroups, envGroupWithoutVersion{
+			Name: envGroup,
+		})
+	}
+
+	var ops []PatchOperation
+
+	for _, envGroup := range envGroups {
+		ops = append(ops, PatchOperation{
+			Operation: AddOperation,
+			Path:      "/envGroups/-",
+			Value:     envGroup,
+		})
+	}
+
+	return ops
+}
+
+// SetBuildContext is a flag that can be applied to a PorterApp to set the build context
+type SetBuildContext struct {
+	Context string
+}
+
+// AsPatchOperations returns the patch operations to set the build context
+func (f SetBuildContext) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/context",
+			Value:     f.Context,
+		},
+	}
+}
+
+// SetBuildMethod is a flag that can be applied to a PorterApp to set the build method
+type SetBuildMethod struct {
+	Method string
+}
+
+// AsPatchOperations returns the patch operations to set the build method
+func (f SetBuildMethod) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/method",
+			Value:     f.Method,
+		},
+	}
+}
+
+// SetBuildDockerfile is a flag that can be applied to a PorterApp to set the build Dockerfile
+type SetBuildDockerfile struct {
+	Dockerfile string
+}
+
+// AsPatchOperations returns the patch operations to set the build Dockerfile
+func (f SetBuildDockerfile) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/dockerfile",
+			Value:     f.Dockerfile,
+		},
+	}
+}
+
+// AttachBuildpacks is a flag that can be applied to a PorterApp to attach buildpacks
+type AttachBuildpacks struct {
+	Buildpacks []string
+}
+
+// AsPatchOperations returns the patch operations to attach the buildpacks
+func (f AttachBuildpacks) AsPatchOperations() []PatchOperation {
+	var ops []PatchOperation
+
+	for _, buildpack := range f.Buildpacks {
+		ops = append(ops, PatchOperation{
+			Operation: AddOperation,
+			Path:      "/build/buildpacks/-",
+			Value:     buildpack,
+		})
+	}
+
+	return ops
+}
+
+// SetBuilder is a flag that can be applied to a PorterApp to set the builder
+type SetBuilder struct {
+	Builder string
+}
+
+// AsPatchOperations returns the patch operations to set the builder
+func (f SetBuilder) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/builder",
+			Value:     f.Builder,
+		},
+	}
+}
+
+// SetImageRepo is a flag that can be applied to a PorterApp to set the image repo
+type SetImageRepo struct {
+	Repo string
+}
+
+// AsPatchOperations returns the patch operations to set the image repo
+func (f SetImageRepo) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/image/repository",
+			Value:     f.Repo,
+		},
+	}
+}
+
+// SetImageTag is a flag that can be applied to a PorterApp to set the image tag
+type SetImageTag struct {
+	Tag string
+}
+
+// AsPatchOperations returns the patch operations to set the image tag
+func (f SetImageTag) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/image/tag",
+			Value:     f.Tag,
+		},
+	}
+}

+ 62 - 0
internal/porter_app/v2/patch.go

@@ -0,0 +1,62 @@
+package v2
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	jsonpatch "github.com/evanphx/json-patch"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// PatchApp applies a set of JSON patch operations to an app proto and returns the modified proto
+func PatchApp(ctx context.Context, app *porterv1.PorterApp, ops []PatchOperation) (*porterv1.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "v2-patch-app")
+	defer span.End()
+
+	var patchedApp *porterv1.PorterApp
+
+	if app == nil {
+		return patchedApp, telemetry.Error(ctx, span, nil, "no app provided")
+	}
+
+	by, err := helpers.MarshalContractObject(ctx, app)
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to marshal app")
+	}
+
+	var opStrs []string
+
+	for _, op := range ops {
+		opAsJSON, err := op.String()
+		if err != nil {
+			return patchedApp, telemetry.Error(ctx, span, err, "failed to convert patch operation to string")
+		}
+
+		opStrs = append(opStrs, fmt.Sprintf("\t%s", opAsJSON))
+	}
+
+	patchJson := fmt.Sprintf("[\n%s\n]", strings.Join(opStrs, ",\n"))
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "patch-json", Value: patchJson})
+
+	patch, err := jsonpatch.DecodePatch([]byte(patchJson))
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to decode patch")
+	}
+
+	modified, err := patch.Apply(by)
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to apply patch")
+	}
+
+	patchedApp = &porterv1.PorterApp{}
+
+	err = helpers.UnmarshalContractObject(modified, patchedApp)
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to unmarshal patched app")
+	}
+
+	return patchedApp, nil
+}