From c436e1a2a22062a78e62f0b619cfcd47446cc5bc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 4 May 2026 08:40:34 +0100 Subject: [PATCH] Add CLI fleet connection reads --- docs/API.md | 12 ++ .../v6/internal/subsystems/api-contracts.md | 10 +- .../v6/internal/subsystems/registry.json | 10 +- pkg/pulsecli/actions.go | 133 +++--------------- pkg/pulsecli/api_client.go | 118 ++++++++++++++++ pkg/pulsecli/fleet.go | 117 +++++++++++++++ pkg/pulsecli/fleet_test.go | 133 ++++++++++++++++++ pkg/pulsecli/root.go | 2 + pkg/pulsecli/root_test.go | 16 +++ .../release_control/subsystem_lookup_test.py | 4 +- 10 files changed, 437 insertions(+), 118 deletions(-) create mode 100644 pkg/pulsecli/api_client.go create mode 100644 pkg/pulsecli/fleet.go create mode 100644 pkg/pulsecli/fleet_test.go diff --git a/docs/API.md b/docs/API.md index 4432264e7..cae863792 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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`. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d26bb16bf..2b997128e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 266027a81..45f3249d8 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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" ] }, { diff --git a/pkg/pulsecli/actions.go b/pkg/pulsecli/actions.go index b5e1ddd84..29afd32e0 100644 --- a/pkg/pulsecli/actions.go +++ b/pkg/pulsecli/actions.go @@ -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) } diff --git a/pkg/pulsecli/api_client.go b/pkg/pulsecli/api_client.go new file mode 100644 index 000000000..9805fdd19 --- /dev/null +++ b/pkg/pulsecli/api_client.go @@ -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 +} diff --git a/pkg/pulsecli/fleet.go b/pkg/pulsecli/fleet.go new file mode 100644 index 000000000..dc462cd56 --- /dev/null +++ b/pkg/pulsecli/fleet.go @@ -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) +} diff --git a/pkg/pulsecli/fleet_test.go b/pkg/pulsecli/fleet_test.go new file mode 100644 index 000000000..2f5c04850 --- /dev/null +++ b/pkg/pulsecli/fleet_test.go @@ -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] + }, + }, + }, + ) +} diff --git a/pkg/pulsecli/root.go b/pkg/pulsecli/root.go index f624f70cb..123a7f1ef 100644 --- a/pkg/pulsecli/root.go +++ b/pkg/pulsecli/root.go @@ -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 } diff --git a/pkg/pulsecli/root_test.go b/pkg/pulsecli/root_test.go index e1d10a12a..6aaf5dcd2 100644 --- a/pkg/pulsecli/root_test.go +++ b/pkg/pulsecli/root_test.go @@ -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) + } +} diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 0ecc7dfbf..49531bd2a 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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, } ], )