diff --git a/internal/featureflags/temporary.go b/internal/featureflags/temporary.go index 59c9ce29f..1ac28c562 100644 --- a/internal/featureflags/temporary.go +++ b/internal/featureflags/temporary.go @@ -33,15 +33,18 @@ const ( // Segments toggles whether segment configurations are downloaded and / or deployed. // Introduced: v2.18.0 Segments FeatureFlag = "MONACO_FEAT_SEGMENTS" + + CreateReferencesOnlyInStringValues FeatureFlag = "MONACO_CREATE_REFS_IN_STRINGS" ) // temporaryDefaultValues defines temporary feature flags and their default values. // It is suitable for features that are hidden during development or have some uncertainty. // These should always be removed after release of a feature, or some stabilization period if needed. var temporaryDefaultValues = map[FeatureFlag]defaultValue{ - SkipReadOnlyAccountGroupUpdates: false, - PersistSettingsOrder: true, - OpenPipeline: true, - IgnoreSkippedConfigs: false, - Segments: false, + SkipReadOnlyAccountGroupUpdates: false, + PersistSettingsOrder: true, + OpenPipeline: true, + IgnoreSkippedConfigs: false, + Segments: false, + CreateReferencesOnlyInStringValues: false, } diff --git a/internal/json/apply.go b/internal/json/apply.go new file mode 100644 index 000000000..202ca53ce --- /dev/null +++ b/internal/json/apply.go @@ -0,0 +1,62 @@ +/* + * @license + * Copyright 2025 Dynatrace LLC + * 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 json + +import ( + "encoding/json" +) + +// ApplyToStringValues unmarshals a JSON string and applies the specified transformation function to each string value before remarshaling and returning the result. +func ApplyToStringValues(jsonString string, f func(v string) string) (string, error) { + var v interface{} + if err := json.Unmarshal([]byte(jsonString), &v); err != nil { + return "", err + } + + v = walkAnyAndApplyToStringValues(v, f) + + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func walkAnyAndApplyToStringValues(v any, f func(v string) string) any { + switch vv := v.(type) { + case string: + if f == nil { + return vv + } + return f(vv) + + case []interface{}: + for i, u := range vv { + vv[i] = walkAnyAndApplyToStringValues(u, f) + } + return vv + + case map[string]interface{}: + for k, u := range vv { + vv[k] = walkAnyAndApplyToStringValues(u, f) + } + return vv + + default: + return vv + } +} diff --git a/internal/json/apply_test.go b/internal/json/apply_test.go new file mode 100644 index 000000000..fc17cf7c2 --- /dev/null +++ b/internal/json/apply_test.go @@ -0,0 +1,129 @@ +//go:build unit + +/* + * @license + * Copyright 2025 Dynatrace LLC + * 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 json + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestApplyToStringValues_Success(t *testing.T) { + tests := []struct { + name string + content string + f func(s string) string + expectedResult string + }{ + { + name: "boolean value is preserved", + content: "true", + expectedResult: "true", + }, + { + name: "boolean value is not replaced", + content: "true", + f: func(s string) string { return strings.ReplaceAll(s, "true", "true") }, + expectedResult: "true", + }, + { + name: "float value is preserved", + content: "10", + expectedResult: "10", + }, + { + name: "float value is not replaced", + content: "10", + f: func(s string) string { return strings.ReplaceAll(s, "10", "10") }, + expectedResult: "10", + }, + { + name: "string value is replaced if found", + content: "\"find\"", + f: func(s string) string { return strings.ReplaceAll(s, "find", "replace") }, + expectedResult: "\"replace\"", + }, + { + name: "string value is not replaced if not found", + content: "\"nope\"", + f: func(s string) string { return strings.ReplaceAll(s, "find", "replace") }, + expectedResult: "\"nope\"", + }, + { + name: "string value is replaced if found in object", + content: `{"key": "find"}`, + f: func(s string) string { return strings.ReplaceAll(s, "find", "replace") }, + expectedResult: `{"key":"replace"}`, + }, + { + name: "string value is not replaced if not found in object", + content: `{"key": "nope"}`, + f: func(s string) string { return strings.ReplaceAll(s, "find", "replace") }, + expectedResult: `{"key":"nope"}`, + }, + { + name: "string value is replaced if found in object in array", + content: `[{"key": "find"}]`, + f: func(s string) string { return strings.ReplaceAll(s, "find", "replace") }, + expectedResult: `[{"key":"replace"}]`, + }, + { + name: "string value is not replaced if not found in object in array", + content: `[{"key": "nope"}]`, + f: func(s string) string { return strings.ReplaceAll(s, "find", "replace") }, + expectedResult: `[{"key":"nope"}]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ApplyToStringValues(tt.content, tt.f) + assert.EqualValues(t, tt.expectedResult, result) + assert.NoError(t, err) + }) + } +} + +func TestApplyToStringValues_Errors(t *testing.T) { + tests := []struct { + name string + content string + f func(s string) string + }{ + { + name: "empty string doesnt work", + content: "", + }, + { + name: "unquoted string produces error", + content: "something", + }, + { + name: "truncated json produces error", + content: `{ "key": "value", `, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ApplyToStringValues(tt.content, tt.f) + assert.Empty(t, result) + assert.Error(t, err) + }) + } +} diff --git a/pkg/download/dependency_resolution/resolver/shared.go b/pkg/download/dependency_resolution/resolver/shared.go index df43d69e9..fb8efb32c 100644 --- a/pkg/download/dependency_resolution/resolver/shared.go +++ b/pkg/download/dependency_resolution/resolver/shared.go @@ -21,6 +21,8 @@ import ( "regexp" "strings" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags" + "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/json" "github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/log" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config" "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/config/parameter/reference" @@ -67,6 +69,21 @@ func sanitizeTemplateVar(templateVarName string) string { } func replaceAll(content string, key string, s string) string { + if featureflags.CreateReferencesOnlyInStringValues.Enabled() { + f := func(v string) string { + return replaceAllUsingRegEx(v, key, s) + } + result, err := json.ApplyToStringValues(content, f) + if err == nil { + return result + } + + log.Debug("Failed to replace %q with %q in string values in %q: %s", key, s, content, err.Error()) + } + return replaceAllUsingRegEx(content, key, s) +} + +func replaceAllUsingRegEx(content string, key string, s string) string { // The prefix and suffix we search for are alphanumerical, as well as the "-", and "_". // From investigating, this character set seems to be the most basic regex that still avoids false positive substring matches. str := fmt.Sprintf("([^a-zA-Z0-9_-])(%s)([^a-zA-Z0-9_-])", key)