Skip to content

Commit 9fcef76

Browse files
committed
fix: Paginate all API endpoints, remove Mobile testdata
1 parent f754376 commit 9fcef76

12 files changed

Lines changed: 186 additions & 134 deletions

File tree

assets/screenshot.png

-64.1 KB
Loading

docs/architecture.md

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,24 @@ Single-binary Go CLI. Parses fleet-gitops YAML, fetches current state from Fleet
55
## Layout
66

77
```
8-
cmd/fleet-plan/ Cobra commands (main.go, diff.go, version.go)
8+
cmd/fleet-plan/
9+
main.go Cobra root command, flag wiring, runDiff entrypoint
10+
version.go Version subcommand (set via ldflags)
11+
cmd_test.go CLI flag and command tests
912
internal/
10-
api/client.go Read-only Fleet REST client
11-
config/config.go Auth resolution (flags > env > config file)
12-
parser/parser.go YAML parser for fleet-gitops repos
13-
diff/differ.go Semantic diff engine
14-
output/ Renderers: terminal, json, markdown
15-
testutil/ Shared test helpers
16-
testdata/ Realistic fleet-gitops fixture for tests
13+
api/client.go Read-only Fleet REST client (GET only, HTTPS enforced)
14+
config/config.go Auth resolution: flags > env vars > config file
15+
parser/parser.go YAML parser for fleet-gitops repos (path traversal protected)
16+
diff/differ.go Semantic diff engine with per-field change tracking
17+
output/
18+
terminal.go ANSI-colored terminal renderer (truncation, diff context)
19+
json.go JSON renderer
20+
markdown.go Markdown renderer
21+
gen_screenshot.go Screenshot generator (//go:build ignore)
22+
testutil/ Shared test helpers (TestdataRoot)
23+
testdata/ Realistic fleet-gitops fixture repo for tests
24+
assets/ Logo and screenshot images
25+
docs/ Architecture and API endpoint docs
1726
```
1827

1928
## Data flow
@@ -24,7 +33,7 @@ flowchart LR
2433
C[Fleet API] -->|api.FetchAll| D[FleetState]
2534
B --> E[diff.Diff]
2635
D --> E
27-
E --> F[DiffResult]
36+
E --> F["[]DiffResult"]
2837
F --> G{--format}
2938
G -->|terminal| H[terminal.go]
3039
G -->|json| I[json.go]
@@ -33,13 +42,30 @@ flowchart LR
3342

3443
## API client
3544

36-
`FetchAll` parallelizes all GET requests via `errgroup`. When `default.yml` has global sections, it also fetches `/config`, global policies, and global queries.
45+
`FetchAll` parallelizes all GET requests via `errgroup`. When `default.yml` has global sections, it also fetches `/config`, global policies, and global queries. HTTPS is enforced by default (`FLEET_PLAN_INSECURE=1` to override for local dev).
3746

3847
See [api-endpoints.md](api-endpoints.md) for the full list.
3948

49+
## Auth resolution
50+
51+
Priority order (highest wins):
52+
53+
1. `--url` / `--token` flags
54+
2. `FLEET_PLAN_URL` / `FLEET_PLAN_TOKEN` env vars
55+
3. Config file: `~/.config/fleet-plan.json` or `<repo>/.config/fleet-plan.json`
56+
57+
Config file supports multiple contexts:
58+
59+
```json
60+
{
61+
"contexts": { "dev": { "url": "...", "token": "..." } },
62+
"default_context": "dev"
63+
}
64+
```
65+
4066
## Parser
4167

42-
Walks `teams/*.yml`, resolves `path:` references, produces `ParsedRepo`. Also parses `default.yml` for labels, `org_settings`, `agent_options`, `controls`, and global policies/queries.
68+
Walks `teams/*.yml`, resolves `path:` references, produces `ParsedRepo`. Also parses `default.yml` for labels, `org_settings`, `agent_options`, `controls`, and global policies/queries. All path references are validated against the repo root to prevent traversal.
4369

4470
## Diff engine
4571

@@ -56,12 +82,32 @@ Compares `FleetState` (API) vs `ParsedRepo` (YAML). Produces `[]DiffResult` per
5682
| Profiles | PayloadDisplayName | add/delete only |
5783
| Labels | `name` (cross-ref) | valid/missing with host counts |
5884

