Skip to content

Commit

Permalink
fix(customresources): use a 3-way merge patch instead of strategic merge
Browse files Browse the repository at this point in the history
CustomResources do not support [JSON strategic merges](https://tools.ietf.org/html/rfc6902) on patch updates.

Instead, they require a [JSON 3-way merge patch](https://tools.ietf.org/html/rfc7386) to properly apply the patch.

CustomResource kinds now are treated separately from other known k8s kinds in
order to apply the proper patch.

A new [CoreOS prometheus-operator](https://github.com/coreos/prometheus-operator) test with a step update is included to validate this update path.
  • Loading branch information
metral committed May 4, 2020
1 parent 91b4266 commit 4147861
Show file tree
Hide file tree
Showing 8 changed files with 265 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Bug Fixes

- fix(customresources): use a 3-way merge patch instead of strategic merge. (https://github.com/pulumi/pulumi-kubernetes/pull/1095)
- Fix required input props in Go SDK. (https://github.com/pulumi/pulumi-kubernetes/pull/1090)
- Update Go SDK using latest codegen packages. (https://github.com/pulumi/pulumi-kubernetes/pull/1089)
- Fix schema type for Fields and RawExtension. (https://github.com/pulumi/pulumi-kubernetes/pull/1086)
Expand Down
52 changes: 44 additions & 8 deletions provider/pkg/openapi/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package openapi
import (
"fmt"

"github.com/pulumi/pulumi-kubernetes/provider/v2/pkg/kinds"
"github.com/pulumi/pulumi/sdk/v2/go/common/util/contract"
logger "github.com/pulumi/pulumi/sdk/v2/go/common/util/logging"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
Expand Down Expand Up @@ -75,7 +76,7 @@ func ValidateAgainstSchema(
// to JSON merge patch.
func PatchForResourceUpdate(
resources openapi.Resources, lastSubmitted, currentSubmitted, liveOldObj *unstructured.Unstructured,
) ([]byte, types.PatchType, strategicpatch.LookupPatchMeta, error) {
) (patch []byte, patchType types.PatchType, lookupPatchMeta strategicpatch.LookupPatchMeta, err error) {
// Create JSON blobs for each of these, preparing to create the three-way merge patch.
lastSubmittedJSON, err := lastSubmitted.MarshalJSON()
if err != nil {
Expand All @@ -92,23 +93,58 @@ func PatchForResourceUpdate(
return nil, "", nil, err
}

// Try to build a three-way "strategic" merge.
// Use kinds.Namespaced() to determine if kind is unknown, such as for CRD Kinds.
kind := kinds.Kind(lastSubmitted.GetKind())
if knownKind, _ := kind.Namespaced(); !knownKind {
// Use a JSON merge patch for CRD Kinds.
patch, patchType, err = MergePatch(
lastSubmitted, lastSubmittedJSON, currentSubmittedJSON, liveOldJSON,
)
return patch, patchType, lookupPatchMeta, err
}

// Attempt a three-way strategic merge.
patch, patchType, lookupPatchMeta, err = StrategicMergePatch(
resources, lastSubmitted, lastSubmittedJSON, currentSubmittedJSON, liveOldJSON,
)
// Else, fall back to a three-way JSON merge patch.
if err != nil {
patch, patchType, err = MergePatch(
lastSubmitted, lastSubmittedJSON, currentSubmittedJSON, liveOldJSON,
)
}
return patch, patchType, lookupPatchMeta, err
}

// StrategicMergePatch is a helper to use a three-way strategic merge on a resource version.
// See for more details: https://tools.ietf.org/html/rfc6902
func StrategicMergePatch(
resources openapi.Resources, lastSubmitted *unstructured.Unstructured, lastSubmittedJSON, currentSubmittedJSON, liveOldJSON []byte,
) (patch []byte, patchType types.PatchType, lookupPatchMeta strategicpatch.LookupPatchMeta, err error) {
gvk := lastSubmitted.GroupVersionKind()
if resSchema := resources.LookupResource(gvk); resSchema != nil {
logger.V(1).Infof("Attempting to update '%s' '%s/%s' with strategic merge",
gvk.String(), lastSubmitted.GetNamespace(), lastSubmitted.GetName())
patch, patchType, lookupPatchMeta, err := strategicMergePatch(
patch, patchType, lookupPatchMeta, err = strategicMergePatch(
gvk, resSchema, lastSubmittedJSON, currentSubmittedJSON, liveOldJSON)
if err == nil {
return patch, patchType, lookupPatchMeta, nil
}
}
if err != nil {
return patch, patchType, lookupPatchMeta, err
}
return patch, patchType, lookupPatchMeta, nil
}

// MergePatch is a helper to use a three-way JSON merge patch on a resource version.
// See for more details: https://tools.ietf.org/html/rfc7386
func MergePatch(
lastSubmitted *unstructured.Unstructured, lastSubmittedJSON, currentSubmittedJSON, liveOldJSON []byte,
) (patch []byte, patchType types.PatchType, err error) {
gvk := lastSubmitted.GroupVersionKind()
// Fall back to three-way JSON merge patch.
logger.V(1).Infof("Attempting to update '%s' '%s/%s' with JSON merge",
gvk.String(), lastSubmitted.GetNamespace(), lastSubmitted.GetName())
patch, patchType, err := jsonMergePatch(lastSubmittedJSON, currentSubmittedJSON, liveOldJSON)
return patch, patchType, nil, err
patch, patchType, err = jsonMergePatch(lastSubmittedJSON, currentSubmittedJSON, liveOldJSON)
return patch, patchType, err
}

// SupportsDryRun returns true if the given GVK supports dry-run applies.
Expand Down
29 changes: 29 additions & 0 deletions tests/examples/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,35 @@ func TestAccHelmLocal(t *testing.T) {
integration.ProgramTest(t, &test)
}

func TestAccPrometheusOperator(t *testing.T) {
skipIfShort(t)
test := getBaseOptions(t).
With(integration.ProgramTestOptions{
Dir: path.Join(getCwd(t), "prometheus-operator"),
SkipRefresh: true,
ExtraRuntimeValidation: func(
t *testing.T, stackInfo integration.RuntimeValidationStackInfo,
) {
assert.NotNil(t, stackInfo.Deployment)
assert.Equal(t, 10, len(stackInfo.Deployment.Resources))
},
EditDirs: []integration.EditDir{
{
Dir: path.Join(getCwd(t), "prometheus-operator", "steps"),
Additive: true,
ExtraRuntimeValidation: func(
t *testing.T, stackInfo integration.RuntimeValidationStackInfo,
) {
assert.NotNil(t, stackInfo.Deployment)
assert.Equal(t, 10, len(stackInfo.Deployment.Resources))
},
},
},
})

integration.ProgramTest(t, &test)
}

func TestAccMariadb(t *testing.T) {
skipIfShort(t)
test := getBaseOptions(t).
Expand Down
3 changes: 3 additions & 0 deletions tests/examples/prometheus-operator/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: prometheus-operator
description: The CoreOS Prometheus Operator
runtime: nodejs
78 changes: 78 additions & 0 deletions tests/examples/prometheus-operator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as k8s from "@pulumi/kubernetes";
import * as pulumi from "@pulumi/pulumi";

// PrometheusOperatorArgs are the options to configure on the CoreOS
// PrometheusOperator.
interface PrometheusOperatorArgs {
namespace: pulumi.Input<string>;
version?: string;
}

// PrometheusOperator implements the CoreOS Prometheus Operator.
export class PrometheusOperator extends pulumi.ComponentResource {
public readonly configFile: k8s.yaml.ConfigFile;
constructor(
name: string,
args: PrometheusOperatorArgs,
opts?: pulumi.ComponentResourceOptions,
) {
super('pulumi:monitoring/v1:PrometheusOperator', name, {}, opts);

this.configFile = new k8s.yaml.ConfigFile(
name,
{
file: `https://github.com/coreos/prometheus-operator/raw/release-${args.version || '0.38'}/bundle.yaml`,
transformations: [
obj => {
if (obj.metadata.namespace) {
obj.metadata.namespace = args.namespace;
}
if (obj.kind === 'ClusterRoleBinding') {
obj.subjects[0].namespace = args.namespace;
}
},
],
}, { parent: this });
}
}

// Create the Prometheus Operator.
const prometheusOperator = new PrometheusOperator("prometheus", {
namespace: "default",
});

// Create the Prometheus Operator ServiceMonitor.
const myMonitoring = new k8s.apiextensions.CustomResource('my-monitoring', {
apiVersion: 'monitoring.coreos.com/v1',
kind: 'ServiceMonitor',
spec: {
selector: {
matchLabels: { app: 'my-app' },
},
endpoints: [
{
port: 'http',
interval: '65s',
// start with the following
relabelings: [
{
regex: '(.*)',
targetLabel: 'stackdriver',
replacement: 'true',
action: 'replace'
}
],
// try to add the following in replacement of above in steps/step1.ts
// metricRelabelings: [
// {
// sourceLabels: ['__name__'],
// regex: 'typhoon_(.*)',
// targetLabel: 'stackdriver',
// replacement: 'true',
// action: 'replace'
// }
// ]
},
],
},
}, {dependsOn: prometheusOperator});
10 changes: 10 additions & 0 deletions tests/examples/prometheus-operator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "prometheus-operator",
"version": "0.1.0",
"dependencies": {
"@pulumi/pulumi": "latest"
},
"peerDependencies": {
"@pulumi/kubernetes": "latest"
}
}
78 changes: 78 additions & 0 deletions tests/examples/prometheus-operator/step1/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as k8s from "@pulumi/kubernetes";
import * as pulumi from "@pulumi/pulumi";

// PrometheusOperatorArgs are the options to configure on the CoreOS
// PrometheusOperator.
interface PrometheusOperatorArgs {
namespace: pulumi.Input<string>;
version?: string;
}

// PrometheusOperator implements the CoreOS Prometheus Operator.
export class PrometheusOperator extends pulumi.ComponentResource {
public readonly configFile: k8s.yaml.ConfigFile;
constructor(
name: string,
args: PrometheusOperatorArgs,
opts?: pulumi.ComponentResourceOptions,
) {
super('pulumi:monitoring/v1:PrometheusOperator', name, {}, opts);

this.configFile = new k8s.yaml.ConfigFile(
name,
{
file: `https://github.com/coreos/prometheus-operator/raw/release-${args.version || '0.38'}/bundle.yaml`,
transformations: [
obj => {
if (obj.metadata.namespace) {
obj.metadata.namespace = args.namespace;
}
if (obj.kind === 'ClusterRoleBinding') {
obj.subjects[0].namespace = args.namespace;
}
},
],
}, { parent: this });
}
}

// Create the Prometheus Operator.
const prometheusOperator = new PrometheusOperator("prometheus", {
namespace: "default",
});

// Create the Prometheus Operator ServiceMonitor.
const myMonitoring = new k8s.apiextensions.CustomResource('my-monitoring', {
apiVersion: 'monitoring.coreos.com/v1',
kind: 'ServiceMonitor',
spec: {
selector: {
matchLabels: { app: 'my-app' },
},
endpoints: [
{
port: 'http',
interval: '65s',
// removing the following in index.ts in favor of below
// relabelings: [
// {
// regex: '(.*)',
// targetLabel: 'stackdriver',
// replacement: 'true',
// action: 'replace'
// }
// ],
// add the following in replacement of above in index.ts
metricRelabelings: [
{
sourceLabels: ['__name__'],
regex: 'typhoon_(.*)',
targetLabel: 'stackdriver',
replacement: 'true',
action: 'replace'
}
]
},
],
},
}, {dependsOn: prometheusOperator});
22 changes: 22 additions & 0 deletions tests/examples/prometheus-operator/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"outDir": "bin",
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"sourceMap": true,
"stripInternal": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"strictNullChecks": true
},
"files": [
"index.ts"
]
}

0 comments on commit 4147861

Please sign in to comment.