@@ -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
1823type 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