59-
Whitespace is normalized before comparison to avoid false positives from YAML vs API newline differences.
85+
Whitespace is normalized before comparison to avoid false positives from YAML vs API newline differences. Per-field diffs are stored in `ResourceChange.Fields` for both added and modified resources.
86+
87+
## Output modes
88+
89+
| Mode | Flag | Description |
90+
|------|------|-------------|
91+
| Terminal (default) | `--format terminal` | ANSI-colored, smart truncation (80 chars), diff context around changes, capped at 3 fields per resource |
92+
| Terminal verbose | `--verbose` | Full untruncated old/new values for all changed fields |
93+
| JSON | `--format json` | Machine-readable, all fields |
94+
| Markdown | `--format markdown` | For CI comments / MR descriptions |
95+
96+
## Screenshot generation
97+
98+
`gen_screenshot.go` (`//go:build ignore`) renders representative output from testdata fixtures for the README screenshot. Regenerate with:
99+
100+
```bash
101+
go run ./internal/output/gen_screenshot.go > /tmp/raw.txt
102+
termshot --raw-read /tmp/raw.txt -f assets/screenshot.png -C 100
103+
```
104+
105+
Requires a PTY wrapper (e.g., `script -qec`) for ANSI color output when piping.
60106

61107
## Tests
62108

63109
```
64110
go test -race ./...
65111
```
66112

67-
All packages have `_test.go`. Tests use `testdata/` as a shared fleet-gitops fixture. Table-driven throughout.
113+
All packages have `_test.go`. Tests use `testdata/` as a shared fleet-gitops fixture. Table-driven throughout. Coverage target: >= 75% (current: ~81%).

internal/api/client.go

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -263,40 +263,86 @@ func (c *Client) GetConfig(ctx context.Context) (map[string]any, error) {
263263
return result, nil
264264
}
265265

