diff --git a/cmd/fleet-plan/main.go b/cmd/fleet-plan/main.go index 6f81474..f79eac1 100644 --- a/cmd/fleet-plan/main.go +++ b/cmd/fleet-plan/main.go @@ -110,6 +110,7 @@ func runDiff(cmd *cobra.Command, _ []string) error { // --git: detect CI context, resolve changed files + affected teams. var changedFiles []string var ci git.Env + var includeGlobal bool teams := flagTeams if flagGit { @@ -119,6 +120,7 @@ func runDiff(cmd *cobra.Command, _ []string) error { return nil } changedFiles = resolved.ChangedFiles + includeGlobal = resolved.IncludeGlobal if len(resolved.Teams) > 0 && len(teams) == 0 { teams = resolved.Teams } @@ -136,6 +138,24 @@ func runDiff(cmd *cobra.Command, _ []string) error { return fmt.Errorf("no teams found in %s/teams/\nAre you in a fleet-gitops repo? Try --repo /path/to/repo", flagRepo) } + // Parse baseline (base branch) for subtraction when in --git mode. + var baseline *parser.ParsedRepo + if flagGit && len(changedFiles) > 0 && ci.DiffBaseSHA != "" { + baseRoot, baseCleanup, err := git.CheckoutBaseline(flagRepo, ci.DiffBaseSHA, changedFiles) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not extract baseline (%v), skipping baseline subtraction\n", err) + } else { + defer baseCleanup() + baseDefaultFile := resolveBaselineDefault(baseRoot, flagRepo, flagBase, flagEnv) + baseParsed, err := parser.ParseRepo(baseRoot, teams, baseDefaultFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not parse baseline (%v), skipping baseline subtraction\n", err) + } else { + baseline = baseParsed + } + } + } + client, err := api.NewClient(auth.URL, auth.Token) if err != nil { return err @@ -149,8 +169,11 @@ func runDiff(cmd *cobra.Command, _ []string) error { return err } - results := diff.Diff(state, repo, teams, changedFiles, - diff.WithScriptEnricher(client)) + diffOpts := []diff.DiffOption{diff.WithScriptEnricher(client), diff.WithVerbose(flagVerbose), diff.WithIncludeGlobal(includeGlobal)} + if baseline != nil { + diffOpts = append(diffOpts, diff.WithBaseline(baseline)) + } + results := diff.Diff(state, repo, teams, changedFiles, diffOpts...) elapsed := time.Since(start) hasChanges := output.HasChanges(results) @@ -219,7 +242,9 @@ func resolveDefaultFile(repo, base, env string) (path string, cleanup func(), er env = filepath.Join(repo, env) } - tmp, err := os.CreateTemp("", "fleet-plan-default-*.yml") + // Place the merged file inside the repo so the parser resolves path: + // references (./queries/...) relative to the repo root, not /tmp. + tmp, err := os.CreateTemp(repo, ".fleet-plan-default-*.yml") if err != nil { return "", nil, fmt.Errorf("creating temp file for merged config: %w", err) } @@ -234,6 +259,41 @@ func resolveDefaultFile(repo, base, env string) (path string, cleanup func(), er return tmpPath, func() { os.Remove(tmpPath) }, nil } +// resolveBaselineDefault returns the path to default.yml for baseline parsing. +// When --base and --env are used, it merges the base branch's base.yml with +// the env overlay (from the MR branch, since env overlays rarely change). +// Falls back to the baseline's default.yml if present. +func resolveBaselineDefault(baseRoot, repoRoot, base, env string) string { + if base != "" && env != "" { + baseFile := filepath.Join(baseRoot, base) + if _, err := os.Stat(baseFile); err != nil { + // base.yml doesn't exist at base ref, skip + return "" + } + // Use the env overlay from the MR branch (repoRoot), not the baseline. + envFile := env + if !filepath.IsAbs(envFile) { + envFile = filepath.Join(repoRoot, envFile) + } + if _, err := os.Stat(envFile); err != nil { + return "" + } + // Place the merged file inside the baseline temp dir so the parser + // resolves path: refs (./queries/...) relative to the baseline root. + tmpPath := filepath.Join(baseRoot, "default.yml") + if err := merge.MergeFiles(baseFile, envFile, tmpPath); err != nil { + return "" + } + return tmpPath + } + // No base+env: check for plain default.yml in the baseline. + candidate := filepath.Join(baseRoot, "default.yml") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + return "" +} + // buildHeading returns the default CI heading using the Fleet server URL. func buildHeading(fleetURL string) string { display := strings.TrimPrefix(fleetURL, "https://") diff --git a/internal/diff/differ.go b/internal/diff/differ.go index 7d14ba4..47464b1 100644 --- a/internal/diff/differ.go +++ b/internal/diff/differ.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "os" "regexp" "sort" "strings" @@ -99,7 +100,10 @@ type ScriptEnricher interface { type DiffOption func(*diffOptions) type diffOptions struct { - enricher ScriptEnricher + enricher ScriptEnricher + baseline *parser.ParsedRepo + verbose bool + includeGlobal bool } // WithScriptEnricher enables script-level diffing for fleet-maintained apps. @@ -107,6 +111,55 @@ func WithScriptEnricher(e ScriptEnricher) DiffOption { return func(o *diffOptions) { o.enricher = e } } +// WithVerbose enables detailed stderr logging of baseline subtraction. +func WithVerbose(v bool) DiffOption { + return func(o *diffOptions) { o.verbose = v } +} + +// WithIncludeGlobal forces global config diffing even when team filters are +// set. Used in --git mode when both global and team files changed. +func WithIncludeGlobal(v bool) DiffOption { + return func(o *diffOptions) { o.includeGlobal = v } +} + +// WithBaseline provides a parsed base-branch repo. When set, Diff subtracts +// changes that already exist between the base branch and Fleet (i.e. changes +// merged to main but not yet deployed) so that only the incremental changes +// introduced by the current MR are reported. +func WithBaseline(b *parser.ParsedRepo) DiffOption { + return func(o *diffOptions) { o.baseline = b } +} + +// vlog writes to stderr when verbose mode is enabled. +func vlog(verbose bool, format string, args ...any) { + if verbose { + fmt.Fprintf(os.Stderr, "[verbose] "+format+"\n", args...) + } +} + +// rdSummary returns a one-line summary of a ResourceDiff. +func rdSummary(rd ResourceDiff) string { + return fmt.Sprintf("+%d ~%d -%d", len(rd.Added), len(rd.Modified), len(rd.Deleted)) +} + +// rdNames returns names of changes for debugging. +func rdNames(rd ResourceDiff) string { + var names []string + for _, c := range rd.Added { + names = append(names, "+"+c.Name) + } + for _, c := range rd.Modified { + names = append(names, "~"+c.Name) + } + for _, c := range rd.Deleted { + names = append(names, "-"+c.Name) + } + if len(names) == 0 { + return "(none)" + } + return strings.Join(names, ", ") +} + // Diff computes the diff between current Fleet state and proposed YAML for all // teams. If teamFilters is non-empty, only matching teams are diffed. // If changedFiles is non-empty, only resources whose SourceFile matches are @@ -125,8 +178,13 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st labelMap[l.Name] = l } + vlog(cfg.verbose, "baseline=%v, baseline.Global=%v, teamFilters=%v, changedFiles=%v", + cfg.baseline != nil, cfg.baseline != nil && cfg.baseline.Global != nil, teamFilters, changedFiles) + // --- Global config diff (default.yml) --- - if proposed.Global != nil && len(teamFilters) == 0 { + if proposed.Global != nil && (len(teamFilters) == 0 || cfg.includeGlobal) { + vlog(cfg.verbose, "(global) proposed: %d policies, %d queries", len(proposed.Global.Policies), len(proposed.Global.Queries)) + vlog(cfg.verbose, "(global) fleet: %d policies, %d queries", len(current.GlobalPolicies), len(current.GlobalQueries)) globalResult := DiffResult{Team: "(global)"} if current.Config != nil { @@ -139,6 +197,31 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st // Diff global queries globalResult.Queries = diffQueries(current.GlobalQueries, proposed.Global.Queries) + vlog(cfg.verbose, "(global) MR diff: policies=%s queries=%s config=%d", + rdSummary(globalResult.Policies), rdSummary(globalResult.Queries), len(globalResult.Config)) + + // Subtract baseline for global scope + if cfg.baseline != nil && cfg.baseline.Global != nil { + vlog(cfg.verbose, "(global) baseline: %d policies, %d queries", + len(cfg.baseline.Global.Policies), len(cfg.baseline.Global.Queries)) + var baseConfig []ConfigChange + if current.Config != nil { + baseConfig, _ = diffConfig(current.Config, cfg.baseline.Global) + } + basePolicies := diffPolicies(current.GlobalPolicies, cfg.baseline.Global.Policies) + baseQueries := diffQueries(current.GlobalQueries, cfg.baseline.Global.Queries) + + vlog(cfg.verbose, "(global) baseline diff: policies=%s queries=%s config=%d", + rdSummary(basePolicies), rdSummary(baseQueries), len(baseConfig)) + + globalResult.Config = subtractConfigChanges(globalResult.Config, baseConfig) + globalResult.Policies = subtractResourceDiff(globalResult.Policies, basePolicies) + globalResult.Queries = subtractResourceDiff(globalResult.Queries, baseQueries) + + vlog(cfg.verbose, "(global) after subtraction: policies=%s queries=%s config=%d", + rdSummary(globalResult.Policies), rdSummary(globalResult.Queries), len(globalResult.Config)) + } + results = append(results, globalResult) } @@ -181,14 +264,22 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st result.Errors = append(result.Errors, fmt.Sprintf("info: team %q does not exist in Fleet yet (will be created)", proposedTeam.Name)) } } else { + vlog(cfg.verbose, "[%s] proposed: %d policies, %d queries", proposedTeam.Name, + len(proposedTeam.Policies), len(proposedTeam.Queries)) + vlog(cfg.verbose, "[%s] fleet: %d policies, %d queries", proposedTeam.Name, + len(currentTeam.Policies), len(currentTeam.Queries)) + result.Policies = diffPolicies(currentTeam.Policies, proposedTeam.Policies) result.Queries = diffQueries(currentTeam.Queries, proposedTeam.Queries) + // enrichedSoftware holds the API software state with fleet-maintained + // app scripts populated. Hoisted here so the baseline subtraction + // can reuse the same enriched state. + enrichedSoftware := currentTeam.Software + if currentTeam.SoftwareUnavailable { result.Errors = append(result.Errors, "software diff skipped: API token lacks permission to read software titles") } else { - currentSoftware := currentTeam.Software - // Fleet's /teams API may return fleet_maintained_apps: null, or // return a partial list (e.g., only macOS FMAs while Windows FMAs // are merged into packages). Infer from software titles + catalog @@ -198,10 +289,10 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st if cfg.enricher != nil && len(inferred) > 0 { cfg.enricher.EnrichFleetAppScripts(context.Background(), inferred) } - currentSoftware.FleetMaintained = mergeFleetApps(currentTeam.Software.FleetMaintained, inferred) + enrichedSoftware.FleetMaintained = mergeFleetApps(currentTeam.Software.FleetMaintained, inferred) } - result.Software = diffSoftware(currentSoftware, proposedTeam.Software) + result.Software = diffSoftware(enrichedSoftware, proposedTeam.Software) } if currentTeam.ProfilesUnavailable { @@ -217,15 +308,65 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st } else { result.Scripts = diffScripts(currentTeam.Scripts, proposedTeam.Scripts) } + + vlog(cfg.verbose, "[%s] MR diff: policies=%s queries=%s software=%s scripts=%s", + proposedTeam.Name, rdSummary(result.Policies), rdSummary(result.Queries), + rdSummary(result.Software), rdSummary(result.Scripts)) + if cfg.verbose { + vlog(true, "[%s] MR queries: %s", proposedTeam.Name, rdNames(result.Queries)) + } + + // Subtract baseline: remove changes that already exist between the + // base branch and Fleet (merged but not yet deployed). + if cfg.baseline != nil { + if baseTeam, ok := findBaselineTeam(cfg.baseline, proposedTeam.Name); ok { + vlog(cfg.verbose, "[%s] baseline team found: %d policies, %d queries", + proposedTeam.Name, len(baseTeam.Policies), len(baseTeam.Queries)) + baseDiff := DiffResult{} + baseDiff.Policies = diffPolicies(currentTeam.Policies, baseTeam.Policies) + baseDiff.Queries = diffQueries(currentTeam.Queries, baseTeam.Queries) + if !currentTeam.SoftwareUnavailable { + baseDiff.Software = diffSoftware(enrichedSoftware, baseTeam.Software) + } + if !currentTeam.ProfilesUnavailable { + baseDiff.Profiles, _ = diffProfiles(currentTeam.Profiles, baseTeam.Profiles) + } + if !currentTeam.ScriptsUnavailable { + baseDiff.Scripts = diffScripts(currentTeam.Scripts, baseTeam.Scripts) + } + vlog(cfg.verbose, "[%s] baseline diff: policies=%s queries=%s software=%s", + proposedTeam.Name, rdSummary(baseDiff.Policies), + rdSummary(baseDiff.Queries), rdSummary(baseDiff.Software)) + if cfg.verbose { + vlog(true, "[%s] baseline queries: %s", proposedTeam.Name, rdNames(baseDiff.Queries)) + } + result.Policies = subtractResourceDiff(result.Policies, baseDiff.Policies) + result.Queries = subtractResourceDiff(result.Queries, baseDiff.Queries) + result.Software = subtractResourceDiff(result.Software, baseDiff.Software) + result.Profiles = subtractResourceDiff(result.Profiles, baseDiff.Profiles) + result.Scripts = subtractResourceDiff(result.Scripts, baseDiff.Scripts) + vlog(cfg.verbose, "[%s] after subtraction: policies=%s queries=%s software=%s", + proposedTeam.Name, rdSummary(result.Policies), + rdSummary(result.Queries), rdSummary(result.Software)) + } else { + vlog(cfg.verbose, "[%s] no baseline team found", proposedTeam.Name) + } + } } if len(changedFiles) > 0 { + vlog(cfg.verbose, "[%s] before changedFiles filter: policies=%s queries=%s software=%s", + proposedTeam.Name, rdSummary(result.Policies), rdSummary(result.Queries), + rdSummary(result.Software)) sourceNames := buildSourceMap(proposedTeam) result.Policies = filterResourceDiff(result.Policies, sourceNames, changedFiles) result.Queries = filterResourceDiff(result.Queries, sourceNames, changedFiles) result.Software = filterResourceDiff(result.Software, sourceNames, changedFiles) result.Profiles = filterResourceDiff(result.Profiles, sourceNames, changedFiles) result.Scripts = filterResourceDiff(result.Scripts, sourceNames, changedFiles) + vlog(cfg.verbose, "[%s] after changedFiles filter: policies=%s queries=%s software=%s", + proposedTeam.Name, rdSummary(result.Policies), rdSummary(result.Queries), + rdSummary(result.Software)) } result.Labels = validateLabels(proposedTeam, labelMap, changedNames(result.Policies)) @@ -244,9 +385,11 @@ func buildSourceMap(team parser.ParsedTeam) map[string][]string { } for _, p := range team.Policies { add(p.Name, p.SourceFile) + add(p.Name, team.SourceFile) } for _, q := range team.Queries { add(q.Name, q.SourceFile) + add(q.Name, team.SourceFile) } for _, p := range team.Software.Packages { key := parser.NormalizeSoftwarePath(p.RefPath) @@ -258,6 +401,7 @@ func buildSourceMap(team parser.ParsedTeam) map[string][]string { } if key != "" { add(parser.NormalizeSoftwarePath(key), p.SourceFile) + add(parser.NormalizeSoftwarePath(key), team.SourceFile) for _, sf := range p.SourceFiles { add(parser.NormalizeSoftwarePath(key), sf) } @@ -276,9 +420,11 @@ func buildSourceMap(team parser.ParsedTeam) map[string][]string { } for _, p := range team.Profiles { add(p.Name, p.SourceFile) + add(p.Name, team.SourceFile) } for _, s := range team.Scripts { add(s.Name, s.SourceFile) + add(s.Name, team.SourceFile) } return m } @@ -315,6 +461,107 @@ func filterChanges(changes []ResourceChange, keep func(string) bool) []ResourceC return out } +// ---------- Baseline subtraction ---------- + +// findBaselineTeam looks up a team by name in the baseline parsed repo. +func findBaselineTeam(baseline *parser.ParsedRepo, name string) (parser.ParsedTeam, bool) { + for _, t := range baseline.Teams { + if strings.EqualFold(t.Name, name) { + return t, true + } + } + return parser.ParsedTeam{}, false +} + +// subtractResourceDiff removes changes from "total" that also appear in +// "baseline". A change is considered the same if it has the same Name and +// change type (added/modified/deleted). +// +// For modified resources, if the resource appears in both diffs but with +// different field changes, it is kept (the MR introduced additional changes +// beyond what the baseline already had). +func subtractResourceDiff(total, baseline ResourceDiff) ResourceDiff { + return ResourceDiff{ + Added: subtractChanges(total.Added, baseline.Added), + Modified: subtractModified(total.Modified, baseline.Modified), + Deleted: subtractChanges(total.Deleted, baseline.Deleted), + } +} + +// subtractChanges removes entries from "total" whose Name matches an entry in +// "baseline". Used for Added and Deleted lists where name-match is sufficient. +func subtractChanges(total, baseline []ResourceChange) []ResourceChange { + if len(baseline) == 0 { + return total + } + baseNames := make(map[string]bool, len(baseline)) + for _, b := range baseline { + baseNames[b.Name] = true + } + var out []ResourceChange + for _, c := range total { + if !baseNames[c.Name] { + out = append(out, c) + } + } + return out +} + +// subtractModified removes entries from "total" that have the exact same field +// diffs in "baseline". If a resource is modified in both but with different +// fields or values, it is kept (the MR changed it further). +func subtractModified(total, baseline []ResourceChange) []ResourceChange { + if len(baseline) == 0 { + return total + } + baseFields := make(map[string]map[string]FieldDiff, len(baseline)) + for _, b := range baseline { + baseFields[b.Name] = b.Fields + } + var out []ResourceChange + for _, c := range total { + bf, exists := baseFields[c.Name] + if !exists || !sameFieldDiffs(c.Fields, bf) { + out = append(out, c) + } + } + return out +} + +// sameFieldDiffs returns true if two field diff maps are identical. +func sameFieldDiffs(a, b map[string]FieldDiff) bool { + if len(a) != len(b) { + return false + } + for k, av := range a { + bv, ok := b[k] + if !ok || av.Old != bv.Old || av.New != bv.New { + return false + } + } + return true +} + +// subtractConfigChanges removes ConfigChange entries from "total" that also +// appear in "baseline" with the same Section, Key, Old, and New values. +func subtractConfigChanges(total, baseline []ConfigChange) []ConfigChange { + if len(baseline) == 0 { + return total + } + type configKey struct{ Section, Key, Old, New string } + baseSet := make(map[configKey]bool, len(baseline)) + for _, b := range baseline { + baseSet[configKey{b.Section, b.Key, b.Old, b.New}] = true + } + var out []ConfigChange + for _, c := range total { + if !baseSet[configKey{c.Section, c.Key, c.Old, c.New}] { + out = append(out, c) + } + } + return out +} + // ---------- Per-resource diffing ---------- func diffPolicies(current []api.Policy, proposed []parser.ParsedPolicy) ResourceDiff { diff --git a/internal/diff/differ_test.go b/internal/diff/differ_test.go index d3e0783..fd8885b 100644 --- a/internal/diff/differ_test.go +++ b/internal/diff/differ_test.go @@ -1710,6 +1710,178 @@ func TestDiffScriptChangedFileFilter(t *testing.T) { } } +// ---------- Baseline subtraction tests ---------- + +func TestSubtractResourceDiff(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total ResourceDiff + baseline ResourceDiff + want ResourceDiff + }{ + { + name: "empty baseline changes nothing", + total: ResourceDiff{Added: []ResourceChange{{Name: "A"}}}, + baseline: ResourceDiff{}, + want: ResourceDiff{Added: []ResourceChange{{Name: "A"}}}, + }, + { + name: "subtract matching delete", + total: ResourceDiff{Deleted: []ResourceChange{{Name: "old-policy"}, {Name: "mr-policy"}}}, + baseline: ResourceDiff{Deleted: []ResourceChange{{Name: "old-policy"}}}, + want: ResourceDiff{Deleted: []ResourceChange{{Name: "mr-policy"}}}, + }, + { + name: "subtract matching add", + total: ResourceDiff{Added: []ResourceChange{{Name: "base-add"}, {Name: "mr-add"}}}, + baseline: ResourceDiff{Added: []ResourceChange{{Name: "base-add"}}}, + want: ResourceDiff{Added: []ResourceChange{{Name: "mr-add"}}}, + }, + { + name: "subtract identical modify", + total: ResourceDiff{Modified: []ResourceChange{ + {Name: "same-mod", Fields: map[string]FieldDiff{"query": {Old: "a", New: "b"}}}, + {Name: "mr-mod", Fields: map[string]FieldDiff{"query": {Old: "x", New: "y"}}}, + }}, + baseline: ResourceDiff{Modified: []ResourceChange{ + {Name: "same-mod", Fields: map[string]FieldDiff{"query": {Old: "a", New: "b"}}}, + }}, + want: ResourceDiff{Modified: []ResourceChange{ + {Name: "mr-mod", Fields: map[string]FieldDiff{"query": {Old: "x", New: "y"}}}, + }}, + }, + { + name: "keep modify with different fields", + total: ResourceDiff{Modified: []ResourceChange{ + {Name: "evolved", Fields: map[string]FieldDiff{"query": {Old: "a", New: "c"}}}, + }}, + baseline: ResourceDiff{Modified: []ResourceChange{ + {Name: "evolved", Fields: map[string]FieldDiff{"query": {Old: "a", New: "b"}}}, + }}, + want: ResourceDiff{Modified: []ResourceChange{ + {Name: "evolved", Fields: map[string]FieldDiff{"query": {Old: "a", New: "c"}}}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := subtractResourceDiff(tt.total, tt.baseline) + assertResourceDiffEqual(t, tt.want, got) + }) + } +} + +func TestDiffWithBaselineSubtraction(t *testing.T) { + t.Parallel() + + // Simulate: base branch already removed "old-policy" and modified "shared-query". + // MR adds "new-query" and removes "mr-removed-policy". + // Only the MR's changes should appear. + + current := &api.FleetState{ + Teams: []api.Team{ + { + ID: 1, + Name: "TestTeam", + Policies: []api.Policy{ + {Name: "old-policy", Query: "SELECT 1;", Platform: "linux"}, + {Name: "mr-removed-policy", Query: "SELECT 2;", Platform: "windows"}, + }, + Queries: []api.Query{ + {Name: "shared-query", Query: "SELECT old;", Interval: 3600}, + {Name: "existing-query", Query: "SELECT x;", Interval: 300}, + }, + }, + }, + } + + // MR branch: old-policy gone (already removed in base), mr-removed-policy gone (MR removes it), + // shared-query modified to "SELECT new;" (already modified in base to same value), + // new-query added (MR adds it). + proposed := &parser.ParsedRepo{ + Teams: []parser.ParsedTeam{ + { + Name: "TestTeam", + SourceFile: "teams/test.yml", + Queries: []parser.ParsedQuery{ + {Name: "shared-query", Query: "SELECT new;", Interval: 3600}, + {Name: "existing-query", Query: "SELECT x;", Interval: 300}, + {Name: "new-query", Query: "SELECT fresh;", Interval: 600, SourceFile: "queries/new.yml"}, + }, + }, + }, + } + + // Base branch: old-policy already removed, shared-query already modified, + // but mr-removed-policy still present. + baseline := &parser.ParsedRepo{ + Teams: []parser.ParsedTeam{ + { + Name: "TestTeam", + SourceFile: "teams/test.yml", + Policies: []parser.ParsedPolicy{ + {Name: "mr-removed-policy", Query: "SELECT 2;", Platform: "windows"}, + }, + Queries: []parser.ParsedQuery{ + {Name: "shared-query", Query: "SELECT new;", Interval: 3600}, + {Name: "existing-query", Query: "SELECT x;", Interval: 300}, + }, + }, + }, + } + + results := Diff(current, proposed, nil, nil, WithBaseline(baseline)) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + r := results[0] + + // old-policy deletion should be subtracted (already in base). + if len(r.Policies.Deleted) != 1 { + t.Fatalf("expected 1 deleted policy (mr-removed-policy), got %d: %+v", len(r.Policies.Deleted), r.Policies.Deleted) + } + if r.Policies.Deleted[0].Name != "mr-removed-policy" { + t.Errorf("deleted policy name: got %q, want mr-removed-policy", r.Policies.Deleted[0].Name) + } + + // shared-query modification should be subtracted (same diff in base). + if len(r.Queries.Modified) != 0 { + t.Errorf("expected 0 modified queries (subtracted), got %d: %+v", len(r.Queries.Modified), r.Queries.Modified) + } + + // new-query addition should remain (not in base). + if len(r.Queries.Added) != 1 { + t.Fatalf("expected 1 added query (new-query), got %d", len(r.Queries.Added)) + } + if r.Queries.Added[0].Name != "new-query" { + t.Errorf("added query name: got %q, want new-query", r.Queries.Added[0].Name) + } +} + +func assertResourceDiffEqual(t *testing.T, want, got ResourceDiff) { + t.Helper() + assertChangesEqual(t, "Added", want.Added, got.Added) + assertChangesEqual(t, "Modified", want.Modified, got.Modified) + assertChangesEqual(t, "Deleted", want.Deleted, got.Deleted) +} + +func assertChangesEqual(t *testing.T, label string, want, got []ResourceChange) { + t.Helper() + if len(want) != len(got) { + t.Errorf("%s: want %d changes, got %d", label, len(want), len(got)) + return + } + for i := range want { + if want[i].Name != got[i].Name { + t.Errorf("%s[%d].Name: want %q, got %q", label, i, want[i].Name, got[i].Name) + } + } +} + // findTeam locates a DiffResult by team name, failing the test if not found. func findTeam(t *testing.T, results []DiffResult, name string) *DiffResult { t.Helper() diff --git a/internal/git/baseline.go b/internal/git/baseline.go new file mode 100644 index 0000000..2a04b2e --- /dev/null +++ b/internal/git/baseline.go @@ -0,0 +1,153 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// CheckoutBaseline extracts the base-branch versions of the given files into a +// temporary directory that mirrors the repo layout. It uses "git show :" +// so it works in shallow clones without a full checkout. +// +// The caller must call the returned cleanup function to remove the temp dir. +// If the base ref is unavailable or a file doesn't exist at the base ref, that +// file is silently skipped (it may be newly added in the MR). +func CheckoutBaseline(repoRoot string, baseRef string, files []string) (tmpRoot string, cleanup func(), err error) { + if baseRef == "" { + return "", nil, fmt.Errorf("no base ref provided") + } + + tmpRoot, err = os.MkdirTemp("", "fleet-plan-baseline-*") + if err != nil { + return "", nil, fmt.Errorf("creating temp dir: %w", err) + } + cleanup = func() { os.RemoveAll(tmpRoot) } + + // Ensure teams/ directory exists so ParseRepo doesn't bail early. + os.MkdirAll(filepath.Join(tmpRoot, "teams"), 0o755) + + // Resolve which files we need: the explicitly changed files, plus any + // files they reference (path: directives in team YAML). We start with + // the team files themselves, then do a second pass for references. + needed := collectBaselineFiles(repoRoot, baseRef, files) + + var extracted int + for _, f := range needed { + content, err := gitShow(repoRoot, baseRef, f) + if err != nil { + // File doesn't exist at base ref (newly added), skip. + continue + } + + dst := filepath.Join(tmpRoot, f) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + cleanup() + return "", nil, fmt.Errorf("creating dir for %s: %w", f, err) + } + if err := os.WriteFile(dst, content, 0o644); err != nil { + cleanup() + return "", nil, fmt.Errorf("writing %s: %w", f, err) + } + extracted++ + } + + if extracted == 0 { + cleanup() + return "", nil, fmt.Errorf("no baseline files could be extracted") + } + + return tmpRoot, cleanup, nil +} + +// collectBaselineFiles returns the full set of files needed for baseline parsing. +// This includes the changed team files and any path: references they contain. +func collectBaselineFiles(repoRoot, baseRef string, changedFiles []string) []string { + seen := make(map[string]bool) + var result []string + + add := func(f string) { + if !seen[f] { + seen[f] = true + result = append(result, f) + } + } + + // Start with all fleet-relevant changed files. + for _, f := range changedFiles { + add(f) + } + + // For each team or config file, extract it from base ref and scan for path: + // references. Also extract any referenced resource files so the parser can + // resolve them. + for _, f := range changedFiles { + if !strings.HasPrefix(f, "teams/") && f != "base.yml" && f != "default.yml" { + continue + } + content, err := gitShow(repoRoot, baseRef, f) + if err != nil { + continue + } + for _, ref := range extractPathRefs(content, f) { + add(ref) + // Resource YAML files may reference scripts, profiles, etc. + refContent, err := gitShow(repoRoot, baseRef, ref) + if err != nil { + continue + } + for _, nested := range extractPathRefs(refContent, ref) { + add(nested) + } + } + } + + // Only include default.yml/base.yml if they're already in changedFiles. + // Extracting them unconditionally causes over-subtraction for MRs that + // don't touch global config. + + return result +} + +// extractPathRefs scans YAML content for "path:" directives and returns the +// resolved file paths relative to repo root. +func extractPathRefs(content []byte, sourceFile string) []string { + var refs []string + sourceDir := filepath.Dir(sourceFile) + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "- path:") && !strings.HasPrefix(trimmed, "path:") { + continue + } + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + continue + } + ref := strings.TrimSpace(parts[1]) + // Remove quotes if present. + ref = strings.Trim(ref, `"'`) + if ref == "" { + continue + } + // Resolve relative to the source file's directory. + resolved := filepath.Join(sourceDir, ref) + resolved = filepath.Clean(resolved) + if !strings.HasPrefix(resolved, "..") { + refs = append(refs, resolved) + } + } + return refs +} + +// gitShow runs "git show :" and returns the file content. +func gitShow(repoRoot, ref, path string) ([]byte, error) { + cmd := exec.Command("git", "show", ref+":"+path) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git show %s:%s: %w", ref, path, err) + } + return out, nil +} diff --git a/internal/git/baseline_test.go b/internal/git/baseline_test.go new file mode 100644 index 0000000..ca01669 --- /dev/null +++ b/internal/git/baseline_test.go @@ -0,0 +1,183 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestExtractPathRefs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + sourceFile string + want []string + }{ + { + name: "policy path refs", + content: " - path: ../policies/windows/filevault.yml\n - path: ../policies/linux/ssh.yml\n", + sourceFile: "teams/workstations.yml", + want: []string{"policies/windows/filevault.yml", "policies/linux/ssh.yml"}, + }, + { + name: "software path ref", + content: " path: ../software/mac/slack/slack.yml\n", + sourceFile: "teams/workstations.yml", + want: []string{"software/mac/slack/slack.yml"}, + }, + { + name: "quoted path", + content: " - path: \"../queries/windows/hardware.yml\"\n", + sourceFile: "teams/test.yml", + want: []string{"queries/windows/hardware.yml"}, + }, + { + name: "no path refs", + content: "name: TestTeam\nagent_options:\n config:\n options:\n logger_plugin: tls\n", + sourceFile: "teams/test.yml", + want: nil, + }, + { + name: "nested ref from software yaml", + content: "path: install.ps1\n", + sourceFile: "software/windows/agent/agent.yml", + want: []string{"software/windows/agent/install.ps1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractPathRefs([]byte(tt.content), tt.sourceFile) + if len(got) != len(tt.want) { + t.Fatalf("got %d refs, want %d: %v", len(got), len(tt.want), got) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Errorf("ref[%d]: got %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestCheckoutBaseline_NoBaseRef(t *testing.T) { + t.Parallel() + _, _, err := CheckoutBaseline("/tmp", "", []string{"teams/test.yml"}) + if err == nil { + t.Fatal("expected error for empty base ref") + } +} + +func TestCheckoutBaseline_ExtractsFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + gitRun(t, dir, "init") + gitRun(t, dir, "config", "user.email", "test@test.com") + gitRun(t, dir, "config", "user.name", "Test") + + // Create initial commit with a team file and a policy file. + os.MkdirAll(filepath.Join(dir, "teams"), 0o755) + os.MkdirAll(filepath.Join(dir, "policies", "linux"), 0o755) + os.WriteFile(filepath.Join(dir, "teams", "test.yml"), []byte("name: Test\npolicies:\n - path: ../policies/linux/ssh.yml\n"), 0o644) + os.WriteFile(filepath.Join(dir, "policies", "linux", "ssh.yml"), []byte("name: SSH\nquery: SELECT 1;\n"), 0o644) + gitRun(t, dir, "add", "-A") + gitRun(t, dir, "commit", "-m", "init") + + baseSHA := gitOutput(t, dir, "rev-parse", "HEAD") + + // Modify the team file on a new "branch" (just a new commit). + os.WriteFile(filepath.Join(dir, "teams", "test.yml"), []byte("name: Test\npolicies:\n - path: ../policies/linux/ssh.yml\nqueries:\n - path: ../queries/new.yml\n"), 0o644) + gitRun(t, dir, "add", "-A") + gitRun(t, dir, "commit", "-m", "add query") + + // CheckoutBaseline should extract the base version. + tmpRoot, cleanup, err := CheckoutBaseline(dir, baseSHA, []string{"teams/test.yml"}) + if err != nil { + t.Fatalf("CheckoutBaseline: %v", err) + } + defer cleanup() + + // Verify the team file was extracted with the base content. + content, err := os.ReadFile(filepath.Join(tmpRoot, "teams", "test.yml")) + if err != nil { + t.Fatalf("reading extracted team file: %v", err) + } + if !strings.Contains(string(content), "name: Test") { + t.Errorf("expected team file content, got: %s", content) + } + if strings.Contains(string(content), "queries") { + t.Errorf("base version should not contain queries section, got: %s", content) + } + + // Verify the referenced policy file was also extracted. + policyContent, err := os.ReadFile(filepath.Join(tmpRoot, "policies", "linux", "ssh.yml")) + if err != nil { + t.Fatalf("reading extracted policy file: %v", err) + } + if !strings.Contains(string(policyContent), "name: SSH") { + t.Errorf("expected policy file content, got: %s", policyContent) + } +} + +func TestCollectBaselineFiles_OnlyIncludesChangedFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + gitRun(t, dir, "init") + gitRun(t, dir, "config", "user.email", "test@test.com") + gitRun(t, dir, "config", "user.name", "Test") + + os.MkdirAll(filepath.Join(dir, "teams"), 0o755) + os.WriteFile(filepath.Join(dir, "default.yml"), []byte("org_settings:\n"), 0o644) + os.WriteFile(filepath.Join(dir, "teams", "test.yml"), []byte("name: Test\n"), 0o644) + gitRun(t, dir, "add", "-A") + gitRun(t, dir, "commit", "-m", "init") + + sha := gitOutput(t, dir, "rev-parse", "HEAD") + + // default.yml not in changedFiles, should NOT be collected. + files := collectBaselineFiles(dir, sha, []string{"teams/test.yml"}) + for _, f := range files { + if f == "default.yml" { + t.Errorf("default.yml should not be collected when not in changedFiles, got: %v", files) + } + } + + // default.yml in changedFiles, should be collected. + files = collectBaselineFiles(dir, sha, []string{"default.yml"}) + found := false + for _, f := range files { + if f == "default.yml" { + found = true + } + } + if !found { + t.Errorf("expected default.yml when in changedFiles, got: %v", files) + } +} + +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func gitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/git/scope.go b/internal/git/scope.go index 9c06111..117e6bc 100644 --- a/internal/git/scope.go +++ b/internal/git/scope.go @@ -57,7 +57,7 @@ func ResolveScope(root string, changedFiles []string, envFile string) Scope { } } - if isFleetResourceOrTeam(f) { + if isFleetResourceOrTeam(f) || f == "base.yml" || f == "default.yml" { scope.ChangedFiles = append(scope.ChangedFiles, f) } }