Skip to content

Commit 1b04e08

Browse files
authored
Merge pull request #6 from TsekNet/dt/fix-fma-inference
fix: Improve fleet-maintained app inference
2 parents 0ecb943 + 06190a9 commit 1b04e08

2 files changed

Lines changed: 326 additions & 24 deletions

File tree

internal/diff/differ.go

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,9 @@ func Diff(current *api.FleetState, proposed *parser.ParsedRepo, teamFilters []st
178178
// the field is null but the YAML defines fleet-maintained apps,
179179
// silently reconstruct the current state from software titles +
180180
// the fleet-maintained catalog so we can produce an accurate diff.
181-
if currentTeam.Software.FleetMaintained == nil &&
182-
len(proposedTeam.Software.FleetMaintained) > 0 {
183-
inferred := inferFleetMaintainedApps(currentTeam, current.FleetMaintainedCatalog)
181+
if currentTeam.Software.FleetMaintained == nil &&
182+
len(proposedTeam.Software.FleetMaintained) > 0 {
183+
inferred := inferFleetMaintainedApps(currentTeam, current.FleetMaintainedCatalog, proposedTeam.Software.Packages)
184184
currentSoftware.FleetMaintained = inferred // may be nil; that's fine
185185
}
186186

@@ -599,27 +599,27 @@ func sortResourceChanges(rd *ResourceDiff) {
599599
sort.Slice(rd.Deleted, func(i, j int) bool { return byName(rd.Deleted[i], rd.Deleted[j]) })
600600
}
601601

602-
func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) []api.TeamFleetApp {
602+
func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp, proposedPackages []parser.ParsedSoftwarePackage) []api.TeamFleetApp {
603603
if len(team.SoftwareTitles) == 0 || len(catalog) == 0 {
604604
return nil
605605
}
606606

607-
customPackageURLs := make(map[string]bool)
608-
for _, p := range team.Software.Packages {
607+
// Build exclusion set from YAML-defined custom packages. We cannot use
608+
// team.Software.Packages because the API merges fleet-maintained apps
609+
// into that list when fleet_maintained_apps is null. The proposed YAML
610+
// packages are the authoritative set of custom (non-FMA) software.
611+
proposedPkgURLs := make(map[string]bool)
612+
for _, p := range proposedPackages {
609613
u := normalizeSoftwarePath(p.URL)
610614
if u != "" {
611-
customPackageURLs[u] = true
615+
proposedPkgURLs[u] = true
612616
}
613617
}
614618

615-
// Index 1 (strongest): catalog app ID -> catalog entry.
616-
// The API returns fleet_maintained_app_id on software titles that were
617-
// installed via a fleet-maintained app; this is the most reliable join.
618619
catalogByAppID := make(map[uint]api.FleetMaintainedApp)
619-
// Index 2: catalog SoftwareTitleID -> catalog entry.
620620
catalogByTitleID := make(map[uint]api.FleetMaintainedApp)
621-
// Index 3 (weakest): lowercase name|platform -> catalog entries.
622621
catalogByNamePlatform := make(map[string][]api.FleetMaintainedApp)
622+
catalogByPlatform := make(map[string][]api.FleetMaintainedApp)
623623
for _, app := range catalog {
624624
if app.ID != 0 {
625625
catalogByAppID[app.ID] = app
@@ -631,6 +631,10 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
631631
if key != "" {
632632
catalogByNamePlatform[key] = append(catalogByNamePlatform[key], app)
633633
}
634+
plat := normalizeFleetPlatform(app.Platform)
635+
if plat != "" {
636+
catalogByPlatform[plat] = append(catalogByPlatform[plat], app)
637+
}
634638
}
635639

636640
inferred := make(map[string]api.TeamFleetApp)
@@ -643,14 +647,11 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
643647
}
644648

645649
packageURL := normalizeSoftwarePath(title.SoftwarePackage.PackageURL)
646-
if packageURL != "" && customPackageURLs[packageURL] {
650+
if packageURL != "" && proposedPkgURLs[packageURL] {
647651
continue
648652
}
649653

650654
// Strategy 1: match by fleet_maintained_app_id (exact, from API).
651-
// This works across all platforms (macOS source="apps", Windows
652-
// source="programs"/"ps1_packages") so we check it before any
653-
// source-based filtering.
654655
if title.SoftwarePackage.FleetMaintainedAppID != nil {
655656
if app, ok := catalogByAppID[*title.SoftwarePackage.FleetMaintainedAppID]; ok {
656657
slug := normalizeSoftwarePath(app.Slug)
@@ -664,13 +665,6 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
664665
}
665666
}
666667

667-
// Strategies 2 and 3 rely on heuristics that only work reliably for
668-
// macOS "apps" source titles. Skip other sources to avoid false matches
669-
// against custom packages (source="programs", "deb_packages", etc.).
670-
if !strings.EqualFold(strings.TrimSpace(title.Source), "apps") {
671-
continue
672-
}
673-
674668
// Strategy 2: match by SoftwareTitleID -> catalog SoftwareTitleID.
675669
if app, ok := catalogByTitleID[title.ID]; ok {
676670
slug := normalizeSoftwarePath(app.Slug)
@@ -683,9 +677,24 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
683677
}
684678
}
685679

686-
// Strategy 3: match by name|platform (requires exactly 1 match).
680+
// Strategy 3: match by name|platform (requires exactly 1 catalog hit).
681+
// Windows titles often include arch suffixes (e.g., "Notepad++ (64-bit x64)")
682+
// that the catalog omits, so try the raw name first, then stripped.
683+
// The catalog uses short marketing names (e.g., "OBS", "Zoom") while the
684+
// OS reports full product names (e.g., "OBS Studio", "Zoom Workplace"),
685+
// so fall back to prefix matching when exact/stripped matching fails.
687686
key := fleetCatalogKey(title.Name, title.SoftwarePackage.Platform)
688687
matches := catalogByNamePlatform[key]
688+
if len(matches) != 1 {
689+
stripped := stripArchSuffix(title.Name)
690+
if stripped != strings.TrimSpace(strings.ToLower(title.Name)) {
691+
key = fleetCatalogKey(stripped, title.SoftwarePackage.Platform)
692+
matches = catalogByNamePlatform[key]
693+
}
694+
}
695+
if len(matches) != 1 {
696+
matches = catalogPrefixMatch(title.Name, title.SoftwarePackage.Platform, catalogByPlatform)
697+
}
689698
if len(matches) != 1 {
690699
continue
691700
}
@@ -710,6 +719,31 @@ func inferFleetMaintainedApps(team api.Team, catalog []api.FleetMaintainedApp) [
710719
return out
711720
}
712721

722+
// catalogPrefixMatch finds catalog entries whose name is a prefix of the
723+
// title name on the same platform. Returns matches only when exactly one
724+
// catalog entry qualifies (ambiguous results are discarded). This handles
725+
// cases where the catalog uses short marketing names ("OBS", "Zoom") while
726+
// the OS reports full product names ("OBS Studio", "Zoom Workplace").
727+
func catalogPrefixMatch(titleName, titlePlatform string, byPlatform map[string][]api.FleetMaintainedApp) []api.FleetMaintainedApp {
728+
plat := normalizeFleetPlatform(titlePlatform)
729+
candidates := byPlatform[plat]
730+
if len(candidates) == 0 {
731+
return nil
732+
}
733+
norm := strings.TrimSpace(strings.ToLower(stripArchSuffix(titleName)))
734+
if norm == "" {
735+
return nil
736+
}
737+
var hits []api.FleetMaintainedApp
738+
for _, app := range candidates {
739+
catName := strings.TrimSpace(strings.ToLower(app.Name))
740+
if catName != "" && strings.HasPrefix(norm, catName) {
741+
hits = append(hits, app)
742+
}
743+
}
744+
return hits
745+
}
746+
713747
func fleetCatalogKey(name, platform string) string {
714748
name = strings.TrimSpace(strings.ToLower(name))
715749
platform = normalizeFleetPlatform(platform)
@@ -719,6 +753,12 @@ func fleetCatalogKey(name, platform string) string {
719753
return name + "|" + platform
720754
}
721755

756+
var archSuffixRe = regexp.MustCompile(`(?i)\s*\((?:x64|x86|64-bit(?:\s+x64)?|32-bit|arm64|amd64)\)\s*$`)
757+
758+
func stripArchSuffix(name string) string {
759+
return strings.TrimSpace(archSuffixRe.ReplaceAllString(name, ""))
760+
}
761+
722762
func normalizeFleetPlatform(platform string) string {
723763
p := strings.TrimSpace(strings.ToLower(platform))
724764
switch p {

0 commit comments

Comments
 (0)