266-
// GetTeams fetches all teams.
266+
// GetTeams fetches all teams with pagination.
267267
func (c *Client) GetTeams(ctx context.Context) ([]Team, error) {
268-
var resp teamsResponse
269-
q := url.Values{"per_page": {"250"}}
270-
if err := c.get(ctx, "/api/v1/fleet/teams", q, &resp); err != nil {
271-
return nil, fmt.Errorf("fetching teams: %w", err)
268+
var all []Team
269+
page := 0
270+
for {
271+
q := url.Values{
272+
"per_page": {"250"},
273+
"page": {strconv.Itoa(page)},
274+
}
275+
var resp teamsResponse
276+
if err := c.get(ctx, "/api/v1/fleet/teams", q, &resp); err != nil {
277+
return nil, fmt.Errorf("fetching teams: %w", err)
278+
}
279+
all = append(all, resp.Teams...)
280+
if len(resp.Teams) < 250 {
281+
break
282+
}
283+
page++
284+
if page > 100 { // safety: max 25k teams
285+
break
286+
}
272287
}
273-
return resp.Teams, nil
288+
return all, nil
274289
}
275290

276-
// GetPolicies fetches policies for a team (0 = global).
291+
// GetPolicies fetches policies for a team (0 = global) with pagination.
277292
func (c *Client) GetPolicies(ctx context.Context, teamID uint) ([]Policy, error) {
278-
path := "/api/v1/fleet/policies"
293+
apiPath := "/api/v1/fleet/policies"
279294
if teamID > 0 {
280-
path = fmt.Sprintf("/api/v1/fleet/teams/%d/policies", teamID)
295+
apiPath = fmt.Sprintf("/api/v1/fleet/teams/%d/policies", teamID)
281296
}
282-
var resp policiesResponse
283-
if err := c.get(ctx, path, nil, &resp); err != nil {
284-
return nil, fmt.Errorf("fetching policies (team %d): %w", teamID, err)
297+
var all []Policy
298+
page := 0
299+
for {
300+
q := url.Values{
301+
"per_page": {"250"},
302+
"page": {strconv.Itoa(page)},
303+
}
304+
var resp policiesResponse
305+
if err := c.get(ctx, apiPath, q, &resp); err != nil {
306+
return nil, fmt.Errorf("fetching policies (team %d): %w", teamID, err)
307+
}
308+
all = append(all, resp.Policies...)
309+
if len(resp.Policies) < 250 {
310+
break
311+
}
312+
page++
313+
if page > 100 { // safety: max 25k policies
314+
break
315+
}
285316
}
286-
return resp.Policies, nil
317+
return all, nil
287318
}
288319

289-
// GetQueries fetches queries, optionally filtered by team.
320+
// GetQueries fetches queries, optionally filtered by team, with pagination.
290321
func (c *Client) GetQueries(ctx context.Context, teamID uint) ([]Query, error) {
291-
q := url.Values{"per_page": {"250"}}
292-
if teamID > 0 {
293-
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
294-
}
295-
var resp queriesResponse
296-
if err := c.get(ctx, "/api/v1/fleet/queries", q, &resp); err != nil {
297-
return nil, fmt.Errorf("fetching queries (team %d): %w", teamID, err)
322+
var all []Query
323+
page := 0
324+
for {
325+
q := url.Values{
326+
"per_page": {"250"},
327+
"page": {strconv.Itoa(page)},
328+
}
329+
if teamID > 0 {
330+
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
331+
}
332+
var resp queriesResponse
333+
if err := c.get(ctx, "/api/v1/fleet/queries", q, &resp); err != nil {
334+
return nil, fmt.Errorf("fetching queries (team %d): %w", teamID, err)
335+
}
336+
all = append(all, resp.Queries...)
337+
if len(resp.Queries) < 250 {
338+
break
339+
}
340+
page++
341+
if page > 100 { // safety: max 25k queries
342+
break
343+
}
298344
}
299-
return resp.Queries, nil
345+
return all, nil
300346
}
301347

302348
// GetSoftware fetches managed (available_for_install) software titles for a team.
@@ -356,14 +402,29 @@ func (c *Client) GetFleetMaintainedApps(ctx context.Context) ([]FleetMaintainedA
356402
return all, nil
357403
}
358404

359-
// GetLabels fetches all labels.
405+
// GetLabels fetches all labels with pagination.
360406
func (c *Client) GetLabels(ctx context.Context) ([]Label, error) {
361-
var resp labelsResponse
362-
q := url.Values{"per_page": {"250"}}
363-
if err := c.get(ctx, "/api/v1/fleet/labels", q, &resp); err != nil {
364-
return nil, fmt.Errorf("fetching labels: %w", err)
407+
var all []Label
408+
page := 0
409+
for {
410+
q := url.Values{
411+
"per_page": {"250"},
412+
"page": {strconv.Itoa(page)},
413+
}
414+
var resp labelsResponse
415+
if err := c.get(ctx, "/api/v1/fleet/labels", q, &resp); err != nil {
416+
return nil, fmt.Errorf("fetching labels: %w", err)
417+
}
418+
all = append(all, resp.Labels...)
419+
if len(resp.Labels) < 250 {
420+
break
421+
}
422+
page++
423+
if page > 100 { // safety: max 25k labels
424+
break
425+
}
365426
}
366-
return resp.Labels, nil
427+
return all, nil
367428
}
368429

369430
// GetProfiles fetches MDM profiles for a team.

internal/diff/differ.go

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package diff
55

66
import (
7+
"encoding/json"
78
"fmt"
89
"regexp"
910
"sort"
@@ -479,7 +480,7 @@ func diffSoftware(current api.TeamSoftware, proposed parser.ParsedSoftware) Reso
479480
}
480481

481482
func normalizeSoftwarePath(s string) string {
482-
s = strings.TrimSpace(strings.ToLower(s))
483+
s = strings.TrimSpace(s)
483484
if s == "" {
484485
return ""
485486
}
@@ -695,17 +696,6 @@ func validateLabels(team parser.ParsedTeam, labelMap map[string]api.Label, chang
695696

696697
// ---------- Global config diffing ----------
697698

698-
// serverOnlyKeys are keys returned by the Fleet API that don't exist in YAML.
699-
// Skip these during comparison to avoid false positives.
700-
var serverOnlyKeys = map[string]bool{
701-
"license": true,
702-
"logging": true,
703-
"update_interval": true,
704-
"vulnerabilities": true,
705-
"sandbox_enabled": true,
706-
"server_settings": true, // server_url is set by Fleet, not YAML
707-
}
708-
709699
// diffConfig compares the current Fleet config (from API) against proposed
710700
// global config sections from default.yml. Returns a list of config changes.
711701
// Skips values containing "$" (env var placeholders that Fleet substitutes).
@@ -739,8 +729,12 @@ func diffConfig(apiConfig map[string]any, proposed *parser.ParsedGlobal) []Confi
739729
}
740730
}
741731
case "controls":
742-
// controls fields are spread across the top level (mdm, etc.)
743-
apiSection = apiConfig
732+
// controls fields map to the "mdm" section in the API response
733+
if v, ok := apiConfig["mdm"]; ok {
734+
if m, ok := v.(map[string]any); ok {
735+
apiSection = m
736+
}
737+
}
744738
}
745739

746740
if apiSection == nil {
@@ -783,16 +777,25 @@ func containsEnvVar(s string) bool {
783777
}
784778

785779
// flattenMap recursively flattens a nested map into dot-separated key paths.
786-
// Calls fn(key, value) for each leaf value.
780+
// Calls fn(key, value) for each leaf value. Slices are serialized to JSON
781+
// for stable, order-independent comparison.
787782
func flattenMap(m map[string]any, prefix string, fn func(key, val string)) {
788783
for k, v := range m {
789784
fullKey := k
790785
if prefix != "" {
791786
fullKey = prefix + "." + k
792787
}
793-
if nested, ok := v.(map[string]any); ok {
794-
flattenMap(nested, fullKey, fn)
795-
} else {
788+
switch val := v.(type) {
789+
case map[string]any:
790+
flattenMap(val, fullKey, fn)
791+
case []any:
792+
b, err := json.Marshal(val)
793+
if err != nil {
794+
fn(fullKey, fmt.Sprint(val))
795+
} else {
796+
fn(fullKey, string(b))
797+
}
798+
default:
796799
fn(fullKey, fmt.Sprint(v))
797800
}
798801
}

internal/diff/differ_test.go

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222
// - Queries: Uptime (interval changed → modified)
2323
// - No policies (SSH Root Login is new → added)
2424
//
25-
// Mobile team: not in API → new team info message.
2625
// Labels: macOS 14+ and Windows 11 exist. Ubuntu 24.04 does NOT → missing label error.
2726
func TestDiffTestdataAgainstMockAPI(t *testing.T) {
2827
root := testutil.TestdataRoot(t)
@@ -84,10 +83,10 @@ func TestDiffTestdataAgainstMockAPI(t *testing.T) {
8483
// --- Test all teams (unfiltered) ---
8584
allResults := Diff(current, proposed, "")
8685

87-
// Should have 4 results: (global), Workstations, Servers, Mobile
86+
// Should have 3 results: (global), Workstations, Servers
8887
// The (global) result comes from default.yml parsing.
89-
if len(allResults) != 4 {
90-
t.Fatalf("expected 4 results, got %d", len(allResults))
88+
if len(allResults) != 3 {
89+
t.Fatalf("expected 3 results, got %d", len(allResults))
9190
}
9291

9392
// Verify global result exists and is first
@@ -98,9 +97,9 @@ func TestDiffTestdataAgainstMockAPI(t *testing.T) {
9897
// --- Workstations ---
9998
ws := findTeam(t, allResults, "Workstations")
10099

101-
// Policies: Gatekeeper, Defender, SSH, Firewall are new (4 added)
102-
if len(ws.Policies.Added) != 4 {
103-
t.Errorf("Workstations: expected 4 added policies, got %d: %v", len(ws.Policies.Added), ws.Policies.Added)
100+
// Policies: Defender, SSH, Firewall are new (3 added)
101+
if len(ws.Policies.Added) != 3 {
102+
t.Errorf("Workstations: expected 3 added policies, got %d: %v", len(ws.Policies.Added), ws.Policies.Added)
104103
}
105104
// FileVault modified (query changed)
106105
if len(ws.Policies.Modified) != 1 {
@@ -184,28 +183,6 @@ func TestDiffTestdataAgainstMockAPI(t *testing.T) {
184183
}
185184
}
186185

187-
// --- Mobile (new team) ---
188-
mob := findTeam(t, allResults, "Mobile")
189-
190-
// All resources should be "added" since team is new
191-
if len(mob.Policies.Added) != 1 {
192-
t.Errorf("Mobile: expected 1 added policy, got %d", len(mob.Policies.Added))
193-
}
194-
if len(mob.Queries.Added) != 1 {
195-
t.Errorf("Mobile: expected 1 added query, got %d", len(mob.Queries.Added))
196-
}
197-
198-
// Should have info message about new team
199-
foundNewTeamInfo := false
200-
for _, e := range mob.Errors {
201-
if strings.Contains(e, "does not exist in Fleet yet") {
202-
foundNewTeamInfo = true
203-
break
204-
}
205-
}
206-
if !foundNewTeamInfo {
207-
t.Error("expected info message about new team for Mobile")
208-
}
209186
}
210187

211188
// TestDiffTestdataWorkstationsOnly verifies filtered diff for a single team.

0 commit comments

Comments
 (0)