Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 37 additions & 1 deletion internal/terraform/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
92 changes: 84 additions & 8 deletions internal/terraform/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,51 @@ 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
Groups []*status_pagev1.PageComponentGroup
}

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)
Expand All @@ -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, &notificationv1.ListNotificationsRequest{})
if err != nil {
return nil, fmt.Errorf("failed to list notifications: %w", err)
Expand All @@ -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)
Expand All @@ -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
}
}
Loading
Loading