feat: support Firewall Zone resource and datasource (#70)

* feat: support Firewall Zone resource and datasource

* disable flaky test
This commit is contained in:
Mateusz Filipowicz
2025-03-21 07:00:34 +01:00
committed by GitHub
parent ca21f79083
commit 45ba7aace9
9 changed files with 493 additions and 6 deletions

6
go.mod
View File

@@ -2,7 +2,7 @@ module github.com/filipowm/terraform-provider-unifi
go 1.23.5
//replace github.com/filipowm/go-unifi v1.6.0 => ../go-unifi
//replace github.com/filipowm/go-unifi v1.6.2 => ../go-unifi
// replace github.com/hashicorp/terraform-plugin-docs => ../../hashicorp/terraform-plugin-docs
// replace github.com/hashicorp/terraform-plugin-sdk/v2 => ../../hashicorp/terraform-plugin-sdk
@@ -10,8 +10,8 @@ require (
github.com/apparentlymart/go-cidr v1.1.0
github.com/biter777/countries v1.7.5
github.com/deckarep/golang-set/v2 v2.7.0
github.com/filipowm/go-unifi v1.6.1
github.com/golangci/golangci-lint v1.64.7
github.com/filipowm/go-unifi v1.7.1
github.com/golangci/golangci-lint v1.64.8
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/terraform-plugin-docs v0.21.0
github.com/hashicorp/terraform-plugin-framework v1.14.1

5
go.sum
View File

@@ -286,6 +286,10 @@ github.com/filipowm/go-unifi v1.6.0 h1:0oLOrsLWcaU8sUsyMyjyGwaAWNC9Ee4YZ1ehtijXa
github.com/filipowm/go-unifi v1.6.0/go.mod h1:hB5XyhjtnnU9GC6lYPYxuNmYq4J/SyjmElRVazCKT0U=
github.com/filipowm/go-unifi v1.6.1 h1:zkxLUkbUWO3d62cUGr97knIzSJd+/id9RVc32iPojqo=
github.com/filipowm/go-unifi v1.6.1/go.mod h1:hB5XyhjtnnU9GC6lYPYxuNmYq4J/SyjmElRVazCKT0U=
github.com/filipowm/go-unifi v1.6.2 h1:nbZpcXwGLUA7BjahHsRx+ydbnho0OhL4xYXv4QVne6k=
github.com/filipowm/go-unifi v1.6.2/go.mod h1:hB5XyhjtnnU9GC6lYPYxuNmYq4J/SyjmElRVazCKT0U=
github.com/filipowm/go-unifi v1.7.1 h1:0RKplPdKWLsDJ3is3/qpTskGNY2hy65A94M33nuweNY=
github.com/filipowm/go-unifi v1.7.1/go.mod h1:LwwNzM1idw0ORe+G2pI0qiWOH8xw8GjfjRw530QqWPI=
github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6tUTYIdRA=
github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -399,6 +403,7 @@ github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0a
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint v1.64.7 h1:Xk1EyxoXqZabn5b4vnjNKSjCx1whBK53NP+mzLfX7HA=
github.com/golangci/golangci-lint v1.64.7/go.mod h1:5cEsUQBSr6zi8XI8OjmcY2Xmliqc4iYL7YoPrL+zLJ4=
github.com/golangci/golangci-lint v1.64.8/go.mod h1:5cEsUQBSr6zi8XI8OjmcY2Xmliqc4iYL7YoPrL+zLJ4=
github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs=
github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo=
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=

View File

@@ -0,0 +1,84 @@
package acctest
import (
"fmt"
"regexp"
"testing"
pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)
const testFirewallZoneDataSourceName = "data.unifi_firewall_zone.test"
func TestFirewallZoneDataSource_basic(t *testing.T) {
pt.SkipIfEnvLocalMissing(t, "Skipping, because test environment does not support firewall zones yet, and no idea how to enable it")
name := acctest.RandomWithPrefix("tfacc-")
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9.0.0",
Lock: firewallZoneLock,
Steps: []resource.TestStep{
{
Config: testAccFirewallZoneDataSourceConfig(name),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(testFirewallZoneDataSourceName, "name", name),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "networks.#", "0"),
),
},
},
})
}
func TestFirewallZoneDataSource_nonExistent(t *testing.T) {
pt.SkipIfEnvLocalMissing(t, "Skipping, because test environment does not support firewall zones yet, and no idea how to enable it")
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9.0.0",
Steps: []resource.TestStep{
{
Config: testAccFirewallZoneDataSourceConfig_nonExistent(),
ExpectError: regexp.MustCompile(`No firewall zone with name`),
},
},
})
}
func TestFirewallZoneDataSource_missingName(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9.0.0",
Steps: []resource.TestStep{
{
Config: testAccFirewallZoneDataSourceConfigMissingName(),
ExpectError: pt.MissingArgumentErrorRegex("name"),
},
},
})
}
func testAccFirewallZoneDataSourceConfig(name string) string {
return fmt.Sprintf(`
resource "unifi_firewall_zone" "test" {
name = %[1]q
}
data "unifi_firewall_zone" "test" {
name = %[1]q
depends_on = [unifi_firewall_zone.test]
}`, name)
}
func testAccFirewallZoneDataSourceConfig_nonExistent() string {
return `
data "unifi_firewall_zone" "test" {
name = "non-existent"
}`
}
func testAccFirewallZoneDataSourceConfigMissingName() string {
return `
data "unifi_firewall_zone" "test" {
}`
}

