Skip to content

Commit 9f2e06f

Browse files
committed
feat(zero_trust_access_application): v4 to v5 migration
1 parent 3e41d48 commit 9f2e06f

File tree

6 files changed

+1320
-936
lines changed

6 files changed

+1320
-936
lines changed

integration/v4_to_v5/testdata/zero_trust_access_application/expected/zero_trust_access_application.tf

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ variable "cloudflare_zone_id" {
88
type = string
99
}
1010

11+
variable "cloudflare_domain" {
12+
description = "Cloudflare Domain"
13+
type = string
14+
}
15+
1116
# Resource-specific variables with defaults
1217
variable "app_prefix" {
1318
type = string
@@ -28,7 +33,7 @@ variable "policy_ids" {
2833
locals {
2934
name_prefix = "cftftest"
3035
common_account_id = var.cloudflare_account_id
31-
app_domain_suffix = "cort.terraform.cfapi.net"
36+
app_domain_suffix = var.cloudflare_domain
3237
common_policies = ["default-policy-id"]
3338
}
3439

integration/v4_to_v5/testdata/zero_trust_access_application/expected/zero_trust_access_application_e2e.tf

Lines changed: 0 additions & 43 deletions
This file was deleted.

integration/v4_to_v5/testdata/zero_trust_access_application/input/zero_trust_access_application.tf

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ variable "cloudflare_zone_id" {
88
type = string
99
}
1010

11+
variable "cloudflare_domain" {
12+
description = "Cloudflare Domain"
13+
type = string
14+
}
15+
1116
# Resource-specific variables with defaults
1217
variable "app_prefix" {
1318
type = string
@@ -28,7 +33,7 @@ variable "policy_ids" {
2833
locals {
2934
name_prefix = "cftftest"
3035
common_account_id = var.cloudflare_account_id
31-
app_domain_suffix = "cort.terraform.cfapi.net"
36+
app_domain_suffix = var.cloudflare_domain
3237
common_policies = ["default-policy-id"]
3338
}
3439

integration/v4_to_v5/testdata/zero_trust_access_application/input/zero_trust_access_application_e2e.tf

Lines changed: 0 additions & 42 deletions
This file was deleted.

internal/resources/zero_trust_access_application/v4_to_v5.go

Lines changed: 170 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
package zero_trust_access_application
22

33
import (
4-
"fmt"
4+
"sort"
55
"strconv"
6+
"strings"
67

78
"github.com/hashicorp/hcl/v2"
89
"github.com/hashicorp/hcl/v2/hclsyntax"
910
"github.com/hashicorp/hcl/v2/hclwrite"
1011
"github.com/tidwall/gjson"
1112
"github.com/tidwall/sjson"
13+
"github.com/zclconf/go-cty/cty"
1214

1315
"github.com/cloudflare/tf-migrate/internal"
1416
"github.com/cloudflare/tf-migrate/internal/transform"
@@ -59,8 +61,20 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite
5961
// V4 has type default = "self_hosted", default to this value if type is not specified in V4 config
6062
tfhcl.EnsureAttribute(body, "type", "self_hosted")
6163

64+
// V5 changed the default for http_only_cookie_attribute from false to true
65+
// Explicitly set to false to maintain v4 behavior when not specified
66+
// Only applicable for types: self_hosted, ssh, vnc, rdp, mcp_portal
67+
appType := tfhcl.ExtractStringFromAttribute(body.GetAttribute("type"))
68+
if appType == "self_hosted" || appType == "ssh" || appType == "vnc" || appType == "rdp" || appType == "mcp_portal" {
69+
tfhcl.EnsureAttribute(body, "http_only_cookie_attribute", "false")
70+
}
71+
6272
tfhcl.RemoveAttributes(body, "domain_type")
6373

74+
// Remove attributes with default/empty values that v4 provider removes from state
75+
// This prevents drift when migrating to v5
76+
removeDefaultValueAttributes(body)
77+
6478
tfhcl.ConvertBlocksToAttribute(body, "cors_headers", "cors_headers", nil)
6579
tfhcl.ConvertBlocksToAttributeList(body, "destinations", nil)
6680
tfhcl.ConvertBlocksToAttributeList(body, "footer_links", nil)
@@ -82,6 +96,9 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite
8296
tfhcl.RemoveFunctionWrapper(body, "custom_pages", "toset")
8397
tfhcl.RemoveFunctionWrapper(body, "self_hosted_domains", "toset")
8498

99+
// Sort self_hosted_domains to match provider ordering and avoid drift
100+
sortStringArrayAttribute(body, "self_hosted_domains")
101+
85102
m.transformSaasAppBlock(body)
86103
m.transformScimConfigBlock(body)
87104
m.transformTargetCriteriaBlocks(body)
@@ -92,6 +109,137 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite
92109
}, nil
93110
}
94111

112+
// removeDefaultValueAttributes removes attributes that have default/empty values.
113+
// v4 provider removes these from state, so we should remove them from config to avoid drift.
114+
func removeDefaultValueAttributes(body *hclwrite.Body) {
115+
// Boolean attributes that should be removed if false
116+
boolAttrs := []string{
117+
"auto_redirect_to_identity",
118+
"enable_binding_cookie",
119+
"options_preflight_bypass",
120+
"service_auth_401_redirect",
121+
"skip_interstitial",
122+
}
123+
124+
for _, attrName := range boolAttrs {
125+
if attr := body.GetAttribute(attrName); attr != nil {
126+
if val, ok := tfhcl.ExtractBoolFromAttribute(attr); ok && !val {
127+
// Remove if value is explicitly false
128+
tfhcl.RemoveAttributes(body, attrName)
129+
}
130+
}
131+
}
132+
133+
// Array attributes that should be removed if empty
134+
arrayAttrs := []string{"allowed_idps", "tags"}
135+
for _, attrName := range arrayAttrs {
136+
if attr := body.GetAttribute(attrName); attr != nil {
137+
tokens := attr.Expr().BuildTokens(nil)
138+
// Check if it's an empty array []
139+
tokenStr := string(tokens.Bytes())
140+
if strings.TrimSpace(tokenStr) == "[]" {
141+
tfhcl.RemoveAttributes(body, attrName)
142+
}
143+
}
144+
}
145+
}
146+
147+
// sortStringArrayAttribute sorts a string array attribute alphabetically.
148+
// This is needed when the provider returns arrays in a consistent (sorted) order
149+
// different from the user-specified order, causing drift.
150+
func sortStringArrayAttribute(body *hclwrite.Body, attrName string) {
151+
attr := body.GetAttribute(attrName)
152+
if attr == nil {
153+
return
154+
}
155+
156+
// Parse the expression to extract string values
157+
expr := attr.Expr()
158+
159+
// Try to parse as tuple (array)
160+
tokens := expr.BuildTokens(nil)
161+
tokenBytes := tokens.Bytes()
162+
163+
// Parse the HCL expression
164+
parsed, diags := hclsyntax.ParseExpression(tokenBytes, "", hcl.Pos{Line: 1, Column: 1})
165+
if diags.HasErrors() {
166+
return
167+
}
168+
169+
// Check if it's a tuple (array)
170+
tuple, ok := parsed.(*hclsyntax.TupleConsExpr)
171+
if !ok {
172+
return
173+
}
174+
175+
// Extract string values
176+
var strings []string
177+
for _, elem := range tuple.Exprs {
178+
if template, ok := elem.(*hclsyntax.TemplateExpr); ok {
179+
// Handle string literals
180+
if len(template.Parts) == 1 {
181+
if lit, ok := template.Parts[0].(*hclsyntax.LiteralValueExpr); ok {
182+
if lit.Val.Type() == cty.String {
183+
strings = append(strings, lit.Val.AsString())
184+
}
185+
}
186+
}
187+
}
188+
}
189+
190+
// If we couldn't extract all strings, don't modify
191+
if len(strings) != len(tuple.Exprs) {
192+
return
193+
}
194+
195+
// Sort the strings
196+
// Special handling for OIDC scopes: use canonical OIDC scope ordering
197+
// The provider orders scopes according to the OIDC spec: openid, profile, email, then others alphabetically
198+
if attrName == "scopes" && len(strings) > 0 {
199+
// Define canonical OIDC scope order
200+
scopeOrder := map[string]int{
201+
"openid": 1,
202+
"profile": 2,
203+
"email": 3,
204+
"address": 4,
205+
"phone": 5,
206+
"offline_access": 6,
207+
}
208+
209+
sort.SliceStable(strings, func(i, j int) bool {
210+
orderI, hasI := scopeOrder[strings[i]]
211+
orderJ, hasJ := scopeOrder[strings[j]]
212+
213+
// Both have defined order - use it
214+
if hasI && hasJ {
215+
return orderI < orderJ
216+
}
217+
// Only i has order - it comes first
218+
if hasI {
219+
return true
220+
}
221+
// Only j has order - it comes first
222+
if hasJ {
223+
return false
224+
}
225+
// Neither has order - sort alphabetically
226+
return strings[i] < strings[j]
227+
})
228+
} else {
229+
// Not scopes, sort normally
230+
sort.Strings(strings)
231+
}
232+
233+
// Build new array tokens with sorted values
234+
var sortedTokens []hclwrite.Tokens
235+
for _, s := range strings {
236+
sortedTokens = append(sortedTokens, hclwrite.TokensForValue(cty.StringVal(s)))
237+
}
238+
239+
// Set the attribute with sorted values
240+
body.SetAttributeRaw(attrName, hclwrite.TokensForTuple(sortedTokens))
241+
}
242+
95243
func (m *V4ToV5Migrator) transformSaasAppBlock(body *hclwrite.Body) {
96244
saasAppBlocks := tfhcl.FindBlocksByType(body, "saas_app")
97245
if len(saasAppBlocks) == 0 {
@@ -137,6 +285,9 @@ func (m *V4ToV5Migrator) transformSaasAppBlock(body *hclwrite.Body) {
137285

138286
tfhcl.ConvertSingleBlockToAttribute(saasAppBody, "hybrid_and_implicit_options", "hybrid_and_implicit_options")
139287
tfhcl.ConvertSingleBlockToAttribute(saasAppBody, "refresh_token_options", "refresh_token_options")
288+
289+
// Sort scopes array to match provider ordering and avoid drift
290+
sortStringArrayAttribute(saasAppBody, "scopes")
140291
}
141292

142293
tfhcl.ConvertSingleBlockToAttribute(body, "saas_app", "saas_app")
@@ -342,20 +493,6 @@ func (m *V4ToV5Migrator) transformSingleInstance(result string, instance gjson.R
342493
return result
343494
}
344495

345-
// Debug: Log CFGFiles status
346-
if ctx != nil {
347-
fmt.Printf("DEBUG transformSingleInstance: resource=%s, CFGFiles count=%d\n", resourceName, len(ctx.CFGFiles))
348-
if len(ctx.CFGFiles) > 0 {
349-
fmt.Printf("DEBUG transformSingleInstance: CFGFiles keys: ")
350-
for k := range ctx.CFGFiles {
351-
fmt.Printf("%s, ", k)
352-
}
353-
fmt.Printf("\n")
354-
}
355-
} else {
356-
fmt.Printf("DEBUG transformSingleInstance: ctx is nil for resource=%s\n", resourceName)
357-
}
358-
359496
// If ctx is nil, create an empty context so transformations can still run
360497
if ctx == nil {
361498
ctx = &transform.Context{
@@ -637,6 +774,24 @@ func (m *V4ToV5Migrator) transformSaasApp(result string, attrs gjson.Result, att
637774
customClaims.ForEach(func(idx, item gjson.Result) bool {
638775
itemPath := attrPath + ".saas_app.custom_claim." + idx.String()
639776
result = state.TransformFieldArrayToObject(result, itemPath, item, "source", state.ArrayToObjectOptions{})
777+
778+
// Remove empty name_by_idp maps from custom_claims (OIDC)
779+
// Empty maps should be null/absent, not {}
780+
transformedItem := gjson.Parse(result).Get(itemPath)
781+
nameByIdp := transformedItem.Get("source.name_by_idp")
782+
if nameByIdp.Exists() && nameByIdp.IsObject() {
783+
// Check if it's an empty object
784+
isEmpty := true
785+
nameByIdp.ForEach(func(key, value gjson.Result) bool {
786+
isEmpty = false
787+
return false // Stop iteration
788+
})
789+
if isEmpty {
790+
// Remove the empty name_by_idp field
791+
result, _ = sjson.Delete(result, itemPath+".source.name_by_idp")
792+
}
793+
}
794+
640795
return true
641796
})
642797
// Rename custom_claim to custom_claims (plural)

0 commit comments

Comments
 (0)