Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Normalize x-kubernetes-* fields for outputs #3348

Merged
merged 7 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
- JSONPath expressions used with the `pulumi.com/waitFor` annotation will no longer hang indefinitely if they match non-primitive fields.
(https://github.com/pulumi/pulumi-kubernetes/issues/3345)

- [java] CRDs that contain any `x-kubernetes-*` fields can now be succesfully created and managed by Pulumi.
(https://github.com/pulumi/pulumi-kubernetes/issues/3325)

## 4.18.3 (October 31, 2024)

### Fixed
Expand Down
71 changes: 47 additions & 24 deletions provider/pkg/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2925,29 +2925,42 @@ func mapReplStripComputed(v resource.PropertyValue) (any, bool) {
return nil, false
}

// mapReplUnderscoreToDash is needed to work around cases where SDKs don't allow dashes in variable names, and so the
// parameter is renamed with an underscore during schema generation. This function normalizes those keys to the format
// expected by the cluster.
// underscoreToDashMap holds the mappings between underscore and dash keys.
var underscoreToDashMap = map[string]string{
"x_kubernetes_embedded_resource": "x-kubernetes-embedded-resource",
"x_kubernetes_int_or_string": "x-kubernetes-int-or-string",
"x_kubernetes_list_map_keys": "x-kubernetes-list-map-keys",
"x_kubernetes_list_type": "x-kubernetes-list-type",
"x_kubernetes_map_type": "x-kubernetes-map-type",
"x_kubernetes_preserve_unknown_fields": "x-kubernetes-preserve-unknown-fields",
"x_kubernetes_validations": "x-kubernetes-validations",
}

// dashedToUnderscoreMap holds the reverse mappings between dash and underscore keys. This
// is a precomputed map based on underscoreToDashMap at runtime to avoid duplicating
// code, or extra passes over the map.
var dashToUnderscoreMap map[string]string = func() map[string]string {
dashToUnderscoreMap := make(map[string]string, len(underscoreToDashMap))
for k, v := range underscoreToDashMap {
dashToUnderscoreMap[v] = k
}
return dashToUnderscoreMap
}()

// mapReplUnderscoreToDash denormalizes keys by replacing underscores with dashes.
func mapReplUnderscoreToDash(v string) (string, bool) {
switch v {
case "x_kubernetes_embedded_resource":
return "x-kubernetes-embedded-resource", true
case "x_kubernetes_int_or_string":
return "x-kubernetes-int-or-string", true
case "x_kubernetes_list_map_keys":
return "x-kubernetes-list-map-keys", true
case "x_kubernetes_list_type":
return "x-kubernetes-list-type", true
case "x_kubernetes_map_type":
return "x-kubernetes-map-type", true
case "x_kubernetes_preserve_unknown_fields":
return "x-kubernetes-preserve-unknown-fields", true
case "x_kubernetes_validations":
return "x-kubernetes-validations", true
}
return "", false
val, ok := underscoreToDashMap[v]
return val, ok
}

// mapReplDashToUnderscore normalizes keys by replacing dashes with underscores.
func mapReplDashToUnderscore(v string) (resource.PropertyKey, bool) {
val, ok := dashToUnderscoreMap[v]
return resource.PropertyKey(val), ok
}

// propMapToUnstructured converts a resource.PropertyMap to an *unstructured.Unstructured; and applies field name denormalization
// and secret stripping.
func propMapToUnstructured(pm resource.PropertyMap) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: pm.MapRepl(mapReplUnderscoreToDash, mapReplStripSecrets)}
}
Expand All @@ -2962,11 +2975,17 @@ func initialAPIVersion(state resource.PropertyMap, oldInputs *unstructured.Unstr
return oldInputs.GetAPIVersion()
}

