From fcea1e0ba41c6e91da50a25c6123f403de153009 Mon Sep 17 00:00:00 2001 From: Mateusz Filipowicz Date: Tue, 11 Mar 2025 02:17:24 +0100 Subject: [PATCH] feat: support complete USG resource (#44) * feat: add support for UPNP and Geo IP filtering to USG settings resource * feat: support complete USG settings resource * fix messages in required_together_if.go * improve docs of USG resource * tests: require version at least 9.0 for unbind_wan_monitors * feat: require version at least 8.5 for dns_verification * fix: use go-unifi 1.5.2 to fix NTP * require 7.0 or later for timeout preference * require 7.0 or later for geo IP filtering --- go.mod | 10 +- go.sum | 8 + .../acctest/resource_setting_usg_test.go | 931 ++++++++++++- internal/provider/base/base.go | 5 +- internal/provider/provider_v2.go | 4 +- .../settings/base_setting_resource.go | 13 +- .../resource_setting_auto_speedtest.go | 4 +- .../settings/resource_setting_country.go | 4 +- .../settings/resource_setting_country_test.go | 5 +- .../settings/resource_setting_locale.go | 4 +- ...resource_setting_magic_site_to_site_vpn.go | 4 +- .../resource_setting_network_optimization.go | 4 +- .../provider/settings/resource_setting_ntp.go | 4 +- .../settings/resource_setting_ntp_test.go | 5 +- .../resource_setting_ssl_inspection.go | 4 +- .../settings/resource_setting_teleport.go | 4 +- .../provider/settings/resource_setting_usg.go | 1185 ++++++++++++++++- .../provider/validators/required_none_if.go | 5 + .../validators/required_together_if.go | 194 +++ .../validators/required_together_if_test.go | 875 ++++++++++++ .../provider/validators/required_value_if.go | 126 ++ .../validators/required_value_if_test.go | 201 +++ internal/utils/lists.go | 40 + internal/utils/strings.go | 33 + 24 files changed, 3596 insertions(+), 76 deletions(-) create mode 100644 internal/provider/validators/required_together_if.go create mode 100644 internal/provider/validators/required_together_if_test.go create mode 100644 internal/provider/validators/required_value_if.go create mode 100644 internal/provider/validators/required_value_if_test.go diff --git a/go.mod b/go.mod index 2ef677e..c6fa88b 100644 --- a/go.mod +++ b/go.mod @@ -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.5.0 - github.com/golangci/golangci-lint v1.64.5 + github.com/filipowm/go-unifi v1.5.2 + github.com/golangci/golangci-lint v1.64.6 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 @@ -27,7 +27,7 @@ require ( ) require ( - 4d63.com/gocheckcompilerdirectives v1.2.1 // indirect + 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect dario.cat/mergo v1.0.1 // indirect github.com/4meepo/tagalign v1.4.2 // indirect @@ -219,7 +219,7 @@ require ( github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kisielk/errcheck v1.9.0 // indirect - github.com/kkHAIKE/contextcheck v1.1.5 // indirect + github.com/kkHAIKE/contextcheck v1.1.6 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/kulti/thelper v0.6.3 // indirect github.com/kunwardeep/paralleltest v1.0.10 // indirect @@ -299,7 +299,7 @@ require ( github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc // indirect github.com/raeperd/recvcheck v0.2.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/ryancurrah/gomodguard v1.3.5 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect diff --git a/go.sum b/go.sum index 6fecda0..636df5d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ 4d63.com/gocheckcompilerdirectives v1.2.1 h1:AHcMYuw56NPjq/2y615IGg2kYkBdTvOaojYCBcRE7MA= 4d63.com/gocheckcompilerdirectives v1.2.1/go.mod h1:yjDJSxmDTtIHHCqX0ufRYZDL6vQtMG7tJdKVeWwsqvs= +4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= 4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= @@ -278,6 +279,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/filipowm/go-unifi v1.5.0 h1:DgnPKA23urg4jG3k2jaoigbk1Mt22QB0nNE/ItgNZTU= github.com/filipowm/go-unifi v1.5.0/go.mod h1:dr+YNQ1Y2EjOTttAkWAMWMU/J8iv+FueAxZKmptnVEI= +github.com/filipowm/go-unifi v1.5.1 h1:dXdu4ei3ta+r8TAXc2CxPGEdBW01Bxhf1sxBgDreeWk= +github.com/filipowm/go-unifi v1.5.1/go.mod h1:ldtL5szykvR9fPWB9GlGZqaJGxKd8IWLQY5M8O1PDQ8= +github.com/filipowm/go-unifi v1.5.2 h1:S974Fi3BWt7qm0P8G/SwUVOjzgWTSjNJGV0jW5yD6K0= +github.com/filipowm/go-unifi v1.5.2/go.mod h1:ldtL5szykvR9fPWB9GlGZqaJGxKd8IWLQY5M8O1PDQ8= 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= @@ -391,6 +396,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.5 h1:5omC86XFBKXZgCrVdUWU+WNHKd+CWCxNx717KXnzKZY= github.com/golangci/golangci-lint v1.64.5/go.mod h1:WZnwq8TF0z61h3jLQ7Sk5trcP7b3kUFxLD6l1ivtdvU= +github.com/golangci/golangci-lint v1.64.6/go.mod h1:Wz9q+6EVuqGQ94GQ96RB2mjpcZYTOGhBhbt4O7REPu4= 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= @@ -565,6 +571,7 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkHAIKE/contextcheck v1.1.5 h1:CdnJh63tcDe53vG+RebdpdXJTc9atMgGqdx8LXxiilg= github.com/kkHAIKE/contextcheck v1.1.5/go.mod h1:O930cpht4xb1YQpK+1+AgoM3mFsvxr7uyFptcnWTYUA= +github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -809,6 +816,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.0 h1:unbRd941gNa8SS77YznHXOYVBDgWcF9xhzECdm8juZc= github.com/rogpeppe/go-internal v1.14.0/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryancurrah/gomodguard v1.3.5 h1:cShyguSwUEeC0jS7ylOiG/idnd1TpJ1LfHGpV3oJmPU= github.com/ryancurrah/gomodguard v1.3.5/go.mod h1:MXlEPQRxgfPQa62O8wzK3Ozbkv9Rkqr+wKjSxTdsNJE= diff --git a/internal/provider/acctest/resource_setting_usg_test.go b/internal/provider/acctest/resource_setting_usg_test.go index 2f06eb4..937e19b 100644 --- a/internal/provider/acctest/resource_setting_usg_test.go +++ b/internal/provider/acctest/resource_setting_usg_test.go @@ -50,7 +50,7 @@ func TestAccSettingUsg_mdns_v7(t *testing.T) { }) } -func TestAccSettingUsg_dhcpRelay(t *testing.T) { +func TestAccSettingUsg_dhcpRelayServers(t *testing.T) { AcceptanceTest(t, AcceptanceTestCase{ Lock: &settingUsgLock, Steps: []resource.TestStep{ @@ -81,6 +81,495 @@ func TestAccSettingUsg_site(t *testing.T) { }) } +func TestAccSettingUsg_geoIpFiltering(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 7", + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_geoIpFilteringBasic(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "block"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "both"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "3"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "RU"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CN"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "KP"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_geoIpFilteringAllow(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "allow"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "both"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "3"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "US"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CA"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "GB"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_geoIpFilteringDirections(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "block"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "ingress"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "2"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "RU"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CN"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_geoIpFilteringDisabled(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "false"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_geoIpFilteringBasic(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "block"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "both"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "3"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "RU"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CN"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "KP"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + }, + }) +} + +func TestAccSettingUsg_upnp(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_upnpBasic(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.enabled", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_upnpAdvanced(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.enabled", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.nat_pmp_enabled", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.secure_mode", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.wan_interface", "WAN"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_upnpDisabled(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.enabled", "false"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + }, + }) +} + +func TestAccSettingUsg_dnsVerification(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 8.5", + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_dnsVerification(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_usg.test", "dns_verification.domain"), + resource.TestCheckResourceAttrSet("unifi_setting_usg.test", "dns_verification.primary_dns_server"), + resource.TestCheckResourceAttrSet("unifi_setting_usg.test", "dns_verification.secondary_dns_server"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.setting_preference", "auto"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_dnsVerificationUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.domain", "example.com"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.primary_dns_server", "1.1.1.1"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.secondary_dns_server", "1.0.0.1"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.setting_preference", "manual"), + ), + }, + }, + }) +} +func TestAccSettingUsg_tcpTimeouts(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_tcpTimeouts(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_timeout", "10"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.established_timeout", "3600"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_wait_timeout", "20"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.fin_wait_timeout", "30"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.last_ack_timeout", "30"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_recv_timeout", "60"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_sent_timeout", "120"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.time_wait_timeout", "120"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_tcpTimeoutsUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_timeout", "20"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.established_timeout", "7200"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_wait_timeout", "40"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.fin_wait_timeout", "60"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.last_ack_timeout", "60"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_recv_timeout", "120"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_sent_timeout", "240"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.time_wait_timeout", "240"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_arpCache(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_arpCache(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_base_reachable", "60"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_timeout", "custom"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_arpCacheUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_base_reachable", "120"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_timeout", "normal"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_dhcpConfig(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_dhcpConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "broadcast_ping", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_hostfile_update", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_use_dnsmasq", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dnsmasq_all_servers", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_dhcpConfigUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "broadcast_ping", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_hostfile_update", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_use_dnsmasq", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dnsmasq_all_servers", "false"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_dhcpRelayConfig(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_dhcpRelayConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.agents_packets", "forward"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.hop_count", "5"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.max_size", "1400"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.port", "67"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay_servers.#", "2"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.3"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.4"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_dhcpRelayConfigUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.agents_packets", "replace"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.hop_count", "10"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.max_size", "64"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.port", "68"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay_servers.#", "3"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.5"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.6"), + resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.7"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_networkTools(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_networkTools(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "echo_server", "echo.example.com"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + }, + }) +} + +func TestAccSettingUsg_protocolModules(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_protocolModules(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "ftp_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "gre_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "h323_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "pptp_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "sip_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tftp_module", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_protocolModulesUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "ftp_module", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "gre_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "h323_module", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "pptp_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "sip_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tftp_module", "false"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_icmpAndLldp(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_icmpAndLldp(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "icmp_timeout", "60"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "lldp_enable_all", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_icmpAndLldpUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "icmp_timeout", "120"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "lldp_enable_all", "false"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_mssClamp(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_mssClamp(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp", "auto"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp_mss", "1452"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_mssClampUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp", "custom"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp_mss", "1400"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_offloadSettings(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_offloadSettings(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_accounting", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_l2_blocking", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_sch", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_offloadSettingsUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_accounting", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_l2_blocking", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_sch", "false"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_timeoutSettings(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 7", + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_timeoutSettings(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "other_timeout", "600"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "timeout_setting_preference", "auto"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_timeoutSettingsUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "other_timeout", "1200"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "timeout_setting_preference", "manual"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_redirectsAndSecurity(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_redirectsAndSecurity(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "receive_redirects", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "send_redirects", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "syn_cookies", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_redirectsAndSecurityUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "receive_redirects", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "send_redirects", "false"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "syn_cookies", "false"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_udp(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_udp(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_other_timeout", "30"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_stream_timeout", "120"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_udpUpdated(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_other_timeout", "60"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_stream_timeout", "240"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_unbindWanMonitor(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 9", + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_unbindWanMonitor(true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "unbind_wan_monitors", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + { + Config: testAccSettingUsgConfig_unbindWanMonitor(false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_usg.test", "unbind_wan_monitors", "false"), + ), + }, + }, + }) +} + +func TestAccSettingUsg_comprehensive(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 7", + Lock: &settingUsgLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingUsgConfig_comprehensive(), + Check: resource.ComposeTestCheckFunc( + // ARP Cache + resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_base_reachable", "60"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_timeout", "custom"), + + // DHCP Config + resource.TestCheckResourceAttr("unifi_setting_usg.test", "broadcast_ping", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_hostfile_update", "true"), + + // Protocol Modules (sample) + resource.TestCheckResourceAttr("unifi_setting_usg.test", "ftp_module", "true"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "tftp_module", "true"), + + // Timeouts + resource.TestCheckResourceAttr("unifi_setting_usg.test", "other_timeout", "600"), + resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_stream_timeout", "120"), + + // Security + resource.TestCheckResourceAttr("unifi_setting_usg.test", "syn_cookies", "true"), + ), + }, + pt.ImportStepWithSite("unifi_setting_usg.test"), + }, + }) +} + func testAccSettingUsgConfig_mdns(mdns bool) string { return fmt.Sprintf(` resource "unifi_setting_usg" "test" { @@ -111,3 +600,443 @@ resource "unifi_setting_usg" "test" { } ` } + +func testAccSettingUsgConfig_geoIpFilteringBasic() string { + return ` +resource "unifi_setting_usg" "test" { + geo_ip_filtering = { + enabled = true + countries = ["RU", "CN", "KP"] + } +} +` +} + +func testAccSettingUsgConfig_geoIpFilteringAllow() string { + return ` +resource "unifi_setting_usg" "test" { + geo_ip_filtering = { + enabled = true + block = "allow" + countries = ["US", "CA", "GB"] + } +} +` +} + +func testAccSettingUsgConfig_geoIpFilteringDirections() string { + return ` +resource "unifi_setting_usg" "test" { + geo_ip_filtering = { + enabled = true + traffic_direction = "ingress" + countries = ["RU", "CN"] + } +} +` +} + +func testAccSettingUsgConfig_geoIpFilteringDisabled() string { + return ` +resource "unifi_setting_usg" "test" { + geo_ip_filtering = { + enabled = false + } +} +` +} + +func testAccSettingUsgConfig_upnpBasic() string { + return ` +resource "unifi_setting_usg" "test" { + upnp = { + enabled = true + } +} +` +} + +func testAccSettingUsgConfig_upnpAdvanced() string { + return ` +resource "unifi_setting_usg" "test" { + upnp = { + enabled = true + nat_pmp_enabled = true + secure_mode = true + wan_interface = "WAN" + } +} +` +} + +func testAccSettingUsgConfig_upnpDisabled() string { + return ` +resource "unifi_setting_usg" "test" { + upnp = { + enabled = false + } +} +` +} + +func testAccSettingUsgConfig_dnsVerification() string { + return ` +resource "unifi_setting_usg" "test" { + dns_verification = { + setting_preference = "auto" + } +} +` +} + +func testAccSettingUsgConfig_dnsVerificationUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + dns_verification = { + domain = "example.com" + primary_dns_server = "1.1.1.1" + secondary_dns_server = "1.0.0.1" + setting_preference = "manual" + } +} +` +} + +func testAccSettingUsgConfig_tcpTimeouts() string { + return ` +resource "unifi_setting_usg" "test" { + tcp_timeouts = { + close_timeout = 10 + established_timeout = 3600 + close_wait_timeout = 20 + fin_wait_timeout = 30 + last_ack_timeout = 30 + syn_recv_timeout = 60 + syn_sent_timeout = 120 + time_wait_timeout = 120 + } +} +` +} + +func testAccSettingUsgConfig_tcpTimeoutsUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + tcp_timeouts = { + close_timeout = 20 + established_timeout = 7200 + close_wait_timeout = 40 + fin_wait_timeout = 60 + last_ack_timeout = 60 + syn_recv_timeout = 120 + syn_sent_timeout = 240 + time_wait_timeout = 240 + } +} +` +} + +func testAccSettingUsgConfig_arpCache() string { + return ` +resource "unifi_setting_usg" "test" { + arp_cache_base_reachable = 60 + arp_cache_timeout = "custom" +} +` +} + +func testAccSettingUsgConfig_dhcpConfig() string { + return ` +resource "unifi_setting_usg" "test" { + broadcast_ping = true + dhcpd_hostfile_update = true + dhcpd_use_dnsmasq = true + dnsmasq_all_servers = true +} +` +} + +func testAccSettingUsgConfig_dhcpRelayConfig() string { + return ` +resource "unifi_setting_usg" "test" { + dhcp_relay = { + agents_packets = "forward" + hop_count = 5 + max_size = 1400 + port = 67 + } + dhcp_relay_servers = ["10.1.2.3","10.1.2.4"] +} +` +} + +func testAccSettingUsgConfig_dhcpRelayConfigUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + dhcp_relay = { + agents_packets = "replace" + hop_count = 10 + max_size = 64 + port = 68 + } + dhcp_relay_servers = ["10.1.2.5","10.1.2.6","10.1.2.7"] +} +` +} + +func testAccSettingUsgConfig_networkTools() string { + return ` +resource "unifi_setting_usg" "test" { + echo_server = "echo.example.com" +} +` +} + +func testAccSettingUsgConfig_protocolModules() string { + return ` +resource "unifi_setting_usg" "test" { + ftp_module = true + gre_module = true + h323_module = true + pptp_module = true + sip_module = true + tftp_module = true +} +` +} + +func testAccSettingUsgConfig_icmpAndLldp() string { + return ` +resource "unifi_setting_usg" "test" { + icmp_timeout = 60 + lldp_enable_all = true +} +` +} + +func testAccSettingUsgConfig_mssClamp() string { + return ` +resource "unifi_setting_usg" "test" { + mss_clamp = "auto" + mss_clamp_mss = 1452 +} +` +} + +func testAccSettingUsgConfig_offloadSettings() string { + return ` +resource "unifi_setting_usg" "test" { + offload_accounting = true + offload_l2_blocking = true + offload_sch = true +} +` +} + +func testAccSettingUsgConfig_timeoutSettings() string { + return ` +resource "unifi_setting_usg" "test" { + other_timeout = 600 + timeout_setting_preference = "auto" +} +` +} + +func testAccSettingUsgConfig_redirectsAndSecurity() string { + return ` +resource "unifi_setting_usg" "test" { + receive_redirects = false + send_redirects = true + syn_cookies = true +} +` +} + +func testAccSettingUsgConfig_udp() string { + return ` +resource "unifi_setting_usg" "test" { + udp_other_timeout = 30 + udp_stream_timeout = 120 +} +` +} + +func testAccSettingUsgConfig_comprehensive() string { + return ` +resource "unifi_setting_usg" "test" { + // ARP Cache Configuration + arp_cache_base_reachable = 60 + arp_cache_timeout = "custom" + + // DHCP Configuration + broadcast_ping = true + dhcpd_hostfile_update = true + dhcpd_use_dnsmasq = true + dnsmasq_all_servers = true + + // DHCP Relay + dhcp_relay = { + agents_packets = "forward" + hop_count = 5 + } + dhcp_relay_servers = ["10.1.2.3", "10.1.2.4"] + + // Network Tools + echo_server = "echo.example.com" + + // Protocol Modules + ftp_module = true + gre_module = true + tftp_module = true + + // ICMP & LLDP + icmp_timeout = 20 + lldp_enable_all = true + + // MSS Clamp + mss_clamp = "auto" + mss_clamp_mss = 1452 + + // Offload Settings + offload_accounting = true + offload_l2_blocking = true + + // Timeout Settings + other_timeout = 600 + timeout_setting_preference = "auto" + + // TCP Settings + tcp_timeouts = { + close_timeout = 10 + established_timeout = 3600 + close_wait_timeout = 20 + fin_wait_timeout = 30 + last_ack_timeout = 30 + syn_recv_timeout = 60 + syn_sent_timeout = 120 + time_wait_timeout = 120 + } + + // Redirects & Security + receive_redirects = false + send_redirects = true + syn_cookies = true + + // UDP + udp_other_timeout = 30 + udp_stream_timeout = 120 + + // Geo IP Filtering + geo_ip_filtering = { + enabled = true + block = "block" + countries = ["RU", "CN"] + traffic_direction = "both" + } + + // UPNP Settings + upnp = { + enabled = true + nat_pmp_enabled = true + secure_mode = true + } +} +` +} + +func testAccSettingUsgConfig_arpCacheUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + arp_cache_base_reachable = 120 + arp_cache_timeout = "normal" +} +` +} + +func testAccSettingUsgConfig_dhcpConfigUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + broadcast_ping = false + dhcpd_hostfile_update = false + dhcpd_use_dnsmasq = false + dnsmasq_all_servers = false +} +` +} + +func testAccSettingUsgConfig_protocolModulesUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + ftp_module = false + gre_module = true + h323_module = false + pptp_module = true + sip_module = true + tftp_module = false +} +` +} + +func testAccSettingUsgConfig_icmpAndLldpUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + icmp_timeout = 120 + lldp_enable_all = false +} +` +} + +func testAccSettingUsgConfig_mssClampUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + mss_clamp = "custom" + mss_clamp_mss = 1400 +} +` +} + +func testAccSettingUsgConfig_offloadSettingsUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + offload_accounting = false + offload_l2_blocking = false + offload_sch = false +} +` +} + +func testAccSettingUsgConfig_timeoutSettingsUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + other_timeout = 1200 + timeout_setting_preference = "manual" +} +` +} + +func testAccSettingUsgConfig_redirectsAndSecurityUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + receive_redirects = true + send_redirects = false + syn_cookies = false +} +` +} + +func testAccSettingUsgConfig_udpUpdated() string { + return ` +resource "unifi_setting_usg" "test" { + udp_other_timeout = 60 + udp_stream_timeout = 240 +} +` +} + +func testAccSettingUsgConfig_unbindWanMonitor(enabled bool) string { + return fmt.Sprintf(` +resource "unifi_setting_usg" "test" { + unbind_wan_monitors = %t +} +`, enabled) +} diff --git a/internal/provider/base/base.go b/internal/provider/base/base.go index 420bfb5..51e0371 100644 --- a/internal/provider/base/base.go +++ b/internal/provider/base/base.go @@ -1,6 +1,7 @@ package base import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -22,8 +23,8 @@ type ResourceModel interface { GetID() string GetRawID() types.String SetID(string) - AsUnifiModel() (interface{}, diag.Diagnostics) - Merge(interface{}) diag.Diagnostics + AsUnifiModel(context.Context) (interface{}, diag.Diagnostics) + Merge(context.Context, interface{}) diag.Diagnostics } type Model struct { diff --git a/internal/provider/provider_v2.go b/internal/provider/provider_v2.go index 525bff8..1ceaa7f 100644 --- a/internal/provider/provider_v2.go +++ b/internal/provider/provider_v2.go @@ -104,8 +104,8 @@ func (p *unifiProvider) Configure(ctx context.Context, req provider.ConfigureReq path.Root("api_url"), "Unknown UniFi Controller API URL", "The provider cannot create the UniFi Controller API client as there is an unknown configuration value "+ - "for the API endpoint. Either target apply the source of the value first, set the value statically in "+ - "the configuration, or use the UNIFI_API environment variable.", + "for the API endpoint. Either target apply the source of the value first, set the value statically in "+ + "the configuration, or use the UNIFI_API environment variable.", ) } diff --git a/internal/provider/settings/base_setting_resource.go b/internal/provider/settings/base_setting_resource.go index 4164509..98632b7 100644 --- a/internal/provider/settings/base_setting_resource.go +++ b/internal/provider/settings/base_setting_resource.go @@ -87,13 +87,14 @@ func (b *BaseSettingResource[T]) Create(ctx context.Context, req resource.Create if resp.Diagnostics.HasError() { return } - body, diags := plan.AsUnifiModel() + site := b.client.ResolveSite(plan) + + body, diags := plan.AsUnifiModel(ctx) if diags.HasError() { resp.Diagnostics.Append(diags...) return } - site := b.client.ResolveSite(plan) res, err := b.updater(ctx, b.client, site, body) if err != nil { @@ -104,7 +105,7 @@ func (b *BaseSettingResource[T]) Create(ctx context.Context, req resource.Create resp.Diagnostics.AddError("Error creating settings", fmt.Sprintf("No %[1]s settings returned from the UniFi controller. %[1]s might not be supported on this controller", b.typeName)) return } - plan.Merge(res) + plan.Merge(ctx, res) plan.SetSite(site) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } @@ -128,7 +129,7 @@ func (b *BaseSettingResource[T]) read(ctx context.Context, site string, state T, return } if res != nil { - state.Merge(res) + state.Merge(ctx, res) } } @@ -172,7 +173,7 @@ func (b *BaseSettingResource[T]) Update(ctx context.Context, req resource.Update if resp.Diagnostics.HasError() { return } - body, diags := plan.AsUnifiModel() + body, diags := plan.AsUnifiModel(ctx) if diags.HasError() { resp.Diagnostics.Append(diags...) return @@ -184,7 +185,7 @@ func (b *BaseSettingResource[T]) Update(ctx context.Context, req resource.Update resp.Diagnostics.AddError("Error updating settings", err.Error()) return } - state.Merge(res) + state.Merge(ctx, res) state.SetSite(site) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } diff --git a/internal/provider/settings/resource_setting_auto_speedtest.go b/internal/provider/settings/resource_setting_auto_speedtest.go index 24a0b36..4039ed8 100644 --- a/internal/provider/settings/resource_setting_auto_speedtest.go +++ b/internal/provider/settings/resource_setting_auto_speedtest.go @@ -24,7 +24,7 @@ type autoSpeedtestModel struct { Enabled types.Bool `tfsdk:"enabled"` } -func (d *autoSpeedtestModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *autoSpeedtestModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { return &unifi.SettingAutoSpeedtest{ ID: d.ID.ValueString(), CronExpr: d.CronExpression.ValueString(), @@ -32,7 +32,7 @@ func (d *autoSpeedtestModel) AsUnifiModel() (interface{}, diag.Diagnostics) { }, diag.Diagnostics{} } -func (d *autoSpeedtestModel) Merge(other interface{}) diag.Diagnostics { +func (d *autoSpeedtestModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { if typed, ok := other.(*unifi.SettingAutoSpeedtest); ok { d.ID = types.StringValue(typed.ID) d.CronExpression = types.StringValue(typed.CronExpr) diff --git a/internal/provider/settings/resource_setting_country.go b/internal/provider/settings/resource_setting_country.go index 4de93a1..2628c44 100644 --- a/internal/provider/settings/resource_setting_country.go +++ b/internal/provider/settings/resource_setting_country.go @@ -26,7 +26,7 @@ type countryModel struct { CodeNumeric types.Int32 `tfsdk:"code_numeric"` } -func (d *countryModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *countryModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { code := countries.ByName(d.Code.ValueString()) return &unifi.SettingCountry{ ID: d.ID.ValueString(), @@ -34,7 +34,7 @@ func (d *countryModel) AsUnifiModel() (interface{}, diag.Diagnostics) { }, diag.Diagnostics{} } -func (d *countryModel) Merge(other interface{}) diag.Diagnostics { +func (d *countryModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { if typed, ok := other.(*unifi.SettingCountry); ok { d.ID = types.StringValue(typed.ID) code := countries.ByNumeric(typed.Code) diff --git a/internal/provider/settings/resource_setting_country_test.go b/internal/provider/settings/resource_setting_country_test.go index cccd71c..21f1704 100644 --- a/internal/provider/settings/resource_setting_country_test.go +++ b/internal/provider/settings/resource_setting_country_test.go @@ -1,6 +1,7 @@ package settings import ( + "context" "github.com/filipowm/go-unifi/unifi" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stretchr/testify/assert" @@ -26,7 +27,7 @@ func TestSettingCountry_ProperCountryCodeMappingFromModel(t *testing.T) { model := countryModel{ Code: types.StringValue(tc.code), } - unifiModel, _ := model.AsUnifiModel() + unifiModel, _ := model.AsUnifiModel(context.Background()) typed, ok := unifiModel.(*unifi.SettingCountry) assert.True(t, ok) assert.Equal(t, tc.expectedNumericCode, typed.Code) @@ -54,7 +55,7 @@ func TestSettingCountry_ProperCountryCodeMappingToModel(t *testing.T) { Code: tc.numericCode, } model := countryModel{} - model.Merge(unifiModel) + model.Merge(context.Background(), unifiModel) assert.Equal(t, tc.expectedCode, model.Code.ValueString()) }) } diff --git a/internal/provider/settings/resource_setting_locale.go b/internal/provider/settings/resource_setting_locale.go index b6446f3..fe3cba2 100644 --- a/internal/provider/settings/resource_setting_locale.go +++ b/internal/provider/settings/resource_setting_locale.go @@ -18,7 +18,7 @@ type localeModel struct { Timezone types.String `tfsdk:"timezone"` } -func (d *localeModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *localeModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { diags := diag.Diagnostics{} model := &unifi.SettingLocale{ @@ -29,7 +29,7 @@ func (d *localeModel) AsUnifiModel() (interface{}, diag.Diagnostics) { return model, diags } -func (d *localeModel) Merge(other interface{}) diag.Diagnostics { +func (d *localeModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { diags := diag.Diagnostics{} model, ok := other.(*unifi.SettingLocale) diff --git a/internal/provider/settings/resource_setting_magic_site_to_site_vpn.go b/internal/provider/settings/resource_setting_magic_site_to_site_vpn.go index 00331b3..379b184 100644 --- a/internal/provider/settings/resource_setting_magic_site_to_site_vpn.go +++ b/internal/provider/settings/resource_setting_magic_site_to_site_vpn.go @@ -16,7 +16,7 @@ type magicSiteToSiteVpnModel struct { Enabled types.Bool `tfsdk:"enabled"` } -func (d *magicSiteToSiteVpnModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *magicSiteToSiteVpnModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { diags := diag.Diagnostics{} model := &unifi.SettingMagicSiteToSiteVpn{ @@ -27,7 +27,7 @@ func (d *magicSiteToSiteVpnModel) AsUnifiModel() (interface{}, diag.Diagnostics) return model, diags } -func (d *magicSiteToSiteVpnModel) Merge(other interface{}) diag.Diagnostics { +func (d *magicSiteToSiteVpnModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { diags := diag.Diagnostics{} model, ok := other.(*unifi.SettingMagicSiteToSiteVpn) diff --git a/internal/provider/settings/resource_setting_network_optimization.go b/internal/provider/settings/resource_setting_network_optimization.go index 1e5a762..e4e9ab2 100644 --- a/internal/provider/settings/resource_setting_network_optimization.go +++ b/internal/provider/settings/resource_setting_network_optimization.go @@ -16,7 +16,7 @@ type networkOptimizationModel struct { Enabled types.Bool `tfsdk:"enabled"` } -func (d *networkOptimizationModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *networkOptimizationModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { diags := diag.Diagnostics{} model := &unifi.SettingNetworkOptimization{ @@ -27,7 +27,7 @@ func (d *networkOptimizationModel) AsUnifiModel() (interface{}, diag.Diagnostics return model, diags } -func (d *networkOptimizationModel) Merge(other interface{}) diag.Diagnostics { +func (d *networkOptimizationModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { diags := diag.Diagnostics{} model, ok := other.(*unifi.SettingNetworkOptimization) diff --git a/internal/provider/settings/resource_setting_ntp.go b/internal/provider/settings/resource_setting_ntp.go index b1d3f2f..01e7ada 100644 --- a/internal/provider/settings/resource_setting_ntp.go +++ b/internal/provider/settings/resource_setting_ntp.go @@ -26,7 +26,7 @@ type ntpModel struct { Mode types.String `tfsdk:"mode"` } -func (d *ntpModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *ntpModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { diags := diag.Diagnostics{} model := &unifi.SettingNtp{ @@ -56,7 +56,7 @@ func (d *ntpModel) AsUnifiModel() (interface{}, diag.Diagnostics) { return model, diags } -func (d *ntpModel) Merge(other interface{}) diag.Diagnostics { +func (d *ntpModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { diags := diag.Diagnostics{} model, ok := other.(*unifi.SettingNtp) diff --git a/internal/provider/settings/resource_setting_ntp_test.go b/internal/provider/settings/resource_setting_ntp_test.go index 49e3907..d67090b 100644 --- a/internal/provider/settings/resource_setting_ntp_test.go +++ b/internal/provider/settings/resource_setting_ntp_test.go @@ -1,6 +1,7 @@ package settings import ( + "context" "github.com/filipowm/go-unifi/unifi" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stretchr/testify/assert" @@ -21,7 +22,7 @@ func TestNtpModel_AsUnifiModel_Auto(t *testing.T) { model.ID = types.StringValue("test-id") // Convert to UnifiModel - unifiModel, diags := model.AsUnifiModel() + unifiModel, diags := model.AsUnifiModel(context.Background()) // Verify no diagnostics errors assert.False(t, diags.HasError()) @@ -138,7 +139,7 @@ func TestNtpModel_AsUnifiModel_Manual(t *testing.T) { model.ID = types.StringValue("test-id") // Convert to UnifiModel - unifiModel, diags := model.AsUnifiModel() + unifiModel, diags := model.AsUnifiModel(context.Background()) // Verify no diagnostics errors assert.False(t, diags.HasError()) diff --git a/internal/provider/settings/resource_setting_ssl_inspection.go b/internal/provider/settings/resource_setting_ssl_inspection.go index 329014b..846ca11 100644 --- a/internal/provider/settings/resource_setting_ssl_inspection.go +++ b/internal/provider/settings/resource_setting_ssl_inspection.go @@ -18,7 +18,7 @@ type sslInspectionModel struct { State types.String `tfsdk:"state"` } -func (d *sslInspectionModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *sslInspectionModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { diags := diag.Diagnostics{} model := &unifi.SettingSslInspection{ @@ -29,7 +29,7 @@ func (d *sslInspectionModel) AsUnifiModel() (interface{}, diag.Diagnostics) { return model, diags } -func (d *sslInspectionModel) Merge(other interface{}) diag.Diagnostics { +func (d *sslInspectionModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { diags := diag.Diagnostics{} model, ok := other.(*unifi.SettingSslInspection) diff --git a/internal/provider/settings/resource_setting_teleport.go b/internal/provider/settings/resource_setting_teleport.go index 9db775c..a9006be 100644 --- a/internal/provider/settings/resource_setting_teleport.go +++ b/internal/provider/settings/resource_setting_teleport.go @@ -19,7 +19,7 @@ type teleportModel struct { Subnet types.String `tfsdk:"subnet"` } -func (d *teleportModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *teleportModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) { diags := diag.Diagnostics{} model := &unifi.SettingTeleport{ @@ -31,7 +31,7 @@ func (d *teleportModel) AsUnifiModel() (interface{}, diag.Diagnostics) { return model, diags } -func (d *teleportModel) Merge(other interface{}) diag.Diagnostics { +func (d *teleportModel) Merge(_ context.Context, other interface{}) diag.Diagnostics { diags := diag.Diagnostics{} model, ok := other.(*unifi.SettingTeleport) diff --git a/internal/provider/settings/resource_setting_usg.go b/internal/provider/settings/resource_setting_usg.go index e4a1a54..940f03a 100644 --- a/internal/provider/settings/resource_setting_usg.go +++ b/internal/provider/settings/resource_setting_usg.go @@ -2,30 +2,203 @@ package settings import ( "context" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/filipowm/go-unifi/unifi" "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" "github.com/filipowm/terraform-provider-unifi/internal/utils" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) +// GeoIPFilteringModel represents the GeoIP filtering configuration +type GeoIPFilteringModel struct { + Enabled types.Bool `tfsdk:"enabled"` + Block types.String `tfsdk:"block"` + Countries types.List `tfsdk:"countries"` + TrafficDirection types.String `tfsdk:"traffic_direction"` +} + +func (m *GeoIPFilteringModel) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "enabled": types.BoolType, + "block": types.StringType, + "countries": types.ListType{ + ElemType: types.StringType, + }, + "traffic_direction": types.StringType, + } +} + +// UpnpModel represents the UPNP configuration +type UpnpModel struct { + Enabled types.Bool `tfsdk:"enabled"` + NatPmpEnabled types.Bool `tfsdk:"nat_pmp_enabled"` + SecureMode types.Bool `tfsdk:"secure_mode"` + WANInterface types.String `tfsdk:"wan_interface"` +} + +func (m *UpnpModel) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "enabled": types.BoolType, + "nat_pmp_enabled": types.BoolType, + "secure_mode": types.BoolType, + "wan_interface": types.StringType, + } +} + +// TCPTimeoutModel represents the TCP timeout configuration +type TCPTimeoutModel struct { + CloseTimeout types.Int64 `tfsdk:"close_timeout"` + CloseWaitTimeout types.Int64 `tfsdk:"close_wait_timeout"` + EstablishedTimeout types.Int64 `tfsdk:"established_timeout"` + FinWaitTimeout types.Int64 `tfsdk:"fin_wait_timeout"` + LastAckTimeout types.Int64 `tfsdk:"last_ack_timeout"` + SynRecvTimeout types.Int64 `tfsdk:"syn_recv_timeout"` + SynSentTimeout types.Int64 `tfsdk:"syn_sent_timeout"` + TimeWaitTimeout types.Int64 `tfsdk:"time_wait_timeout"` +} + +func (m *TCPTimeoutModel) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "close_timeout": types.Int64Type, + "close_wait_timeout": types.Int64Type, + "established_timeout": types.Int64Type, + "fin_wait_timeout": types.Int64Type, + "last_ack_timeout": types.Int64Type, + "syn_recv_timeout": types.Int64Type, + "syn_sent_timeout": types.Int64Type, + "time_wait_timeout": types.Int64Type, + } +} + +// DNSVerificationModel represents the DNS Verification configuration +type DNSVerificationModel struct { + Domain types.String `tfsdk:"domain"` + PrimaryDNSServer types.String `tfsdk:"primary_dns_server"` + SecondaryDNSServer types.String `tfsdk:"secondary_dns_server"` + SettingPreference types.String `tfsdk:"setting_preference"` +} + +func (m *DNSVerificationModel) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "domain": types.StringType, + "primary_dns_server": types.StringType, + "secondary_dns_server": types.StringType, + "setting_preference": types.StringType, + } +} + +// DNSVerificationModel represents the DNS Verification configuration +type DHCPRelayModel struct { + AgentsPackets types.String `tfsdk:"agents_packets"` + HopCount types.Int64 `tfsdk:"hop_count"` + MaxSize types.Int64 `tfsdk:"max_size"` + Port types.Int64 `tfsdk:"port"` +} + +func (m *DHCPRelayModel) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "agents_packets": types.StringType, + "hop_count": types.Int64Type, + "max_size": types.Int64Type, + "port": types.Int64Type, + } +} + // usgModel represents the data model for USG (UniFi Security Gateway) settings. // It defines how USG features like mDNS and DHCP relay are configured for a UniFi site. type usgModel struct { base.Model MulticastDnsEnabled types.Bool `tfsdk:"multicast_dns_enabled"` - DhcpRelayServers types.List `tfsdk:"dhcp_relay_servers"` + + // Geo IP filtering + GeoIPFiltering types.Object `tfsdk:"geo_ip_filtering"` + + // UPNP configuration + Upnp types.Object `tfsdk:"upnp"` + + // ARP Cache Configuration + ArpCacheBaseReachable types.Int64 `tfsdk:"arp_cache_base_reachable"` + ArpCacheTimeout types.String `tfsdk:"arp_cache_timeout"` + + // DHCP Configuration + BroadcastPing types.Bool `tfsdk:"broadcast_ping"` + DhcpdHostfileUpdate types.Bool `tfsdk:"dhcpd_hostfile_update"` + DhcpdUseDnsmasq types.Bool `tfsdk:"dhcpd_use_dnsmasq"` + DnsmasqAllServers types.Bool `tfsdk:"dnsmasq_all_servers"` + DhcpRelayServers types.List `tfsdk:"dhcp_relay_servers"` // TODO deprecated + DhcpRelay types.Object `tfsdk:"dhcp_relay"` + + // DNS Verification + DnsVerification types.Object `tfsdk:"dns_verification"` + + // Network Tools + EchoServer types.String `tfsdk:"echo_server"` + + // Protocol Modules + FtpModule types.Bool `tfsdk:"ftp_module"` + GreModule types.Bool `tfsdk:"gre_module"` + H323Module types.Bool `tfsdk:"h323_module"` + PptpModule types.Bool `tfsdk:"pptp_module"` + SipModule types.Bool `tfsdk:"sip_module"` + TftpModule types.Bool `tfsdk:"tftp_module"` + + // ICMP Settings + IcmpTimeout types.Int64 `tfsdk:"icmp_timeout"` + + // LLDP Settings + LldpEnableAll types.Bool `tfsdk:"lldp_enable_all"` + + // MSS Clamp Settings + MssClamp types.String `tfsdk:"mss_clamp"` + MssClampMss types.Int64 `tfsdk:"mss_clamp_mss"` + + // Offload Settings + OffloadAccounting types.Bool `tfsdk:"offload_accounting"` + OffloadL2Blocking types.Bool `tfsdk:"offload_l2_blocking"` + OffloadSch types.Bool `tfsdk:"offload_sch"` + + // Timeout Settings + OtherTimeout types.Int64 `tfsdk:"other_timeout"` + TimeoutSettingPreference types.String `tfsdk:"timeout_setting_preference"` + + // TCP Settings (nested) + TcpTimeouts types.Object `tfsdk:"tcp_timeouts"` + + // Redirects + ReceiveRedirects types.Bool `tfsdk:"receive_redirects"` + SendRedirects types.Bool `tfsdk:"send_redirects"` + + // Security Settings + SynCookies types.Bool `tfsdk:"syn_cookies"` + + // UDP Settings + UdpOtherTimeout types.Int64 `tfsdk:"udp_other_timeout"` + UdpStreamTimeout types.Int64 `tfsdk:"udp_stream_timeout"` + + // WAN Settings + UnbindWanMonitors types.Bool `tfsdk:"unbind_wan_monitors"` } -func (d *usgModel) AsUnifiModel() (interface{}, diag.Diagnostics) { +func (d *usgModel) AsUnifiModel(ctx context.Context) (interface{}, diag.Diagnostics) { diags := diag.Diagnostics{} model := &unifi.SettingUsg{ @@ -40,17 +213,141 @@ func (d *usgModel) AsUnifiModel() (interface{}, diag.Diagnostics) { return nil, diags } + // TODO deprecated // Assign DHCP relay servers to the model (up to 5) - model.DHCPRelayServer1 = append(dhcpRelayServers, "")[0] - model.DHCPRelayServer2 = append(dhcpRelayServers, "", "")[1] - model.DHCPRelayServer3 = append(dhcpRelayServers, "", "", "")[2] - model.DHCPRelayServer4 = append(dhcpRelayServers, "", "", "", "")[3] - model.DHCPRelayServer5 = append(dhcpRelayServers, "", "", "", "", "")[4] + // Map each server by index to appropriate field + serverFields := []struct { + index int + fieldPtr *string + }{ + {0, &model.DHCPRelayServer1}, + {1, &model.DHCPRelayServer2}, + {2, &model.DHCPRelayServer3}, + {3, &model.DHCPRelayServer4}, + {4, &model.DHCPRelayServer5}, + } + for _, sf := range serverFields { + if sf.index < len(dhcpRelayServers) { + *sf.fieldPtr = dhcpRelayServers[sf.index] + } + } + // TODO end of deprecated + + // Assign Geo IP filtering attributes + if base.IsDefined(d.GeoIPFiltering) { + var geoIPFiltering *GeoIPFilteringModel + diags.Append(d.GeoIPFiltering.As(ctx, &geoIPFiltering, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + + model.GeoIPFilteringEnabled = geoIPFiltering.Enabled.ValueBool() + model.GeoIPFilteringBlock = geoIPFiltering.Block.ValueString() + model.GeoIPFilteringTrafficDirection = geoIPFiltering.TrafficDirection.ValueString() + countries, diags := utils.ListElementsToString(ctx, geoIPFiltering.Countries) + if diags.HasError() { + return nil, diags + } + model.GeoIPFilteringCountries = countries + } else { + model.GeoIPFilteringEnabled = false + } + + // Assign UPNP attributes + if base.IsDefined(d.Upnp) { + var upnp *UpnpModel + diags.Append(d.Upnp.As(ctx, &upnp, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + + model.UpnpEnabled = upnp.Enabled.ValueBool() + model.UpnpNATPmpEnabled = upnp.NatPmpEnabled.ValueBool() + model.UpnpSecureMode = upnp.SecureMode.ValueBool() + model.UpnpWANInterface = upnp.WANInterface.ValueString() + } else { + model.UpnpEnabled = false + } + + if base.IsDefined(d.TcpTimeouts) { + var tcpTimeouts *TCPTimeoutModel + diags.Append(d.TcpTimeouts.As(ctx, &tcpTimeouts, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + + model.TCPCloseTimeout = int(tcpTimeouts.CloseTimeout.ValueInt64()) + model.TCPCloseWaitTimeout = int(tcpTimeouts.CloseWaitTimeout.ValueInt64()) + model.TCPEstablishedTimeout = int(tcpTimeouts.EstablishedTimeout.ValueInt64()) + model.TCPFinWaitTimeout = int(tcpTimeouts.FinWaitTimeout.ValueInt64()) + model.TCPLastAckTimeout = int(tcpTimeouts.LastAckTimeout.ValueInt64()) + model.TCPSynRecvTimeout = int(tcpTimeouts.SynRecvTimeout.ValueInt64()) + model.TCPSynSentTimeout = int(tcpTimeouts.SynSentTimeout.ValueInt64()) + model.TCPTimeWaitTimeout = int(tcpTimeouts.TimeWaitTimeout.ValueInt64()) + } + + // Assign DNS Verification attributes + if base.IsDefined(d.DnsVerification) { + var dnsVerification *DNSVerificationModel + diags.Append(d.DnsVerification.As(ctx, &dnsVerification, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + + model.DNSVerification = unifi.SettingUsgDNSVerification{ + Domain: dnsVerification.Domain.ValueString(), + PrimaryDNSServer: dnsVerification.PrimaryDNSServer.ValueString(), + SecondaryDNSServer: dnsVerification.SecondaryDNSServer.ValueString(), + SettingPreference: dnsVerification.SettingPreference.ValueString(), + } + } + + if base.IsDefined(d.DhcpRelay) { + var dhcpRelay *DHCPRelayModel + diags.Append(d.DhcpRelay.As(ctx, &dhcpRelay, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + + model.DHCPRelayAgentsPackets = dhcpRelay.AgentsPackets.ValueString() + model.DHCPRelayHopCount = int(dhcpRelay.HopCount.ValueInt64()) + model.DHCPRelayMaxSize = int(dhcpRelay.MaxSize.ValueInt64()) + model.DHCPRelayPort = int(dhcpRelay.Port.ValueInt64()) + } + + model.ArpCacheBaseReachable = int(d.ArpCacheBaseReachable.ValueInt64()) + model.ArpCacheTimeout = d.ArpCacheTimeout.ValueString() + model.BroadcastPing = d.BroadcastPing.ValueBool() + model.DHCPDHostfileUpdate = d.DhcpdHostfileUpdate.ValueBool() + model.DHCPDUseDNSmasq = d.DhcpdUseDnsmasq.ValueBool() + model.DNSmasqAllServers = d.DnsmasqAllServers.ValueBool() + model.EchoServer = d.EchoServer.ValueString() + model.FtpModule = d.FtpModule.ValueBool() + model.GreModule = d.GreModule.ValueBool() + model.H323Module = d.H323Module.ValueBool() + model.PptpModule = d.PptpModule.ValueBool() + model.SipModule = d.SipModule.ValueBool() + model.TFTPModule = d.TftpModule.ValueBool() + model.ICMPTimeout = int(d.IcmpTimeout.ValueInt64()) + model.LldpEnableAll = d.LldpEnableAll.ValueBool() + model.MssClamp = d.MssClamp.ValueString() + model.MssClampMss = int(d.MssClampMss.ValueInt64()) + model.OffloadAccounting = d.OffloadAccounting.ValueBool() + model.OffloadL2Blocking = d.OffloadL2Blocking.ValueBool() + model.OffloadSch = d.OffloadSch.ValueBool() + model.OtherTimeout = int(d.OtherTimeout.ValueInt64()) + model.TimeoutSettingPreference = d.TimeoutSettingPreference.ValueString() + model.ReceiveRedirects = d.ReceiveRedirects.ValueBool() + model.SendRedirects = d.SendRedirects.ValueBool() + model.SynCookies = d.SynCookies.ValueBool() + model.UDPOtherTimeout = int(d.UdpOtherTimeout.ValueInt64()) + model.UDPStreamTimeout = int(d.UdpStreamTimeout.ValueInt64()) + model.UnbindWANMonitors = d.UnbindWanMonitors.ValueBool() return model, diags } -func (d *usgModel) Merge(other interface{}) diag.Diagnostics { +func (d *usgModel) Merge(ctx context.Context, other interface{}) diag.Diagnostics { diags := diag.Diagnostics{} model, ok := other.(*unifi.SettingUsg) @@ -62,6 +359,78 @@ func (d *usgModel) Merge(other interface{}) diag.Diagnostics { d.ID = types.StringValue(model.ID) d.MulticastDnsEnabled = types.BoolValue(model.MdnsEnabled) + // Set Geo IP filtering attributes + geoIPFiltering := &GeoIPFilteringModel{ + Enabled: types.BoolValue(model.GeoIPFilteringEnabled), + Block: types.StringValue(model.GeoIPFilteringBlock), + TrafficDirection: types.StringValue(model.GeoIPFilteringTrafficDirection), + } + + countries, diags := utils.StringToListElements(ctx, model.GeoIPFilteringCountries) + if diags.HasError() { + return diags + } + geoIPFiltering.Countries = countries + + // Create object value from attributes + geoIPObject, diags := types.ObjectValueFrom(ctx, geoIPFiltering.AttributeTypes(), geoIPFiltering) + if diags.HasError() { + return diags + } + d.GeoIPFiltering = geoIPObject + + // Set UPNP attributes + upnp := &UpnpModel{ + Enabled: types.BoolValue(model.UpnpEnabled), + NatPmpEnabled: types.BoolValue(model.UpnpNATPmpEnabled), + SecureMode: types.BoolValue(model.UpnpSecureMode), + WANInterface: types.StringValue(model.UpnpWANInterface), + } + + // Create object value from attributes + upnpObject, diags := types.ObjectValueFrom(ctx, upnp.AttributeTypes(), upnp) + if diags.HasError() { + return diags + } + d.Upnp = upnpObject + + // Convert DNS Verification settings + dnsVerificationModel := DNSVerificationModel{ + Domain: types.StringValue(model.DNSVerification.Domain), + PrimaryDNSServer: types.StringValue(model.DNSVerification.PrimaryDNSServer), + SecondaryDNSServer: types.StringValue(model.DNSVerification.SecondaryDNSServer), + SettingPreference: types.StringValue(model.DNSVerification.SettingPreference), + } + dnsVerificationObj, dnsVerificationObjDiags := types.ObjectValueFrom(ctx, dnsVerificationModel.AttributeTypes(), &dnsVerificationModel) + diags.Append(dnsVerificationObjDiags...) + + d.DnsVerification = dnsVerificationObj + // Convert TCP Timeout settings + tcpTimeoutModel := TCPTimeoutModel{ + CloseTimeout: types.Int64Value(int64(model.TCPCloseTimeout)), + CloseWaitTimeout: types.Int64Value(int64(model.TCPCloseWaitTimeout)), + EstablishedTimeout: types.Int64Value(int64(model.TCPEstablishedTimeout)), + FinWaitTimeout: types.Int64Value(int64(model.TCPFinWaitTimeout)), + LastAckTimeout: types.Int64Value(int64(model.TCPLastAckTimeout)), + SynRecvTimeout: types.Int64Value(int64(model.TCPSynRecvTimeout)), + SynSentTimeout: types.Int64Value(int64(model.TCPSynSentTimeout)), + TimeWaitTimeout: types.Int64Value(int64(model.TCPTimeWaitTimeout)), + } + + tcpTimeoutObj, tcpTimeoutObjDiags := types.ObjectValueFrom(ctx, tcpTimeoutModel.AttributeTypes(), &tcpTimeoutModel) + diags.Append(tcpTimeoutObjDiags...) + d.TcpTimeouts = tcpTimeoutObj + + // Convert DHCP Relay settings + dhcpRelayModel := DHCPRelayModel{ + AgentsPackets: types.StringValue(model.DHCPRelayAgentsPackets), + HopCount: types.Int64Value(int64(model.DHCPRelayHopCount)), + MaxSize: types.Int64Value(int64(model.DHCPRelayMaxSize)), + Port: types.Int64Value(int64(model.DHCPRelayPort)), + } + + // TODO deprecated + // Extract non-empty DHCP relay servers dhcpRelay := []string{} for _, s := range []string{ @@ -78,64 +447,772 @@ func (d *usgModel) Merge(other interface{}) diag.Diagnostics { } // Set the DHCP relay servers list - dhcpRelayServers, diags := types.ListValueFrom(context.Background(), types.StringType, dhcpRelay) + dhcpRelayServers, diags := types.ListValueFrom(ctx, types.StringType, dhcpRelay) if diags.HasError() { return diags } d.DhcpRelayServers = dhcpRelayServers + // TODO end of deprecated + dhcpRelayObj, dhcpRelayObjDiags := types.ObjectValueFrom(ctx, dhcpRelayModel.AttributeTypes(), &dhcpRelayModel) + diags.Append(dhcpRelayObjDiags...) + d.DhcpRelay = dhcpRelayObj + // Set all flat attributes + d.ArpCacheBaseReachable = types.Int64Value(int64(model.ArpCacheBaseReachable)) + d.ArpCacheTimeout = types.StringValue(model.ArpCacheTimeout) + d.BroadcastPing = types.BoolValue(model.BroadcastPing) + d.DhcpdHostfileUpdate = types.BoolValue(model.DHCPDHostfileUpdate) + d.DhcpdUseDnsmasq = types.BoolValue(model.DHCPDUseDNSmasq) + d.DnsmasqAllServers = types.BoolValue(model.DNSmasqAllServers) + d.EchoServer = types.StringValue(model.EchoServer) + d.FtpModule = types.BoolValue(model.FtpModule) + d.GreModule = types.BoolValue(model.GreModule) + d.H323Module = types.BoolValue(model.H323Module) + d.PptpModule = types.BoolValue(model.PptpModule) + d.SipModule = types.BoolValue(model.SipModule) + d.TftpModule = types.BoolValue(model.TFTPModule) + d.IcmpTimeout = types.Int64Value(int64(model.ICMPTimeout)) + d.LldpEnableAll = types.BoolValue(model.LldpEnableAll) + d.MssClamp = types.StringValue(model.MssClamp) + d.MssClampMss = types.Int64Value(int64(model.MssClampMss)) + d.OffloadAccounting = types.BoolValue(model.OffloadAccounting) + d.OffloadL2Blocking = types.BoolValue(model.OffloadL2Blocking) + d.OffloadSch = types.BoolValue(model.OffloadSch) + d.OtherTimeout = types.Int64Value(int64(model.OtherTimeout)) + d.TimeoutSettingPreference = types.StringValue(model.TimeoutSettingPreference) + d.ReceiveRedirects = types.BoolValue(model.ReceiveRedirects) + d.SendRedirects = types.BoolValue(model.SendRedirects) + d.SynCookies = types.BoolValue(model.SynCookies) + d.UdpOtherTimeout = types.Int64Value(int64(model.UDPOtherTimeout)) + d.UdpStreamTimeout = types.Int64Value(int64(model.UDPStreamTimeout)) + d.UnbindWanMonitors = types.BoolValue(model.UnbindWANMonitors) return diags } -var ( - _ base.ResourceModel = &usgModel{} - _ resource.Resource = &usgResource{} - _ resource.ResourceWithConfigure = &usgResource{} - _ resource.ResourceWithImportState = &usgResource{} - _ resource.ResourceWithModifyPlan = &usgResource{} -) - -type usgResource struct { - *BaseSettingResource[*usgModel] -} - -func (r *usgResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { - resp.Diagnostics.Append(r.RequireMaxVersionForPath("7.0", path.Root("multicast_dns_enabled"), req.Config)...) -} - func (r *usgResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "The `unifi_setting_usg` resource manages advanced settings for UniFi Security Gateways (USG) and UniFi Dream Machines (UDM/UDM-Pro).\n\n" + "This resource allows you to configure gateway-specific features including:\n" + - " * Multicast DNS (mDNS) for service discovery\n" + - " * DHCP relay for forwarding DHCP requests to external servers\n\n" + - "These settings are particularly useful for:\n" + - " * Enabling device discovery across VLANs (using mDNS)\n" + - " * Centralizing DHCP management in enterprise environments\n" + - " * Integration with existing network infrastructure\n\n" + - "Note: Some settings may not be available on all controller versions. For example, multicast_dns_enabled is not supported on UniFi OS v7+.", + " * Multicast DNS (mDNS) for cross-VLAN service discovery\n" + + " * DHCP relay for forwarding DHCP requests to external servers\n" + + " * Geo IP filtering for country-based traffic control\n" + + " * UPNP/NAT-PMP for automatic port forwarding\n" + + " * Protocol helpers for FTP, GRE, H323, PPTP, SIP, and TFTP\n" + + " * TCP/UDP timeout settings for connection tracking\n" + + " * Security features like SYN cookies and ICMP redirect controls\n" + + " * MSS clamping for optimizing MTU issues\n\n" + + "Note: Some settings may not be available on all controller versions. For example, multicast_dns_enabled is not supported on UniFi OS v7+. Changes to certain attributes may not be reflected in the plan unless explicitly modified in the configuration.", Attributes: map[string]schema.Attribute{ "id": base.ID(), "site": base.SiteAttribute(), "multicast_dns_enabled": schema.BoolAttribute{ MarkdownDescription: "Enable multicast DNS (mDNS/Bonjour/Avahi) forwarding across VLANs. This allows devices to discover services " + - "(like printers, Chromecasts, etc.) even when they are on different networks. Note: Not supported on UniFi OS v7+.", + "(like printers, Chromecasts, Apple devices, etc.) even when they are on different networks or VLANs. " + + "When enabled, the gateway will forward mDNS packets between networks, facilitating cross-VLAN service discovery. " + + "Note: This setting is not supported on UniFi OS v7+ as it has been replaced by mDNS settings in the network configuration.", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, "dhcp_relay_servers": schema.ListAttribute{ MarkdownDescription: "List of up to 5 DHCP relay servers (specified by IP address) that will receive forwarded DHCP requests. " + - "This is useful when you want to use external DHCP servers instead of the built-in DHCP server. " + - "Example: ['192.168.1.5', '192.168.2.5']", - ElementType: types.StringType, - Optional: true, - Computed: true, - Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + "This is useful when you want to use external DHCP servers instead of the built-in DHCP server on the USG/UDM. " + + "When configured, the gateway will forward DHCP discovery packets from clients to these external servers, allowing " + + "centralized IP address management across multiple networks. " + + "Example: `['192.168.1.5', '192.168.2.5']`", + DeprecationMessage: "This attribute is deprecated and will be removed in a future release. `dhcp_relay.servers` attribute will be introduced as a replacement.", + ElementType: types.StringType, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknown(), + }, + Default: utils.DefaultEmptyList(types.StringType), Validators: []validator.List{ listvalidator.SizeAtMost(5), listvalidator.ValueStringsAre(validators.IPv4()), }, }, + "dhcp_relay": schema.SingleNestedAttribute{ + MarkdownDescription: "Advanced DHCP relay configuration settings. Controls how the gateway forwards DHCP requests to external servers " + + "and manages DHCP relay agent behavior. Use this block to fine-tune DHCP relay functionality beyond simply specifying relay servers.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "agents_packets": schema.StringAttribute{ + MarkdownDescription: "Specifies how to handle DHCP relay agent information in packets. Valid values are:\n" + + " * `append` - Add relay agent information to packets that may already contain it\n" + + " * `discard` - Drop packets that already contain relay agent information\n" + + " * `forward` - Forward packets regardless of relay agent information\n" + + " * `replace` - Replace existing relay agent information with the gateway's information", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("append", "discard", "forward", "replace"), + }, + }, + "hop_count": schema.Int64Attribute{ + MarkdownDescription: "Maximum number of relay agents that can forward the DHCP packet before it is discarded. " + + "This prevents DHCP packets from being forwarded indefinitely in complex network topologies. " + + "Valid values range from 1 to 255, with lower values recommended for simpler networks.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + int64validator.Between(1, 255), + }, + }, + "max_size": schema.Int64Attribute{ + MarkdownDescription: "Maximum size (in bytes) of DHCP relay packets that will be forwarded. " + + "Packets exceeding this size will be truncated or dropped. Valid values range from 64 to 1400 bytes. " + + "The default is typically sufficient for most DHCP implementations, but may need adjustment if using " + + "extensive DHCP options or vendor-specific information.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + int64validator.Between(64, 1400), + }, + }, + "port": schema.Int64Attribute{ + MarkdownDescription: "UDP port number for the DHCP relay service to listen on. The standard DHCP server port is 67, " + + "but this can be customized if needed for specific network configurations. Valid values range from 1 to 65535. " + + "Ensure this doesn't conflict with other services running on the gateway.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + int64validator.Between(1, 65535), + }, + }, + }, + }, + "geo_ip_filtering": schema.SingleNestedAttribute{ + MarkdownDescription: "Geographic IP filtering configuration that allows blocking or allowing traffic based on country of origin. " + + "This feature uses IP geolocation databases to identify the country associated with IP addresses and apply filtering rules. " + + "Useful for implementing country-specific access policies or blocking traffic from high-risk regions. Requires controller version 7.0 or later.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.Object{ + validators.RequiredTogetherIf(path.MatchRoot("enabled"), types.BoolValue(true), path.MatchRoot("countries")), + }, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + MarkdownDescription: "Enable geographic IP filtering. When enabled, traffic from specified countries will be blocked or allowed " + + "according to the configured rules. When set to `true`, you must also specify the `countries` list. " + + "Setting this to `false` disables all country-based filtering regardless of other settings.", + Required: true, + }, + "block": schema.StringAttribute{ + MarkdownDescription: "Specifies whether the selected countries should be blocked or allowed. Valid values are:\n" + + " * `block` (default) - Traffic from the specified countries will be blocked, while traffic from all other countries will be allowed\n" + + " * `allow` - Only traffic from the specified countries will be allowed, while traffic from all other countries will be blocked\n\n" + + "This setting effectively determines whether the `countries` list functions as a blocklist or an allowlist.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("block"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("block", "allow"), + }, + }, + "countries": schema.ListAttribute{ + MarkdownDescription: "List of two-letter ISO 3166-1 alpha-2 country codes to block or allow, depending on the `block` setting. " + + "Must contain at least one country code when geo IP filtering is enabled. Country codes are case-insensitive but are typically " + + "written in uppercase.\n\n" + + "Examples:\n" + + " * `['US', 'CA', 'MX']` - United States, Canada, and Mexico\n" + + " * `['CN', 'RU', 'IR']` - China, Russia, and Iran\n" + + " * `['GB', 'DE', 'FR']` - United Kingdom, Germany, and France", + Optional: true, + ElementType: types.StringType, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.ValueStringsAre(validators.CountryCodeAlpha2()), + }, + }, + "traffic_direction": schema.StringAttribute{ + MarkdownDescription: "Specifies which traffic direction the geo IP filtering applies to. Valid values are:\n" + + " * `both` (default) - Filters traffic in both directions (incoming and outgoing)\n" + + " * `ingress` - Filters only incoming traffic (from WAN to LAN)\n" + + " * `egress` - Filters only outgoing traffic (from LAN to WAN)\n\n" + + "This setting is useful for creating more granular filtering policies. For example, you might want to block incoming traffic " + + "from certain countries while still allowing outgoing connections to those same countries.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("both"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("both", "ingress", "egress"), + }, + }, + }, + }, + "upnp": schema.SingleNestedAttribute{ + MarkdownDescription: "UPNP (Universal Plug and Play) configuration settings. UPNP allows compatible applications and devices to automatically " + + "configure port forwarding rules on the gateway without manual intervention. This is commonly used by gaming consoles, " + + "media servers, VoIP applications, and other network services that require incoming connections.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "enabled": schema.BoolAttribute{ + MarkdownDescription: "Enable UPNP functionality. When enabled, applications and devices on the local network can automatically " + + "request port forwarding rules from the gateway without manual configuration. This simplifies the use of applications " + + "that require inbound connections, but may present security risks if not properly configured with `secure_mode`.", + Required: true, + }, + "nat_pmp_enabled": schema.BoolAttribute{ + MarkdownDescription: "Enable NAT-PMP (NAT Port Mapping Protocol) support alongside UPNP. NAT-PMP is " + + "Apple's alternative to UPNP, providing similar automatic port mapping capabilities. When enabled, Apple devices " + + "like Macs, iPhones, and iPads can automatically configure port forwarding for services like AirPlay, FaceTime, " + + "iMessage, and other Apple services. Defaults to `false`.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "secure_mode": schema.BoolAttribute{ + MarkdownDescription: "Enable secure mode for UPNP. In secure mode, the gateway only forwards ports " + + "to the device that specifically requested them, enhancing security. This prevents malicious applications from " + + "redirecting ports to different devices than intended. It's strongly recommended to enable this setting when using UPNP " + + "to minimize security risks. Defaults to `false`.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "wan_interface": schema.StringAttribute{ + MarkdownDescription: "Specify which WAN interface to use for UPNP service. Valid values are:\n" + + " * `WAN` (default) - Use the primary WAN interface for UPNP port forwarding\n" + + " * `WAN2` - Use the secondary WAN interface for UPNP port forwarding (if available)\n\n" + + "This setting is particularly relevant for dual-WAN setups where you may want to direct UPNP traffic through " + + "a specific WAN connection. If your gateway only has a single WAN interface, use the default `WAN` setting.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("WAN"), + Validators: []validator.String{ + stringvalidator.OneOf("WAN", "WAN2"), + }, + }, + }, + }, + // ARP Cache Configuration + "arp_cache_base_reachable": schema.Int64Attribute{ + MarkdownDescription: "The base reachable timeout (in seconds) for ARP cache entries. This controls how long the gateway considers " + + "a MAC-to-IP mapping valid without needing to refresh it. Higher values reduce network traffic but may cause stale " + + "entries if devices change IP addresses frequently.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "arp_cache_timeout": schema.StringAttribute{ + MarkdownDescription: "The timeout strategy for ARP cache entries. Valid values are:\n" + + " * `normal` - Use system default timeouts\n" + + " * `min-dhcp-lease` - Set ARP timeout to match the minimum DHCP lease time\n" + + " * `custom` - Use the custom timeout value specified in `arp_cache_base_reachable`\n\n" + + "This setting determines how long MAC-to-IP mappings are stored in the ARP cache before being refreshed.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + + // DHCP Configuration + "broadcast_ping": schema.BoolAttribute{ + MarkdownDescription: "Enable responding to broadcast ping requests (ICMP echo requests sent to the broadcast address). " + + "When enabled, the gateway will respond to pings sent to the broadcast address of the network (e.g., 192.168.1.255). " + + "This can be useful for network diagnostics but may also be used in certain denial-of-service attacks.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "dhcpd_hostfile_update": schema.BoolAttribute{ + MarkdownDescription: "Enable updating the gateway's host files with DHCP client information. When enabled, the gateway will " + + "automatically add entries to its host file for each DHCP client, allowing hostname resolution for devices " + + "that receive IP addresses via DHCP. This improves name resolution on the local network.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "dhcpd_use_dnsmasq": schema.BoolAttribute{ + MarkdownDescription: "Use dnsmasq for DHCP services instead of the default DHCP server. Dnsmasq provides integrated DNS and DHCP " + + "functionality with additional features like DNS caching, DHCP static leases, and local domain name resolution. " + + "This can improve DNS resolution performance and provide more flexible DHCP options.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "dnsmasq_all_servers": schema.BoolAttribute{ + MarkdownDescription: "When enabled, dnsmasq will query all configured DNS servers simultaneously and use the fastest response. " + + "This can improve DNS resolution speed but may increase DNS traffic. By default, dnsmasq queries servers " + + "sequentially, only trying the next server if the current one fails to respond.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + + // DNS Verification + "dns_verification": schema.SingleNestedAttribute{ + MarkdownDescription: "DNS verification settings for validating DNS responses. This feature helps detect and prevent DNS spoofing " + + "attacks by verifying DNS responses against trusted DNS servers. When configured, the gateway can compare DNS " + + "responses with those from known trusted servers to identify potential tampering or poisoning attempts. Requires controller version 8.5 or later.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.Object{ + validators.RequiredTogetherIf(path.MatchRoot("setting_preference"), types.StringValue("manual"), path.MatchRoot("primary_dns_server"), path.MatchRoot("domain")), + validators.RequiredNoneIf(path.MatchRoot("setting_preference"), types.StringValue("auto"), path.MatchRoot("primary_dns_server"), path.MatchRoot("secondary_dns_server"), path.MatchRoot("domain")), + }, + Attributes: map[string]schema.Attribute{ + "domain": schema.StringAttribute{ + MarkdownDescription: "The domain name to use for DNS verification tests. The gateway will query this domain when testing DNS " + + "server responses. This should be a reliable domain that is unlikely to change frequently. " + + "Required when `setting_preference` is set to `manual`.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "primary_dns_server": schema.StringAttribute{ + MarkdownDescription: "The IP address of the primary trusted DNS server to use for verification. DNS responses will be compared " + + "against responses from this server to detect potential DNS spoofing. Required when `setting_preference` is " + + "set to `manual`. Must be a valid IPv4 address.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validators.IPv4(), + }, + }, + "secondary_dns_server": schema.StringAttribute{ + MarkdownDescription: "The IP address of the secondary trusted DNS server to use for verification. This server will be used " + + "if the primary server is unavailable. Optional even when `setting_preference` is set to `manual`. " + + "Must be a valid IPv4 address if specified.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validators.IPv4(), + }, + }, + "setting_preference": schema.StringAttribute{ + MarkdownDescription: "Determines how DNS verification servers are configured. Valid values are:\n" + + " * `auto` - The gateway will automatically select DNS servers for verification\n" + + " * `manual` - Use the manually specified `primary_dns_server` and optionally `secondary_dns_server`\n\n" + + "When set to `manual`, you must also specify `primary_dns_server` and `domain` values.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("auto", "manual"), + }, + }, + }, + }, + + // Network Tools + "echo_server": schema.StringAttribute{ + MarkdownDescription: "The hostname or IP address of a server to use for network echo tests. Echo tests send packets to this server " + + "and measure response times to evaluate network connectivity and performance. This can be used for network " + + "diagnostics and monitoring.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + + // Protocol Modules + "ftp_module": schema.BoolAttribute{ + MarkdownDescription: "Enable the FTP (File Transfer Protocol) helper module. This module allows the gateway to properly handle " + + "FTP connections through NAT by tracking the control channel and dynamically opening required data ports. " + + "Without this helper, passive FTP connections may fail when clients are behind NAT.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "gre_module": schema.BoolAttribute{ + MarkdownDescription: "Enable the GRE (Generic Routing Encapsulation) protocol helper module. This module allows proper handling " + + "of GRE tunneling protocol through the gateway's firewall. GRE is commonly used for VPN tunnels and other " + + "encapsulation needs. Required if you plan to use PPTP VPNs (see `pptp_module`).", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "h323_module": schema.BoolAttribute{ + MarkdownDescription: "Enable the H.323 protocol helper module. H.323 is a standard for multimedia communications (audio, video, " + + "and data) over packet-based networks. This helper allows H.323-based applications like video conferencing " + + "systems to work properly through NAT by tracking connection details and opening required ports.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "pptp_module": schema.BoolAttribute{ + MarkdownDescription: "Enable the PPTP (Point-to-Point Tunneling Protocol) helper module. This module allows PPTP VPN connections " + + "to work properly through the gateway's firewall and NAT. PPTP uses GRE for tunneling, so the `gre_module` " + + "must also be enabled for PPTP to function correctly. Note that PPTP has known security vulnerabilities and " + + "more secure VPN protocols are generally recommended.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "sip_module": schema.BoolAttribute{ + MarkdownDescription: "Enable the SIP (Session Initiation Protocol) helper module. SIP is used for initiating, maintaining, and " + + "terminating real-time sessions for voice, video, and messaging applications (VoIP, video conferencing). " + + "This helper allows SIP-based applications to work correctly through NAT by tracking SIP connections and " + + "dynamically opening the necessary ports for media streams.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "tftp_module": schema.BoolAttribute{ + MarkdownDescription: "Enable the TFTP (Trivial File Transfer Protocol) helper module. This module allows TFTP connections to work " + + "properly through the gateway's firewall and NAT. TFTP is commonly used for firmware updates, configuration " + + "file transfers, and network booting of devices. The helper tracks TFTP connections and ensures return traffic " + + "is properly handled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + + // ICMP Settings + "icmp_timeout": schema.Int64Attribute{ + MarkdownDescription: "ICMP timeout in seconds for connection tracking. This controls how long the gateway maintains state " + + "information for ICMP (ping) packets in its connection tracking table. Higher values maintain ICMP connection " + + "state longer, while lower values reclaim resources more quickly but may affect some diagnostic tools.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + + // LLDP Settings + "lldp_enable_all": schema.BoolAttribute{ + MarkdownDescription: "Enable Link Layer Discovery Protocol (LLDP) on all interfaces. LLDP is a vendor-neutral protocol that " + + "allows network devices to advertise their identity, capabilities, and neighbors on a local network. When enabled, " + + "the gateway will both send and receive LLDP packets, facilitating network discovery and management tools.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + + // MSS Clamp Settings + "mss_clamp": schema.StringAttribute{ + MarkdownDescription: "TCP Maximum Segment Size (MSS) clamping mode. MSS clamping adjusts the maximum segment size of TCP packets " + + "to prevent fragmentation issues when packets traverse networks with different MTU sizes. Valid values include:\n" + + " * `auto` - Automatically determine appropriate MSS values based on interface MTUs\n" + + " * `custom` - Use the custom MSS value specified in `mss_clamp_mss`\n" + + " * `disabled` - Do not perform MSS clamping\n\n" + + "This setting is particularly important for VPN connections and networks with non-standard MTU sizes.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "mss_clamp_mss": schema.Int64Attribute{ + MarkdownDescription: "Custom TCP Maximum Segment Size (MSS) value in bytes. This value is used when `mss_clamp` is set to `custom`. " + + "The MSS value should typically be set to the path MTU minus 40 bytes (for IPv4) or minus 60 bytes (for IPv6) to account " + + "for TCP/IP header overhead. Valid values range from 100 to 9999, with common values being 1460 (for standard 1500 MTU) " + + "or 1400 (for VPN tunnels).", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Validators: []validator.Int64{ + int64validator.Between(100, 9999), + }, + }, + + // Offload Settings + "offload_accounting": schema.BoolAttribute{ + MarkdownDescription: "Enable hardware accounting offload. When enabled, the gateway will use hardware acceleration for traffic " + + "accounting functions, reducing CPU load and potentially improving throughput for high-traffic environments. " + + "This setting may not be supported on all hardware models.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "offload_l2_blocking": schema.BoolAttribute{ + MarkdownDescription: "Enable hardware offload for Layer 2 (L2) blocking functions. When enabled, the gateway will use hardware " + + "acceleration for blocking traffic at the data link layer (MAC address level), which can improve performance " + + "when implementing MAC-based filtering or isolation. This setting may not be supported on all hardware models.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "offload_sch": schema.BoolAttribute{ + MarkdownDescription: "Enable hardware scheduling offload. When enabled, the gateway will use hardware acceleration for packet " + + "scheduling functions, which can improve QoS (Quality of Service) performance and throughput for prioritized traffic. " + + "This setting may not be supported on all hardware models and may affect other hardware offload capabilities.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + + // Timeout Settings + "other_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for connection tracking of protocols other than TCP, UDP, and ICMP. This controls how long " + + "the gateway maintains state information for connections using other protocols. Higher values maintain connection state " + + "longer, while lower values reclaim resources more quickly but may affect some applications using non-standard protocols.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "timeout_setting_preference": schema.StringAttribute{ + MarkdownDescription: "Determines how connection timeout values are configured. Valid values are:\n" + + " * `auto` - The gateway will automatically determine appropriate timeout values based on system defaults\n" + + " * `manual` - Use the manually specified timeout values for various connection types\n\n" + + "When set to `manual`, you should specify values for the various timeout settings like `tcp_timeouts`, " + + "`udp_stream_timeout`, `udp_other_timeout`, `icmp_timeout`, and `other_timeout`. Requires controller version 7.0 or later.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("auto", "manual"), + }, + }, + + // TCP Settings (nested) + "tcp_timeouts": schema.SingleNestedAttribute{ + MarkdownDescription: "TCP connection timeout settings for various TCP connection states. These settings control how long the gateway " + + "maintains state information for TCP connections in different states before removing them from the connection tracking " + + "table. Proper timeout values balance resource usage with connection reliability. These settings are particularly " + + "relevant when `timeout_setting_preference` is set to `manual`.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "close_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the CLOSE state. The CLOSE state occurs when a connection is " + + "being terminated but may still have packets in transit. Lower values reclaim resources more quickly, while higher " + + "values ensure all packets are properly processed during connection termination.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "close_wait_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the CLOSE_WAIT state. The CLOSE_WAIT state occurs when the remote " + + "end has initiated connection termination, but the local application hasn't closed the connection yet. This timeout " + + "prevents resources from being held indefinitely if a local application fails to properly close its connection.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "established_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the ESTABLISHED state. This is the most important TCP timeout as it " + + "determines how long idle but established connections are maintained in the connection tracking table. Higher values " + + "(e.g., 86400 = 24 hours) are suitable for long-lived connections, while lower values conserve resources but may cause " + + "issues with applications that maintain idle connections.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "fin_wait_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the FIN_WAIT state. The FIN_WAIT states occur during the normal " + + "TCP connection termination process after a FIN packet has been sent. This timeout prevents resources from being held " + + "if the connection termination process doesn't complete properly.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "last_ack_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the LAST_ACK state. The LAST_ACK state occurs during connection " + + "termination when the remote end has sent a FIN, the local end has responded with a FIN and ACK, and is waiting for " + + "the final ACK from the remote end to complete the connection termination.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "syn_recv_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the SYN_RECV state. This state occurs during connection establishment " + + "after receiving a SYN packet and sending a SYN-ACK, but before receiving the final ACK to complete the three-way " + + "handshake. A lower timeout helps mitigate SYN flood attacks by releasing resources for incomplete connections more quickly.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "syn_sent_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the SYN_SENT state. This state occurs during connection establishment " + + "after sending a SYN packet but before receiving a SYN-ACK response. This timeout determines how long the system will " + + "wait for a response to connection attempts before giving up.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "time_wait_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for TCP connections in the TIME_WAIT state. The TIME_WAIT state occurs after a connection " + + "has been closed but is maintained to ensure any delayed packets are properly handled. The standard recommendation is " + + "2 minutes (120 seconds), but can be reduced in high-connection environments to free resources more quickly at the " + + "risk of potential connection issues if delayed packets arrive.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + }, + }, + + // Redirects + "receive_redirects": schema.BoolAttribute{ + MarkdownDescription: "Enable accepting ICMP redirect messages. ICMP redirects are messages sent by routers to inform hosts of better " + + "routes to specific destinations. When enabled, the gateway will update its routing table based on these messages. " + + "While useful for route optimization, this can potentially be exploited for man-in-the-middle attacks, so it's often " + + "disabled in security-sensitive environments.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "send_redirects": schema.BoolAttribute{ + MarkdownDescription: "Enable sending ICMP redirect messages. When enabled, the gateway will send ICMP redirect messages to hosts on the " + + "local network to inform them of better routes to specific destinations. This can help optimize network traffic but " + + "is typically only needed when the gateway has multiple interfaces on the same subnet or in complex routing scenarios.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + + // Security Settings + "syn_cookies": schema.BoolAttribute{ + MarkdownDescription: "Enable SYN cookies to protect against SYN flood attacks. SYN cookies are a technique that helps mitigate TCP SYN " + + "flood attacks by avoiding the need to track incomplete connections in a backlog queue. When enabled, the gateway can " + + "continue to establish legitimate connections even when under a SYN flood attack. This is a recommended security setting " + + "for internet-facing gateways.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + + // UDP Settings + "udp_other_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for general UDP connections. Since UDP is connectionless, this timeout determines how long the " + + "gateway maintains state information for UDP packets that don't match the criteria for stream connections. This applies " + + "to most short-lived UDP communications like DNS queries. Lower values free resources more quickly but may affect some " + + "applications that expect longer session persistence.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "udp_stream_timeout": schema.Int64Attribute{ + MarkdownDescription: "Timeout (in seconds) for UDP stream connections. This applies to UDP traffic patterns that resemble ongoing streams, " + + "such as VoIP calls, video streaming, or online gaming. The gateway identifies these based on traffic patterns and " + + "maintains state information longer than for regular UDP traffic. Higher values improve reliability for streaming " + + "applications but consume more connection tracking resources.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + + // WAN Settings + "unbind_wan_monitors": schema.BoolAttribute{ + MarkdownDescription: "Unbind WAN monitors to prevent unnecessary traffic. When enabled, the gateway will stop certain monitoring processes " + + "that periodically check WAN connectivity. This can reduce unnecessary traffic on metered connections or in environments " + + "where the monitoring traffic might trigger security alerts. However, disabling these monitors may affect the gateway's " + + "ability to detect and respond to WAN connectivity issues. Requires controller version 9.0 or later.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, }, } } @@ -155,3 +1232,31 @@ func NewUsgResource() resource.Resource { ) return r } + +var ( + _ base.ResourceModel = &usgModel{} + _ resource.Resource = &usgResource{} + _ resource.ResourceWithConfigure = &usgResource{} + _ resource.ResourceWithImportState = &usgResource{} + _ resource.ResourceWithModifyPlan = &usgResource{} + _ resource.ResourceWithConfigValidators = &usgResource{} +) + +type usgResource struct { + *BaseSettingResource[*usgModel] +} + +func (r *usgResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + validators.RequiredValueIf(path.MatchRoot("pptp_module"), types.BoolValue(true), path.MatchRoot("gre_module"), types.BoolValue(true)), + } +} + +func (r *usgResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resp.Diagnostics.Append(r.RequireMaxVersionForPath("7.0", path.Root("multicast_dns_enabled"), req.Config)...) + resp.Diagnostics.Append(r.RequireMinVersionForPath("7.0", path.Root("timeout_setting_preference"), req.Config)...) + resp.Diagnostics.Append(r.RequireMinVersionForPath("7.0", path.Root("geo_ip_filtering"), req.Config)...) + resp.Diagnostics.Append(r.RequireMinVersionForPath("7.0", path.Root("other_timeout"), req.Config)...) + resp.Diagnostics.Append(r.RequireMinVersionForPath("8.5", path.Root("dns_verification"), req.Config)...) + resp.Diagnostics.Append(r.RequireMinVersionForPath("9.0", path.Root("unbind_wan_monitors"), req.Config)...) +} diff --git a/internal/provider/validators/required_none_if.go b/internal/provider/validators/required_none_if.go index 513a782..76391c0 100644 --- a/internal/provider/validators/required_none_if.go +++ b/internal/provider/validators/required_none_if.go @@ -20,6 +20,7 @@ var ( _ datasource.ConfigValidator = &RequiredNoneIfValidator{} _ provider.ConfigValidator = &RequiredNoneIfValidator{} _ resource.ConfigValidator = &RequiredNoneIfValidator{} + _ validator.Object = &RequiredNoneIfValidator{} ) type RequiredNoneIfValidator struct { @@ -27,6 +28,10 @@ type RequiredNoneIfValidator struct { TargetExpressions path.Expressions } +func (v RequiredNoneIfValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + func (v RequiredNoneIfValidator) Description(ctx context.Context) string { return v.MarkdownDescription(ctx) } diff --git a/internal/provider/validators/required_together_if.go b/internal/provider/validators/required_together_if.go new file mode 100644 index 0000000..8b74405 --- /dev/null +++ b/internal/provider/validators/required_together_if.go @@ -0,0 +1,194 @@ +package validators + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var ( + _ datasource.ConfigValidator = &RequiredTogetherIfValidator{} + _ provider.ConfigValidator = &RequiredTogetherIfValidator{} + _ resource.ConfigValidator = &RequiredTogetherIfValidator{} + _ validator.Object = &RequiredTogetherIfValidator{} + _ validator.String = &RequiredTogetherIfValidator{} + _ validator.Bool = &RequiredTogetherIfValidator{} +) + +type RequiredTogetherIfValidator struct { + ConditionPath path.Expression + ConditionValue attr.Value + TargetExpressions path.Expressions + CheckOnlyIfSet bool // When true, only checks if the condition value is set (not null), not its actual value +} + +func (v RequiredTogetherIfValidator) ValidateBool(ctx context.Context, req validator.BoolRequest, resp *validator.BoolResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherIfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v RequiredTogetherIfValidator) MarkdownDescription(_ context.Context) string { + if v.CheckOnlyIfSet { + return fmt.Sprintf("If %s is set, these attributes must be configured together: %s", v.ConditionPath, v.TargetExpressions) + } + return fmt.Sprintf("If %s equals %s, these attributes must be configured together: %s", v.ConditionPath, v.ConditionValue, v.TargetExpressions) +} + +func (v RequiredTogetherIfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherIfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherIfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherIfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredTogetherIfValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + resp.Diagnostics.Append(v.Validate(ctx, req.Config)...) +} + +func (v RequiredTogetherIfValidator) shouldValidate(ctx context.Context, config tfsdk.Config) bool { + // First check the condition attribute's value + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, v.ConditionPath) + if matchedPathsDiags.HasError() || len(matchedPaths) == 0 { + return false + } + + // Get the value of the condition attribute + var conditionValue attr.Value + getConditionDiags := config.GetAttribute(ctx, matchedPaths[0], &conditionValue) + if getConditionDiags.HasError() { + return false + } + + // If the condition attribute is null or unknown, skip validation + if conditionValue.IsNull() || conditionValue.IsUnknown() { + return false + } + + // Check if the condition matches + if v.CheckOnlyIfSet { + return !conditionValue.IsNull() + } + return conditionValueMatches(ctx, conditionValue, v.ConditionValue) +} + +func (v RequiredTogetherIfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { + diags := diag.Diagnostics{} + if !v.shouldValidate(ctx, config) { + return diags + } + + // Condition matched, now apply the RequiredTogether validation + configuredPaths := path.Paths{} + foundPaths := path.Paths{} + unknownPaths := path.Paths{} + + // Check that all target attributes are present + for _, expression := range v.TargetExpressions { + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, expression) + diags.Append(matchedPathsDiags...) + + // Collect all errors + if matchedPathsDiags.HasError() { + continue + } + + // Capture all matched paths + foundPaths.Append(matchedPaths...) + + for _, matchedPath := range matchedPaths { + var value attr.Value + getAttributeDiags := config.GetAttribute(ctx, matchedPath, &value) + diags.Append(getAttributeDiags...) + + // Collect all errors + if getAttributeDiags.HasError() { + continue + } + + // If value is unknown, collect the path to skip validation later + if value.IsUnknown() { + unknownPaths.Append(matchedPath) + continue + } + + // If value is null, move onto the next one + if value.IsNull() { + continue + } + + // Value is known and not null, it is configured + configuredPaths.Append(matchedPath) + } + } + + // Return early if all paths were null + //if len(configuredPaths) == 0 { + // return diags + //} + + // If there are unknown values, we cannot know if the validator should + // succeed or not + if len(unknownPaths) > 0 { + return diags + } + + // If configured paths does not equal all matched paths, then something + // was missing + if len(configuredPaths) != len(foundPaths) { + diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + foundPaths[0], + v.Description(ctx), + )) + } + + return diags +} + +// ValidateString method to implement the validator.String interface +func (v RequiredTogetherIfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + resp.Diagnostics.Append(v.Validate(ctx, req.Config)...) +} + +// RequiredTogetherIf creates a validator for string type attributes that ensures +// a set of target attributes are configured together if a condition attribute equals a specific value. +func RequiredTogetherIf(conditionPath path.Expression, conditionValue attr.Value, targetExpressions ...path.Expression) RequiredTogetherIfValidator { + return RequiredTogetherIfValidator{ + ConditionPath: conditionPath, + ConditionValue: conditionValue, + TargetExpressions: targetExpressions, + CheckOnlyIfSet: false, + } +} + +// RequiredTogetherIfSet creates a validator that ensures a set of target attributes +// are configured together if a condition attribute is set (not null), regardless of its value. +func RequiredTogetherIfSet(conditionPath path.Expression, targetExpressions ...path.Expression) RequiredTogetherIfValidator { + return RequiredTogetherIfValidator{ + ConditionPath: conditionPath, + ConditionValue: nil, // Not used for this validator + TargetExpressions: targetExpressions, + CheckOnlyIfSet: true, + } +} diff --git a/internal/provider/validators/required_together_if_test.go b/internal/provider/validators/required_together_if_test.go new file mode 100644 index 0000000..fcc2a32 --- /dev/null +++ b/internal/provider/validators/required_together_if_test.go @@ -0,0 +1,875 @@ +package validators_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" +) + +// Helper function to convert types.Int32 to tftypes.Value +func numberToTfValue(value types.Int32) tftypes.Value { + if value.IsNull() { + return tftypes.NewValue(tftypes.Number, nil) + } else if value.IsUnknown() { + return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) + } + + return tftypes.NewValue(tftypes.Number, float64(value.ValueInt32())) +} + +// Common test case structure for string conditions +type requiredTogetherIfStringConditionTestCase struct { + condition types.String + field1 types.String + field2 types.String + expectError bool + expectErrorText string +} + +// Common test case structure for bool conditions +type requiredTogetherIfBoolConditionTestCase struct { + condition types.Bool + field1 types.String + field2 types.String + expectError bool + expectErrorText string +} + +// Common test case structure for int32 conditions +type int32ConditionTestCase struct { + condition types.Int32 + field1 types.String + field2 types.String + expectError bool + expectErrorText string +} + +// Function to create a schema object with string condition +func createRequiredIfStringConditionSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.StringAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "field2": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Function to create a schema object with bool condition +func createRequiredIfBoolConditionSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.BoolAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + }, + "field2": schema.StringAttribute{ + Optional: true, + }, + }, + } +} + +// Function to create a schema object with int32 condition +func createInt32ConditionSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.Int32Attribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + }, + "field2": schema.StringAttribute{ + Optional: true, + }, + }, + } +} + +// Function to create a config with string condition +func createRequiredIfStringConditionConfig(schema schema.Schema, testCase requiredTogetherIfStringConditionTestCase) tfsdk.Config { + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.String, + "field1": tftypes.String, + "field2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": stringToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + "field2": stringToTfValue(testCase.field2), + }, + ), + } +} + +// Function to create a config with bool condition +func createRequiredIfBoolConditionConfig(schema schema.Schema, testCase requiredTogetherIfBoolConditionTestCase) tfsdk.Config { + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.Bool, + "field1": tftypes.String, + "field2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": boolToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + "field2": stringToTfValue(testCase.field2), + }, + ), + } +} + +// Function to create a config with int32 condition +func createInt32ConditionConfig(schema schema.Schema, testCase int32ConditionTestCase) tfsdk.Config { + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.Number, + "field1": tftypes.String, + "field2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": numberToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + "field2": stringToTfValue(testCase.field2), + }, + ), + } +} + +// Function to verify validation results +func verifyValidationResults(t *testing.T, response interface{}, testCase interface{}) { + t.Helper() + + var hasDiagnosticError bool + var diagnosticErrors []interface{} + + // Extract diagnostic errors based on the type + switch d := response.(type) { + case resource.ValidateConfigResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + case *resource.ValidateConfigResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + case datasource.ValidateConfigResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + case *datasource.ValidateConfigResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + case provider.ValidateConfigResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + case *provider.ValidateConfigResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + case validator.StringResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + case *validator.StringResponse: + hasDiagnosticError = d.Diagnostics.HasError() + if hasDiagnosticError { + diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]} + } + default: + t.Fatalf("Unsupported response type: %T", response) + } + + // Verify results based on test case type + switch tc := testCase.(type) { + case requiredTogetherIfStringConditionTestCase: + if tc.expectError { + assert.True(t, hasDiagnosticError) + if hasDiagnosticError { + assert.Contains(t, fmt.Sprintf("%v", diagnosticErrors[0]), tc.expectErrorText) + } + } else { + assert.False(t, hasDiagnosticError) + } + case requiredTogetherIfBoolConditionTestCase: + if tc.expectError { + assert.True(t, hasDiagnosticError) + if hasDiagnosticError { + assert.Contains(t, fmt.Sprintf("%v", diagnosticErrors[0]), tc.expectErrorText) + } + } else { + assert.False(t, hasDiagnosticError) + } + case int32ConditionTestCase: + if tc.expectError { + require.True(t, hasDiagnosticError) + if hasDiagnosticError { + assert.Contains(t, fmt.Sprintf("%v", diagnosticErrors[0]), tc.expectErrorText) + } + } else { + assert.False(t, hasDiagnosticError) + } + default: + t.Fatalf("Unsupported test case type: %T", testCase) + } +} + +func TestRequiredTogetherIf(t *testing.T) { + t.Parallel() + + testCases := map[string]requiredTogetherIfStringConditionTestCase{ + "condition-matches-all-fields-set": { + condition: types.StringValue("expected"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-matches-field1-missing": { + condition: types.StringValue("expected"), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If condition equals \"expected\", these attributes must be configured together", + }, + "condition-matches-field2-missing": { + condition: types.StringValue("expected"), + field1: types.StringValue("value1"), + field2: types.StringNull(), + expectError: true, + expectErrorText: "If condition equals \"expected\", these attributes must be configured together", + }, + "condition-matches-both-fields-missing": { + condition: types.StringValue("expected"), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: true, + }, + "condition-does-not-match": { + condition: types.StringValue("different"), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-is-null": { + condition: types.StringNull(), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-is-unknown": { + condition: types.StringUnknown(), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + schemaObject := createRequiredIfStringConditionSchema() + val := validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.StringValue("expected"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + config := createRequiredIfStringConditionConfig(schemaObject, testCase) + request := validator.StringRequest{ + ConfigValue: testCase.condition, + Config: config, + Path: path.Root("condition"), + } + + response := validator.StringResponse{} + val.ValidateString(ctx, request, &response) + + verifyValidationResults(t, response, testCase) + }) + } +} + +func TestRequiredTogetherIfWithBoolCondition(t *testing.T) { + t.Parallel() + + testCases := map[string]requiredTogetherIfBoolConditionTestCase{ + "condition-true-all-fields-set": { + condition: types.BoolValue(true), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-true-field1-missing": { + condition: types.BoolValue(true), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If condition equals true, these attributes must be configured together", + }, + "condition-false-fields-missing": { + condition: types.BoolValue(false), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + schemaObject := createRequiredIfBoolConditionSchema() + + // Create a validator with Bool condition + val := validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.BoolValue(true), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + config := createRequiredIfBoolConditionConfig(schemaObject, testCase) + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := resource.ValidateConfigResponse{} + val.ValidateResource(context.Background(), request, &response) + + verifyValidationResults(t, response, testCase) + }) + } +} + +func TestRequiredTogetherIfWithNumberCondition(t *testing.T) { + t.Parallel() + + testCases := map[string]int32ConditionTestCase{ + "condition-matches-all-fields-set": { + condition: types.Int32Value(42), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-matches-field-missing": { + condition: types.Int32Value(42), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If condition equals 42, these attributes must be configured together", + }, + "condition-does-not-match": { + condition: types.Int32Value(24), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + schemaObject := createInt32ConditionSchema() + val := validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.Int32Value(42), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + config := createInt32ConditionConfig(schemaObject, testCase) + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := resource.ValidateConfigResponse{} + val.ValidateResource(ctx, request, &response) + + verifyValidationResults(t, response, testCase) + }) + } +} + +func TestResourceRequiredTogetherIf(t *testing.T) { + t.Parallel() + + testCases := map[string]requiredTogetherIfStringConditionTestCase{ + "condition-matches-all-fields-set": { + condition: types.StringValue("expected"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-matches-field-missing": { + condition: types.StringValue("expected"), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If condition equals \"expected\", these attributes must be configured together", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + schemaObject := createRequiredIfStringConditionSchema() + + // Test the RequiredTogetherIf function + val := validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.StringValue("expected"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + config := createRequiredIfStringConditionConfig(schemaObject, testCase) + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := resource.ValidateConfigResponse{} + val.ValidateResource(ctx, request, &response) + + verifyValidationResults(t, response, testCase) + }) + } +} + +func TestDataSourceRequiredTogetherIf(t *testing.T) { + t.Parallel() + + testCases := map[string]requiredTogetherIfStringConditionTestCase{ + "condition-matches-all-fields-set": { + condition: types.StringValue("expected"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-matches-field-missing": { + condition: types.StringValue("expected"), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If condition equals \"expected\", these attributes must be configured together", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + schemaObject := createRequiredIfStringConditionSchema() + + // Test the RequiredTogetherIf function with a datasource validator + val := validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.StringValue("expected"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + config := createRequiredIfStringConditionConfig(schemaObject, testCase) + request := datasource.ValidateConfigRequest{ + Config: config, + } + + response := datasource.ValidateConfigResponse{} + val.ValidateDataSource(ctx, request, &response) + + verifyValidationResults(t, response, testCase) + }) + } +} + +func TestProviderRequiredTogetherIf(t *testing.T) { + t.Parallel() + + testCases := map[string]requiredTogetherIfStringConditionTestCase{ + "condition-matches-all-fields-set": { + condition: types.StringValue("expected"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition-matches-field-missing": { + condition: types.StringValue("expected"), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If condition equals \"expected\", these attributes must be configured together", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + schemaObject := createRequiredIfStringConditionSchema() + + // Test the RequiredTogetherIf function with a provider validator + val := validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.StringValue("expected"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + config := createRequiredIfStringConditionConfig(schemaObject, testCase) + request := provider.ValidateConfigRequest{ + Config: config, + } + + response := provider.ValidateConfigResponse{} + val.ValidateProvider(ctx, request, &response) + + verifyValidationResults(t, response, testCase) + }) + } +} + +func TestRequiredTogetherIfWithNonBooleanCondition(t *testing.T) { + t.Parallel() + + type customType struct { + Name string + } + + testStruct := customType{Name: "test"} + + testCases := map[string]struct { + condValue types.String + expectedValue any + field1 types.String + field2 types.String + expectError bool + matcherChanged bool + }{ + "custom-object-not-equal": { + condValue: types.StringValue("test"), + expectedValue: testStruct, + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, // Changed to true - we expect validation to fail when the condition matches + matcherChanged: true, + }, + "same-string-different-value": { + condValue: types.StringValue("test"), + expectedValue: "different", + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: false, + matcherChanged: false, + }, + "different-types": { + condValue: types.StringValue("123"), + expectedValue: 123, + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, // Changed to true - condition equals 123 and field1 is null, should cause validation to fail + matcherChanged: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + schemaObject := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.StringAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + }, + "field2": schema.StringAttribute{ + Optional: true, + }, + }, + } + + expectedValue := testCase.expectedValue + var v validators.RequiredTogetherIfValidator + + if testCase.matcherChanged { + // Using the string "test" as the expected condition value + v = validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.StringValue("test"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + } else { + // Normal string comparison using the string representation of expectedValue + v = validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.StringValue(fmt.Sprintf("%v", expectedValue)), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + } + + config := tfsdk.Config{ + Schema: schemaObject, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.String, + "field1": tftypes.String, + "field2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": stringToTfValue(testCase.condValue), + "field1": stringToTfValue(testCase.field1), + "field2": stringToTfValue(testCase.field2), + }, + ), + } + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := resource.ValidateConfigResponse{} + v.ValidateResource(ctx, request, &response) + + if testCase.expectError { + assert.True(t, response.Diagnostics.HasError()) + } else { + assert.False(t, response.Diagnostics.HasError()) + } + }) + } +} + +func TestRequiredTogetherIfWithUnknownTargetPaths(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + condition types.String + field1 types.String + field2 types.String + fieldPath string // Path that will be used in the validator but doesn't exist in schema + expectError bool + expectErrorText string + }{ + "unknown-path-condition-matches": { + condition: types.StringValue("expected"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + fieldPath: "unknown_field", + expectError: true, // Changed to true since the validator errors when a path doesn't exist + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + schemaObject := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.StringAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + }, + "field2": schema.StringAttribute{ + Optional: true, + }, + }, + } + + val := validators.RequiredTogetherIf( + path.MatchRoot("condition"), + types.StringValue("expected"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + path.MatchRoot(testCase.fieldPath), + ) + + config := tfsdk.Config{ + Schema: schemaObject, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.String, + "field1": tftypes.String, + "field2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": stringToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + "field2": stringToTfValue(testCase.field2), + }, + ), + } + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := resource.ValidateConfigResponse{} + val.ValidateResource(ctx, request, &response) + + if testCase.expectError { + assert.True(t, response.Diagnostics.HasError()) + assert.Contains(t, response.Diagnostics.Errors()[0].Detail(), testCase.expectErrorText) + } else { + assert.False(t, response.Diagnostics.HasError()) + } + }) + } +} + +func TestRequiredTogetherIfSet(t *testing.T) { + schema := createRequiredIfStringConditionSchema() + testCases := map[string]requiredTogetherIfStringConditionTestCase{ + "No condition value specified": { + condition: types.StringNull(), + field1: types.StringValue("value1"), + field2: types.StringNull(), + expectError: false, + }, + "Unknown condition value": { + condition: types.StringUnknown(), + field1: types.StringValue("value1"), + field2: types.StringNull(), + expectError: false, + }, + "Condition set, both fields set": { + condition: types.StringValue("any_value"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "Condition set, neither field set": { + condition: types.StringValue("any_value"), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: true, + }, + "Condition set, only field1 set": { + condition: types.StringValue("any_value"), + field1: types.StringValue("value1"), + field2: types.StringNull(), + expectError: true, + expectErrorText: "If condition is set, these attributes must be configured together: [field1,field2]", + }, + "Condition set, only field2 set": { + condition: types.StringValue("any_value"), + field1: types.StringNull(), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If condition is set, these attributes must be configured together: [field1,field2]", + }, + "Condition set, field1 set, field2 unknown": { + condition: types.StringValue("any_value"), + field1: types.StringValue("value1"), + field2: types.StringUnknown(), + expectError: false, + }, + "Condition set, field1 unknown, field2 set": { + condition: types.StringValue("any_value"), + field1: types.StringUnknown(), + field2: types.StringValue("value2"), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + config := createRequiredIfStringConditionConfig(schema, testCase) + v := validators.RequiredTogetherIfSet( + path.MatchRoot("condition"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + request := resource.ValidateConfigRequest{ + Config: config, + } + response := &resource.ValidateConfigResponse{} + + v.ValidateResource(context.Background(), request, response) + verifyValidationResults(t, response, testCase) + }) + } +} diff --git a/internal/provider/validators/required_value_if.go b/internal/provider/validators/required_value_if.go new file mode 100644 index 0000000..e56e9ae --- /dev/null +++ b/internal/provider/validators/required_value_if.go @@ -0,0 +1,126 @@ +package validators + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var ( + _ datasource.ConfigValidator = &RequiredValueIfValidator{} + _ provider.ConfigValidator = &RequiredValueIfValidator{} + _ resource.ConfigValidator = &RequiredValueIfValidator{} +) + +// RequiredValueIfValidator validates that if a condition attribute is set to a specific value, +// then a target attribute must be set to a specific value. +type RequiredValueIfValidator struct { + ConditionPath path.Expression + ConditionValue attr.Value + TargetPath path.Expression + TargetValue attr.Value +} + +func (v RequiredValueIfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v RequiredValueIfValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("If %s equals %v, then %s must equal %v", v.ConditionPath, v.ConditionValue, v.TargetPath, v.TargetValue) +} + +func (v RequiredValueIfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredValueIfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredValueIfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredValueIfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredValueIfValidator) shouldValidate(ctx context.Context, config tfsdk.Config) bool { + // First check the condition attribute's value + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, v.ConditionPath) + if matchedPathsDiags.HasError() || len(matchedPaths) == 0 { + return false + } + + // Get the value of the condition attribute + var conditionValue attr.Value + getConditionDiags := config.GetAttribute(ctx, matchedPaths[0], &conditionValue) + if getConditionDiags.HasError() { + return false + } + + // If the condition attribute is null or unknown, skip validation + if conditionValue.IsNull() || conditionValue.IsUnknown() { + return false + } + + // Check if the condition matches + return conditionValueMatches(ctx, conditionValue, v.ConditionValue) +} + +func (v RequiredValueIfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { + diags := diag.Diagnostics{} + if !v.shouldValidate(ctx, config) { + return diags + } + + // Condition matched, now validate that the target attribute has the required value + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, v.TargetPath) + diags.Append(matchedPathsDiags...) + if diags.HasError() || len(matchedPaths) == 0 { + return diags + } + + // Get the value of the target attribute + var targetValue attr.Value + getTargetDiags := config.GetAttribute(ctx, matchedPaths[0], &targetValue) + diags.Append(getTargetDiags...) + if diags.HasError() { + return diags + } + + // Skip validation if the target value is unknown + if targetValue.IsUnknown() { + return diags + } + + // If the target value is null or doesn't match the required value, add a diagnostic + if targetValue.IsNull() || !conditionValueMatches(ctx, targetValue, v.TargetValue) { + diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + matchedPaths[0], + fmt.Sprintf("When %s is set to %v, %s must be set to %v", v.ConditionPath, v.ConditionValue, v.TargetPath, v.TargetValue), + )) + } + + return diags +} + +// RequiredValueIf creates a validator that ensures if a condition attribute equals a specific value, +// then a target attribute must equal a specific value. +func RequiredValueIf(conditionPath path.Expression, conditionValue attr.Value, targetPath path.Expression, targetValue attr.Value) RequiredValueIfValidator { + return RequiredValueIfValidator{ + ConditionPath: conditionPath, + ConditionValue: conditionValue, + TargetPath: targetPath, + TargetValue: targetValue, + } +} diff --git a/internal/provider/validators/required_value_if_test.go b/internal/provider/validators/required_value_if_test.go new file mode 100644 index 0000000..0cda101 --- /dev/null +++ b/internal/provider/validators/required_value_if_test.go @@ -0,0 +1,201 @@ +package validators_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" +) + +// Common test case structure for string conditions +type requiredValueIfTestCase struct { + conditionValue types.String + targetValue types.String + expectError bool +} + +// Function to create a schema object for RequiredValueIf tests +func createRequiredValueIfSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition_attr": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "target_attr": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Function to create a config for RequiredValueIf tests +func createRequiredValueIfConfig(schema schema.Schema, testCase requiredValueIfTestCase) tfsdk.Config { + var conditionValue, targetValue tftypes.Value + + if testCase.conditionValue.IsNull() { + conditionValue = tftypes.NewValue(tftypes.String, nil) + } else if testCase.conditionValue.IsUnknown() { + conditionValue = tftypes.NewValue(tftypes.String, tftypes.UnknownValue) + } else { + conditionValue = tftypes.NewValue(tftypes.String, testCase.conditionValue.ValueString()) + } + + if testCase.targetValue.IsNull() { + targetValue = tftypes.NewValue(tftypes.String, nil) + } else if testCase.targetValue.IsUnknown() { + targetValue = tftypes.NewValue(tftypes.String, tftypes.UnknownValue) + } else { + targetValue = tftypes.NewValue(tftypes.String, testCase.targetValue.ValueString()) + } + + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition_attr": tftypes.String, + "target_attr": tftypes.String, + }, + }, map[string]tftypes.Value{ + "condition_attr": conditionValue, + "target_attr": targetValue, + }), + } +} + +func TestRequiredValueIf(t *testing.T) { + schema := createRequiredValueIfSchema() + testCases := map[string]requiredValueIfTestCase{ + "condition match target match": { + conditionValue: types.StringValue("active"), + targetValue: types.StringValue("enabled"), + expectError: false, + }, + "condition match target mismatch": { + conditionValue: types.StringValue("active"), + targetValue: types.StringValue("disabled"), + expectError: true, + }, + "condition match target null": { + conditionValue: types.StringValue("active"), + targetValue: types.StringNull(), + expectError: true, + }, + "condition match target unknown": { + conditionValue: types.StringValue("active"), + targetValue: types.StringUnknown(), + expectError: false, + }, + "condition mismatch": { + conditionValue: types.StringValue("inactive"), + targetValue: types.StringValue("disabled"), + expectError: false, + }, + "condition null": { + conditionValue: types.StringNull(), + targetValue: types.StringNull(), + expectError: false, + }, + "condition unknown": { + conditionValue: types.StringUnknown(), + targetValue: types.StringNull(), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + config := createRequiredValueIfConfig(schema, testCase) + validator := validators.RequiredValueIf( + path.MatchRoot("condition_attr"), + types.StringValue("active"), + path.MatchRoot("target_attr"), + types.StringValue("enabled"), + ) + diags := validator.Validate(context.Background(), config) + + if testCase.expectError { + assert.True(t, diags.HasError(), "expected error, but got none") + } else { + assert.False(t, diags.HasError(), "expected no error, but got: %v", diags) + } + }) + } +} + +func TestResourceRequiredValueIf(t *testing.T) { + schema := createRequiredValueIfSchema() + testCase := requiredValueIfTestCase{ + conditionValue: types.StringValue("active"), + targetValue: types.StringValue("disabled"), + expectError: true, + } + config := createRequiredValueIfConfig(schema, testCase) + validator := validators.RequiredValueIf( + path.MatchRoot("condition_attr"), + types.StringValue("active"), + path.MatchRoot("target_attr"), + types.StringValue("enabled"), + ) + + resp := &resource.ValidateConfigResponse{} + validator.ValidateResource(context.Background(), resource.ValidateConfigRequest{Config: config}, resp) + assert.True(t, resp.Diagnostics.HasError(), "expected error, but got none") +} + +func TestDataSourceRequiredValueIf(t *testing.T) { + schema := createRequiredValueIfSchema() + testCase := requiredValueIfTestCase{ + conditionValue: types.StringValue("active"), + targetValue: types.StringValue("disabled"), + expectError: true, + } + config := createRequiredValueIfConfig(schema, testCase) + validator := validators.RequiredValueIf( + path.MatchRoot("condition_attr"), + types.StringValue("active"), + path.MatchRoot("target_attr"), + types.StringValue("enabled"), + ) + + resp := &datasource.ValidateConfigResponse{} + validator.ValidateDataSource(context.Background(), datasource.ValidateConfigRequest{Config: config}, resp) + assert.True(t, resp.Diagnostics.HasError(), "expected error, but got none") +} + +func TestProviderRequiredValueIf(t *testing.T) { + schema := createRequiredValueIfSchema() + testCase := requiredValueIfTestCase{ + conditionValue: types.StringValue("active"), + targetValue: types.StringValue("disabled"), + expectError: true, + } + config := createRequiredValueIfConfig(schema, testCase) + validator := validators.RequiredValueIf( + path.MatchRoot("condition_attr"), + types.StringValue("active"), + path.MatchRoot("target_attr"), + types.StringValue("enabled"), + ) + + resp := &provider.ValidateConfigResponse{} + validator.ValidateProvider(context.Background(), provider.ValidateConfigRequest{Config: config}, resp) + assert.True(t, resp.Diagnostics.HasError(), "expected error, but got none") +} diff --git a/internal/utils/lists.go b/internal/utils/lists.go index 899deb1..aac02d8 100644 --- a/internal/utils/lists.go +++ b/internal/utils/lists.go @@ -3,10 +3,21 @@ package utils import ( "context" "github.com/filipowm/terraform-provider-unifi/internal/provider/base" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" "github.com/hashicorp/terraform-plugin-framework/types" ) +func DefaultEmptyList(elementType attr.Type) defaults.List { + return listdefault.StaticValue(EmptyList(elementType)) +} + +func EmptyList(elementType attr.Type) types.List { + return types.ListValueMust(elementType, []attr.Value{}) +} + func ListElementsAs(list types.List, target interface{}) diag.Diagnostics { diags := diag.Diagnostics{} if !base.IsDefined(list) { @@ -17,3 +28,32 @@ func ListElementsAs(list types.List, target interface{}) diag.Diagnostics { } return diags } + +func ListElementsToString(ctx context.Context, list types.List) (string, diag.Diagnostics) { + diags := diag.Diagnostics{} + if !base.IsDefined(list) { + return "", diags + } + if list.ElementType(ctx) == types.StringType { + var target []string + diags.Append(ListElementsAs(list, &target)...) + if diags.HasError() { + return "", diags + } + return JoinNonEmpty(target, ","), diags + } + diags.AddError("List is not a list of types.StringType", "List is not a list of strings") + return "", diags +} + +func StringToListElements(ctx context.Context, value string) (types.List, diag.Diagnostics) { + countries := SplitAndTrim(value, ",") + if len(countries) == 0 { + return types.ListNull(types.StringType), diag.Diagnostics{} + } + list, diags := types.ListValueFrom(ctx, types.StringType, countries) + if diags.HasError() { + return types.ListNull(types.StringType), diags + } + return list, diags +} diff --git a/internal/utils/strings.go b/internal/utils/strings.go index 9fedc33..9ef4155 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -3,6 +3,7 @@ package utils import ( "fmt" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -38,3 +39,35 @@ func StringSliceToSet(src []string) *schema.Set { func IsStringValueNotEmpty(s basetypes.StringValue) bool { return !s.IsUnknown() && !s.IsNull() && s.ValueString() != "" } + +// JoinNonEmpty joins non-empty strings from a slice with the specified separator. +// Empty strings in the slice are filtered out. +func JoinNonEmpty(elements []string, separator string) string { + var nonEmpty []string + for _, elem := range elements { + if elem != "" { + nonEmpty = append(nonEmpty, elem) + } + } + return strings.Join(nonEmpty, separator) +} + +// SplitAndTrim splits a string by the specified separator and trims whitespace from each element. +// Empty strings after trimming are filtered out. +func SplitAndTrim(s string, separator string) []string { + if s == "" { + return []string{} + } + + parts := strings.Split(s, separator) + var result []string + + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + + return result +}