Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cmd/fleet-plan/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"testing"

"github.com/CampusTech/fleet-plan/internal/git"
"github.com/CampusTech/fleet-plan/internal/parser"
)

// ---------- version command ----------
Expand Down Expand Up @@ -755,3 +756,38 @@ func TestRunExitCodes(t *testing.T) {
})
}
}

func TestHasNoTeam(t *testing.T) {
tests := []struct {
name string
teams []parser.ParsedTeam
want bool
}{
{name: "no teams at all"},
{
name: "ordinary teams only",
teams: []parser.ParsedTeam{{Name: "Workstations", SourceFile: "teams/workstations.yml"}},
},
{
name: "teams layout no-team file",
teams: []parser.ParsedTeam{
{Name: "Workstations", SourceFile: "teams/workstations.yml"},
{Name: "No team", SourceFile: "teams/no-team.yml"},
},
want: true,
},
{
name: "fleets layout unassigned file",
teams: []parser.ParsedTeam{{Name: "Unassigned", SourceFile: "fleets/unassigned.yml"}},
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasNoTeam(tt.teams); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
17 changes: 16 additions & 1 deletion cmd/fleet-plan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ func runDiff(cmd *cobra.Command, _ []string) error {
}
fmt.Fprintf(os.Stderr, "Fetching Fleet state from %s...\n", auth.URL)

state, err := client.FetchAll(ctx, repo.Global != nil)
state, err := client.FetchAll(ctx, api.FetchOptions{
Global: repo.Global != nil,
NoTeam: hasNoTeam(repo.Teams),
})
if err != nil {
return err
}
Expand Down Expand Up @@ -228,6 +231,18 @@ func runDiff(cmd *cobra.Command, _ []string) error {
return nil
}

// hasNoTeam reports whether the repo configures Fleet's "hosts on no team"
// bucket. Fetching that bucket costs extra API calls, so it is only requested
// when a file describes it.
func hasNoTeam(teams []parser.ParsedTeam) bool {
for _, t := range teams {
if parser.IsNoTeam(t.Name, t.SourceFile) {
return true
}
}
return false
}

// errChangesDetected signals --detailed-exitcodes exit status 2. It is not a
// failure: main translates it to the exit code after runDiff's defers run.
var errChangesDetected = errors.New("changes detected")
Expand Down
3 changes: 3 additions & 0 deletions docs/API-Endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All read-only. fleet-plan never writes to your Fleet server.
| `GET` | `/api/v1/fleet/labels` | Label validation and host counts |
| `GET` | `/api/v1/fleet/teams/{id}/policies` | Per-team policies |
| `GET` | `/api/v1/fleet/global/policies` | Global policies (when default.yml parsed) |
| `GET` | `/api/v1/fleet/teams/0/policies` | Policies for hosts on no team (when the repo has a no-team file) |
| `GET` | `/api/v1/fleet/queries` | Per-team and global queries |
| `GET` | `/api/v1/fleet/configuration_profiles` | MDM configuration profiles |
| `GET` | `/api/v1/fleet/software/titles` | Managed software titles (paginated) |
Expand All @@ -19,4 +20,6 @@ All read-only. fleet-plan never writes to your Fleet server.

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

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.

HTTPS enforced unless `FLEET_PLAN_INSECURE=1`.
2 changes: 2 additions & 0 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ Walks `teams/*.yml`, resolves `path:` references, produces `ParsedRepo`. Also pa

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

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.

| Resource | Match key | Diff fields |
|----------|-----------|-------------|
| Config sections (global) | dot-path key | old/new value (skips `$VAR` placeholders) |
Expand Down
136 changes: 122 additions & 14 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,32 @@ type FleetState struct {
Config map[string]any // from GET /api/v1/fleet/config
GlobalPolicies []Policy // from GET /api/v1/fleet/global/policies (teamID=0)
GlobalQueries []Query // from GET /api/v1/fleet/queries (teamID=0)
NoTeam *NoTeam // "hosts on no team" bucket, when requested
}

// NoTeam holds the current state of Fleet's "hosts on no team" bucket, which
// the /teams endpoint does not return. Its resources live behind team_id=0.
//
// Queries are absent by design: Fleet scopes queries to a real team or to the
// global scope, so a no-team file cannot define them. Managed software is
// absent too — Fleet only reports configured packages through the teams list,
// which excludes this bucket.
type NoTeam struct {
Policies []Policy
Profiles []Profile
Scripts []Script
PoliciesUnavailable bool
ProfilesUnavailable bool
ScriptsUnavailable bool
}

// FetchOptions selects the optional scopes FetchAll retrieves. Both cost extra
// round trips, so callers ask for them only when the repo defines them.
type FetchOptions struct {
// Global fetches /config, /global/policies, and global queries.
Global bool
// NoTeam fetches the "hosts on no team" bucket (team_id=0).
NoTeam bool
}

// Team represents a Fleet team with its associated resources.
Expand Down Expand Up @@ -415,6 +441,35 @@ func (c *Client) GetPolicies(ctx context.Context, teamID uint) ([]Policy, error)
return all, nil
}

// GetNoTeamPolicies fetches the policies that belong to Fleet's "hosts on no
// team" bucket. This is a different endpoint from GetPolicies(0), which
// returns global policies. The response also carries inherited_policies (the
// global ones, which apply to no-team hosts as well); those are deliberately
// ignored, since a no-team YAML file does not own them.
func (c *Client) GetNoTeamPolicies(ctx context.Context) ([]Policy, error) {
var all []Policy
page := 0
for {
q := url.Values{
"per_page": {"250"},
"page": {strconv.Itoa(page)},
}
var resp policiesResponse
if err := c.get(ctx, "/api/v1/fleet/teams/0/policies", q, &resp); err != nil {
return nil, fmt.Errorf("fetching no-team policies: %w", err)
}
all = append(all, resp.Policies...)
if len(resp.Policies) < 250 {
break
}
page++
if page > 100 { // safety: max 25k policies
break
}
}
return all, nil
}

// GetQueries fetches queries, optionally filtered by team, with pagination.
func (c *Client) GetQueries(ctx context.Context, teamID uint) ([]Query, error) {
var all []Query
Expand Down Expand Up @@ -443,7 +498,9 @@ func (c *Client) GetQueries(ctx context.Context, teamID uint) ([]Query, error) {
return all, nil
}

// GetSoftware fetches managed (available_for_install) software titles for a team.
// GetSoftware fetches managed (available_for_install) software titles for a
// team. team_id is always sent; see GetProfiles for why teamID 0 must not be
// omitted.
// Uses available_for_install=true to exclude detected-only titles (OS packages,
// browser extensions, etc.) and only return software deployed via Fleet/GitOps.
// Paginates to collect all results.
Expand All @@ -456,9 +513,7 @@ func (c *Client) GetSoftware(ctx context.Context, teamID uint) ([]SoftwareTitle,
"page": {strconv.Itoa(page)},
"available_for_install": {"true"},
}
if teamID > 0 {
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
}
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
var resp softwareResponse
if err := c.get(ctx, "/api/v1/fleet/software/titles", q, &resp); err != nil {
return nil, fmt.Errorf("fetching software (team %d): %w", teamID, err)
Expand Down Expand Up @@ -570,7 +625,9 @@ func (c *Client) GetLabels(ctx context.Context) ([]Label, error) {
return all, nil
}

// GetProfiles fetches MDM profiles for a team with pagination.
// GetProfiles fetches MDM profiles for a team with pagination. team_id is
// always sent: Fleet reads teamID 0 as the "hosts on no team" bucket, whereas
// omitting the parameter returns every team's profiles.
func (c *Client) GetProfiles(ctx context.Context, teamID uint) ([]Profile, error) {
var all []Profile
page := 0
Expand All @@ -579,9 +636,7 @@ func (c *Client) GetProfiles(ctx context.Context, teamID uint) ([]Profile, error
"per_page": {"250"},
"page": {strconv.Itoa(page)},
}
if teamID > 0 {
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
}
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
var resp profilesResponse
if err := c.get(ctx, "/api/v1/fleet/configuration_profiles", q, &resp); err != nil {
return nil, fmt.Errorf("fetching profiles (team %d): %w", teamID, err)
Expand All @@ -598,7 +653,8 @@ func (c *Client) GetProfiles(ctx context.Context, teamID uint) ([]Profile, error
return all, nil
}

// GetScripts fetches scripts for a team with pagination.
// GetScripts fetches scripts for a team with pagination. team_id is always
// sent; see GetProfiles for why teamID 0 must not be omitted.
func (c *Client) GetScripts(ctx context.Context, teamID uint) ([]Script, error) {
var all []Script
page := 0
Expand All @@ -607,9 +663,7 @@ func (c *Client) GetScripts(ctx context.Context, teamID uint) ([]Script, error)
"per_page": {"250"},
"page": {strconv.Itoa(page)},
}
if teamID > 0 {
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
}
q.Set("team_id", strconv.FormatUint(uint64(teamID), 10))
var resp scriptsResponse
if err := c.get(ctx, "/api/v1/fleet/scripts", q, &resp); err != nil {
return nil, fmt.Errorf("fetching scripts (team %d): %w", teamID, err)
Expand Down Expand Up @@ -680,9 +734,13 @@ func (c *Client) getScriptContent(ctx context.Context, scriptID uint) (string, e
// FetchAll concurrently fetches the complete Fleet state. Uses errgroup for
// parallel requests. If fetchGlobal is true, also fetches global config,
// policies, and queries (for default.yml diffing).
func (c *Client) FetchAll(ctx context.Context, fetchGlobal ...bool) (*FleetState, error) {
func (c *Client) FetchAll(ctx context.Context, opts ...FetchOptions) (*FleetState, error) {
state := &FleetState{}
wantGlobal := len(fetchGlobal) > 0 && fetchGlobal[0]
var o FetchOptions
if len(opts) > 0 {
o = opts[0]
}
wantGlobal := o.Global

teams, err := c.GetTeams(ctx)
if err != nil {
Expand Down Expand Up @@ -743,6 +801,49 @@ func (c *Client) FetchAll(ctx context.Context, fetchGlobal ...bool) (*FleetState
})
}

// noTeam is written only by the goroutines below, then attached to state
// after g.Wait().
var noTeam *NoTeam
if o.NoTeam {
noTeam = &NoTeam{}
g.Go(func() error {
policies, err := c.GetNoTeamPolicies(gctx)
if err != nil {
if !isPermissionError(err) {
return err
}
noTeam.PoliciesUnavailable = true
return nil
}
noTeam.Policies = policies
return nil
})
g.Go(func() error {
profiles, err := c.GetProfiles(gctx, 0)
if err != nil {
if !isPermissionError(err) {
return err
}
noTeam.ProfilesUnavailable = true
return nil
}
noTeam.Profiles = profiles
return nil
})
g.Go(func() error {
scripts, err := c.GetScripts(gctx, 0)
if err != nil {
if !isPermissionError(err) {
return err
}
noTeam.ScriptsUnavailable = true
return nil
}
noTeam.Scripts = scripts
return nil
})
}

// teamPartials holds per-goroutine results indexed by team slot.
// Each field is written by exactly one goroutine, so there is no data race.
type teamPartial struct {
Expand Down Expand Up @@ -845,6 +946,13 @@ func (c *Client) FetchAll(ctx context.Context, fetchGlobal ...bool) (*FleetState
teamResults[i].ScriptsUnavailable = p.scriptsUnavailable
}

if noTeam != nil {
if !noTeam.ScriptsUnavailable && len(noTeam.Scripts) > 0 {
c.EnrichScriptContents(ctx, noTeam.Scripts)
}
state.NoTeam = noTeam
}

// Enrich script contents (second pass, needs script IDs from first pass)
for i := range teamResults {
if !teamResults[i].ScriptsUnavailable && len(teamResults[i].Scripts) > 0 {
Expand Down
Loading
Loading