// checkpointObject generates a checkpointed PropertyMap from the live and input Kubernetes objects.
// It normalizes `x-kubernetes-*` fields to their underscored equivalents, handles secret data annotations,
// processes `stringData` for secret kinds by marking corresponding `data` fields as secrets,
// and includes metadata such as the initial API version and field manager.
func checkpointObject(inputs, live *unstructured.Unstructured, fromInputs resource.PropertyMap,
initialAPIVersion, fieldManager string,
) resource.PropertyMap {
object := resource.NewPropertyMapFromMap(live.Object)
inputsPM := resource.NewPropertyMapFromMap(inputs.Object)
// When checkpointing the live object, we need to ensure we normalize any `x-kubernetes-*` fields to their
// underscored versions so they can be correctly diffed, and deseriazlied to their typed SDK equivalents.
object := resource.NewPropertyMapFromMapRepl(live.Object, mapReplDashToUnderscore, nil)
inputsPM := resource.NewPropertyMapFromMapRepl(inputs.Object, mapReplDashToUnderscore, nil)

annotateSecrets(object, fromInputs)
annotateSecrets(inputsPM, fromInputs)
Expand Down Expand Up @@ -2998,10 +3017,14 @@ func checkpointObject(inputs, live *unstructured.Unstructured, fromInputs resour
return object
}

// parseCheckpointObject parses the given resource.PropertyMap, stripping sensitive information and normalizing field names.
// It returns two unstructured.Unstructured objects: oldInputs containing the input properties and live containing the live state.
func parseCheckpointObject(obj resource.PropertyMap) (oldInputs, live *unstructured.Unstructured) {
// Since we are converting everything to unstructured's, we need to strip out any secretness that
// may nested deep within the object.
pm := obj.MapRepl(nil, mapReplStripSecrets)
// Note: we also handle conversion of underscored `x_kubernetes_*` fields to their respective dashed
// versions here.
pm := obj.MapRepl(mapReplUnderscoreToDash, mapReplStripSecrets)

//
// NOTE: Inputs are now stored in `__inputs` to allow output properties to work. The inputs and
Expand All @@ -3026,7 +3049,7 @@ func parseCheckpointObject(obj resource.PropertyMap) (oldInputs, live *unstructu

oldInputs = &unstructured.Unstructured{Object: inputs.(map[string]any)}
live = &unstructured.Unstructured{Object: liveMap.(map[string]any)}
return
return oldInputs, live
}

// partialError creates an error for resources that did not complete an operation in progress.
Expand Down
66 changes: 60 additions & 6 deletions provider/pkg/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ var (
"qux": map[string]any{
"xuq": "oof",
},
"x_kubernetes_preserve_unknown_fields": true,
}
objLive = map[string]any{
initialAPIVersionKey: "",
Expand All @@ -48,6 +49,26 @@ var (
"xuq": map[string]any{
"qux": "foo",
},
"x_kubernetes_preserve_unknown_fields": true,
}

objInputsWithDash = map[string]any{
"foo": "bar",
"baz": float64(1234),
"qux": map[string]any{
"xuq": "oof",
},
"x-kubernetes-preserve-unknown-fields": true,
}
objLiveWithDash = map[string]any{
initialAPIVersionKey: "",
fieldManagerKey: "",
"oof": "bar",
"zab": float64(4321),
"xuq": map[string]any{
"qux": "foo",
},
"x-kubernetes-preserve-unknown-fields": true,
}
)

Expand All @@ -58,17 +79,17 @@ func TestParseOldCheckpointObject(t *testing.T) {
})

oldInputs, live := parseCheckpointObject(old)
assert.Equal(t, objInputs, oldInputs.Object)
assert.Equal(t, objLive, live.Object)
assert.Equal(t, objInputsWithDash, oldInputs.Object)
assert.Equal(t, objLiveWithDash, live.Object)
}

func TestParseNewCheckpointObject(t *testing.T) {
old := resource.NewPropertyMapFromMap(objLive)
old["__inputs"] = resource.NewObjectProperty(resource.NewPropertyMapFromMap(objInputs))

oldInputs, live := parseCheckpointObject(old)
assert.Equal(t, objInputs, oldInputs.Object)
assert.Equal(t, objLive, live.Object)
assert.Equal(t, objInputsWithDash, oldInputs.Object)
assert.Equal(t, objLiveWithDash, live.Object)
}

