feat: support customizing guest portal logo and background with unifi_portal_file and unifi_setting_guest_access resources (#74)

* feat: support customizing guest portal logo and background with `unifi_portal_file` and `unifi_setting_guest_access` resources

* ci: run acceptance tests on go.mod changes

* f
This commit is contained in:
Mateusz Filipowicz
2025-03-22 17:31:30 +01:00
committed by GitHub
parent 8dd4bfcb97
commit a133383b43
9 changed files with 306 additions and 47 deletions

3
.gitattributes vendored
View File

@@ -1,9 +1,6 @@
* text=auto
*.go diff=golang
*.sh eol=lf
*.jar binary
*.wt binary
*.bson binary

View File

@@ -1,14 +1,17 @@
name: Acceptance Tests
on:
pull_request:
paths:
- "internal/**"
- "scripts/**"
- "tools/**"
- "main.go"
- "docker-compose.yaml"
- ".github/workflows/acctest.yml"
- "Makefile"
branches:
- "*"
# paths:
# - "internal/**"
# - "scripts/**"
# - "tools/**"
# - "main.go"
# - "docker-compose.yaml"
# - ".github/workflows/acctest.yml"
# - "Makefile"
# - "go.mod"
push:
branches:
- "main"
@@ -24,6 +27,8 @@ on:
- "Makefile"
schedule:
- cron: "0 13 * * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,61 @@
package acctest
import (
"context"
"fmt"
pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"path/filepath"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
)
func TestAccPortalFile_basic(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
CheckDestroy: testAccCheckPortalFileDestroy,
Steps: []resource.TestStep{
{
Config: testAccPortalFileConfig("files/testfile.png"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_portal_file.test", "site", "default"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "filename"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "content_type"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "file_size"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "md5"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "url"),
),
ConfigPlanChecks: pt.CheckResourceActions("unifi_portal_file.test", plancheck.ResourceActionCreate),
},
{
Config: testAccPortalFileConfig("files/testfile2.jpg"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_portal_file.test", "site", "default"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "filename"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "content_type"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "file_size"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "md5"),
resource.TestCheckResourceAttrSet("unifi_portal_file.test", "url"),
),
ConfigPlanChecks: pt.CheckResourceActions("unifi_portal_file.test", plancheck.ResourceActionReplace),
},
},
})
}
func testAccCheckPortalFileDestroy(s *terraform.State) error {
return pt.CheckDestroy("unifi_portal_file", func(ctx context.Context, site, id string) error {
_, err := testClient.GetPortalFile(ctx, site, id)
return err
})(s)
}
func testAccPortalFileConfig(filePath string) string {
return fmt.Sprintf(`
resource "unifi_portal_file" "test" {
file_path = %q
}
`, filepath.ToSlash(filePath))
}

View File

