diff --git a/internal/terraform/enums.go b/internal/terraform/enums.go index 10a0cb9..3bc9f3a 100644 --- a/internal/terraform/enums.go +++ b/internal/terraform/enums.go @@ -111,37 +111,6 @@ func recordComparatorToString(c monitorv1.RecordComparator) string { } } -func notificationProviderToString(p notificationv1.NotificationProvider) string { - switch p { - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_DISCORD: - return "discord" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_EMAIL: - return "email" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_GOOGLE_CHAT: - return "google_chat" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_GRAFANA_ONCALL: - return "grafana_oncall" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_NTFY: - return "ntfy" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_PAGERDUTY: - return "pagerduty" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_OPSGENIE: - return "opsgenie" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SLACK: - return "slack" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SMS: - return "sms" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_TELEGRAM: - return "telegram" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_WEBHOOK: - return "webhook" - case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_WHATSAPP: - return "whatsapp" - default: - return "unknown" - } -} - func opsgenieRegionToString(r notificationv1.OpsgenieRegion) string { switch r { case notificationv1.OpsgenieRegion_OPSGENIE_REGION_US: diff --git a/internal/terraform/generate_test.go b/internal/terraform/generate_test.go index bf387a0..cf54094 100644 --- a/internal/terraform/generate_test.go +++ b/internal/terraform/generate_test.go @@ -213,6 +213,108 @@ func TestCrossReferenceFallbackToString(t *testing.T) { mustContain(t, content, `monitor_id = "nonexistent-id"`) } +func TestGenerateNotificationsFile_MsTeams(t *testing.T) { + n := ¬ificationv1.Notification{} + n.SetId("1") + n.SetName("Teams Alerts") + + nd := ¬ificationv1.NotificationData{} + td := ¬ificationv1.MsTeamsData{} + td.SetWebhookUrl("https://prod.example.com/webhook") + nd.SetMsTeams(td) + n.SetData(nd) + + data := &WorkspaceData{Notifications: []*notificationv1.Notification{n}} + gen := NewGenerator(data) + content := string(gen.GenerateNotificationsFile().Bytes()) + + mustContain(t, content, `resource "openstatus_notification" "teams_alerts"`) + mustContain(t, content, `provider_type = "ms_teams"`) + mustContain(t, content, "ms_teams {") + mustContain(t, content, `webhook_url = "https://prod.example.com/webhook"`) +} + +func TestGenerateNotificationsFile_WebhookHeaders(t *testing.T) { + n := ¬ificationv1.Notification{} + n.SetId("1") + n.SetName("Webhook") + + nd := ¬ificationv1.NotificationData{} + wd := ¬ificationv1.WebhookData{} + wd.SetEndpoint("https://example.com/hook") + h1 := ¬ificationv1.WebhookHeader{} + h1.SetKey("Authorization") + h1.SetValue("Bearer xyz") + h2 := ¬ificationv1.WebhookHeader{} + h2.SetKey("X-Source") + h2.SetValue("openstatus") + wd.SetHeaders([]*notificationv1.WebhookHeader{h1, h2}) + nd.SetWebhook(wd) + n.SetData(nd) + + data := &WorkspaceData{Notifications: []*notificationv1.Notification{n}} + gen := NewGenerator(data) + content := string(gen.GenerateNotificationsFile().Bytes()) + + mustContain(t, content, `headers = [`) + mustContain(t, content, `key = "Authorization"`) + mustContain(t, content, `value = "Bearer xyz"`) + mustContain(t, content, `key = "X-Source"`) + mustNotContain(t, content, "headers {") +} + +func TestGenerateNotificationsFile_MonitorIdsTraversal(t *testing.T) { + m := &monitorv1.HTTPMonitor{} + m.SetId("mon-known") + m.SetName("API") + + n := ¬ificationv1.Notification{} + n.SetId("n1") + n.SetName("Slack") + n.SetMonitorIds([]string{"mon-unknown", "mon-known"}) + + nd := ¬ificationv1.NotificationData{} + sd := ¬ificationv1.SlackData{} + sd.SetWebhookUrl("https://hooks.example.com/xxx") + nd.SetSlack(sd) + n.SetData(nd) + + data := &WorkspaceData{ + HTTPMonitors: []*monitorv1.HTTPMonitor{m}, + Notifications: []*notificationv1.Notification{n}, + } + gen := NewGenerator(data) + content := string(gen.GenerateNotificationsFile().Bytes()) + + mustContain(t, content, "openstatus_http_monitor.api.id") + mustContain(t, content, `"mon-unknown"`) + idx := strings.Index(content, "monitor_ids") + if idx == -1 { + t.Fatalf("monitor_ids not found in output:\n%s", content) + } + line := content[idx : strings.Index(content[idx:], "\n")+idx] + if strings.Index(line, "openstatus_http_monitor.api.id") > strings.Index(line, `"mon-unknown"`) { + t.Errorf("expected sorted order (mon-known first, mon-unknown second), got: %s", line) + } +} + +func TestGenerateNotificationsFile_UnknownProviderSkipped(t *testing.T) { + n := ¬ificationv1.Notification{} + n.SetId("n1") + n.SetName("Mystery") + + data := &WorkspaceData{Notifications: []*notificationv1.Notification{n}} + gen := NewGenerator(data) + nContent := string(gen.GenerateNotificationsFile().Bytes()) + importsContent := string(gen.GenerateImportsFile().Bytes()) + + if nContent != "" { + t.Errorf("expected empty notifications file for skipped resource, got:\n%s", nContent) + } + mustNotContain(t, importsContent, "openstatus_notification.mystery") + mustNotContain(t, importsContent, `id = "n1"`) +} + func TestGenerateMonitorsFile_HTTP_OpenTelemetry(t *testing.T) { ot := &monitorv1.OpenTelemetryConfig{} ot.SetEndpoint("https://otel.example.com/v1/metrics") diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go index a04d9cd..b8d45e6 100644 --- a/internal/terraform/hcl.go +++ b/internal/terraform/hcl.go @@ -2,6 +2,7 @@ package terraform import ( "fmt" + "os" "sort" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" @@ -24,29 +25,31 @@ type Generator struct { pageRefs map[string]resourceRef groupRefs map[string]resourceRef - httpMonitorNames map[string]string - tcpMonitorNames map[string]string - dnsMonitorNames map[string]string - notifNames map[string]string - pageNames map[string]string - componentNames map[string]string - groupNames map[string]string + httpMonitorNames map[string]string + tcpMonitorNames map[string]string + dnsMonitorNames map[string]string + notifNames map[string]string + pageNames map[string]string + componentNames map[string]string + groupNames map[string]string + skippedNotifications map[string]bool } func NewGenerator(data *WorkspaceData) *Generator { g := &Generator{ - data: data, - registry: NewNameRegistry(), - monitorRefs: make(map[string]resourceRef), - pageRefs: make(map[string]resourceRef), - groupRefs: make(map[string]resourceRef), - httpMonitorNames: make(map[string]string), - tcpMonitorNames: make(map[string]string), - dnsMonitorNames: make(map[string]string), - notifNames: make(map[string]string), - pageNames: make(map[string]string), - componentNames: make(map[string]string), - groupNames: make(map[string]string), + data: data, + registry: NewNameRegistry(), + monitorRefs: make(map[string]resourceRef), + pageRefs: make(map[string]resourceRef), + groupRefs: make(map[string]resourceRef), + httpMonitorNames: make(map[string]string), + tcpMonitorNames: make(map[string]string), + dnsMonitorNames: make(map[string]string), + notifNames: make(map[string]string), + pageNames: make(map[string]string), + componentNames: make(map[string]string), + groupNames: make(map[string]string), + skippedNotifications: make(map[string]bool), } for _, m := range data.HTTPMonitors { @@ -65,6 +68,11 @@ func NewGenerator(data *WorkspaceData) *Generator { g.monitorRefs[m.GetId()] = resourceRef{"openstatus_dns_monitor", name} } for _, n := range data.Notifications { + if _, ok := renderableNotification(n); !ok { + g.skippedNotifications[n.GetId()] = true + fmt.Fprintf(os.Stderr, "warning: skipping notification %q — unknown provider type (CLI may be outdated)\n", n.GetName()) + continue + } name := g.registry.Name("openstatus_notification", n.GetName()) g.notifNames[n.GetId()] = name } @@ -225,21 +233,21 @@ func (g *Generator) GenerateNotificationsFile() *hclwrite.File { body := f.Body() for _, n := range g.data.Notifications { + if g.skippedNotifications[n.GetId()] { + continue + } + providerType, ok := renderableNotification(n) + if !ok { + continue + } name := g.notifNames[n.GetId()] block := body.AppendNewBlock("resource", []string{"openstatus_notification", name}) b := block.Body() b.SetAttributeValue("name", cty.StringVal(n.GetName())) - b.SetAttributeValue("provider_type", cty.StringVal(notificationProviderToString(n.GetProvider()))) - - if ids := n.GetMonitorIds(); len(ids) > 0 { - vals := make([]cty.Value, len(ids)) - for i, id := range ids { - vals[i] = cty.StringVal(id) - } - b.SetAttributeValue("monitor_ids", cty.SetVal(vals)) - } + b.SetAttributeValue("provider_type", cty.StringVal(providerType)) + g.writeMonitorIds(b, n.GetMonitorIds()) g.writeNotificationProvider(b, n) body.AppendNewline() @@ -248,6 +256,80 @@ func (g *Generator) GenerateNotificationsFile() *hclwrite.File { return f } +func (g *Generator) writeMonitorIds(b *hclwrite.Body, ids []string) { + if len(ids) == 0 { + return + } + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + + tokens := hclwrite.Tokens{ + {Type: hclsyntax.TokenOBrack, Bytes: []byte("[")}, + } + for i, id := range sorted { + if i > 0 { + tokens = append(tokens, + &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte(",")}, + &hclwrite.Token{Type: hclsyntax.TokenIdent, Bytes: []byte(" ")}, + ) + } + if ref, found := g.monitorRefs[id]; found { + tokens = append(tokens, traversalTokensInline(ref.ResourceType, ref.Name, "id")...) + } else { + tokens = append(tokens, stringLitTokens(id)...) + } + } + tokens = append(tokens, + &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte("]")}, + &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + ) + b.SetAttributeRaw("monitor_ids", tokens) +} + +func stringLitTokens(s string) hclwrite.Tokens { + return hclwrite.Tokens{ + {Type: hclsyntax.TokenOQuote, Bytes: []byte(`"`)}, + {Type: hclsyntax.TokenQuotedLit, Bytes: []byte(s)}, + {Type: hclsyntax.TokenCQuote, Bytes: []byte(`"`)}, + } +} + +func renderableNotification(n *notificationv1.Notification) (string, bool) { + data := n.GetData() + if data == nil { + return "", false + } + switch data.Data.(type) { + case *notificationv1.NotificationData_Discord: + return "discord", true + case *notificationv1.NotificationData_Email: + return "email", true + case *notificationv1.NotificationData_Slack: + return "slack", true + case *notificationv1.NotificationData_Pagerduty: + return "pagerduty", true + case *notificationv1.NotificationData_Opsgenie: + return "opsgenie", true + case *notificationv1.NotificationData_Webhook: + return "webhook", true + case *notificationv1.NotificationData_Telegram: + return "telegram", true + case *notificationv1.NotificationData_Sms: + return "sms", true + case *notificationv1.NotificationData_Whatsapp: + return "whatsapp", true + case *notificationv1.NotificationData_GoogleChat: + return "google_chat", true + case *notificationv1.NotificationData_GrafanaOncall: + return "grafana_oncall", true + case *notificationv1.NotificationData_Ntfy: + return "ntfy", true + case *notificationv1.NotificationData_MsTeams: + return "ms_teams", true + } + return "", false +} + func (g *Generator) writeNotificationProvider(b *hclwrite.Body, n *notificationv1.Notification) { data := n.GetData() if data == nil { @@ -276,10 +358,18 @@ func (g *Generator) writeNotificationProvider(b *hclwrite.Body, n *notificationv case *notificationv1.NotificationData_Webhook: pb := b.AppendNewBlock("webhook", nil).Body() pb.SetAttributeValue("endpoint", cty.StringVal(d.Webhook.GetEndpoint())) + vals := make([]cty.Value, 0, len(d.Webhook.GetHeaders())) for _, h := range d.Webhook.GetHeaders() { - hb := pb.AppendNewBlock("headers", nil).Body() - hb.SetAttributeValue("key", cty.StringVal(h.GetKey())) - hb.SetAttributeValue("value", cty.StringVal(h.GetValue())) + if h.GetKey() == "" { + continue + } + vals = append(vals, cty.ObjectVal(map[string]cty.Value{ + "key": cty.StringVal(h.GetKey()), + "value": cty.StringVal(h.GetValue()), + })) + } + if len(vals) > 0 { + pb.SetAttributeValue("headers", cty.ListVal(vals)) } case *notificationv1.NotificationData_Telegram: pb := b.AppendNewBlock("telegram", nil).Body() @@ -306,6 +396,9 @@ func (g *Generator) writeNotificationProvider(b *hclwrite.Body, n *notificationv appendTODOComment(pb) pb.SetAttributeValue("token", cty.StringVal("REPLACE_ME")) } + case *notificationv1.NotificationData_MsTeams: + pb := b.AppendNewBlock("ms_teams", nil).Body() + pb.SetAttributeValue("webhook_url", cty.StringVal(d.MsTeams.GetWebhookUrl())) } } @@ -450,6 +543,9 @@ func (g *Generator) GenerateImportsFile() *hclwrite.File { writeImportBlock(body, "openstatus_dns_monitor", g.dnsMonitorNames[m.GetId()], m.GetId()) } for _, n := range g.data.Notifications { + if g.skippedNotifications[n.GetId()] { + continue + } writeImportBlock(body, "openstatus_notification", g.notifNames[n.GetId()], n.GetId()) } for _, sp := range g.data.StatusPages { @@ -580,6 +676,15 @@ func setTraversalOrString(b *hclwrite.Body, attr string, refs map[string]resourc } func traversalTokens(parts ...string) hclwrite.Tokens { + tokens := traversalTokensInline(parts...) + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + return tokens +} + +func traversalTokensInline(parts ...string) hclwrite.Tokens { tokens := hclwrite.Tokens{} for i, part := range parts { if i > 0 { @@ -593,10 +698,6 @@ func traversalTokens(parts ...string) hclwrite.Tokens { Bytes: []byte(part), }) } - tokens = append(tokens, &hclwrite.Token{ - Type: hclsyntax.TokenNewline, - Bytes: []byte("\n"), - }) return tokens } diff --git a/plan.md b/plan.md index 660e271..07d7fda 100644 --- a/plan.md +++ b/plan.md @@ -483,26 +483,25 @@ Order matters within this phase: add helpers first, then call them. - [x] `internal/terraform/generate_test.go` — `TestGenerateMonitorsFile_OpenTelemetry_SkippedWhenEmpty`: `OpenTelemetryConfig{Endpoint:"", Headers:nil}` → no block. - [x] `go test ./internal/terraform/...` green. -### Phase 5 — Notification refactor + fixes (commit 5: `fix(terraform): notification provider type from data oneof; ms_teams; webhook headers attribute; monitor_ids traversals`) +### Phase 5 — Notification refactor + fixes (commit 5: `fix(terraform): notification provider type from data oneof; ms_teams; webhook headers attribute; monitor_ids traversals`) ✅ This is the largest phase. Land in one commit (per Q7) but write it incrementally. -- [ ] `internal/terraform/hcl.go` — change `writeNotificationProvider` signature to `func writeNotificationProvider(b *hclwrite.Body, n *notificationv1.Notification) (providerType string, ok bool)`; each oneof case returns its tf-string + `true`; nil data or unknown oneof returns `"", false`. -- [ ] `internal/terraform/hcl.go` — add the `*notificationv1.NotificationData_MsTeams` case emitting `ms_teams { webhook_url = … }`. -- [ ] `internal/terraform/hcl.go` — rewrite the `*notificationv1.NotificationData_Webhook` case so `headers` is emitted as a `cty.ListVal([]cty.Value{cty.ObjectVal({key,value})})` set via `SetAttributeValue("headers", …)`, not as nested `headers { … }` blocks. -- [ ] `internal/terraform/hcl.go` — replace the current `monitor_ids` plain-string emission with a token-list builder. **Sort ids via `sort.Strings` first** (set semantics, deterministic output). Walk sorted ids, traversal-token for ids in `monitorRefs`, string-literal-token otherwise, joined with commas and bracketed; set via `SetAttributeRaw("monitor_ids", tokens)`. Helper-extract a small `commaToken()`, `stringLitToken(s)` for legibility. -- [ ] `internal/terraform/hcl.go` — note: the existing `traversalTokens` helper appends a trailing newline (suited for the `to = openstatus_x.foo.id` line in `import` blocks). For inline list usage in `monitor_ids`, add `traversalTokensInline(parts ...string) hclwrite.Tokens` that emits identical tokens **without** the trailing newline, and refactor `traversalTokens` to call it + append the newline. -- [ ] `internal/terraform/hcl.go` — add `renderableNotification(n *notificationv1.Notification) (providerType string, ok bool)`: a single switch over `n.GetData().Data` that returns `("discord", true)` / `("ms_teams", true)` / etc. for each known oneof case, and `("", false)` for nil data or unknown oneof. -- [ ] `internal/terraform/hcl.go` — `Generator` struct: add `skippedNotifications map[string]bool` field; initialize in `NewGenerator`. -- [ ] `internal/terraform/hcl.go` — in `NewGenerator`'s notifications loop: call `renderableNotification(n)`. If `!ok`, set `g.skippedNotifications[n.GetId()] = true` and `fmt.Fprintf(os.Stderr, "warning: skipping notification %q — unknown provider type (CLI may be outdated)\n", n.GetName())`. If `ok`, register the name as today. -- [ ] `internal/terraform/hcl.go` — `GenerateNotificationsFile`: at the top of the per-notification loop, `if g.skippedNotifications[n.GetId()] { continue }`. Use `writeNotificationProvider` for the inner block (the same switch as `renderableNotification` but emitting tokens — slight duplication accepted). -- [ ] `internal/terraform/hcl.go` — `GenerateImportsFile`: same `if g.skippedNotifications[n.GetId()] { continue }` guard before emitting the notification import block. -- [ ] `internal/terraform/enums.go` — remove `notificationProviderToString` (now unused), OR keep it for reference but mark unused. Recommend remove. -- [ ] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_MsTeams`: oneof set to MsTeamsData → `provider_type = "ms_teams"`, `ms_teams { webhook_url = … }`. -- [ ] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_WebhookHeaders`: webhook with two headers → assert `headers = [` literal substring, no `headers {` block. -- [ ] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_MonitorIdsTraversal`: workspace with one HTTP monitor `mon-1` named `"API"` + a notification referencing `mon-1` and `mon-unknown` → assert `monitor_ids = [openstatus_http_monitor.api.id, "mon-unknown"]`. -- [ ] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_UnknownProviderSkipped`: notification with nil/UNSPECIFIED data → no resource block emitted; `GenerateImportsFile` does not include its id. -- [ ] `go test ./internal/terraform/...` green. +- [x] `internal/terraform/hcl.go` — add the `*notificationv1.NotificationData_MsTeams` case emitting `ms_teams { webhook_url = … }`. +- [x] `internal/terraform/hcl.go` — rewrite the `*notificationv1.NotificationData_Webhook` case so `headers` is emitted as a `cty.ListVal([]cty.Value{cty.ObjectVal({key,value})})` set via `SetAttributeValue("headers", …)`, not as nested `headers { … }` blocks. +- [x] `internal/terraform/hcl.go` — replace the current `monitor_ids` plain-string emission with a token-list builder (`writeMonitorIds`). Sort via `sort.Strings` first; traversal tokens for known refs, string-literal tokens otherwise. +- [x] `internal/terraform/hcl.go` — add `traversalTokensInline(parts ...string)` (no trailing newline) and refactor `traversalTokens` to call it + append newline. Add `stringLitTokens(s)` helper. +- [x] `internal/terraform/hcl.go` — add `renderableNotification(n) (providerType string, ok bool)` switch returning the tf-string per oneof case. +- [x] `internal/terraform/hcl.go` — `Generator` struct: add `skippedNotifications map[string]bool` field; initialize in `NewGenerator`. +- [x] `internal/terraform/hcl.go` — `NewGenerator` notifications loop: skip + warn on `!ok` via `renderableNotification`. +- [x] `internal/terraform/hcl.go` — `GenerateNotificationsFile`: skip when `g.skippedNotifications[n.GetId()]`; provider_type comes from `renderableNotification`. +- [x] `internal/terraform/hcl.go` — `GenerateImportsFile`: same skip guard for the notification import block. +- [x] `internal/terraform/enums.go` — remove `notificationProviderToString` (now unused). +- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_MsTeams`. +- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_WebhookHeaders`. +- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_MonitorIdsTraversal`. +- [x] `internal/terraform/generate_test.go` — `TestGenerateNotificationsFile_UnknownProviderSkipped`. +- [x] `go test ./internal/terraform/...` green. ### Phase 6 — CLI ergonomics (commit 6: `feat(terraform): --force flag and terraform init -upgrade hint`)