diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index 4d33e98..b41a57f 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -2,7 +2,7 @@ ## CLI interface - openstatus -OpenStatus CLI lets you manage your status pages and uptime monitors from the command line. Report and track incidents, define monitors as code, and run on-demand checks. Get started: openstatus login Save your API token openstatus status-report create Report an incident openstatus status-report list View active incidents openstatus maintenance create Schedule a maintenance window openstatus maintenance list View maintenance windows openstatus monitors apply Sync monitors from config openstatus monitors list List your monitors openstatus run Run synthetic tests https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new. +OpenStatus CLI lets you manage your status pages and uptime monitors from the command line. Report and track incidents, define monitors as code, and run on-demand checks. Get started: openstatus login Save your API token openstatus status-report create Report an incident openstatus status-report list View active incidents openstatus maintenance create Schedule a maintenance window openstatus maintenance list View maintenance windows openstatus monitors apply Sync monitors from config openstatus monitors list List your monitors openstatus run Run synthetic tests openstatus pl list List your private locations https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new. Manage status pages, monitors, and incidents from the terminal. @@ -619,6 +619,89 @@ The following flags are supported: |-----------------------------|-----------------------------|--------|:-------------:|:----------------------:| | `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +### `private-locations` command (aliases: `pl`) + +Manage private locations. + +Private locations are self-hosted checker agents that run monitors from inside your own network. This command lists them, shows their details, and creates new ones. Public regions (Fly.io, Koyeb, Railway) are not listed here; they appear in 'openstatus monitors info' and 'openstatus check'. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] private-locations [ARGUMENTS...] +``` + +### `private-locations list` subcommand + +List all private locations. + +> openstatus private-locations list +> openstatus pl list +> openstatus pl list --limit 10 + +List the private locations in your workspace, with the health of each agent and the number of monitors it runs. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] private-locations list [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-------------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--limit="…"` | Maximum number of private locations to return (1-100) | int | `0` | *none* | + +### `private-locations info` subcommand + +Get private location details. + +> openstatus private-locations info +> openstatus pl info pl_1a2b3c +> openstatus pl info pl_1a2b3c --show-token + +Fetch a private location including the monitors it runs and its agent token. The token is masked unless --show-token is passed. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] private-locations info [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|----------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--show-token` | Reveal the agent token instead of masking it | bool | `false` | *none* | + +### `private-locations create` subcommand + +Create a private location. + +> openstatus private-locations create --name office-paris +> openstatus pl create --name office-paris --monitor-ids 12345,12346 +> openstatus pl create --name office-paris --metadata env=prod --metadata team=infra + +Create a private location and print its agent token. The token is shown in full because you need it to configure the agent; retrieve it later with 'openstatus pl info --show-token'. Run without --name in an interactive terminal to be prompted, including a picker for the monitors to attach. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] private-locations create [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-----------------------------|-------------------------------------------------------|--------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | string | | `OPENSTATUS_API_TOKEN` | +| `--name="…"` | Name of the private location | string | | *none* | +| `--monitor-ids="…"` | Monitor IDs to attach, comma-separated or repeated | string | | *none* | +| `--metadata="…"` | Key/value label, repeatable: --metadata = | string | | *none* | + ### `run` command (aliases: `r`) Run your uptime tests. diff --git a/docs/openstatus.1 b/docs/openstatus.1 index f17515b..b0ed0af 100644 --- a/docs/openstatus.1 +++ b/docs/openstatus.1 @@ -1,5 +1,5 @@ '\" t -.\" Automatically generated by Pandoc 3.9.0.2 +.\" Automatically generated by Pandoc 3.10.1 .\" .TH "" "" "" "" .SH OpenStatus CLI @@ -13,8 +13,8 @@ status\-report create Report an incident openstatus status\-report list View active incidents openstatus maintenance create Schedule a maintenance window openstatus maintenance list View maintenance windows openstatus monitors apply Sync monitors from config openstatus monitors -list List your monitors openstatus run Run synthetic tests -https://docs.openstatus.dev | +list List your monitors openstatus run Run synthetic tests openstatus pl +list List your private locations https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new. .PP Manage status pages, monitors, and incidents from the terminal. @@ -1751,6 +1751,213 @@ T}@T{ \f[CR]OPENSTATUS_API_TOKEN\f[R] T} .TE +.SS \f[CR]private\-locations\f[R] command (aliases: \f[CR]pl\f[R]) +Manage private locations. +.PP +Private locations are self\-hosted checker agents that run monitors from +inside your own network. +This command lists them, shows their details, and creates new ones. +Public regions (Fly.io, Koyeb, Railway) are not listed here; they appear +in `openstatus monitors info' and `openstatus check'. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] private\-locations [ARGUMENTS...] +.EE +.SS \f[CR]private\-locations list\f[R] subcommand +List all private locations. +.RS +.PP +openstatus private\-locations list openstatus pl list openstatus pl list +\(enlimit 10 +.RE +.PP +List the private locations in your workspace, with the health of each +agent and the number of monitors it runs. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] private\-locations list [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(15.5n) lw(29.4n) lw(4.3n) cw(8.0n) cw(12.8n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-limit=\(dq\&...\(dq\f[R] +T}@T{ +Maximum number of private locations to return (1\-100) +T}@T{ +int +T}@T{ +\f[CR]0\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]private\-locations info\f[R] subcommand +Get private location details. +.RS +.PP +openstatus private\-locations info openstatus pl info pl_1a2b3c +openstatus pl info pl_1a2b3c \(enshow\-token +.RE +.PP +Fetch a private location including the monitors it runs and its agent +token. +The token is masked unless \(enshow\-token is passed. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] private\-locations info [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(16.6n) lw(26.4n) lw(4.6n) cw(8.6n) cw(13.8n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-show\-token\f[R] +T}@T{ +Reveal the agent token instead of masking it +T}@T{ +bool +T}@T{ +\f[CR]false\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE +.SS \f[CR]private\-locations create\f[R] subcommand +Create a private location. +.RS +.PP +openstatus private\-locations create \(enname office\-paris openstatus +pl create \(enname office\-paris \(enmonitor\-ids 12345,12346 openstatus +pl create \(enname office\-paris \(enmetadata env=prod \(enmetadata +team=infra +.RE +.PP +Create a private location and print its agent token. +The token is shown in full because you need it to configure the agent; +retrieve it later with `openstatus pl info \(enshow\-token'. +Run without \(enname in an interactive terminal to be prompted, +including a picker for the monitors to attach. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] private\-locations create [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(15.5n) lw(29.4n) lw(4.3n) cw(8.0n) cw(12.8n). +T{ +Name +T}@T{ +Description +T}@T{ +Type +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +string +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-name=\(dq\&...\(dq\f[R] +T}@T{ +Name of the private location +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-monitor\-ids=\(dq\&...\(dq\f[R] +T}@T{ +Monitor IDs to attach, comma\-separated or repeated +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +T{ +\f[CR]\-\-metadata=\(dq\&...\(dq\f[R] +T}@T{ +Key/value label, repeatable: \(enmetadata = +T}@T{ +string +T}@T{ +T}@T{ +\f[I]none\f[R] +T} +.TE .SS \f[CR]run\f[R] command (aliases: \f[CR]r\f[R]) Run your uptime tests. .RS diff --git a/go.mod b/go.mod index 3918cdf..78c5178 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.25.0 require github.com/urfave/cli/v3 v3.9.0 // direct require ( - buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260512200453-7d7b7047611f.1 - buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260611194648-df8af73df8e6.1 + buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260724145211-8d1b4f4069ec.1 + buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260724145211-8d1b4f4069ec.1 connectrpc.com/connect v1.20.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/huh v1.0.0 @@ -25,6 +25,7 @@ require ( github.com/zclconf/go-cty v1.18.1 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 + google.golang.org/protobuf v1.36.11 sigs.k8s.io/yaml v1.6.0 ) @@ -77,5 +78,4 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/tools v0.45.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index e8d07fd..f4a7c92 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,10 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202604152011 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 h1:t8f+WWZ5WNrZaP5zrpWD8f1tKU7eelJMbIAT9FRX558= buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1/go.mod h1:/t9AeRQQp2iNkiGHDLfHLW3SzNpYpNPGRZ+Ih8+SOUs= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260512200453-7d7b7047611f.1 h1:NCdo1Xp+waRtzNvXDE9Io5gqzvvMIn3zItVFsxxmBc0= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260512200453-7d7b7047611f.1/go.mod h1:pUXTguUtnl60N+cwJnHkPmsdnt0WniHDO9V37KslI4U= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260611194648-df8af73df8e6.1 h1:CCLwLRzDx0s4FfXfbVUy5SBB5m4nGfV9SYTrScUvHqU= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260611194648-df8af73df8e6.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260724145211-8d1b4f4069ec.1 h1:A3yb5Bf2iuld7SYNcq7I2uXv/zEr58dJFSoQm2HeFOQ= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.20.0-20260724145211-8d1b4f4069ec.1/go.mod h1:MmC+HrHeHglSXd6g09VexkguG+ohgxelutvlXZW/syg= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260724145211-8d1b4f4069ec.1 h1:tERlcNbY6AkmrrDiOT1nlDvEEA+JdSnOQZTDd5BYCb4= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260724145211-8d1b4f4069ec.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 5009fe4..a5abb4a 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -14,6 +14,7 @@ import ( "github.com/openstatusHQ/cli/internal/maintenance" "github.com/openstatusHQ/cli/internal/monitors" "github.com/openstatusHQ/cli/internal/notification" + "github.com/openstatusHQ/cli/internal/privatelocation" "github.com/openstatusHQ/cli/internal/run" "github.com/openstatusHQ/cli/internal/statuspage" "github.com/openstatusHQ/cli/internal/statusreport" @@ -40,9 +41,10 @@ Get started: openstatus monitors apply Sync monitors from config openstatus monitors list List your monitors openstatus run Run synthetic tests + openstatus pl list List your private locations https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, - Version: "v1.2.1", + Version: "v1.3.0", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "json", @@ -76,6 +78,7 @@ https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, maintenance.MaintenanceCmd(), statuspage.StatusPageCmd(), notification.NotificationCmd(), + privatelocation.PrivateLocationsCmd(), run.RunCmd(), whoami.WhoamiCmd(), login.LoginCmd(), diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index b40b4d4..5cc720c 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.2.1" { - t.Errorf("Expected version 'v1.2.1', got %s", app.Version) + if app.Version != "v1.3.0" { + t.Errorf("Expected version 'v1.3.0', got %s", app.Version) } if !app.Suggest { @@ -32,22 +32,23 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 11 { - t.Errorf("Expected 11 commands, got %d", len(app.Commands)) + if len(app.Commands) != 12 { + t.Errorf("Expected 12 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ - "check": false, - "monitors": false, - "status-report": false, - "maintenance": false, - "status-page": false, - "notification": false, - "run": false, - "whoami": false, - "login": false, - "logout": false, - "terraform": false, + "check": false, + "monitors": false, + "status-report": false, + "maintenance": false, + "status-page": false, + "notification": false, + "private-locations": false, + "run": false, + "whoami": false, + "login": false, + "logout": false, + "terraform": false, } for _, subcmd := range app.Commands { diff --git a/internal/monitors/monitor_create.go b/internal/monitors/monitor_create.go index 589dd9f..907b4b3 100644 --- a/internal/monitors/monitor_create.go +++ b/internal/monitors/monitor_create.go @@ -100,22 +100,23 @@ func httpMonitorToLocal(m *monitorv1.HTTPMonitor) (Monitor, error) { } return Monitor{ - ID: id, - Name: m.GetName(), - Description: m.GetDescription(), - URL: m.GetUrl(), - Periodicity: periodicityToString(m.GetPeriodicity()), - Method: httpMethodToString(m.GetMethod()), - Regions: regionsToStrings(m.GetRegions()), - Active: m.GetActive(), - Public: m.GetPublic(), - Timeout: int(m.GetTimeout()), - DegradedAfter: int(m.GetDegradedAt()), - Body: m.GetBody(), - Headers: headers, - Assertions: assertions, - Retry: int(m.GetRetry()), - JobType: "http", + ID: id, + Name: m.GetName(), + Description: m.GetDescription(), + URL: m.GetUrl(), + Periodicity: periodicityToString(m.GetPeriodicity()), + Method: httpMethodToString(m.GetMethod()), + Regions: regionsToStrings(m.GetRegions()), + PrivateLocationIDs: m.GetPrivateLocationIds(), + Active: m.GetActive(), + Public: m.GetPublic(), + Timeout: int(m.GetTimeout()), + DegradedAfter: int(m.GetDegradedAt()), + Body: m.GetBody(), + Headers: headers, + Assertions: assertions, + Retry: int(m.GetRetry()), + JobType: "http", }, nil } @@ -125,18 +126,19 @@ func tcpMonitorToLocal(m *monitorv1.TCPMonitor) (Monitor, error) { return Monitor{}, fmt.Errorf("invalid monitor ID %q: %w", m.GetId(), err) } return Monitor{ - ID: id, - Name: m.GetName(), - Description: m.GetDescription(), - URL: m.GetUri(), - Periodicity: periodicityToString(m.GetPeriodicity()), - Regions: regionsToStrings(m.GetRegions()), - Active: m.GetActive(), - Public: m.GetPublic(), - Timeout: int(m.GetTimeout()), - DegradedAfter: int(m.GetDegradedAt()), - Retry: int(m.GetRetry()), - JobType: "tcp", + ID: id, + Name: m.GetName(), + Description: m.GetDescription(), + URL: m.GetUri(), + Periodicity: periodicityToString(m.GetPeriodicity()), + Regions: regionsToStrings(m.GetRegions()), + PrivateLocationIDs: m.GetPrivateLocationIds(), + Active: m.GetActive(), + Public: m.GetPublic(), + Timeout: int(m.GetTimeout()), + DegradedAfter: int(m.GetDegradedAt()), + Retry: int(m.GetRetry()), + JobType: "tcp", }, nil } diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index 33382eb..fd6bd15 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -19,13 +19,15 @@ import ( "github.com/openstatusHQ/cli/internal/api" "github.com/openstatusHQ/cli/internal/auth" output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/privatelocation" ) type MonitorInfoOutput struct { - Monitor Monitor `json:"monitor"` - Status string `json:"status,omitempty"` - Regions []RegionStatusOutput `json:"regions,omitempty"` - Summary *SummaryOutput `json:"summary,omitempty"` + Monitor Monitor `json:"monitor"` + Status string `json:"status,omitempty"` + Regions []RegionStatusOutput `json:"regions,omitempty"` + PrivateLocations []privatelocation.Ref `json:"private_locations,omitempty"` + Summary *SummaryOutput `json:"summary,omitempty"` } type RegionStatusOutput struct { @@ -191,6 +193,7 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, monitorConfig := resp.GetMonitor() var monitor Monitor var regions []monitorv1.Region + var privateLocationIDs []string switch { case monitorConfig.HasHttp(): monitor, err = httpMonitorToLocal(monitorConfig.GetHttp()) @@ -198,12 +201,14 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, return err } regions = monitorConfig.GetHttp().GetRegions() + privateLocationIDs = monitorConfig.GetHttp().GetPrivateLocationIds() case monitorConfig.HasTcp(): monitor, err = tcpMonitorToLocal(monitorConfig.GetTcp()) if err != nil { return err } regions = monitorConfig.GetTcp().GetRegions() + privateLocationIDs = monitorConfig.GetTcp().GetPrivateLocationIds() default: if monitorConfig.HasDns() { return fmt.Errorf("DNS monitors are not yet supported in the CLI. Monitor ID: %s", monitorId) @@ -212,13 +217,22 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, } var ( - statusResp *monitorv1.GetMonitorStatusResponse - summaryResp *monitorv1.GetMonitorSummaryResponse - statusErr error - summaryErr error - wg sync.WaitGroup + statusResp *monitorv1.GetMonitorStatusResponse + summaryResp *monitorv1.GetMonitorSummaryResponse + locationRefs map[string]privatelocation.Ref + statusErr error + summaryErr error + locationErr error + wg sync.WaitGroup ) wg.Add(2) + if len(privateLocationIDs) > 0 { + wg.Add(1) + go func() { + defer wg.Done() + locationRefs, locationErr = privatelocation.ResolveWithHTTPClient(ctx, httpClient, apiKey, privateLocationIDs) + }() + } go func() { defer wg.Done() statusResp, statusErr = client.GetMonitorStatus(ctx, &monitorv1.GetMonitorStatusRequest{Id: monitorId}) @@ -240,6 +254,9 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, fmt.Fprintln(os.Stderr, "Warning: could not fetch monitor summary:", summaryErr) summaryResp = nil } + if locationErr != nil { + fmt.Fprintln(os.Stderr, "Warning: could not resolve private locations:", locationErr) + } var globalStatus string if statusResp != nil { @@ -250,6 +267,9 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, infoOutput := MonitorInfoOutput{ Monitor: monitor, } + if len(privateLocationIDs) > 0 { + infoOutput.PrivateLocations = privatelocation.Refs(privateLocationIDs, locationRefs) + } if statusResp != nil { infoOutput.Status = globalStatus for _, rs := range statusResp.GetRegions() { @@ -304,6 +324,10 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, data = append(data, []string{fmt.Sprintf("Locations (%s)", provider), strings.Join(codes, ", ")}) } } + if len(privateLocationIDs) > 0 { + labels := privatelocation.Labels(privateLocationIDs, locationRefs, true) + data = append(data, []string{"Locations (Private)", strings.Join(labels, ", ")}) + } data = append(data, []string{"Active", fmt.Sprintf("%t", monitor.Active)}) data = append(data, []string{"Public", fmt.Sprintf("%t", monitor.Public)}) diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index 10f7825..b7f27f3 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -450,22 +450,23 @@ func configToTCPMonitor(m config.Monitor) (*monitorv1.TCPMonitor, error) { } type Monitor struct { - ID int `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - Periodicity string `json:"periodicity"` - Description string `json:"description"` - Method string `json:"method"` - Regions []string `json:"regions"` - Active bool `json:"active"` - Public bool `json:"public"` - Timeout int `json:"timeout"` - DegradedAfter int `json:"degraded_after,omitempty"` - Body string `json:"body"` - Headers []Header `json:"headers,omitempty"` - Assertions []Assertion `json:"assertions,omitempty"` - Retry int `json:"retry"` - JobType string `json:"jobType"` + ID int `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Periodicity string `json:"periodicity"` + Description string `json:"description"` + Method string `json:"method"` + Regions []string `json:"regions"` + PrivateLocationIDs []string `json:"private_location_ids,omitempty"` + Active bool `json:"active"` + Public bool `json:"public"` + Timeout int `json:"timeout"` + DegradedAfter int `json:"degraded_after,omitempty"` + Body string `json:"body"` + Headers []Header `json:"headers,omitempty"` + Assertions []Assertion `json:"assertions,omitempty"` + Retry int `json:"retry"` + JobType string `json:"jobType"` } type Header struct { diff --git a/internal/monitors/monitors_list.go b/internal/monitors/monitors_list.go index a18df37..d4d40aa 100644 --- a/internal/monitors/monitors_list.go +++ b/internal/monitors/monitors_list.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "os" + "strings" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" @@ -11,18 +13,23 @@ import ( "github.com/rodaine/table" "github.com/urfave/cli/v3" + "github.com/openstatusHQ/cli/internal/api" "github.com/openstatusHQ/cli/internal/auth" output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/privatelocation" ) type monitorListEntry struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Kind string `json:"kind"` + PrivateLocations []privatelocation.Ref `json:"private_locations,omitempty"` + + privateLocationIDs []string } -func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceClient, showAll bool, s *output.Spinner) error { +func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceClient, resolver PrivateLocationResolver, showAll bool, s *output.Spinner) error { resp, err := client.ListMonitors(ctx, &monitorv1.ListMonitorsRequest{}) output.StopSpinner(s) if err != nil { @@ -34,10 +41,11 @@ func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceCli for _, monitor := range resp.GetHttpMonitors() { if monitor.GetActive() || showAll { entries = append(entries, monitorListEntry{ - ID: monitor.GetId(), - Name: monitor.GetName(), - URL: monitor.GetUrl(), - Kind: "http", + ID: monitor.GetId(), + Name: monitor.GetName(), + URL: monitor.GetUrl(), + Kind: "http", + privateLocationIDs: monitor.GetPrivateLocationIds(), }) } } @@ -45,10 +53,11 @@ func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceCli for _, monitor := range resp.GetTcpMonitors() { if monitor.GetActive() || showAll { entries = append(entries, monitorListEntry{ - ID: monitor.GetId(), - Name: monitor.GetName(), - URL: monitor.GetUri(), - Kind: "tcp", + ID: monitor.GetId(), + Name: monitor.GetName(), + URL: monitor.GetUri(), + Kind: "tcp", + privateLocationIDs: monitor.GetPrivateLocationIds(), }) } } @@ -56,14 +65,40 @@ func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceCli for _, monitor := range resp.GetDnsMonitors() { if monitor.GetActive() || showAll { entries = append(entries, monitorListEntry{ - ID: monitor.GetId(), - Name: monitor.GetName(), - URL: monitor.GetUri(), - Kind: "dns", + ID: monitor.GetId(), + Name: monitor.GetName(), + URL: monitor.GetUri(), + Kind: "dns", + privateLocationIDs: monitor.GetPrivateLocationIds(), }) } } + // The private-location column is only rendered when something is attached, so workspaces + // without the feature keep the original four-column output and pay for no extra request. + var allIDs []string + for _, e := range entries { + allIDs = append(allIDs, e.privateLocationIDs...) + } + + hasPrivateLocations := len(allIDs) > 0 + var refs map[string]privatelocation.Ref + if hasPrivateLocations && resolver != nil { + resolved, err := resolver(ctx, allIDs) + if err != nil { + fmt.Fprintln(os.Stderr, "Warning: could not resolve private locations:", err) + } + refs = resolved + } + + if hasPrivateLocations { + for i := range entries { + if len(entries[i].privateLocationIDs) > 0 { + entries[i].PrivateLocations = privatelocation.Refs(entries[i].privateLocationIDs, refs) + } + } + } + if output.IsJSONOutput() { return output.PrintJSON(entries) } @@ -71,11 +106,24 @@ func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceCli headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() columnFmt := color.New(color.FgYellow).SprintfFunc() - tbl := table.New("ID", "Name", "Url", "Kind") + columns := []any{"ID", "Name", "Url", "Kind"} + if hasPrivateLocations { + columns = append(columns, "Private Locations") + } + + tbl := table.New(columns...) tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) for _, e := range entries { - tbl.AddRow(e.ID, e.Name, e.URL, e.Kind) + if !hasPrivateLocations { + tbl.AddRow(e.ID, e.Name, e.URL, e.Kind) + continue + } + locations := "—" + if len(e.privateLocationIDs) > 0 { + locations = strings.Join(privatelocation.Labels(e.privateLocationIDs, refs, false), ", ") + } + tbl.AddRow(e.ID, e.Name, e.URL, e.Kind, locations) } tbl.Print() @@ -83,9 +131,19 @@ func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceCli return nil } +// PrivateLocationResolver turns private location IDs into display references. It is injected so +// the monitors package stays independent of how the lookup is performed. +type PrivateLocationResolver func(ctx context.Context, ids []string) (map[string]privatelocation.Ref, error) + +func newPrivateLocationResolver(httpClient *http.Client, apiKey string) PrivateLocationResolver { + return func(ctx context.Context, ids []string) (map[string]privatelocation.Ref, error) { + return privatelocation.ResolveWithHTTPClient(ctx, httpClient, apiKey, ids) + } +} + func ListMonitorsWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string) error { client := NewMonitorClientWithHTTPClient(httpClient, apiKey) - return ListMonitors(ctx, client, false, nil) + return ListMonitors(ctx, client, newPrivateLocationResolver(httpClient, apiKey), false, nil) } func GetMonitorsListCmd() *cli.Command { @@ -118,7 +176,7 @@ It displays the ID, name, URL, and kind of each monitor.`, } s := output.StartSpinner("Fetching monitors...") client := NewMonitorClient(apiKey) - err = ListMonitors(ctx, client, cmd.Bool("all"), s) + err = ListMonitors(ctx, client, newPrivateLocationResolver(api.DefaultHTTPClient, apiKey), cmd.Bool("all"), s) if err != nil { return cli.Exit(err.Error(), 1) } diff --git a/internal/monitors/monitors_private_location_test.go b/internal/monitors/monitors_private_location_test.go new file mode 100644 index 0000000..164d023 --- /dev/null +++ b/internal/monitors/monitors_private_location_test.go @@ -0,0 +1,137 @@ +package monitors_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/rodaine/table" + + "github.com/openstatusHQ/cli/internal/monitors" + "github.com/openstatusHQ/cli/internal/privatelocation" +) + +func bodyResponder(body string) *interceptorHTTPClient { + return &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } +} + +// captureTable collects table output. rodaine/table binds DefaultWriter to os.Stdout at package +// init, so redirecting os.Stdout after the fact would capture nothing. +func captureTable(t *testing.T, f func()) string { + t.Helper() + + var buf bytes.Buffer + original := table.DefaultWriter + table.DefaultWriter = &buf + t.Cleanup(func() { table.DefaultWriter = original }) + + f() + + return buf.String() +} + +const monitorsWithoutPrivateLocations = `{"httpMonitors":[{"id":"123","name":"api-prod","url":"https://api.example.com","active":true}],"tcpMonitors":[],"dnsMonitors":[]}` + +const monitorsWithPrivateLocations = `{"httpMonitors":[{"id":"123","name":"api-prod","url":"https://api.example.com","active":true,"privateLocationIds":["pl_1"]},{"id":"124","name":"marketing","url":"https://example.com","active":true}],"tcpMonitors":[],"dnsMonitors":[]}` + +// Not parallel: these subtests swap the package-level table.DefaultWriter. +func Test_ListMonitors_PrivateLocations(t *testing.T) { + t.Run("Output is unchanged when nothing is attached", func(t *testing.T) { + client := bodyResponder(monitorsWithoutPrivateLocations) + monitorClient := monitors.NewMonitorClientWithHTTPClient(client.GetHTTPClient(), "test-token") + + resolverCalls := 0 + resolver := func(ctx context.Context, ids []string) (map[string]privatelocation.Ref, error) { + resolverCalls++ + return nil, nil + } + + out := captureTable(t, func() { + if err := monitors.ListMonitors(context.Background(), monitorClient, resolver, false, nil); err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + if resolverCalls != 0 { + t.Errorf("Expected zero private-location lookups, got %d", resolverCalls) + } + if strings.Contains(out, "Private Locations") { + t.Error("Expected no private-location column when nothing is attached") + } + for _, want := range []string{"ID", "Name", "Url", "Kind", "api-prod"} { + if !strings.Contains(out, want) { + t.Errorf("Expected the original output to contain %q", want) + } + } + }) + + t.Run("Renders resolved names when attached", func(t *testing.T) { + client := bodyResponder(monitorsWithPrivateLocations) + monitorClient := monitors.NewMonitorClientWithHTTPClient(client.GetHTTPClient(), "test-token") + + resolver := func(ctx context.Context, ids []string) (map[string]privatelocation.Ref, error) { + return map[string]privatelocation.Ref{ + "pl_1": {ID: "pl_1", Name: "office-paris", Status: "active"}, + }, nil + } + + out := captureTable(t, func() { + if err := monitors.ListMonitors(context.Background(), monitorClient, resolver, false, nil); err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + if !strings.Contains(out, "Private Locations") { + t.Error("Expected the private-location column") + } + if !strings.Contains(out, "office-paris") { + t.Error("Expected the resolved location name") + } + }) + + t.Run("Falls back to raw IDs when the lookup fails", func(t *testing.T) { + client := bodyResponder(monitorsWithPrivateLocations) + monitorClient := monitors.NewMonitorClientWithHTTPClient(client.GetHTTPClient(), "test-token") + + resolver := func(ctx context.Context, ids []string) (map[string]privatelocation.Ref, error) { + return nil, errors.New("permission denied") + } + + out := captureTable(t, func() { + if err := monitors.ListMonitors(context.Background(), monitorClient, resolver, false, nil); err != nil { + t.Errorf("Expected the command to succeed despite the lookup failure, got %v", err) + } + }) + + if !strings.Contains(out, "pl_1") { + t.Error("Expected the raw private-location ID as a fallback") + } + }) + + t.Run("Tolerates a nil resolver", func(t *testing.T) { + client := bodyResponder(monitorsWithPrivateLocations) + monitorClient := monitors.NewMonitorClientWithHTTPClient(client.GetHTTPClient(), "test-token") + + out := captureTable(t, func() { + if err := monitors.ListMonitors(context.Background(), monitorClient, nil, false, nil); err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + if !strings.Contains(out, "pl_1") { + t.Error("Expected the raw private-location ID when no resolver is supplied") + } + }) +} diff --git a/internal/privatelocation/export_test.go b/internal/privatelocation/export_test.go new file mode 100644 index 0000000..0828cbd --- /dev/null +++ b/internal/privatelocation/export_test.go @@ -0,0 +1,11 @@ +package privatelocation + +var ( + StatusToString = statusToString + FormatLastSeen = formatLastSeen + FormatMetadata = formatMetadata + FormatMonitors = formatMonitors + ParseMetadataFlag = parseMetadataFlag +) + +type MonitorRef = monitorRef diff --git a/internal/privatelocation/privatelocation.go b/internal/privatelocation/privatelocation.go new file mode 100644 index 0000000..c595611 --- /dev/null +++ b/internal/privatelocation/privatelocation.go @@ -0,0 +1,259 @@ +package privatelocation + +import ( + "context" + "fmt" + "net/http" + "os" + "sort" + "strings" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/private_location/v1/private_locationv1connect" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" + "connectrpc.com/connect" + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/olekukonko/tablewriter/tw" + "github.com/urfave/cli/v3" + + "github.com/openstatusHQ/cli/internal/api" + output "github.com/openstatusHQ/cli/internal/cli" +) + +// Ref is the resolved identity of a private location, embedded in the JSON output of the +// monitors commands. +type Ref struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +func NewPrivateLocationClient(apiKey string) private_locationv1connect.PrivateLocationServiceClient { + return private_locationv1connect.NewPrivateLocationServiceClient( + api.DefaultHTTPClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func NewPrivateLocationClientWithHTTPClient(httpClient *http.Client, apiKey string) private_locationv1connect.PrivateLocationServiceClient { + return private_locationv1connect.NewPrivateLocationServiceClient( + httpClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +// newMonitorClient is a local copy of the monitor client constructor. internal/monitors imports +// this package to resolve location names, so importing it back would create a cycle. +func newMonitorClient(httpClient *http.Client, apiKey string) monitorv1connect.MonitorServiceClient { + return monitorv1connect.NewMonitorServiceClient( + httpClient, + api.ConnectBaseURL, + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +func statusToString(s private_locationv1.PrivateLocationStatus) string { + switch s { + case private_locationv1.PrivateLocationStatus_PRIVATE_LOCATION_STATUS_ACTIVE: + return "active" + case private_locationv1.PrivateLocationStatus_PRIVATE_LOCATION_STATUS_ERROR: + return "error" + default: + return "unknown" + } +} + +func colorizeStatus(status string) string { + switch status { + case "active": + return color.GreenString("● active") + case "error": + return color.RedString("● error") + default: + return "● unknown" + } +} + +// formatLastSeen renders an agent heartbeat timestamp. The API returns an empty string when the +// agent has never reported. +func formatLastSeen(rfc3339 string) string { + if rfc3339 == "" { + return "never" + } + return output.FormatTimestamp(rfc3339) +} + +// MaskToken hides all but the last four characters of an agent token. +func MaskToken(token string) string { + if token == "" { + return "" + } + const visible = 4 + if len(token) <= visible { + return strings.Repeat("•", len(token)) + } + return strings.Repeat("•", 12) + token[len(token)-visible:] +} + +func formatMetadata(metadata map[string]string) string { + if len(metadata) == 0 { + return "none" + } + keys := make([]string, 0, len(metadata)) + for k := range metadata { + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, k+"="+metadata[k]) + } + return strings.Join(pairs, ", ") +} + +// Resolve turns private location IDs into display references. Callers that cannot tolerate a +// failure should inspect the error; the returned map is always usable, and IDs with no match are +// simply absent so callers can fall back to the raw ID. +func Resolve(ctx context.Context, client private_locationv1connect.PrivateLocationServiceClient, ids []string) (map[string]Ref, error) { + refs := make(map[string]Ref, len(ids)) + if len(ids) == 0 { + return refs, nil + } + + resp, err := client.ListPrivateLocations(ctx, &private_locationv1.ListPrivateLocationsRequest{}) + if err != nil { + return refs, err + } + + wanted := make(map[string]struct{}, len(ids)) + for _, id := range ids { + wanted[id] = struct{}{} + } + + for _, l := range resp.GetPrivateLocations() { + if _, ok := wanted[l.GetId()]; !ok { + continue + } + refs[l.GetId()] = Ref{ + ID: l.GetId(), + Name: l.GetName(), + Status: statusToString(l.GetStatus()), + } + } + + return refs, nil +} + +// ResolveWithHTTPClient is the entry point used by internal/monitors. +func ResolveWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, ids []string) (map[string]Ref, error) { + return Resolve(ctx, NewPrivateLocationClientWithHTTPClient(httpClient, apiKey), ids) +} + +// Label renders a resolved location for the monitors display, falling back to the raw ID when the +// lookup did not return it. +func Label(id string, refs map[string]Ref, withStatus bool) string { + ref, ok := refs[id] + if !ok { + return id + } + if !withStatus { + return ref.Name + } + return fmt.Sprintf("%s (%s)", ref.Name, colorizeStatus(ref.Status)) +} + +// Labels renders a list of locations in the order the IDs were given. +func Labels(ids []string, refs map[string]Ref, withStatus bool) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = append(out, Label(id, refs, withStatus)) + } + return out +} + +// Refs returns resolved references in the order the IDs were given, synthesising an entry for any +// ID the lookup did not return. +func Refs(ids []string, refs map[string]Ref) []Ref { + out := make([]Ref, 0, len(ids)) + for _, id := range ids { + if ref, ok := refs[id]; ok { + out = append(out, ref) + continue + } + out = append(out, Ref{ID: id}) + } + return out +} + +func newBlueprintTable() *tablewriter.Table { + return tablewriter.NewTable(os.Stdout, + tablewriter.WithRenderer(renderer.NewBlueprint()), + tablewriter.WithRendition(tw.Rendition{ + Symbols: tw.NewSymbolCustom("custom").WithColumn("="), + Borders: tw.Border{ + Top: tw.Off, + Left: tw.Off, + Right: tw.Off, + Bottom: tw.Off, + }, + Settings: tw.Settings{ + Lines: tw.Lines{ + ShowHeaderLine: tw.Off, + ShowFooterLine: tw.On, + }, + Separators: tw.Separators{ + BetweenRows: tw.Off, + BetweenColumns: tw.On, + }, + }, + }), + tablewriter.WithRowAlignment(tw.AlignLeft), + tablewriter.WithHeaderAlignment(tw.AlignLeft), + ) +} + +// monitorNames returns an ID to name map for every monitor in the workspace. +func monitorNames(ctx context.Context, client monitorv1connect.MonitorServiceClient) (map[string]string, error) { + resp, err := client.ListMonitors(ctx, &monitorv1.ListMonitorsRequest{}) + if err != nil { + return nil, err + } + + names := make(map[string]string) + for _, m := range resp.GetHttpMonitors() { + names[m.GetId()] = m.GetName() + } + for _, m := range resp.GetTcpMonitors() { + names[m.GetId()] = m.GetName() + } + for _, m := range resp.GetDnsMonitors() { + names[m.GetId()] = m.GetName() + } + return names, nil +} + +func PrivateLocationsCmd() *cli.Command { + return &cli.Command{ + Name: "private-locations", + Aliases: []string{"pl"}, + Usage: "Manage private locations", + Description: `Private locations are self-hosted checker agents that run monitors from inside +your own network. This command lists them, shows their details, and creates new ones. + +Public regions (Fly.io, Koyeb, Railway) are not listed here; they appear in +'openstatus monitors info' and 'openstatus check'.`, + Commands: []*cli.Command{ + GetPrivateLocationListCmd(), + GetPrivateLocationInfoCmd(), + GetPrivateLocationCreateCmd(), + }, + } +} diff --git a/internal/privatelocation/privatelocation_create.go b/internal/privatelocation/privatelocation_create.go new file mode 100644 index 0000000..8f3b46c --- /dev/null +++ b/internal/privatelocation/privatelocation_create.go @@ -0,0 +1,243 @@ +package privatelocation + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/private_location/v1/private_locationv1connect" + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" + "github.com/charmbracelet/huh" + "github.com/logrusorgru/aurora/v4" + "github.com/urfave/cli/v3" + + "github.com/openstatusHQ/cli/internal/api" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/wizard" +) + +type CreateParams struct { + Name string + MonitorIDs []string + Metadata map[string]string +} + +type createOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Token string `json:"token"` + MonitorIDs []string `json:"monitor_ids"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` +} + +// parseMetadataFlag turns repeated key=value pairs into a map. +func parseMetadataFlag(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + metadata := make(map[string]string, len(values)) + for _, v := range values { + key, value, found := strings.Cut(v, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("invalid --metadata %q: expected key=value", v) + } + metadata[key] = value + } + return metadata, nil +} + +func CreatePrivateLocation(ctx context.Context, client private_locationv1connect.PrivateLocationServiceClient, p CreateParams) (*private_locationv1.PrivateLocation, error) { + req := &private_locationv1.CreatePrivateLocationRequest{} + req.SetName(p.Name) + if len(p.MonitorIDs) > 0 { + req.SetMonitorIds(p.MonitorIDs) + } + if len(p.Metadata) > 0 { + req.SetMetadata(p.Metadata) + } + + resp, err := client.CreatePrivateLocation(ctx, req) + if err != nil { + return nil, output.FormatError(err, "private location", "") + } + + return resp.GetPrivateLocation(), nil +} + +func renderCreated(location *private_locationv1.PrivateLocation) error { + if output.IsJSONOutput() { + return output.PrintJSON(createOutput{ + ID: location.GetId(), + Name: location.GetName(), + Token: location.GetToken(), + MonitorIDs: location.GetMonitorIds(), + Metadata: location.GetMetadata(), + CreatedAt: location.GetCreatedAt(), + }) + } + + if output.IsQuiet() { + fmt.Println(location.GetId()) + return nil + } + + fmt.Println(aurora.Bold("Private location created:")) + tbl := newBlueprintTable() + tbl.Bulk([][]string{ + {"ID", location.GetId()}, + {"Name", location.GetName()}, + {"Monitors", fmt.Sprintf("%d", len(location.GetMonitorIds()))}, + {"Metadata", formatMetadata(location.GetMetadata())}, + {"Token", location.GetToken()}, + }) + tbl.Render() + + fmt.Println("\nStore this token securely. Retrieve it again with:") + fmt.Printf(" openstatus pl info %s --show-token\n", location.GetId()) + + return nil +} + +func CreatePrivateLocationWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, p CreateParams) error { + client := NewPrivateLocationClientWithHTTPClient(httpClient, apiKey) + location, err := CreatePrivateLocation(ctx, client, p) + if err != nil { + return err + } + return renderCreated(location) +} + +// runCreateWizard prompts for anything the flags did not supply. +func runCreateWizard(ctx context.Context, monitorClient monitorv1connect.MonitorServiceClient, prefilled CreateParams) (CreateParams, error) { + inputs := prefilled + + var fields []huh.Field + + if inputs.Name == "" { + fields = append(fields, huh.NewInput(). + Title("Name"). + Description("A label for this private location, e.g. office-paris"). + Validate(wizard.NotEmpty("name")). + Value(&inputs.Name)) + } + + if len(inputs.MonitorIDs) == 0 { + s := output.StartSpinner("Fetching monitors...") + names, err := monitorNames(ctx, monitorClient) + output.StopSpinner(s) + if err != nil { + return inputs, fmt.Errorf("could not fetch monitors: %w", err) + } + + if len(names) > 0 { + ids := make([]string, 0, len(names)) + for id := range names { + ids = append(ids, id) + } + sort.Strings(ids) + + options := make([]huh.Option[string], 0, len(ids)) + for _, id := range ids { + options = append(options, huh.NewOption(fmt.Sprintf("%s (%s)", names[id], id), id)) + } + + fields = append(fields, huh.NewMultiSelect[string](). + Title("Monitors"). + Description("Monitors this private location should run (optional)"). + Options(options...). + Value(&inputs.MonitorIDs)) + } + } + + if len(fields) == 0 { + return inputs, nil + } + + form := huh.NewForm(huh.NewGroup(fields...)).WithTheme(huh.ThemeBase()) + if err := form.Run(); err != nil { + return inputs, wizard.HandleFormError(err) + } + + return inputs, nil +} + +func GetPrivateLocationCreateCmd() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Create a private location", + UsageText: `openstatus private-locations create --name office-paris + openstatus pl create --name office-paris --monitor-ids 12345,12346 + openstatus pl create --name office-paris --metadata env=prod --metadata team=infra`, + Description: `Create a private location and print its agent token. The token is shown in full +because you need it to configure the agent; retrieve it later with 'openstatus pl info --show-token'. + +Run without --name in an interactive terminal to be prompted, including a +picker for the monitors to attach.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.StringFlag{ + Name: "name", + Usage: "Name of the private location", + }, + &cli.StringSliceFlag{ + Name: "monitor-ids", + Usage: "Monitor IDs to attach, comma-separated or repeated", + }, + &cli.StringSliceFlag{ + Name: "metadata", + Usage: "Key/value label, repeatable: --metadata =", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + metadata, err := parseMetadataFlag(cmd.StringSlice("metadata")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + params := CreateParams{ + Name: cmd.String("name"), + MonitorIDs: cmd.StringSlice("monitor-ids"), + Metadata: metadata, + } + + if params.Name == "" { + if output.IsJSONOutput() || !output.IsStdinTerminal() { + return cli.Exit("missing required flags: --name", 1) + } + params, err = runCreateWizard(ctx, newMonitorClient(api.DefaultHTTPClient, apiKey), params) + if err != nil { + return cli.Exit(err.Error(), 1) + } + } + + s := output.StartSpinner("Creating private location...") + client := NewPrivateLocationClient(apiKey) + location, err := CreatePrivateLocation(ctx, client, params) + output.StopSpinner(s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + if err := renderCreated(location); err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/privatelocation/privatelocation_create_test.go b/internal/privatelocation/privatelocation_create_test.go new file mode 100644 index 0000000..96c3a0b --- /dev/null +++ b/internal/privatelocation/privatelocation_create_test.go @@ -0,0 +1,92 @@ +package privatelocation_test + +import ( + "context" + "strings" + "testing" + + "github.com/openstatusHQ/cli/internal/privatelocation" +) + +const createProcedure = "/CreatePrivateLocation" + +const createBody = `{"privateLocation":{"id":"pl_new","name":"office-paris","token":"os_pl_live_9f3a2b1c8d4e6f","monitorIds":["123"],"metadata":{"env":"prod"},"createdAt":"2026-07-24T14:02:11Z","status":"PRIVATE_LOCATION_STATUS_UNSPECIFIED"}}` + +// Not parallel: these subtests redirect the process-wide os.Stdout. +func Test_CreatePrivateLocation(t *testing.T) { + t.Run("Prints the token in full", func(t *testing.T) { + client := jsonResponder(map[string]string{createProcedure: createBody}, nil) + + out := captureStdout(t, func() { + err := privatelocation.CreatePrivateLocationWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", privatelocation.CreateParams{ + Name: "office-paris", + MonitorIDs: []string{"123"}, + Metadata: map[string]string{"env": "prod"}, + }) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + // The token is only useful if it can be copied, so create never masks it. + if !strings.Contains(out, "os_pl_live_9f3a2b1c8d4e6f") { + t.Error("Expected the full token on create") + } + if !strings.Contains(out, "openstatus pl info pl_new --show-token") { + t.Error("Expected the retrieval hint") + } + }) + + t.Run("Sends the requested name and monitors", func(t *testing.T) { + var calls []string + client := jsonResponder(map[string]string{createProcedure: createBody}, &calls) + + out := captureStdout(t, func() { + err := privatelocation.CreatePrivateLocationWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", privatelocation.CreateParams{ + Name: "office-paris", + }) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + _ = out + + if len(calls) != 1 || !strings.HasSuffix(calls[0], createProcedure) { + t.Errorf("Expected exactly one CreatePrivateLocation call, got %v", calls) + } + }) + + t.Run("Surfaces API failures", func(t *testing.T) { + client := errorResponder(400, `{"code":"invalid_argument","message":"name is required"}`) + + err := privatelocation.CreatePrivateLocationWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", privatelocation.CreateParams{Name: ""}) + if err == nil { + t.Fatal("Expected an error for a rejected call") + } + }) +} + +func Test_CreateCmdFlags(t *testing.T) { + t.Parallel() + + cmd := privatelocation.GetPrivateLocationCreateCmd() + + expected := map[string]bool{ + "access-token": false, + "name": false, + "monitor-ids": false, + "metadata": false, + } + for _, f := range cmd.Flags { + for _, name := range f.Names() { + if _, ok := expected[name]; ok { + expected[name] = true + } + } + } + for name, found := range expected { + if !found { + t.Errorf("Expected flag '%s' not found", name) + } + } +} diff --git a/internal/privatelocation/privatelocation_info.go b/internal/privatelocation/privatelocation_info.go new file mode 100644 index 0000000..420a847 --- /dev/null +++ b/internal/privatelocation/privatelocation_info.go @@ -0,0 +1,189 @@ +package privatelocation + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + "sync" + + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" + "connectrpc.com/connect" + "github.com/logrusorgru/aurora/v4" + "github.com/urfave/cli/v3" + + "github.com/openstatusHQ/cli/internal/api" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" +) + +type monitorRef struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +type infoOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Token string `json:"token"` + Monitors []monitorRef `json:"monitors"` + Metadata map[string]string `json:"metadata,omitempty"` + LastSeenAt string `json:"last_seen_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func GetPrivateLocationInfo(ctx context.Context, httpClient *http.Client, apiKey string, id string, showToken bool, s *output.Spinner) error { + if id == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus private-locations info ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus pl info pl_1a2b3c") + return errors.New("private location ID is required") + } + + locationClient := NewPrivateLocationClientWithHTTPClient(httpClient, apiKey) + monitorClient := newMonitorClient(httpClient, apiKey) + + var ( + locationResp *private_locationv1.GetPrivateLocationResponse + names map[string]string + locationErr error + namesErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { + defer wg.Done() + req := &private_locationv1.GetPrivateLocationRequest{} + req.SetId(id) + locationResp, locationErr = locationClient.GetPrivateLocation(ctx, req) + }() + go func() { + defer wg.Done() + names, namesErr = monitorNames(ctx, monitorClient) + }() + wg.Wait() + + output.StopSpinner(s) + + if locationErr != nil { + var connectErr *connect.Error + if errors.As(locationErr, &connectErr) && connectErr.Code() == connect.CodeNotFound { + return fmt.Errorf("private location %q not found. Run 'openstatus pl list' to see available locations", id) + } + return output.FormatError(locationErr, "private location", id) + } + + if namesErr != nil { + fmt.Fprintln(os.Stderr, "Warning: could not resolve monitor names:", namesErr) + names = map[string]string{} + } + + location := locationResp.GetPrivateLocation() + monitorIDs := location.GetMonitorIds() + + monitors := make([]monitorRef, 0, len(monitorIDs)) + for _, monitorID := range monitorIDs { + monitors = append(monitors, monitorRef{ID: monitorID, Name: names[monitorID]}) + } + + token := location.GetToken() + if !showToken { + token = MaskToken(token) + } + + if output.IsJSONOutput() { + return output.PrintJSON(infoOutput{ + ID: location.GetId(), + Name: location.GetName(), + Status: statusToString(location.GetStatus()), + Token: token, + Monitors: monitors, + Metadata: location.GetMetadata(), + LastSeenAt: location.GetLastSeenAt(), + CreatedAt: location.GetCreatedAt(), + UpdatedAt: location.GetUpdatedAt(), + }) + } + + fmt.Println(aurora.Bold("Private Location:")) + tbl := newBlueprintTable() + + data := [][]string{ + {"ID", location.GetId()}, + {"Name", location.GetName()}, + {"Status", colorizeStatus(statusToString(location.GetStatus()))}, + {"Last Seen", formatLastSeen(location.GetLastSeenAt())}, + {"Monitors", formatMonitors(monitors)}, + {"Metadata", formatMetadata(location.GetMetadata())}, + {"Token", token}, + } + + tbl.Bulk(data) + tbl.Render() + + if !showToken && location.GetToken() != "" && !output.IsQuiet() { + fmt.Printf("\nRun with --show-token to reveal the agent token.\n") + } + + return nil +} + +func formatMonitors(monitors []monitorRef) string { + if len(monitors) == 0 { + return "none" + } + labels := make([]string, 0, len(monitors)) + for _, m := range monitors { + if m.Name == "" { + labels = append(labels, m.ID) + continue + } + labels = append(labels, fmt.Sprintf("%s (%s)", m.Name, m.ID)) + } + return fmt.Sprintf("%d: %s", len(monitors), strings.Join(labels, ", ")) +} + +func GetPrivateLocationInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, id string, showToken bool) error { + return GetPrivateLocationInfo(ctx, httpClient, apiKey, id, showToken, nil) +} + +func GetPrivateLocationInfoCmd() *cli.Command { + return &cli.Command{ + Name: "info", + Usage: "Get private location details", + UsageText: `openstatus private-locations info + openstatus pl info pl_1a2b3c + openstatus pl info pl_1a2b3c --show-token`, + Description: `Fetch a private location including the monitors it runs and its agent token. +The token is masked unless --show-token is passed.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.BoolFlag{ + Name: "show-token", + Usage: "Reveal the agent token instead of masking it", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + id := cmd.Args().Get(0) + s := output.StartSpinner("Fetching private location...") + if err := GetPrivateLocationInfo(ctx, api.DefaultHTTPClient, apiKey, id, cmd.Bool("show-token"), s); err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/privatelocation/privatelocation_info_test.go b/internal/privatelocation/privatelocation_info_test.go new file mode 100644 index 0000000..568fc10 --- /dev/null +++ b/internal/privatelocation/privatelocation_info_test.go @@ -0,0 +1,132 @@ +package privatelocation_test + +import ( + "bytes" + "context" + "io" + "os" + "strings" + "testing" + + "github.com/openstatusHQ/cli/internal/privatelocation" +) + +const ( + getProcedure = "/GetPrivateLocation" + listMonitorProcedure = "/ListMonitors" +) + +// captureStdout runs f with stdout redirected and returns everything it printed. +func captureStdout(t *testing.T, f func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + original := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = original }) + + f() + + if err := w.Close(); err != nil { + t.Fatalf("failed to close pipe: %v", err) + } + os.Stdout = original + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("failed to read pipe: %v", err) + } + return buf.String() +} + +const infoBody = `{"privateLocation":{"id":"pl_1","name":"office-paris","token":"os_pl_live_9f3a2b1c8d4e6f","monitorIds":["123","999"],"lastSeenAt":"2026-07-24T14:02:11Z","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","metadata":{"env":"prod"},"status":"PRIVATE_LOCATION_STATUS_ACTIVE"}}` + +const monitorsBody = `{"httpMonitors":[{"id":"123","name":"api-prod","url":"https://api.example.com","active":true}],"tcpMonitors":[],"dnsMonitors":[]}` + +// Not parallel: these subtests redirect the process-wide os.Stdout. +func Test_GetPrivateLocationInfo(t *testing.T) { + t.Run("Masks the token by default", func(t *testing.T) { + client := jsonResponder(map[string]string{ + getProcedure: infoBody, + listMonitorProcedure: monitorsBody, + }, nil) + + out := captureStdout(t, func() { + if err := privatelocation.GetPrivateLocationInfoWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", "pl_1", false); err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + if strings.Contains(out, "os_pl_live_9f3a2b1c8d4e6f") { + t.Error("Expected the token to be masked by default") + } + if !strings.Contains(out, "4e6f") { + t.Error("Expected the masked token to keep the last four characters") + } + }) + + t.Run("Reveals the token with showToken", func(t *testing.T) { + client := jsonResponder(map[string]string{ + getProcedure: infoBody, + listMonitorProcedure: monitorsBody, + }, nil) + + out := captureStdout(t, func() { + if err := privatelocation.GetPrivateLocationInfoWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", "pl_1", true); err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + if !strings.Contains(out, "os_pl_live_9f3a2b1c8d4e6f") { + t.Error("Expected the full token when showToken is set") + } + }) + + t.Run("Resolves monitor names and falls back to IDs", func(t *testing.T) { + client := jsonResponder(map[string]string{ + getProcedure: infoBody, + listMonitorProcedure: monitorsBody, + }, nil) + + out := captureStdout(t, func() { + if err := privatelocation.GetPrivateLocationInfoWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", "pl_1", false); err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + if !strings.Contains(out, "api-prod") { + t.Error("Expected the resolved monitor name") + } + // 999 has no matching monitor, so it should appear as a bare ID. + if !strings.Contains(out, "999") { + t.Error("Expected the unresolved monitor ID to still be listed") + } + }) + + t.Run("Requires an ID", func(t *testing.T) { + client := jsonResponder(map[string]string{}, nil) + + err := privatelocation.GetPrivateLocationInfoWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", "", false) + if err == nil { + t.Fatal("Expected an error when no ID is given") + } + }) + + t.Run("Gives a pl-specific hint when not found", func(t *testing.T) { + client := errorResponder(404, `{"code":"not_found","message":"missing"}`) + + err := privatelocation.GetPrivateLocationInfoWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", "pl_bogus", false) + if err == nil { + t.Fatal("Expected a not-found error") + } + if !strings.Contains(err.Error(), "openstatus pl list") { + t.Errorf("Expected the hint to reference 'openstatus pl list', got %q", err.Error()) + } + if !strings.Contains(err.Error(), "pl_bogus") { + t.Errorf("Expected the offending ID in the message, got %q", err.Error()) + } + }) +} diff --git a/internal/privatelocation/privatelocation_list.go b/internal/privatelocation/privatelocation_list.go new file mode 100644 index 0000000..9ab80bf --- /dev/null +++ b/internal/privatelocation/privatelocation_list.go @@ -0,0 +1,129 @@ +package privatelocation + +import ( + "context" + "fmt" + "net/http" + + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/private_location/v1/private_locationv1connect" + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" + "github.com/fatih/color" + "github.com/rodaine/table" + "github.com/urfave/cli/v3" + + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" +) + +type listEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + MonitorCount int32 `json:"monitor_count"` + LastSeenAt string `json:"last_seen_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func ListPrivateLocations(ctx context.Context, client private_locationv1connect.PrivateLocationServiceClient, limit int, s *output.Spinner) error { + req := &private_locationv1.ListPrivateLocationsRequest{} + if limit > 0 { + req.SetLimit(int32(limit)) + } + + resp, err := client.ListPrivateLocations(ctx, req) + output.StopSpinner(s) + if err != nil { + return output.FormatError(err, "private location", "") + } + + locations := resp.GetPrivateLocations() + + if output.IsJSONOutput() { + entries := make([]listEntry, 0, len(locations)) + for _, l := range locations { + entries = append(entries, listEntry{ + ID: l.GetId(), + Name: l.GetName(), + Status: statusToString(l.GetStatus()), + MonitorCount: l.GetMonitorCount(), + LastSeenAt: l.GetLastSeenAt(), + CreatedAt: l.GetCreatedAt(), + UpdatedAt: l.GetUpdatedAt(), + }) + } + return output.PrintJSON(entries) + } + + if len(locations) == 0 { + if !output.IsQuiet() { + fmt.Println("No private locations found") + } + return nil + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("ID", "Name", "Status", "Monitors", "Last Seen") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, l := range locations { + tbl.AddRow( + l.GetId(), + l.GetName(), + colorizeStatus(statusToString(l.GetStatus())), + l.GetMonitorCount(), + formatLastSeen(l.GetLastSeenAt()), + ) + } + + tbl.Print() + + if total := int(resp.GetTotalSize()); total > len(locations) && !output.IsQuiet() { + fmt.Printf("\nShowing %d of %d. Use --limit to see more.\n", len(locations), total) + } + + return nil +} + +func ListPrivateLocationsWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, limit int) error { + client := NewPrivateLocationClientWithHTTPClient(httpClient, apiKey) + return ListPrivateLocations(ctx, client, limit, nil) +} + +func GetPrivateLocationListCmd() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List all private locations", + UsageText: `openstatus private-locations list + openstatus pl list + openstatus pl list --limit 10`, + Description: `List the private locations in your workspace, with the health of each agent +and the number of monitors it runs.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of private locations to return (1-100)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + s := output.StartSpinner("Fetching private locations...") + client := NewPrivateLocationClient(apiKey) + if err := ListPrivateLocations(ctx, client, int(cmd.Int("limit")), s); err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/privatelocation/privatelocation_list_test.go b/internal/privatelocation/privatelocation_list_test.go new file mode 100644 index 0000000..8fc401a --- /dev/null +++ b/internal/privatelocation/privatelocation_list_test.go @@ -0,0 +1,53 @@ +package privatelocation_test + +import ( + "context" + "testing" + + "github.com/openstatusHQ/cli/internal/privatelocation" +) + +const listProcedure = "/ListPrivateLocations" + +func Test_ListPrivateLocations(t *testing.T) { + t.Parallel() + + t.Run("Successfully returns private locations", func(t *testing.T) { + body := `{"privateLocations":[{"id":"pl_1","name":"office-paris","monitorCount":4,"lastSeenAt":"2026-07-24T14:02:11Z","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","status":"PRIVATE_LOCATION_STATUS_ACTIVE"},{"id":"pl_2","name":"k8s-prod-eu","monitorCount":2,"lastSeenAt":"","createdAt":"2026-02-01T00:00:00Z","updatedAt":"2026-02-01T00:00:00Z","status":"PRIVATE_LOCATION_STATUS_ERROR"}],"totalSize":2}` + client := jsonResponder(map[string]string{listProcedure: body}, nil) + + err := privatelocation.ListPrivateLocationsWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", 0) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Returns empty list without error", func(t *testing.T) { + body := `{"privateLocations":[],"totalSize":0}` + client := jsonResponder(map[string]string{listProcedure: body}, nil) + + err := privatelocation.ListPrivateLocationsWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", 0) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Renders when the page is truncated", func(t *testing.T) { + body := `{"privateLocations":[{"id":"pl_1","name":"office-paris","monitorCount":1,"status":"PRIVATE_LOCATION_STATUS_ACTIVE"}],"totalSize":63}` + client := jsonResponder(map[string]string{listProcedure: body}, nil) + + err := privatelocation.ListPrivateLocationsWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", 1) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Surfaces API failures", func(t *testing.T) { + client := errorResponder(403, `{"code":"permission_denied","message":"no access"}`) + + err := privatelocation.ListPrivateLocationsWithHTTPClient(context.Background(), client.GetHTTPClient(), "test-token", 0) + if err == nil { + t.Fatal("Expected an error for a rejected call") + } + }) +} diff --git a/internal/privatelocation/privatelocation_test.go b/internal/privatelocation/privatelocation_test.go new file mode 100644 index 0000000..dd89281 --- /dev/null +++ b/internal/privatelocation/privatelocation_test.go @@ -0,0 +1,291 @@ +package privatelocation_test + +import ( + "io" + "net/http" + "strings" + "testing" + + private_locationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/private_location/v1" + + "github.com/openstatusHQ/cli/internal/privatelocation" +) + +type interceptorHTTPClient struct { + f func(req *http.Request) (*http.Response, error) +} + +func (i *interceptorHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { + return i.f(req) +} + +func (i *interceptorHTTPClient) GetHTTPClient() *http.Client { + return &http.Client{ + Transport: i, + } +} + +// jsonResponder replies to every request with the given body, recording the procedures called. +func jsonResponder(bodies map[string]string, calls *[]string) *interceptorHTTPClient { + return &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if calls != nil { + *calls = append(*calls, req.URL.Path) + } + body := "{}" + for suffix, b := range bodies { + if strings.HasSuffix(req.URL.Path, suffix) { + body = b + break + } + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } +} + +// errorResponder replies with a Connect error envelope. +func errorResponder(status int, body string) *interceptorHTTPClient { + return &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } +} + +func Test_PrivateLocationsCmd(t *testing.T) { + t.Parallel() + + t.Run("Returns valid command", func(t *testing.T) { + cmd := privatelocation.PrivateLocationsCmd() + + if cmd == nil { + t.Fatal("Expected non-nil command") + } + if cmd.Name != "private-locations" { + t.Errorf("Expected command name 'private-locations', got %s", cmd.Name) + } + }) + + t.Run("Has pl alias and no others", func(t *testing.T) { + cmd := privatelocation.PrivateLocationsCmd() + + if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "pl" { + t.Errorf("Expected exactly the 'pl' alias, got %v", cmd.Aliases) + } + }) + + t.Run("Has expected subcommands", func(t *testing.T) { + cmd := privatelocation.PrivateLocationsCmd() + + if len(cmd.Commands) != 3 { + t.Errorf("Expected 3 subcommands, got %d", len(cmd.Commands)) + } + + expected := map[string]bool{"list": false, "info": false, "create": false} + for _, sub := range cmd.Commands { + if _, ok := expected[sub.Name]; ok { + expected[sub.Name] = true + } + } + for name, found := range expected { + if !found { + t.Errorf("Expected subcommand '%s' not found", name) + } + } + }) + + t.Run("info has no view alias", func(t *testing.T) { + cmd := privatelocation.PrivateLocationsCmd() + + for _, sub := range cmd.Commands { + if sub.Name != "info" { + continue + } + for _, alias := range sub.Aliases { + if alias == "view" { + t.Error("Expected no 'view' alias on info") + } + } + } + }) +} + +func Test_MaskToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + want string + }{ + {"empty stays empty", "", ""}, + {"short token fully masked", "abc", "•••"}, + {"exactly four fully masked", "abcd", "••••"}, + {"long token keeps last four", "os_pl_live_9f3a2b1c8d4e6f", "••••••••••••4e6f"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := privatelocation.MaskToken(tt.token); got != tt.want { + t.Errorf("MaskToken(%q) = %q, want %q", tt.token, got, tt.want) + } + }) + } +} + +func Test_statusToString(t *testing.T) { + t.Parallel() + + tests := []struct { + status private_locationv1.PrivateLocationStatus + want string + }{ + {private_locationv1.PrivateLocationStatus_PRIVATE_LOCATION_STATUS_ACTIVE, "active"}, + {private_locationv1.PrivateLocationStatus_PRIVATE_LOCATION_STATUS_ERROR, "error"}, + {private_locationv1.PrivateLocationStatus_PRIVATE_LOCATION_STATUS_UNSPECIFIED, "unknown"}, + } + + for _, tt := range tests { + if got := privatelocation.StatusToString(tt.status); got != tt.want { + t.Errorf("StatusToString(%v) = %q, want %q", tt.status, got, tt.want) + } + } +} + +func Test_formatLastSeen(t *testing.T) { + t.Parallel() + + if got := privatelocation.FormatLastSeen(""); got != "never" { + t.Errorf("Expected 'never' for empty timestamp, got %q", got) + } + + if got := privatelocation.FormatLastSeen("2026-07-24T14:02:11Z"); got == "never" || got == "" { + t.Errorf("Expected a formatted timestamp, got %q", got) + } + + if got := privatelocation.FormatLastSeen("not-a-date"); got != "not-a-date" { + t.Errorf("Expected unparseable input to pass through, got %q", got) + } +} + +func Test_formatMetadata(t *testing.T) { + t.Parallel() + + if got := privatelocation.FormatMetadata(nil); got != "none" { + t.Errorf("Expected 'none' for empty metadata, got %q", got) + } + + // Keys are sorted so the output is stable across runs. + got := privatelocation.FormatMetadata(map[string]string{"team": "infra", "env": "prod"}) + if got != "env=prod, team=infra" { + t.Errorf("Expected sorted metadata, got %q", got) + } +} + +func Test_formatMonitors(t *testing.T) { + t.Parallel() + + if got := privatelocation.FormatMonitors(nil); got != "none" { + t.Errorf("Expected 'none' for no monitors, got %q", got) + } + + got := privatelocation.FormatMonitors([]privatelocation.MonitorRef{ + {ID: "123", Name: "api-prod"}, + {ID: "456"}, + }) + if got != "2: api-prod (123), 456" { + t.Errorf("Expected resolved name then bare ID fallback, got %q", got) + } +} + +func Test_parseMetadataFlag(t *testing.T) { + t.Parallel() + + t.Run("nil for no values", func(t *testing.T) { + got, err := privatelocation.ParseMetadataFlag(nil) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if got != nil { + t.Errorf("Expected nil map, got %v", got) + } + }) + + t.Run("parses key=value pairs", func(t *testing.T) { + got, err := privatelocation.ParseMetadataFlag([]string{"env=prod", "team=infra"}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if got["env"] != "prod" || got["team"] != "infra" { + t.Errorf("Unexpected metadata: %v", got) + } + }) + + t.Run("keeps = inside the value", func(t *testing.T) { + got, err := privatelocation.ParseMetadataFlag([]string{"query=a=b"}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if got["query"] != "a=b" { + t.Errorf("Expected value 'a=b', got %q", got["query"]) + } + }) + + t.Run("rejects malformed input", func(t *testing.T) { + for _, bad := range []string{"noequals", "=novalue"} { + if _, err := privatelocation.ParseMetadataFlag([]string{bad}); err == nil { + t.Errorf("Expected an error for %q", bad) + } + } + }) +} + +func Test_Refs(t *testing.T) { + t.Parallel() + + refs := map[string]privatelocation.Ref{ + "pl_1": {ID: "pl_1", Name: "office-paris", Status: "active"}, + } + + got := privatelocation.Refs([]string{"pl_1", "pl_missing"}, refs) + if len(got) != 2 { + t.Fatalf("Expected 2 refs, got %d", len(got)) + } + if got[0].Name != "office-paris" { + t.Errorf("Expected resolved name, got %q", got[0].Name) + } + // Unresolved IDs still produce an entry so callers can fall back to the raw ID. + if got[1].ID != "pl_missing" || got[1].Name != "" { + t.Errorf("Expected bare ID for unresolved entry, got %+v", got[1]) + } +} + +func Test_Labels(t *testing.T) { + t.Parallel() + + refs := map[string]privatelocation.Ref{ + "pl_1": {ID: "pl_1", Name: "office-paris", Status: "active"}, + } + + labels := privatelocation.Labels([]string{"pl_1", "pl_missing"}, refs, false) + if labels[0] != "office-paris" { + t.Errorf("Expected name, got %q", labels[0]) + } + if labels[1] != "pl_missing" { + t.Errorf("Expected raw ID fallback, got %q", labels[1]) + } + + withStatus := privatelocation.Labels([]string{"pl_1"}, refs, true) + if !strings.Contains(withStatus[0], "office-paris") || !strings.Contains(withStatus[0], "active") { + t.Errorf("Expected name and status, got %q", withStatus[0]) + } +}