Skip to content

Commit f95000e

Browse files
committed
Reconcile the encrypted_value, plaintext_value in the read to unknown went dates don't match
1 parent b3a4c70 commit f95000e

File tree

4 files changed

+235
-30
lines changed

4 files changed

+235
-30
lines changed

github/resource_github_actions_organization_secret.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,6 @@ func resourceGithubActionsOrganizationSecretRead(d *schema.ResourceData, meta in
172172
return err
173173
}
174174

175-
if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil {
176-
return err
177-
}
178-
if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil {
179-
return err
180-
}
181175
if err = d.Set("created_at", secret.CreatedAt.String()); err != nil {
182176
return err
183177
}
@@ -223,14 +217,35 @@ func resourceGithubActionsOrganizationSecretRead(d *schema.ResourceData, meta in
223217
// timestamp we've persisted in the state. In that case, we can no longer
224218
// trust that the value (which we don't see) is equal to what we've declared
225219
// previously.
226-
//
227-
// The only solution to enforce consistency between is to mark the resource
228-
// as deleted (unset the ID) in order to fix potential drift by recreating
229-
// the resource.
230220
destroyOnDrift := d.Get("destroy_on_drift").(bool)
231-
if updatedAt, ok := d.GetOk("updated_at"); ok && destroyOnDrift && updatedAt != secret.UpdatedAt.String() {
221+
storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at")
222+
223+
if hasStoredUpdatedAt && storedUpdatedAt != secret.UpdatedAt.String() {
232224
log.Printf("[INFO] The secret %s has been externally updated in GitHub", d.Id())
233-
d.SetId("")
225+
226+
if destroyOnDrift {
227+
// Original behavior: mark for recreation
228+
d.SetId("")
229+
return nil
230+
} else {
231+
// Alternative approach: set sensitive values to empty to trigger update plan
232+
// This tells Terraform that the current state is unknown and needs reconciliation
233+
if err = d.Set("encrypted_value", ""); err != nil {
234+
return err
235+
}
236+
if err = d.Set("plaintext_value", ""); err != nil {
237+
return err
238+
}
239+
log.Printf("[INFO] Detected drift but destroy_on_drift=false, clearing sensitive values to trigger update")
240+
}
241+
} else {
242+
// No drift detected, preserve the configured values in state
243+
if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil {
244+
return err
245+
}
246+
if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil {
247+
return err
248+
}
234249
}
235250

236251
// Always update the timestamp to prevent repeated drift detection

github/resource_github_actions_organization_secret_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1011
)
1112

1213
func TestAccGithubActionsOrganizationSecret(t *testing.T) {
@@ -184,4 +185,114 @@ func TestAccGithubActionsOrganizationSecret(t *testing.T) {
184185
testCase(t, organization)
185186
})
186187
})
188+
189+
// Unit tests for drift detection behavior
190+
t.Run("destroyOnDrift false clears sensitive values instead of recreating", func(t *testing.T) {
191+
originalTimestamp := "2023-01-01T00:00:00Z"
192+
newTimestamp := "2023-01-02T00:00:00Z"
193+
194+
d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]interface{}{
195+
"secret_name": "test-secret",
196+
"plaintext_value": "original-value",
197+
"encrypted_value": "original-encrypted",
198+
"visibility": "private",
199+
"destroy_on_drift": false,
200+
"updated_at": originalTimestamp,
201+
})
202+
d.SetId("test-secret")
203+
204+
// Simulate drift detection logic when destroy_on_drift is false
205+
destroyOnDrift := d.Get("destroy_on_drift").(bool)
206+
storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at")
207+
208+
if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp {
209+
if destroyOnDrift {
210+
// Would clear ID for recreation
211+
d.SetId("")
212+
} else {
213+
// Should clear sensitive values to trigger update
214+
d.Set("encrypted_value", "")
215+
d.Set("plaintext_value", "")
216+
}
217+
d.Set("updated_at", newTimestamp)
218+
}
219+
220+
// Should NOT have cleared the ID when destroy_on_drift=false
221+
if d.Id() == "" {
222+
t.Error("Expected ID to be preserved when destroy_on_drift=false, but it was cleared")
223+
}
224+
225+
// Should have cleared sensitive values to trigger update plan
226+
if plaintextValue := d.Get("plaintext_value").(string); plaintextValue != "" {
227+
t.Errorf("Expected plaintext_value to be cleared for update plan, got %s", plaintextValue)
228+
}
229+
230+
if encryptedValue := d.Get("encrypted_value").(string); encryptedValue != "" {
231+
t.Errorf("Expected encrypted_value to be cleared for update plan, got %s", encryptedValue)
232+
}
233+
234+
// Should have updated the timestamp
235+
if updatedAt := d.Get("updated_at").(string); updatedAt != newTimestamp {
236+
t.Errorf("Expected timestamp to be updated to %s, got %s", newTimestamp, updatedAt)
237+
}
238+
})
239+
240+
t.Run("destroyOnDrift true still recreates resource on drift", func(t *testing.T) {
241+
originalTimestamp := "2023-01-01T00:00:00Z"
242+
newTimestamp := "2023-01-02T00:00:00Z"
243+
244+
d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]interface{}{
245+
"secret_name": "test-secret",
246+
"plaintext_value": "original-value",
247+
"visibility": "private",
248+
"destroy_on_drift": true, // Explicitly set to true
249+
"updated_at": originalTimestamp,
250+
})
251+
d.SetId("test-secret")
252+
253+
// Simulate drift detection logic when destroy_on_drift is true
254+
destroyOnDrift := d.Get("destroy_on_drift").(bool)
255+
storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at")
256+
257+
if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp {
258+
if destroyOnDrift {
259+
// Should clear ID for recreation (original behavior)
260+
d.SetId("")
261+
return // Exit early like the real function would
262+
}
263+
}
264+
265+
// Should have cleared the ID for recreation when destroy_on_drift=true
266+
if d.Id() != "" {
267+
t.Error("Expected ID to be cleared for recreation when destroy_on_drift=true, but it was preserved")
268+
}
269+
})
270+
271+
t.Run("destroy_on_drift requires ForceNew due to Update function", func(t *testing.T) {
272+
// Test that destroy_on_drift field has ForceNew: true
273+
// This is required because we have an Update function defined
274+
schema := resourceGithubActionsOrganizationSecret().Schema["destroy_on_drift"]
275+
if !schema.ForceNew {
276+
t.Error("destroy_on_drift field must have ForceNew: true when Update function is defined, to avoid 'Provider produced inconsistent result' errors")
277+
}
278+
279+
// Test that it defaults to true
280+
if schema.Default != true {
281+
t.Error("destroy_on_drift should default to true for backward compatibility")
282+
}
283+
})
284+
285+
t.Run("default destroy_on_drift is true", func(t *testing.T) {
286+
d := schema.TestResourceDataRaw(t, resourceGithubActionsOrganizationSecret().Schema, map[string]interface{}{
287+
"secret_name": "test-secret",
288+
"plaintext_value": "test-value",
289+
"visibility": "private",
290+
// destroy_on_drift not set, should default to true
291+
})
292+
293+
destroyOnDrift := d.Get("destroy_on_drift").(bool)
294+
if !destroyOnDrift {
295+
t.Error("Expected destroy_on_drift to default to true")
296+
}
297+
})
187298
}

