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
2 changes: 1 addition & 1 deletion docs/API-Endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All read-only. fleet-plan never writes to your Fleet server.
| Method | Endpoint | Purpose |
|--------|----------|---------|
| `GET` | `/api/v1/fleet/config` | Global config (org_settings, agent_options, controls) |
| `GET` | `/api/v1/fleet/teams` | Team list + embedded software config |
| `GET` | `/api/v1/fleet/teams` | Team list + embedded software config + team settings (`webhook_settings`, `host_expiry_settings`, `integrations`, `features`) |
| `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) |
Expand Down
5 changes: 3 additions & 2 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Config file supports multiple contexts:

## Parser

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.
Walks `teams/*.yml`, resolves `path:` references, produces `ParsedRepo`. Also parses `default.yml` for labels, `org_settings`, `agent_options`, `controls`, and global policies/queries. A team's `settings:` block (or the older `team_settings:` spelling) is kept as a nested map for field-level diffing. All path references are validated against the repo root to prevent traversal.

---

Expand All @@ -91,7 +91,8 @@ Compares `FleetState` (API) vs `ParsedRepo` (YAML). Produces `[]DiffResult` per

| Resource | Match key | Diff fields |
|----------|-----------|-------------|
| Config sections | dot-path key | old/new value (skips `$VAR` placeholders) |
| Config sections (global) | dot-path key | old/new value (skips `$VAR` placeholders) |
| Team `settings:` | dot-path key | old/new value vs the team object from `GET /teams`; `secrets:` is never diffed |
| Policies | `name` | query, description, resolution, platform, critical |
| Queries | `name` | query, interval, platform, logging |
| Software packages | `referenced_yaml_path` | url, hash, self_service |
Expand Down
23 changes: 23 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,29 @@ type Team struct {
SoftwareUnavailable bool // true when GetSoftware returned 403/404 (token lacks permission)
ProfilesUnavailable bool // true when GetProfiles returned 403/404 (token lacks permission)
ScriptsUnavailable bool // true when GetScripts returned 403/404 (token lacks permission)

// Settings holds the raw team object as returned by the API, so the
// settings blocks a team YAML configures (webhook_settings,
// host_expiry_settings, integrations, features) can be diffed field by
// field. Populated by UnmarshalJSON.
Settings map[string]any `json:"-"`
}

// UnmarshalJSON decodes a team twice: once into the typed struct and once into
// a generic map kept in Settings. The map is what the settings diff compares
// against, and keeping it avoids having to model every settings sub-key Fleet
// may add.
func (t *Team) UnmarshalJSON(data []byte) error {
type teamAlias Team // avoid recursing into this method
var alias teamAlias
if err := json.Unmarshal(data, &alias); err != nil {
return err
}
*t = Team(alias)
// A team that does not decode as an object is a server-side surprise, not
// something to fail the whole diff over: leave Settings nil.
_ = json.Unmarshal(data, &t.Settings)
return nil
}

// TeamSoftware mirrors /api/v1/fleet/teams[].software for managed software
Expand Down
50 changes: 50 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,56 @@ func TestGetTeams(t *testing.T) {
}
}

func TestGetTeamsCapturesRawSettings(t *testing.T) {
// The settings blocks a team YAML configures live on the team object
// itself; the client keeps the raw object so they can be diffed.
const body = `{"teams":[{
"id": 1,
"name": "Workstations",
"host_expiry_settings": {"host_expiry_enabled": true, "host_expiry_window": 30},
"webhook_settings": {"failing_policies_webhook": {"destination_url": "https://example.com/hook"}},
"features": {"enable_software_inventory": true}
}]}`

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, body)
}))
defer ts.Close()

teams, err := testClient(t, ts, "testtoken").GetTeams(context.Background())
if err != nil {
t.Fatalf("GetTeams: %v", err)
}
if len(teams) != 1 {
t.Fatalf("got %d teams, want 1", len(teams))
}

// The typed fields still decode.
if teams[0].ID != 1 || teams[0].Name != "Workstations" {
t.Errorf("typed fields: got id=%d name=%q", teams[0].ID, teams[0].Name)
}

hes, ok := teams[0].Settings["host_expiry_settings"].(map[string]any)
if !ok {
t.Fatalf("Settings missing host_expiry_settings: %+v", teams[0].Settings)
}
if hes["host_expiry_enabled"] != true {
t.Errorf("host_expiry_enabled: got %v, want true", hes["host_expiry_enabled"])
}
if _, ok := teams[0].Settings["features"]; !ok {
t.Errorf("Settings missing features: %+v", teams[0].Settings)
}
}

func TestTeamUnmarshalJSONNonObject(t *testing.T) {
// A team that is not a JSON object should surface as an error rather than
// panicking or silently producing a half-decoded team.
var team Team
if err := json.Unmarshal([]byte(`"not-an-object"`), &team); err == nil {
t.Fatal("expected an error unmarshalling a non-object team")
}
}

// ---------- GetPolicies ----------

func TestGetPoliciesPathRouting(t *testing.T) {
Expand Down
87 changes: 87 additions & 0 deletions internal/diff/differ.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st

result.Policies = diffPolicies(currentTeam.Policies, proposedTeam.Policies)
result.Queries = diffQueries(currentTeam.Queries, proposedTeam.Queries)
result.Config, result.SkippedConfigSections = diffTeamSettings(currentTeam.Settings, proposedTeam.Settings)

// enrichedSoftware holds the API software state with fleet-maintained
// app scripts populated. Hoisted here so the baseline subtraction
Expand Down Expand Up @@ -356,6 +357,7 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st
baseDiff := DiffResult{}
baseDiff.Policies = diffPolicies(currentTeam.Policies, baseTeam.Policies)
baseDiff.Queries = diffQueries(currentTeam.Queries, baseTeam.Queries)
baseDiff.Config, _ = diffTeamSettings(currentTeam.Settings, baseTeam.Settings)
if !currentTeam.SoftwareUnavailable {
baseDiff.Software = diffSoftware(enrichedSoftware, baseTeam.Software)
}
Expand All @@ -371,6 +373,7 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st
if cfg.verbose {
vlog(true, "[%s] baseline queries: %s", proposedTeam.Name, rdNames(baseDiff.Queries))
}
result.Config = subtractConfigChanges(result.Config, baseDiff.Config)
result.Policies = subtractResourceDiff(result.Policies, baseDiff.Policies)
result.Queries = subtractResourceDiff(result.Queries, baseDiff.Queries)
result.Software = subtractResourceDiff(result.Software, baseDiff.Software)
Expand Down Expand Up @@ -1472,6 +1475,90 @@ func diffConfig(apiConfig map[string]any, proposed *parser.ParsedGlobal) ([]Conf
return changes, skipped
}

// teamSettingsSections maps a sub-key of a team's `settings:` block to the
// field on the Fleet team object that holds its live value. Everything Fleet
// exposes on GET /teams is listed here; a sub-key that is not is reported as
// skipped rather than silently dropped, so a new Fleet setting shows up as
// "not diffed" instead of "no changes".
var teamSettingsSections = map[string]string{
"webhook_settings": "webhook_settings",
"host_expiry_settings": "host_expiry_settings",
"integrations": "integrations",
"features": "features",
}

// diffTeamSettings compares a team's live settings (the raw team object from
// GET /teams) against the `settings:` block in its YAML, field by field. It
// returns the changes plus the names of settings sub-keys that could not be
// diffed.
//
// `secrets:` is never diffed: enroll secrets are credentials, and the diff
// output ends up in CI logs and MR comments.
func diffTeamSettings(current, proposed map[string]any) ([]ConfigChange, []string) {
if len(proposed) == 0 {
return nil, nil
}

var changes []ConfigChange
var skipped []string

for section, v := range proposed {
if section == "secrets" {
continue
}
apiKey, known := teamSettingsSections[section]
if !known {
skipped = append(skipped, "settings."+section)
continue
}
proposedMap, ok := v.(map[string]any)
if !ok {
skipped = append(skipped, "settings."+section)
continue
}
apiSection, ok := current[apiKey].(map[string]any)
if !ok {
// Fleet did not return this section (older server, or a token
// without permission to see it): say so rather than reporting
// every proposed key as a change.
skipped = append(skipped, "settings."+section)
continue
}

// Same guards as diffConfig: skip env var placeholders Fleet
// substitutes, and skip keys the API does not expose a value for,
// since "" cannot be distinguished from "not reported".
flattenMap(proposedMap, section, func(key, proposedVal string) {
if containsEnvVar(proposedVal) || proposedVal == "<nil>" || proposedVal == "" {
return
}
apiVal := getNestedValue(apiSection, strings.TrimPrefix(key, section+"."))
if apiVal == "<nil>" || apiVal == "" {
return
}
compareAPI, compareProposed := apiVal, proposedVal
if looksLikeJSON(apiVal) && looksLikeJSON(proposedVal) {
compareAPI = normalizeJSON(apiVal)
compareProposed = normalizeJSON(proposedVal)
}
if compareAPI != compareProposed {
changes = append(changes, ConfigChange{
Section: "settings",
Key: key,
Old: apiVal,
New: proposedVal,
})
}
})
}

// flattenMap walks maps in random order; sort so output is stable.
sort.Slice(changes, func(i, j int) bool { return changes[i].Key < changes[j].Key })
sort.Strings(skipped)

return changes, skipped
}

// containsEnvVar returns true if the string contains a $ (env var placeholder).
func containsEnvVar(s string) bool {
return strings.Contains(s, "$")
Expand Down
Loading
Loading