mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add CLI fleet connection reads
This commit is contained in:
+12
@@ -137,6 +137,18 @@ Report an incorrect merge (creates exclusions).
|
||||
{ "sources": ["proxmox", "agent"], "notes": "optional note" }
|
||||
```
|
||||
|
||||
### Fleet Connections
|
||||
`GET /api/connections`
|
||||
Returns the canonical fleet connections ledger with per-row fleet-governance state. Requires admin access with `settings:read`.
|
||||
|
||||
The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels.
|
||||
|
||||
CLI adapter:
|
||||
```bash
|
||||
PULSE_API_TOKEN=your-token pulse fleet connections \
|
||||
--api-url http://localhost:7655
|
||||
```
|
||||
|
||||
### Unified Action Planning
|
||||
`POST /api/actions/plan`
|
||||
Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`.
|
||||
|
||||
@@ -31,8 +31,10 @@ product API routes free of maintainer commercial analytics.
|
||||
4. `internal/api/activity_audit_handlers.go`
|
||||
5. `internal/api/actions.go`
|
||||
6. `internal/actionplanner/planner.go`
|
||||
7. `pkg/pulsecli/actions.go`
|
||||
8. `pkg/pulsecli/root.go`
|
||||
7. `pkg/pulsecli/api_client.go`
|
||||
8. `pkg/pulsecli/actions.go`
|
||||
9. `pkg/pulsecli/fleet.go`
|
||||
10. `pkg/pulsecli/root.go`
|
||||
5. `frontend-modern/src/types/api.ts`
|
||||
6. `frontend-modern/src/types/actionAudit.ts`
|
||||
7. `frontend-modern/src/api/actionAudit.ts`
|
||||
@@ -595,6 +597,10 @@ the canonical monitored-system blocked payload.
|
||||
kernel, architecture, and command capability, so settings surfaces can
|
||||
render recognizable standalone-host identity without a second inventory
|
||||
fetch or frontend-local host reconciliation rules.
|
||||
`pulse fleet connections` may read that same `GET /api/connections`
|
||||
payload as a deterministic CLI adapter for agent-ready operations, but it
|
||||
must remain a read-only view over the canonical connections ledger rather
|
||||
than re-deriving fleet governance state in CLI-local code.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
||||
@@ -1565,6 +1565,8 @@
|
||||
"frontend-modern/src/utils/infrastructureSettingsPresentation.ts",
|
||||
"internal/websocket/hub.go",
|
||||
"pkg/pulsecli/actions.go",
|
||||
"pkg/pulsecli/api_client.go",
|
||||
"pkg/pulsecli/fleet.go",
|
||||
"pkg/pulsecli/root.go"
|
||||
],
|
||||
"verification": {
|
||||
@@ -1688,16 +1690,20 @@
|
||||
},
|
||||
{
|
||||
"id": "pulse-cli-action-planning-contract",
|
||||
"label": "Pulse CLI action planning API adapter proof",
|
||||
"label": "Pulse CLI API adapter proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"pkg/pulsecli/actions.go",
|
||||
"pkg/pulsecli/api_client.go",
|
||||
"pkg/pulsecli/fleet.go",
|
||||
"pkg/pulsecli/root.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"pkg/pulsecli/actions_test.go"
|
||||
"pkg/pulsecli/actions_test.go",
|
||||
"pkg/pulsecli/fleet_test.go",
|
||||
"pkg/pulsecli/root_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+21
-112
@@ -4,10 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,16 +14,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultActionsAPIURL = "http://127.0.0.1:7655"
|
||||
maxActionRequestBytes = 1 << 20
|
||||
maxActionPlanResponseBytes = 1 << 20
|
||||
maxActionErrorBodyChars = 4096
|
||||
maxActionRequestBytes = 1 << 20
|
||||
)
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type ActionsDeps struct {
|
||||
HTTPClient HTTPDoer
|
||||
Getenv func(string) string
|
||||
@@ -100,7 +91,7 @@ func newActionsCmd(deps *ActionsDeps) *cobra.Command {
|
||||
Token: strings.TrimSpace(actionGetenv(deps, "PULSE_API_TOKEN")),
|
||||
}
|
||||
if opts.APIURL == "" {
|
||||
opts.APIURL = defaultActionsAPIURL
|
||||
opts.APIURL = defaultPulseAPIURL
|
||||
}
|
||||
|
||||
planCmd := &cobra.Command{
|
||||
@@ -135,7 +126,7 @@ func newActionCapabilitiesCmd(deps *ActionsDeps) *cobra.Command {
|
||||
Token: strings.TrimSpace(actionGetenv(deps, "PULSE_API_TOKEN")),
|
||||
}
|
||||
if opts.APIURL == "" {
|
||||
opts.APIURL = defaultActionsAPIURL
|
||||
opts.APIURL = defaultPulseAPIURL
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -158,7 +149,7 @@ func newActionAuditCmd(deps *ActionsDeps) *cobra.Command {
|
||||
Limit: 100,
|
||||
}
|
||||
if opts.APIURL == "" {
|
||||
opts.APIURL = defaultActionsAPIURL
|
||||
opts.APIURL = defaultPulseAPIURL
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -183,7 +174,7 @@ func newActionEventsCmd(deps *ActionsDeps) *cobra.Command {
|
||||
Limit: 100,
|
||||
}
|
||||
if opts.APIURL == "" {
|
||||
opts.APIURL = defaultActionsAPIURL
|
||||
opts.APIURL = defaultPulseAPIURL
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -236,12 +227,12 @@ func runActionPlan(cmd *cobra.Command, deps *ActionsDeps, opts actionPlanOptions
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxActionPlanResponseBytes, "action plan response")
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxPulseAPIResponseBytes, "action plan response")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read action plan response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return actionStatusError("action plan request", resp.Status, respBody)
|
||||
return apiStatusError("action plan request", resp.Status, respBody)
|
||||
}
|
||||
|
||||
var plan unified.ActionPlan
|
||||
@@ -286,12 +277,12 @@ func runActionCapabilities(cmd *cobra.Command, deps *ActionsDeps, opts actionCap
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxActionPlanResponseBytes, "action capabilities response")
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxPulseAPIResponseBytes, "action capabilities response")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read action capabilities response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return actionStatusError("action capabilities request", resp.Status, respBody)
|
||||
return apiStatusError("action capabilities request", resp.Status, respBody)
|
||||
}
|
||||
|
||||
var facets actionResourceFacetsResponse
|
||||
@@ -330,7 +321,7 @@ func runActionAudit(cmd *cobra.Command, deps *ActionsDeps, opts actionAuditOptio
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint, err := actionAPIEndpoint(opts.APIURL, "/audit/actions")
|
||||
endpoint, err := pulseAPIEndpoint(opts.APIURL, "/audit/actions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -349,12 +340,12 @@ func runActionAudit(cmd *cobra.Command, deps *ActionsDeps, opts actionAuditOptio
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxActionPlanResponseBytes, "action audit response")
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxPulseAPIResponseBytes, "action audit response")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read action audit response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return actionStatusError("action audit request", resp.Status, respBody)
|
||||
return apiStatusError("action audit request", resp.Status, respBody)
|
||||
}
|
||||
|
||||
var audits actionAuditListResponse
|
||||
@@ -389,7 +380,7 @@ func runActionEvents(cmd *cobra.Command, deps *ActionsDeps, opts actionEventsOpt
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint, err := actionAPIEndpoint(opts.APIURL, "/audit/actions/"+url.PathEscape(actionID)+"/events")
|
||||
endpoint, err := pulseAPIEndpoint(opts.APIURL, "/audit/actions/"+url.PathEscape(actionID)+"/events")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -408,12 +399,12 @@ func runActionEvents(cmd *cobra.Command, deps *ActionsDeps, opts actionEventsOpt
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxActionPlanResponseBytes, "action lifecycle response")
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxPulseAPIResponseBytes, "action lifecycle response")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read action lifecycle response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return actionStatusError("action lifecycle request", resp.Status, respBody)
|
||||
return apiStatusError("action lifecycle request", resp.Status, respBody)
|
||||
}
|
||||
|
||||
var events actionLifecycleEventsResponse
|
||||
@@ -640,44 +631,6 @@ func actionResourceFacetsEndpoint(raw, resourceID string) (string, error) {
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func actionAPIEndpoint(raw, apiPath string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("api url is required (use --api-url or PULSE_API_URL)")
|
||||
}
|
||||
if !strings.HasPrefix(apiPath, "/") {
|
||||
return nil, fmt.Errorf("api path must start with /")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid api url: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("invalid api url: scheme must be http or https")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid api url: host is required")
|
||||
}
|
||||
|
||||
apiPath = strings.TrimRight(apiPath, "/")
|
||||
fullPath := "/api" + apiPath
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
switch {
|
||||
case path == "":
|
||||
parsed.Path = fullPath
|
||||
case path == fullPath || strings.HasSuffix(path, fullPath):
|
||||
parsed.Path = path
|
||||
case path == "/api" || strings.HasSuffix(path, "/api"):
|
||||
parsed.Path = path + apiPath
|
||||
default:
|
||||
parsed.Path = path + fullPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func actionAuditQuery(resourceID, since string, limit int) (url.Values, error) {
|
||||
if limit <= 0 {
|
||||
return nil, fmt.Errorf("limit must be greater than zero")
|
||||
@@ -699,59 +652,15 @@ func actionAuditQuery(resourceID, since string, limit int) (url.Values, error) {
|
||||
}
|
||||
|
||||
func actionHTTPClient(deps *ActionsDeps) HTTPDoer {
|
||||
if deps != nil && deps.HTTPClient != nil {
|
||||
return deps.HTTPClient
|
||||
if deps != nil {
|
||||
return cliHTTPClient(deps.HTTPClient)
|
||||
}
|
||||
return http.DefaultClient
|
||||
return cliHTTPClient(nil)
|
||||
}
|
||||
|
||||
func actionGetenv(deps *ActionsDeps, key string) string {
|
||||
if deps != nil && deps.Getenv != nil {
|
||||
return deps.Getenv(key)
|
||||
if deps != nil {
|
||||
return cliGetenv(deps.Getenv, key)
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
func actionStatusError(operation, status string, body []byte) error {
|
||||
if strings.TrimSpace(operation) == "" {
|
||||
operation = "request"
|
||||
}
|
||||
message := strings.TrimSpace(string(body))
|
||||
if message == "" {
|
||||
return fmt.Errorf("%s failed: %s", operation, status)
|
||||
}
|
||||
if len(message) > maxActionErrorBodyChars {
|
||||
message = message[:maxActionErrorBodyChars] + "..."
|
||||
}
|
||||
return fmt.Errorf("%s failed: %s: %s", operation, status, message)
|
||||
}
|
||||
|
||||
func decodeJSONBytes(data []byte, out any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("invalid trailing JSON content")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSONString(data string, out any) error {
|
||||
decoder := json.NewDecoder(strings.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("invalid trailing JSON content")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return cliGetenv(nil, key)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package pulsecli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPulseAPIURL = "http://127.0.0.1:7655"
|
||||
maxPulseAPIResponseBytes = 1 << 20
|
||||
maxPulseAPIErrorBodyChars = 4096
|
||||
)
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func cliHTTPClient(client HTTPDoer) HTTPDoer {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func cliGetenv(getenv func(string) string, key string) string {
|
||||
if getenv != nil {
|
||||
return getenv(key)
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
func pulseAPIEndpoint(raw, apiPath string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("api url is required (use --api-url or PULSE_API_URL)")
|
||||
}
|
||||
if !strings.HasPrefix(apiPath, "/") {
|
||||
return nil, fmt.Errorf("api path must start with /")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid api url: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("invalid api url: scheme must be http or https")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid api url: host is required")
|
||||
}
|
||||
|
||||
apiPath = strings.TrimRight(apiPath, "/")
|
||||
fullPath := "/api" + apiPath
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
switch {
|
||||
case path == "":
|
||||
parsed.Path = fullPath
|
||||
case path == fullPath || strings.HasSuffix(path, fullPath):
|
||||
parsed.Path = path
|
||||
case path == "/api" || strings.HasSuffix(path, "/api"):
|
||||
parsed.Path = path + apiPath
|
||||
default:
|
||||
parsed.Path = path + fullPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func apiStatusError(operation, status string, body []byte) error {
|
||||
if strings.TrimSpace(operation) == "" {
|
||||
operation = "request"
|
||||
}
|
||||
message := strings.TrimSpace(string(body))
|
||||
if message == "" {
|
||||
return fmt.Errorf("%s failed: %s", operation, status)
|
||||
}
|
||||
if len(message) > maxPulseAPIErrorBodyChars {
|
||||
message = message[:maxPulseAPIErrorBodyChars] + "..."
|
||||
}
|
||||
return fmt.Errorf("%s failed: %s: %s", operation, status, message)
|
||||
}
|
||||
|
||||
func decodeJSONBytes(data []byte, out any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("invalid trailing JSON content")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSONString(data string, out any) error {
|
||||
decoder := json.NewDecoder(strings.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("invalid trailing JSON content")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package pulsecli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type FleetDeps struct {
|
||||
HTTPClient HTTPDoer
|
||||
Getenv func(string) string
|
||||
}
|
||||
|
||||
type fleetConnectionsOptions struct {
|
||||
APIURL string
|
||||
Token string
|
||||
}
|
||||
|
||||
type fleetConnectionsResponse struct {
|
||||
Connections []json.RawMessage `json:"connections"`
|
||||
Systems []json.RawMessage `json:"systems,omitempty"`
|
||||
}
|
||||
|
||||
func newFleetCmd(deps *FleetDeps) *cobra.Command {
|
||||
fleetCmd := &cobra.Command{
|
||||
Use: "fleet",
|
||||
Short: "Inspect canonical Pulse fleet state",
|
||||
}
|
||||
fleetCmd.AddCommand(newFleetConnectionsCmd(deps))
|
||||
return fleetCmd
|
||||
}
|
||||
|
||||
func newFleetConnectionsCmd(deps *FleetDeps) *cobra.Command {
|
||||
opts := fleetConnectionsOptions{
|
||||
APIURL: strings.TrimSpace(fleetGetenv(deps, "PULSE_API_URL")),
|
||||
Token: strings.TrimSpace(fleetGetenv(deps, "PULSE_API_TOKEN")),
|
||||
}
|
||||
if opts.APIURL == "" {
|
||||
opts.APIURL = defaultPulseAPIURL
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "connections",
|
||||
Short: "List canonical fleet connection rows",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runFleetConnections(cmd, deps, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.APIURL, "api-url", opts.APIURL, "Pulse server URL or /api base URL")
|
||||
cmd.Flags().StringVar(&opts.Token, "token", opts.Token, "Pulse API token; defaults to PULSE_API_TOKEN")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runFleetConnections(cmd *cobra.Command, deps *FleetDeps, opts fleetConnectionsOptions) error {
|
||||
token := strings.TrimSpace(opts.Token)
|
||||
if token == "" {
|
||||
return fmt.Errorf("api token is required (use --token or PULSE_API_TOKEN)")
|
||||
}
|
||||
|
||||
endpoint, err := pulseAPIEndpoint(opts.APIURL, "/connections")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build fleet connections request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := fleetHTTPClient(deps).Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fleet connections request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := ReadBoundedHTTPBody(resp.Body, resp.ContentLength, maxPulseAPIResponseBytes, "fleet connections response")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read fleet connections response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return apiStatusError("fleet connections request", resp.Status, respBody)
|
||||
}
|
||||
|
||||
var connections fleetConnectionsResponse
|
||||
if err := decodeJSONBytes(respBody, &connections); err != nil {
|
||||
return fmt.Errorf("failed to decode fleet connections response: %w", err)
|
||||
}
|
||||
if connections.Connections == nil {
|
||||
connections.Connections = []json.RawMessage{}
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(cmd.OutOrStdout())
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(connections); err != nil {
|
||||
return fmt.Errorf("failed to write fleet connections response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fleetHTTPClient(deps *FleetDeps) HTTPDoer {
|
||||
if deps != nil {
|
||||
return cliHTTPClient(deps.HTTPClient)
|
||||
}
|
||||
return cliHTTPClient(nil)
|
||||
}
|
||||
|
||||
func fleetGetenv(deps *FleetDeps, key string) string {
|
||||
if deps != nil {
|
||||
return cliGetenv(deps.Getenv, key)
|
||||
}
|
||||
return cliGetenv(nil, key)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package pulsecli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFleetConnectionsCommandFetchesCanonicalConnections(t *testing.T) {
|
||||
var receivedAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/connections" {
|
||||
t.Fatalf("path = %s, want /api/connections", r.URL.Path)
|
||||
}
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"connections": [
|
||||
{
|
||||
"id": "agent:host-1",
|
||||
"type": "agent",
|
||||
"name": "host-1",
|
||||
"address": "host-1",
|
||||
"state": "active",
|
||||
"enabled": true,
|
||||
"surfaces": ["host"],
|
||||
"scope": {"host": true},
|
||||
"source": "agent",
|
||||
"fleet": {
|
||||
"enrollmentState": "enrolled",
|
||||
"livenessState": "active",
|
||||
"versionDrift": "behind",
|
||||
"adapterHealth": "healthy",
|
||||
"configRollout": "reported",
|
||||
"credentialStatus": "verified",
|
||||
"updateStatus": "update-available",
|
||||
"remoteControl": "enabled"
|
||||
},
|
||||
"capabilities": {
|
||||
"supportsPause": false,
|
||||
"supportsScope": false,
|
||||
"supportsTest": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"systems": [
|
||||
{
|
||||
"id": "agent:host-1",
|
||||
"type": "agent",
|
||||
"components": [
|
||||
{"connectionId": "agent:host-1", "type": "agent", "role": "primary"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cmd := newTestFleetRootCommand(map[string]string{
|
||||
"PULSE_API_TOKEN": "test-token",
|
||||
"PULSE_API_URL": server.URL + "/api",
|
||||
})
|
||||
cmd.SetArgs([]string{"fleet", "connections"})
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute fleet connections: %v", err)
|
||||
}
|
||||
if receivedAuth != "Bearer test-token" {
|
||||
t.Fatalf("Authorization = %q", receivedAuth)
|
||||
}
|
||||
|
||||
var response fleetConnectionsResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode command output: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(response.Connections) != 1 || len(response.Systems) != 1 {
|
||||
t.Fatalf("connections response = %+v", response)
|
||||
}
|
||||
var connection struct {
|
||||
ID string `json:"id"`
|
||||
Fleet struct {
|
||||
RemoteControl string `json:"remoteControl"`
|
||||
} `json:"fleet"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Connections[0], &connection); err != nil {
|
||||
t.Fatalf("decode connection: %v", err)
|
||||
}
|
||||
if connection.ID != "agent:host-1" || connection.Fleet.RemoteControl != "enabled" {
|
||||
t.Fatalf("connection = %+v", connection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetConnectionsCommandRequiresToken(t *testing.T) {
|
||||
cmd := newTestFleetRootCommand(nil)
|
||||
cmd.SetArgs([]string{"fleet", "connections", "--api-url", "http://127.0.0.1:7655"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "api token is required") {
|
||||
t.Fatalf("expected token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestFleetRootCommand(env map[string]string) *cobra.Command {
|
||||
return NewRootCommand(
|
||||
CommandSpec{
|
||||
Use: "pulse",
|
||||
Short: "Pulse",
|
||||
Long: "Pulse",
|
||||
Version: "1.2.3",
|
||||
},
|
||||
RuntimeSpec{},
|
||||
CommandDeps{
|
||||
Fleet: &FleetDeps{
|
||||
Getenv: func(key string) string {
|
||||
if env == nil {
|
||||
return ""
|
||||
}
|
||||
return env[key]
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type CommandDeps struct {
|
||||
Bootstrap *BootstrapDeps
|
||||
Mock *MockDeps
|
||||
Actions *ActionsDeps
|
||||
Fleet *FleetDeps
|
||||
}
|
||||
|
||||
func NewRootCommand(command CommandSpec, runtime RuntimeSpec, deps CommandDeps) *cobra.Command {
|
||||
@@ -52,6 +53,7 @@ func NewRootCommand(command CommandSpec, runtime RuntimeSpec, deps CommandDeps)
|
||||
cmd.AddCommand(newBootstrapTokenCmd(deps.Bootstrap))
|
||||
cmd.AddCommand(newMockCmd(deps.Mock))
|
||||
cmd.AddCommand(newActionsCmd(deps.Actions))
|
||||
cmd.AddCommand(newFleetCmd(deps.Fleet))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -61,3 +61,19 @@ func TestNewRootCommandConfigInfoSkipsRunE(t *testing.T) {
|
||||
t.Fatalf("config info output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRootCommandIncludesFleetConnections(t *testing.T) {
|
||||
cmd := NewRootCommand(CommandSpec{
|
||||
Use: "pulse",
|
||||
Short: "Pulse",
|
||||
Long: "Pulse",
|
||||
}, RuntimeSpec{}, CommandDeps{})
|
||||
|
||||
found, _, err := cmd.Find([]string{"fleet", "connections"})
|
||||
if err != nil {
|
||||
t.Fatalf("find fleet connections: %v", err)
|
||||
}
|
||||
if found == nil || found.Use != "connections" {
|
||||
t.Fatalf("fleet connections command not registered: %#v", found)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3625,8 +3625,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 160,
|
||||
"heading_line": 95,
|
||||
"line": 162,
|
||||
"heading_line": 97,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user