func TestCheckpointObject(t *testing.T) {
Expand Down Expand Up @@ -118,13 +139,46 @@ func TestCheckpointSecretObject(t *testing.T) {
assert.True(t, oldInputsVal["data"].ContainsSecrets())
}

// Ensure that well-known x-kubernetes-* fields are normazlied to x_kubernetes_*
// in the checkpoint object.
func TestCheckpointXKubernetesFields(t *testing.T) {
objInputWithDash := map[string]any{
"kind": "fakekind",
"spec": map[string]any{
"x-kubernetes-preserve-unknown-fields": "true",
},
}
objLiveWithDash := map[string]any{
initialAPIVersionKey: "",
fieldManagerKey: "",
"kind": "fakekind",
"spec": map[string]any{
"x-kubernetes-preserve-unknown-fields": "true",
},
}

inputs := &unstructured.Unstructured{Object: objInputWithDash}
live := &unstructured.Unstructured{Object: objLiveWithDash}

obj := checkpointObject(inputs, live, nil, "", "")
assert.NotNil(t, obj)

// Ensure we do not have the original x-kubernetes-* fields in the checkpoint objects.
assert.NotContains(t, obj.Mappable()["spec"], "x-kubernetes-preserve-unknown-fields")
assert.NotContains(t, obj["__inputs"].Mappable(), "x-kubernetes-preserve-unknown-fields")

// Ensure we have the normalized x_kubernetes_* fields in the checkpoint objects.
assert.Contains(t, obj.Mappable()["spec"], "x_kubernetes_preserve_unknown_fields")
assert.Contains(t, obj["__inputs"].Mappable().(map[string]any)["spec"], "x_kubernetes_preserve_unknown_fields")
}

func TestRoundtripCheckpointObject(t *testing.T) {
old := resource.NewPropertyMapFromMap(objLive)
old["__inputs"] = resource.NewObjectProperty(resource.NewPropertyMapFromMap(objInputs))

oldInputs, oldLive := parseCheckpointObject(old)
assert.Equal(t, objInputs, oldInputs.Object)
assert.Equal(t, objLive, oldLive.Object)
assert.Equal(t, objInputsWithDash, oldInputs.Object)
assert.Equal(t, objLiveWithDash, oldLive.Object)

obj := checkpointObject(oldInputs, oldLive, nil, "", "")
assert.Equal(t, old, obj)
Expand Down
65 changes: 65 additions & 0 deletions tests/sdk/java/crd_java_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2016-2024, Pulumi Corporation.
//
// 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 (
"testing"

"github.com/pulumi/providertest/pulumitest"
"github.com/pulumi/pulumi-kubernetes/tests/v4"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestJavaCanCreateCRD tests that we can create a CRD using the Java SDK, and that `x-kubernetes-*` fields are
// correctly serialized.
func TestJavaCanCreateCRD(t *testing.T) {
// Step 1 creates a CRD with `x-kubernetes-preserve-unknown-fields` set to true.
test := pulumitest.NewPulumiTest(t, "testdata/crd-java/step1")
t.Logf("into %s", test.Source())
t.Cleanup(func() {
test.Destroy()
})
test.Preview()
test.Up()

// Step 2 adds a pulumi CRD get operation and ensures we can read its URN properly.
test.UpdateSource("testdata/crd-java/step2")
test.Preview()
test.Up()
up := test.Up(optup.ExpectNoChanges())

urn, ok := up.Outputs["urn"]
require.True(t, ok)
require.NotNil(t, urn)
require.Equal(t, urn.Value, "urn:pulumi:test::crd_java::kubernetes:apiextensions.k8s.io/v1:CustomResourceDefinition::getCRDUrn")

// Verify with kubectl that the CRD has `x-kubernetes-*` fields set correctly.
output, err := tests.Kubectl("get crd javacrds.example.com -o json")
require.NoError(t, err)
assert.Contains(t, string(output), `"x-kubernetes-preserve-unknown-fields": true`)

// Step 3 removes the `x-kubernetes-preserve-unknown-fields` field and ensures that the CRD is updated.
test.UpdateSource("testdata/crd-java/step3")
test.Preview()
test.Up()
up = test.Up(optup.ExpectNoChanges())

// Verify with kubectl that the CRD no longer has `x-kubernetes-*` fields set.
output, err = tests.Kubectl("get crd javacrds.example.com -o json")
require.NoError(t, err)
assert.NotContains(t, string(output), `"x-kubernetes-preserve-unknown-fields": true`)
}
3 changes: 3 additions & 0 deletions tests/sdk/java/testdata/crd-java/step1/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: crd_java
description: A minimal Kubernetes Java Pulumi program to test the creation of CRDs
runtime: java
Loading
Loading