From 1268769c2f04f98f1602c0f20fdfb3fe33ed4122 Mon Sep 17 00:00:00 2001 From: tnolet Date: Tue, 1 Sep 2026 15:14:30 +0200 Subject: [PATCH 1/2] feat: add status page v3 resources Adds checkly_status_page_v3, checkly_status_page_v3_component and checkly_status_page_v3_automation_rule resources for components-based v3 status pages, with composite-ID import for the nested resources and deprecation pointers on the v2 status page resources. Pins checkly-go-sdk to a pre-release commit of checkly/checkly-go-sdk#169; to be bumped to v1.23.0 once that release is tagged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014WTgyksVAQY5jpD2fb3rYL --- checkly/provider.go | 55 +++-- checkly/resource_status_page.go | 4 +- checkly/resource_status_page_service.go | 4 +- checkly/resource_status_page_v3.go | 221 +++++++++++++++++ ...resource_status_page_v3_automation_rule.go | 223 ++++++++++++++++++ ...rce_status_page_v3_automation_rule_test.go | 170 +++++++++++++ checkly/resource_status_page_v3_component.go | 176 ++++++++++++++ .../resource_status_page_v3_component_test.go | 139 +++++++++++ checkly/resource_status_page_v3_test.go | 126 ++++++++++ checkly/resource_status_page_v3_unit_test.go | 126 ++++++++++ docs/resources/status_page.md | 4 +- docs/resources/status_page_service.md | 4 +- docs/resources/status_page_v3.md | 70 ++++++ .../status_page_v3_automation_rule.md | 71 ++++++ docs/resources/status_page_v3_component.md | 55 +++++ .../checkly_status_page_v3/resource.tf | 27 +++ .../resource.tf | 25 ++ .../resource.tf | 19 ++ go.mod | 2 +- go.sum | 2 + 20 files changed, 1490 insertions(+), 33 deletions(-) create mode 100644 checkly/resource_status_page_v3.go create mode 100644 checkly/resource_status_page_v3_automation_rule.go create mode 100644 checkly/resource_status_page_v3_automation_rule_test.go create mode 100644 checkly/resource_status_page_v3_component.go create mode 100644 checkly/resource_status_page_v3_component_test.go create mode 100644 checkly/resource_status_page_v3_test.go create mode 100644 checkly/resource_status_page_v3_unit_test.go create mode 100644 docs/resources/status_page_v3.md create mode 100644 docs/resources/status_page_v3_automation_rule.md create mode 100644 docs/resources/status_page_v3_component.md create mode 100644 examples/resources/checkly_status_page_v3/resource.tf create mode 100644 examples/resources/checkly_status_page_v3_automation_rule/resource.tf create mode 100644 examples/resources/checkly_status_page_v3_component/resource.tf diff --git a/checkly/provider.go b/checkly/provider.go index 21e1e40..d0a235f 100644 --- a/checkly/provider.go +++ b/checkly/provider.go @@ -31,32 +31,35 @@ func Provider() *schema.Provider { }, }, ResourcesMap: map[string]*schema.Resource{ - "checkly_check": resourceCheck(), - "checkly_heartbeat": resourceHeartbeat(), // Renamed - "checkly_heartbeat_monitor": resourceHeartbeatMonitor(), - "checkly_tcp_check": resourceTCPCheck(), // Renamed - "checkly_tcp_monitor": resourceTCPMonitor(), - "checkly_check_group": resourceCheckGroup(), - "checkly_check_group_v2": resourceCheckGroupV2(), - "checkly_snippet": resourceSnippet(), - "checkly_dashboard": resourceDashboard(), - "checkly_maintenance_windows": resourceMaintenanceWindow(), - "checkly_alert_channel": resourceAlertChannel(), - "checkly_trigger_check": resourceTriggerCheck(), - "checkly_trigger_group": resourceTriggerGroup(), - "checkly_environment_variable": resourceEnvironmentVariable(), - "checkly_private_location": resourcePrivateLocation(), - "checkly_client_certificate": resourceClientCertificate(), - "checkly_status_page": resourceStatusPage(), - "checkly_status_page_service": resourceStatusPageService(), - "checkly_url_monitor": resourceURLMonitor(), - "checkly_dns_monitor": resourceDNSMonitor(), - "checkly_icmp_monitor": resourceICMPMonitor(), - "checkly_grpc_monitor": resourceGRPCMonitor(), - "checkly_traceroute_monitor": resourceTracerouteMonitor(), - "checkly_ssl_monitor": resourceSSLMonitor(), - "checkly_playwright_check_suite": resourcePlaywrightCheckSuite(), - "checkly_playwright_code_bundle": resourcePlaywrightCodeBundle(), + "checkly_check": resourceCheck(), + "checkly_heartbeat": resourceHeartbeat(), // Renamed + "checkly_heartbeat_monitor": resourceHeartbeatMonitor(), + "checkly_tcp_check": resourceTCPCheck(), // Renamed + "checkly_tcp_monitor": resourceTCPMonitor(), + "checkly_check_group": resourceCheckGroup(), + "checkly_check_group_v2": resourceCheckGroupV2(), + "checkly_snippet": resourceSnippet(), + "checkly_dashboard": resourceDashboard(), + "checkly_maintenance_windows": resourceMaintenanceWindow(), + "checkly_alert_channel": resourceAlertChannel(), + "checkly_trigger_check": resourceTriggerCheck(), + "checkly_trigger_group": resourceTriggerGroup(), + "checkly_environment_variable": resourceEnvironmentVariable(), + "checkly_private_location": resourcePrivateLocation(), + "checkly_client_certificate": resourceClientCertificate(), + "checkly_status_page": resourceStatusPage(), + "checkly_status_page_service": resourceStatusPageService(), + "checkly_status_page_v3": resourceStatusPageV3(), + "checkly_status_page_v3_component": resourceStatusPageV3Component(), + "checkly_status_page_v3_automation_rule": resourceStatusPageV3AutomationRule(), + "checkly_url_monitor": resourceURLMonitor(), + "checkly_dns_monitor": resourceDNSMonitor(), + "checkly_icmp_monitor": resourceICMPMonitor(), + "checkly_grpc_monitor": resourceGRPCMonitor(), + "checkly_traceroute_monitor": resourceTracerouteMonitor(), + "checkly_ssl_monitor": resourceSSLMonitor(), + "checkly_playwright_check_suite": resourcePlaywrightCheckSuite(), + "checkly_playwright_code_bundle": resourcePlaywrightCodeBundle(), }, DataSourcesMap: map[string]*schema.Resource{ "checkly_static_ips": dataSourceStaticIPs(), diff --git a/checkly/resource_status_page.go b/checkly/resource_status_page.go index ff431bb..f80deac 100644 --- a/checkly/resource_status_page.go +++ b/checkly/resource_status_page.go @@ -20,7 +20,9 @@ func resourceStatusPage() *schema.Resource { StateContext: schema.ImportStatePassthroughContext, }, Description: "Checkly status pages allow you to easily communicate " + - "the uptime and health of your applications and services to your customers.", + "the uptime and health of your applications and services to your customers. " + + "This resource creates a legacy v2 status page: the new " + + "`checkly_status_page_v3` resource should always be preferred for new pages.", Schema: map[string]*schema.Schema{ "name": { Type: schema.TypeString, diff --git a/checkly/resource_status_page_service.go b/checkly/resource_status_page_service.go index 38e4118..58ee7da 100644 --- a/checkly/resource_status_page_service.go +++ b/checkly/resource_status_page_service.go @@ -20,7 +20,9 @@ func resourceStatusPageService() *schema.Resource { StateContext: schema.ImportStatePassthroughContext, }, Description: "Status page services represent functional pieces of " + - "your application or website, such as landing page, API, support portal etc.", + "your application or website, such as landing page, API, support portal etc. " + + "Services belong to legacy v2 status pages: on new v3 pages, use " + + "`checkly_status_page_v3_component` instead.", Schema: map[string]*schema.Schema{ "name": { Type: schema.TypeString, diff --git a/checkly/resource_status_page_v3.go b/checkly/resource_status_page_v3.go new file mode 100644 index 0000000..e634b64 --- /dev/null +++ b/checkly/resource_status_page_v3.go @@ -0,0 +1,221 @@ +package checkly + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + checkly "github.com/checkly/checkly-go-sdk" +) + +var statusPageV3ThemeValues = allowedValues[string]{ + {Value: "AUTO"}, + {Value: "DARK"}, + {Value: "LIGHT"}, +} + +// The server lowercases the URL before storing it, so accepting uppercase +// here would create a permanent diff between config and state. +var statusPageV3URLRegex = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) + +func resourceStatusPageV3() *schema.Resource { + return &schema.Resource{ + Create: resourceStatusPageV3Create, + Read: resourceStatusPageV3Read, + Update: resourceStatusPageV3Update, + Delete: resourceStatusPageV3Delete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Description: "Checkly status pages allow you to easily communicate " + + "the uptime and health of your applications and services to your " + + "customers. A v3 status page has no cards or services: its " + + "structure is managed with `checkly_status_page_v3_component` " + + "resources, and incidents can be automated with " + + "`checkly_status_page_v3_automation_rule` resources. The " + + "`checkly_status_page_v3` resource should always be preferred " + + "over the old `checkly_status_page` resource.", + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the status page.", + }, + "url": { + Type: schema.TypeString, + Required: true, + Description: "The unique subdomain of the status page: lowercase alphanumeric characters and dashes, no leading or trailing dash, at most 63 characters.", + ValidateFunc: func(value interface{}, key string) (warns []string, errs []error) { + v := value.(string) + if !statusPageV3URLRegex.MatchString(v) { + errs = append(errs, fmt.Errorf("%q can only contain lowercase alphanumeric characters and dashes, with no leading or trailing dash, got: %s", key, v)) + } + return warns, errs + }, + }, + "custom_domain": { + Type: schema.TypeString, + Optional: true, + Description: "A custom user domain, e.g. \"status.example.com\". See the docs on updating your DNS and SSL usage.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "A short description shown on the status page.", + }, + "logo": { + Type: schema.TypeString, + Optional: true, + Description: "A URL to an image file to use as the logo for the status page.", + }, + "logo_dark": { + Type: schema.TypeString, + Optional: true, + Description: "A URL to an image file to use as the logo in dark mode.", + }, + "redirect_to": { + Type: schema.TypeString, + Optional: true, + Description: "The URL the user should be redirected to when clicking the logo.", + }, + "favicon": { + Type: schema.TypeString, + Optional: true, + Description: "A URL to an image file to use as the favicon of the status page.", + }, + "default_theme": { + Type: schema.TypeString, + Optional: true, + Default: "AUTO", + Description: "The default theme of the status page. " + statusPageV3ThemeValues.String(), + ValidateFunc: validateOneOf(statusPageV3ThemeValues.Values()), + }, + "privacy_policy_link": { + Type: schema.TypeString, + Optional: true, + Description: "A link to your privacy policy, shown in the page footer.", + }, + "terms_of_service_link": { + Type: schema.TypeString, + Optional: true, + Description: "A link to your terms of service, shown in the page footer.", + }, + "footer_text": { + Type: schema.TypeString, + Optional: true, + Description: "Free-form footer text.", + }, + "google_analytics_tag": { + Type: schema.TypeString, + Optional: true, + Description: "A Google Analytics tag ID (e.g. \"G-XXXXXXXXXX\") to embed on the public page.", + }, + "allow_indexing": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Whether search engines may index the public page. (Default `true`).", + }, + }, + } +} + +func statusPageV3FromResourceData(d *schema.ResourceData) checkly.StatusPageV3 { + return checkly.StatusPageV3{ + ID: d.Id(), + Name: d.Get("name").(string), + URL: d.Get("url").(string), + CustomDomain: d.Get("custom_domain").(string), + Description: d.Get("description").(string), + Logo: d.Get("logo").(string), + LogoDark: d.Get("logo_dark").(string), + RedirectTo: d.Get("redirect_to").(string), + Favicon: d.Get("favicon").(string), + DefaultTheme: checkly.StatusPageTheme(d.Get("default_theme").(string)), + PrivacyPolicyLink: d.Get("privacy_policy_link").(string), + TermsOfServiceLink: d.Get("terms_of_service_link").(string), + FooterText: d.Get("footer_text").(string), + GoogleAnalyticsTag: d.Get("google_analytics_tag").(string), + AllowIndexing: d.Get("allow_indexing").(bool), + } +} + +func resourceDataFromStatusPageV3(p *checkly.StatusPageV3, d *schema.ResourceData) error { + pairs := []struct { + key string + value interface{} + }{ + {"name", p.Name}, + {"url", p.URL}, + {"custom_domain", p.CustomDomain}, + {"description", p.Description}, + {"logo", p.Logo}, + {"logo_dark", p.LogoDark}, + {"redirect_to", p.RedirectTo}, + {"favicon", p.Favicon}, + {"default_theme", string(p.DefaultTheme)}, + {"privacy_policy_link", p.PrivacyPolicyLink}, + {"terms_of_service_link", p.TermsOfServiceLink}, + {"footer_text", p.FooterText}, + {"google_analytics_tag", p.GoogleAnalyticsTag}, + {"allow_indexing", p.AllowIndexing}, + } + for _, pair := range pairs { + if err := d.Set(pair.key, pair.value); err != nil { + return fmt.Errorf("failed to set %q: %w", pair.key, err) + } + } + return nil +} + +func resourceStatusPageV3Create(d *schema.ResourceData, client interface{}) error { + statusPage := statusPageV3FromResourceData(d) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + result, err := client.(checkly.Client).CreateStatusPageV3(ctx, statusPage) + if err != nil { + return fmt.Errorf("CreateStatusPageV3: API error: %w", err) + } + d.SetId(result.ID) + return resourceStatusPageV3Read(d, client) +} + +func resourceStatusPageV3Read(d *schema.ResourceData, client interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + statusPage, err := client.(checkly.Client).GetStatusPageV3(ctx, d.Id()) + if err != nil { + if strings.Contains(err.Error(), "404") { + // If the resource was deleted remotely, mark it as successfully + // gone by unsetting its ID. + d.SetId("") + return nil + } + return fmt.Errorf("resourceStatusPageV3Read: API error: %w", err) + } + return resourceDataFromStatusPageV3(statusPage, d) +} + +func resourceStatusPageV3Update(d *schema.ResourceData, client interface{}) error { + statusPage := statusPageV3FromResourceData(d) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + _, err := client.(checkly.Client).UpdateStatusPageV3(ctx, statusPage.ID, statusPage) + if err != nil { + return fmt.Errorf("resourceStatusPageV3Update: API error: %w", err) + } + return resourceStatusPageV3Read(d, client) +} + +func resourceStatusPageV3Delete(d *schema.ResourceData, client interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + err := client.(checkly.Client).DeleteStatusPageV3(ctx, d.Id()) + if err != nil { + return fmt.Errorf("resourceStatusPageV3Delete: API error: %w", err) + } + return nil +} diff --git a/checkly/resource_status_page_v3_automation_rule.go b/checkly/resource_status_page_v3_automation_rule.go new file mode 100644 index 0000000..7aee650 --- /dev/null +++ b/checkly/resource_status_page_v3_automation_rule.go @@ -0,0 +1,223 @@ +package checkly + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + checkly "github.com/checkly/checkly-go-sdk" +) + +var statusPageV3TargetImpactValues = allowedValues[string]{ + {Value: "UNDER_MAINTENANCE"}, + {Value: "DEGRADED_PERFORMANCE"}, + {Value: "PARTIAL_OUTAGE"}, + {Value: "MAJOR_OUTAGE"}, +} + +func resourceStatusPageV3AutomationRule() *schema.Resource { + return &schema.Resource{ + Create: resourceStatusPageV3AutomationRuleCreate, + Read: resourceStatusPageV3AutomationRuleRead, + Update: resourceStatusPageV3AutomationRuleUpdate, + Delete: resourceStatusPageV3AutomationRuleDelete, + Importer: statusPageV3CompositeImporter("rule"), + Description: "An automation rule of a v3 status page. When a check " + + "whose tags overlap with the rule's tags fails, Checkly opens " + + "one incident on the page impacting the listed components, and " + + "resolves it when the check recovers. Import uses the composite " + + "ID `/`.", + Schema: map[string]*schema.Schema{ + "status_page_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the v3 status page the rule belongs to.", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the rule.", + }, + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "A disabled rule never opens incidents. (Default `true`).", + }, + "first_update": { + Type: schema.TypeString, + Required: true, + Description: "Body of the status update that opens the incident.", + }, + "last_update": { + Type: schema.TypeString, + Required: true, + Description: "Body of the status update that resolves the incident.", + }, + "notify_subscribers": { + Type: schema.TypeBool, + Optional: true, + Default: true, + Description: "Whether subscribers are notified of the automated updates. (Default `true`).", + }, + "cool_down_window_minutes": { + Type: schema.TypeInt, + Optional: true, + Default: 5, + Description: "Minimum minutes after an automated incident before this rule may open the next one. `0` disables the cool down. (Default `5`).", + ValidateFunc: validateBetween(0, 1440), + }, + "tags": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Description: "A failing check matches this rule when it, or its group, carries ANY of these tags. At least one tag is required.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "component": { + Type: schema.TypeSet, + Optional: true, + Description: "The components an automated incident impacts, with the impact each gets.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "component_id": { + Type: schema.TypeString, + Required: true, + Description: "The ID of the impacted component. Must be on the same status page.", + }, + "target_impact": { + Type: schema.TypeString, + Required: true, + Description: "The impact set on the component while the incident is open. " + statusPageV3TargetImpactValues.String(), + ValidateFunc: validateOneOf(statusPageV3TargetImpactValues.Values()), + }, + }, + }, + }, + }, + } +} + +func statusPageV3AutomationRuleFromResourceData(d *schema.ResourceData) (checkly.StatusPageAutomationRuleV3, error) { + components := []checkly.StatusPageAutomationRuleComponentV3{} + seen := map[string]bool{} + for _, it := range d.Get("component").(*schema.Set).List() { + tm := it.(tfMap) + componentID := tm["component_id"].(string) + // The API stores one impact per (rule, component); a duplicate would + // surface as an opaque server error. + if seen[componentID] { + return checkly.StatusPageAutomationRuleV3{}, fmt.Errorf("component %q is listed more than once", componentID) + } + seen[componentID] = true + components = append(components, checkly.StatusPageAutomationRuleComponentV3{ + ComponentID: componentID, + TargetImpact: checkly.StatusPageTargetImpactV3(tm["target_impact"].(string)), + }) + } + return checkly.StatusPageAutomationRuleV3{ + ID: d.Id(), + Name: d.Get("name").(string), + Enabled: d.Get("enabled").(bool), + FirstUpdate: d.Get("first_update").(string), + LastUpdate: d.Get("last_update").(string), + NotifySubscribers: d.Get("notify_subscribers").(bool), + CoolDownWindowMinutes: d.Get("cool_down_window_minutes").(int), + Tags: stringsFromSet(d.Get("tags").(*schema.Set)), + Components: components, + }, nil +} + +func resourceDataFromStatusPageV3AutomationRule(r *checkly.StatusPageAutomationRuleV3, d *schema.ResourceData) error { + components := make([]tfMap, 0, len(r.Components)) + for _, component := range r.Components { + components = append(components, tfMap{ + "component_id": component.ComponentID, + "target_impact": string(component.TargetImpact), + }) + } + pairs := []struct { + key string + value interface{} + }{ + {"status_page_id", r.StatusPageID}, + {"name", r.Name}, + {"enabled", r.Enabled}, + {"first_update", r.FirstUpdate}, + {"last_update", r.LastUpdate}, + {"notify_subscribers", r.NotifySubscribers}, + {"cool_down_window_minutes", r.CoolDownWindowMinutes}, + {"tags", r.Tags}, + {"component", components}, + } + for _, pair := range pairs { + if err := d.Set(pair.key, pair.value); err != nil { + return fmt.Errorf("failed to set %q: %w", pair.key, err) + } + } + return nil +} + +func resourceStatusPageV3AutomationRuleCreate(d *schema.ResourceData, client interface{}) error { + rule, err := statusPageV3AutomationRuleFromResourceData(d) + if err != nil { + return fmt.Errorf("resourceStatusPageV3AutomationRuleCreate: translation error: %w", err) + } + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + result, err := client.(checkly.Client).CreateStatusPageAutomationRuleV3(ctx, statusPageID, rule) + if err != nil { + return fmt.Errorf("CreateStatusPageAutomationRuleV3: API error: %w", err) + } + d.SetId(result.ID) + return resourceStatusPageV3AutomationRuleRead(d, client) +} + +func resourceStatusPageV3AutomationRuleRead(d *schema.ResourceData, client interface{}) error { + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + rule, err := client.(checkly.Client).GetStatusPageAutomationRuleV3(ctx, statusPageID, d.Id()) + if err != nil { + if strings.Contains(err.Error(), "404") { + // Deleted remotely, either the rule itself or the whole page: + // mark the resource as gone. + d.SetId("") + return nil + } + return fmt.Errorf("resourceStatusPageV3AutomationRuleRead: API error: %w", err) + } + return resourceDataFromStatusPageV3AutomationRule(rule, d) +} + +func resourceStatusPageV3AutomationRuleUpdate(d *schema.ResourceData, client interface{}) error { + rule, err := statusPageV3AutomationRuleFromResourceData(d) + if err != nil { + return fmt.Errorf("resourceStatusPageV3AutomationRuleUpdate: translation error: %w", err) + } + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + _, err = client.(checkly.Client).UpdateStatusPageAutomationRuleV3(ctx, statusPageID, rule.ID, rule) + if err != nil { + return fmt.Errorf("resourceStatusPageV3AutomationRuleUpdate: API error: %w", err) + } + return resourceStatusPageV3AutomationRuleRead(d, client) +} + +func resourceStatusPageV3AutomationRuleDelete(d *schema.ResourceData, client interface{}) error { + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + err := client.(checkly.Client).DeleteStatusPageAutomationRuleV3(ctx, statusPageID, d.Id()) + if err != nil { + return fmt.Errorf("resourceStatusPageV3AutomationRuleDelete: API error: %w", err) + } + return nil +} diff --git a/checkly/resource_status_page_v3_automation_rule_test.go b/checkly/resource_status_page_v3_automation_rule_test.go new file mode 100644 index 0000000..05471d0 --- /dev/null +++ b/checkly/resource_status_page_v3_automation_rule_test.go @@ -0,0 +1,170 @@ +package checkly + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +const statusPageV3AutomationRuleResource = "checkly_status_page_v3_automation_rule.test" + +func TestAccStatusPageV3AutomationRuleCheckRequiredFields(t *testing.T) { + accTestCase(t, []resource.TestStep{ + { + Config: `resource "checkly_status_page_v3_automation_rule" "test" {}`, + ExpectError: regexp.MustCompile(`The argument "first_update" is required`), + }, + { + Config: ` + resource "checkly_status_page_v3_automation_rule" "test" { + status_page_id = "6a11caa1-b6cb-4e46-8bd3-e70a90c5add8" + name = "foo" + first_update = "Investigating." + last_update = "Resolved." + tags = [] + } + `, + ExpectError: regexp.MustCompile(`Attribute tags requires 1 item minimum`), + }, + { + Config: ` + resource "checkly_status_page_v3_automation_rule" "test" { + status_page_id = "6a11caa1-b6cb-4e46-8bd3-e70a90c5add8" + name = "foo" + first_update = "Investigating." + last_update = "Resolved." + tags = ["foo"] + + component { + component_id = "0e4f5a72-6a5c-42a1-9a8a-5f7d38c8a9d1" + target_impact = "OPERATIONAL" + } + } + `, + ExpectError: regexp.MustCompile(`"component.0.target_impact" must be one of`), + }, + }) +} + +func TestAccStatusPageV3AutomationRuleHappyPath(t *testing.T) { + rInt := acctest.RandInt() + base := fmt.Sprintf(` + resource "checkly_status_page_v3" "test" { + name = "foo" + url = "status-page-v3-rule-%d" + } + + resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.test.id + name = "Foo API" + display_order = 0 + } + `, rInt) + accTestCase(t, []resource.TestStep{ + { + Config: base + ` + resource "checkly_status_page_v3_automation_rule" "test" { + status_page_id = checkly_status_page_v3.test.id + name = "Foo API outage" + first_update = "We are investigating an issue." + last_update = "The issue has been resolved." + tags = ["foo-api"] + + component { + component_id = checkly_status_page_v3_component.api.id + target_impact = "MAJOR_OUTAGE" + } + } + `, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "name", + "Foo API outage", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "enabled", + "true", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "notify_subscribers", + "true", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "cool_down_window_minutes", + "5", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "tags.#", + "1", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "component.#", + "1", + ), + resource.TestCheckResourceAttrPair( + statusPageV3AutomationRuleResource, + "status_page_id", + "checkly_status_page_v3.test", + "id", + ), + ), + }, + { + Config: base + ` + resource "checkly_status_page_v3_automation_rule" "test" { + status_page_id = checkly_status_page_v3.test.id + name = "Bar API outage" + enabled = false + first_update = "We are investigating an issue." + last_update = "The issue has been resolved." + notify_subscribers = false + cool_down_window_minutes = 30 + tags = ["foo-api", "bar-api"] + + component { + component_id = checkly_status_page_v3_component.api.id + target_impact = "DEGRADED_PERFORMANCE" + } + } + `, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "name", + "Bar API outage", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "enabled", + "false", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "cool_down_window_minutes", + "30", + ), + resource.TestCheckResourceAttr( + statusPageV3AutomationRuleResource, + "tags.#", + "2", + ), + ), + }, + { + ResourceName: statusPageV3AutomationRuleResource, + ImportState: true, + ImportStateVerify: true, + // The import ID is composite: "/". + ImportStateIdFunc: statusPageV3CompositeImportID(statusPageV3AutomationRuleResource), + }, + }) +} diff --git a/checkly/resource_status_page_v3_component.go b/checkly/resource_status_page_v3_component.go new file mode 100644 index 0000000..39164f2 --- /dev/null +++ b/checkly/resource_status_page_v3_component.go @@ -0,0 +1,176 @@ +package checkly + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + checkly "github.com/checkly/checkly-go-sdk" +) + +var statusPageV3ComponentTypeValues = allowedValues[string]{ + {Value: "SERVICE", Description: "a monitored thing with its own status"}, + {Value: "GROUP", Description: "a container for other components"}, +} + +// statusPageV3CompositeImporter imports nested v3 status page resources, +// whose API routes need the page ID in addition to their own: the import ID +// is "/". +func statusPageV3CompositeImporter(resourceName string) *schema.ResourceImporter { + return &schema.ResourceImporter{ + StateContext: func(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + parts := strings.Split(d.Id(), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("invalid import ID %q, expected \"/<%s_id>\"", d.Id(), resourceName) + } + if err := d.Set("status_page_id", parts[0]); err != nil { + return nil, fmt.Errorf("failed to set \"status_page_id\": %w", err) + } + d.SetId(parts[1]) + return []*schema.ResourceData{d}, nil + }, + } +} + +func resourceStatusPageV3Component() *schema.Resource { + return &schema.Resource{ + Create: resourceStatusPageV3ComponentCreate, + Read: resourceStatusPageV3ComponentRead, + Update: resourceStatusPageV3ComponentUpdate, + Delete: resourceStatusPageV3ComponentDelete, + Importer: statusPageV3CompositeImporter("component"), + Description: "A component of a v3 status page: either a SERVICE (a " + + "monitored thing with its own status) or a GROUP (a container " + + "for other components). Import uses the composite ID " + + "`/`.", + Schema: map[string]*schema.Schema{ + "status_page_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the v3 status page the component belongs to. A component cannot be moved to another page.", + }, + "type": { + Type: schema.TypeString, + Optional: true, + Default: "SERVICE", + Description: "The type of the component. " + statusPageV3ComponentTypeValues.String() + " (Default `SERVICE`).", + ValidateFunc: validateOneOf(statusPageV3ComponentTypeValues.Values()), + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name shown on the status page.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "An optional description shown next to the name.", + }, + "display_order": { + Type: schema.TypeInt, + Required: true, + Description: "The position among siblings; lower comes first.", + }, + "hidden": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Hide the component from the public page while keeping it available for incidents and automation. (Default `false`).", + }, + "parent_id": { + Type: schema.TypeString, + Optional: true, + Description: "The ID of the GROUP component to nest this component under. Must be on the same status page.", + }, + }, + } +} + +func statusPageV3ComponentFromResourceData(d *schema.ResourceData) checkly.StatusPageComponentV3 { + return checkly.StatusPageComponentV3{ + ID: d.Id(), + Type: checkly.StatusPageComponentV3Type(d.Get("type").(string)), + Name: d.Get("name").(string), + Description: d.Get("description").(string), + DisplayOrder: d.Get("display_order").(int), + Hidden: d.Get("hidden").(bool), + ParentID: d.Get("parent_id").(string), + } +} + +func resourceDataFromStatusPageV3Component(c *checkly.StatusPageComponentV3, d *schema.ResourceData) error { + pairs := []struct { + key string + value interface{} + }{ + {"status_page_id", c.StatusPageID}, + {"type", string(c.Type)}, + {"name", c.Name}, + {"description", c.Description}, + {"display_order", c.DisplayOrder}, + {"hidden", c.Hidden}, + {"parent_id", c.ParentID}, + } + for _, pair := range pairs { + if err := d.Set(pair.key, pair.value); err != nil { + return fmt.Errorf("failed to set %q: %w", pair.key, err) + } + } + return nil +} + +func resourceStatusPageV3ComponentCreate(d *schema.ResourceData, client interface{}) error { + component := statusPageV3ComponentFromResourceData(d) + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + result, err := client.(checkly.Client).CreateStatusPageComponentV3(ctx, statusPageID, component) + if err != nil { + return fmt.Errorf("CreateStatusPageComponentV3: API error: %w", err) + } + d.SetId(result.ID) + return resourceStatusPageV3ComponentRead(d, client) +} + +func resourceStatusPageV3ComponentRead(d *schema.ResourceData, client interface{}) error { + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + component, err := client.(checkly.Client).GetStatusPageComponentV3(ctx, statusPageID, d.Id()) + if err != nil { + if strings.Contains(err.Error(), "404") { + // Deleted remotely, either the component itself or the whole + // page: mark the resource as gone. + d.SetId("") + return nil + } + return fmt.Errorf("resourceStatusPageV3ComponentRead: API error: %w", err) + } + return resourceDataFromStatusPageV3Component(component, d) +} + +func resourceStatusPageV3ComponentUpdate(d *schema.ResourceData, client interface{}) error { + component := statusPageV3ComponentFromResourceData(d) + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + _, err := client.(checkly.Client).UpdateStatusPageComponentV3(ctx, statusPageID, component.ID, component) + if err != nil { + return fmt.Errorf("resourceStatusPageV3ComponentUpdate: API error: %w", err) + } + return resourceStatusPageV3ComponentRead(d, client) +} + +func resourceStatusPageV3ComponentDelete(d *schema.ResourceData, client interface{}) error { + statusPageID := d.Get("status_page_id").(string) + ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout()) + defer cancel() + err := client.(checkly.Client).DeleteStatusPageComponentV3(ctx, statusPageID, d.Id()) + if err != nil { + return fmt.Errorf("resourceStatusPageV3ComponentDelete: API error: %w", err) + } + return nil +} diff --git a/checkly/resource_status_page_v3_component_test.go b/checkly/resource_status_page_v3_component_test.go new file mode 100644 index 0000000..0a73a3e --- /dev/null +++ b/checkly/resource_status_page_v3_component_test.go @@ -0,0 +1,139 @@ +package checkly + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +// statusPageV3CompositeImportID builds the "/" import ID +// for nested v3 status page resources from the resource's state. +func statusPageV3CompositeImportID(resourceName string) resource.ImportStateIdFunc { + return func(state *terraform.State) (string, error) { + rs, ok := state.RootModule().Resources[resourceName] + if !ok { + return "", fmt.Errorf("resource %q not found in state", resourceName) + } + return rs.Primary.Attributes["status_page_id"] + "/" + rs.Primary.ID, nil + } +} + +func TestAccStatusPageV3ComponentCheckRequiredFields(t *testing.T) { + accTestCase(t, []resource.TestStep{ + { + Config: `resource "checkly_status_page_v3_component" "test" {}`, + ExpectError: regexp.MustCompile(`The argument "status_page_id" is required`), + }, + { + Config: ` + resource "checkly_status_page_v3_component" "test" { + status_page_id = "6a11caa1-b6cb-4e46-8bd3-e70a90c5add8" + name = "foo" + } + `, + ExpectError: regexp.MustCompile(`The argument "display_order" is required`), + }, + }) +} + +func TestAccStatusPageV3ComponentHappyPath(t *testing.T) { + rInt := acctest.RandInt() + page := fmt.Sprintf(` + resource "checkly_status_page_v3" "test" { + name = "foo" + url = "status-page-v3-comp-%d" + } + + resource "checkly_status_page_v3_component" "group" { + status_page_id = checkly_status_page_v3.test.id + type = "GROUP" + name = "Foo group" + display_order = 0 + } + `, rInt) + accTestCase(t, []resource.TestStep{ + { + Config: page + ` + resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.test.id + name = "Foo API" + description = "The public API" + display_order = 1 + parent_id = checkly_status_page_v3_component.group.id + } + `, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "checkly_status_page_v3_component.group", + "type", + "GROUP", + ), + resource.TestCheckResourceAttr( + "checkly_status_page_v3_component.api", + "type", + "SERVICE", + ), + resource.TestCheckResourceAttr( + "checkly_status_page_v3_component.api", + "hidden", + "false", + ), + resource.TestCheckResourceAttrPair( + "checkly_status_page_v3_component.api", + "status_page_id", + "checkly_status_page_v3.test", + "id", + ), + resource.TestCheckResourceAttrPair( + "checkly_status_page_v3_component.api", + "parent_id", + "checkly_status_page_v3_component.group", + "id", + ), + ), + }, + { + Config: page + ` + resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.test.id + name = "Bar API" + display_order = 2 + hidden = true + } + `, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "checkly_status_page_v3_component.api", + "name", + "Bar API", + ), + resource.TestCheckResourceAttr( + "checkly_status_page_v3_component.api", + "display_order", + "2", + ), + resource.TestCheckResourceAttr( + "checkly_status_page_v3_component.api", + "hidden", + "true", + ), + resource.TestCheckResourceAttr( + "checkly_status_page_v3_component.api", + "parent_id", + "", + ), + ), + }, + { + ResourceName: "checkly_status_page_v3_component.api", + ImportState: true, + ImportStateVerify: true, + // The import ID is composite: "/". + ImportStateIdFunc: statusPageV3CompositeImportID("checkly_status_page_v3_component.api"), + }, + }) +} diff --git a/checkly/resource_status_page_v3_test.go b/checkly/resource_status_page_v3_test.go new file mode 100644 index 0000000..a54c065 --- /dev/null +++ b/checkly/resource_status_page_v3_test.go @@ -0,0 +1,126 @@ +package checkly + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +const statusPageV3Resource = "checkly_status_page_v3.test" + +func TestAccStatusPageV3CheckRequiredFields(t *testing.T) { + accTestCase(t, []resource.TestStep{ + { + Config: `resource "checkly_status_page_v3" "test" {}`, + ExpectError: regexp.MustCompile(`The argument "name" is required`), + }, + { + Config: `resource "checkly_status_page_v3" "test" { name = "foo" }`, + ExpectError: regexp.MustCompile(`The argument "url" is required`), + }, + }) +} + +func TestAccStatusPageV3URLValidation(t *testing.T) { + accTestCase(t, []resource.TestStep{ + { + Config: ` + resource "checkly_status_page_v3" "test" { + name = "foo" + url = "Not A Slug" + } + `, + ExpectError: regexp.MustCompile(`can only contain lowercase alphanumeric characters and dashes`), + }, + }) +} + +func TestAccStatusPageV3HappyPath(t *testing.T) { + rInt := acctest.RandInt() + accTestCase(t, []resource.TestStep{ + { + Config: fmt.Sprintf(` + resource "checkly_status_page_v3" "test" { + name = "foo" + url = "status-page-v3-%d" + } + `, rInt), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + statusPageV3Resource, + "name", + "foo", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "url", + fmt.Sprintf("status-page-v3-%d", rInt), + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "default_theme", + "AUTO", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "allow_indexing", + "true", + ), + ), + }, + { + Config: fmt.Sprintf(` + resource "checkly_status_page_v3" "test" { + name = "bar" + url = "status-page-v3-%d" + description = "All systems" + logo = "https://example.org/logo.png" + logo_dark = "https://example.org/logo-dark.png" + redirect_to = "https://example.org" + favicon = "https://example.org/favicon.png" + default_theme = "DARK" + privacy_policy_link = "https://example.org/privacy" + terms_of_service_link = "https://example.org/terms" + footer_text = "Example Inc." + google_analytics_tag = "G-XXXXXXXXXX" + allow_indexing = false + } + `, rInt), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + statusPageV3Resource, + "name", + "bar", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "description", + "All systems", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "default_theme", + "DARK", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "footer_text", + "Example Inc.", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "allow_indexing", + "false", + ), + ), + }, + { + ResourceName: statusPageV3Resource, + ImportState: true, + ImportStateVerify: true, + }, + }) +} diff --git a/checkly/resource_status_page_v3_unit_test.go b/checkly/resource_status_page_v3_unit_test.go new file mode 100644 index 0000000..f85af51 --- /dev/null +++ b/checkly/resource_status_page_v3_unit_test.go @@ -0,0 +1,126 @@ +package checkly + +import ( + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + + checkly "github.com/checkly/checkly-go-sdk" +) + +func TestEncodeDecodeStatusPageV3Resource(t *testing.T) { + want := checkly.StatusPageV3{ + Name: "Foo v3 status page", + URL: "foo-v3-status-page", + CustomDomain: "status.example.org", + Description: "All Foo systems", + Logo: "https://example.org/logo.png", + LogoDark: "https://example.org/logo-dark.png", + RedirectTo: "https://example.org", + Favicon: "https://example.org/favicon.png", + DefaultTheme: checkly.StatusPageThemeDark, + PrivacyPolicyLink: "https://example.org/privacy", + TermsOfServiceLink: "https://example.org/terms", + FooterText: "Foo Inc.", + GoogleAnalyticsTag: "G-XXXXXXXXXX", + AllowIndexing: true, + } + data := resourceStatusPageV3().TestResourceData() + if err := resourceDataFromStatusPageV3(&want, data); err != nil { + t.Fatal(err) + } + got := statusPageV3FromResourceData(data) + if !cmp.Equal(want, got) { + t.Error(cmp.Diff(want, got)) + } +} + +func TestEncodeDecodeStatusPageV3ComponentResource(t *testing.T) { + want := checkly.StatusPageComponentV3{ + StatusPageID: "e35f7e14-91b2-4d24-b7b6-e0f9e2f8e51c", + Type: checkly.StatusPageComponentV3TypeService, + Name: "Foo API", + Description: "The Foo public API", + DisplayOrder: 3, + Hidden: true, + ParentID: "0a2f26fb-47cc-42b7-91c6-40de3ec91a52", + } + data := resourceStatusPageV3Component().TestResourceData() + if err := resourceDataFromStatusPageV3Component(&want, data); err != nil { + t.Fatal(err) + } + got := statusPageV3ComponentFromResourceData(data) + // StatusPageID travels through the "status_page_id" attribute, not the + // component payload. + got.StatusPageID = data.Get("status_page_id").(string) + if !cmp.Equal(want, got) { + t.Error(cmp.Diff(want, got)) + } +} + +func TestEncodeDecodeStatusPageV3AutomationRuleResource(t *testing.T) { + want := checkly.StatusPageAutomationRuleV3{ + StatusPageID: "e35f7e14-91b2-4d24-b7b6-e0f9e2f8e51c", + Name: "Foo API outage", + Enabled: true, + FirstUpdate: "We are investigating an issue.", + LastUpdate: "The issue has been resolved.", + NotifySubscribers: false, + CoolDownWindowMinutes: 30, + Tags: []string{"bar-api", "foo-api"}, + Components: []checkly.StatusPageAutomationRuleComponentV3{ + { + ComponentID: "0e4f5a72-6a5c-42a1-9a8a-5f7d38c8a9d1", + TargetImpact: checkly.StatusPageTargetImpactV3MajorOutage, + }, + { + ComponentID: "b7a2f0cd-4f8e-4ec0-8f5a-1c9a7e2d3b40", + TargetImpact: checkly.StatusPageTargetImpactV3DegradedPerformance, + }, + }, + } + data := resourceStatusPageV3AutomationRule().TestResourceData() + if err := resourceDataFromStatusPageV3AutomationRule(&want, data); err != nil { + t.Fatal(err) + } + got, err := statusPageV3AutomationRuleFromResourceData(data) + if err != nil { + t.Fatal(err) + } + got.StatusPageID = data.Get("status_page_id").(string) + // tags and component are sets: their order is not preserved. + sort.Strings(got.Tags) + sort.Slice(got.Components, func(i, j int) bool { + return got.Components[i].ComponentID < got.Components[j].ComponentID + }) + if !cmp.Equal(want, got) { + t.Error(cmp.Diff(want, got)) + } +} + +func TestStatusPageV3AutomationRuleDuplicateComponents(t *testing.T) { + rule := checkly.StatusPageAutomationRuleV3{ + Name: "Foo API outage", + FirstUpdate: "Investigating.", + LastUpdate: "Resolved.", + Tags: []string{"foo-api"}, + Components: []checkly.StatusPageAutomationRuleComponentV3{ + { + ComponentID: "0e4f5a72-6a5c-42a1-9a8a-5f7d38c8a9d1", + TargetImpact: checkly.StatusPageTargetImpactV3MajorOutage, + }, + { + ComponentID: "0e4f5a72-6a5c-42a1-9a8a-5f7d38c8a9d1", + TargetImpact: checkly.StatusPageTargetImpactV3PartialOutage, + }, + }, + } + data := resourceStatusPageV3AutomationRule().TestResourceData() + if err := resourceDataFromStatusPageV3AutomationRule(&rule, data); err != nil { + t.Fatal(err) + } + if _, err := statusPageV3AutomationRuleFromResourceData(data); err == nil { + t.Error("expected an error for a duplicate component_id") + } +} diff --git a/docs/resources/status_page.md b/docs/resources/status_page.md index a11aaf2..74aa791 100644 --- a/docs/resources/status_page.md +++ b/docs/resources/status_page.md @@ -3,12 +3,12 @@ page_title: "checkly_status_page Resource - terraform-provider-checkly" subcategory: "" description: |- - Checkly status pages allow you to easily communicate the uptime and health of your applications and services to your customers. + Checkly status pages allow you to easily communicate the uptime and health of your applications and services to your customers. This resource creates a legacy v2 status page: the new checkly_status_page_v3 resource should always be preferred for new pages. --- # checkly_status_page (Resource) -Checkly status pages allow you to easily communicate the uptime and health of your applications and services to your customers. +Checkly status pages allow you to easily communicate the uptime and health of your applications and services to your customers. This resource creates a legacy v2 status page: the new `checkly_status_page_v3` resource should always be preferred for new pages. ## Example Usage diff --git a/docs/resources/status_page_service.md b/docs/resources/status_page_service.md index 61232a2..18f0ecd 100644 --- a/docs/resources/status_page_service.md +++ b/docs/resources/status_page_service.md @@ -3,12 +3,12 @@ page_title: "checkly_status_page_service Resource - terraform-provider-checkly" subcategory: "" description: |- - Status page services represent functional pieces of your application or website, such as landing page, API, support portal etc. + Status page services represent functional pieces of your application or website, such as landing page, API, support portal etc. Services belong to legacy v2 status pages: on new v3 pages, use checkly_status_page_v3_component instead. --- # checkly_status_page_service (Resource) -Status page services represent functional pieces of your application or website, such as landing page, API, support portal etc. +Status page services represent functional pieces of your application or website, such as landing page, API, support portal etc. Services belong to legacy v2 status pages: on new v3 pages, use `checkly_status_page_v3_component` instead. ## Example Usage diff --git a/docs/resources/status_page_v3.md b/docs/resources/status_page_v3.md new file mode 100644 index 0000000..a274112 --- /dev/null +++ b/docs/resources/status_page_v3.md @@ -0,0 +1,70 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "checkly_status_page_v3 Resource - terraform-provider-checkly" +subcategory: "" +description: |- + Checkly status pages allow you to easily communicate the uptime and health of your applications and services to your customers. A v3 status page has no cards or services: its structure is managed with checkly_status_page_v3_component resources, and incidents can be automated with checkly_status_page_v3_automation_rule resources. The checkly_status_page_v3 resource should always be preferred over the old checkly_status_page resource. +--- + +# checkly_status_page_v3 (Resource) + +Checkly status pages allow you to easily communicate the uptime and health of your applications and services to your customers. A v3 status page has no cards or services: its structure is managed with `checkly_status_page_v3_component` resources, and incidents can be automated with `checkly_status_page_v3_automation_rule` resources. The `checkly_status_page_v3` resource should always be preferred over the old `checkly_status_page` resource. + +## Example Usage + +```terraform +resource "checkly_status_page_v3" "example" { + name = "Example Application" + url = "my-example-status-page" + default_theme = "DARK" +} + +# The page structure is declared with components: GROUPs contain SERVICEs. +resource "checkly_status_page_v3_component" "services" { + status_page_id = checkly_status_page_v3.example.id + type = "GROUP" + name = "Services" + display_order = 0 +} + +resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.example.id + name = "API" + display_order = 1 + parent_id = checkly_status_page_v3_component.services.id +} + +resource "checkly_status_page_v3_component" "database" { + status_page_id = checkly_status_page_v3.example.id + name = "Database" + display_order = 2 + parent_id = checkly_status_page_v3_component.services.id +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the status page. +- `url` (String) The unique subdomain of the status page: lowercase alphanumeric characters and dashes, no leading or trailing dash, at most 63 characters. + +### Optional + +- `allow_indexing` (Boolean) Whether search engines may index the public page. (Default `true`). +- `custom_domain` (String) A custom user domain, e.g. "status.example.com". See the docs on updating your DNS and SSL usage. +- `default_theme` (String) The default theme of the status page. The allowed values are `AUTO`, `DARK` and `LIGHT`. +- `description` (String) A short description shown on the status page. +- `favicon` (String) A URL to an image file to use as the favicon of the status page. +- `footer_text` (String) Free-form footer text. +- `google_analytics_tag` (String) A Google Analytics tag ID (e.g. "G-XXXXXXXXXX") to embed on the public page. +- `logo` (String) A URL to an image file to use as the logo for the status page. +- `logo_dark` (String) A URL to an image file to use as the logo in dark mode. +- `privacy_policy_link` (String) A link to your privacy policy, shown in the page footer. +- `redirect_to` (String) The URL the user should be redirected to when clicking the logo. +- `terms_of_service_link` (String) A link to your terms of service, shown in the page footer. + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/docs/resources/status_page_v3_automation_rule.md b/docs/resources/status_page_v3_automation_rule.md new file mode 100644 index 0000000..cb53410 --- /dev/null +++ b/docs/resources/status_page_v3_automation_rule.md @@ -0,0 +1,71 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "checkly_status_page_v3_automation_rule Resource - terraform-provider-checkly" +subcategory: "" +description: |- + An automation rule of a v3 status page. When a check whose tags overlap with the rule's tags fails, Checkly opens one incident on the page impacting the listed components, and resolves it when the check recovers. Import uses the composite ID /. +--- + +# checkly_status_page_v3_automation_rule (Resource) + +An automation rule of a v3 status page. When a check whose tags overlap with the rule's tags fails, Checkly opens one incident on the page impacting the listed components, and resolves it when the check recovers. Import uses the composite ID `/`. + +## Example Usage + +```terraform +resource "checkly_status_page_v3" "example" { + name = "Example Application" + url = "my-example-status-page" +} + +resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.example.id + name = "API" + display_order = 0 +} + +# When a check tagged "api" fails, an incident is opened on the page marking +# the API component as a major outage, and resolved when the check recovers. +resource "checkly_status_page_v3_automation_rule" "api_outage" { + status_page_id = checkly_status_page_v3.example.id + name = "API outage" + first_update = "We are investigating an issue with the API." + last_update = "The issue has been resolved." + tags = ["api"] + + component { + component_id = checkly_status_page_v3_component.api.id + target_impact = "MAJOR_OUTAGE" + } +} +``` + + +## Schema + +### Required + +- `first_update` (String) Body of the status update that opens the incident. +- `last_update` (String) Body of the status update that resolves the incident. +- `name` (String) The name of the rule. +- `status_page_id` (String) The ID of the v3 status page the rule belongs to. +- `tags` (Set of String) A failing check matches this rule when it, or its group, carries ANY of these tags. At least one tag is required. + +### Optional + +- `component` (Block Set) The components an automated incident impacts, with the impact each gets. (see [below for nested schema](#nestedblock--component)) +- `cool_down_window_minutes` (Number) Minimum minutes after an automated incident before this rule may open the next one. `0` disables the cool down. (Default `5`). +- `enabled` (Boolean) A disabled rule never opens incidents. (Default `true`). +- `notify_subscribers` (Boolean) Whether subscribers are notified of the automated updates. (Default `true`). + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `component` + +Required: + +- `component_id` (String) The ID of the impacted component. Must be on the same status page. +- `target_impact` (String) The impact set on the component while the incident is open. The allowed values are `UNDER_MAINTENANCE`, `DEGRADED_PERFORMANCE`, `PARTIAL_OUTAGE` and `MAJOR_OUTAGE`. diff --git a/docs/resources/status_page_v3_component.md b/docs/resources/status_page_v3_component.md new file mode 100644 index 0000000..7238562 --- /dev/null +++ b/docs/resources/status_page_v3_component.md @@ -0,0 +1,55 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "checkly_status_page_v3_component Resource - terraform-provider-checkly" +subcategory: "" +description: |- + A component of a v3 status page: either a SERVICE (a monitored thing with its own status) or a GROUP (a container for other components). Import uses the composite ID /. +--- + +# checkly_status_page_v3_component (Resource) + +A component of a v3 status page: either a SERVICE (a monitored thing with its own status) or a GROUP (a container for other components). Import uses the composite ID `/`. + +## Example Usage + +```terraform +resource "checkly_status_page_v3" "example" { + name = "Example Application" + url = "my-example-status-page" +} + +resource "checkly_status_page_v3_component" "services" { + status_page_id = checkly_status_page_v3.example.id + type = "GROUP" + name = "Services" + display_order = 0 +} + +resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.example.id + name = "API" + description = "The public API" + display_order = 1 + parent_id = checkly_status_page_v3_component.services.id +} +``` + + +## Schema + +### Required + +- `display_order` (Number) The position among siblings; lower comes first. +- `name` (String) The name shown on the status page. +- `status_page_id` (String) The ID of the v3 status page the component belongs to. A component cannot be moved to another page. + +### Optional + +- `description` (String) An optional description shown next to the name. +- `hidden` (Boolean) Hide the component from the public page while keeping it available for incidents and automation. (Default `false`). +- `parent_id` (String) The ID of the GROUP component to nest this component under. Must be on the same status page. +- `type` (String) The type of the component. The allowed values are `SERVICE` (a monitored thing with its own status) and `GROUP` (a container for other components). (Default `SERVICE`). + +### Read-Only + +- `id` (String) The ID of this resource. diff --git a/examples/resources/checkly_status_page_v3/resource.tf b/examples/resources/checkly_status_page_v3/resource.tf new file mode 100644 index 0000000..33d9896 --- /dev/null +++ b/examples/resources/checkly_status_page_v3/resource.tf @@ -0,0 +1,27 @@ +resource "checkly_status_page_v3" "example" { + name = "Example Application" + url = "my-example-status-page" + default_theme = "DARK" +} + +# The page structure is declared with components: GROUPs contain SERVICEs. +resource "checkly_status_page_v3_component" "services" { + status_page_id = checkly_status_page_v3.example.id + type = "GROUP" + name = "Services" + display_order = 0 +} + +resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.example.id + name = "API" + display_order = 1 + parent_id = checkly_status_page_v3_component.services.id +} + +resource "checkly_status_page_v3_component" "database" { + status_page_id = checkly_status_page_v3.example.id + name = "Database" + display_order = 2 + parent_id = checkly_status_page_v3_component.services.id +} diff --git a/examples/resources/checkly_status_page_v3_automation_rule/resource.tf b/examples/resources/checkly_status_page_v3_automation_rule/resource.tf new file mode 100644 index 0000000..8c34406 --- /dev/null +++ b/examples/resources/checkly_status_page_v3_automation_rule/resource.tf @@ -0,0 +1,25 @@ +resource "checkly_status_page_v3" "example" { + name = "Example Application" + url = "my-example-status-page" +} + +resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.example.id + name = "API" + display_order = 0 +} + +# When a check tagged "api" fails, an incident is opened on the page marking +# the API component as a major outage, and resolved when the check recovers. +resource "checkly_status_page_v3_automation_rule" "api_outage" { + status_page_id = checkly_status_page_v3.example.id + name = "API outage" + first_update = "We are investigating an issue with the API." + last_update = "The issue has been resolved." + tags = ["api"] + + component { + component_id = checkly_status_page_v3_component.api.id + target_impact = "MAJOR_OUTAGE" + } +} diff --git a/examples/resources/checkly_status_page_v3_component/resource.tf b/examples/resources/checkly_status_page_v3_component/resource.tf new file mode 100644 index 0000000..28c47e2 --- /dev/null +++ b/examples/resources/checkly_status_page_v3_component/resource.tf @@ -0,0 +1,19 @@ +resource "checkly_status_page_v3" "example" { + name = "Example Application" + url = "my-example-status-page" +} + +resource "checkly_status_page_v3_component" "services" { + status_page_id = checkly_status_page_v3.example.id + type = "GROUP" + name = "Services" + display_order = 0 +} + +resource "checkly_status_page_v3_component" "api" { + status_page_id = checkly_status_page_v3.example.id + name = "API" + description = "The public API" + display_order = 1 + parent_id = checkly_status_page_v3_component.services.id +} diff --git a/go.mod b/go.mod index a382f59..eb52d48 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkly/terraform-provider-checkly go 1.26.0 require ( - github.com/checkly/checkly-go-sdk v1.22.0 + github.com/checkly/checkly-go-sdk v1.22.1-0.20260901131331-dbb4714a11df github.com/google/go-cmp v0.7.0 github.com/hashicorp/terraform-plugin-docs v0.25.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.1 diff --git a/go.sum b/go.sum index 1fbba34..7b37733 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/checkly/checkly-go-sdk v1.22.0 h1:Hl3xa5zc7QtxFCSFtfJJ/Uq7GDzUDKbTWtCRcKeF9Fc= github.com/checkly/checkly-go-sdk v1.22.0/go.mod h1:pndYEiaO8wC5Ztyn1PMeNtFVKFQUqZGzwcUmbgIG3Do= +github.com/checkly/checkly-go-sdk v1.22.1-0.20260901131331-dbb4714a11df h1:KwZpK06nqF9y+IGkUWgDhe1tiukWEfA9r6eHcL6rA1k= +github.com/checkly/checkly-go-sdk v1.22.1-0.20260901131331-dbb4714a11df/go.mod h1:pndYEiaO8wC5Ztyn1PMeNtFVKFQUqZGzwcUmbgIG3Do= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= From 1f3b12bf375cde1bb1b2255c4b1a19f1d2ca82a0 Mon Sep 17 00:00:00 2001 From: tnolet Date: Tue, 1 Sep 2026 15:30:46 +0200 Subject: [PATCH 2/2] fix: ensure removed status page v3 optionals clear remotely The v3 page update endpoint only patches fields present in the payload; the SDK previously dropped emptied optional fields from the JSON, so removing e.g. footer_text from config left the old value on the server and produced a permanent diff. Bumps the SDK pin to the commit that always serializes the clearable fields, and adds an acceptance test step reverting to the minimal config to prove the optionals clear. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014WTgyksVAQY5jpD2fb3rYL --- checkly/resource_status_page_v3_test.go | 37 +++++++++++++++++++++++++ go.mod | 2 +- go.sum | 2 ++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/checkly/resource_status_page_v3_test.go b/checkly/resource_status_page_v3_test.go index a54c065..2b4eb21 100644 --- a/checkly/resource_status_page_v3_test.go +++ b/checkly/resource_status_page_v3_test.go @@ -117,6 +117,43 @@ func TestAccStatusPageV3HappyPath(t *testing.T) { ), ), }, + { + // Back to the minimal config: every removed optional must be + // cleared remotely, or this step fails its post-apply plan. + Config: fmt.Sprintf(` + resource "checkly_status_page_v3" "test" { + name = "bar" + url = "status-page-v3-%d" + } + `, rInt), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + statusPageV3Resource, + "description", + "", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "footer_text", + "", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "logo", + "", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "google_analytics_tag", + "", + ), + resource.TestCheckResourceAttr( + statusPageV3Resource, + "allow_indexing", + "true", + ), + ), + }, { ResourceName: statusPageV3Resource, ImportState: true, diff --git a/go.mod b/go.mod index eb52d48..731c37d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkly/terraform-provider-checkly go 1.26.0 require ( - github.com/checkly/checkly-go-sdk v1.22.1-0.20260901131331-dbb4714a11df + github.com/checkly/checkly-go-sdk v1.22.1-0.20260901132945-463d57b1072f github.com/google/go-cmp v0.7.0 github.com/hashicorp/terraform-plugin-docs v0.25.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.1 diff --git a/go.sum b/go.sum index 7b37733..a478336 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/checkly/checkly-go-sdk v1.22.0 h1:Hl3xa5zc7QtxFCSFtfJJ/Uq7GDzUDKbTWtC github.com/checkly/checkly-go-sdk v1.22.0/go.mod h1:pndYEiaO8wC5Ztyn1PMeNtFVKFQUqZGzwcUmbgIG3Do= github.com/checkly/checkly-go-sdk v1.22.1-0.20260901131331-dbb4714a11df h1:KwZpK06nqF9y+IGkUWgDhe1tiukWEfA9r6eHcL6rA1k= github.com/checkly/checkly-go-sdk v1.22.1-0.20260901131331-dbb4714a11df/go.mod h1:pndYEiaO8wC5Ztyn1PMeNtFVKFQUqZGzwcUmbgIG3Do= +github.com/checkly/checkly-go-sdk v1.22.1-0.20260901132945-463d57b1072f h1:JZEMAVNRTtmbYbCBj2fF26WOJ2Tr4B2lp90KTAd0B3g= +github.com/checkly/checkly-go-sdk v1.22.1-0.20260901132945-463d57b1072f/go.mod h1:pndYEiaO8wC5Ztyn1PMeNtFVKFQUqZGzwcUmbgIG3Do= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=