Skip to content

Commit 2a5f22c

Browse files
committed
fix(terraform): make sure unsupported XML nodes are properly preserved on updates
1 parent cbb5b44 commit 2a5f22c

2 files changed

Lines changed: 242 additions & 1 deletion

File tree

assets/terraform/test/resource_template_stack_test.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
package provider_test
22

33
import (
4+
"context"
5+
"encoding/xml"
46
"fmt"
57
"testing"
68

9+
"github.com/PaloAltoNetworks/pango/generic"
10+
"github.com/PaloAltoNetworks/pango/panorama/template_stack"
11+
"github.com/PaloAltoNetworks/pango/util"
12+
"github.com/PaloAltoNetworks/pango/xmlapi"
713
"github.com/hashicorp/terraform-plugin-testing/config"
814
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
915
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
1016
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
1117
"github.com/hashicorp/terraform-plugin-testing/statecheck"
18+
"github.com/hashicorp/terraform-plugin-testing/terraform"
1219
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
1320
)
1421

@@ -399,3 +406,230 @@ resource "panos_template_stack" "example" {
399406
default_vsys = "vsys1"
400407
}
401408
`
409+
410+
// TestAccTemplateStack_DeviceVariablePreservation verifies that updating a
411+
// template stack (e.g. changing description) does not delete per-device
412+
// variable overrides stored as sub-elements of device entries.
413+
//
414+
// This reproduces a reported bug where the PUT request sends device entries
415+
// without their child elements, causing PAN-OS to delete per-device template
416+
// variable overrides.
417+
func TestAccTemplateStack_DeviceVariablePreservation(t *testing.T) {
418+
t.Parallel()
419+
420+
nameSuffix := acctest.RandStringFromCharSet(6, acctest.CharSetAlphaNum)
421+
prefix := fmt.Sprintf("test-acc-%s", nameSuffix)
422+
423+
suffix := acctest.RandStringFromCharSet(13, "0123456789")
424+
serialNumber := fmt.Sprintf("00%s", suffix)
425+
426+
stackName := fmt.Sprintf("%s-stack", prefix)
427+
428+
location := config.ObjectVariable(map[string]config.Variable{
429+
"panorama": config.ObjectVariable(map[string]config.Variable{}),
430+
})
431+
432+
configVars := map[string]config.Variable{
433+
"prefix": config.StringVariable(prefix),
434+
"location": location,
435+
"serial_number": config.StringVariable(serialNumber),
436+
}
437+
438+
// Build the xpath for direct SDK access since Read/Update have a bug
439+
// with name formatting. Use ReadWithXpath/UpdateWithXpath instead.
440+
loc := template_stack.Location{
441+
Panorama: &template_stack.PanoramaLocation{
442+
PanoramaDevice: "localhost.localdomain",
443+
},
444+
}
445+
stackXpathParts, err := loc.XpathWithComponents(sdkClient.Versioning(), util.AsEntryXpath(stackName))
446+
if err != nil {
447+
t.Fatalf("Failed to build template stack xpath: %v", err)
448+
}
449+
stackXpath := util.AsXpath(stackXpathParts)
450+
451+
// injectDeviceVariable uses a direct API call to add a per-device variable
452+
// override to the device entry, simulating a user setting local values
453+
// via the Panorama UI.
454+
//
455+
// We use sdkClient.Communicate directly because the SDK's UpdateWithXpath
456+
// uses SpecMatches which doesn't compare Misc fields, so it would skip
457+
// the update.
458+
injectDeviceVariable := func() {
459+
// Build the xpath to the specific device entry within the template stack.
460+
deviceXpath := fmt.Sprintf("%s/devices/entry[@name='%s']", stackXpath, serialNumber)
461+
462+
// Override the stack-level template variable with a per-device value.
463+
// The variable name must match the existing template variable.
464+
varName := fmt.Sprintf("$%s-var", prefix)
465+
variableXml := generic.Xml{
466+
XMLName: xml.Name{Local: "variable"},
467+
Nodes: []generic.Xml{
468+
{
469+
XMLName: xml.Name{Local: "entry"},
470+
Name: &varName,
471+
Nodes: []generic.Xml{
472+
{
473+
XMLName: xml.Name{Local: "type"},
474+
Nodes: []generic.Xml{
475+
{
476+
XMLName: xml.Name{Local: "ip-netmask"},
477+
Text: []byte("10.0.0.1/24"),
478+
},
479+
},
480+
},
481+
},
482+
},
483+
},
484+
}
485+
486+
cmd := &xmlapi.Config{
487+
Action: "set",
488+
Xpath: deviceXpath,
489+
Element: variableXml,
490+
Target: sdkClient.GetTarget(),
491+
}
492+
493+
if _, _, err := sdkClient.Communicate(context.TODO(), cmd, false, nil); err != nil {
494+
t.Fatalf("Failed to inject per-device variable: %v", err)
495+
}
496+
}
497+
498+
// checkDeviceVariableExists verifies that the per-device variable data
499+
// survived the Terraform update by reading the template stack via the
500+
// pango SDK and checking the device entry's Misc field.
501+
checkDeviceVariableExists := func(s *terraform.State) error {
502+
svc := template_stack.NewService(sdkClient)
503+
504+
entry, err := svc.ReadWithXpath(context.TODO(), stackXpath, "get")
505+
if err != nil {
506+
return fmt.Errorf("failed to read template stack: %v", err)
507+
}
508+
509+
for _, device := range entry.Devices {
510+
if device.Name == serialNumber {
511+
if len(device.Misc) == 0 {
512+
return fmt.Errorf(
513+
"device %s lost its Misc data: per-device variable overrides were deleted during template stack update",
514+
serialNumber,
515+
)
516+
}
517+
return nil
518+
}
519+
}
520+
return fmt.Errorf("device %s not found in template stack after update", serialNumber)
521+
}
522+
523+
resource.Test(t, resource.TestCase{
524+
PreCheck: func() { testAccPreCheck(t) },
525+
ProtoV6ProviderFactories: testAccProviders,
526+
Steps: []resource.TestStep{
527+
{
528+
Config: templateStack_DeviceVarPreservation_Step1_Tmpl,
529+
ConfigVariables: configVars,
530+
ConfigStateChecks: []statecheck.StateCheck{
531+
statecheck.ExpectKnownValue(
532+
"panos_template_stack.test",
533+
tfjsonpath.New("description"),
534+
knownvalue.StringExact("Original description"),
535+
),
536+
statecheck.ExpectKnownValue(
537+
"panos_template_stack.test",
538+
tfjsonpath.New("devices"),
539+
knownvalue.ListExact([]knownvalue.Check{
540+
knownvalue.ObjectExact(map[string]knownvalue.Check{
541+
"name": knownvalue.StringExact(serialNumber),
542+
}),
543+
}),
544+
),
545+
},
546+
},
547+
{
548+
PreConfig: injectDeviceVariable,
549+
Config: templateStack_DeviceVarPreservation_Step2_Tmpl,
550+
ConfigVariables: configVars,
551+
Check: checkDeviceVariableExists,
552+
ConfigStateChecks: []statecheck.StateCheck{
553+
statecheck.ExpectKnownValue(
554+
"panos_template_stack.test",
555+
tfjsonpath.New("description"),
556+
knownvalue.StringExact("Updated description"),
557+
),
558+
statecheck.ExpectKnownValue(
559+
"panos_template_stack.test",
560+
tfjsonpath.New("devices"),
561+
knownvalue.ListExact([]knownvalue.Check{
562+
knownvalue.ObjectExact(map[string]knownvalue.Check{
563+
"name": knownvalue.StringExact(serialNumber),
564+
}),
565+
}),
566+
),
567+
},
568+
},
569+
},
570+
})
571+
}
572+
573+
const templateStack_DeviceVarPreservation_Step1_Tmpl = `
574+
variable "prefix" { type = string }
575+
variable "location" { type = any }
576+
variable "serial_number" { type = string }
577+
578+
resource "panos_template" "test" {
579+
location = var.location
580+
name = "${var.prefix}-template"
581+
}
582+
583+
resource "panos_firewall_device" "test" {
584+
location = var.location
585+
name = var.serial_number
586+
hostname = "fw-devvar.example.com"
587+
ip = "192.0.2.10"
588+
}
589+
590+
resource "panos_template_stack" "test" {
591+
location = var.location
592+
name = "${var.prefix}-stack"
593+
description = "Original description"
594+
templates = [panos_template.test.name]
595+
devices = [{ name = panos_firewall_device.test.name }]
596+
}
597+
598+
resource "panos_template_variable" "test" {
599+
location = { template_stack = { name = panos_template_stack.test.name } }
600+
name = format("$%s-var", var.prefix)
601+
type = { ip_netmask = "10.0.0.0/24" }
602+
}
603+
`
604+
605+
const templateStack_DeviceVarPreservation_Step2_Tmpl = `
606+
variable "prefix" { type = string }
607+
variable "location" { type = any }
608+
variable "serial_number" { type = string }
609+
610+
resource "panos_template" "test" {
611+
location = var.location
612+
name = "${var.prefix}-template"
613+
}
614+
615+
resource "panos_firewall_device" "test" {
616+
location = var.location
617+
name = var.serial_number
618+
hostname = "fw-devvar.example.com"
619+
ip = "192.0.2.10"
620+
}
621+
622+
resource "panos_template_stack" "test" {
623+
location = var.location
624+
name = "${var.prefix}-stack"
625+
description = "Updated description"
626+
templates = [panos_template.test.name]
627+
devices = [{ name = panos_firewall_device.test.name }]
628+
}
629+
630+
resource "panos_template_variable" "test" {
631+
location = { template_stack = { name = panos_template_stack.test.name } }
632+
name = format("$%s-var", var.prefix)
633+
type = { ip_netmask = "10.0.0.0/24" }
634+
}
635+
`

templates/terraform-provider/conversion/copy_to_pango.tmpl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
{{- $terraformType := printf "%s%sObject" $.Spec.TerraformType .TerraformName.CamelCase }}
3535
{{- $pangoEntries := printf "%s_pango_entries" .TerraformName.LowerCamelCase }}
3636
{{- $tfEntries := printf "%s_tf_entries" .TerraformName.LowerCamelCase }}
37+
{{- $existingEntries := printf "%s_existing_entries" .TerraformName.LowerCamelCase }}
3738
{{- if eq .ItemsType "entry" }}
3839
var {{ $tfEntries }} []{{ $terraformType }}
3940
var {{ $pangoEntries }} []{{ $pangoType }}
@@ -43,8 +44,14 @@
4344
if diags.HasError() {
4445
return diags
4546
}
47+
{{ $existingEntries }} := make(map[string]*{{ $pangoType }})
48+
if *obj != nil {
49+
for idx := range (*obj).{{ .PangoName.CamelCase }} {
50+
{{ $existingEntries }}[(*obj).{{ .PangoName.CamelCase }}[idx].Name] = &(*obj).{{ .PangoName.CamelCase }}[idx]
51+
}
52+
}
4653
for _, elt := range {{ $tfEntries }} {
47-
var entry *{{ $pangoType }}
54+
entry := {{ $existingEntries }}[elt.Name.ValueString()]
4855
diags.Append(elt.CopyToPango(ctx, client, append(ancestors, elt), &entry, ev)...)
4956
if diags.HasError() {
5057
return diags

0 commit comments

Comments
 (0)