diff --git a/examples/resources/launchdarkly_alert/main.tf b/examples/resources/launchdarkly_alert/main.tf new file mode 100644 index 00000000..ecf03511 --- /dev/null +++ b/examples/resources/launchdarkly_alert/main.tf @@ -0,0 +1,53 @@ +terraform { + required_providers { + launchdarkly = { + source = "launchdarkly/launchdarkly" + } + } +} + +provider "launchdarkly" { + access_token = var.ld_access_token + observability_host = var.observability_host +} + +variable "ld_access_token" { + description = "LaunchDarkly personal access token (api-...)" + type = string + sensitive = true +} + +variable "observability_host" { + description = "Observability backend base URL (e.g. http://localhost:8082)" + type = string +} + +variable "project_id" { + description = "Observability project string ID" + type = string +} + +resource "launchdarkly_alert" "test" { + project_id = var.project_id + name = "Terraform E2E Test Alert" + product_type = "Errors" + function_type = "Count" + + slack_channels = ["#alerts-channel"] + + emails = ["oncall@example.com"] + + triggers { + type = "Constant" + condition = "Above" + alert_value = 100 + warn_value = 50 + } + + query = "level=error" + disabled = false +} + +output "alert_id" { + value = launchdarkly_alert.test.id +} diff --git a/launchdarkly/config.go b/launchdarkly/config.go index 8f1df1f6..7a634738 100644 --- a/launchdarkly/config.go +++ b/launchdarkly/config.go @@ -1,9 +1,12 @@ package launchdarkly import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "io" "log" "math" "net/http" @@ -43,6 +46,49 @@ type Client struct { fallbackClient *http.Client semaphore *semaphore.Weighted + + // observabilityClient and observabilityHost are used for the observability + // platform's REST API (alert CRUD, etc.). The existing apiKey is reused + // for auth via the Gonfalon-Authorization header. + observabilityClient *http.Client + observabilityHost string +} + +// observabilityRequest makes an authenticated HTTP request to the observability +// backend. The existing apiKey is sent as the Gonfalon-Authorization header +// so that NewGonfalonOAuthMiddleware can validate it. body may be nil for +// requests that have no payload (GET, DELETE). +func (c *Client) observabilityRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { + if c.observabilityHost == "" { + return nil, fmt.Errorf("observability_host must be configured to manage observability alerts") + } + + var reqBody io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to encode request body: %w", err) + } + reqBody = bytes.NewBuffer(encoded) + } + + endpoint := strings.TrimRight(c.observabilityHost, "/") + path + req, err := http.NewRequestWithContext(ctx, method, endpoint, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to build observability request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Gonfalon-Authorization", c.apiKey) + + var resp *http.Response + err = c.withConcurrency(ctx, func() error { + resp, err = c.observabilityClient.Do(req) + return err + }) + if err != nil { + return nil, err + } + return resp, nil } func (c *Client) withConcurrency(ctx context.Context, fn func() error) error { @@ -114,14 +160,18 @@ func baseNewClient(token string, apiHost string, oauth bool, httpTimeoutSeconds fallbackClient := newRetryableClient(standardRetryPolicy) fallbackClient.Timeout = time.Duration(5 * time.Second) + observabilityClient := newRetryableClient(standardRetryPolicy) + observabilityClient.Timeout = time.Duration(httpTimeoutSeconds) * time.Second + return &Client{ - apiKey: token, - apiHost: apiHost, - ld: ldapi.NewAPIClient(standardConfig), - ld404Retry: ldapi.NewAPIClient(configWith404Retries), - ctx: ctx, - fallbackClient: fallbackClient, - semaphore: semaphore.NewWeighted(int64(maxConcurrent)), + apiKey: token, + apiHost: apiHost, + ld: ldapi.NewAPIClient(standardConfig), + ld404Retry: ldapi.NewAPIClient(configWith404Retries), + ctx: ctx, + fallbackClient: fallbackClient, + observabilityClient: observabilityClient, + semaphore: semaphore.NewWeighted(int64(maxConcurrent)), }, nil } diff --git a/launchdarkly/keys.go b/launchdarkly/keys.go index e93f7716..7d5a1580 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -7,6 +7,9 @@ const ( ACTIONS = "actions" ACTION_SET = "action_set" AI_CONFIG_KEY = "config_key" + ALERT_ID = "alert_id" + ALERT_VALUE = "alert_value" + AUTO_INVESTIGATION_ENABLED = "auto_investigation_enabled" ANALYSIS_TYPE = "analysis_type" API_KEY = "api_key" APPROVAL_SETTINGS = "approval_settings" @@ -44,6 +47,9 @@ const ( DEFAULT_TTL = "default_ttl" DEPRECATED = "deprecated" DESCRIPTION = "description" + DESTINATION_TYPE = "destination_type" + DESTINATIONS = "destinations" + DISABLED = "disabled" DISPLAY_KEY = "display_key" EFFECT = "effect" EMAIL = "email" @@ -51,6 +57,7 @@ const ( ENABLED = "enabled" ENVIRONMENTS = "environments" ENV_KEY = "env_key" + EVALUATION_DELAY_SECONDS = "evaluation_delay_seconds" EVALUATION_METRIC_KEY = "evaluation_metric_key" EVENT_KEY = "event_key" EXCLUDED = "excluded" @@ -65,7 +72,11 @@ const ( FLAG_ID = "flag_id" FLAG_KEY = "flag_key" FULL_KEY = "full_key" + FUNCTION_COLUMN = "function_column" + FUNCTION_TYPE = "function_type" GLOBAL = "global" + GROUP_BY_KEYS = "group_by_keys" + HIDE_GRAPH = "hide_graph" GUARDED_RELEASE_CONFIG = "guarded_release_config" ICON = "icon" ID = "id" @@ -74,9 +85,14 @@ const ( INCLUDED_CONTEXTS = "included_contexts" INCLUDE_IN_SNIPPET = "include_in_snippet" INCLUDE_UNITS_WITHOUT_EVENTS = "include_units_without_events" + INFO_VALUE = "info_value" INLINE_ROLES = "inline_roles" INSTRUCTIONS = "instructions" INTEGRATION_KEY = "integration_key" + INVESTIGATION_COOLDOWN = "investigation_cooldown" + INVESTIGATION_MODE = "investigation_mode" + INVESTIGATION_PROMPT = "investigation_prompt" + INVESTIGATION_REPOSITORIES = "investigation_repositories" IP_ADDRESS = "ip_address" IS_ACTIVE = "is_active" IS_INVERTED = "is_inverted" @@ -91,6 +107,7 @@ const ( MAINTAINER_ID = "maintainer_id" MAINTAINER_TEAM_KEY = "maintainer_team_key" MEMBER_IDS = "member_ids" + MESSAGE_CONTENT = "message_content" MESSAGES = "messages" MIN_NUM_APPROVALS = "min_num_approvals" MIN_SAMPLE_SIZE = "min_sample_size" @@ -103,6 +120,7 @@ const ( NEGATE = "negate" NOT_ACTIONS = "not_actions" NOT_RESOURCES = "not_resources" + OBSERVABILITY_HOST = "observability_host" OFF_VARIATION = "off_variation" ON = "on" ON_VARIATION = "on_variation" @@ -112,8 +130,10 @@ const ( PERCENTILE_VALUE = "percentile_value" POLICY = "policy" POLICY_STATEMENTS = "policy_statements" + PRODUCT_TYPE = "product_type" PREREQUISITES = "prerequisites" PROGRESSIVE_RELEASE_CONFIG = "progressive_release_config" + PROJECT_ID = "project_id" PROJECT_KEY = "project_key" PROJECT_KEYS = "project_keys" PROVIDER_NAME = "model_provider" @@ -159,10 +179,15 @@ const ( TARGETS = "targets" TEAM_MEMBERS = "team_members" TEMPORARY = "temporary" + THRESHOLD_CONDITION = "threshold_condition" + THRESHOLD_TYPE = "threshold_type" TOKEN = "token" TOOL_KEYS = "tool_keys" TRACK_EVENTS = "track_events" TRIGGER_URL = "trigger_url" + TRIGGERS = "triggers" + TYPE_ID = "type_id" + TYPE_NAME = "type_name" TRUE_DESCRIPTION = "true_description" TRUE_DISPLAY_NAME = "true_display_name" UNBOUNDED = "unbounded" @@ -183,6 +208,7 @@ const ( VERSION = "version" VIEWS = "views" VIEW_KEY = "view_key" + WARN_VALUE = "warn_value" VIEW_KEYS = "view_keys" WEIGHT = "weight" ) diff --git a/launchdarkly/provider.go b/launchdarkly/provider.go index 4bf304b3..20468f4f 100644 --- a/launchdarkly/provider.go +++ b/launchdarkly/provider.go @@ -19,9 +19,10 @@ const ( // Environment Variables const ( - LAUNCHDARKLY_ACCESS_TOKEN = "LAUNCHDARKLY_ACCESS_TOKEN" - LAUNCHDARKLY_API_HOST = "LAUNCHDARKLY_API_HOST" - LAUNCHDARKLY_OAUTH_TOKEN = "LAUNCHDARKLY_OAUTH_TOKEN" + LAUNCHDARKLY_ACCESS_TOKEN = "LAUNCHDARKLY_ACCESS_TOKEN" + LAUNCHDARKLY_API_HOST = "LAUNCHDARKLY_API_HOST" + LAUNCHDARKLY_OAUTH_TOKEN = "LAUNCHDARKLY_OAUTH_TOKEN" + LAUNCHDARKLY_OBSERVABILITY_HOST = "LAUNCHDARKLY_OBSERVABILITY_HOST" ) // Provider keys @@ -54,6 +55,11 @@ func providerSchema() map[string]*schema.Schema { Optional: true, Description: "The HTTP timeout (in seconds) when making API calls to LaunchDarkly. Defaults to 20 seconds.", }, + OBSERVABILITY_HOST: { + Type: schema.TypeString, + Optional: true, + Description: "The LaunchDarkly Observability host address (e.g. `https://app.highlight.io`). Required when managing `launchdarkly_alert` resources. You can also set this with the `LAUNCHDARKLY_OBSERVABILITY_HOST` environment variable.", + }, } } @@ -86,6 +92,7 @@ func Provider() *schema.Provider { "launchdarkly_view": resourceView(), "launchdarkly_view_filter_links": resourceViewFilterLinks(), "launchdarkly_view_links": resourceViewLinks(), + "launchdarkly_alert": resourceAlert(), "launchdarkly_webhook": resourceWebhook(), }, DataSourcesMap: map[string]*schema.Resource{ @@ -120,6 +127,7 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{} accessToken := os.Getenv(LAUNCHDARKLY_ACCESS_TOKEN) oauthToken := os.Getenv(LAUNCHDARKLY_OAUTH_TOKEN) host := os.Getenv(LAUNCHDARKLY_API_HOST) + observabilityHost := os.Getenv(LAUNCHDARKLY_OBSERVABILITY_HOST) if host == "" { host = DEFAULT_LAUNCHDARKLY_HOST @@ -148,22 +156,26 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{} return nil, diag.Errorf("either an %q or %q must be specified", ACCESS_TOKEN, OAUTH_TOKEN) } + configObservabilityHost := optionalStringAttr(d, OBSERVABILITY_HOST) + if configObservabilityHost != "" { + observabilityHost = configObservabilityHost + } + httpTimeoutSeconds := optionalIntFromResourceData(d, HTTP_TIMEOUT, 0) if httpTimeoutSeconds == 0 { httpTimeoutSeconds = DEFAULT_HTTP_TIMEOUT_S } + var client *Client + var err error if oauthToken != "" { - client, err := newClient(oauthToken, host, true, httpTimeoutSeconds, DEFAULT_MAX_CONCURRENCY) - if err != nil { - return client, diag.FromErr(err) - } - return client, diags + client, err = newClient(oauthToken, host, true, httpTimeoutSeconds, DEFAULT_MAX_CONCURRENCY) + } else { + client, err = newClient(accessToken, host, false, httpTimeoutSeconds, DEFAULT_MAX_CONCURRENCY) } - - client, err := newClient(accessToken, host, false, httpTimeoutSeconds, DEFAULT_MAX_CONCURRENCY) if err != nil { return client, diag.FromErr(err) } + client.observabilityHost = observabilityHost return client, diags } diff --git a/launchdarkly/resource_launchdarkly_alert.go b/launchdarkly/resource_launchdarkly_alert.go new file mode 100644 index 00000000..c0766dde --- /dev/null +++ b/launchdarkly/resource_launchdarkly_alert.go @@ -0,0 +1,526 @@ +package launchdarkly + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +// alertAPIResponse mirrors the JSON shape returned by the observability backend. +type alertAPIResponse struct { + ID int `json:"id"` + ProjectID int `json:"project_id"` + Name string `json:"name"` + MessageContent *string `json:"message_content"` + ProductType string `json:"product_type"` + FunctionType string `json:"function_type"` + FunctionColumn *string `json:"function_column"` + Query *string `json:"query"` + GroupByKeys []string `json:"group_by_keys"` + Disabled bool `json:"disabled"` + SlackChannels []string `json:"slack_channels"` + Emails []string `json:"emails"` + Triggers []alertTriggerAPIResponse `json:"triggers"` + ThresholdValue *float64 `json:"threshold_value"` + ThresholdWindow *int `json:"threshold_window"` + ThresholdCooldown *int `json:"threshold_cooldown"` + ThresholdType string `json:"threshold_type"` + ThresholdCondition string `json:"threshold_condition"` + AutoInvestigationEnabled bool `json:"auto_investigation_enabled"` + InvestigationCooldown *int `json:"investigation_cooldown"` + InvestigationMode *string `json:"investigation_mode"` + InvestigationRepositories []string `json:"investigation_repositories"` + InvestigationPrompt *string `json:"investigation_prompt"` + EvaluationDelaySeconds *int `json:"evaluation_delay_seconds"` + HideGraph bool `json:"hide_graph"` +} + +type alertTriggerAPIResponse struct { + Type string `json:"type"` + Condition string `json:"condition"` + InfoValue *float64 `json:"info_value"` + WarnValue *float64 `json:"warn_value"` + AlertValue *float64 `json:"alert_value"` +} + +func resourceAlert() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceAlertCreate, + ReadContext: resourceAlertRead, + UpdateContext: resourceAlertUpdate, + DeleteContext: resourceAlertDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceAlertImport, + }, + Description: "Manages a LaunchDarkly Observability alert. Requires `observability_host` to be set on the provider.", + Schema: map[string]*schema.Schema{ + PROJECT_ID: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The observability project ID.", + }, + NAME: { + Type: schema.TypeString, + Required: true, + Description: "The display name for the alert.", + }, + PRODUCT_TYPE: { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + Description: "The product type for the alert (e.g. `Errors`, `Logs`, `Traces`, `Sessions`, `Metrics`).", + }, + FUNCTION_TYPE: { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + Description: "The aggregation function (e.g. `Count`, `P50`, `P75`, `P90`, `P95`, `P99`, `Max`, `Avg`, `Sum`).", + }, + "slack_channels": { + Type: schema.TypeList, + Optional: true, + Description: "Slack channel names or IDs to notify when the alert fires (e.g. `#alerts` or `C12345ABC`). The backend resolves each value against the workspace's integrated Slack channels.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + EMAILS: { + Type: schema.TypeList, + Optional: true, + Description: "Email addresses to notify when the alert fires.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + TRIGGERS: { + Type: schema.TypeList, + Optional: true, + Description: "Alert trigger thresholds.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + THRESHOLD_TYPE: { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + Description: "Threshold type: `Constant`, `Percent`, or `PercentChange`.", + }, + THRESHOLD_CONDITION: { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + Description: "Threshold condition: `Above` or `Below`.", + }, + INFO_VALUE: { + Type: schema.TypeFloat, + Optional: true, + Description: "Threshold value for an info-level firing.", + }, + WARN_VALUE: { + Type: schema.TypeFloat, + Optional: true, + Description: "Threshold value for a warn-level firing.", + }, + ALERT_VALUE: { + Type: schema.TypeFloat, + Optional: true, + Description: "Threshold value for an alert-level firing.", + }, + }, + }, + }, + FUNCTION_COLUMN: { + Type: schema.TypeString, + Optional: true, + Description: "The numeric column to aggregate (e.g. `duration`).", + }, + "query": { + Type: schema.TypeString, + Optional: true, + Description: "Search query used to filter telemetry for this alert.", + }, + GROUP_BY_KEYS: { + Type: schema.TypeList, + Optional: true, + Description: "Attribute keys to group results by.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + MESSAGE_CONTENT: { + Type: schema.TypeString, + Optional: true, + Description: "Custom message included in alert notifications.", + }, + DISABLED: { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When `true`, the alert is disabled and will not fire.", + }, + HIDE_GRAPH: { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When `true`, suppresses the inline graph image from alert notifications.", + }, + EVALUATION_DELAY_SECONDS: { + Type: schema.TypeInt, + Optional: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.IntBetween(0, 3600)), + Description: "Seconds of evaluation delay (0–3600) to allow late-arriving data.", + }, + AUTO_INVESTIGATION_ENABLED: { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When `true`, Vega automatically investigates fired alerts.", + }, + INVESTIGATION_COOLDOWN: { + Type: schema.TypeInt, + Optional: true, + Description: "Cooldown in seconds before starting a new AI investigation (default 86400).", + }, + INVESTIGATION_MODE: { + Type: schema.TypeString, + Optional: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + Description: "AI investigation mode (e.g. `Investigate`, `InvestigateWithCode`, `Fix`).", + }, + INVESTIGATION_REPOSITORIES: { + Type: schema.TypeList, + Optional: true, + Description: "GitHub repository slugs available to the AI investigation.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + INVESTIGATION_PROMPT: { + Type: schema.TypeString, + Optional: true, + Description: "Custom prompt prepended when starting an AI investigation.", + }, + }, + } +} + +func alertSchemaToBody(d *schema.ResourceData) map[string]interface{} { + body := map[string]interface{}{ + "project_id": d.Get(PROJECT_ID).(string), + "name": d.Get(NAME).(string), + "product_type": d.Get(PRODUCT_TYPE).(string), + "function_type": d.Get(FUNCTION_TYPE).(string), + "disabled": d.Get(DISABLED).(bool), + "hide_graph": d.Get(HIDE_GRAPH).(bool), + "auto_investigation_enabled": d.Get(AUTO_INVESTIGATION_ENABLED).(bool), + } + + if v, ok := d.GetOk(FUNCTION_COLUMN); ok { + body["function_column"] = v.(string) + } + if v, ok := d.GetOk("query"); ok { + body["query"] = v.(string) + } + if v, ok := d.GetOk(MESSAGE_CONTENT); ok { + body["message_content"] = v.(string) + } + if v, ok := d.GetOk(EVALUATION_DELAY_SECONDS); ok { + body["evaluation_delay_seconds"] = v.(int) + } + if v, ok := d.GetOk(INVESTIGATION_COOLDOWN); ok { + body["investigation_cooldown"] = v.(int) + } + if v, ok := d.GetOk(INVESTIGATION_MODE); ok { + body["investigation_mode"] = v.(string) + } + if v, ok := d.GetOk(INVESTIGATION_PROMPT); ok { + body["investigation_prompt"] = v.(string) + } + + if v, ok := d.GetOk(GROUP_BY_KEYS); ok { + raw := v.([]interface{}) + keys := make([]string, len(raw)) + for i, k := range raw { + keys[i] = k.(string) + } + body["group_by_keys"] = keys + } + + if v, ok := d.GetOk(INVESTIGATION_REPOSITORIES); ok { + raw := v.([]interface{}) + repos := make([]string, len(raw)) + for i, r := range raw { + repos[i] = r.(string) + } + body["investigation_repositories"] = repos + } + + if rawChannels, ok := d.GetOk("slack_channels"); ok { + raw := rawChannels.([]interface{}) + channels := make([]string, len(raw)) + for i, ch := range raw { + channels[i] = ch.(string) + } + body["slack_channels"] = channels + } + + if rawEmails, ok := d.GetOk(EMAILS); ok { + raw := rawEmails.([]interface{}) + emails := make([]string, len(raw)) + for i, e := range raw { + emails[i] = e.(string) + } + body["emails"] = emails + } + + if rawTriggers, ok := d.GetOk(TRIGGERS); ok { + triggers := make([]map[string]interface{}, 0) + for _, rt := range rawTriggers.([]interface{}) { + tm := rt.(map[string]interface{}) + t := map[string]interface{}{ + "type": tm[THRESHOLD_TYPE].(string), + "condition": tm[THRESHOLD_CONDITION].(string), + } + if v, ok := tm[INFO_VALUE]; ok && v.(float64) != 0 { + t["info_value"] = v.(float64) + } + if v, ok := tm[WARN_VALUE]; ok && v.(float64) != 0 { + t["warn_value"] = v.(float64) + } + if v, ok := tm[ALERT_VALUE]; ok && v.(float64) != 0 { + t["alert_value"] = v.(float64) + } + triggers = append(triggers, t) + } + body["triggers"] = triggers + } + + return body +} + +func alertResponseToState(d *schema.ResourceData, alert *alertAPIResponse) diag.Diagnostics { + // project_id is not set from the response here — the API returns a numeric + // project ID, while Terraform state stores the string verbose ID. Callers + // are responsible for setting project_id from the resource ID before calling + // this function (see resourceAlertRead). + if err := d.Set(NAME, alert.Name); err != nil { + return diag.FromErr(err) + } + if err := d.Set(PRODUCT_TYPE, alert.ProductType); err != nil { + return diag.FromErr(err) + } + if err := d.Set(FUNCTION_TYPE, alert.FunctionType); err != nil { + return diag.FromErr(err) + } + if err := d.Set(DISABLED, alert.Disabled); err != nil { + return diag.FromErr(err) + } + if err := d.Set(HIDE_GRAPH, alert.HideGraph); err != nil { + return diag.FromErr(err) + } + if err := d.Set(AUTO_INVESTIGATION_ENABLED, alert.AutoInvestigationEnabled); err != nil { + return diag.FromErr(err) + } + + if alert.FunctionColumn != nil { + if err := d.Set(FUNCTION_COLUMN, *alert.FunctionColumn); err != nil { + return diag.FromErr(err) + } + } + if alert.Query != nil { + if err := d.Set("query", *alert.Query); err != nil { + return diag.FromErr(err) + } + } + if alert.MessageContent != nil { + if err := d.Set(MESSAGE_CONTENT, *alert.MessageContent); err != nil { + return diag.FromErr(err) + } + } + if alert.EvaluationDelaySeconds != nil { + if err := d.Set(EVALUATION_DELAY_SECONDS, *alert.EvaluationDelaySeconds); err != nil { + return diag.FromErr(err) + } + } + if alert.InvestigationCooldown != nil { + if err := d.Set(INVESTIGATION_COOLDOWN, *alert.InvestigationCooldown); err != nil { + return diag.FromErr(err) + } + } + if alert.InvestigationMode != nil { + if err := d.Set(INVESTIGATION_MODE, *alert.InvestigationMode); err != nil { + return diag.FromErr(err) + } + } + if alert.InvestigationPrompt != nil { + if err := d.Set(INVESTIGATION_PROMPT, *alert.InvestigationPrompt); err != nil { + return diag.FromErr(err) + } + } + + if len(alert.GroupByKeys) > 0 { + if err := d.Set(GROUP_BY_KEYS, alert.GroupByKeys); err != nil { + return diag.FromErr(err) + } + } + if len(alert.InvestigationRepositories) > 0 { + if err := d.Set(INVESTIGATION_REPOSITORIES, alert.InvestigationRepositories); err != nil { + return diag.FromErr(err) + } + } + + if err := d.Set("slack_channels", alert.SlackChannels); err != nil { + return diag.FromErr(err) + } + + if err := d.Set(EMAILS, alert.Emails); err != nil { + return diag.FromErr(err) + } + + triggers := make([]map[string]interface{}, 0, len(alert.Triggers)) + for _, t := range alert.Triggers { + tm := map[string]interface{}{ + THRESHOLD_TYPE: t.Type, + THRESHOLD_CONDITION: t.Condition, + } + if t.InfoValue != nil { + tm[INFO_VALUE] = *t.InfoValue + } + if t.WarnValue != nil { + tm[WARN_VALUE] = *t.WarnValue + } + if t.AlertValue != nil { + tm[ALERT_VALUE] = *t.AlertValue + } + triggers = append(triggers, tm) + } + if err := d.Set(TRIGGERS, triggers); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func doAlertRequest(client *Client, method, path string, body interface{}) (*alertAPIResponse, int, error) { + resp, err := client.observabilityRequest(client.ctx, method, path, body) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent { + return nil, resp.StatusCode, nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, resp.StatusCode, fmt.Errorf("observability API returned %d: %s", resp.StatusCode, string(raw)) + } + + var alert alertAPIResponse + if err := json.Unmarshal(raw, &alert); err != nil { + return nil, resp.StatusCode, fmt.Errorf("failed to decode alert response: %w", err) + } + return &alert, resp.StatusCode, nil +} + +func parseAlertID(id string) (string, int, error) { + parts := strings.SplitN(id, "/", 2) + if len(parts) != 2 { + return "", 0, fmt.Errorf("alert ID %q must be in the format {project_id}/{alert_id}", id) + } + alertID, err := strconv.Atoi(parts[1]) + if err != nil { + return "", 0, fmt.Errorf("alert ID %q has non-integer alert_id component: %w", id, err) + } + return parts[0], alertID, nil +} + +func resourceAlertCreate(ctx context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + client := metaRaw.(*Client) + body := alertSchemaToBody(d) + + alert, _, err := doAlertRequest(client, http.MethodPost, "/private/terraform/alerts", body) + if err != nil { + return diag.Errorf("failed to create alert: %s", err) + } + + d.SetId(fmt.Sprintf("%s/%d", d.Get(PROJECT_ID).(string), alert.ID)) + return resourceAlertRead(ctx, d, metaRaw) +} + +func resourceAlertRead(_ context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + client := metaRaw.(*Client) + projectID, alertID, err := parseAlertID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + // Set project_id from the resource ID so the state always has the string + // verbose ID, not the numeric ID returned by the API. + if err := d.Set(PROJECT_ID, projectID); err != nil { + return diag.FromErr(err) + } + + path := fmt.Sprintf("/private/terraform/alerts/%d?project_id=%s", alertID, projectID) + alert, statusCode, err := doAlertRequest(client, http.MethodGet, path, nil) + if err != nil { + if statusCode == http.StatusNotFound { + d.SetId("") + return nil + } + return diag.Errorf("failed to read alert %s: %s", d.Id(), err) + } + + return alertResponseToState(d, alert) +} + +func resourceAlertUpdate(ctx context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + client := metaRaw.(*Client) + _, alertID, err := parseAlertID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + body := alertSchemaToBody(d) + path := fmt.Sprintf("/private/terraform/alerts/%d", alertID) + + _, _, err = doAlertRequest(client, http.MethodPut, path, body) + if err != nil { + return diag.Errorf("failed to update alert %s: %s", d.Id(), err) + } + + return resourceAlertRead(ctx, d, metaRaw) +} + +func resourceAlertDelete(_ context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + client := metaRaw.(*Client) + projectID, alertID, err := parseAlertID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + path := fmt.Sprintf("/private/terraform/alerts/%d?project_id=%s", alertID, projectID) + _, _, err = doAlertRequest(client, http.MethodDelete, path, nil) + if err != nil { + return diag.Errorf("failed to delete alert %s: %s", d.Id(), err) + } + + return nil +} + +func resourceAlertImport(_ context.Context, d *schema.ResourceData, _ interface{}) ([]*schema.ResourceData, error) { + projectID, _, err := parseAlertID(d.Id()) + if err != nil { + return nil, err + } + // Set project_id so it's in state before resourceAlertRead is called. + if err := d.Set(PROJECT_ID, projectID); err != nil { + return nil, err + } + return []*schema.ResourceData{d}, nil +}