diff --git a/.gitattributes b/.gitattributes index 5037dfa..645213a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,6 @@ * text=auto - *.go diff=golang - *.sh eol=lf - *.jar binary *.wt binary *.bson binary diff --git a/.github/workflows/acctest.yml b/.github/workflows/acctest.yml index ec8dbc1..8f3cec3 100644 --- a/.github/workflows/acctest.yml +++ b/.github/workflows/acctest.yml @@ -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 }} diff --git a/internal/provider/acctest/files/testfile.png b/internal/provider/acctest/files/testfile.png new file mode 100644 index 0000000..49cbdb5 Binary files /dev/null and b/internal/provider/acctest/files/testfile.png differ diff --git a/internal/provider/acctest/files/testfile2.jpg b/internal/provider/acctest/files/testfile2.jpg new file mode 100644 index 0000000..d6badef Binary files /dev/null and b/internal/provider/acctest/files/testfile2.jpg differ diff --git a/internal/provider/acctest/resource_portal_file_test.go b/internal/provider/acctest/resource_portal_file_test.go new file mode 100644 index 0000000..bbd99ed --- /dev/null +++ b/internal/provider/acctest/resource_portal_file_test.go @@ -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)) +} diff --git a/internal/provider/acctest/resource_setting_guest_access_test.go b/internal/provider/acctest/resource_setting_guest_access_test.go index bd2d830..f760a86 100644 --- a/internal/provider/acctest/resource_setting_guest_access_test.go +++ b/internal/provider/acctest/resource_setting_guest_access_test.go @@ -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" { diff --git a/internal/provider/portal/resource_portal_file.go b/internal/provider/portal/resource_portal_file.go new file mode 100644 index 0000000..ce6103a --- /dev/null +++ b/internal/provider/portal/resource_portal_file.go @@ -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") +} diff --git a/internal/provider/provider_v2.go b/internal/provider/provider_v2.go index 2a0c94d..8754576 100644 --- a/internal/provider/provider_v2.go +++ b/internal/provider/provider_v2.go @@ -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, diff --git a/internal/provider/settings/resource_setting_guest_access.go b/internal/provider/settings/resource_setting_guest_access.go index 3a50375..40fa878 100644 --- a/internal/provider/settings/resource_setting_guest_access.go +++ b/internal/provider/settings/resource_setting_guest_access.go @@ -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,