Add CLI fleet connection reads

This commit is contained in:
rcourtman
2026-05-04 08:40:34 +01:00
parent 7956f8a968
commit c436e1a2a2
10 changed files with 437 additions and 118 deletions
+12
View File
@@ -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
View File
@@ -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)
}
+118
View File
@@ -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
}
+117
View File
@@ -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)
}
+133
View File
@@ -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]
},
},
},
)
}
+2
View File
@@ -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
}
+16
View File
@@ -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,
}
],
)