diff --git a/azurerm/internal/services/web/client/client.go b/azurerm/internal/services/web/client/client.go index 6c89651665ade..d9e06b5a72beb 100644 --- a/azurerm/internal/services/web/client/client.go +++ b/azurerm/internal/services/web/client/client.go @@ -12,6 +12,7 @@ type Client struct { BaseClient *web.BaseClient CertificatesClient *web.CertificatesClient CertificatesOrderClient *web.AppServiceCertificateOrdersClient + StaticSitesClient *web.StaticSitesClient } func NewClient(o *common.ClientOptions) *Client { @@ -33,6 +34,9 @@ func NewClient(o *common.ClientOptions) *Client { certificatesOrderClient := web.NewAppServiceCertificateOrdersClientWithBaseURI(o.ResourceManagerEndpoint, o.SubscriptionId) o.ConfigureClient(&certificatesOrderClient.Client, o.ResourceManagerAuthorizer) + staticSitesClient := web.NewStaticSitesClientWithBaseURI(o.ResourceManagerEndpoint, o.SubscriptionId) + o.ConfigureClient(&staticSitesClient.Client, o.ResourceManagerAuthorizer) + return &Client{ AppServiceEnvironmentsClient: &appServiceEnvironmentsClient, AppServicePlansClient: &appServicePlansClient, @@ -40,5 +44,6 @@ func NewClient(o *common.ClientOptions) *Client { BaseClient: &baseClient, CertificatesClient: &certificatesClient, CertificatesOrderClient: &certificatesOrderClient, + StaticSitesClient: &staticSitesClient, } } diff --git a/azurerm/internal/services/web/parse/static_site.go b/azurerm/internal/services/web/parse/static_site.go new file mode 100644 index 0000000000000..0feea4bfc575f --- /dev/null +++ b/azurerm/internal/services/web/parse/static_site.go @@ -0,0 +1,33 @@ +package parse + +import ( + "fmt" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" +) + +type StaticSiteResourceID struct { + ResourceGroup string + Name string +} + +func StaticSiteID(input string) (*StaticSiteResourceID, error) { + id, err := azure.ParseAzureResourceID(input) + if err != nil { + return nil, fmt.Errorf("[ERROR] Unable to parse Static Site ID %q: %+v", input, err) + } + + staticSite := StaticSiteResourceID{ + ResourceGroup: id.ResourceGroup, + } + + if staticSite.Name, err = id.PopSegment("staticSites"); err != nil { + return nil, err + } + + if err := id.ValidateNoEmptySegments(input); err != nil { + return nil, err + } + + return &staticSite, nil +} diff --git a/azurerm/internal/services/web/registration.go b/azurerm/internal/services/web/registration.go index 60ae99de46521..48ebed1d250d9 100644 --- a/azurerm/internal/services/web/registration.go +++ b/azurerm/internal/services/web/registration.go @@ -45,5 +45,6 @@ func (r Registration) SupportedResources() map[string]*schema.Resource { "azurerm_app_service": resourceArmAppService(), "azurerm_function_app": resourceArmFunctionApp(), "azurerm_function_app_slot": resourceArmFunctionAppSlot(), + "azurerm_static_site": resourceArmStaticSite(), } } diff --git a/azurerm/internal/services/web/resource_arm_static_site.go b/azurerm/internal/services/web/resource_arm_static_site.go new file mode 100644 index 0000000000000..e902f662ce342 --- /dev/null +++ b/azurerm/internal/services/web/resource_arm_static_site.go @@ -0,0 +1,283 @@ +package web + +import ( + "fmt" + "log" + "time" + + "github.com/Azure/azure-sdk-for-go/services/web/mgmt/2019-08-01/web" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/azure" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/helpers/tf" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/web/parse" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/web/validate" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/tags" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/timeouts" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +func resourceArmStaticSite() *schema.Resource { + return &schema.Resource{ + Create: resourceArmStaticSiteCreateOrUpdate, + Read: resourceArmStaticSiteRead, + Update: resourceArmStaticSiteCreateOrUpdate, + Delete: resourceArmStaticSiteDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Update: schema.DefaultTimeout(30 * time.Minute), + Delete: schema.DefaultTimeout(30 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validate.StaticSiteName, + }, + + "resource_group_name": azure.SchemaResourceGroupName(), + + "location": azure.SchemaLocation(), + + "tags": tags.Schema(), + + "github_configuration": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "repo_token": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + "repo_url": { + Type: schema.TypeString, + Required: true, + }, + "branch": { + Type: schema.TypeString, + Required: true, + }, + "app_location": { + Type: schema.TypeString, + Required: true, + }, + "api_location": { + Type: schema.TypeString, + Optional: true, + }, + "artifact_location": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + } +} + +func resourceArmStaticSiteCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Web.StaticSitesClient + ctx, cancel := timeouts.ForCreate(meta.(*clients.Client).StopContext, d) + defer cancel() + + log.Printf("[INFO] preparing arguments for AzureRM Static Site creation.") + + name := d.Get("name").(string) + resGroup := d.Get("resource_group_name").(string) + + if d.IsNewResource() { + existing, err := client.GetStaticSite(ctx, resGroup, name) + if err != nil { + if !utils.ResponseWasNotFound(existing.Response) { + return fmt.Errorf("Error checking for presence of existing Static Site %q (Resource Group %q): %s", name, resGroup, err) + } + } + + if existing.ID != nil && *existing.ID != "" { + return tf.ImportAsExistsError("azurerm_static_site", *existing.ID) + } + } + + location := azure.NormalizeLocation(d.Get("location").(string)) + t := d.Get("tags").(map[string]interface{}) + + nameSku := "Free" + tierSku := "Free" + staticSiteSkuDescription := &web.SkuDescription{Name: &nameSku, Tier: &tierSku} + + staticSiteType := "Microsoft.Web/staticSites" + + staticSiteSourceControlRaw := d.Get("github_configuration").([]interface{}) + staticSiteSourceControl := expandStaticSiteSourceControl(staticSiteSourceControlRaw) + + siteEnvelope := web.StaticSiteARMResource{ + Sku: staticSiteSkuDescription, + Type: &staticSiteType, + StaticSite: staticSiteSourceControl, + Location: &location, + Tags: tags.Expand(t), + } + + _, err := client.CreateOrUpdateStaticSite(ctx, resGroup, name, siteEnvelope) + if err != nil { + return fmt.Errorf("Error creating Static Site %q (Resource Group %q): %s", name, resGroup, err) + } + + read, err := client.GetStaticSite(ctx, resGroup, name) + if err != nil { + return fmt.Errorf("Error retrieving Static Site %q (Resource Group %q): %s", name, resGroup, err) + } + if read.ID == nil { + return fmt.Errorf("Cannot read Static Site %q (resource group %q) ID", name, resGroup) + } + + d.SetId(*read.ID) + + return resourceArmStaticSiteRead(d, meta) +} + +func resourceArmStaticSiteRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Web.StaticSitesClient + ctx, cancel := timeouts.ForRead(meta.(*clients.Client).StopContext, d) + defer cancel() + + id, err := parse.StaticSiteID(d.Id()) + if err != nil { + return err + } + + resp, err := client.GetStaticSite(ctx, id.ResourceGroup, id.Name) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + log.Printf("[DEBUG] Static Site %q (resource group %q) was not found - removing from state", id.Name, id.ResourceGroup) + d.SetId("") + return nil + } + return fmt.Errorf("Error making Read request on AzureRM Static Site %q: %+v", id.Name, err) + } + d.Set("name", id.Name) + d.Set("resource_group_name", id.ResourceGroup) + + if location := resp.Location; location != nil { + d.Set("location", azure.NormalizeLocation(*location)) + } + + sc := flattenStaticSiteSourceControl(resp.StaticSite, d) + if err := d.Set("github_configuration", sc); err != nil { + return fmt.Errorf("Error setting `github_configuration`: %s", err) + } + + return tags.FlattenAndSet(d, resp.Tags) +} + +func resourceArmStaticSiteDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*clients.Client).Web.StaticSitesClient + ctx, cancel := timeouts.ForDelete(meta.(*clients.Client).StopContext, d) + defer cancel() + + id, err := parse.StaticSiteID(d.Id()) + if err != nil { + return err + } + + log.Printf("[DEBUG] Deleting Static Site %q (resource group %q)", id.Name, id.ResourceGroup) + + resp, err := client.DeleteStaticSite(ctx, id.ResourceGroup, id.Name) + if err != nil { + if !utils.ResponseWasNotFound(resp) { + return err + } + } + + return nil +} + +func flattenStaticSiteSourceControl(input *web.StaticSite, d *schema.ResourceData) []interface{} { + if input == nil { + log.Printf("[DEBUG] SiteSourceControlProperties is nil") + return []interface{}{} + } + + repoURL := "" + if input.RepositoryURL != nil { + repoURL = *input.RepositoryURL + } + branch := "" + if input.Branch != nil && *input.Branch != "" { + branch = *input.Branch + } + + repoToken := "" + apiLocation := "" + appLocation := "" + artifactLocation := "" + if sc, ok := d.GetOk("github_configuration"); ok { + var val []interface{} + + if v, ok := sc.([]interface{}); ok { + val = v + } + + if len(val) > 0 && val[0] != nil { + raw := val[0].(map[string]interface{}) + repoToken = raw["repo_token"].(string) + apiLocation = raw["api_location"].(string) + appLocation = raw["app_location"].(string) + artifactLocation = raw["artifact_location"].(string) + } + } + + return []interface{}{ + map[string]interface{}{ + "repo_url": repoURL, + "branch": branch, + "repo_token": repoToken, + "api_location": apiLocation, + "artifact_location": artifactLocation, + "app_location": appLocation, + }, + } +} + +func expandStaticSiteSourceControl(input []interface{}) *web.StaticSite { + if len(input) == 0 { + return nil + } + sourceControl := input[0].(map[string]interface{}) + repoURL := sourceControl["repo_url"].(string) + branch := sourceControl["branch"].(string) + repoToken := sourceControl["repo_token"].(string) + + appLocation := sourceControl["app_location"].(string) + apiLocation := "" + if v, ok := sourceControl["api_location"]; ok { + apiLocation = v.(string) + } + artifactLocation := "" + if v, ok := sourceControl["artifact_location"]; ok { + artifactLocation = v.(string) + } + + staticSite := &web.StaticSite{ + RepositoryURL: &repoURL, + Branch: &branch, + RepositoryToken: &repoToken, + BuildProperties: &web.StaticSiteBuildProperties{ + AppLocation: &appLocation, + APILocation: &apiLocation, + AppArtifactLocation: &artifactLocation, + }, + } + + return staticSite +} diff --git a/azurerm/internal/services/web/tests/helpers_test.go b/azurerm/internal/services/web/tests/helpers_test.go new file mode 100644 index 0000000000000..ff530bf8858ee --- /dev/null +++ b/azurerm/internal/services/web/tests/helpers_test.go @@ -0,0 +1,9 @@ +package tests + +import ( + "os" +) + +func skipStaticSite() bool { + return os.Getenv("ARM_TEST_GITHUB_TOKEN") == "" +} diff --git a/azurerm/internal/services/web/tests/resource_arm_static_site_test.go b/azurerm/internal/services/web/tests/resource_arm_static_site_test.go new file mode 100644 index 0000000000000..abe5e76e0db7e --- /dev/null +++ b/azurerm/internal/services/web/tests/resource_arm_static_site_test.go @@ -0,0 +1,172 @@ +package tests + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/acceptance" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/clients" + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/utils" +) + +func TestAccAzureRMStaticSite_basic(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_static_site", "test") + + if ok := skipStaticSite(); ok { + t.Skip("Skipping as `ARM_TEST_GITHUB_TOKEN` was not specified") + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMStaticSiteDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAzureRMStaticSite_basic(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMStaticSiteExists(data.ResourceName), + ), + }, + data.ImportStep( + "github_configuration.0.api_location", + "github_configuration.0.app_location", + "github_configuration.0.artifact_location", + "github_configuration.0.repo_token"), + }, + }) +} + +func TestAccAzureRMStaticSite_requiresImport(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_static_site", "test") + + if ok := skipStaticSite(); ok { + t.Skip("Skipping as `ARM_TEST_GITHUB_TOKEN` was not specified") + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + Providers: acceptance.SupportedProviders, + CheckDestroy: testCheckAzureRMStaticSiteDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAzureRMStaticSite_basic(data), + Check: resource.ComposeTestCheckFunc( + testCheckAzureRMStaticSiteExists(data.ResourceName), + ), + }, + data.RequiresImportErrorStep(testAccAzureRMStaticSite_requiresImport), + }, + }) +} + +func testCheckAzureRMStaticSiteDestroy(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Web.StaticSitesClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + for _, rs := range s.RootModule().Resources { + if rs.Type != "azurerm_static_site" { + continue + } + + name := rs.Primary.Attributes["name"] + resourceGroup := rs.Primary.Attributes["resource_group_name"] + + resp, err := client.GetStaticSite(ctx, resourceGroup, name) + + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return nil + } + return err + } + + return nil + } + + return nil +} + +func testCheckAzureRMStaticSiteExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := acceptance.AzureProvider.Meta().(*clients.Client).Web.StaticSitesClient + ctx := acceptance.AzureProvider.Meta().(*clients.Client).StopContext + + // Ensure we have enough information in state to look up in API + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + staticSiteName := rs.Primary.Attributes["name"] + resourceGroup, hasResourceGroup := rs.Primary.Attributes["resource_group_name"] + if !hasResourceGroup { + return fmt.Errorf("Bad: no resource group found in state for Static Site: %s", staticSiteName) + } + + resp, err := client.GetStaticSite(ctx, resourceGroup, staticSiteName) + if err != nil { + if utils.ResponseWasNotFound(resp.Response) { + return fmt.Errorf("Bad: Static Site %q (resource group: %q) does not exist", staticSiteName, resourceGroup) + } + + return fmt.Errorf("Bad: Get on StaticSitesClient: %+v", err) + } + + return nil + } +} + +func testAccAzureRMStaticSite_basic(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "test" { + name = "acctestRG-%d" + location = "%s" +} + +resource "azurerm_static_site" "test" { + name = "acctestSS-%d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + + github_configuration { + repo_url = "https://github.com/aristosvo/azure-static-web-app" + branch = "master" + repo_token = "%s" + + app_location = "/" + api_location = "" + artifact_location = "dist/angular-basic" + } +} +`, data.RandomInteger, data.Locations.Primary, data.RandomInteger, os.Getenv("ARM_TEST_GITHUB_TOKEN")) +} + +func testAccAzureRMStaticSite_requiresImport(data acceptance.TestData) string { + template := testAccAzureRMStaticSite_basic(data) + return fmt.Sprintf(` +%s + +resource "azurerm_static_site" "import" { + name = azurerm_static_site.test.name + location = azurerm_static_site.test.location + resource_group_name = azurerm_static_site.test.resource_group_name + + github_configuration { + repo_url = azurerm_static_site.test.github_configuration.0.repo_url + branch = azurerm_static_site.test.github_configuration.0.branch + repo_token = azurerm_static_site.test.github_configuration.0.repo_token + + app_location = azurerm_static_site.test.github_configuration.0.app_location + api_location = azurerm_static_site.test.github_configuration.0.api_location + artifact_location = azurerm_static_site.test.github_configuration.0.artifact_location + } +} +`, template) +} diff --git a/azurerm/internal/services/web/validate/static_site.go b/azurerm/internal/services/web/validate/static_site.go new file mode 100644 index 0000000000000..9677fd416d8a1 --- /dev/null +++ b/azurerm/internal/services/web/validate/static_site.go @@ -0,0 +1,34 @@ +package validate + +import ( + "fmt" + "regexp" + + "github.com/terraform-providers/terraform-provider-azurerm/azurerm/internal/services/web/parse" +) + +// StaticSiteID validates that the specified ID is a valid Static Site ID +func StaticSiteID(i interface{}, k string) (warnings []string, errors []error) { + v, ok := i.(string) + if !ok { + errors = append(errors, fmt.Errorf("expected type of %q to be string", k)) + return + } + + if _, err := parse.StaticSiteID(v); err != nil { + errors = append(errors, fmt.Errorf("Can not parse %q as a resource id: %v", k, err)) + return + } + + return warnings, errors +} + +func StaticSiteName(v interface{}, k string) (warnings []string, errors []error) { + value := v.(string) + + if matched := regexp.MustCompile(`^[0-9a-zA-Z-]{1,60}$`).Match([]byte(value)); !matched { + errors = append(errors, fmt.Errorf("%q may only contain alphanumeric characters and dashes and up to 60 characters in length", k)) + } + + return warnings, errors +} diff --git a/website/azurerm.erb b/website/azurerm.erb index 972c812fe1e34..64de57d1b48a9 100644 --- a/website/azurerm.erb +++ b/website/azurerm.erb @@ -809,6 +809,10 @@
  • azurerm_function_app_slot
  • + +
  • + azurerm_static_site +
  • diff --git a/website/docs/r/static_site.html.markdown b/website/docs/r/static_site.html.markdown new file mode 100644 index 0000000000000..c29d9dcaf6776 --- /dev/null +++ b/website/docs/r/static_site.html.markdown @@ -0,0 +1,83 @@ +--- +subcategory: "App Service (Web Apps)" +layout: "azurerm" +page_title: "Azure Resource Manager: azurerm_static_site" +description: |- + Manages a Static Web App. +--- + +# azurerm_static_site + +Manages a Static Web App. + +## Example Usage + +```hcl +resource "azurerm_static_site" "example" { + name = "example" + resource_group_name = "example" + location = "West Europe" + + github_configuration { + repo_token = "personal-access-token-github" + repo_url = "https://github.com/example/static-web-app-example" + branch = "master" + app_location = "/" + } +} +``` + +## Arguments Reference + +The following arguments are supported: + +* `name` - (Required) The name which should be used for this Static Web App. Changing this forces a new Static Web App to be created. + +* `location` - (Required) The Azure Region where the Static Web App should exist. Changing this forces a new Static Web App to be created. + +* `resource_group_name` - (Required) The name of the Resource Group where the Static Web App should exist. Changing this forces a new Static Web App to be created. + +* `github_configuration` - (Required) A `github_configuration` block as defined below. + +--- + +* `tags` - (Optional) A mapping of tags which should be assigned to the Static Web App. + +--- + +A `github_configuration` block supports the following: + +* `app_location` - (Required) The path to the Static Web App site code within the repository. + +* `branch` - (Required) The target branch in the repository. + +* `repo_token` - (Required) A user's github repository token. This is used to setup the Github Actions workflow file and API secrets. + +* `repo_url` - (Required) URL for the repository of the Static Web App site. + +* `api_location` - (Optional) The path to the Function App api code within the repository. + +* `artifact_location` - (Optional) The path of the Static Web App artifacts after building. + +## Attributes Reference + +In addition to the Arguments listed above - the following Attributes are exported: + +* `id` - The ID of the Static Web App. + +## Timeouts + +The `timeouts` block allows you to specify [timeouts](https://www.terraform.io/docs/configuration/resources.html#timeouts) for certain actions: + +* `create` - (Defaults to 30 minutes) Used when creating the Static Web App. +* `read` - (Defaults to 5 minutes) Used when retrieving the Static Web App. +* `update` - (Defaults to 30 minutes) Used when updating the Static Web App. +* `delete` - (Defaults to 30 minutes) Used when deleting the Static Web App. + +## Import + +Static Web Apps can be imported using the `resource id`, e.g. + +```shell +terraform import azurerm_static_site.example /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Web/staticSites/my-static-site1 +``` \ No newline at end of file