Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions examples/resources/launchdarkly_alert/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 57 additions & 7 deletions launchdarkly/config.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package launchdarkly

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
26 changes: 26 additions & 0 deletions launchdarkly/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -44,13 +47,17 @@ 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"
EMAILS = "emails"
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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -183,6 +208,7 @@ const (
VERSION = "version"
VIEWS = "views"
VIEW_KEY = "view_key"
WARN_VALUE = "warn_value"
VIEW_KEYS = "view_keys"
WEIGHT = "weight"
)
32 changes: 22 additions & 10 deletions launchdarkly/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.",
},
}
}

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading