diff --git a/README.md b/README.md index 8a6fa59..dea7c05 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Generate Terraform HCL for your entire workspace: openstatus terraform generate --output-dir ./terraform ``` -This creates `provider.tf`, `monitors.tf`, `notifications.tf`, `status_pages.tf`, and `imports.tf` ready for `terraform plan`. +This creates `provider.tf`, `monitors.tf`, `notifications.tf`, `status_pages.tf`, `private_locations.tf`, and `imports.tf` ready for `terraform plan`. ## Authentication diff --git a/internal/cmd/app.go b/internal/cmd/app.go index a5abb4a..c30a97f 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -44,7 +44,7 @@ Get started: openstatus pl list List your private locations https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, - Version: "v1.3.0", + Version: "v1.3.1", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "json", diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 5cc720c..190c162 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -20,8 +20,8 @@ func Test_NewApp(t *testing.T) { t.Errorf("Expected app name 'openstatus', got %s", app.Name) } - if app.Version != "v1.3.0" { - t.Errorf("Expected version 'v1.3.0', got %s", app.Version) + if app.Version != "v1.3.1" { + t.Errorf("Expected version 'v1.3.1', got %s", app.Version) } if !app.Suggest { diff --git a/internal/terraform/cli_test.go b/internal/terraform/cli_test.go index 9b6f11d..92dada3 100644 --- a/internal/terraform/cli_test.go +++ b/internal/terraform/cli_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" ) func TestCheckExistingFiles_RefusesExisting(t *testing.T) { @@ -26,6 +28,21 @@ func TestCheckExistingFiles_RefusesExisting(t *testing.T) { } } +func TestCheckExistingFiles_RefusesExistingPrivateLocations(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "private_locations.tf"), []byte("existing"), 0o644); err != nil { + t.Fatalf("seeding fixture: %v", err) + } + + err := checkExistingFiles(dir, false) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "private_locations.tf") { + t.Errorf("expected error to mention filename, got: %v", err) + } +} + func TestCheckExistingFiles_OverwritesWithForce(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "monitors.tf"), []byte("existing"), 0o644); err != nil { @@ -57,11 +74,30 @@ func TestPrintSummary_IncludesInitUpgradeHint(t *testing.T) { if !strings.Contains(out, "terraform init -upgrade") { t.Errorf("expected init-upgrade hint, got:\n%s", out) } - if !strings.Contains(out, "~> 0.2") { + if !strings.Contains(out, "~> 0.3") { t.Errorf("expected version mention in hint, got:\n%s", out) } } +func TestPrintSummary_CountsPrivateLocations(t *testing.T) { + l := &private_locationv1.PrivateLocation{} + l.SetId("pl_1") + l.SetName("office-paris") + + out := captureStdout(t, func() { + printSummary("/tmp/out", &WorkspaceData{ + PrivateLocations: []*private_locationv1.PrivateLocation{l}, + }) + }) + + if !strings.Contains(out, "1 private locations") { + t.Errorf("expected private location count, got:\n%s", out) + } + if !strings.Contains(out, "1 import blocks") { + t.Errorf("expected private location to count toward imports, got:\n%s", out) + } +} + func captureStdout(t *testing.T, fn func()) string { t.Helper() orig := os.Stdout diff --git a/internal/terraform/fetch.go b/internal/terraform/fetch.go index c6d9987..278b418 100644 --- a/internal/terraform/fetch.go +++ b/internal/terraform/fetch.go @@ -3,18 +3,24 @@ package terraform import ( "context" "fmt" + "net/http" + "os" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/notification/v1/notificationv1connect" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/private_location/v1/private_locationv1connect" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_page/v1/status_pagev1connect" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" "connectrpc.com/connect" "github.com/openstatusHQ/cli/internal/api" ) +const privateLocationPageSize = 100 + type StatusPageData struct { Page *status_pagev1.StatusPage Components []*status_pagev1.PageComponent @@ -22,21 +28,26 @@ type StatusPageData struct { } type WorkspaceData struct { - HTTPMonitors []*monitorv1.HTTPMonitor - TCPMonitors []*monitorv1.TCPMonitor - DNSMonitors []*monitorv1.DNSMonitor - Notifications []*notificationv1.Notification - StatusPages []StatusPageData + HTTPMonitors []*monitorv1.HTTPMonitor + TCPMonitors []*monitorv1.TCPMonitor + DNSMonitors []*monitorv1.DNSMonitor + Notifications []*notificationv1.Notification + StatusPages []StatusPageData + PrivateLocations []*private_locationv1.PrivateLocation } func FetchWorkspaceData(ctx context.Context, apiKey string) (*WorkspaceData, error) { + return FetchWorkspaceDataWithHTTPClient(ctx, api.DefaultHTTPClient, apiKey) +} + +func FetchWorkspaceDataWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string) (*WorkspaceData, error) { interceptor := connect.WithInterceptors(api.NewAuthInterceptor(apiKey)) protoJSON := connect.WithProtoJSON() data := &WorkspaceData{} // Monitors - monitorClient := monitorv1connect.NewMonitorServiceClient(api.DefaultHTTPClient, api.ConnectBaseURL, interceptor, protoJSON) + monitorClient := monitorv1connect.NewMonitorServiceClient(httpClient, api.ConnectBaseURL, interceptor, protoJSON) monitorResp, err := monitorClient.ListMonitors(ctx, &monitorv1.ListMonitorsRequest{}) if err != nil { return nil, fmt.Errorf("failed to list monitors: %w", err) @@ -46,7 +57,7 @@ func FetchWorkspaceData(ctx context.Context, apiKey string) (*WorkspaceData, err data.DNSMonitors = monitorResp.GetDnsMonitors() // Notifications - notifClient := notificationv1connect.NewNotificationServiceClient(api.DefaultHTTPClient, api.ConnectBaseURL, interceptor, protoJSON) + notifClient := notificationv1connect.NewNotificationServiceClient(httpClient, api.ConnectBaseURL, interceptor, protoJSON) notifResp, err := notifClient.ListNotifications(ctx, ¬ificationv1.ListNotificationsRequest{}) if err != nil { return nil, fmt.Errorf("failed to list notifications: %w", err) @@ -62,7 +73,7 @@ func FetchWorkspaceData(ctx context.Context, apiKey string) (*WorkspaceData, err } // Status Pages - pageClient := status_pagev1connect.NewStatusPageServiceClient(api.DefaultHTTPClient, api.ConnectBaseURL, interceptor, protoJSON) + pageClient := status_pagev1connect.NewStatusPageServiceClient(httpClient, api.ConnectBaseURL, interceptor, protoJSON) pageResp, err := pageClient.ListStatusPages(ctx, &status_pagev1.ListStatusPagesRequest{}) if err != nil { return nil, fmt.Errorf("failed to list status pages: %w", err) @@ -81,5 +92,70 @@ func FetchWorkspaceData(ctx context.Context, apiKey string) (*WorkspaceData, err }) } + // Private Locations + plClient := private_locationv1connect.NewPrivateLocationServiceClient(httpClient, api.ConnectBaseURL, interceptor, protoJSON) + locations, err := fetchPrivateLocations(ctx, plClient) + switch { + case err == nil: + data.PrivateLocations = locations + case isFeatureUnavailable(err): + // Partial results are dropped on purpose: Terraform owns monitor_ids, so + // an incomplete set would detach monitors on the next apply. + fmt.Fprintf(os.Stderr, "warning: skipping private locations — %v\n", err) + default: + return nil, fmt.Errorf("failed to fetch private locations: %w", err) + } + return data, nil } + +// fetchPrivateLocations returns every private location with its monitor_ids. +// ListPrivateLocations only reports monitor_count, so each summary needs a Get. +// Errors are returned unwrapped so the caller can inspect the Connect code. +func fetchPrivateLocations(ctx context.Context, client private_locationv1connect.PrivateLocationServiceClient) ([]*private_locationv1.PrivateLocation, error) { + var locations []*private_locationv1.PrivateLocation + + for offset := int32(0); ; { + listReq := &private_locationv1.ListPrivateLocationsRequest{} + listReq.SetLimit(privateLocationPageSize) + listReq.SetOffset(offset) + + listResp, err := client.ListPrivateLocations(ctx, listReq) + if err != nil { + return nil, err + } + + summaries := listResp.GetPrivateLocations() + if len(summaries) == 0 { + break + } + + for _, summary := range summaries { + getReq := &private_locationv1.GetPrivateLocationRequest{} + getReq.SetId(summary.GetId()) + getResp, err := client.GetPrivateLocation(ctx, getReq) + if err != nil { + return nil, err + } + locations = append(locations, getResp.GetPrivateLocation()) + } + + offset += int32(len(summaries)) + if offset >= listResp.GetTotalSize() { + break + } + } + + return locations, nil +} + +// isFeatureUnavailable reports whether the workspace simply cannot use private +// locations, as opposed to a failure worth aborting the whole export for. +func isFeatureUnavailable(err error) bool { + switch connect.CodeOf(err) { + case connect.CodePermissionDenied, connect.CodeUnimplemented: + return true + default: + return false + } +} diff --git a/internal/terraform/fetch_test.go b/internal/terraform/fetch_test.go new file mode 100644 index 0000000..05bfe2b --- /dev/null +++ b/internal/terraform/fetch_test.go @@ -0,0 +1,304 @@ +package terraform + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" +) + +type recordedCall struct { + Procedure string + Limit int32 + Offset int32 +} + +// fakeTransport serves a queue of bodies per procedure suffix. Procedures with no +// queue get "{}", so unrelated sections of the export come back empty. +type fakeTransport struct { + bodies map[string][]fakeResponse + calls []recordedCall +} + +type fakeResponse struct { + status int + body string +} + +func okResponse(body string) fakeResponse { + return fakeResponse{status: http.StatusOK, body: body} +} + +// connectError builds a Connect error envelope for a unary JSON call. The HTTP +// status is what maps back to a connect.Code on the client side. +func connectError(status int, code string) fakeResponse { + return fakeResponse{status: status, body: fmt.Sprintf(`{"code":%q,"message":"denied"}`, code)} +} + +func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) { + call := recordedCall{Procedure: req.URL.Path} + + if req.Body != nil { + raw, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + var decoded struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + } + if len(raw) > 0 { + _ = json.Unmarshal(raw, &decoded) + } + call.Limit = decoded.Limit + call.Offset = decoded.Offset + } + f.calls = append(f.calls, call) + + resp := okResponse("{}") + for suffix, queue := range f.bodies { + if !strings.HasSuffix(req.URL.Path, suffix) { + continue + } + if len(queue) == 0 { + break + } + resp = queue[0] + if len(queue) > 1 { + f.bodies[suffix] = queue[1:] + } + break + } + + return &http.Response{ + StatusCode: resp.status, + Body: io.NopCloser(strings.NewReader(resp.body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil +} + +func (f *fakeTransport) callsTo(suffix string) []recordedCall { + var out []recordedCall + for _, c := range f.calls { + if strings.HasSuffix(c.Procedure, suffix) { + out = append(out, c) + } + } + return out +} + +func newFetchClient(bodies map[string][]fakeResponse) (*http.Client, *fakeTransport) { + transport := &fakeTransport{bodies: bodies} + return &http.Client{Transport: transport}, transport +} + +func listBody(ids []string, totalSize int) string { + entries := make([]string, 0, len(ids)) + for _, id := range ids { + entries = append(entries, fmt.Sprintf(`{"id":%q,"name":%q,"monitorCount":1}`, id, id)) + } + return fmt.Sprintf(`{"privateLocations":[%s],"totalSize":%d}`, strings.Join(entries, ","), totalSize) +} + +func getBody(id string, monitorIDs []string) string { + quoted := make([]string, 0, len(monitorIDs)) + for _, m := range monitorIDs { + quoted = append(quoted, fmt.Sprintf("%q", m)) + } + return fmt.Sprintf(`{"privateLocation":{"id":%q,"name":%q,"monitorIds":[%s]}}`, id, id, strings.Join(quoted, ",")) +} + +func manyIDs(prefix string, n int) []string { + out := make([]string, 0, n) + for i := range n { + out = append(out, fmt.Sprintf("%s%d", prefix, i)) + } + return out +} + +func TestFetchPrivateLocations_SinglePage(t *testing.T) { + client, transport := newFetchClient(map[string][]fakeResponse{ + "/ListPrivateLocations": {okResponse(listBody([]string{"pl_1", "pl_2"}, 2))}, + "/GetPrivateLocation": {okResponse(getBody("pl_1", []string{"mon-1", "mon-2"})), okResponse(getBody("pl_2", nil))}, + }) + + data, err := FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := len(data.PrivateLocations); got != 2 { + t.Fatalf("got %d private locations, want 2", got) + } + if got := data.PrivateLocations[0].GetMonitorIds(); len(got) != 2 { + t.Errorf("got monitor_ids %v, want 2 entries", got) + } + if got := len(transport.callsTo("/ListPrivateLocations")); got != 1 { + t.Errorf("got %d list calls, want 1", got) + } + if got := len(transport.callsTo("/GetPrivateLocation")); got != 2 { + t.Errorf("got %d get calls, want 2", got) + } +} + +func TestFetchPrivateLocations_MultiPage(t *testing.T) { + firstPage := manyIDs("pl_", privateLocationPageSize) + secondPage := manyIDs("pl_second_", 50) + + client, transport := newFetchClient(map[string][]fakeResponse{ + "/ListPrivateLocations": { + okResponse(listBody(firstPage, 150)), + okResponse(listBody(secondPage, 150)), + }, + "/GetPrivateLocation": {okResponse(getBody("pl_x", nil))}, + }) + + data, err := FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := len(data.PrivateLocations); got != 150 { + t.Fatalf("got %d private locations, want 150", got) + } + + listCalls := transport.callsTo("/ListPrivateLocations") + if len(listCalls) != 2 { + t.Fatalf("got %d list calls, want 2", len(listCalls)) + } + if listCalls[0].Limit != privateLocationPageSize || listCalls[0].Offset != 0 { + t.Errorf("first page requested limit=%d offset=%d, want limit=%d offset=0", listCalls[0].Limit, listCalls[0].Offset, privateLocationPageSize) + } + if listCalls[1].Offset != privateLocationPageSize { + t.Errorf("second page requested offset=%d, want %d", listCalls[1].Offset, privateLocationPageSize) + } +} + +func TestFetchPrivateLocations_EmptyPageStopsLoop(t *testing.T) { + client, transport := newFetchClient(map[string][]fakeResponse{ + "/ListPrivateLocations": { + okResponse(listBody([]string{"pl_1"}, 99)), + okResponse(listBody(nil, 99)), + }, + "/GetPrivateLocation": {okResponse(getBody("pl_1", nil))}, + }) + + data, err := FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := len(data.PrivateLocations); got != 1 { + t.Fatalf("got %d private locations, want 1", got) + } + if got := len(transport.callsTo("/ListPrivateLocations")); got != 2 { + t.Errorf("got %d list calls, want 2 (loop must stop on an empty page)", got) + } +} + +func TestFetchPrivateLocations_EmptyWorkspace(t *testing.T) { + client, transport := newFetchClient(map[string][]fakeResponse{ + "/ListPrivateLocations": {okResponse(listBody(nil, 0))}, + }) + + data, err := FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(data.PrivateLocations) != 0 { + t.Errorf("got %d private locations, want 0", len(data.PrivateLocations)) + } + if got := len(transport.callsTo("/GetPrivateLocation")); got != 0 { + t.Errorf("got %d get calls, want 0", got) + } +} + +func TestFetchPrivateLocations_ToleratesUnavailableFeature(t *testing.T) { + tests := []struct { + name string + status int + code string + procedure string + }{ + {"permission denied on list", http.StatusForbidden, "permission_denied", "/ListPrivateLocations"}, + {"unimplemented on list", http.StatusNotFound, "unimplemented", "/ListPrivateLocations"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, _ := newFetchClient(map[string][]fakeResponse{ + tt.procedure: {connectError(tt.status, tt.code)}, + }) + + var data *WorkspaceData + var err error + warning := captureStderr(t, func() { + data, err = FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token") + }) + + if err != nil { + t.Fatalf("expected the export to continue, got error: %v", err) + } + if len(data.PrivateLocations) != 0 { + t.Errorf("got %d private locations, want 0", len(data.PrivateLocations)) + } + if !strings.Contains(warning, "warning: skipping private locations") { + t.Errorf("expected a stderr warning, got: %q", warning) + } + }) + } +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr = w + defer func() { os.Stderr = orig }() + + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + w.Close() + return <-done +} + +func TestFetchPrivateLocations_FatalOnOtherErrors(t *testing.T) { + client, _ := newFetchClient(map[string][]fakeResponse{ + "/ListPrivateLocations": {connectError(http.StatusUnauthorized, "unauthenticated")}, + }) + + if _, err := FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token"); err == nil { + t.Fatal("expected an error for a non-entitlement failure, got nil") + } +} + +func TestFetchPrivateLocations_PartialResultsDiscarded(t *testing.T) { + client, _ := newFetchClient(map[string][]fakeResponse{ + "/ListPrivateLocations": {okResponse(listBody([]string{"pl_1", "pl_2"}, 2))}, + "/GetPrivateLocation": { + okResponse(getBody("pl_1", []string{"mon-1"})), + connectError(http.StatusForbidden, "permission_denied"), + }, + }) + + data, err := FetchWorkspaceDataWithHTTPClient(context.Background(), client, "test-token") + if err != nil { + t.Fatalf("expected the export to continue, got error: %v", err) + } + if len(data.PrivateLocations) != 0 { + t.Errorf("got %d private locations, want 0 — partial results must be discarded", len(data.PrivateLocations)) + } +} diff --git a/internal/terraform/generate.go b/internal/terraform/generate.go index 42f069d..ebd9912 100644 --- a/internal/terraform/generate.go +++ b/internal/terraform/generate.go @@ -17,6 +17,7 @@ var generatedFileNames = []string{ "monitors.tf", "notifications.tf", "status_pages.tf", + "private_locations.tf", "imports.tf", } @@ -96,6 +97,12 @@ func GetTerraformGenerateCmd() *cli.Command { } } + if gen.HasPrivateLocations() { + if err := writeFile(filepath.Join(outputDir, "private_locations.tf"), gen.GeneratePrivateLocationsFile().Bytes()); err != nil { + return cli.Exit(fmt.Sprintf("failed to write private_locations.tf: %v", err), 1) + } + } + if err := writeFile(filepath.Join(outputDir, "imports.tf"), gen.GenerateImportsFile().Bytes()); err != nil { return cli.Exit(fmt.Sprintf("failed to write imports.tf: %v", err), 1) } @@ -134,6 +141,7 @@ func printSummary(outputDir string, data *WorkspaceData) { dnsCount := len(data.DNSMonitors) monitorTotal := httpCount + tcpCount + dnsCount notifCount := len(data.Notifications) + plCount := len(data.PrivateLocations) pageCount := len(data.StatusPages) compCount := 0 @@ -143,7 +151,7 @@ func printSummary(outputDir string, data *WorkspaceData) { groupCount += len(sp.Groups) } - importCount := monitorTotal + notifCount + pageCount + compCount + groupCount + importCount := monitorTotal + notifCount + pageCount + compCount + groupCount + plCount fmt.Printf("\nGenerated Terraform configuration in %s\n\n", outputDir) if monitorTotal > 0 { @@ -155,10 +163,13 @@ func printSummary(outputDir string, data *WorkspaceData) { if pageCount > 0 { fmt.Printf(" %d status pages (%d components, %d groups)\n", pageCount, compCount, groupCount) } + if plCount > 0 { + fmt.Printf(" %d private locations\n", plCount) + } fmt.Printf(" %d import blocks\n", importCount) fmt.Printf("\nNext steps:\n") fmt.Printf(" cd %s\n", outputDir) fmt.Printf(" terraform init\n") fmt.Printf(" terraform plan\n") - fmt.Printf("\nNote: provider version pinned to ~> 0.2. Run 'terraform init -upgrade' if you previously ran this command.\n") + fmt.Printf("\nNote: provider version pinned to ~> 0.3. Run 'terraform init -upgrade' if you previously ran this command.\n") } diff --git a/internal/terraform/generate_test.go b/internal/terraform/generate_test.go index cf54094..a9bd4d4 100644 --- a/internal/terraform/generate_test.go +++ b/internal/terraform/generate_test.go @@ -6,13 +6,14 @@ import ( monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" ) func TestGenerateProviderFile(t *testing.T) { content := string(GenerateProviderFile()) mustContain(t, content, `source = "openstatusHQ/openstatus"`) - mustContain(t, content, `version = "~> 0.2"`) + mustContain(t, content, `version = "~> 0.3"`) mustContain(t, content, `provider "openstatus" {}`) mustContain(t, content, `OPENSTATUS_API_TOKEN`) } @@ -503,10 +504,25 @@ func TestGenerateStatusPagesFile_EmailDomainAccess(t *testing.T) { content := string(gen.GenerateStatusPagesFile().Bytes()) mustContain(t, content, `access_type = "email-domain"`) - mustContain(t, content, `auth_email_domains = ["acme.com", "example.com"]`) + mustContain(t, content, `auth_email_domains = ["example.com", "acme.com"]`) mustNotContain(t, content, "REPLACE_ME") } +func TestGenerateStatusPagesFile_EmailDomainsDeduped(t *testing.T) { + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("Internal") + page.SetSlug("internal") + page.SetAccessType(status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_AUTHENTICATED) + page.SetAuthEmailDomains([]string{"example.com", "acme.com", "example.com"}) + + data := &WorkspaceData{StatusPages: []StatusPageData{{Page: page}}} + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustContain(t, content, `auth_email_domains = ["example.com", "acme.com"]`) +} + func TestGenerateStatusPagesFile_EmailDomainEmptyFallback(t *testing.T) { page := &status_pagev1.StatusPage{} page.SetId("p1") @@ -542,10 +558,32 @@ func TestGenerateStatusPagesFile_ThemeLocaleAllowIndex(t *testing.T) { mustContain(t, content, `theme = "dark"`) mustContain(t, content, `default_locale = "fr"`) - mustContain(t, content, `locales = ["en", "fr"]`) + mustContain(t, content, `locales = ["fr", "en"]`) mustContain(t, content, `allow_index = true`) } +// Regression: the API can return duplicate locales. Emitting them made apply fail +// with "Provider produced inconsistent result after apply" once the API deduped. +func TestGenerateStatusPagesFile_LocalesDeduped(t *testing.T) { + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("Meow Meow") + page.SetSlug("meow-meow") + page.SetLocales([]status_pagev1.Locale{ + status_pagev1.Locale_LOCALE_EN, + status_pagev1.Locale_LOCALE_FR, + status_pagev1.Locale_LOCALE_DE, + status_pagev1.Locale_LOCALE_EN, + status_pagev1.Locale_LOCALE_EN, + }) + + data := &WorkspaceData{StatusPages: []StatusPageData{{Page: page}}} + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustContain(t, content, `locales = ["en", "fr", "de"]`) +} + func TestGenerateStatusPagesFile_DefaultsOmitted(t *testing.T) { page := &status_pagev1.StatusPage{} page.SetId("p1") @@ -564,6 +602,231 @@ func TestGenerateStatusPagesFile_DefaultsOmitted(t *testing.T) { mustNotContain(t, content, "allow_index") } +func newPrivateLocation(id, name string, monitorIDs []string, metadata map[string]string) *private_locationv1.PrivateLocation { + l := &private_locationv1.PrivateLocation{} + l.SetId(id) + l.SetName(name) + if len(monitorIDs) > 0 { + l.SetMonitorIds(monitorIDs) + } + if len(metadata) > 0 { + l.SetMetadata(metadata) + } + return l +} + +func TestGeneratePrivateLocationsFile(t *testing.T) { + l := newPrivateLocation("pl_1", "office-paris", nil, nil) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{l}} + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustContain(t, content, `resource "openstatus_private_location" "office_paris"`) + mustContain(t, content, `name = "office-paris"`) + mustContain(t, content, "openstatus pl info --show-token") + if !gen.HasPrivateLocations() { + t.Error("HasPrivateLocations() should be true") + } +} + +func TestGeneratePrivateLocationsFile_MonitorIdsTraversal(t *testing.T) { + http := &monitorv1.HTTPMonitor{} + http.SetId("mon-http") + http.SetName("API Health") + + tcp := &monitorv1.TCPMonitor{} + tcp.SetId("mon-tcp") + tcp.SetName("DB TCP") + + l := newPrivateLocation("pl_1", "office-paris", []string{"mon-tcp", "mon-http"}, nil) + + data := &WorkspaceData{ + HTTPMonitors: []*monitorv1.HTTPMonitor{http}, + TCPMonitors: []*monitorv1.TCPMonitor{tcp}, + PrivateLocations: []*private_locationv1.PrivateLocation{l}, + } + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustContain(t, content, "openstatus_http_monitor.api_health.id") + mustContain(t, content, "openstatus_tcp_monitor.db_tcp.id") + mustNotContain(t, content, `"mon-http"`) + mustNotContain(t, content, `"mon-tcp"`) +} + +func TestGeneratePrivateLocationsFile_UnknownMonitorFallback(t *testing.T) { + l := newPrivateLocation("pl_1", "office-paris", []string{"mon-gone"}, nil) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{l}} + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustContain(t, content, `monitor_ids = ["mon-gone"]`) +} + +func TestGeneratePrivateLocationsFile_NoMonitors(t *testing.T) { + l := newPrivateLocation("pl_1", "spare-agent", nil, nil) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{l}} + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustNotContain(t, content, "monitor_ids") +} + +func TestGeneratePrivateLocationsFile_Metadata(t *testing.T) { + l := newPrivateLocation("pl_1", "office-paris", nil, map[string]string{ + "env": "prod", + "k8s.cluster": "eu-1", + }) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{l}} + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustContain(t, content, "metadata = {") + mustContain(t, content, `env = "prod"`) + mustContain(t, content, `"k8s.cluster" = "eu-1"`) + if strings.Index(content, "env") > strings.Index(content, "k8s.cluster") { + t.Errorf("expected metadata keys sorted, got:\n%s", content) + } +} + +func TestGeneratePrivateLocationsFile_NoMetadata(t *testing.T) { + l := newPrivateLocation("pl_1", "office-paris", nil, nil) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{l}} + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustNotContain(t, content, "metadata") +} + +func TestGeneratePrivateLocationsFile_NoComputedAttrs(t *testing.T) { + l := newPrivateLocation("pl_1", "office-paris", nil, nil) + l.SetToken("secret-agent-token") + l.SetCreatedAt("2026-01-01T00:00:00Z") + l.SetUpdatedAt("2026-02-01T00:00:00Z") + l.SetLastSeenAt("2026-07-24T14:02:11Z") + l.SetStatus(private_locationv1.PrivateLocationStatus_PRIVATE_LOCATION_STATUS_ACTIVE) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{l}} + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustNotContain(t, content, "secret-agent-token") + mustNotContain(t, content, "created_at") + mustNotContain(t, content, "updated_at") + mustNotContain(t, content, "last_seen_at") + mustNotContain(t, content, "status =") +} + +func TestGenerateImportsFile_PrivateLocation(t *testing.T) { + l := newPrivateLocation("pl_1a2b3c", "office-paris", nil, nil) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{l}} + gen := NewGenerator(data) + content := string(gen.GenerateImportsFile().Bytes()) + + mustContain(t, content, "to = openstatus_private_location.office_paris") + mustContain(t, content, `id = "pl_1a2b3c"`) +} + +func TestPrivateLocationNameCollision(t *testing.T) { + first := newPrivateLocation("pl_1", "office", nil, nil) + second := newPrivateLocation("pl_2", "office", nil, nil) + + data := &WorkspaceData{PrivateLocations: []*private_locationv1.PrivateLocation{first, second}} + gen := NewGenerator(data) + content := string(gen.GeneratePrivateLocationsFile().Bytes()) + + mustContain(t, content, `resource "openstatus_private_location" "office"`) + mustContain(t, content, `resource "openstatus_private_location" "office_2"`) +} + +func TestTotalResourceCount_OnlyPrivateLocations(t *testing.T) { + data := &WorkspaceData{ + PrivateLocations: []*private_locationv1.PrivateLocation{ + newPrivateLocation("pl_1", "office-paris", nil, nil), + newPrivateLocation("pl_2", "k8s-prod-eu", nil, nil), + }, + } + gen := NewGenerator(data) + + if got := gen.TotalResourceCount(); got != 2 { + t.Errorf("TotalResourceCount() = %d, want 2", got) + } +} + +func TestGenerateStatusPagesFile_CustomTheme(t *testing.T) { + theme := &status_pagev1.CustomTheme{} + theme.SetLight(map[string]string{ + "--primary": "hsl(24 94% 50%)", + "--radius": "0.5rem", + }) + theme.SetDark(map[string]string{ + "--primary": "hsl(24 94% 60%)", + }) + + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("Themed") + page.SetSlug("themed") + page.SetCustomTheme(theme) + + data := &WorkspaceData{StatusPages: []StatusPageData{{Page: page}}} + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustContain(t, content, "custom_theme = {") + mustContain(t, content, "light = {") + mustContain(t, content, "dark = {") + mustContain(t, content, `"--primary" = "hsl(24 94% 50%)"`) + mustContain(t, content, `"--radius" = "0.5rem"`) + mustContain(t, content, `"--primary" = "hsl(24 94% 60%)"`) + if strings.Index(content, "dark") > strings.Index(content, "light") { + t.Errorf("expected theme modes in sorted order (dark before light), got:\n%s", content) + } +} + +func TestGenerateStatusPagesFile_CustomThemeLightOnly(t *testing.T) { + theme := &status_pagev1.CustomTheme{} + theme.SetLight(map[string]string{"--primary": "hsl(24 94% 50%)"}) + + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("Themed") + page.SetSlug("themed") + page.SetCustomTheme(theme) + + data := &WorkspaceData{StatusPages: []StatusPageData{{Page: page}}} + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustContain(t, content, "light = {") + mustNotContain(t, content, "dark") +} + +func TestGenerateStatusPagesFile_CustomThemeOmittedWhenEmpty(t *testing.T) { + nilTheme := &status_pagev1.StatusPage{} + nilTheme.SetId("p1") + nilTheme.SetTitle("Plain") + nilTheme.SetSlug("plain") + + emptyTheme := &status_pagev1.StatusPage{} + emptyTheme.SetId("p2") + emptyTheme.SetTitle("Also Plain") + emptyTheme.SetSlug("also-plain") + emptyTheme.SetCustomTheme(&status_pagev1.CustomTheme{}) + + data := &WorkspaceData{StatusPages: []StatusPageData{{Page: nilTheme}, {Page: emptyTheme}}} + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustNotContain(t, content, "custom_theme") +} + func mustContain(t *testing.T, content, substr string) { t.Helper() if !strings.Contains(content, substr) { diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go index b8d45e6..75dc47d 100644 --- a/internal/terraform/hcl.go +++ b/internal/terraform/hcl.go @@ -32,6 +32,7 @@ type Generator struct { pageNames map[string]string componentNames map[string]string groupNames map[string]string + privateLocationNames map[string]string skippedNotifications map[string]bool } @@ -49,6 +50,7 @@ func NewGenerator(data *WorkspaceData) *Generator { pageNames: make(map[string]string), componentNames: make(map[string]string), groupNames: make(map[string]string), + privateLocationNames: make(map[string]string), skippedNotifications: make(map[string]bool), } @@ -92,6 +94,10 @@ func NewGenerator(data *WorkspaceData) *Generator { g.componentNames[comp.GetId()] = cName } } + for _, l := range data.PrivateLocations { + name := g.registry.Name("openstatus_private_location", l.GetName()) + g.privateLocationNames[l.GetId()] = name + } return g } @@ -101,7 +107,7 @@ func GenerateProviderFile() []byte { required_providers { openstatus = { source = "openstatusHQ/openstatus" - version = "~> 0.2" + version = "~> 0.3" } } } @@ -437,8 +443,7 @@ func (g *Generator) GenerateStatusPagesFile() *hclwrite.File { b.SetAttributeValue("password", cty.StringVal("REPLACE_ME")) case "email-domain": b.SetAttributeValue("access_type", cty.StringVal("email-domain")) - domains := append([]string(nil), page.GetAuthEmailDomains()...) - sort.Strings(domains) + domains := dedupe(page.GetAuthEmailDomains()) if len(domains) > 0 { vals := make([]cty.Value, len(domains)) for i, d := range domains { @@ -466,14 +471,13 @@ func (g *Generator) GenerateStatusPagesFile() *hclwrite.File { b.SetAttributeValue("default_locale", cty.StringVal(dl)) } if locs := page.GetLocales(); len(locs) > 0 { - strs := make([]string, 0, len(locs)) + raw := make([]string, 0, len(locs)) for _, l := range locs { if s := localeToString(l); s != "" { - strs = append(strs, s) + raw = append(raw, s) } } - if len(strs) > 0 { - sort.Strings(strs) + if strs := dedupe(raw); len(strs) > 0 { vals := make([]cty.Value, len(strs)) for i, s := range strs { vals[i] = cty.StringVal(s) @@ -484,6 +488,7 @@ func (g *Generator) GenerateStatusPagesFile() *hclwrite.File { if page.GetAllowIndex() { b.SetAttributeValue("allow_index", cty.BoolVal(true)) } + writeCustomTheme(b, page.GetCustomTheme()) body.AppendNewline() @@ -529,6 +534,30 @@ func (g *Generator) GenerateStatusPagesFile() *hclwrite.File { return f } +func (g *Generator) GeneratePrivateLocationsFile() *hclwrite.File { + f := hclwrite.NewEmptyFile() + body := f.Body() + + body.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte("# Agent tokens are generated by OpenStatus and exposed as the read-only\n")}, + {Type: hclsyntax.TokenComment, Bytes: []byte("# `token` attribute. Reveal one with 'openstatus pl info --show-token'.\n")}, + }) + body.AppendNewline() + + for _, l := range g.data.PrivateLocations { + name := g.privateLocationNames[l.GetId()] + b := body.AppendNewBlock("resource", []string{"openstatus_private_location", name}).Body() + + b.SetAttributeValue("name", cty.StringVal(l.GetName())) + g.writeMonitorIds(b, l.GetMonitorIds()) + setStringMap(b, "metadata", l.GetMetadata()) + + body.AppendNewline() + } + + return f +} + func (g *Generator) GenerateImportsFile() *hclwrite.File { f := hclwrite.NewEmptyFile() body := f.Body() @@ -558,6 +587,9 @@ func (g *Generator) GenerateImportsFile() *hclwrite.File { writeImportBlock(body, "openstatus_status_page_component", g.componentNames[comp.GetId()], fmt.Sprintf("%s/%s", page.GetId(), comp.GetId())) } } + for _, l := range g.data.PrivateLocations { + writeImportBlock(body, "openstatus_private_location", g.privateLocationNames[l.GetId()], l.GetId()) + } return f } @@ -567,6 +599,7 @@ func (g *Generator) TotalResourceCount() int { for _, sp := range g.data.StatusPages { count += 1 + len(sp.Components) + len(sp.Groups) } + count += len(g.data.PrivateLocations) return count } @@ -582,6 +615,10 @@ func (g *Generator) HasStatusPages() bool { return len(g.data.StatusPages) > 0 } +func (g *Generator) HasPrivateLocations() bool { + return len(g.data.PrivateLocations) > 0 +} + // helpers func writeRegions(b *hclwrite.Body, regions []monitorv1.Region) { @@ -600,6 +637,60 @@ func writeRegions(b *hclwrite.Body, regions []monitorv1.Region) { b.SetAttributeValue("regions", cty.ListVal(vals)) } +// dedupe drops repeats while keeping the API's order. Both callers feed provider +// attributes typed as List: sorting or duplicating them makes the applied result +// differ from the plan, which Terraform rejects as an inconsistent result. +func dedupe(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, v := range values { + if seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out +} + +// stringMapValue returns cty.NilVal for an empty map so callers can omit the +// attribute entirely rather than emit an empty one. +func stringMapValue(m map[string]string) cty.Value { + if len(m) == 0 { + return cty.NilVal + } + vals := make(map[string]cty.Value, len(m)) + for k, v := range m { + vals[k] = cty.StringVal(v) + } + return cty.MapVal(vals) +} + +func setStringMap(b *hclwrite.Body, attr string, m map[string]string) { + if v := stringMapValue(m); v != cty.NilVal { + b.SetAttributeValue(attr, v) + } +} + +// writeCustomTheme emits only the modes that have entries: the provider rejects +// an empty map with "omit the map instead of leaving it empty". +func writeCustomTheme(b *hclwrite.Body, theme *status_pagev1.CustomTheme) { + if theme == nil { + return + } + modes := make(map[string]cty.Value, 2) + if v := stringMapValue(theme.GetLight()); v != cty.NilVal { + modes["light"] = v + } + if v := stringMapValue(theme.GetDark()); v != cty.NilVal { + modes["dark"] = v + } + if len(modes) == 0 { + return + } + b.SetAttributeValue("custom_theme", cty.ObjectVal(modes)) +} + func writeOpenTelemetry(b *hclwrite.Body, ot *monitorv1.OpenTelemetryConfig) { if ot == nil { return diff --git a/internal/terraform/smoke_test.go b/internal/terraform/smoke_test.go index 9c9d2be..6192d53 100644 --- a/internal/terraform/smoke_test.go +++ b/internal/terraform/smoke_test.go @@ -20,6 +20,7 @@ import ( monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" ) @@ -33,11 +34,12 @@ func TestSmokeValidate(t *testing.T) { 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(), + "provider.tf": GenerateProviderFile(), + "monitors.tf": gen.GenerateMonitorsFile().Bytes(), + "notifications.tf": gen.GenerateNotificationsFile().Bytes(), + "status_pages.tf": gen.GenerateStatusPagesFile().Bytes(), + "private_locations.tf": gen.GeneratePrivateLocationsFile().Bytes(), + "imports.tf": gen.GenerateImportsFile().Bytes(), } for name, content := range files { if err := os.WriteFile(filepath.Join(dir, name), content, 0644); err != nil { @@ -45,14 +47,25 @@ func TestSmokeValidate(t *testing.T) { } } + // Force registry-only installation. A dev_overrides block in ~/.terraformrc or + // a legacy ~/.terraform.d/plugins mirror would otherwise resolve the provider + // locally and validate the config against a stale schema. + cliConfig := filepath.Join(dir, "registry.tfrc") + if err := os.WriteFile(cliConfig, []byte("provider_installation {\n direct {}\n}\n"), 0644); err != nil { + t.Fatalf("writing terraform CLI config: %v", err) + } + env := append(os.Environ(), "TF_CLI_CONFIG_FILE="+cliConfig) + initCmd := exec.Command("terraform", "init", "-upgrade", "-no-color") initCmd.Dir = dir + initCmd.Env = env 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 + validateCmd.Env = env if out, err := validateCmd.CombinedOutput(); err != nil { t.Fatalf("terraform validate failed: %v\noutput:\n%s", err, out) } @@ -139,6 +152,18 @@ func smokeFixture() *WorkspaceData { }) page.SetAllowIndex(true) + // Variable names must come from the provider's allowlist or validate fails. + customTheme := &status_pagev1.CustomTheme{} + customTheme.SetLight(map[string]string{ + "--primary": "hsl(24 94% 50%)", + "--radius": "0.5rem", + }) + customTheme.SetDark(map[string]string{ + "--primary": "hsl(24 94% 60%)", + "--background": "hsl(240 10% 4%)", + }) + page.SetCustomTheme(customTheme) + group := &status_pagev1.PageComponentGroup{} group.SetId("group-1") group.SetPageId("page-1") @@ -162,6 +187,20 @@ func smokeFixture() *WorkspaceData { ipPage.SetAccessType(status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_IP_RESTRICTED) ipPage.SetAllowedIpRanges("10.0.0.0/8,192.168.0.0/16") + attachedLocation := &private_locationv1.PrivateLocation{} + attachedLocation.SetId("pl-office") + attachedLocation.SetName("office-paris") + attachedLocation.SetMonitorIds([]string{"mon-http", "mon-tcp"}) + attachedLocation.SetMetadata(map[string]string{ + "env": "prod", + "k8s.cluster": "eu-1", + }) + attachedLocation.SetToken("smoke-token") + + bareLocation := &private_locationv1.PrivateLocation{} + bareLocation.SetId("pl-spare") + bareLocation.SetName("spare-agent") + return &WorkspaceData{ HTTPMonitors: []*monitorv1.HTTPMonitor{httpMon}, TCPMonitors: []*monitorv1.TCPMonitor{tcpMon}, @@ -175,6 +214,7 @@ func smokeFixture() *WorkspaceData { }, {Page: ipPage}, }, + PrivateLocations: []*private_locationv1.PrivateLocation{attachedLocation, bareLocation}, } } diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index a569e50..113605e 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -382,6 +382,7 @@ This creates an `openstatus-terraform/` directory with: - `monitors.tf` — all HTTP, TCP, and DNS monitors - `notifications.tf` — all notification channels with provider-specific blocks - `status_pages.tf` — status pages, components, and component groups +- `private_locations.tf` — private locations with their monitor assignments - `imports.tf` — Terraform 1.5+ import blocks for all resources **Custom output directory:**