From c9120c1a375d270a5f1136d6abb93652dfaf91b5 Mon Sep 17 00:00:00 2001 From: Krzysztof Klimonda Date: Mon, 13 Apr 2026 14:31:29 +0200 Subject: [PATCH 1/2] feat(terraform): Set default vsys for template and template stacks on create --- .../internal/provider/template_custom.go | 92 +++++++++ .../test/resource_panorama_template_test.go | 53 +++++ .../test/resource_template_stack_test.go | 183 ++++++++---------- pkg/properties/normalized.go | 97 ++++++++-- pkg/schema/object/object.go | 12 ++ .../terraform_provider/crud_operations.go | 8 + .../terraform_provider/entity_generators.go | 9 +- specs/panorama/template.yaml | 6 +- specs/schema/object.schema.json | 14 ++ .../terraform-provider/resource/create.tmpl | 20 ++ .../terraform-provider/resource/delete.tmpl | 14 ++ .../terraform-provider/resource/read.tmpl | 16 ++ .../terraform-provider/resource/resource.tmpl | 9 +- .../terraform-provider/resource/update.tmpl | 14 ++ 14 files changed, 418 insertions(+), 129 deletions(-) create mode 100644 assets/terraform/internal/provider/template_custom.go diff --git a/assets/terraform/internal/provider/template_custom.go b/assets/terraform/internal/provider/template_custom.go new file mode 100644 index 00000000..93e2e36a --- /dev/null +++ b/assets/terraform/internal/provider/template_custom.go @@ -0,0 +1,92 @@ +package provider + +import ( + "context" + + "github.com/PaloAltoNetworks/pango/panorama/template" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// TemplateCustom stores state shared between PreCreate and PostCreate hooks. +type TemplateCustom struct { + savedDefaultVsys *string +} + +func NewTemplateCustom(data *ProviderData) (*TemplateCustom, error) { + return &TemplateCustom{}, nil +} + +// PreCreate saves and strips default_vsys from the SDK object before Create, +// because PAN-OS cannot set this field during initial creation. +func (o *TemplateResource) PreCreate( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, + state *TemplateResourceModel, + location template.Location, + obj *template.Entry, + ev *EncryptedValuesManager, +) { + o.custom.savedDefaultVsys = obj.DefaultVsys + obj.DefaultVsys = nil +} + +// PostCreate creates the vsys referenced by default_vsys inside the template, +// then sets default_vsys via an Update call. The vsys is added directly to the +// SDK entry's Config struct so the Update's edit action includes it. +func (o *TemplateResource) PostCreate( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, + state *TemplateResourceModel, + location template.Location, + obj *template.Entry, + ev *EncryptedValuesManager, +) { + if o.custom.savedDefaultVsys == nil { + return + } + + defaultVsys := *o.custom.savedDefaultVsys + templateName := state.Name.ValueString() + + tflog.Info(ctx, "performing post-create update to set default_vsys", map[string]any{ + "resource_name": "panos_template", + "name": templateName, + "default_vsys": defaultVsys, + }) + + // Populate the Config struct with the vsys entry so the SDK's edit + // action includes it. PAN-OS requires the vsys to exist before it + // accepts it as a valid default_vsys reference. + obj.Config = &template.Config{ + Devices: []template.ConfigDevices{ + { + Name: "localhost.localdomain", + Vsys: []template.ConfigDevicesVsys{ + {Name: defaultVsys}, + }, + }, + }, + } + obj.DefaultVsys = o.custom.savedDefaultVsys + + components, err := state.resourceXpathParentComponents() + if err != nil { + resp.Diagnostics.AddError("Error creating resource xpath for post-create update", err.Error()) + return + } + + updated, err := o.manager.Update(ctx, location, components, obj, "") + if err != nil { + resp.Diagnostics.AddError( + "Error setting default_vsys after create", + "Template created successfully but setting default_vsys failed. "+ + "Run terraform apply again to retry. Error: "+err.Error(), + ) + return + } + + resp.Diagnostics.Append(state.CopyFromPango(ctx, o.client, nil, updated, ev)...) +} diff --git a/assets/terraform/test/resource_panorama_template_test.go b/assets/terraform/test/resource_panorama_template_test.go index 7b4b9eed..89a64a41 100644 --- a/assets/terraform/test/resource_panorama_template_test.go +++ b/assets/terraform/test/resource_panorama_template_test.go @@ -40,6 +40,59 @@ func TestAccPanosTemplate_RequiredInputs(t *testing.T) { }) } +// TestAccPanosTemplate_DefaultVsys verifies that default_vsys can be set +// during the initial create step. The CRUD hooks strip default_vsys from the +// create call and set it via a post-create update. +func TestAccPanosTemplate_DefaultVsys(t *testing.T) { + t.Parallel() + + nameSuffix := acctest.RandStringFromCharSet(6, acctest.CharSetAlphaNum) + prefix := fmt.Sprintf("test-acc-%s", nameSuffix) + + location := config.ObjectVariable(map[string]config.Variable{ + "panorama": config.ObjectVariable(map[string]config.Variable{}), + }) + + configVars := map[string]config.Variable{ + "prefix": config.StringVariable(prefix), + "location": location, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders, + Steps: []resource.TestStep{ + { + Config: template_DefaultVsys_Tmpl, + ConfigVariables: configVars, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "panos_template.test", + tfjsonpath.New("name"), + knownvalue.StringExact(fmt.Sprintf("%s-template", prefix)), + ), + statecheck.ExpectKnownValue( + "panos_template.test", + tfjsonpath.New("default_vsys"), + knownvalue.StringExact("vsys1"), + ), + }, + }, + }, + }) +} + +const template_DefaultVsys_Tmpl = ` +variable "prefix" { type = string } +variable "location" { type = any } + +resource "panos_template" "test" { + location = var.location + name = "${var.prefix}-template" + default_vsys = "vsys1" +} +` + func makePanosTemplateConfig(label string) string { configTpl := ` variable "template_name" { type = string } diff --git a/assets/terraform/test/resource_template_stack_test.go b/assets/terraform/test/resource_template_stack_test.go index b799ca08..5955a018 100644 --- a/assets/terraform/test/resource_template_stack_test.go +++ b/assets/terraform/test/resource_template_stack_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/xml" "fmt" + "regexp" "testing" "github.com/PaloAltoNetworks/pango/generic" @@ -171,6 +172,10 @@ resource "panos_template_stack" "example" { } ` +// TestAccTemplateStack_DefaultVsys verifies that default_vsys can be set +// during the initial create step. The template is created with default_vsys +// first (which triggers its hooks to create the vsys), then the template-stack +// references that template and sets its own default_vsys. func TestAccTemplateStack_DefaultVsys(t *testing.T) { t.Parallel() @@ -181,16 +186,18 @@ func TestAccTemplateStack_DefaultVsys(t *testing.T) { "panorama": config.ObjectVariable(map[string]config.Variable{}), }) + configVars := map[string]config.Variable{ + "prefix": config.StringVariable(prefix), + "location": location, + } + resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProviders, Steps: []resource.TestStep{ { - Config: templateStack_DefaultVsys_Tmpl, - ConfigVariables: map[string]config.Variable{ - "prefix": config.StringVariable(prefix), - "location": location, - }, + Config: templateStack_DefaultVsys_Tmpl, + ConfigVariables: configVars, ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue( "panos_template_stack.example", @@ -212,23 +219,70 @@ const templateStack_DefaultVsys_Tmpl = ` variable "prefix" { type = string } variable "location" { type = any } -data "panos_template" "existing" { - location = { - panorama = { - panorama_device = "localhost.localdomain" - } - } - name = "test-acc-tmpl" +resource "panos_template" "test" { + location = var.location + name = "${var.prefix}-template" + default_vsys = "vsys1" } resource "panos_template_stack" "example" { location = var.location name = var.prefix default_vsys = "vsys1" - templates = [data.panos_template.existing.name] + templates = [panos_template.test.name] } ` +// TestAccTemplateStack_DefaultVsysNoTemplateVsys verifies that setting +// default_vsys on a template-stack fails when the referenced template does not +// have the vsys created (i.e. template is created without default_vsys). +func TestAccTemplateStack_DefaultVsysNoTemplateVsys(t *testing.T) { + t.Parallel() + + nameSuffix := acctest.RandStringFromCharSet(6, acctest.CharSetAlphaNum) + prefix := fmt.Sprintf("test-acc-%s", nameSuffix) + + location := config.ObjectVariable(map[string]config.Variable{ + "panorama": config.ObjectVariable(map[string]config.Variable{}), + }) + + configVars := map[string]config.Variable{ + "prefix": config.StringVariable(prefix), + "location": location, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders, + Steps: []resource.TestStep{ + { + Config: templateStack_DefaultVsysNoTemplateVsys_Tmpl, + ConfigVariables: configVars, + ExpectError: regexp.MustCompile(`Error in create`), + }, + }, + }) +} + +const templateStack_DefaultVsysNoTemplateVsys_Tmpl = ` +variable "prefix" { type = string } +variable "location" { type = any } + +resource "panos_template" "test" { + location = var.location + name = "${var.prefix}-template" +} + +resource "panos_template_stack" "example" { + location = var.location + name = var.prefix + default_vsys = "vsys1" + templates = [panos_template.test.name] +} +` + +// TestAccTemplateStack_Complete creates a template stack with all fields +// including default_vsys set directly during the initial create step. func TestAccTemplateStack_Complete(t *testing.T) { t.Parallel() @@ -245,51 +299,20 @@ func TestAccTemplateStack_Complete(t *testing.T) { "panorama": config.ObjectVariable(map[string]config.Variable{}), }) + configVars := map[string]config.Variable{ + "prefix": config.StringVariable(prefix), + "location": location, + "serial_number_1": config.StringVariable(serialNumber1), + "serial_number_2": config.StringVariable(serialNumber2), + } + resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProviders, Steps: []resource.TestStep{ { - Config: templateStack_Complete_Step1_Tmpl, - ConfigVariables: map[string]config.Variable{ - "prefix": config.StringVariable(prefix), - "location": location, - "serial_number_1": config.StringVariable(serialNumber1), - "serial_number_2": config.StringVariable(serialNumber2), - }, - ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue( - "panos_template_stack.example", - tfjsonpath.New("name"), - knownvalue.StringExact(prefix), - ), - statecheck.ExpectKnownValue( - "panos_template_stack.example", - tfjsonpath.New("description"), - knownvalue.StringExact("Complete template stack"), - ), - statecheck.ExpectKnownValue( - "panos_template_stack.example", - tfjsonpath.New("devices"), - knownvalue.ListExact([]knownvalue.Check{ - knownvalue.ObjectExact(map[string]knownvalue.Check{ - "name": knownvalue.StringExact(serialNumber1), - }), - knownvalue.ObjectExact(map[string]knownvalue.Check{ - "name": knownvalue.StringExact(serialNumber2), - }), - }), - ), - }, - }, - { - Config: templateStack_Complete_Step2_Tmpl, - ConfigVariables: map[string]config.Variable{ - "prefix": config.StringVariable(prefix), - "location": location, - "serial_number_1": config.StringVariable(serialNumber1), - "serial_number_2": config.StringVariable(serialNumber2), - }, + Config: templateStack_Complete_Tmpl, + ConfigVariables: configVars, ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue( "panos_template_stack.example", @@ -324,60 +347,16 @@ func TestAccTemplateStack_Complete(t *testing.T) { }) } -const templateStack_Complete_Step1_Tmpl = ` +const templateStack_Complete_Tmpl = ` variable "prefix" { type = string } variable "location" { type = any } variable "serial_number_1" { type = string } variable "serial_number_2" { type = string } -data "panos_template" "existing" { - location = { - panorama = { - panorama_device = "localhost.localdomain" - } - } - name = "test-acc-tmpl" -} - -resource "panos_firewall_device" "device1" { - location = var.location - name = var.serial_number_1 - hostname = "fw1.example.com" - ip = "192.0.2.1" -} - -resource "panos_firewall_device" "device2" { - location = var.location - name = var.serial_number_2 - hostname = "fw2.example.com" - ip = "192.0.2.2" -} - -resource "panos_template_stack" "example" { +resource "panos_template" "test" { location = var.location - name = var.prefix - description = "Complete template stack" - templates = [data.panos_template.existing.name] - devices = [ - { name = panos_firewall_device.device1.name }, - { name = panos_firewall_device.device2.name } - ] -} -` - -const templateStack_Complete_Step2_Tmpl = ` -variable "prefix" { type = string } -variable "location" { type = any } -variable "serial_number_1" { type = string } -variable "serial_number_2" { type = string } - -data "panos_template" "existing" { - location = { - panorama = { - panorama_device = "localhost.localdomain" - } - } - name = "test-acc-tmpl" + name = "${var.prefix}-template" + default_vsys = "vsys1" } resource "panos_firewall_device" "device1" { @@ -398,7 +377,7 @@ resource "panos_template_stack" "example" { location = var.location name = var.prefix description = "Complete template stack" - templates = [data.panos_template.existing.name] + templates = [panos_template.test.name] devices = [ { name = panos_firewall_device.device1.name }, { name = panos_firewall_device.device2.name } diff --git a/pkg/properties/normalized.go b/pkg/properties/normalized.go index da804e60..8fe1ffac 100644 --- a/pkg/properties/normalized.go +++ b/pkg/properties/normalized.go @@ -60,24 +60,72 @@ const ( ) type TerraformProviderConfig struct { - Description string `json:"description" yaml:"description"` - Subcategory string `json:"subcategory" yaml:"subcategory"` - SkipSubcategory bool `json:"skip_subcategory" yaml:"skip_subcategory"` - Ephemeral bool `json:"ephemeral" yaml:"ephemeral"` - Action bool `json:"action" yaml:"action"` - CustomValidation bool `json:"custom_validation" yaml:"custom_validation"` - SkipResource bool `json:"skip_resource" yaml:"skip_resource"` - SkipDatasource bool `json:"skip_datasource" yaml:"skip_datasource"` - SkipDatasourceListing bool `json:"skip_datasource_listing" yaml:"skip_datasource_listing"` - ResourceType TerraformResourceType `json:"resource_type" yaml:"resource_type"` - XmlNode *string `json:"xml_node" yaml:"xml_node"` - CustomFuncs map[string]bool `json:"custom_functions" yaml:"custom_functions"` - ResourceVariants []TerraformResourceVariant `json:"resource_variants" yaml:"resource_variants"` - Suffix string `json:"suffix" yaml:"suffix"` - PluralSuffix string `json:"plural_suffix" yaml:"plural_suffix"` - PluralName string `json:"plural_name" yaml:"plural_name"` - PluralType object.TerraformPluralType `json:"plural_type" yaml:"plural_type"` - PluralDescription string `json:"plural_description" yaml:"plural_description"` + Description string `json:"description" yaml:"description"` + Subcategory string `json:"subcategory" yaml:"subcategory"` + SkipSubcategory bool `json:"skip_subcategory" yaml:"skip_subcategory"` + Ephemeral bool `json:"ephemeral" yaml:"ephemeral"` + Action bool `json:"action" yaml:"action"` + CustomValidation bool `json:"custom_validation" yaml:"custom_validation"` + SkipResource bool `json:"skip_resource" yaml:"skip_resource"` + SkipDatasource bool `json:"skip_datasource" yaml:"skip_datasource"` + SkipDatasourceListing bool `json:"skip_datasource_listing" yaml:"skip_datasource_listing"` + ResourceType TerraformResourceType `json:"resource_type" yaml:"resource_type"` + XmlNode *string `json:"xml_node" yaml:"xml_node"` + CustomFuncs map[string]bool `json:"custom_functions" yaml:"custom_functions"` + ResourceVariants []TerraformResourceVariant `json:"resource_variants" yaml:"resource_variants"` + Suffix string `json:"suffix" yaml:"suffix"` + PluralSuffix string `json:"plural_suffix" yaml:"plural_suffix"` + PluralName string `json:"plural_name" yaml:"plural_name"` + PluralType object.TerraformPluralType `json:"plural_type" yaml:"plural_type"` + PluralDescription string `json:"plural_description" yaml:"plural_description"` + CrudHooks *TerraformProviderCrudHooks `json:"crud_hooks" yaml:"crud_hooks"` +} + +type TerraformProviderCrudHooks struct { + PreCreate bool + PostCreate bool + PreRead bool + PostRead bool + PreUpdate bool + PostUpdate bool + PreDelete bool + PostDelete bool +} + +func (c *TerraformProviderConfig) HasCrudHook(hook string) bool { + if c.CrudHooks == nil { + return false + } + switch hook { + case "PreCreate": + return c.CrudHooks.PreCreate + case "PostCreate": + return c.CrudHooks.PostCreate + case "PreRead": + return c.CrudHooks.PreRead + case "PostRead": + return c.CrudHooks.PostRead + case "PreUpdate": + return c.CrudHooks.PreUpdate + case "PostUpdate": + return c.CrudHooks.PostUpdate + case "PreDelete": + return c.CrudHooks.PreDelete + case "PostDelete": + return c.CrudHooks.PostDelete + default: + return false + } +} + +func (c *TerraformProviderConfig) HasAnyCrudHook() bool { + if c.CrudHooks == nil { + return false + } + return c.CrudHooks.PreCreate || c.CrudHooks.PostCreate || + c.CrudHooks.PreRead || c.CrudHooks.PostRead || + c.CrudHooks.PreUpdate || c.CrudHooks.PostUpdate || + c.CrudHooks.PreDelete || c.CrudHooks.PostDelete } type Location struct { @@ -822,6 +870,19 @@ func schemaToSpec(object object.Object) (*Normalization, error) { }, } + if object.TerraformConfig.CrudHooks != nil { + spec.TerraformProviderConfig.CrudHooks = &TerraformProviderCrudHooks{ + PreCreate: object.TerraformConfig.CrudHooks.PreCreate, + PostCreate: object.TerraformConfig.CrudHooks.PostCreate, + PreRead: object.TerraformConfig.CrudHooks.PreRead, + PostRead: object.TerraformConfig.CrudHooks.PostRead, + PreUpdate: object.TerraformConfig.CrudHooks.PreUpdate, + PostUpdate: object.TerraformConfig.CrudHooks.PostUpdate, + PreDelete: object.TerraformConfig.CrudHooks.PreDelete, + PostDelete: object.TerraformConfig.CrudHooks.PostDelete, + } + } + for idx, location := range object.Locations { var xpath []string diff --git a/pkg/schema/object/object.go b/pkg/schema/object/object.go index 857a2024..c79a31a7 100644 --- a/pkg/schema/object/object.go +++ b/pkg/schema/object/object.go @@ -35,6 +35,17 @@ const ( TerraformPluralSetType TerraformPluralType = "set" ) +type TerraformCrudHooks struct { + PreCreate bool `yaml:"pre_create"` + PostCreate bool `yaml:"post_create"` + PreRead bool `yaml:"pre_read"` + PostRead bool `yaml:"post_read"` + PreUpdate bool `yaml:"pre_update"` + PostUpdate bool `yaml:"post_update"` + PreDelete bool `yaml:"pre_delete"` + PostDelete bool `yaml:"post_delete"` +} + type TerraformConfig struct { Description string `yaml:"description"` Action bool `yaml:"action"` @@ -53,6 +64,7 @@ type TerraformConfig struct { PluralName string `yaml:"plural_name"` PluralType TerraformPluralType `yaml:"plural_type"` PluralDescription string `yaml:"plural_description"` + CrudHooks *TerraformCrudHooks `yaml:"crud_hooks"` } type GoSdkMethod string diff --git a/pkg/translate/terraform_provider/crud_operations.go b/pkg/translate/terraform_provider/crud_operations.go index 79cc94b6..c20814b9 100644 --- a/pkg/translate/terraform_provider/crud_operations.go +++ b/pkg/translate/terraform_provider/crud_operations.go @@ -90,6 +90,8 @@ func ResourceCreateFunction(resourceTyp properties.ResourceType, names *NameProv "resourceSDKName": resourceSDKName, "locations": paramSpec.OrderedLocations(), "ParametersWithTriggerOnChangeOf": paramSpec.GetParametersWithTriggerOnChangeOf(), + "HasPreCreateHook": paramSpec.TerraformProviderConfig.HasCrudHook("PreCreate"), + "HasPostCreateHook": paramSpec.TerraformProviderConfig.HasCrudHook("PostCreate"), } return processTemplate(tmpl, "resource-create-function", data, funcMap) @@ -210,6 +212,8 @@ func ResourceReadFunction(resourceTyp properties.ResourceType, names *NameProvid "serviceName": naming.CamelCase("", serviceName, "", false), "resourceSDKName": resourceSDKName, "locations": paramSpec.OrderedLocations(), + "HasPreReadHook": paramSpec.TerraformProviderConfig.HasCrudHook("PreRead"), + "HasPostReadHook": paramSpec.TerraformProviderConfig.HasCrudHook("PostRead"), } funcMap := template.FuncMap{ @@ -275,6 +279,8 @@ func ResourceUpdateFunction(resourceTyp properties.ResourceType, names *NameProv "serviceName": naming.CamelCase("", serviceName, "", false), "resourceSDKName": resourceSDKName, "ParametersWithTriggerOnChangeOf": paramSpec.GetParametersWithTriggerOnChangeOf(), + "HasPreUpdateHook": paramSpec.TerraformProviderConfig.HasCrudHook("PreUpdate"), + "HasPostUpdateHook": paramSpec.TerraformProviderConfig.HasCrudHook("PostUpdate"), } funcMap := template.FuncMap{ @@ -345,6 +351,8 @@ func ResourceDeleteFunction(resourceTyp properties.ResourceType, names *NameProv "structName": names.ResourceStructName, "serviceName": naming.CamelCase("", serviceName, "", false), "resourceSDKName": resourceSDKName, + "HasPreDeleteHook": paramSpec.TerraformProviderConfig.HasCrudHook("PreDelete"), + "HasPostDeleteHook": paramSpec.TerraformProviderConfig.HasCrudHook("PostDelete"), } funcMap := template.FuncMap{ diff --git a/pkg/translate/terraform_provider/entity_generators.go b/pkg/translate/terraform_provider/entity_generators.go index 2c7cc2f9..0160015c 100644 --- a/pkg/translate/terraform_provider/entity_generators.go +++ b/pkg/translate/terraform_provider/entity_generators.go @@ -164,10 +164,11 @@ func (g *GenerateTerraformProvider) GenerateTerraformResource(resourceTyp proper "IsEntry": func() bool { return spec.HasEntryName() && !spec.HasEntryUuid() }, "HasImports": func() bool { return len(spec.Imports.Variants) > 0 }, - "IsCustom": func() bool { return spec.TerraformProviderConfig.ResourceType == properties.TerraformResourceCustom }, - "IsUuid": func() bool { return spec.HasEntryUuid() }, - "IsConfig": func() bool { return !spec.HasEntryName() && !spec.HasEntryUuid() }, - "IsEphemeral": func() bool { return spec.TerraformProviderConfig.Ephemeral }, + "IsCustom": func() bool { return spec.TerraformProviderConfig.ResourceType == properties.TerraformResourceCustom }, + "HasCrudHooks": func() bool { return spec.TerraformProviderConfig.HasAnyCrudHook() }, + "IsUuid": func() bool { return spec.HasEntryUuid() }, + "IsConfig": func() bool { return !spec.HasEntryName() && !spec.HasEntryUuid() }, + "IsEphemeral": func() bool { return spec.TerraformProviderConfig.Ephemeral }, "ListAttribute": func() *properties.NameVariant { return properties.NewNameVariant(spec.TerraformProviderConfig.PluralName) }, diff --git a/specs/panorama/template.yaml b/specs/panorama/template.yaml index f1b52008..ede4c768 100644 --- a/specs/panorama/template.yaml +++ b/specs/panorama/template.yaml @@ -2,6 +2,9 @@ name: "Template" terraform_provider_config: resource_type: entry suffix: "template" + crud_hooks: + pre_create: true + post_create: true go_sdk_config: package: - "panorama" @@ -49,9 +52,6 @@ spec: type: string profiles: - xpath: ["settings", "default-vsys"] - codegen_overrides: - terraform: - private: true - name: config type: object profiles: diff --git a/specs/schema/object.schema.json b/specs/schema/object.schema.json index c951d125..582b8bb5 100644 --- a/specs/schema/object.schema.json +++ b/specs/schema/object.schema.json @@ -42,6 +42,20 @@ "Invoke": { "type": "boolean" } } }, + "crud_hooks": { + "type": "object", + "additionalProperties": false, + "properties": { + "pre_create": { "type": "boolean" }, + "post_create": { "type": "boolean" }, + "pre_read": { "type": "boolean" }, + "post_read": { "type": "boolean" }, + "pre_update": { "type": "boolean" }, + "post_update": { "type": "boolean" }, + "pre_delete": { "type": "boolean" }, + "post_delete": { "type": "boolean" } + } + }, "resource_variants": { "type": "array", "items": { diff --git a/templates/terraform-provider/resource/create.tmpl b/templates/terraform-provider/resource/create.tmpl index 8989052f..67d44070 100644 --- a/templates/terraform-provider/resource/create.tmpl +++ b/templates/terraform-provider/resource/create.tmpl @@ -42,6 +42,13 @@ return } +{{- if .HasPreCreateHook }} + o.PreCreate(ctx, req, resp, &state, location, obj, ev) + if resp.Diagnostics.HasError() { + return + } +{{- end }} + /* // Timeout handling. ctx, cancel := context.WithTimeout(ctx, GetTimeout(state.Timeouts.Create)) @@ -86,6 +93,19 @@ return } +{{- if .HasPostCreateHook }} + // Checkpoint: save state so resource is not orphaned if post-create hook fails. + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + o.PostCreate(ctx, req, resp, &state, location, created, ev) + if resp.Diagnostics.HasError() { + return + } +{{- end }} + {{ RenderEncryptedValuesFinalizer }} resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) diff --git a/templates/terraform-provider/resource/delete.tmpl b/templates/terraform-provider/resource/delete.tmpl index b689147b..e5771e25 100644 --- a/templates/terraform-provider/resource/delete.tmpl +++ b/templates/terraform-provider/resource/delete.tmpl @@ -25,6 +25,13 @@ var location {{ .resourceSDKName }}.Location {{ RenderLocationsStateToPango "state.Location" "location" }} +{{- if .HasPreDeleteHook }} + o.PreDelete(ctx, req, resp, &state, location) + if resp.Diagnostics.HasError() { + return + } +{{- end }} + {{- if .HasEntryName }} components, err := state.resourceXpathParentComponents() if err != nil { @@ -70,3 +77,10 @@ return } {{- end }} + +{{- if .HasPostDeleteHook }} + o.PostDelete(ctx, req, resp, &state, location) + if resp.Diagnostics.HasError() { + return + } +{{- end }} diff --git a/templates/terraform-provider/resource/read.tmpl b/templates/terraform-provider/resource/read.tmpl index 4011730d..2329ce34 100644 --- a/templates/terraform-provider/resource/read.tmpl +++ b/templates/terraform-provider/resource/read.tmpl @@ -30,6 +30,13 @@ return } +{{- if and (eq .ResourceOrDS "Resource") .HasPreReadHook }} + o.PreRead(ctx, req, resp, &state, location, ev) + if resp.Diagnostics.HasError() { + return + } +{{- end }} + {{- if .HasEntryName }} object, err := o.manager.Read(ctx, location, components, state.Name.ValueString()) {{- else }} @@ -51,6 +58,15 @@ copy_diags := state.CopyFromPango(ctx, o.client, nil, object, ev) resp.Diagnostics.Append(copy_diags...) +{{- if and (eq .ResourceOrDS "Resource") .HasPostReadHook }} + if !resp.Diagnostics.HasError() { + o.PostRead(ctx, req, resp, &state, location, object, ev) + if resp.Diagnostics.HasError() { + return + } + } +{{- end }} + /* // Keep the timeouts. // TODO: This won't work for state import. diff --git a/templates/terraform-provider/resource/resource.tmpl b/templates/terraform-provider/resource/resource.tmpl index 10272185..d23bb972 100644 --- a/templates/terraform-provider/resource/resource.tmpl +++ b/templates/terraform-provider/resource/resource.tmpl @@ -35,8 +35,10 @@ func New{{ resourceStructName }}() resource.Resource { type {{ resourceStructName }} struct { client *pango.Client -{{- if IsCustom }} +{{- if or IsCustom HasCrudHooks }} custom *{{ structName }}Custom +{{- end }} +{{- if IsCustom }} {{- else if and IsEntry HasImports }} manager *sdkmanager.ImportableEntryObjectManager[*{{ resourceSDKName }}.Entry, {{ resourceSDKName }}.Location, *{{ resourceSDKName }}.Service] {{- else if IsEntry }} @@ -158,13 +160,16 @@ func (o *{{ resourceStructName }}) Configure(ctx context.Context, req {{ tfresou providerData := req.ProviderData.(*ProviderData) o.client = providerData.Client -{{- if IsCustom }} +{{- if or IsCustom HasCrudHooks }} custom, err := New{{ structName }}Custom(providerData) if err != nil { resp.Diagnostics.AddError("Failed to configure SDK client", err.Error()) return } o.custom = custom +{{- end }} + +{{- if IsCustom }} {{- else if and IsEntry HasImports }} specifier, _, err := {{ resourceSDKName }}.Versioning(o.client.Versioning()) if err != nil { diff --git a/templates/terraform-provider/resource/update.tmpl b/templates/terraform-provider/resource/update.tmpl index e2f42110..c0ac8e7f 100644 --- a/templates/terraform-provider/resource/update.tmpl +++ b/templates/terraform-provider/resource/update.tmpl @@ -53,6 +53,13 @@ return } +{{- if .HasPreUpdateHook }} + o.PreUpdate(ctx, req, resp, &plan, &state, location, obj, ev) + if resp.Diagnostics.HasError() { + return + } +{{- end }} + components, err = plan.resourceXpathParentComponents() if err != nil { resp.Diagnostics.AddError("Error creating resource xpath", err.Error()) @@ -88,6 +95,13 @@ return } +{{- if .HasPostUpdateHook }} + o.PostUpdate(ctx, req, resp, &plan, &state, location, updated, ev) + if resp.Diagnostics.HasError() { + return + } +{{- end }} + {{ RenderEncryptedValuesFinalizer }} // Done. From 990885483c12b8e3667b90615d7de8e4485ac0a1 Mon Sep 17 00:00:00 2001 From: kklimonda-cl Date: Tue, 28 Apr 2026 17:00:17 +0200 Subject: [PATCH 2/2] feat(specs): Add spec, tests and examples for panos_virtual_system (#723) --- assets/terraform/test/resource_vsys_test.go | 123 ++++++++++++++++++++ pkg/translate/imports.go | 4 +- specs/device/vsys.yaml | 98 ++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 assets/terraform/test/resource_vsys_test.go create mode 100644 specs/device/vsys.yaml diff --git a/assets/terraform/test/resource_vsys_test.go b/assets/terraform/test/resource_vsys_test.go new file mode 100644 index 00000000..7e7cb3ed --- /dev/null +++ b/assets/terraform/test/resource_vsys_test.go @@ -0,0 +1,123 @@ +package provider_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +// TestAccPanosVsys_ConflictWithTemplateDefaultVsys verifies that creating a +// panos_vsys resource for "vsys1" fails when the template already has +// default_vsys = "vsys1" (because the template's PostCreate hook already +// created that vsys entry). +func TestAccPanosVsys_ConflictWithTemplateDefaultVsys(t *testing.T) { + t.Parallel() + + nameSuffix := acctest.RandStringFromCharSet(6, acctest.CharSetAlphaNum) + prefix := fmt.Sprintf("test-acc-%s", nameSuffix) + + location := config.ObjectVariable(map[string]config.Variable{ + "panorama": config.ObjectVariable(map[string]config.Variable{}), + }) + + configVars := map[string]config.Variable{ + "prefix": config.StringVariable(prefix), + "location": location, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders, + Steps: []resource.TestStep{ + { + Config: vsys_ConflictWithTemplateDefaultVsys_Tmpl, + ConfigVariables: configVars, + ExpectError: regexp.MustCompile(`.`), + }, + }, + }) +} + +const vsys_ConflictWithTemplateDefaultVsys_Tmpl = ` +variable "prefix" { type = string } +variable "location" { type = any } + +resource "panos_template" "test" { + location = var.location + name = "${var.prefix}-template" + default_vsys = "vsys1" +} + +resource "panos_vsys" "vsys1" { + location = { + template = { + name = panos_template.test.name + } + } + name = "vsys1" +} +` + +// TestAccPanosVsys_WithTemplate verifies that creating a panos_vsys resource +// for "vsys2" succeeds when the template has default_vsys = "vsys1" (a +// different vsys name, so no conflict). +func TestAccPanosVsys_WithTemplate(t *testing.T) { + t.Parallel() + + nameSuffix := acctest.RandStringFromCharSet(6, acctest.CharSetAlphaNum) + prefix := fmt.Sprintf("test-acc-%s", nameSuffix) + + location := config.ObjectVariable(map[string]config.Variable{ + "panorama": config.ObjectVariable(map[string]config.Variable{}), + }) + + configVars := map[string]config.Variable{ + "prefix": config.StringVariable(prefix), + "location": location, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProviders, + Steps: []resource.TestStep{ + { + Config: vsys_WithTemplate_Tmpl, + ConfigVariables: configVars, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + "panos_vsys.vsys2", + tfjsonpath.New("name"), + knownvalue.StringExact("vsys2"), + ), + }, + }, + }, + }) +} + +const vsys_WithTemplate_Tmpl = ` +variable "prefix" { type = string } +variable "location" { type = any } + +resource "panos_template" "test" { + location = var.location + name = "${var.prefix}-template" + default_vsys = "vsys1" +} + +resource "panos_vsys" "vsys2" { + location = { + template = { + name = panos_template.test.name + } + } + name = "vsys2" +} +` diff --git a/pkg/translate/imports.go b/pkg/translate/imports.go index 9f4d1157..50960d92 100644 --- a/pkg/translate/imports.go +++ b/pkg/translate/imports.go @@ -24,8 +24,10 @@ func RenderImports(spec *properties.Normalization, templateTypes ...string) (str manager.AddStandardImport("fmt", "") manager.AddSdkImport("github.com/PaloAltoNetworks/pango/filtering", "") manager.AddSdkImport("github.com/PaloAltoNetworks/pango/generic", "") - manager.AddSdkImport("github.com/PaloAltoNetworks/pango/util", "") manager.AddSdkImport("github.com/PaloAltoNetworks/pango/version", "") + if len(spec.Spec.Params) > 0 || len(spec.Spec.OneOf) > 0 { + manager.AddSdkImport("github.com/PaloAltoNetworks/pango/util", "") + } if spec.HasParametersWithStrconv() { manager.AddStandardImport("errors", "") diff --git a/specs/device/vsys.yaml b/specs/device/vsys.yaml new file mode 100644 index 00000000..f9758a29 --- /dev/null +++ b/specs/device/vsys.yaml @@ -0,0 +1,98 @@ +name: vsys +terraform_provider_config: + description: Proxy configuration + skip_resource: false + skip_datasource: false + resource_type: entry + resource_variants: [] + suffix: vsys + plural_suffix: '' + plural_name: '' + plural_description: '' +go_sdk_config: + skip: false + package: + - device + - vsys +panos_xpath: + path: + - vsys + vars: [] +locations: +- name: template + xpath: + path: + - config + - devices + - $panorama_device + - template + - $template + - config + - devices + - $ngfw_device + vars: + - name: panorama_device + description: Specific Panorama device + required: false + default: localhost.localdomain + validators: [] + type: entry + - name: template + description: Specific Panorama template + required: true + validators: [] + type: entry + - name: ngfw_device + description: The NGFW device + required: false + default: localhost.localdomain + validators: [] + type: entry + description: Located in a specific template + devices: + - panorama + validators: [] + required: false + read_only: false +- name: template-stack + xpath: + path: + - config + - devices + - $panorama_device + - template-stack + - $template_stack + - config + - devices + - $ngfw_device + vars: + - name: panorama_device + description: Specific Panorama device + required: false + default: localhost.localdomain + validators: [] + type: entry + - name: template_stack + description: Specific Panorama template stack + required: true + validators: [] + type: entry + - name: ngfw_device + description: The NGFW device + required: false + default: localhost.localdomain + validators: [] + type: entry + description: Located in a specific template stack + devices: + - panorama + validators: [] + required: false + read_only: false +entries: +- name: name + description: '' + validators: [] +spec: + params: [] + variants: []