Skip to content

Commit fde28b7

Browse files
robbiet480claude
andcommitted
Merge branch 'main' into feat/profile-content-diff
Resolves the conflicts with the no-team diff (#56): - internal/api/client.go: keep this branch's GetProfileContent and EnrichProfileContents alongside main's updated GetScripts doc comment. - internal/api/client_test.go and internal/diff/differ_test.go: both branches appended tests, so git interleaved them. Rebuilt from main's file plus this branch's blocks; every test function from both sides is present. - internal/diff/differ.go: the no-team profile diff added in #56 now passes the profile enricher through, so profiles on hosts with no team get the same content-level diff as any team's. Covered by TestDiffNoTeamProfileContent. Verified against the live Fleet instance: the real no-team file diffs clean, and a locally modified profile still reports its changed key by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEpzMNJnGaBLAfrPeqknCy
2 parents a12216c + e984eae commit fde28b7

13 files changed

Lines changed: 814 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ jobs:
2525
- name: Test
2626
run: go test -v -race -coverprofile=coverage.txt -covermode=atomic -count=1 ./...
2727

28+
- name: Coverage floor
29+
run: ./scripts/coverage-floor.sh coverage.txt 75
30+
2831
- name: Upload coverage to Codecov
2932
uses: codecov/codecov-action@v5
3033
with:

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ go vet ./...
1515
golangci-lint run
1616
```
1717

18-
Coverage target: >= 75% per package, enforced by `codecov.yml`. Current: 83.8%
18+
Coverage target: >= 75% per package, enforced in CI by `scripts/coverage-floor.sh`. Current: 84.7%
1919
overall, every package at or above 78.9%. All packages have `_test.go`. Tests use `testdata/` as a shared fleet-gitops fixture. Table-driven throughout.
2020

2121
## Key packages

CONTRIBUTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ Go 1.26+. CI runs the same commands plus `govulncheck ./...` and CodeQL. See [do
2020
- `go build ./...`, `go vet ./...`, and `go test -race ./...` all pass.
2121
- `golangci-lint run` reports no issues (`golangci-lint fmt` applies the gofmt /
2222
gofumpt formatting it expects).
23-
- New or changed logic has table-driven tests. Coverage should not regress.
23+
- New or changed logic has table-driven tests. Coverage stays at or above 75%
24+
per package: `go test -coverprofile=coverage.txt ./... && ./scripts/coverage-floor.sh`
25+
(CI runs the same check).
2426
- Commit messages use conventional prefixes (`feat:`, `fix:`, `test:`, `docs:`, `chore:`).
2527

2628
## Invariants that reviewers will enforce

cmd/fleet-plan/cmd_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"testing"
1414

1515
"github.com/CampusTech/fleet-plan/internal/git"
16+
"github.com/CampusTech/fleet-plan/internal/parser"
1617
)
1718

1819
// ---------- version command ----------
@@ -755,3 +756,38 @@ func TestRunExitCodes(t *testing.T) {
755756
})
756757
}
757758
}
759+
760+
func TestHasNoTeam(t *testing.T) {
761+
tests := []struct {
762+
name string
763+
teams []parser.ParsedTeam
764+
want bool
765+
}{
766+
{name: "no teams at all"},
767+
{
768+
name: "ordinary teams only",
769+
teams: []parser.ParsedTeam{{Name: "Workstations", SourceFile: "teams/workstations.yml"}},
770+
},
771+
{
772+
name: "teams layout no-team file",
773+
teams: []parser.ParsedTeam{
774+
{Name: "Workstations", SourceFile: "teams/workstations.yml"},
775+
{Name: "No team", SourceFile: "teams/no-team.yml"},
776+
},
777+
want: true,
778+
},
779+
{
780+
name: "fleets layout unassigned file",
781+
teams: []parser.ParsedTeam{{Name: "Unassigned", SourceFile: "fleets/unassigned.yml"}},
782+
want: true,
783+
},
784+
}
785+
786+
for _, tt := range tests {
787+
t.Run(tt.name, func(t *testing.T) {
788+
if got := hasNoTeam(tt.teams); got != tt.want {
789+
t.Errorf("got %v, want %v", got, tt.want)
790+
}
791+
})
792+
}
793+
}

cmd/fleet-plan/main.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,10 @@ func runDiff(cmd *cobra.Command, _ []string) error {
168168
}
169169
fmt.Fprintf(os.Stderr, "Fetching Fleet state from %s...\n", auth.URL)
170170

171-
state, err := client.FetchAll(ctx, repo.Global != nil)
171+
state, err := client.FetchAll(ctx, api.FetchOptions{
172+
Global: repo.Global != nil,
173+
NoTeam: hasNoTeam(repo.Teams),
174+
})
172175
if err != nil {
173176
return err
174177
}
@@ -233,6 +236,18 @@ func runDiff(cmd *cobra.Command, _ []string) error {
233236
return nil
234237
}
235238

239+
// hasNoTeam reports whether the repo configures Fleet's "hosts on no team"
240+
// bucket. Fetching that bucket costs extra API calls, so it is only requested
241+
// when a file describes it.
242+
func hasNoTeam(teams []parser.ParsedTeam) bool {
243+
for _, t := range teams {
244+
if parser.IsNoTeam(t.Name, t.SourceFile) {
245+
return true
246+
}
247+
}
248+
return false
249+
}
250+
236251
// errChangesDetected signals --detailed-exitcodes exit status 2. It is not a
237252
// failure: main translates it to the exit code after runDiff's defers run.
238253
var errChangesDetected = errors.New("changes detected")

codecov.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
# Coverage gates. The project floor is the 75% documented in CLAUDE.md.
1+
# Codecov reporting. The per-package floor is enforced in CI by
2+
# scripts/coverage-floor.sh, not here: the codecov project status configured
3+
# below has never posted a check on this repo, so it cannot be relied on as a
4+
# gate. Keep both -- the status is useful when it works, the script is the one
5+
# that actually blocks a merge.
26
coverage:
37
status:
48
project:

docs/API-Endpoints.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ All read-only. fleet-plan never writes to your Fleet server.
99
| `GET` | `/api/v1/fleet/labels` | Label validation and host counts |
1010
| `GET` | `/api/v1/fleet/teams/{id}/policies` | Per-team policies |
1111
| `GET` | `/api/v1/fleet/global/policies` | Global policies (when default.yml parsed) |
12+
| `GET` | `/api/v1/fleet/teams/0/policies` | Policies for hosts on no team (when the repo has a no-team file) |
1213
| `GET` | `/api/v1/fleet/queries` | Per-team and global queries |
1314
| `GET` | `/api/v1/fleet/configuration_profiles` | MDM configuration profiles (list includes each profile's checksum) |
1415
| `GET` | `/api/v1/fleet/configuration_profiles/{uuid}?alt=media` | Profile content, for key-level diffing (only when the checksum differs) |
@@ -20,4 +21,6 @@ All read-only. fleet-plan never writes to your Fleet server.
2021

2122
Global endpoints (`/config`, `/global/policies`, `/queries` with teamID=0) are only called when `default.yml` defines global sections.
2223

24+
The "hosts on no team" bucket is fetched only when the repo has a no-team file (`teams/no-team.yml` or `fleets/unassigned.yml`). Its resources live behind `team_id=0` on `/configuration_profiles` and `/scripts`, and behind `/teams/0/policies` for policies — note that `/global/policies` is a *different* set. Fleet does not report configured software for this bucket, so software is not diffed there.
25+
2326
HTTPS enforced unless `FLEET_PLAN_INSECURE=1`.

docs/Architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ Walks `teams/*.yml`, resolves `path:` references, produces `ParsedRepo`. Also pa
8989

9090
Compares `FleetState` (API) vs `ParsedRepo` (YAML). Produces `[]DiffResult` per team + a `(global)` result when `default.yml` is present.
9191

92+
Fleet's "hosts on no team" bucket is absent from `GET /teams`, so it is fetched separately (`team_id=0`) and diffed like any other team for policies, profiles, and scripts, baseline subtraction included. Software and queries are reported as skipped there: Fleet exposes configured software only through the teams list, and scopes queries to a real team or the global scope. When the bucket was not fetched, the diff falls back to summarizing what the repo configures for it.
93+
9294
| Resource | Match key | Diff fields |
9395
|----------|-----------|-------------|
9496
| Config sections (global) | dot-path key | old/new value (skips `$VAR` placeholders) |
@@ -157,4 +159,4 @@ This mirrors how fleet-gitops environment overlays work. The merged result is wr
157159
go test -race ./...
158160
```
159161

160-
All packages have `_test.go`. Tests use `testdata/` as a shared fleet-gitops fixture. Table-driven throughout. Coverage target: >= 75% per package, enforced by `codecov.yml` (current: 83.8% overall, lowest package 78.9%).
162+
All packages have `_test.go`. Tests use `testdata/` as a shared fleet-gitops fixture. Table-driven throughout. Coverage target: >= 75% per package, enforced in CI by `scripts/coverage-floor.sh` (current: 84.7% overall, lowest package 78.9%).

internal/api/client.go

Lines changed: 122 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,32 @@ type FleetState struct {
127127
Config map[string]any // from GET /api/v1/fleet/config
128128
GlobalPolicies []Policy // from GET /api/v1/fleet/global/policies (teamID=0)
129129
GlobalQueries []Query // from GET /api/v1/fleet/queries (teamID=0)
130+
NoTeam *NoTeam // "hosts on no team" bucket, when requested
131+
}
132+
133+
// NoTeam holds the current state of Fleet's "hosts on no team" bucket, which
134+
// the /teams endpoint does not return. Its resources live behind team_id=0.
135+
//
136+
// Queries are absent by design: Fleet scopes queries to a real team or to the
137+
// global scope, so a no-team file cannot define them. Managed software is
138+
// absent too — Fleet only reports configured packages through the teams list,
139+
// which excludes this bucket.
140+
type NoTeam struct {
141+
Policies []Policy
142+
Profiles []Profile
143+
Scripts []Script
144+
PoliciesUnavailable bool
145+
ProfilesUnavailable bool
146+
ScriptsUnavailable bool
147+
}
148+
149+
// FetchOptions selects the optional scopes FetchAll retrieves. Both cost extra
150+
// round trips, so callers ask for them only when the repo defines them.
151+
type FetchOptions struct {
152+
// Global fetches /config, /global/policies, and global queries.
153+
Global bool
154+
// NoTeam fetches the "hosts on no team" bucket (team_id=0).
155+
NoTeam bool
130156
}
131157

132158
// Team represents a Fleet team with its associated resources.
@@ -426,6 +452,35 @@ func (c *Client) GetPolicies(ctx context.Context, teamID uint) ([]Policy, error)
426452
return all, nil
427453
}
428454

455+
// GetNoTeamPolicies fetches the policies that belong to Fleet's "hosts on no
456+
// team" bucket. This is a different endpoint from GetPolicies(0), which
457+
// returns global policies. The response also carries inherited_policies (the
458+
// global ones, which apply to no-team hosts as well); those are deliberately
459+
// ignored, since a no-team YAML file does not own them.
460+
func (c *Client) GetNoTeamPolicies(ctx context.Context) ([]Policy, error) {
461+
var all []Policy
462+
page := 0
463+
for {
464+
q := url.Values{
465+
"per_page": {"250"},
466+
"page": {strconv.Itoa(page)},
467+
}
468+
var resp policiesResponse
469+
if err := c.get(ctx, "/api/v1/fleet/teams/0/policies", q, &resp); err != nil {
470+
return nil, fmt.Errorf("fetching no-team policies: %w", err)
471+
}
472+
all = append(all, resp.Policies...)
473+
if len(resp.Policies) < 250 {
474+
break
475+
}
476+
page++
477+
if page > 100 { // safety: max 25k policies
478+
break
479+
}
480+
}
481+
return all, nil
482+
}
483+
429484
// GetQueries fetches queries, optionally filtered by team, with pagination.
430485
func (c *Client) GetQueries(ctx context.Context, teamID uint) ([]Query, error) {
431486
var all []Query
@@ -454,7 +509,9 @@ func (c *Client) GetQueries(ctx context.Context, teamID uint) ([]Query, error) {
454509
return all, nil
455510
}
456511

457-
// GetSoftware fetches managed (available_for_install) software titles for a team.
512+
// GetSoftware fetches managed (available_for_install) software titles for a
513+
// team. team_id is always sent; see GetProfiles for why teamID 0 must not be
514+
// omitted.
458515
// Uses available_for_install=true to exclude detected-only titles (OS packages,
459516
// browser extensions, etc.) and only return software deployed via Fleet/GitOps.
460517
// Paginates to collect all results.
@@ -467,9 +524,7 @@ func (c *Client) GetSoftware(ctx context.Context, teamID uint) ([]SoftwareTitle,
467524
"page": {strconv.Itoa(page)},
468525
"available_for_install": {"true"},
469526
}
470-
if teamID > 0 {
471-
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
472-
}
527+
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
473528
var resp softwareResponse
474529
if err := c.get(ctx, "/api/v1/fleet/software/titles", q, &resp); err != nil {
475530
return nil, fmt.Errorf("fetching software (team %d): %w", teamID, err)
@@ -581,7 +636,9 @@ func (c *Client) GetLabels(ctx context.Context) ([]Label, error) {
581636
return all, nil
582637
}
583638

584-
// GetProfiles fetches MDM profiles for a team with pagination.
639+
// GetProfiles fetches MDM profiles for a team with pagination. team_id is
640+
// always sent: Fleet reads teamID 0 as the "hosts on no team" bucket, whereas
641+
// omitting the parameter returns every team's profiles.
585642
func (c *Client) GetProfiles(ctx context.Context, teamID uint) ([]Profile, error) {
586643
var all []Profile
587644
page := 0
@@ -590,9 +647,7 @@ func (c *Client) GetProfiles(ctx context.Context, teamID uint) ([]Profile, error
590647
"per_page": {"250"},
591648
"page": {strconv.Itoa(page)},
592649
}
593-
if teamID > 0 {
594-
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
595-
}
650+
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
596651
var resp profilesResponse
597652
if err := c.get(ctx, "/api/v1/fleet/configuration_profiles", q, &resp); err != nil {
598653
return nil, fmt.Errorf("fetching profiles (team %d): %w", teamID, err)
@@ -677,7 +732,8 @@ func (c *Client) EnrichProfileContents(ctx context.Context, profiles []Profile)
677732
_ = g.Wait()
678733
}
679734

680-
// GetScripts fetches scripts for a team with pagination.
735+
// GetScripts fetches scripts for a team with pagination. team_id is always
736+
// sent; see GetProfiles for why teamID 0 must not be omitted.
681737
func (c *Client) GetScripts(ctx context.Context, teamID uint) ([]Script, error) {
682738
var all []Script
683739
page := 0
@@ -686,9 +742,7 @@ func (c *Client) GetScripts(ctx context.Context, teamID uint) ([]Script, error)
686742
"per_page": {"250"},
687743
"page": {strconv.Itoa(page)},
688744
}
689-
if teamID > 0 {
690-
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
691-
}
745+
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
692746
var resp scriptsResponse
693747
if err := c.get(ctx, "/api/v1/fleet/scripts", q, &resp); err != nil {
694748
return nil, fmt.Errorf("fetching scripts (team %d): %w", teamID, err)
@@ -759,9 +813,13 @@ func (c *Client) getScriptContent(ctx context.Context, scriptID uint) (string, e
759813
// FetchAll concurrently fetches the complete Fleet state. Uses errgroup for
760814
// parallel requests. If fetchGlobal is true, also fetches global config,
761815
// policies, and queries (for default.yml diffing).
762-
func (c *Client) FetchAll(ctx context.Context, fetchGlobal ...bool) (*FleetState, error) {
816+
func (c *Client) FetchAll(ctx context.Context, opts ...FetchOptions) (*FleetState, error) {
763817
state := &FleetState{}
764-
wantGlobal := len(fetchGlobal) > 0 && fetchGlobal[0]
818+
var o FetchOptions
819+
if len(opts) > 0 {
820+
o = opts[0]
821+
}
822+
wantGlobal := o.Global
765823

766824
teams, err := c.GetTeams(ctx)
767825
if err != nil {
@@ -822,6 +880,49 @@ func (c *Client) FetchAll(ctx context.Context, fetchGlobal ...bool) (*FleetState
822880
})
823881
}
824882

883+
// noTeam is written only by the goroutines below, then attached to state
884+
// after g.Wait().
885+
var noTeam *NoTeam
886+
if o.NoTeam {
887+
noTeam = &NoTeam{}
888+
g.Go(func() error {
889+
policies, err := c.GetNoTeamPolicies(gctx)
890+
if err != nil {
891+
if !isPermissionError(err) {
892+
return err
893+
}
894+
noTeam.PoliciesUnavailable = true
895+
return nil
896+
}
897+
noTeam.Policies = policies
898+
return nil
899+
})
900+
g.Go(func() error {
901+
profiles, err := c.GetProfiles(gctx, 0)
902+
if err != nil {
903+
if !isPermissionError(err) {
904+
return err
905+
}
906+
noTeam.ProfilesUnavailable = true
907+
return nil
908+
}
909+
noTeam.Profiles = profiles
910+
return nil
911+
})
912+
g.Go(func() error {
913+
scripts, err := c.GetScripts(gctx, 0)
914+
if err != nil {
915+
if !isPermissionError(err) {
916+
return err
917+
}
918+
noTeam.ScriptsUnavailable = true
919+
return nil
920+
}
921+
noTeam.Scripts = scripts
922+
return nil
923+
})
924+
}
925+
825926
// teamPartials holds per-goroutine results indexed by team slot.
826927
// Each field is written by exactly one goroutine, so there is no data race.
827928
type teamPartial struct {
@@ -924,6 +1025,13 @@ func (c *Client) FetchAll(ctx context.Context, fetchGlobal ...bool) (*FleetState
9241025
teamResults[i].ScriptsUnavailable = p.scriptsUnavailable
9251026
}
9261027

1028+
if noTeam != nil {
1029+
if !noTeam.ScriptsUnavailable && len(noTeam.Scripts) > 0 {
1030+
c.EnrichScriptContents(ctx, noTeam.Scripts)
1031+
}
1032+
state.NoTeam = noTeam
1033+
}
1034+
9271035
// Enrich script contents (second pass, needs script IDs from first pass)
9281036
for i := range teamResults {
9291037
if !teamResults[i].ScriptsUnavailable && len(teamResults[i].Scripts) > 0 {

0 commit comments

Comments
 (0)