Skip to content

Commit

Permalink
feat: Create references only in string values in JSON templates
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurpitman committed Jan 17, 2025
1 parent a928904 commit dfaff8c
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 6 deletions.
15 changes: 9 additions & 6 deletions internal/featureflags/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,19 @@ const (
// ServiceUsers toggles whether account service users configurations are downloaded and / or deployed.
// Introduced: v2.18.0
ServiceUsers FeatureFlag = "MONACO_FEAT_SERVICE_USERS"

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,
ServiceUsers: false,
SkipReadOnlyAccountGroupUpdates: false,
PersistSettingsOrder: true,
OpenPipeline: true,
IgnoreSkippedConfigs: false,
Segments: false,
ServiceUsers: false,
CreateReferencesOnlyInStringValues: false,
}
62 changes: 62 additions & 0 deletions internal/json/apply.go
Original file line number Diff line number Diff line change
@@ -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
}
}
129 changes: 129 additions & 0 deletions internal/json/apply_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
17 changes: 17 additions & 0 deletions pkg/download/dependency_resolution/resolver/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit dfaff8c

Please sign in to comment.