Skip to content

Commit d23cf9e

Browse files
Fix: implement prevent_auto_deploy flag to allow configuration updates without triggering deployments (#1103)
* Feat: implement prevent_auto_deploy flag to allow configuration updates without triggering deployments * Test: add unit tests for prevent_auto_deploy flag to verify configuration updates without deployment
1 parent 431c784 commit d23cf9e

3 files changed

Lines changed: 631 additions & 13 deletions

File tree

docs/resources/environment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ If true must specify one of the following - 'github_installation_id' if using Gi
7373
- `is_remote_backend` (Boolean) should use remote backend
7474
- `k8s_namespace` (String) kubernetes (or helm) namespace to be used. If modified deletes current environment and creates a new one
7575
- `output` (String) the deployment log output. Returns a json string. It can be either a map of key-value, or an array of (in case of Terragrunt run-all) of moduleName and a map of key-value. Note: if the deployment is still in progress returns 'null'
76-
- `prevent_auto_deploy` (Boolean) use this flag to prevent auto deploy on environment creation
76+
- `prevent_auto_deploy` (Boolean) use this flag to prevent the provider from triggering a deployment (run) when the environment is created or updated. On update, changes to 'configuration', 'sub_environment_configuration' variables and 'variable_sets' are still saved to env0 without a deployment, while changes to 'revision' and 'template_id' are only applied by your next deployment
7777
- `removal_strategy` (String) by default when removing an environment, it gets destroyed. Setting this value to 'mark_as_archived' will force the environment to be archived instead of tying to destroy it ('Mark as inactive' in the UI)
7878
- `revision` (String) the revision the environment is to be run against. Please note that changing this attribute will require environment redeploy
7979
- `run_plan_on_pull_requests` (Boolean) should run terraform plan on pull requests creations.

env0/resource_environment.go

Lines changed: 203 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ func resourceEnvironment() *schema.Resource {
272272
},
273273
"prevent_auto_deploy": {
274274
Type: schema.TypeBool,
275-
Description: "use this flag to prevent auto deploy on environment creation",
275+
Description: "use this flag to prevent the provider from triggering a deployment (run) when the environment is created or updated. On update, changes to 'configuration', 'sub_environment_configuration' variables and 'variable_sets' are still saved to env0 without a deployment, while changes to 'revision' and 'template_id' are only applied by your next deployment",
276276
Optional: true,
277277
},
278278
"terragrunt_working_directory": {
@@ -433,7 +433,15 @@ func setEnvironmentSchema(ctx context.Context, d *schema.ResourceData, environme
433433
}
434434

435435
if !isTemplateless(d) {
436-
if environment.LatestDeploymentLog.BlueprintId != "" {
436+
// When prevent_auto_deploy is set the provider never queues a deployment, so revision and
437+
// template_id are managed declaratively in Terraform and applied by the user's own deploy.
438+
// Overwriting them from the latest deployment log would otherwise cause perpetual drift.
439+
// Trade-off (by design): genuine external changes to these fields are not detected, and a
440+
// revision change is kept in state but only takes effect on the user's next deployment.
441+
// setEnvironmentSchema is shared with the environment data source (which has no
442+
// prevent_auto_deploy field) - the comma-ok assertion safely defaults it to false there.
443+
preventAutoDeploy, _ := d.Get("prevent_auto_deploy").(bool)
444+
if environment.LatestDeploymentLog.BlueprintId != "" && !preventAutoDeploy {
437445
d.Set("template_id", environment.LatestDeploymentLog.BlueprintId)
438446
d.Set("revision", environment.LatestDeploymentLog.BlueprintRevision)
439447
}
@@ -844,7 +852,11 @@ func resourceEnvironmentUpdate(ctx context.Context, d *schema.ResourceData, meta
844852
}
845853

846854
if shouldDeploy(d) {
847-
if err := deploy(d, apiClient); err != nil {
855+
if d.Get("prevent_auto_deploy").(bool) {
856+
if diagErr := updateWithoutDeploy(d, apiClient); diagErr != nil {
857+
return diagErr
858+
}
859+
} else if err := deploy(d, apiClient); err != nil {
848860
return err
849861
}
850862
}
@@ -924,6 +936,21 @@ func updateDriftDetection(d *schema.ResourceData, apiClient client.ApiClientInte
924936
return nil
925937
}
926938

939+
func getEnvironmentConfigurationScope(d *schema.ResourceData) client.Scope {
940+
if _, ok := d.GetOk("sub_environment_configuration"); ok {
941+
return client.ScopeWorkflow
942+
}
943+
944+
return client.ScopeEnvironment
945+
}
946+
947+
func getSubEnvironmentConfigurationChanges(d *schema.ResourceData, index int, subEnvironmentId string, apiClient client.ApiClientInterface) (client.ConfigurationChanges, error) {
948+
configuration := d.Get(fmt.Sprintf("sub_environment_configuration.%d.configuration", index)).([]any)
949+
configurationChanges := stripIsRequired(getConfigurationVariablesFromSchema(configuration))
950+
951+
return getUpdateConfigurationVariables(configurationChanges, subEnvironmentId, client.ScopeEnvironment, apiClient)
952+
}
953+
927954
func deploy(d *schema.ResourceData, apiClient client.ApiClientInterface) diag.Diagnostics {
928955
deployPayload, err := getDeployPayload(d, apiClient, true)
929956
if err != nil {
@@ -952,10 +979,7 @@ func deploy(d *schema.ResourceData, apiClient client.ApiClientInterface) diag.Di
952979
deployPayload.SubEnvironments = make(map[string]client.SubEnvironment)
953980

954981
for i, subEnvironment := range subEnvironments {
955-
configuration := d.Get(fmt.Sprintf("sub_environment_configuration.%d.configuration", i)).([]any)
956-
configurationChanges := stripIsRequired(getConfigurationVariablesFromSchema(configuration))
957-
958-
configurationChanges, err = getUpdateConfigurationVariables(configurationChanges, subEnvironment.Id, client.ScopeEnvironment, apiClient)
982+
configurationChanges, err := getSubEnvironmentConfigurationChanges(d, i, subEnvironment.Id, apiClient)
959983
if err != nil {
960984
return diag.FromErr(err)
961985
}
@@ -983,6 +1007,177 @@ func deploy(d *schema.ResourceData, apiClient client.ApiClientInterface) diag.Di
9831007
return nil
9841008
}
9851009

1010+
func updateWithoutDeploy(d *schema.ResourceData, apiClient client.ApiClientInterface) diag.Diagnostics {
1011+
if d.HasChange("configuration") {
1012+
if err := updateEnvironmentConfigurationWithoutDeploy(d, apiClient); err != nil {
1013+
return diag.Errorf("could not update environment configuration variables: %v", err)
1014+
}
1015+
}
1016+
1017+
if d.HasChange("sub_environment_configuration") {
1018+
if err := updateSubEnvironmentsConfigurationWithoutDeploy(d, apiClient); err != nil {
1019+
return diag.Errorf("could not update sub environment configuration variables: %v", err)
1020+
}
1021+
}
1022+
1023+
if d.HasChange("variable_sets") {
1024+
if err := updateVariableSetsWithoutDeploy(d, apiClient); err != nil {
1025+
return diag.Errorf("could not update variable sets: %v", err)
1026+
}
1027+
}
1028+
1029+
return nil
1030+
}
1031+
1032+
// updateEnvironmentConfigurationWithoutDeploy persists the environment's top-level configuration
1033+
// variables directly, without a deployment. For a regular environment the scope is ENVIRONMENT; for
1034+
// a workflow environment it is WORKFLOW. The WORKFLOW-scoped direct create/update path is unique to
1035+
// this prevent_auto_deploy flow (the deploy path ships workflow vars in the deploy payload), but the
1036+
// backend supports it: /configuration accepts WORKFLOW-scoped writes and the matching
1037+
// workflowEnvironmentId read returns them (verified via an API round-trip on the dev stack).
1038+
func updateEnvironmentConfigurationWithoutDeploy(d *schema.ResourceData, apiClient client.ApiClientInterface) error {
1039+
scope := getEnvironmentConfigurationScope(d)
1040+
1041+
var configurationChanges client.ConfigurationChanges
1042+
if configuration, ok := d.GetOk("configuration"); ok {
1043+
configurationChanges = stripIsRequired(getConfigurationVariablesFromSchema(configuration.([]any)))
1044+
}
1045+
1046+
configurationChanges, err := getUpdateConfigurationVariables(configurationChanges, d.Id(), scope, apiClient)
1047+
if err != nil {
1048+
return err
1049+
}
1050+
1051+
return applyConfigurationChangesWithoutDeploy(configurationChanges, scope, d.Id(), apiClient)
1052+
}
1053+
1054+
func updateSubEnvironmentsConfigurationWithoutDeploy(d *schema.ResourceData, apiClient client.ApiClientInterface) error {
1055+
subEnvironments, err := getSubEnvironments(d)
1056+
if err != nil {
1057+
return fmt.Errorf("failed to extract sub environments from resource data: %w", err)
1058+
}
1059+
1060+
for i, subEnvironment := range subEnvironments {
1061+
if subEnvironment.Id == "" {
1062+
continue
1063+
}
1064+
1065+
// Only touch a sub-environment's variables when its own configuration block changed.
1066+
// updateWithoutDeploy is entered on any sub_environment_configuration change (including
1067+
// non-variable fields like workspace/approve_plan_automatically), so without this guard we
1068+
// would recompute deltas and could update/delete variables the user never touched.
1069+
if !d.HasChange(fmt.Sprintf("sub_environment_configuration.%d.configuration", i)) {
1070+
continue
1071+
}
1072+
1073+
configurationChanges, err := getSubEnvironmentConfigurationChanges(d, i, subEnvironment.Id, apiClient)
1074+
if err != nil {
1075+
return err
1076+
}
1077+
1078+
if err := applyConfigurationChangesWithoutDeploy(configurationChanges, client.ScopeEnvironment, subEnvironment.Id, apiClient); err != nil {
1079+
return err
1080+
}
1081+
}
1082+
1083+
return nil
1084+
}
1085+
1086+
func updateVariableSetsWithoutDeploy(d *schema.ResourceData, apiClient client.ApiClientInterface) error {
1087+
changes, err := getEnvironmentConfigurationSetChanges(d, apiClient)
1088+
if err != nil {
1089+
if errors.Is(err, ErrNoChanges) {
1090+
return nil
1091+
}
1092+
1093+
return err
1094+
}
1095+
1096+
if len(changes.Assign) > 0 {
1097+
if err := apiClient.AssignConfigurationSets(client.ENVIRONMENT, d.Id(), changes.Assign); err != nil {
1098+
return fmt.Errorf("could not assign variable sets: %w", err)
1099+
}
1100+
}
1101+
1102+
if len(changes.Unassign) > 0 {
1103+
if err := apiClient.UnassignConfigurationSets(client.ENVIRONMENT, d.Id(), changes.Unassign); err != nil {
1104+
return fmt.Errorf("could not unassign variable sets: %w", err)
1105+
}
1106+
}
1107+
1108+
return nil
1109+
}
1110+
1111+
// applyConfigurationChangesWithoutDeploy persists configuration variable changes one by one
1112+
// (create/update/delete) directly to the given scope, without a deployment. Unlike the deploy
1113+
// endpoint - which applies all changes atomically - these are sequential per-variable calls, so a
1114+
// mid-loop failure leaves the environment partially applied and returns an error; Terraform
1115+
// re-reconciles the remaining changes on the next apply. This matches the standalone
1116+
// env0_configuration_variable resource's behaviour.
1117+
func applyConfigurationChangesWithoutDeploy(configurationChanges client.ConfigurationChanges, scope client.Scope, scopeId string, apiClient client.ApiClientInterface) error {
1118+
for i := range configurationChanges {
1119+
change := configurationChanges[i]
1120+
1121+
if change.ToDelete != nil && *change.ToDelete {
1122+
if err := apiClient.ConfigurationVariableDelete(change.Id); err != nil {
1123+
return fmt.Errorf("could not delete configuration variable %q: %w", change.Name, err)
1124+
}
1125+
1126+
continue
1127+
}
1128+
1129+
params := configurationVariableCreateParams(change, scope, scopeId)
1130+
1131+
if change.Id != "" {
1132+
if _, err := apiClient.ConfigurationVariableUpdate(client.ConfigurationVariableUpdateParams{Id: change.Id, CommonParams: params}); err != nil {
1133+
return fmt.Errorf("could not update configuration variable %q: %w", change.Name, err)
1134+
}
1135+
1136+
continue
1137+
}
1138+
1139+
if _, err := apiClient.ConfigurationVariableCreate(params); err != nil {
1140+
return fmt.Errorf("could not create configuration variable %q: %w", change.Name, err)
1141+
}
1142+
}
1143+
1144+
return nil
1145+
}
1146+
1147+
func configurationVariableCreateParams(change client.ConfigurationVariable, scope client.Scope, scopeId string) client.ConfigurationVariableCreateParams {
1148+
params := client.ConfigurationVariableCreateParams{
1149+
Name: change.Name,
1150+
Value: change.Value,
1151+
Scope: scope,
1152+
ScopeId: scopeId,
1153+
Description: change.Description,
1154+
Regex: change.Regex,
1155+
}
1156+
1157+
if change.Type != nil {
1158+
params.Type = *change.Type
1159+
}
1160+
1161+
if change.IsSensitive != nil {
1162+
params.IsSensitive = *change.IsSensitive
1163+
}
1164+
1165+
if change.IsReadOnly != nil {
1166+
params.IsReadOnly = *change.IsReadOnly
1167+
}
1168+
1169+
if change.IsRequired != nil {
1170+
params.IsRequired = *change.IsRequired
1171+
}
1172+
1173+
if change.Schema != nil {
1174+
params.EnumValues = change.Schema.Enum
1175+
params.Format = change.Schema.Format
1176+
}
1177+
1178+
return params
1179+
}
1180+
9861181
func update(d *schema.ResourceData, apiClient client.ApiClientInterface) diag.Diagnostics {
9871182
payload, updateEnvPayloadErr := getUpdatePayload(d)
9881183

@@ -1374,11 +1569,7 @@ func getDeployPayload(d *schema.ResourceData, apiClient client.ApiClientInterfac
13741569

13751570
if configuration, ok := d.GetOk("configuration"); ok && isRedeploy {
13761571
configurationChanges := stripIsRequired(getConfigurationVariablesFromSchema(configuration.([]any)))
1377-
scope := client.ScopeEnvironment
1378-
1379-
if _, ok := d.GetOk("sub_environment_configuration"); ok {
1380-
scope = client.ScopeWorkflow
1381-
}
1572+
scope := getEnvironmentConfigurationScope(d)
13821573

13831574
configurationChanges, err = getUpdateConfigurationVariables(configurationChanges, d.Get("id").(string), scope, apiClient)
13841575
if err != nil {

0 commit comments

Comments
 (0)