diff --git a/go.mod b/go.mod index 9291f25..e18cb68 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ go 1.23.5 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.4.0 github.com/golangci/golangci-lint v1.64.5 diff --git a/go.sum b/go.sum index 1378cf2..3a64e54 100644 --- a/go.sum +++ b/go.sum @@ -122,6 +122,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= +github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= diff --git a/internal/provider/acctest/resource_dns_record_test.go b/internal/provider/acctest/resource_dns_record_test.go index 5fb1589..2481cf3 100644 --- a/internal/provider/acctest/resource_dns_record_test.go +++ b/internal/provider/acctest/resource_dns_record_test.go @@ -162,7 +162,7 @@ func TestDNSRecord_Update(t *testing.T) { { Config: testAccDnsRecordConfig(updated), Check: testAccDnsRecordCheckAttrs(updated), - ConfigPlanChecks: pt.CheckResourceAction(testDnsRecordResourceName, plancheck.ResourceActionUpdate), + ConfigPlanChecks: pt.CheckResourceActions(testDnsRecordResourceName, plancheck.ResourceActionUpdate), }, }, }) diff --git a/internal/provider/acctest/resource_setting_country_test.go b/internal/provider/acctest/resource_setting_country_test.go new file mode 100644 index 0000000..acdf1aa --- /dev/null +++ b/internal/provider/acctest/resource_setting_country_test.go @@ -0,0 +1,69 @@ +package acctest + +import ( + "fmt" + pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "regexp" + "sync" + "testing" +) + +var settingCountryLock = &sync.Mutex{} + +func TestAccSettingCountry(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: settingCountryLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingCountryConfig("US"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_country.test", "code", "US"), + resource.TestCheckResourceAttr("unifi_setting_country.test", "code_numeric", "840"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_country.test", plancheck.ResourceActionCreate), + }, + pt.ImportStepWithSite("unifi_setting_country.test"), + { + Config: testAccSettingCountryConfig("PL"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_setting_country.test", "code", "PL"), + resource.TestCheckResourceAttr("unifi_setting_country.test", "code_numeric", "616"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_country.test", plancheck.ResourceActionUpdate), + }, + }, + }) +} + +var invalidCountryCodeErrorRegex = regexp.MustCompile("ISO 3166-1 alpha-2") +var stringLengthExactly2Regex = regexp.MustCompile("string length must be exactly 2") + +func TestAccSettingCountry_invalidCode(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + Lock: settingCountryLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingCountryConfig("WP"), + ExpectError: invalidCountryCodeErrorRegex, + }, + { + Config: testAccSettingCountryConfig("Too long"), + ExpectError: stringLengthExactly2Regex, + }, + { + Config: testAccSettingCountryConfig(""), + ExpectError: stringLengthExactly2Regex, + }, + }, + }) +} + +func testAccSettingCountryConfig(code string) string { + return fmt.Sprintf(` +resource "unifi_setting_country" "test" { + code = %q +} +`, code) +} diff --git a/internal/provider/base/base.go b/internal/provider/base/base.go index bee7859..5c8e78e 100644 --- a/internal/provider/base/base.go +++ b/internal/provider/base/base.go @@ -4,12 +4,31 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" ) type BaseData interface { SetClient(client *Client) } +type Site struct { + Site types.String `tfsdk:"site"` +} + +func NewSite(str string) Site { + return Site{ + Site: types.StringValue(str), + } +} + +func (s *Site) SetSite(site string) { + s.Site = types.StringValue(site) +} + +func (s *Site) AsString() string { + return s.Site.ValueString() +} + func ConfigureDatasource(base BaseData, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { if req.ProviderData == nil { return diff --git a/internal/provider/base/client.go b/internal/provider/base/client.go index 937923e..1405d6b 100644 --- a/internal/provider/base/client.go +++ b/internal/provider/base/client.go @@ -80,6 +80,13 @@ type Client struct { Version *version.Version } +func (c *Client) ResolveSite(site *Site) string { + if site == nil || IsEmptyString(site.Site) { + return c.Site + } + return site.AsString() +} + func CreateHttpTransport(insecure bool) http.RoundTripper { return &http.Transport{ Proxy: http.ProxyFromEnvironment, diff --git a/internal/provider/base/importer.go b/internal/provider/base/importer.go new file mode 100644 index 0000000..3a1e679 --- /dev/null +++ b/internal/provider/base/importer.go @@ -0,0 +1,36 @@ +package base + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "strings" +) + +func ImportSiteAndID(_ context.Context, d *schema.ResourceData, _ interface{}) ([]*schema.ResourceData, error) { + if id := d.Id(); strings.Contains(id, ":") { + importParts := strings.SplitN(id, ":", 2) + d.SetId(importParts[1]) + d.Set("site", importParts[0]) + } + return []*schema.ResourceData{d}, nil +} + +func ImportIDWithSite(req resource.ImportStateRequest, resp *resource.ImportStateResponse) (string, string) { + id := req.ID + if id == "" { + resp.Diagnostics.AddError("Invalid ID", "ID is required") + return "", "" + } + + if strings.Contains(id, ":") { + importParts := strings.SplitN(id, ":", 2) + if len(importParts) == 2 { + return importParts[1], importParts[0] + } + resp.Diagnostics.AddError("Invalid ID", "ID contains too many colon-separated parts. Format should be 'site:id'") + return "", "" + } + resp.Diagnostics.AddError("Invalid ID", "ID does not contain site part. Format should be 'site:id'") + return id, "" +} diff --git a/internal/utils/attribute.go b/internal/provider/base/types.go similarity index 61% rename from internal/utils/attribute.go rename to internal/provider/base/types.go index ad74336..0d1e0ea 100644 --- a/internal/utils/attribute.go +++ b/internal/provider/base/types.go @@ -1,17 +1,18 @@ -package utils +package base import ( "github.com/hashicorp/terraform-plugin-framework/attr" "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/types" ) // ID generates an attribute definition suitable for the always-present `id` attribute. func ID(desc ...string) schema.StringAttribute { a := schema.StringAttribute{ - Computed: true, Description: "The unique identifier of this resource.", + Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -24,6 +25,23 @@ func ID(desc ...string) schema.StringAttribute { return a } +func SiteAttribute(desc ...string) schema.StringAttribute { + s := schema.StringAttribute{ + MarkdownDescription: "The name of the UniFi site where this resource should be applied. If not specified, the default site will be used.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + } + + if len(desc) > 0 { + s.Description = desc[0] + } + return s +} + // ShouldBeRemoved evaluates if an attribute should be removed from the plan during update. func ShouldBeRemoved(plan attr.Value, state attr.Value, isClone bool) bool { return !IsDefined(plan) && IsDefined(state) && !isClone @@ -33,3 +51,7 @@ func ShouldBeRemoved(plan attr.Value, state attr.Value, isClone bool) bool { func IsDefined(v attr.Value) bool { return !v.IsNull() && !v.IsUnknown() } + +func IsEmptyString(s types.String) bool { + return s.IsNull() || s.IsUnknown() || s.ValueString() == "" +} diff --git a/internal/provider/device/resource_port_profile.go b/internal/provider/device/resource_port_profile.go index 89b7053..c30bbfb 100644 --- a/internal/provider/device/resource_port_profile.go +++ b/internal/provider/device/resource_port_profile.go @@ -29,7 +29,7 @@ func ResourcePortProfile() *schema.Resource { UpdateContext: resourcePortProfileUpdate, DeleteContext: resourcePortProfileDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/dns/dns_record_model.go b/internal/provider/dns/dns_record_model.go index 1944de5..9ab7e76 100644 --- a/internal/provider/dns/dns_record_model.go +++ b/internal/provider/dns/dns_record_model.go @@ -2,7 +2,7 @@ package dns import ( "github.com/filipowm/go-unifi/unifi" - "github.com/filipowm/terraform-provider-unifi/internal/utils" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -32,8 +32,8 @@ type dnsRecordsDatasourceModel struct { } var dnsRecordDatasourceAttributes = map[string]schema.Attribute{ - "id": utils.ID(), - "site_id": utils.ID("The site ID where the DNS record is located."), + "id": base.ID(), + "site_id": base.ID("The site ID where the DNS record is located."), "name": schema.StringAttribute{ Description: "DNS record name.", Computed: true, diff --git a/internal/provider/dns/resource_dns_record.go b/internal/provider/dns/resource_dns_record.go index 8a790da..e8989dd 100644 --- a/internal/provider/dns/resource_dns_record.go +++ b/internal/provider/dns/resource_dns_record.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/filipowm/terraform-provider-unifi/internal/provider/base" - "github.com/filipowm/terraform-provider-unifi/internal/utils" "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -53,8 +52,8 @@ func (d *dnsRecordResource) Schema(_ context.Context, _ resource.SchemaRequest, " * Adding TXT records for service verification\n\n", Attributes: map[string]schema.Attribute{ - "id": utils.ID(), - "site_id": utils.ID("The site ID where the DNS record is located."), + "id": base.ID(), + "site_id": base.ID("The site ID where the DNS record is located."), "name": schema.StringAttribute{ MarkdownDescription: "DNS record name.", Required: true, @@ -244,11 +243,6 @@ func (d *dnsRecordResource) ImportState(ctx context.Context, req resource.Import } d.read(ctx, &state, &resp.Diagnostics) - if resp.Diagnostics.HasError() { - return - } - d.read(ctx, &state, &resp.Diagnostics) - if resp.Diagnostics.HasError() { return } diff --git a/internal/provider/dns/resource_dynamic_dns.go b/internal/provider/dns/resource_dynamic_dns.go index dacba81..8bbde81 100644 --- a/internal/provider/dns/resource_dynamic_dns.go +++ b/internal/provider/dns/resource_dynamic_dns.go @@ -4,10 +4,8 @@ import ( "context" "errors" - "github.com/filipowm/terraform-provider-unifi/internal/provider/base" - "github.com/filipowm/terraform-provider-unifi/internal/utils" - "github.com/filipowm/go-unifi/unifi" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -31,7 +29,7 @@ func ResourceDynamicDNS() *schema.Resource { UpdateContext: resourceDynamicDNSUpdate, DeleteContext: resourceDynamicDNSDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/firewall/resource_firewall_group.go b/internal/provider/firewall/resource_firewall_group.go index 0d4152a..cfeed4b 100644 --- a/internal/provider/firewall/resource_firewall_group.go +++ b/internal/provider/firewall/resource_firewall_group.go @@ -31,7 +31,7 @@ func ResourceFirewallGroup() *schema.Resource { UpdateContext: resourceFirewallGroupUpdate, DeleteContext: resourceFirewallGroupDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/firewall/resource_firewall_rule.go b/internal/provider/firewall/resource_firewall_rule.go index 74a485a..9bc956d 100644 --- a/internal/provider/firewall/resource_firewall_rule.go +++ b/internal/provider/firewall/resource_firewall_rule.go @@ -32,7 +32,7 @@ func ResourceFirewallRule() *schema.Resource { UpdateContext: resourceFirewallRuleUpdate, DeleteContext: resourceFirewallRuleDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/network/resource_wlan.go b/internal/provider/network/resource_wlan.go index 532b8df..ce40f1e 100644 --- a/internal/provider/network/resource_wlan.go +++ b/internal/provider/network/resource_wlan.go @@ -33,7 +33,7 @@ func ResourceWLAN() *schema.Resource { UpdateContext: resourceWLANUpdate, DeleteContext: resourceWLANDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/provider_v2.go b/internal/provider/provider_v2.go index 3cc0325..db311c6 100644 --- a/internal/provider/provider_v2.go +++ b/internal/provider/provider_v2.go @@ -4,6 +4,7 @@ import ( "context" "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/filipowm/terraform-provider-unifi/internal/provider/dns" + "github.com/filipowm/terraform-provider-unifi/internal/provider/settings" "github.com/filipowm/terraform-provider-unifi/internal/utils" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -167,6 +168,7 @@ func (p *unifiProvider) Configure(ctx context.Context, req provider.ConfigureReq func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ dns.NewDnsRecordResource, + settings.NewCountryResource, } } diff --git a/internal/provider/radius/resource_account.go b/internal/provider/radius/resource_account.go index dd5e8f1..9711813 100644 --- a/internal/provider/radius/resource_account.go +++ b/internal/provider/radius/resource_account.go @@ -4,10 +4,8 @@ import ( "context" "errors" - "github.com/filipowm/terraform-provider-unifi/internal/provider/base" - "github.com/filipowm/terraform-provider-unifi/internal/utils" - "github.com/filipowm/go-unifi/unifi" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -38,7 +36,7 @@ func ResourceAccount() *schema.Resource { UpdateContext: resourceAccountUpdate, DeleteContext: resourceAccountDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/routing/resource_port_forward.go b/internal/provider/routing/resource_port_forward.go index 0c4dd0e..94127eb 100644 --- a/internal/provider/routing/resource_port_forward.go +++ b/internal/provider/routing/resource_port_forward.go @@ -28,7 +28,7 @@ func ResourcePortForward() *schema.Resource { UpdateContext: resourcePortForwardUpdate, DeleteContext: resourcePortForwardDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/routing/resource_static_route.go b/internal/provider/routing/resource_static_route.go index d5753e7..03627a8 100644 --- a/internal/provider/routing/resource_static_route.go +++ b/internal/provider/routing/resource_static_route.go @@ -28,7 +28,7 @@ func ResourceStaticRoute() *schema.Resource { UpdateContext: resourceStaticRouteUpdate, DeleteContext: resourceStaticRouteDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/settings/resource_setting_country.go b/internal/provider/settings/resource_setting_country.go new file mode 100644 index 0000000..458c780 --- /dev/null +++ b/internal/provider/settings/resource_setting_country.go @@ -0,0 +1,176 @@ +package settings + +import ( + "context" + "errors" + "github.com/biter777/countries" + "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/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type countryModel struct { + base.Site + ID types.String `tfsdk:"id"` + Code types.String `tfsdk:"code"` + CodeNumeric types.Int32 `tfsdk:"code_numeric"` +} + +func (d *countryModel) asUnifiModel() *unifi.SettingCountry { + code := countries.ByName(d.Code.ValueString()) + return &unifi.SettingCountry{ + ID: d.ID.ValueString(), + Code: int(code), + } +} + +func (d *countryModel) merge(other *unifi.SettingCountry) { + d.ID = types.StringValue(other.ID) + // UniFi uses numeric codes, so we need to convert the alpha-2 code to the numeric code, but we store both + code := countries.ByNumeric(other.Code) + d.Code = types.StringValue(code.Alpha2()) + d.CodeNumeric = types.Int32Value(int32(code)) +} + +var ( + _ resource.Resource = &countryResource{} + _ resource.ResourceWithConfigure = &countryResource{} + _ resource.ResourceWithImportState = &countryResource{} + _ base.BaseData = &countryResource{} +) + +type countryResource struct { + client *base.Client +} + +func (c *countryResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, site := base.ImportIDWithSite(req, resp) + if resp.Diagnostics.HasError() { + return + } + state := countryModel{ + ID: types.StringValue(id), + Site: base.NewSite(site), + } + c.read(ctx, site, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func NewCountryResource() resource.Resource { + return &countryResource{} +} + +func (c *countryResource) SetClient(client *base.Client) { + c.client = client +} + +func (c *countryResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + base.ConfigureResource(c, req, resp) +} + +func (c *countryResource) Metadata(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "unifi_setting_country" +} + +func (c *countryResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "The `unifi_setting_country` resource allows you to configure the country settings for your UniFi network. ", + Attributes: map[string]schema.Attribute{ + "id": base.ID(), + "site": base.SiteAttribute(), + "code": schema.StringAttribute{ + Description: "The country code to set for the UniFi site. The country code must be a valid ISO 3166-1 alpha-2 code.", + Required: true, + Validators: []validator.String{ + validators.StringLengthExactly(2), + validators.CountryCodeAlpha2(), + }, + }, + "code_numeric": schema.Int32Attribute{ + Description: "The numeric representation in ISO 3166-1 of the country code.", + Computed: true, + }, + }, + } +} + +func (c *countryResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan countryModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := plan.asUnifiModel() + site := c.client.ResolveSite(&plan.Site) + + res, err := c.client.UpdateSettingCountry(ctx, site, body) + if err != nil { + resp.Diagnostics.AddError("Error creating country settings", err.Error()) + return + } + plan.merge(res) + plan.Site.SetSite(site) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (c *countryResource) read(ctx context.Context, site string, state *countryModel, diag *diag.Diagnostics) { + res, err := c.client.GetSettingCountry(ctx, site) + + if err != nil { + if errors.Is(err, unifi.ErrNotFound) { + diag.AddError("Country settings not found", "The country settings were not found in the UniFi controller") + } else { + diag.AddError("Error reading country settings", err.Error()) + } + return + } + state.merge(res) +} + +func (c *countryResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state countryModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + site := c.client.ResolveSite(&state.Site) + c.read(ctx, site, &state, &resp.Diagnostics) + + if resp.Diagnostics.HasError() { + return + } + (&state).Site.SetSite(site) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (c *countryResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state countryModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := plan.asUnifiModel() + site := c.client.ResolveSite(&plan.Site) + + res, err := c.client.UpdateSettingCountry(ctx, site, body) + if err != nil { + resp.Diagnostics.AddError("Error updating country settings", err.Error()) + return + } + state.merge(res) + state.Site.SetSite(site) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (c *countryResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { + // Not supported +} diff --git a/internal/provider/settings/resource_setting_country_test.go b/internal/provider/settings/resource_setting_country_test.go new file mode 100644 index 0000000..c61da61 --- /dev/null +++ b/internal/provider/settings/resource_setting_country_test.go @@ -0,0 +1,59 @@ +package settings + +import ( + "github.com/filipowm/go-unifi/unifi" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestSettingCountry_ProperCountryCodeMappingFromModel(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + code string + expectedNumericCode int + }{ + {"Poland", "PL", 616}, + {"United States", "US", 840}, + {"Unknown", "WP", 0}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + model := countryModel{ + Code: types.StringValue(tc.code), + } + unifiModel := model.asUnifiModel() + assert.Equal(t, tc.expectedNumericCode, unifiModel.Code) + }) + } +} + +func TestSettingCountry_ProperCountryCodeMappingToModel(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + numericCode int + expectedCode string + }{ + {"Poland", 616, "PL"}, + {"United States", 840, "US"}, + {"Unknown", 0, "Unknown"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + unifiModel := &unifi.SettingCountry{ + Code: tc.numericCode, + } + model := countryModel{} + model.merge(unifiModel) + assert.Equal(t, tc.expectedCode, model.Code.ValueString()) + }) + } +} diff --git a/internal/provider/settings/resource_setting_mgmt.go b/internal/provider/settings/resource_setting_mgmt.go index 92b5ac2..279a248 100644 --- a/internal/provider/settings/resource_setting_mgmt.go +++ b/internal/provider/settings/resource_setting_mgmt.go @@ -5,10 +5,8 @@ import ( "errors" "fmt" - "github.com/filipowm/terraform-provider-unifi/internal/provider/base" - "github.com/filipowm/terraform-provider-unifi/internal/utils" - "github.com/filipowm/go-unifi/unifi" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -34,9 +32,10 @@ func ResourceSettingMgmt() *schema.Resource { UpdateContext: resourceSettingMgmtUpdate, DeleteContext: resourceSettingMgmtDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, + // TODO add more Schema: map[string]*schema.Schema{ "id": { Description: "The unique identifier of the management settings configuration in the UniFi controller.", diff --git a/internal/provider/settings/resource_setting_radius.go b/internal/provider/settings/resource_setting_radius.go index 94a4ba6..c8a7ceb 100644 --- a/internal/provider/settings/resource_setting_radius.go +++ b/internal/provider/settings/resource_setting_radius.go @@ -4,10 +4,8 @@ import ( "context" "errors" - "github.com/filipowm/terraform-provider-unifi/internal/provider/base" - "github.com/filipowm/terraform-provider-unifi/internal/utils" - "github.com/filipowm/go-unifi/unifi" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -31,7 +29,7 @@ func ResourceSettingRadius() *schema.Resource { UpdateContext: resourceSettingRadiusUpdate, DeleteContext: schema.NoopContext, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/settings/resource_setting_usg.go b/internal/provider/settings/resource_setting_usg.go index 76bfba2..4cda9dd 100644 --- a/internal/provider/settings/resource_setting_usg.go +++ b/internal/provider/settings/resource_setting_usg.go @@ -42,7 +42,7 @@ func ResourceSettingUsg() *schema.Resource { UpdateContext: resourceSettingUsgLocker(resourceSettingUsgUpsert), DeleteContext: schema.NoopContext, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/testing/test_helpers.go b/internal/provider/testing/test_helpers.go index b2d5bdb..91df6db 100644 --- a/internal/provider/testing/test_helpers.go +++ b/internal/provider/testing/test_helpers.go @@ -20,6 +20,21 @@ func MarkAccTest(t *testing.T) { } } +func ImportStepWithSite(name string, ignore ...string) resource.TestStep { + step := &resource.TestStep{ + ResourceName: name, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: SiteAndIDImportStateIDFunc(name), + } + + if len(ignore) > 0 { + step.ImportStateVerifyIgnore = ignore + } + + return *step +} + func ImportStep(name string, ignore ...string) resource.TestStep { step := resource.TestStep{ ResourceName: name, @@ -69,8 +84,12 @@ func CheckPlanPreApply(checks ...plancheck.PlanCheck) resource.ConfigPlanChecks } } -func CheckResourceAction(resourceAddress string, action plancheck.ResourceActionType) resource.ConfigPlanChecks { - return CheckPlanPreApply(plancheck.ExpectResourceAction(resourceAddress, action)) +func CheckResourceActions(resourceAddress string, actions ...plancheck.ResourceActionType) resource.ConfigPlanChecks { + var checks []plancheck.PlanCheck + for _, a := range actions { + checks = append(checks, plancheck.ExpectResourceAction(resourceAddress, a)) + } + return CheckPlanPreApply(checks...) } func ComposeConfig(configs ...string) string { diff --git a/internal/provider/user/resource_user.go b/internal/provider/user/resource_user.go index efb5f52..729a7fd 100644 --- a/internal/provider/user/resource_user.go +++ b/internal/provider/user/resource_user.go @@ -35,7 +35,7 @@ func ResourceUser() *schema.Resource { UpdateContext: resourceUserUpdate, DeleteContext: resourceUserDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/user/resource_user_group.go b/internal/provider/user/resource_user_group.go index 5e382df..2e95169 100644 --- a/internal/provider/user/resource_user_group.go +++ b/internal/provider/user/resource_user_group.go @@ -4,10 +4,8 @@ import ( "context" "errors" - "github.com/filipowm/terraform-provider-unifi/internal/provider/base" - "github.com/filipowm/terraform-provider-unifi/internal/utils" - "github.com/filipowm/go-unifi/unifi" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -34,7 +32,7 @@ func ResourceUserGroup() *schema.Resource { UpdateContext: resourceUserGroupUpdate, DeleteContext: resourceUserGroupDelete, Importer: &schema.ResourceImporter{ - StateContext: utils.ImportSiteAndID, + StateContext: base.ImportSiteAndID, }, Schema: map[string]*schema.Schema{ diff --git a/internal/provider/validators/country_code.go b/internal/provider/validators/country_code.go new file mode 100644 index 0000000..ad0ddd4 --- /dev/null +++ b/internal/provider/validators/country_code.go @@ -0,0 +1,39 @@ +package validators + +import ( + "context" + "github.com/biter777/countries" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func CountryCodeAlpha2() validator.String { + return countryCodeAlpha2Validator{} +} + +type countryCodeAlpha2Validator struct{} + +func (c countryCodeAlpha2Validator) Description(_ context.Context) string { + return "The country code must be a valid ISO 3166-1 alpha-2 code." +} + +func (c countryCodeAlpha2Validator) MarkdownDescription(ctx context.Context) string { + return c.Description(ctx) +} + +func (c countryCodeAlpha2Validator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + code := req.ConfigValue + if base.IsEmptyString(code) { + return + } + + codeString := code.ValueString() + if len(codeString) != 2 || countries.ByName(codeString) == countries.Unknown { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + c.Description(ctx), + codeString, + )) + } +} diff --git a/internal/provider/validators/country_code_test.go b/internal/provider/validators/country_code_test.go new file mode 100644 index 0000000..1bb1895 --- /dev/null +++ b/internal/provider/validators/country_code_test.go @@ -0,0 +1,34 @@ +package validators + +import ( + "context" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestCountryCodeValidation(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + code string + validationFailed bool + }{ + {"Poland", "PL", false}, + {"United States", "US", false}, + {"Empty", "", false}, + {"Too long", "ABC", true}, + {"Too short", "A", true}, + {"Unknown", "WP", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + v := countryCodeAlpha2Validator{} + req, resp := newStringValidatorRequestResponse(tc.code) + v.ValidateString(context.Background(), req, resp) + assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError()) + }) + } +} diff --git a/internal/provider/validators/helpers_test.go b/internal/provider/validators/helpers_test.go new file mode 100644 index 0000000..136648a --- /dev/null +++ b/internal/provider/validators/helpers_test.go @@ -0,0 +1,19 @@ +package validators + +import ( + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func newStringValidatorRequestResponse(value string) (validator.StringRequest, *validator.StringResponse) { + req := validator.StringRequest{ + ConfigValue: types.StringValue(value), + Path: path.Empty(), + } + resp := validator.StringResponse{ + Diagnostics: []diag.Diagnostic{}, + } + return req, &resp +} diff --git a/internal/provider/validators/string_length_exactly.go b/internal/provider/validators/string_length_exactly.go new file mode 100644 index 0000000..c5f2fe4 --- /dev/null +++ b/internal/provider/validators/string_length_exactly.go @@ -0,0 +1,57 @@ +package validators + +import ( + "context" + "fmt" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func StringLengthExactly(len int) validator.String { + return stringLengthExactlyValidator{len: len} +} + +type stringLengthExactlyValidator struct { + len int +} + +func (v stringLengthExactlyValidator) invalidUsageMessage() string { + return "length cannot be less than zero" +} + +func (v stringLengthExactlyValidator) Description(_ context.Context) string { + return fmt.Sprintf("string length must be exactly %d", v.len) +} + +func (v stringLengthExactlyValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v stringLengthExactlyValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if v.len < 0 { + resp.Diagnostics.Append( + validatordiag.InvalidValidatorUsageDiagnostic( + req.Path, + "StringLengthExactly", + v.invalidUsageMessage(), + ), + ) + return + } + + value := req.ConfigValue + if !base.IsDefined(value) { + return + } + val := value.ValueString() + if len(val) != v.len { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%s (length: %d)", val, len(val)), + ), + ) + } +} diff --git a/internal/provider/validators/string_length_exactly_test.go b/internal/provider/validators/string_length_exactly_test.go new file mode 100644 index 0000000..d7ed6e3 --- /dev/null +++ b/internal/provider/validators/string_length_exactly_test.go @@ -0,0 +1,35 @@ +package validators + +import ( + "context" + "fmt" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestStringLengthExactlyValidation(t *testing.T) { + t.Parallel() + + testCases := []struct { + value string + length int + validationFailed bool + }{ + {"", 0, false}, + {"", 1, true}, + {"a", 0, true}, + {"a", 1, false}, + {"a", 2, true}, + {"ab", 2, false}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("%s-expected-length-%d", tc.value, tc.length), func(t *testing.T) { + t.Parallel() + v := StringLengthExactly(tc.length) + req, resp := newStringValidatorRequestResponse(tc.value) + v.ValidateString(context.Background(), req, resp) + assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError()) + }) + } +} diff --git a/internal/utils/importer.go b/internal/utils/importer.go deleted file mode 100644 index ade775f..0000000 --- a/internal/utils/importer.go +++ /dev/null @@ -1,16 +0,0 @@ -package utils - -import ( - "context" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "strings" -) - -func ImportSiteAndID(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { - if id := d.Id(); strings.Contains(id, ":") { - importParts := strings.SplitN(id, ":", 2) - d.SetId(importParts[1]) - d.Set("site", importParts[0]) - } - return []*schema.ResourceData{d}, nil -}