Skip to content

Commit 4244eb1

Browse files
authored
fix(pages_project): v4 to v5 migration logic adjustment for pages_project (#76)
1 parent b70a224 commit 4244eb1

File tree

3 files changed

+147
-173
lines changed

3 files changed

+147
-173
lines changed

integration/v4_to_v5/testdata/pages_project/expected/terraform.tfstate

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,7 @@
77
{
88
"attributes": {
99
"account_id": "f037e56e89293a057740de681ac9abbe",
10-
"build_config": {
11-
"build_caching": null,
12-
"build_command": null,
13-
"destination_dir": null,
14-
"root_dir": null,
15-
"web_analytics_tag": null,
16-
"web_analytics_token": null
17-
},
10+
"build_config": {},
1811
"canonical_deployment": null,
1912
"created_on": "2024-01-01T00:00:00Z",
2013
"framework": "",
@@ -41,7 +34,6 @@
4134
"attributes": {
4235
"account_id": "f037e56e89293a057740de681ac9abbe",
4336
"build_config": {
44-
"build_caching": null,
4537
"build_command": "npm run build",
4638
"destination_dir": "public",
4739
"root_dir": "/",
@@ -73,14 +65,7 @@
7365
{
7466
"attributes": {
7567
"account_id": "f037e56e89293a057740de681ac9abbe",
76-
"build_config": {
77-
"build_caching": null,
78-
"build_command": null,
79-
"destination_dir": null,
80-
"root_dir": null,
81-
"web_analytics_tag": null,
82-
"web_analytics_token": null
83-
},
68+
"build_config": {},
8469
"canonical_deployment": null,
8570
"created_on": "2024-01-01T00:00:00Z",
8671
"framework": "",
@@ -124,14 +109,7 @@
124109
{
125110
"attributes": {
126111
"account_id": "f037e56e89293a057740de681ac9abbe",
127-
"build_config": {
128-
"build_caching": null,
129-
"build_command": null,
130-
"destination_dir": null,
131-
"root_dir": null,
132-
"web_analytics_tag": null,
133-
"web_analytics_token": null
134-
},
112+
"build_config": {},
135113
"canonical_deployment": null,
136114
"created_on": "2024-01-01T00:00:00Z",
137115
"deployment_configs": {
@@ -268,12 +246,9 @@
268246
"attributes": {
269247
"account_id": "f037e56e89293a057740de681ac9abbe",
270248
"build_config": {
271-
"build_caching": null,
272249
"build_command": "npm run build",
273250
"destination_dir": "dist",
274-
"root_dir": "/app",
275-
"web_analytics_tag": null,
276-
"web_analytics_token": null
251+
"root_dir": "/app"
277252
},
278253
"canonical_deployment": null,
279254
"created_on": "2024-01-01T00:00:00Z",
@@ -363,14 +338,7 @@
363338
{
364339
"attributes": {
365340
"account_id": "f037e56e89293a057740de681ac9abbe",
366-
"build_config": {
367-
"build_caching": null,
368-
"build_command": null,
369-
"destination_dir": null,
370-
"root_dir": null,
371-
"web_analytics_tag": null,
372-
"web_analytics_token": null
373-
},
341+
"build_config": {},
374342
"canonical_deployment": null,
375343
"created_on": "2024-01-01T00:00:00Z",
376344
"deployment_configs": {

internal/resources/pages_project/v4_to_v5.go

Lines changed: 129 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import (
1414
tfhcl "github.com/cloudflare/tf-migrate/internal/transform/hcl"
1515
)
1616

17+
// contains checks if a string contains a substring
18+
func contains(s, substr string) bool {
19+
return strings.Contains(s, substr)
20+
}
21+
1722
// V4ToV5Migrator handles migration of Pages Project resources from v4 to v5
1823
type V4ToV5Migrator struct {
1924
}
@@ -163,27 +168,7 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite
163168
tfhcl.ConvertSingleBlockToAttribute(productionBody, "placement", "placement")
164169
}
165170

166-
// Check if one has placement and the other doesn't, then add empty placement to the one without
167-
var previewHasPlacement, productionHasPlacement bool
168-
if previewBlock != nil {
169-
previewBody := previewBlock.Body()
170-
previewHasPlacement = previewBody.GetAttribute("placement") != nil || tfhcl.FindBlockByType(previewBody, "placement") != nil
171-
}
172-
if productionBlock != nil {
173-
productionBody := productionBlock.Body()
174-
productionHasPlacement = productionBody.GetAttribute("placement") != nil || tfhcl.FindBlockByType(productionBody, "placement") != nil
175-
}
176-
177-
// If one has placement and the other doesn't, add empty placement to the one without
178-
if previewHasPlacement && !productionHasPlacement && productionBlock != nil {
179-
productionBody := productionBlock.Body()
180-
productionBody.SetAttributeValue("placement", cty.EmptyObjectVal)
181-
} else if productionHasPlacement && !previewHasPlacement && previewBlock != nil {
182-
previewBody := previewBlock.Body()
183-
previewBody.SetAttributeValue("placement", cty.EmptyObjectVal)
184-
}
185-
186-
// Now convert preview and production blocks to attributes
171+
// Convert preview and production blocks to attributes
187172
tfhcl.ConvertSingleBlockToAttribute(deploymentConfigsBody, "preview", "preview")
188173
tfhcl.ConvertSingleBlockToAttribute(deploymentConfigsBody, "production", "production")
189174
}
@@ -218,6 +203,49 @@ func (m *V4ToV5Migrator) TransformState(ctx *transform.Context, instance gjson.R
218203
return result, nil
219204
}
220205

206+
// Parse config to determine which build_config fields were explicitly set
207+
buildConfigFieldsInConfig := make(map[string]bool)
208+
if ctx.CFGFile != nil {
209+
// Find the resource block in the HCL config
210+
for _, block := range ctx.CFGFile.Body().Blocks() {
211+
if block.Type() == "resource" {
212+
labels := block.Labels()
213+
if len(labels) >= 2 && labels[0] == "cloudflare_pages_project" && labels[1] == resourceName {
214+
// Found our resource - check for build_config
215+
buildConfigBlock := tfhcl.FindBlockByType(block.Body(), "build_config")
216+
buildConfigAttr := block.Body().GetAttribute("build_config")
217+
218+
if buildConfigBlock != nil {
219+
// build_config is a block - get all attributes
220+
for name := range buildConfigBlock.Body().Attributes() {
221+
buildConfigFieldsInConfig[name] = true
222+
}
223+
} else if buildConfigAttr != nil {
224+
// build_config is an attribute - parse the value by checking if field names appear in the string
225+
attrStr := string(buildConfigAttr.Expr().BuildTokens(nil).Bytes())
226+
// Simple string-based check for field names in HCL
227+
// This works because HCL syntax is "field = value"
228+
fieldsToCheck := []string{"build_caching", "build_command", "destination_dir", "root_dir", "web_analytics_tag", "web_analytics_token"}
229+
for _, field := range fieldsToCheck {
230+
// Check if the field name appears in the attribute string
231+
// Use a simple string search - if it's there, it was in the config
232+
if len(attrStr) > 0 {
233+
// Check for "field =" or "field=" patterns
234+
fieldPattern := field + " ="
235+
fieldPatternNoSpace := field + "="
236+
if contains(attrStr, fieldPattern) || contains(attrStr, fieldPatternNoSpace) {
237+
buildConfigFieldsInConfig[field] = true
238+
}
239+
}
240+
}
241+
}
242+
break
243+
}
244+
}
245+
}
246+
}
247+
248+
221249
// Step 1: Convert TypeList MaxItems:1 arrays to objects (deepest first)
222250
result = m.convertListToObject(result, "attributes.build_config", attrs.Get("build_config"))
223251
result = m.convertListToObject(result, "attributes.source", attrs.Get("source"))
@@ -247,17 +275,33 @@ func (m *V4ToV5Migrator) TransformState(ctx *transform.Context, instance gjson.R
247275
}
248276

249277
if buildConfigObj := attrs.Get("build_config"); buildConfigObj.Exists() && buildConfigObj.IsObject() {
250-
result = m.populateBuildConfigV5Fields(result, "attributes.build_config", buildConfigObj)
251-
} else {
252-
// No build_config - set it to an object with all null fields to match v5
253-
result, _ = sjson.Set(result, "attributes.build_config", map[string]interface{}{
254-
"build_caching": nil,
255-
"build_command": nil,
256-
"destination_dir": nil,
257-
"root_dir": nil,
258-
"web_analytics_tag": nil,
259-
"web_analytics_token": nil,
278+
// Check if build_config has any truthy values (not null, not false, not empty string)
279+
hasValues := false
280+
buildConfigObj.ForEach(func(key, value gjson.Result) bool {
281+
if value.Exists() && value.Type != gjson.Null {
282+
// Check for truthy values: not false, not empty string
283+
if value.Type == gjson.False {
284+
return true // continue - false is falsy
285+
}
286+
if value.Type == gjson.String && value.String() == "" {
287+
return true // continue - empty string is falsy
288+
}
289+
// Has a truthy value
290+
hasValues = true
291+
return false // early exit
292+
}
293+
return true
260294
})
295+
296+
if hasValues {
297+
result = m.populateBuildConfigV5Fields(result, "attributes.build_config", buildConfigObj, buildConfigFieldsInConfig)
298+
} else {
299+
// All fields are null/empty/false - set to empty object to match v5 provider behavior
300+
result, _ = sjson.Set(result, "attributes.build_config", map[string]interface{}{})
301+
}
302+
} else {
303+
// No build_config - set to empty object to match v5 provider behavior
304+
result, _ = sjson.Set(result, "attributes.build_config", map[string]interface{}{})
261305
}
262306

263307
// Refresh attrs
@@ -325,7 +369,7 @@ func (m *V4ToV5Migrator) convertListToObject(result string, path string, field g
325369

326370
if field.IsArray() {
327371
arr := field.Array()
328-
if len(arr) == 0 && !strings.Contains(path, "compatibility_flags") {
372+
if len(arr) == 0 {
329373
// Empty array - delete it (v4 stores as nil, v5 must too)
330374
// This includes compatibility_flags: [] which the v5 provider removes when not in config
331375
result, _ = sjson.Delete(result, path)
@@ -443,9 +487,13 @@ func (m *V4ToV5Migrator) processDeploymentConfigState(result string, basePath st
443487

444488
// Leave compatibility_flags as null if not present (matches provider behavior)
445489
// The provider returns null for this field when not explicitly set
490+
// Also convert empty arrays to null
446491
compatFlags := freshDeploymentConfig.Get("compatibility_flags")
447492
if !compatFlags.Exists() || compatFlags.Type == gjson.Null {
448493
result, _ = sjson.Set(result, basePath+".compatibility_flags", nil)
494+
} else if compatFlags.IsArray() && len(compatFlags.Array()) == 0 {
495+
// Empty array - convert to null to match provider behavior
496+
result, _ = sjson.Set(result, basePath+".compatibility_flags", nil)
449497
}
450498

451499
deploymentConf := freshDeploymentConfig.Get("usage_model")
@@ -492,22 +540,65 @@ func (m *V4ToV5Migrator) processDeploymentConfigState(result string, basePath st
492540
}
493541
if !freshDeploymentConfig.Get("placement").Exists() {
494542
result, _ = sjson.Set(result, basePath+".placement", nil)
543+
} else {
544+
// Check if placement exists but all its fields are empty/null
545+
placementObj := freshDeploymentConfig.Get("placement")
546+
if placementObj.IsObject() {
547+
hasValues := false
548+
placementObj.ForEach(func(key, value gjson.Result) bool {
549+
if value.Exists() && value.Type != gjson.Null && value.String() != "" {
550+
hasValues = true
551+
return false // early exit
552+
}
553+
return true
554+
})
555+
556+
if !hasValues {
557+
// All placement fields are empty/null - set placement to null
558+
result, _ = sjson.Set(result, basePath+".placement", nil)
559+
} else {
560+
// Placement has values - clean up empty mode field to be null
561+
if mode := placementObj.Get("mode"); mode.Exists() && mode.String() == "" {
562+
result, _ = sjson.Set(result, basePath+".placement.mode", nil)
563+
}
564+
}
565+
}
495566
}
496567

497568
return result
498569
}
499570

500571
// populateBuildConfigV5Fields ensures all v5 build_config fields are present
501-
func (m *V4ToV5Migrator) populateBuildConfigV5Fields(result string, basePath string, buildConfig gjson.Result) string {
502-
// Set all v5 fields, keeping existing values or setting to null
503-
fields := []string{"build_caching", "build_command", "destination_dir", "root_dir", "web_analytics_tag", "web_analytics_token"}
572+
// Only include fields that were explicitly set in the config OR have truthy values
573+
func (m *V4ToV5Migrator) populateBuildConfigV5Fields(result string, basePath string, buildConfig gjson.Result, fieldsInConfig map[string]bool) string {
574+
// Rebuild build_config with only fields that:
575+
// 1. Were explicitly set in the config, OR
576+
// 2. Have truthy values (not null, not false, not empty string)
577+
newBuildConfig := make(map[string]interface{})
578+
fieldsToCheck := []string{"build_caching", "build_command", "destination_dir", "root_dir", "web_analytics_tag", "web_analytics_token"}
579+
580+
for _, field := range fieldsToCheck {
581+
fieldValue := buildConfig.Get(field)
582+
if !fieldValue.Exists() {
583+
// Field doesn't exist - don't add it
584+
continue
585+
}
586+
587+
// Include field if:
588+
// - It was explicitly in the config, OR
589+
// - It has a truthy value (not null, not false, not empty string)
590+
inConfig := fieldsInConfig[field] || fieldsInConfig["*"] // "*" means build_config exists as attribute
591+
hasTruthyValue := fieldValue.Type != gjson.Null &&
592+
fieldValue.Type != gjson.False &&
593+
!(fieldValue.Type == gjson.String && fieldValue.String() == "")
504594

505-
for _, field := range fields {
506-
if !buildConfig.Get(field).Exists() {
507-
result, _ = sjson.Set(result, basePath+"."+field, nil)
595+
if inConfig || hasTruthyValue {
596+
newBuildConfig[field] = fieldValue.Value()
508597
}
509598
}
510599

600+
// Replace build_config with the new object
601+
result, _ = sjson.Set(result, basePath, newBuildConfig)
511602
return result
512603
}
513604

0 commit comments

Comments
 (0)