github/resource_github_actions_secret.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,6 @@ func resourceGithubActionsSecretRead(d *schema.ResourceData, meta interface{}) e
143143
return err
144144
}
145145

146-
if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil {
147-
return err
148-
}
149-
if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil {
150-
return err
151-
}
152146
if err = d.Set("created_at", secret.CreatedAt.String()); err != nil {
153147
return err
154148
}
@@ -164,14 +158,35 @@ func resourceGithubActionsSecretRead(d *schema.ResourceData, meta interface{}) e
164158
// timestamp we've persisted in the state. In that case, we can no longer
165159
// trust that the value (which we don't see) is equal to what we've declared
166160
// previously.
167-
//
168-
// The only solution to enforce consistency between is to mark the resource
169-
// as deleted (unset the ID) in order to fix potential drift by recreating
170-
// the resource.
171161
destroyOnDrift := d.Get("destroy_on_drift").(bool)
172-
if updatedAt, ok := d.GetOk("updated_at"); ok && destroyOnDrift && updatedAt != secret.UpdatedAt.String() {
162+
storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at")
163+
164+
if hasStoredUpdatedAt && storedUpdatedAt != secret.UpdatedAt.String() {
173165
log.Printf("[INFO] The secret %s has been externally updated in GitHub", d.Id())
174-
d.SetId("")
166+
167+
if destroyOnDrift {
168+
// Original behavior: mark for recreation
169+
d.SetId("")
170+
return nil
171+
} else {
172+
// Alternative approach: set sensitive values to empty to trigger update plan
173+
// This tells Terraform that the current state is unknown and needs reconciliation
174+
if err = d.Set("encrypted_value", ""); err != nil {
175+
return err
176+
}
177+
if err = d.Set("plaintext_value", ""); err != nil {
178+
return err
179+
}
180+
log.Printf("[INFO] Detected drift but destroy_on_drift=false, clearing sensitive values to trigger update")
181+
}
182+
} else {
183+
// No drift detected, preserve the configured values in state
184+
if err = d.Set("encrypted_value", d.Get("encrypted_value")); err != nil {
185+
return err
186+
}
187+
if err = d.Set("plaintext_value", d.Get("plaintext_value")); err != nil {
188+
return err
189+
}
175190
}
176191

177192
// Always update the timestamp to prevent repeated drift detection

github/resource_github_actions_secret_test.go

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -394,37 +394,87 @@ func TestGithubActionsSecretDriftDetection(t *testing.T) {
394394
}
395395
})
396396