@@ -702,6 +702,15 @@ func TestAccSettingGuestAccess_portalCustomizationPostVersion74(t *testing.T) {
resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.logo_size", "150"),
),
},
{
Config: testAccSettingGuestAccessConfig_portalCustomizationImagesPost74(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.customized", "true"),
resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.bg_type", "image"),
resource.TestCheckResourceAttrSet("unifi_setting_guest_access.test", "portal_customization.bg_image_file_id"),
resource.TestCheckResourceAttrSet("unifi_setting_guest_access.test", "portal_customization.logo_file_id"),
),
},
},
})
}
@@ -758,7 +767,7 @@ func TestAccSettingGuestAccess_portalCustomization(t *testing.T) {
Config: testAccSettingGuestAccessConfig_basic(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.customized", "false"),
resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.%", "27"),
resource.TestCheckResourceAttr("unifi_setting_guest_access.test", "portal_customization.%", "29"),
),
},
},
@@ -1098,6 +1107,28 @@ resource "unifi_setting_guest_access" "test" {
`
}
func testAccSettingGuestAccessConfig_portalCustomizationImagesPost74() string {
return `
resource "unifi_portal_file" "logo" {
file_path = "files/testfile.png"
}
resource "unifi_portal_file" "background" {
file_path = "files/testfile2.jpg"
}
resource "unifi_setting_guest_access" "test" {
auth = "none"
portal_customization = {
customized = true
bg_type = "image"
bg_image_file_id = unifi_portal_file.background.id
logo_file_id = unifi_portal_file.logo.id
}
}
`
}
func testAccSettingGuestAccessConfig_portalCustomizationGallery() string {
return `
resource "unifi_setting_guest_access" "test" {

View File

@@ -0,0 +1,179 @@
package portal
import (
"context"
"fmt"
"github.com/filipowm/go-unifi/unifi"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
ut "github.com/filipowm/terraform-provider-unifi/internal/provider/types"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"os"
)
var (
_ resource.Resource = &portalFileResource{}
_ resource.ResourceWithConfigure = &portalFileResource{}
_ resource.ResourceWithImportState = &portalFileResource{}
_ base.Resource = &portalFileResource{}
)
type portalFileResource struct {
*base.GenericResource[*portalFileModel]
}
type portalFileModel struct {
base.Model
Filename types.String `tfsdk:"filename"`
FilePath types.String `tfsdk:"file_path"`
ContentType types.String `tfsdk:"content_type"`
FileSize types.Int64 `tfsdk:"file_size"`
MD5 types.String `tfsdk:"md5"`
URL types.String `tfsdk:"url"`
LastModified types.Int64 `tfsdk:"last_modified"`
}
func (m *portalFileModel) Merge(_ context.Context, data interface{}) diag.Diagnostics {
var diags diag.Diagnostics
portalFile, ok := data.(*unifi.PortalFile)
if !ok {
diags.AddError("Invalid data type", fmt.Sprintf("Expected *unifi.PortalFile, got: %T", data))
return diags
}
m.ID = types.StringValue(portalFile.ID)
m.Filename = types.StringValue(portalFile.Filename)
m.ContentType = types.StringValue(portalFile.ContentType)
m.FileSize = types.Int64Value(int64(portalFile.FileSize))
m.MD5 = types.StringValue(portalFile.MD5)
m.URL = types.StringValue(portalFile.URL)
m.LastModified = types.Int64Value(int64(portalFile.LastModified))
return diags
}
func (m *portalFileModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
// Not used for upload - we don't convert the model to a UniFi model
// The file path is used directly for upload
return nil, diag.Diagnostics{}
}
func NewPortalFileResource() resource.Resource {
return &portalFileResource{
GenericResource: base.NewGenericResource(
"unifi_portal_file",
func() *portalFileModel { return &portalFileModel{} },
base.ResourceFunctions{
Read: func(ctx context.Context, client *base.Client, site, id string) (interface{}, error) {
return client.GetPortalFile(ctx, site, id)
},
Create: nil, // Custom implementation in CreateWithContext
Update: nil, // Portal files cannot be updated, only replaced
Delete: func(ctx context.Context, client *base.Client, site, id string) error {
return client.DeletePortalFile(ctx, site, id)
},
},
),
}
}
func (r *portalFileResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "The `unifi_portal_file` resource manages files uploaded to the UniFi guest portal. " +
"This resource allows you to upload images that can be used in customizing " +
"the UniFi guest portal interface.\n\n" +
"**Note:** This resource uploads files to the UniFi controller. The file must exist on the local filesystem " +
"where Terraform is executed.",
Attributes: map[string]schema.Attribute{
"id": ut.ID(),
"site": ut.SiteAttribute(),
"file_path": schema.StringAttribute{
MarkdownDescription: "Path to the file on the local filesystem to upload to the UniFi controller. " +
"The file must exist and be readable.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},
"filename": schema.StringAttribute{
MarkdownDescription: "Name of the file as stored in the UniFi controller.",
Computed: true,
},
"content_type": schema.StringAttribute{
MarkdownDescription: "MIME type of the file.",
Computed: true,
},
"file_size": schema.Int64Attribute{
MarkdownDescription: "Size of the file in bytes.",
Computed: true,
},
"md5": schema.StringAttribute{
MarkdownDescription: "MD5 hash of the file content.",
Computed: true,
},
"url": schema.StringAttribute{
MarkdownDescription: "URL where the file can be accessed on the UniFi controller.",
Computed: true,
},
"last_modified": schema.Int64Attribute{
MarkdownDescription: "Timestamp when the file was last modified.",
Computed: true,
},
},
}
}
func (r *portalFileResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data portalFileModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Get file path
filePath := data.FilePath.ValueString()
if filePath == "" {
resp.Diagnostics.AddError("File path is required", "A valid file path must be provided")
return
}
// Check if file exists
_, err := os.Stat(filePath)
if err != nil {
resp.Diagnostics.AddError("Invalid file path", fmt.Sprintf("Error accessing file: %s", err))
return
}
site := r.GetClient().ResolveSite(&data)
portalFile, err := r.GetClient().UploadPortalFile(ctx, site, filePath)
if err != nil {
resp.Diagnostics.AddError("Error uploading file", fmt.Sprintf("Could not upload file: %s", err))
return
}
// Map response back to model
resp.Diagnostics.Append(data.Merge(ctx, portalFile)...)
if resp.Diagnostics.HasError() {
return
}
data.Site = types.StringValue(site)
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *portalFileResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resp.Diagnostics.AddError("Import is not supported", "The `unifi_portal_file` resource does not support import")
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/filipowm/terraform-provider-unifi/internal/provider/dns"
"github.com/filipowm/terraform-provider-unifi/internal/provider/firewall"
"github.com/filipowm/terraform-provider-unifi/internal/provider/portal"
"github.com/filipowm/terraform-provider-unifi/internal/provider/settings"
"github.com/filipowm/terraform-provider-unifi/internal/provider/utils"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
@@ -177,7 +178,7 @@ func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource
dns.NewDnsRecordResource,
firewall.NewFirewallZoneResource,
firewall.NewFirewallZonePolicyResource,
//portal.NewPortalFileResource,
portal.NewPortalFileResource,
settings.NewAutoSpeedtestResource,
settings.NewCountryResource,
settings.NewDpiResource,

View File

@@ -98,6 +98,7 @@ type portalCustomizationModel struct {
Customized types.Bool `tfsdk:"customized"`
AuthenticationText types.String `tfsdk:"authentication_text"`
BgColor types.String `tfsdk:"bg_color"`
BgImageFileId types.String `tfsdk:"bg_image_file_id"`
BgImageTile types.Bool `tfsdk:"bg_image_tile"`
BgType types.String `tfsdk:"bg_type"`
BoxColor types.String `tfsdk:"box_color"`
@@ -110,6 +111,7 @@ type portalCustomizationModel struct {
ButtonTextColor types.String `tfsdk:"button_text_color"`
Languages types.List `tfsdk:"languages"`
LinkColor types.String `tfsdk:"link_color"`
LogoFileId types.String `tfsdk:"logo_file_id"`
LogoPosition types.String `tfsdk:"logo_position"`
LogoSize types.Int32 `tfsdk:"logo_size"`
SuccessText types.String `tfsdk:"success_text"`
@@ -129,6 +131,7 @@ func (m *portalCustomizationModel) AttributeTypes() map[string]attr.Type {
"customized": types.BoolType,
"authentication_text": types.StringType,
"bg_color": types.StringType,
"bg_image_file_id": types.StringType,
"bg_image_tile": types.BoolType,
"bg_type": types.StringType,
"box_color": types.StringType,
@@ -143,6 +146,7 @@ func (m *portalCustomizationModel) AttributeTypes() map[string]attr.Type {
ElemType: types.StringType,
},
"link_color": types.StringType,
"logo_file_id": types.StringType,
"logo_position": types.StringType,
"logo_size": types.Int32Type,
"success_text": types.StringType,
@@ -478,6 +482,7 @@ func (d *guestAccessModel) AsUnifiModel(ctx context.Context) (interface{}, diag.
model.PortalCustomized = portalCustomization.Customized.ValueBool()
model.PortalCustomizedAuthenticationText = portalCustomization.AuthenticationText.ValueString()
model.PortalCustomizedBgColor = portalCustomization.BgColor.ValueString()
model.PortalCustomizedBgImageFilename = portalCustomization.BgImageFileId.ValueString()
model.PortalCustomizedBgImageTile = portalCustomization.BgImageTile.ValueBool()
model.PortalCustomizedBgType = portalCustomization.BgType.ValueString()
model.PortalCustomizedBoxColor = portalCustomization.BoxColor.ValueString()
@@ -490,6 +495,7 @@ func (d *guestAccessModel) AsUnifiModel(ctx context.Context) (interface{}, diag.
model.PortalCustomizedButtonTextColor = portalCustomization.ButtonTextColor.ValueString()
model.PortalCustomizedLanguages = languages
model.PortalCustomizedLinkColor = portalCustomization.LinkColor.ValueString()
model.PortalCustomizedLogoFilename = portalCustomization.LogoFileId.ValueString()
model.PortalCustomizedLogoPosition = portalCustomization.LogoPosition.ValueString()
model.PortalCustomizedLogoSize = int(portalCustomization.LogoSize.ValueInt32())
model.PortalCustomizedSuccessText = portalCustomization.SuccessText.ValueString()
@@ -816,6 +822,7 @@ func (d *guestAccessModel) Merge(ctx context.Context, unifiModel interface{}) di
Customized: types.BoolValue(model.PortalCustomized),
AuthenticationText: types.StringValue(model.PortalCustomizedAuthenticationText),
BgColor: types.StringValue(model.PortalCustomizedBgColor),
BgImageFileId: types.StringValue(model.PortalCustomizedBgImageFilename),
BgImageTile: types.BoolValue(model.PortalCustomizedBgImageTile),
BgType: types.StringValue(model.PortalCustomizedBgType),
BoxColor: types.StringValue(model.PortalCustomizedBoxColor),
@@ -828,6 +835,7 @@ func (d *guestAccessModel) Merge(ctx context.Context, unifiModel interface{}) di
ButtonTextColor: types.StringValue(model.PortalCustomizedButtonTextColor),
Languages: languages,
LinkColor: types.StringValue(model.PortalCustomizedLinkColor),
LogoFileId: types.StringValue(model.PortalCustomizedLogoFilename),
LogoPosition: types.StringValue(model.PortalCustomizedLogoPosition),
LogoSize: types.Int32Value(int32(model.PortalCustomizedLogoSize)),
SuccessText: types.StringValue(model.PortalCustomizedSuccessText),
@@ -880,14 +888,6 @@ func (g *guestAccessResource) ModifyPlan(_ context.Context, req resource.ModifyP
resp.Diagnostics.Append(g.RequireMinVersionForPath("7.4", path.Root("portal_customization").AtName("logo_position"), req.Config)...)
}
func requiredTogetherIfTrue(condition string, attrs ...string) validators.RequiredTogetherIfValidator {
var expressions []path.Expression
for _, attr := range attrs {
expressions = append(expressions, path.MatchRoot(attr))
}
return validators.RequiredTogetherIf(path.MatchRoot(condition), types.BoolValue(true), expressions...)
}
func requiredTogetherIfStringVal(condition, value string, attrs ...string) validators.RequiredTogetherIfValidator {
var expressions []path.Expression
for _, attr := range attrs {
@@ -904,17 +904,6 @@ func (g *guestAccessResource) ConfigValidators(_ context.Context) []resource.Con
return []resource.ConfigValidator{
// Auth validators
requiredTogetherIfStringVal("auth", "custom", "custom_ip"),
//requiredTogetherIfStringVal("auth", "facebook_wifi", "facebook_wifi.gateway_id", "facebook_wifi.gateway_name", "facebook_wifi.gateway_secret"),
// Facebook validators
// Google validators
requiredTogetherIfTrue("google.enabled", "google.client_id", "google.client_secret"),
requiredStringValueIfTrue("google.enabled", "auth", "hotspot"),
// Password validators
requiredTogetherIfTrue("password_enabled", "password"),
requiredStringValueIfTrue("password_enabled", "auth", "hotspot"),
// Payment validators
requiredTogetherIfStringVal("payment_gateway", "authorize", "authorize"),
@@ -925,21 +914,7 @@ func (g *guestAccessResource) ConfigValidators(_ context.Context) []resource.Con
requiredTogetherIfStringVal("payment_gateway", "stripe", "stripe"),
// Portal validators
//requiredTogetherIfStringVal("portal_customized_bg_type", "color", "portal_customized_bg_color"),
//requiredTogetherIfStringVal("portal_customized_bg_type", "gallery", "portal_customized_unsplash_author_name", "portal_customized_unsplash_author_username"),
//requiredTogetherIfStringVal("portal_customized_bg_type", "image", "portal_customized_bg_image_filename"),
//requiredTogetherIfTrue("portal_customized_bg_image_enabled", "portal_customized_bg_image_filename"),
//requiredTogetherIfTrue("portal_customized_logo_enabled", "portal_customized_logo_filename"),
//requiredTogetherIfTrue("portal_customized_tos_enabled", "portal_customized_tos"),
//requiredTogetherIfTrue("portal_customized_welcome_text_enabled", "portal_customized_welcome_text"),
//requiredTogetherIfTrue("portal_use_hostname", "portal_hostname"),
// RADIUS validators
//requiredTogetherIfTrue("radius_disconnect_enabled", "radius_disconnect_port"),
//requiredTogetherIfTrue("radius_enabled", "radius_auth_type", "radius_profile_id"),
// Restricted DNS validators
//requiredTogetherIfTrue("restricted_dns_enabled", "restricted_dns_servers"),
requiredTogetherIfStringVal("portal_customization.bg_type", "image", "portal_customization.bg_image_file_id"),
// Voucher validators
requiredStringValueIfTrue("voucher_enabled", "auth", "hotspot"),
@@ -1251,6 +1226,11 @@ func (g *guestAccessResource) Schema(_ context.Context, _ resource.SchemaRequest
validators.HexColor,
},
},
"bg_image_file_id": schema.StringAttribute{
MarkdownDescription: "ID of the background image portal file. File must exist in controller, use `unifi_portal_file` to manage it.",
Optional: true,
Computed: true,
},
"bg_image_tile": schema.BoolAttribute{
MarkdownDescription: "Tile the background image.",
Optional: true,
@@ -1342,6 +1322,11 @@ func (g *guestAccessResource) Schema(_ context.Context, _ resource.SchemaRequest
validators.HexColor,
},
},
"logo_file_id": schema.StringAttribute{
MarkdownDescription: "ID of the logo image portal file. File must exist in controller, use `unifi_portal_file` to manage it.",
Optional: true,
Computed: true,
},
"logo_position": schema.StringAttribute{
MarkdownDescription: "Position of the logo in the portal. Valid values are: left, center, right.",
Optional: true,