Skip to content

Commit ac3886a

Browse files
authored
Fix: prevent perpetual drift in sub-environment configuration blocks (#1098)
* Fix: prevent perpetual drift in sub-environment configuration blocks Sub-environment read overwrote state with API response in API order and clobbered values for sensitive variables, causing plans to never converge. Now merges in schema order, keeps the schema value for sensitive variables (API returns redacted), and still surfaces remote-only variables as drift. * Address review: schema-side sensitive flag, error/empty-id tests, doc trade-off - OR remote.IsSensitive with the schema's is_sensitive flag so the schema value is kept even during the transition where the user toggles is_sensitive=true before the backend reflects it - Drop unnecessary pointer-to-loop-var in the drift loop - Add unit tests for setSubEnvironmentSchema: empty sub-env id skip and error propagation - Add merge subtest for schema-only sensitive flag - Note the sensitive-edit invisibility trade-off so the perpetual-drift bug is not silently reintroduced
1 parent d3e2204 commit ac3886a

2 files changed

Lines changed: 236 additions & 11 deletions

File tree

env0/resource_environment.go

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -728,9 +728,8 @@ func resourceEnvironmentRead(ctx context.Context, d *schema.ResourceData, meta a
728728
return nil
729729
}
730730

731-
func setSubEnvironmentSchema(d *schema.ResourceData, apiClient client.ApiClientInterface) any {
731+
func setSubEnvironmentSchema(d *schema.ResourceData, apiClient client.ApiClientInterface) error {
732732
iSubEnvironments, ok := d.GetOk("sub_environment_configuration")
733-
734733
if !ok {
735734
return nil
736735
}
@@ -741,19 +740,19 @@ func setSubEnvironmentSchema(d *schema.ResourceData, apiClient client.ApiClientI
741740
for _, iSubEnvironment := range subEnvironmentsList {
742741
subEnvironment := iSubEnvironment.(map[string]any)
743742

744-
environmentConfigurationVariables, err := apiClient.ConfigurationVariablesByScope(client.ScopeEnvironment, subEnvironment["id"].(string))
745-
if err != nil {
746-
return fmt.Errorf("could not fetch environment configuration variables for sub environment %s: %w", subEnvironment["id"].(string), err)
743+
subEnvId, _ := subEnvironment["id"].(string)
744+
if subEnvId == "" {
745+
newSubEnvironments = append(newSubEnvironments, subEnvironment)
746+
continue
747747
}
748748

749-
newConfiguration := make([]any, 0, len(environmentConfigurationVariables))
750-
751-
for _, variable := range environmentConfigurationVariables {
752-
newConfiguration = append(newConfiguration, createVariable(&variable))
749+
remoteVariables, err := apiClient.ConfigurationVariablesByScope(client.ScopeEnvironment, subEnvId)
750+
if err != nil {
751+
return fmt.Errorf("could not fetch environment configuration variables for sub environment %s: %w", subEnvId, err)
753752
}
754753

755-
subEnvironment["configuration"] = newConfiguration
756-
754+
stateVariables, _ := subEnvironment["configuration"].([]any)
755+
subEnvironment["configuration"] = mergeSubEnvironmentConfiguration(stateVariables, remoteVariables)
757756
newSubEnvironments = append(newSubEnvironments, subEnvironment)
758757
}
759758

@@ -762,6 +761,54 @@ func setSubEnvironmentSchema(d *schema.ResourceData, apiClient client.ApiClientI
762761
return nil
763762
}
764763

764+
// API returns a redacted value for sensitive variables, so the schema value is kept.
765+
// Consequence: remote value edits on sensitive variables are not detected — comparing values
766+
// would reintroduce the perpetual drift this function exists to fix.
767+
func mergeSubEnvironmentConfiguration(stateVariables []any, remoteVariables client.ConfigurationChanges) []any {
768+
merged := make([]any, 0, len(stateVariables))
769+
770+
for _, ivariable := range stateVariables {
771+
variable := ivariable.(map[string]any)
772+
variableName := variable["name"].(string)
773+
schemaIsSensitive, _ := variable["is_sensitive"].(bool)
774+
775+
for i := range remoteVariables {
776+
remote := &remoteVariables[i]
777+
if remote.Name != variableName {
778+
continue
779+
}
780+
781+
newVariable := createVariable(remote).(map[string]any)
782+
783+
remoteIsSensitive := remote.IsSensitive != nil && *remote.IsSensitive
784+
if remoteIsSensitive || schemaIsSensitive {
785+
newVariable["value"] = variable["value"]
786+
}
787+
788+
merged = append(merged, newVariable)
789+
790+
break
791+
}
792+
}
793+
794+
for _, remote := range remoteVariables {
795+
found := false
796+
797+
for _, ivariable := range stateVariables {
798+
if ivariable.(map[string]any)["name"].(string) == remote.Name {
799+
found = true
800+
break
801+
}
802+
}
803+
804+
if !found {
805+
merged = append(merged, createVariable(&remote))
806+
}
807+
}
808+
809+
return merged
810+
}
811+
765812
func resourceEnvironmentUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
766813
apiClient := meta.(client.ApiClientInterface)
767814

env0/resource_environment_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/env0/terraform-provider-env0/client/http"
1313
"github.com/google/uuid"
1414
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
15+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1516
"go.uber.org/mock/gomock"
1617
)
1718

@@ -3638,6 +3639,183 @@ func TestUnitEnvironmentWithSubEnvironment(t *testing.T) {
36383639
})
36393640
}
36403641

3642+
func TestMergeSubEnvironmentConfiguration(t *testing.T) {
3643+
varType := client.ConfigurationVariableType(0)
3644+
trueVal := true
3645+
3646+
stateVar := func(name, value string) map[string]any {
3647+
return map[string]any{"name": name, "value": value}
3648+
}
3649+
3650+
remoteVar := func(name, value string, sensitive *bool) client.ConfigurationVariable {
3651+
return client.ConfigurationVariable{
3652+
Name: name,
3653+
Value: value,
3654+
Type: &varType,
3655+
IsSensitive: sensitive,
3656+
Schema: &client.ConfigurationVariableSchema{Type: "string"},
3657+
}
3658+
}
3659+
3660+
t.Run("preserves schema order when API returns reversed", func(t *testing.T) {
3661+
state := []any{stateVar("a", "av"), stateVar("b", "bv"), stateVar("c", "cv")}
3662+
remote := client.ConfigurationChanges{
3663+
remoteVar("c", "cv", nil),
3664+
remoteVar("b", "bv", nil),
3665+
remoteVar("a", "av", nil),
3666+
}
3667+
3668+
merged := mergeSubEnvironmentConfiguration(state, remote)
3669+
3670+
if len(merged) != 3 {
3671+
t.Fatalf("expected 3 vars, got %d", len(merged))
3672+
}
3673+
3674+
names := []string{
3675+
merged[0].(map[string]any)["name"].(string),
3676+
merged[1].(map[string]any)["name"].(string),
3677+
merged[2].(map[string]any)["name"].(string),
3678+
}
3679+
expected := []string{"a", "b", "c"}
3680+
3681+
for i, name := range names {
3682+
if name != expected[i] {
3683+
t.Fatalf("order mismatch at %d: got %q want %q", i, name, expected[i])
3684+
}
3685+
}
3686+
})
3687+
3688+
t.Run("preserves schema value for sensitive vars without comparing API value", func(t *testing.T) {
3689+
state := []any{stateVar("secret", "real-secret")}
3690+
remote := client.ConfigurationChanges{remoteVar("secret", "", &trueVal)}
3691+
3692+
merged := mergeSubEnvironmentConfiguration(state, remote)
3693+
3694+
if len(merged) != 1 {
3695+
t.Fatalf("expected 1 var, got %d", len(merged))
3696+
}
3697+
3698+
got := merged[0].(map[string]any)["value"]
3699+
if got != "real-secret" {
3700+
t.Fatalf("sensitive value not preserved: got %q", got)
3701+
}
3702+
})
3703+
3704+
t.Run("preserves schema value when schema marks sensitive but API does not", func(t *testing.T) {
3705+
state := []any{map[string]any{"name": "secret", "value": "real-secret", "is_sensitive": true}}
3706+
remote := client.ConfigurationChanges{remoteVar("secret", "stale-from-api", nil)}
3707+
3708+
merged := mergeSubEnvironmentConfiguration(state, remote)
3709+
3710+
if len(merged) != 1 {
3711+
t.Fatalf("expected 1 var, got %d", len(merged))
3712+
}
3713+
3714+
got := merged[0].(map[string]any)["value"]
3715+
if got != "real-secret" {
3716+
t.Fatalf("schema-sensitive value not preserved: got %q", got)
3717+
}
3718+
})
3719+
3720+
t.Run("appends remote-only vars as drift", func(t *testing.T) {
3721+
state := []any{stateVar("a", "av")}
3722+
remote := client.ConfigurationChanges{
3723+
remoteVar("a", "av", nil),
3724+
remoteVar("b", "remote-only", nil),
3725+
}
3726+
3727+
merged := mergeSubEnvironmentConfiguration(state, remote)
3728+
3729+
if len(merged) != 2 {
3730+
t.Fatalf("expected 2 vars (incl drift), got %d", len(merged))
3731+
}
3732+
3733+
if merged[0].(map[string]any)["name"] != "a" {
3734+
t.Fatalf("expected schema var first, got %v", merged[0])
3735+
}
3736+
3737+
if merged[1].(map[string]any)["name"] != "b" {
3738+
t.Fatalf("expected drift var second, got %v", merged[1])
3739+
}
3740+
})
3741+
3742+
t.Run("schema var missing from API is dropped", func(t *testing.T) {
3743+
state := []any{stateVar("a", "av"), stateVar("gone", "v")}
3744+
remote := client.ConfigurationChanges{remoteVar("a", "av", nil)}
3745+
3746+
merged := mergeSubEnvironmentConfiguration(state, remote)
3747+
3748+
if len(merged) != 1 {
3749+
t.Fatalf("expected 1 var, got %d", len(merged))
3750+
}
3751+
3752+
if merged[0].(map[string]any)["name"] != "a" {
3753+
t.Fatalf("expected only 'a' var, got %v", merged[0])
3754+
}
3755+
})
3756+
}
3757+
3758+
func TestSetSubEnvironmentSchema(t *testing.T) {
3759+
t.Run("skips API call when sub-env id is empty (initial deploy not yet propagated)", func(t *testing.T) {
3760+
ctrl := gomock.NewController(t)
3761+
defer ctrl.Finish()
3762+
3763+
mock := client.NewMockApiClientInterface(ctrl)
3764+
3765+
d := schema.TestResourceDataRaw(t, resourceEnvironment().Schema, map[string]any{
3766+
"name": "env",
3767+
"project_id": "p",
3768+
"template_id": "t",
3769+
"sub_environment_configuration": []any{
3770+
map[string]any{
3771+
"alias": "rootService1",
3772+
"configuration": []any{
3773+
map[string]any{"name": "ALPHA", "value": "av"},
3774+
},
3775+
},
3776+
},
3777+
})
3778+
3779+
if err := setSubEnvironmentSchema(d, mock); err != nil {
3780+
t.Fatalf("unexpected error: %v", err)
3781+
}
3782+
})
3783+
3784+
t.Run("propagates API errors as wrapped error", func(t *testing.T) {
3785+
ctrl := gomock.NewController(t)
3786+
defer ctrl.Finish()
3787+
3788+
mock := client.NewMockApiClientInterface(ctrl)
3789+
mock.EXPECT().
3790+
ConfigurationVariablesByScope(client.ScopeEnvironment, "sub-1").
3791+
Return(nil, errors.New("boom"))
3792+
3793+
d := schema.TestResourceDataRaw(t, resourceEnvironment().Schema, map[string]any{
3794+
"name": "env",
3795+
"project_id": "p",
3796+
"template_id": "t",
3797+
"sub_environment_configuration": []any{
3798+
map[string]any{
3799+
"id": "sub-1",
3800+
"alias": "rootService1",
3801+
"configuration": []any{
3802+
map[string]any{"name": "ALPHA", "value": "av"},
3803+
},
3804+
},
3805+
},
3806+
})
3807+
3808+
err := setSubEnvironmentSchema(d, mock)
3809+
if err == nil {
3810+
t.Fatalf("expected error, got nil")
3811+
}
3812+
3813+
if !strings.Contains(err.Error(), "sub-1") || !strings.Contains(err.Error(), "boom") {
3814+
t.Fatalf("expected wrapped error mentioning sub-1 and boom, got %q", err.Error())
3815+
}
3816+
})
3817+
}
3818+
36413819
func TestUnitEnvironmentIsRequiredDeprecated(t *testing.T) {
36423820
resourceType := "env0_environment"
36433821
resourceName := "test"

0 commit comments

Comments
 (0)