397-
t.Run("destroyOnDrift false updates timestamp without recreation", func(t *testing.T) {
397+
t.Run("destroyOnDrift false clears sensitive values instead of recreating", func(t *testing.T) {
398398
originalTimestamp := "2023-01-01T00:00:00Z"
399399
newTimestamp := "2023-01-02T00:00:00Z"
400400

401401
d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]interface{}{
402402
"repository": "test-repo",
403403
"secret_name": "test-secret",
404-
"plaintext_value": "test-value",
404+
"plaintext_value": "original-value",
405+
"encrypted_value": "original-encrypted",
405406
"destroy_on_drift": false,
406407
"updated_at": originalTimestamp,
407408
})
408409
d.SetId("test-secret")
409410

410-
// Test the drift detection logic when destroy_on_drift is false
411+
// Simulate drift detection logic when destroy_on_drift is false
411412
destroyOnDrift := d.Get("destroy_on_drift").(bool)
412-
if updatedAt, ok := d.GetOk("updated_at"); ok && !destroyOnDrift && updatedAt != newTimestamp {
413-
// This simulates what happens when destroy_on_drift=false
413+
storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at")
414+
415+
if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp {
416+
if destroyOnDrift {
417+
// Would clear ID for recreation
418+
d.SetId("")
419+
} else {
420+
// Should clear sensitive values to trigger update
421+
d.Set("encrypted_value", "")
422+
d.Set("plaintext_value", "")
423+
}
414424
d.Set("updated_at", newTimestamp)
415425
}
416426

417-
// Should NOT have cleared the ID
427+
// Should NOT have cleared the ID when destroy_on_drift=false
418428
if d.Id() == "" {
419429
t.Error("Expected ID to be preserved when destroy_on_drift=false, but it was cleared")
420430
}
421431

432+
// Should have cleared sensitive values to trigger update plan
433+
if plaintextValue := d.Get("plaintext_value").(string); plaintextValue != "" {
434+
t.Errorf("Expected plaintext_value to be cleared for update plan, got %s", plaintextValue)
435+
}
436+
437+
if encryptedValue := d.Get("encrypted_value").(string); encryptedValue != "" {
438+
t.Errorf("Expected encrypted_value to be cleared for update plan, got %s", encryptedValue)
439+
}
440+
422441
// Should have updated the timestamp
423442
if updatedAt := d.Get("updated_at").(string); updatedAt != newTimestamp {
424443
t.Errorf("Expected timestamp to be updated to %s, got %s", newTimestamp, updatedAt)
425444
}
426445
})
427446

447+
t.Run("destroyOnDrift true still recreates resource on drift", func(t *testing.T) {
448+
originalTimestamp := "2023-01-01T00:00:00Z"
449+
newTimestamp := "2023-01-02T00:00:00Z"
450+
451+
d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]interface{}{
452+
"repository": "test-repo",
453+
"secret_name": "test-secret",
454+
"plaintext_value": "original-value",
455+
"destroy_on_drift": true, // Explicitly set to true
456+
"updated_at": originalTimestamp,
457+
})
458+
d.SetId("test-secret")
459+
460+
// Simulate drift detection logic when destroy_on_drift is true
461+
destroyOnDrift := d.Get("destroy_on_drift").(bool)
462+
storedUpdatedAt, hasStoredUpdatedAt := d.GetOk("updated_at")
463+
464+
if hasStoredUpdatedAt && storedUpdatedAt != newTimestamp {
465+
if destroyOnDrift {
466+
// Should clear ID for recreation (original behavior)
467+
d.SetId("")
468+
return // Exit early like the real function would
469+
}
470+
}
471+
472+
// Should have cleared the ID for recreation when destroy_on_drift=true
473+
if d.Id() != "" {
474+
t.Error("Expected ID to be cleared for recreation when destroy_on_drift=true, but it was preserved")
475+
}
476+
})
477+
428478
t.Run("default destroy_on_drift is true", func(t *testing.T) {
429479
d := schema.TestResourceDataRaw(t, resourceGithubActionsSecret().Schema, map[string]interface{}{
430480
"repository": "test-repo",
@@ -463,6 +513,20 @@ func TestGithubActionsSecretDriftDetection(t *testing.T) {
463513
}
464514
})
465515

516+
t.Run("destroy_on_drift requires ForceNew due to Update function", func(t *testing.T) {
517+
// Test that destroy_on_drift field has ForceNew: true
518+
// This is required because we have an Update function defined
519+
schema := resourceGithubActionsSecret().Schema["destroy_on_drift"]
520+
if !schema.ForceNew {
521+
t.Error("destroy_on_drift field must have ForceNew: true when Update function is defined, to avoid 'Provider produced inconsistent result' errors")
522+
}
523+
524+
// Test that it defaults to true
525+
if schema.Default != true {
526+
t.Error("destroy_on_drift should default to true for backward compatibility")
527+
}
528+
})
529+
466530
t.Run("destroy_on_drift field properties", func(t *testing.T) {
467531
resource := resourceGithubActionsSecret()
468532
driftField := resource.Schema["destroy_on_drift"]

0 commit comments

Comments
 (0)