diff --git a/internal/terraform/smoke_test.go b/internal/terraform/smoke_test.go new file mode 100644 index 0000000..9c9d2be --- /dev/null +++ b/internal/terraform/smoke_test.go @@ -0,0 +1,192 @@ +//go:build smoke + +// Run with: go test -tags=smoke ./internal/terraform/ +// +// This smoke test exercises the generator's output against a real terraform +// binary and the live openstatusHQ/openstatus provider from the public +// registry. It does NOT require an API token and does NOT call any +// OpenStatus RPC — only `terraform init` (which fetches the provider) and +// `terraform validate` (which checks schema/syntax fit). +// +// Skipped automatically when the terraform binary is not on PATH. + +package terraform + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" +) + +func TestSmokeValidate(t *testing.T) { + if _, err := exec.LookPath("terraform"); err != nil { + t.Skipf("terraform binary not in PATH; install terraform 1.0+ to run this test") + } + + dir := t.TempDir() + data := smokeFixture() + gen := NewGenerator(data) + + files := map[string][]byte{ + "provider.tf": GenerateProviderFile(), + "monitors.tf": gen.GenerateMonitorsFile().Bytes(), + "notifications.tf": gen.GenerateNotificationsFile().Bytes(), + "status_pages.tf": gen.GenerateStatusPagesFile().Bytes(), + "imports.tf": gen.GenerateImportsFile().Bytes(), + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), content, 0644); err != nil { + t.Fatalf("writing %s: %v", name, err) + } + } + + initCmd := exec.Command("terraform", "init", "-upgrade", "-no-color") + initCmd.Dir = dir + if out, err := initCmd.CombinedOutput(); err != nil { + t.Fatalf("terraform init failed: %v\noutput:\n%s", err, out) + } + + validateCmd := exec.Command("terraform", "validate", "-no-color") + validateCmd.Dir = dir + if out, err := validateCmd.CombinedOutput(); err != nil { + t.Fatalf("terraform validate failed: %v\noutput:\n%s", err, out) + } +} + +func smokeFixture() *WorkspaceData { + httpMon := &monitorv1.HTTPMonitor{} + httpMon.SetId("mon-http") + httpMon.SetName("API Health") + httpMon.SetUrl("https://api.example.com/health") + httpMon.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_5M) + httpMon.SetMethod(monitorv1.HTTPMethod_HTTP_METHOD_GET) + httpMon.SetTimeout(45000) + httpMon.SetRetry(3) + httpMon.SetFollowRedirects(true) + httpMon.SetActive(true) + httpMon.SetRegions([]monitorv1.Region{monitorv1.Region_REGION_FLY_IAD}) + + otelConfig := &monitorv1.OpenTelemetryConfig{} + otelConfig.SetEndpoint("https://otel.example.com/v1/metrics") + otelHeader := &monitorv1.Headers{} + otelHeader.SetKey("X-Api-Key") + otelHeader.SetValue("secret") + otelConfig.SetHeaders([]*monitorv1.Headers{otelHeader}) + httpMon.SetOpenTelemetry(otelConfig) + + statusAssertion := &monitorv1.StatusCodeAssertion{} + statusAssertion.SetTarget(200) + statusAssertion.SetComparator(monitorv1.NumberComparator_NUMBER_COMPARATOR_EQUAL) + httpMon.SetStatusCodeAssertions([]*monitorv1.StatusCodeAssertion{statusAssertion}) + + tcpMon := &monitorv1.TCPMonitor{} + tcpMon.SetId("mon-tcp") + tcpMon.SetName("DB TCP") + tcpMon.SetUri("db.example.com:5432") + tcpMon.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_5M) + tcpMon.SetTimeout(45000) + tcpMon.SetRetry(3) + tcpMon.SetActive(true) + + dnsMon := &monitorv1.DNSMonitor{} + dnsMon.SetId("mon-dns") + dnsMon.SetName("DNS Check") + dnsMon.SetUri("example.com") + dnsMon.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_10M) + dnsMon.SetTimeout(45000) + dnsMon.SetRetry(3) + dnsMon.SetActive(true) + dnsRecord := &monitorv1.RecordAssertion{} + dnsRecord.SetRecord("A") + dnsRecord.SetTarget("93.184.216.34") + dnsRecord.SetComparator(monitorv1.RecordComparator_RECORD_COMPARATOR_EQUAL) + dnsMon.SetRecordAssertions([]*monitorv1.RecordAssertion{dnsRecord}) + + slackNotif := newNotification("notif-slack", "Slack Alerts", []string{"mon-http"}, func(d *notificationv1.NotificationData) { + sd := ¬ificationv1.SlackData{} + sd.SetWebhookUrl("https://hooks.example.com/slack") + d.SetSlack(sd) + }) + teamsNotif := newNotification("notif-teams", "Teams Alerts", nil, func(d *notificationv1.NotificationData) { + td := ¬ificationv1.MsTeamsData{} + td.SetWebhookUrl("https://example.com/teams") + d.SetMsTeams(td) + }) + webhookNotif := newNotification("notif-webhook", "Webhook Alerts", nil, func(d *notificationv1.NotificationData) { + wd := ¬ificationv1.WebhookData{} + wd.SetEndpoint("https://example.com/hook") + h := ¬ificationv1.WebhookHeader{} + h.SetKey("Authorization") + h.SetValue("Bearer xyz") + wd.SetHeaders([]*notificationv1.WebhookHeader{h}) + d.SetWebhook(wd) + }) + + page := &status_pagev1.StatusPage{} + page.SetId("page-1") + page.SetTitle("Public Status") + page.SetSlug("public-status") + page.SetTheme(status_pagev1.PageTheme_PAGE_THEME_DARK) + page.SetDefaultLocale(status_pagev1.Locale_LOCALE_EN) + page.SetLocales([]status_pagev1.Locale{ + status_pagev1.Locale_LOCALE_EN, + status_pagev1.Locale_LOCALE_FR, + }) + page.SetAllowIndex(true) + + group := &status_pagev1.PageComponentGroup{} + group.SetId("group-1") + group.SetPageId("page-1") + group.SetName("Infrastructure") + group.SetDefaultOpen(true) + + component := &status_pagev1.PageComponent{} + component.SetId("comp-1") + component.SetPageId("page-1") + component.SetName("API") + component.SetType(status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_MONITOR) + component.SetMonitorId("mon-http") + component.SetOrder(1) + component.SetGroupId("group-1") + component.SetGroupOrder(1) + + ipPage := &status_pagev1.StatusPage{} + ipPage.SetId("page-2") + ipPage.SetTitle("Internal") + ipPage.SetSlug("internal") + ipPage.SetAccessType(status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_IP_RESTRICTED) + ipPage.SetAllowedIpRanges("10.0.0.0/8,192.168.0.0/16") + + return &WorkspaceData{ + HTTPMonitors: []*monitorv1.HTTPMonitor{httpMon}, + TCPMonitors: []*monitorv1.TCPMonitor{tcpMon}, + DNSMonitors: []*monitorv1.DNSMonitor{dnsMon}, + Notifications: []*notificationv1.Notification{slackNotif, teamsNotif, webhookNotif}, + StatusPages: []StatusPageData{ + { + Page: page, + Groups: []*status_pagev1.PageComponentGroup{group}, + Components: []*status_pagev1.PageComponent{component}, + }, + {Page: ipPage}, + }, + } +} + +func newNotification(id, name string, monitorIDs []string, setData func(*notificationv1.NotificationData)) *notificationv1.Notification { + n := ¬ificationv1.Notification{} + n.SetId(id) + n.SetName(name) + if len(monitorIDs) > 0 { + n.SetMonitorIds(monitorIDs) + } + nd := ¬ificationv1.NotificationData{} + setData(nd) + n.SetData(nd) + return n +} diff --git a/plan.md b/plan.md index 0c18702..64ed65a 100644 --- a/plan.md +++ b/plan.md @@ -512,15 +512,15 @@ This is the largest phase. Land in one commit (per Q7) but write it incrementall - [x] `internal/terraform/cli_test.go` — `TestPrintSummary_IncludesInitUpgradeHint` (captures stdout). - [x] `go test ./internal/terraform/...` green. -### Phase 7 — Opt-in smoke test (commit 7: `test(terraform): add terraform-validate smoke test behind build tag`) - -- [ ] New file `internal/terraform/smoke_test.go` with `//go:build smoke` build tag at top. -- [ ] In the file: build a representative `WorkspaceData` covering one of each emitted resource type (HTTP/TCP/DNS monitor with OTEL; one notification of each type incl. ms_teams + webhook with headers; status page with `ip` access + `theme="dark"` + `default_open=true` group + monitor component). -- [ ] Write all generated files to `t.TempDir()`. Skip the test (`t.Skipf`) if `terraform` is not on `PATH`. -- [ ] Exec `terraform init -upgrade` then `terraform validate` against the temp dir; fail the test on non-zero exit. -- [ ] Document at the top of the file: `// Run with: go test -tags=smoke ./internal/terraform/`. -- [ ] `go test ./internal/terraform/...` (no tag) still green and does NOT invoke terraform. -- [ ] Manually verify: `go test -tags=smoke ./internal/terraform/` passes on this machine (terraform must be on PATH). +### Phase 7 — Opt-in smoke test (commit 7: `test(terraform): add terraform-validate smoke test behind build tag`) ✅ + +- [x] New file `internal/terraform/smoke_test.go` with `//go:build smoke` build tag at top. +- [x] In the file: build a representative `WorkspaceData` covering HTTP/TCP/DNS monitors (HTTP includes OTEL + assertions); slack + ms_teams + webhook-with-headers notifications; status page with theme/locales/allow_index + default_open group + monitor component; second status page with IP access. +- [x] Write all generated files to `t.TempDir()`. Skip the test (`t.Skipf`) if `terraform` is not on `PATH`. +- [x] Exec `terraform init -upgrade` then `terraform validate` against the temp dir; fail the test on non-zero exit. +- [x] Document at the top of the file: `// Run with: go test -tags=smoke ./internal/terraform/`. +- [x] `go test ./internal/terraform/...` (no tag) still green and does NOT invoke terraform. +- [x] Manually verified: `go test -tags=smoke ./internal/terraform/` passes locally. ### Phase 8 — Version + docs (commit 8: `chore: bump cli to v1.1.0 and regenerate docs`)