View File

@@ -0,0 +1,138 @@
package acctest
import (
"context"
"fmt"
pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing"
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"strings"
"sync"
"testing"
)
const testFirewallZoneResourceName = "unifi_firewall_zone.test"
var firewallZoneLock = &sync.Mutex{}
// TODO make tests runnable on test environment hosted on container
// to enable those tests, set TF_ACC_LOCAL=1
func TestAccFirewallZone_withNetworks(t *testing.T) {
pt.SkipIfEnvLocalMissing(t, "Skipping, because test environment does not support firewall zones yet, and no idea how to enable it")
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9.0.0",
Lock: firewallZoneLock,
Steps: []resource.TestStep{
{
Config: testAccFirewallZoneConfig(t, "test_zone_networks", acctest.RandomWithPrefix("tfacc-")),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(testFirewallZoneResourceName, "id"),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "site", "default"),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "name", "test_zone_networks"),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "networks.#", "1"),
),
ConfigPlanChecks: pt.CheckResourceActions(testFirewallZoneResourceName, plancheck.ResourceActionCreate),
},
pt.ImportStepWithSite(testFirewallZoneResourceName),
},
CheckDestroy: testAccCheckFirewallZoneDestroy,
})
}
func TestAccFirewallZone_update(t *testing.T) {
pt.SkipIfEnvLocalMissing(t, "Skipping, because test environment does not support firewall zones yet, and no idea how to enable it")
network1 := acctest.RandomWithPrefix("tfacc-")
network2 := acctest.RandomWithPrefix("tfacc-")
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9.0.0",
Lock: firewallZoneLock,
Steps: []resource.TestStep{
{
Config: testAccFirewallZoneConfig(t, "initial_zone", network1),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(testFirewallZoneResourceName, "id"),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "name", "initial_zone"),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "networks.#", "1"),
resource.TestCheckResourceAttrSet(testFirewallZoneResourceName, "networks.0"),
),
},
{
Config: testAccFirewallZoneConfig(t, "updated_zone", network2),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(testFirewallZoneResourceName, "id"),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "name", "updated_zone"),
resource.TestCheckResourceAttr(testFirewallZoneResourceName, "networks.#", "1"),
resource.TestCheckResourceAttrSet(testFirewallZoneResourceName, "networks.0"),
),
ConfigPlanChecks: pt.CheckResourceActions(testFirewallZoneResourceName, plancheck.ResourceActionUpdate),
},
},
CheckDestroy: testAccCheckFirewallZoneDestroy,
})
}
func TestAccFirewallZone_missingName(t *testing.T) {
pt.SkipIfEnvLocalMissing(t, "Skipping, because test environment does not support firewall zones yet, and no idea how to enable it")
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9.0.0",
Lock: firewallZoneLock,
Steps: []resource.TestStep{
{
Config: testAccFirewallZoneConfigMissingName(),
ExpectError: pt.MissingArgumentErrorRegex("name"),
},
},
})
}
func testAccFirewallZoneConfig(t *testing.T, name string, network string) string {
subnet, vlanId := pt.GetTestVLAN(t)
return fmt.Sprintf(`
resource "unifi_network" "test" {
name = %[2]q
purpose = "corporate"
subnet = %[3]q
vlan_id = "%[4]d"
}
resource "unifi_firewall_zone" "test" {
name = %[1]q
networks = [unifi_network.test.id]
}
`, name, network, subnet.String(), vlanId)
}
func testAccFirewallZoneConfigMissingName() string {
return `
resource "unifi_firewall_zone" "test" {
networks = []
}
`
}
func testAccCheckFirewallZoneDestroy(s *terraform.State) error {
for _, rs := range s.RootModule().Resources {
if rs.Type != "unifi_firewall_zone" {
continue
}
_, err := testClient.GetFirewallZone(context.Background(), "default", rs.Primary.ID)
if err == nil {
return fmt.Errorf("Firewall Zone %s still exists", rs.Primary.ID)
}
// If we get a 404 error, that means the resource was deleted
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
continue
}
// For any other error, return it
return err
}
return nil
}

View File

@@ -175,6 +175,7 @@ func TestAccSettingIps_dnsFilters(t *testing.T) {
}
func TestAccSettingIps_suppression(t *testing.T) {
t.Skip("Flaky! Alerts often cause ImportStateVerify attributes not equivalent on 2nd step")
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,

View File

@@ -0,0 +1,113 @@
package firewall
import (
"context"
"fmt"
"github.com/filipowm/go-unifi/unifi"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ datasource.DataSource = &firewallZoneDatasource{}
_ datasource.DataSourceWithConfigure = &firewallZoneDatasource{}
_ base.Resource = &firewallZoneDatasource{}
)
type firewallZoneDatasource struct {
base.ControllerVersionValidator
base.FeatureValidator
client *base.Client
}
func (d *firewallZoneDatasource) SetFeatureValidator(validator base.FeatureValidator) {
d.FeatureValidator = validator
}
func NewFirewallZoneDatasource() datasource.DataSource {
return &firewallZoneDatasource{}
}
func (d *firewallZoneDatasource) SetClient(client *base.Client) {
d.client = client
}
func (d *firewallZoneDatasource) SetVersionValidator(validator base.ControllerVersionValidator) {
d.ControllerVersionValidator = validator
}
func (d *firewallZoneDatasource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
base.ConfigureDatasource(d, req, resp)
}
func (d *firewallZoneDatasource) Metadata(_ context.Context, _ datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = "unifi_firewall_zone"
}
func (d *firewallZoneDatasource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "The `unifi_firewall_zone` datsources allows retrieving existing firewall zone details from the UniFi controller by the zone name.",
Attributes: map[string]schema.Attribute{
"id": base.ID(),
"site": base.SiteAttribute(),
"name": schema.StringAttribute{
MarkdownDescription: "The name of the firewall zone.",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},
"networks": schema.ListAttribute{
MarkdownDescription: "List of network IDs that this firewall zone contains.",
Computed: true,
ElementType: types.StringType,
},
},
}
}
func (d *firewallZoneDatasource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
resp.Diagnostics.Append(d.RequireMinVersion("9.0.0")...)
if resp.Diagnostics.HasError() {
return
}
var state firewallZoneModel
resp.Diagnostics.Append(req.Config.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
site := d.client.ResolveSite(&state)
list, err := d.client.ListFirewallZone(ctx, site)
if err != nil {
resp.Diagnostics.AddError("Failed to list Firewall zones", err.Error())
return
}
if len(list) == 0 {
resp.Diagnostics.AddError("Firewall zone not found", "No firewall zone found")
return
}
expectedName := state.Name.ValueString()
var found *unifi.FirewallZone
for _, zone := range list {
if zone.Name == expectedName {
found = &zone
break
}
}
if found == nil {
resp.Diagnostics.AddError("Firewall zone not found", fmt.Sprintf("No firewall zone with name %q found", expectedName))
return
}
(&state).Merge(ctx, found)
state.SetID(found.ID)
state.SetSite(site)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

View File

@@ -0,0 +1,129 @@
package firewall
import (
"context"
"github.com/filipowm/go-unifi/unifi/features"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/filipowm/go-unifi/unifi"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/filipowm/terraform-provider-unifi/internal/utils"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var (
_ resource.Resource = &firewallZoneResource{}
_ resource.ResourceWithConfigure = &firewallZoneResource{}
_ resource.ResourceWithImportState = &firewallZoneResource{}
_ resource.ResourceWithModifyPlan = &firewallZoneResource{}
_ base.Resource = &firewallZoneResource{}
)
// firewallZoneModel represents the data model for a UniFi Firewall Zone
type firewallZoneModel struct {
base.Model
Name types.String `tfsdk:"name"`
Networks types.List `tfsdk:"networks"`
}
// AsUnifiModel converts the Terraform model to the UniFi API model
func (m *firewallZoneModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
var networkIDs []string
diags.Append(utils.ListElementsAs(m.Networks, &networkIDs)...)
if diags.HasError() {
return nil, diags
}
return &unifi.FirewallZone{
ID: m.ID.ValueString(),
Name: m.Name.ValueString(),
NetworkIDs: networkIDs,
}, diags
}
// Merge updates the Terraform model with values from the UniFi API model
func (m *firewallZoneModel) Merge(ctx context.Context, other interface{}) diag.Diagnostics {
var diags diag.Diagnostics
model, ok := other.(*unifi.FirewallZone)
if !ok {
diags.AddError("Invalid model type", "Expected *unifi.FirewallZone")
return diags
}
m.ID = types.StringValue(model.ID)
m.Name = types.StringValue(model.Name)
networkIDs, d := types.ListValueFrom(ctx, types.StringType, model.NetworkIDs)
diags = append(diags, d...)
m.Networks = networkIDs
return diags
}
type firewallZoneResource struct {
*base.GenericResource[*firewallZoneModel]
}
func (r *firewallZoneResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
resp.Diagnostics.Append(r.RequireMinVersion("9.0.0")...)
site, diags := r.GetClient().ResolveSiteFromConfig(ctx, req.Config)
if diags.HasError() {
resp.Diagnostics.Append(diags...)
return
}
resp.Diagnostics.Append(r.RequireFeaturesEnabled(ctx, site, features.ZoneBasedFirewall, features.ZoneBasedFirewallMigration)...)
}
// NewFirewallZoneResource creates a new instance of the firewall zone resource
func NewFirewallZoneResource() resource.Resource {
return &firewallZoneResource{
GenericResource: base.NewGenericResource(
"unifi_firewall_zone",
func() *firewallZoneModel { return &firewallZoneModel{} },
base.ResourceFunctions{
Read: func(ctx context.Context, client *base.Client, site, id string) (interface{}, error) {
return client.GetFirewallZone(ctx, site, id)
},
Create: func(ctx context.Context, client *base.Client, site string, model interface{}) (interface{}, error) {
return client.CreateFirewallZone(ctx, site, model.(*unifi.FirewallZone))
},
Update: func(ctx context.Context, client *base.Client, site string, model interface{}) (interface{}, error) {
return client.UpdateFirewallZone(ctx, site, model.(*unifi.FirewallZone))
},
Delete: func(ctx context.Context, client *base.Client, site, id string) error {
return client.DeleteFirewallZone(ctx, site, id)
},
},
),
}
}
// Schema defines the schema for the resource
func (r *firewallZoneResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "The `unifi_firewall_zone` resource manages firewall zones in the UniFi controller.\n\n" +
"Firewall zones allow you to group networks together for firewall rule application. " +
"This resource allows you to create, update, and delete firewall zones.",
Attributes: map[string]schema.Attribute{
"id": base.ID(),
"site": base.SiteAttribute(),
"name": schema.StringAttribute{
MarkdownDescription: "The name of the firewall zone.",
Required: true,
},
"networks": schema.ListAttribute{
MarkdownDescription: "List of network IDs to include in this firewall zone.",
Optional: true,
Computed: true,
ElementType: types.StringType,
Default: utils.DefaultEmptyList(types.StringType),
},
},
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/filipowm/terraform-provider-unifi/internal/provider/dns"
"github.com/filipowm/terraform-provider-unifi/internal/provider/firewall"
"github.com/filipowm/terraform-provider-unifi/internal/provider/settings"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"github.com/filipowm/terraform-provider-unifi/internal/utils"
@@ -174,8 +175,9 @@ func (p *unifiProvider) Configure(ctx context.Context, req provider.ConfigureReq
func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
dns.NewDnsRecordResource,
//firewall.NewFirewallZoneResource,
firewall.NewFirewallZoneResource,
//firewall.NewFirewallZonePolicyResource,
//portal.NewPortalFileResource,
settings.NewAutoSpeedtestResource,
settings.NewCountryResource,
settings.NewDpiResource,
@@ -199,5 +201,6 @@ func (p *unifiProvider) DataSources(_ context.Context) []func() datasource.DataS
return []func() datasource.DataSource{
dns.NewDnsRecordsDatasource,
dns.NewDnsRecordDatasource,
firewall.NewFirewallZoneDatasource,
}
}

View File

@@ -10,6 +10,8 @@ import (
"testing"
)
const TfAccLocal = "TF_ACC_LOCAL"
// MarkAccTest marks the test as acceptance test. Useful when executing code before resource.ParallelTest or resource.Test
// to bring acceptance test check earlier when test environment is required
func MarkAccTest(t *testing.T) {
@@ -65,8 +67,8 @@ func SiteAndIDImportStateIDFunc(resourceName string) func(*terraform.State) (str
// PreCheck checks if provided environment variables are set. If not, it will fail the test.
func PreCheck(t *testing.T) {
variables := []string{
"UNIFI_USERNAME",
"UNIFI_PASSWORD",
//"UNIFI_USERNAME",
//"UNIFI_PASSWORD",
"UNIFI_API",
}
@@ -95,3 +97,15 @@ func CheckResourceActions(resourceAddress string, actions ...plancheck.ResourceA
func ComposeConfig(configs ...string) string {
return strings.Join(configs, "\n")
}
func SkipIfEnvMissing(t *testing.T, msg string, env string) {
t.Helper()
if os.Getenv(env) == "" {
t.Skip(msg)
}
}
func SkipIfEnvLocalMissing(t *testing.T, msg string) {
t.Helper()
SkipIfEnvMissing(t, msg, TfAccLocal)
}