diff --git a/changes/28108-multiple-custom-packages b/changes/28108-multiple-custom-packages new file mode 100644 index 00000000000..f936944fc16 --- /dev/null +++ b/changes/28108-multiple-custom-packages @@ -0,0 +1 @@ +- Added the ability to upload multiple custom packages (up to 10) for the same software title on a team, so IT admins can deploy different versions or architectures (for example, Arm vs. Intel builds or staged rollouts) to label-scoped hosts instead of splitting them across teams. When a host matches more than one package, the first-added package is installed. diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 4a273f4a17a..f5400a0b819 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -2129,6 +2129,18 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, return nil, err } + // A title with more than one custom package is written to its own package YAML + // file, referenced by a single path entry in the fleet file. + if len(softwareTitle.Packages) > 1 { + _, inSetup := setupSoftwareBySoftwareTitle[softwareTitle.ID] + entry, err := cmd.generateMultiPackage(softwareTitle, sw.Name, teamID, teamFilename, downloadIcons, inSetup) + if err != nil { + return nil, err + } + packages = append(packages, entry) + continue + } + var slug string if softwareTitle.SoftwarePackage != nil { @@ -2346,21 +2358,11 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, } } - var labels []fleet.SoftwareScopeLabel var labelKey string + var labelNames []string if softwareTitle.SoftwarePackage != nil { - if len(softwareTitle.SoftwarePackage.LabelsIncludeAny) > 0 { - labels = softwareTitle.SoftwarePackage.LabelsIncludeAny - labelKey = "labels_include_any" - } - if len(softwareTitle.SoftwarePackage.LabelsExcludeAny) > 0 { - labels = softwareTitle.SoftwarePackage.LabelsExcludeAny - labelKey = "labels_exclude_any" - } - if len(softwareTitle.SoftwarePackage.LabelsIncludeAll) > 0 { - labels = softwareTitle.SoftwarePackage.LabelsIncludeAll - labelKey = "labels_include_all" - } + sp := softwareTitle.SoftwarePackage + labelKey, labelNames = scopeLabels(sp.LabelsIncludeAny, sp.LabelsExcludeAny, sp.LabelsIncludeAll) if _, exists := setupSoftwareBySoftwareTitle[softwareTitle.ID]; exists { softwareSpec["setup_experience"] = true } @@ -2368,30 +2370,14 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, softwareSpec["setup_experience_platform"] = strings.Join(crosses, ",") } } else { - platformAndAppID := softwareTitle.AppStoreApp.VPPAppID.String() - - if len(softwareTitle.AppStoreApp.LabelsIncludeAny) > 0 { - labels = softwareTitle.AppStoreApp.LabelsIncludeAny - labelKey = "labels_include_any" - } - if len(softwareTitle.AppStoreApp.LabelsExcludeAny) > 0 { - labels = softwareTitle.AppStoreApp.LabelsExcludeAny - labelKey = "labels_exclude_any" - } - if len(softwareTitle.AppStoreApp.LabelsIncludeAll) > 0 { - labels = softwareTitle.AppStoreApp.LabelsIncludeAll - labelKey = "labels_include_all" - } - if _, exists := setupSoftwareByPlatformAndAppID[platformAndAppID]; exists { + app := softwareTitle.AppStoreApp + labelKey, labelNames = scopeLabels(app.LabelsIncludeAny, app.LabelsExcludeAny, app.LabelsIncludeAll) + if _, exists := setupSoftwareByPlatformAndAppID[app.VPPAppID.String()]; exists { softwareSpec["setup_experience"] = true } } - if len(labels) > 0 { - labelsList := make([]string, len(labels)) - for i, label := range labels { - labelsList[i] = label.LabelName - } - softwareSpec[labelKey] = labelsList + if labelKey != "" { + softwareSpec[labelKey] = labelNames } switch { @@ -2422,6 +2408,87 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, return result, nil } +func (cmd *GenerateGitopsCommand) generateMultiPackage(title *fleet.SoftwareTitle, swName string, teamID uint, teamFilename string, downloadIcons bool, inSetup bool) (map[string]any, error) { + // Paths inside the package YAML file are resolved relative to that file, which + // lives in lib//software, so a sibling dir is reached with ..//. + writeSideFile := func(dir string, name string, contents any) string { + cmd.FilesToWrite[fmt.Sprintf("lib/%s/%s/%s", teamFilename, dir, name)] = contents + return fmt.Sprintf("../%s/%s", dir, name) + } + + items := make([]map[string]any, 0, len(title.Packages)) + for i, pkg := range title.Packages { + prefix := fmt.Sprintf("%s-%s-%d", generateFilename(swName), pkg.Platform, i+1) + item := map[string]any{"hash_sha256": pkg.StorageID} + if pkg.URL != "" { + item["url"] = pkg.URL + } + if pkg.SelfService { + item["self_service"] = true + } + if len(pkg.Categories) > 0 { + item["categories"] = pkg.Categories + } + if pkg.InstallScript != "" { + item["install_script"] = map[string]any{"path": writeSideFile("scripts", prefix+"-install", pkg.InstallScript)} + } + if pkg.PostInstallScript != "" { + item["post_install_script"] = map[string]any{"path": writeSideFile("scripts", prefix+"-postinstall", pkg.PostInstallScript)} + } + if pkg.UninstallScript != "" { + item["uninstall_script"] = map[string]any{"path": writeSideFile("scripts", prefix+"-uninstall", pkg.UninstallScript)} + } + if pkg.PreInstallQuery != "" { + item["pre_install_query"] = map[string]any{"path": writeSideFile("queries", prefix+"-preinstallquery.yml", []map[string]any{{"query": pkg.PreInstallQuery}})} + } + if key, names := scopeLabels(pkg.LabelsIncludeAny, pkg.LabelsExcludeAny, pkg.LabelsIncludeAll); key != "" { + item[key] = names + } + items = append(items, item) + } + + // The icon is per-title, so emit it once on the first package. + if downloadIcons && title.IconUrl != nil && strings.HasPrefix(*title.IconUrl, "/api") && len(items) > 0 { + icon, err := cmd.Client.GetSoftwareTitleIcon(title.ID, teamID) + if err != nil { + return nil, err + } + items[0]["icon"] = map[string]any{"path": writeSideFile("icons", generateFilename(swName)+"-icon.png", icon)} + } + + // The fleet-level entry points at the package file relative to the fleet file. + packageFile := fmt.Sprintf("lib/%s/software/%s.package.yml", teamFilename, generateFilename(swName)) + cmd.FilesToWrite[packageFile] = items + entry := map[string]any{"path": "../" + packageFile} + if inSetup { + entry["setup_experience"] = true + } + if title.DisplayName != "" { + entry["display_name"] = title.DisplayName + } + return entry, nil +} + +func scopeLabels(includeAny []fleet.SoftwareScopeLabel, excludeAny []fleet.SoftwareScopeLabel, includeAll []fleet.SoftwareScopeLabel) (string, []string) { + var labels []fleet.SoftwareScopeLabel + var key string + switch { + case len(includeAny) > 0: + labels, key = includeAny, "labels_include_any" + case len(excludeAny) > 0: + labels, key = excludeAny, "labels_exclude_any" + case len(includeAll) > 0: + labels, key = includeAll, "labels_include_all" + default: + return "", nil + } + names := make([]string, len(labels)) + for i, l := range labels { + names[i] = l.LabelName + } + return key, names +} + func (cmd *GenerateGitopsCommand) generateLabels(team *fleet.Team) ([]map[string]any, error) { var tmID uint // default to 0 for pulling global-only labels if team != nil { diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index d7ab167d1e3..590b3c6499d 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -2121,6 +2121,117 @@ func (c *MockClientWithScriptPackage) GetSetupExperienceSoftware(platform string return c.MockClient.GetSetupExperienceSoftware(platform, teamID) } +const ( + santaHashA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + santaHashB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +type MockClientMultiPackage struct { + MockClient +} + +func (c *MockClientMultiPackage) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error) { + if query == "available_for_install=1&fleet_id=2" { + return []fleet.SoftwareTitleListResult{{ + ID: 7, + Name: "Santa", + HashSHA256: new(santaHashA), + SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "santa-2026.2.pkg", Platform: "darwin", Version: "2026.2"}, + }}, nil + } + return c.MockClient.ListSoftwareTitles(query) +} + +func (c *MockClientMultiPackage) GetSoftwareTitleByID(id uint, teamID *uint) (*fleet.SoftwareTitle, error) { + if id == 7 { + return &fleet.SoftwareTitle{ + ID: 7, + Name: "Santa", + DisplayName: "Santa Security", + Packages: []fleet.SoftwareInstaller{ + { + StorageID: santaHashA, + URL: "https://example.com/santa-2026.2.pkg", + Platform: "darwin", + InstallScript: "install A", + PostInstallScript: "post A", + SelfService: true, + LabelsIncludeAll: []fleet.SoftwareScopeLabel{{LabelName: "macOS"}}, + }, + { + StorageID: santaHashB, + URL: "https://example.com/santa-2026.4.pkg", + Platform: "darwin", + InstallScript: "install B", + SelfService: true, + Categories: []string{"Productivity"}, + LabelsIncludeAll: []fleet.SoftwareScopeLabel{{LabelName: "macOS"}, {LabelName: "IT test team"}}, + }, + }, + }, nil + } + return c.MockClient.GetSoftwareTitleByID(id, teamID) +} + +func (c *MockClientMultiPackage) GetSetupExperienceSoftware(platform string, teamID uint) ([]fleet.SoftwareTitleListResult, error) { + if teamID == 2 { + return []fleet.SoftwareTitleListResult{}, nil + } + return c.MockClient.GetSetupExperienceSoftware(platform, teamID) +} + +func TestGenerateSoftwareMultiplePackages(t *testing.T) { + fleetClient := &MockClientMultiPackage{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(cli.NewApp(), nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]any), + AppConfig: appConfig, + SoftwareList: make(map[uint]Software), + } + + res, err := cmd.generateSoftware("fleets/team-a.yml", 2, "team-a", false) + require.NoError(t, err) + + // the fleet file references the title's packages by a single path entry + packages := res["packages"].([]map[string]any) + require.Len(t, packages, 1) + require.Equal(t, "Santa Security", packages[0]["display_name"]) + + // that path points at a package YAML file holding a two-item list, first-added + // first, with the per-package fields inline + listPath := strings.TrimPrefix(packages[0]["path"].(string), "../") + list := cmd.FilesToWrite[listPath].([]map[string]any) + require.Len(t, list, 2) + + require.Equal(t, santaHashA, list[0]["hash_sha256"]) + require.Equal(t, "https://example.com/santa-2026.2.pkg", list[0]["url"]) + require.Equal(t, true, list[0]["self_service"]) + require.Equal(t, []string{"macOS"}, list[0]["labels_include_all"]) + require.NotContains(t, list[0], "categories") + + require.Equal(t, santaHashB, list[1]["hash_sha256"]) + require.Equal(t, []string{"Productivity"}, list[1]["categories"]) + require.Equal(t, []string{"macOS", "IT test team"}, list[1]["labels_include_all"]) + + // each package's install script is written to its own file, referenced by a path + // relative to the package YAML file's directory + pkgDir := filepath.Dir(listPath) + installA := filepath.Join(pkgDir, list[0]["install_script"].(map[string]any)["path"].(string)) + installB := filepath.Join(pkgDir, list[1]["install_script"].(map[string]any)["path"].(string)) + require.Equal(t, "install A", cmd.FilesToWrite[installA]) + require.Equal(t, "install B", cmd.FilesToWrite[installB]) + require.NotEqual(t, installA, installB) + + // post_install_script is written per package as its own side file + postA := filepath.Join(pkgDir, list[0]["post_install_script"].(map[string]any)["path"].(string)) + require.Equal(t, "post A", cmd.FilesToWrite[postA]) + require.NotContains(t, list[1], "post_install_script") +} + func TestGeneratePolicies(t *testing.T) { // Get the test app config. fleetClient := &MockClient{} diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index 2c621d93daa..00e6eccd936 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -1021,6 +1021,7 @@ spec: id: 0 name: foo software_package: null + packages: null source: chrome_extensions extension_for: chrome display_name: "" @@ -1046,6 +1047,7 @@ spec: id: 0 name: bar software_package: null + packages: null source: deb_packages extension_for: "" display_name: "" @@ -1097,6 +1099,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null }, { @@ -1117,6 +1120,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null } ] diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index dd0f03c8b57..ce2abbd6307 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -269,6 +269,13 @@ func StartSoftwareInstallerServer(t *testing.T) { // serve same content as ruby.deb w.Header().Set("Content-Type", "application/vnd.debian.binary-package") _, _ = w.Write(b) + case strings.Contains(r.URL.Path, "ruby_variant.deb"): + // ruby.deb with extra trailing bytes: same package (the deb archive + // ignores trailing bytes) but a different hash, so it is a distinct + // package of the same title. + w.Header().Set("Content-Type", "application/vnd.debian.binary-package") + _, _ = w.Write(b) + _, _ = w.Write([]byte("\n# variant\n")) case strings.HasSuffix(r.URL.Path, ".pkg"): pkgDir := getPathRelative("../testdata/gitops/lib/") http.ServeFile(w, r, filepath.Join(pkgDir, filepath.Base(r.URL.Path))) diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go index 19a3a0a42e9..a169654ecd5 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go @@ -5810,3 +5810,160 @@ labels: realRunOutput := fleetctltest.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}) require.Contains(t, realRunOutput, "gitops succeeded") } + +func (s *enterpriseIntegrationGitopsTestSuite) TestMultiplePackagesRoundTrip() { + t := s.T() + ctx := context.Background() + testing_utils.StartSoftwareInstallerServer(t) + + user := s.createGitOpsUser(t) + fleetctlConfig := s.createFleetctlConfig(t, user) + + const teamName = "roundtrip_multi" + + // Apply a team holding two custom packages of the same title (ruby, with + // different content per architecture). + // absolute path to a valid PNG fixture for the package icon + _, currentFile, _, ok := runtime.Caller(0) + require.True(t, ok) + iconPath, err := filepath.Abs(filepath.Join(filepath.Dir(currentFile), "../../fleetctl/testdata/gitops/lib/icon.png")) + require.NoError(t, err) + + // gitops validates that referenced labels exist, so create one to scope a package to + _, err = s.DS.NewLabel(ctx, &fleet.Label{Name: "roundtrip_multi_label", Query: "SELECT 1"}) + require.NoError(t, err) + + applyDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(applyDir, "fleets"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(applyDir, "queries"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(applyDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(applyDir, "queries", "ruby-preinstall.yml"), []byte("- query: SELECT 1 FROM osquery_info\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(applyDir, "scripts", "ruby-postinstall.sh"), []byte("#!/bin/sh\necho postinstall\n"), 0o644)) + teamFile := filepath.Join(applyDir, "fleets", "team.yml") + require.NoError(t, os.WriteFile(teamFile, fmt.Appendf(nil, ` +name: %s +team_settings: + secrets: + - secret: roundtrip_secret +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + self_service: true + pre_install_query: + path: ../queries/ruby-preinstall.yml + labels_include_all: + - roundtrip_multi_label + icon: + path: %s + - url: ${SOFTWARE_INSTALLER_URL}/ruby_variant.deb + categories: + - "🔐 Security" + post_install_script: + path: ../scripts/ruby-postinstall.sh +`, teamName, iconPath), 0o644)) + + _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", teamFile}) + require.NoError(t, err) + + team, err := s.DS.TeamByName(ctx, teamName) + require.NoError(t, err) + + // find the title that ended up with both packages + titleID := uint(0) + titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin}) + require.NoError(t, err) + for _, tl := range titles { + p, err := s.DS.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, tl.ID) + require.NoError(t, err) + if len(p) == 2 { + titleID = tl.ID + } + } + require.NotZero(t, titleID, "the two packages should resolve to one title") + + // assertPackages checks the per-package fields that ride through apply and re-apply: + // ruby.deb keeps self_service, a pre-install query, and a scope label; ruby_variant.deb + // keeps a category and a post-install script. + assertPackages := func(pkgs []*fleet.SoftwareInstaller) { + require.True(t, pkgs[0].SelfService) + require.False(t, pkgs[1].SelfService) + require.Equal(t, "SELECT 1 FROM osquery_info", pkgs[0].PreInstallQuery) + require.Empty(t, pkgs[1].PreInstallQuery) + require.Len(t, pkgs[0].LabelsIncludeAll, 1) + require.Equal(t, "roundtrip_multi_label", pkgs[0].LabelsIncludeAll[0].LabelName) + require.Equal(t, "#!/bin/sh\necho postinstall\n", pkgs[1].PostInstallScript) + cats, err := s.DS.GetCategoriesForSoftwareInstallers(ctx, []uint{pkgs[1].InstallerID}) + require.NoError(t, err) + require.Equal(t, []string{"🔐 Security"}, cats[pkgs[1].InstallerID]) + // the icon is title-level, so it is fetched once from the title metadata + meta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, titleID, false) + require.NoError(t, err) + require.NotNil(t, meta.IconUrl) + } + + // first-added first + pkgs, err := s.DS.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, titleID) + require.NoError(t, err) + require.Len(t, pkgs, 2) + firstID, secondID := pkgs[0].InstallerID, pkgs[1].InstallerID + assertPackages(pkgs) + + // generate the team's config back out (needs an admin to read config), with real + // secrets so it re-applies cleanly + admin := fleet.User{ + Name: "Admin " + uuid.NewString(), + Email: uuid.NewString() + "@example.com", + GlobalRole: new(fleet.RoleAdmin), + } + require.NoError(t, admin.SetPassword(test.GoodPassword, 10, 10)) + _, err = s.DS.NewUser(ctx, &admin) + require.NoError(t, err) + adminConfig := s.createFleetctlConfig(t, admin) + + genDir := t.TempDir() + _, err = fleetctltest.RunAppNoChecks([]string{"generate-gitops", "--config", adminConfig.Name(), "--fleet", teamName, "--dir", genDir, "--insecure"}) + require.NoError(t, err) + + genTeamFiles, err := filepath.Glob(filepath.Join(genDir, "fleets", "*.yml")) + require.NoError(t, err) + require.Len(t, genTeamFiles, 1) + + // re-applying the generated output keeps the same two packages, ids, and per-package fields + _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", genTeamFiles[0]}) + require.NoError(t, err) + + pkgs, err = s.DS.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, titleID) + require.NoError(t, err) + require.Len(t, pkgs, 2) + require.Equal(t, firstID, pkgs[0].InstallerID) + require.Equal(t, secondID, pkgs[1].InstallerID) + assertPackages(pkgs) + + // generating again from the re-applied state yields byte-identical files: the + // round-trip is stable, so re-applying the generated config produced no diff + genDir2 := t.TempDir() + _, err = fleetctltest.RunAppNoChecks([]string{"generate-gitops", "--config", adminConfig.Name(), "--fleet", teamName, "--dir", genDir2, "--insecure"}) + require.NoError(t, err) + + readTree := func(dir string) map[string]string { + files := map[string]string{} + require.NoError(t, filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + require.NoError(t, err) + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, p) + require.NoError(t, err) + b, err := os.ReadFile(p) //nolint:gosec // reading generated files under t.TempDir() + require.NoError(t, err) + files[rel] = string(b) + return nil + })) + return files + } + require.Equal(t, readTree(genDir), readTree(genDir2)) +} diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index f3b3edfd474..e56e928e439 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -225,15 +225,16 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return addedInstaller, nil } - addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, true) + // Return the package just added, not the title's first-added one. + addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, installerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting added software installer") } - if payload.AutomaticInstall { + if payload.AutomaticInstall && payload.AddedAutomaticInstallPolicy != nil { policyAct := fleet.ActivityTypeCreatedPolicy{ - ID: addedInstaller.AutomaticInstallPolicies[0].ID, - Name: addedInstaller.AutomaticInstallPolicies[0].Name, + ID: payload.AddedAutomaticInstallPolicy.ID, + Name: payload.AddedAutomaticInstallPolicy.Name, } if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), policyAct); err != nil { @@ -434,18 +435,51 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software) } - // TODO when we start supporting multiple installers per title X team, need to rework how we determine installer to edit - if software.SoftwareInstallersCount != 1 { + if software.SoftwareInstallersCount < 1 { return nil, &fleet.BadRequestError{ Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.", } } + // Defaults to the first-added package; a specific installer_id overrides it below. existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting existing installer") } + // siblings is reused for both installer targeting and the hash-collision check below. + var siblings []*fleet.SoftwareInstaller + if software.SoftwareInstallersCount > 1 || payload.InstallerID != 0 { + siblings, err = svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting title packages") + } + + switch { + case payload.InstallerID == 0 && software.SoftwareInstallersCount > 1: + return nil, &fleet.BadRequestError{ + Message: "installer_id is required when the title has multiple packages.", + } + case payload.InstallerID != 0: + var found bool + for _, p := range siblings { + if p.InstallerID == payload.InstallerID { + found = true + break + } + } + if !found { + return nil, ctxerr.Wrapf(ctx, ¬FoundError{}, + "installer %d does not belong to this title and team", payload.InstallerID) + } + // hydrate the targeted package the same way as the first-added default + existingInstaller, err = svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx, payload.TeamID, payload.TitleID, payload.InstallerID, true) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting targeted installer") + } + } + } + if payload.IsNoopPayload(software) { return existingInstaller, nil // no payload, noop } @@ -507,21 +541,67 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return nil, ctxerr.Wrap(ctx, err, "extracting updated installer metadata") } + // Fleet-maintained apps can't have their package replaced; return the FMA message before the + // extension and identity checks so it isn't masked by a more generic error. + if existingInstaller.FleetMaintainedAppID != nil { + return nil, &fleet.BadRequestError{ + Message: "Couldn't update. The package can't be changed for Fleet-maintained apps.", + InternalErr: ctxerr.New(ctx, "installer file changed for fleet maintained app installer"), + } + } + if newInstallerExtension != existingInstaller.Extension { return nil, &fleet.BadRequestError{ Message: "The selected package is for a different file type.", - InternalErr: ctxerr.Wrap(ctx, err, "installer extension mismatch"), + InternalErr: ctxerr.New(ctx, "installer extension mismatch"), } } - if payloadForNewInstallerFile.Title != software.Name { - return nil, &fleet.BadRequestError{ - Message: "The selected package is for different software.", - InternalErr: ctxerr.Wrap(ctx, err, "installer software title mismatch"), + // The replacement must be the same software as the installer being edited. Its extracted + // identity (bundle id for apps, upgrade code for Windows, else name) must point at this title. + // A bare name match is accepted only when no title claims the identity — so a re-keyed MSI or + // re-bundled pkg still edits — while another app's package, which resolves to a different title, + // is rejected even when the names coincide. A package that changes both its name and its + // upgrade code at once can't be tied to the edited installer, so it is rejected (as before). + switch { + case payloadForNewInstallerFile.UpgradeCode != "" && payloadForNewInstallerFile.UpgradeCode == existingInstaller.UpgradeCode: + // Same Windows product as the edited installer (a sibling MSI whose upgrade code can differ + // from the title's); trusted fast path, skip the title lookup entirely. + default: + resolvedTitleID, err := svc.ds.GetExistingSoftwareInstallerTitleID(ctx, payloadForNewInstallerFile) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "resolving title for updated installer") + } + switch { + case err == nil && resolvedTitleID == payload.TitleID: + // Identity resolves to this title. + case err == nil && (payloadForNewInstallerFile.BundleIdentifier != "" || payloadForNewInstallerFile.UpgradeCode != ""): + // A strong identifier (bundle id / upgrade code) resolves to a different existing title: + // different software, even if the names coincide. Name-only matches are excluded here + // because the resolver's name branch can match multiple same-named titles ambiguously. + return nil, &fleet.BadRequestError{ + Message: "The selected package is for different software.", + InternalErr: ctxerr.Errorf(ctx, "installer resolves to title %d, editing title %d", resolvedTitleID, payload.TitleID), + } + case payloadForNewInstallerFile.Title != software.Name: + // No authoritative identity claims this package and the name does not match either. + return nil, &fleet.BadRequestError{ + Message: "The selected package is for different software.", + InternalErr: ctxerr.New(ctx, "installer identity not found and name mismatch"), + } } } if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID { + // Catch a sibling hash match for a friendly 409; the dedup_token key would otherwise raise a raw 1062. + for _, p := range siblings { + if p.InstallerID != existingInstaller.InstallerID && p.StorageID == payloadForNewInstallerFile.StorageID { + return nil, ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payloadForNewInstallerFile.Filename), + }, "edit collides with sibling package hash") + } + } + activity.SoftwarePackage = &payload.Filename payload.StorageID = payloadForNewInstallerFile.StorageID payload.Filename = payloadForNewInstallerFile.Filename @@ -543,13 +623,6 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. payloadForNewInstallerFile = nil payload.InstallerFile = nil } - - if existingInstaller.FleetMaintainedAppID != nil { - return nil, &fleet.BadRequestError{ - Message: "Couldn't update. The package can't be changed for Fleet-maintained apps.", - InternalErr: ctxerr.Wrap(ctx, err, "installer file changed for fleet maintained app installer"), - } - } } if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload @@ -748,6 +821,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return nil, ctxerr.Wrap(ctx, err, "processing side effects for version pin") } } + + // the pinned version is now the active installer; return it, not the one we pinned away from + payload.InstallerID = activeInstallerID default: if payloadForNewInstallerFile != nil { if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil { @@ -847,8 +923,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } } - // re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later - updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, true) + // re-pull the edited installer to reflect side effects; return that specific + // package, not the title's first-added one. May be able to optimize this out later. + updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, payload.InstallerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata") } @@ -956,7 +1033,7 @@ func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, exi return false, nil, nil } -func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { if teamID == nil { return fleet.NewInvalidArgumentError("fleet_id", "is required") } @@ -967,7 +1044,7 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return err } - // first, look for a software installer + // metaInstaller is fully hydrated (incl. the title-level icon) which the per-package reads below lack. metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) @@ -981,9 +1058,40 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata") } + // An installer id always refers to a software installer, never a VPP or in-house app. + if installerID != nil { + if metaInstaller == nil { + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages") + } + for _, pkg := range pkgs { + if pkg.InstallerID == *installerID { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + return svc.deleteSoftwareInstaller(ctx, pkg) + } + } + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + switch { case metaInstaller != nil: - return svc.deleteSoftwareInstaller(ctx, metaInstaller) + // Delete every package on the title. FMA titles keep one active row, so this + // matches prior behavior for them. Per-package deletes mean a guarded package + // (setup experience / patch policy) fails the title delete partway. + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages to delete") + } + for _, pkg := range pkgs { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + if err := svc.deleteSoftwareInstaller(ctx, pkg); err != nil { + return err + } + } + return nil case metaVPP != nil: return svc.deleteVPPApp(ctx, teamID, metaVPP) case metaInHouse != nil: @@ -1162,7 +1270,7 @@ func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, skipAuthz return meta, nil } -func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) { +func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint, installerID *uint) (string, error) { downloadRequested := alt == "media" if !downloadRequested { svc.authz.SkipAuthorization(ctx) @@ -1182,6 +1290,11 @@ func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt stri TitleID: titleID, TeamID: *teamID, } + // Nil installerID means "no per-package pin" — the token consumer falls + // back to the first-added package. Preserves single-package back-compat. + if installerID != nil { + meta.InstallerID = *installerID + } metaByte, err := json.Marshal(meta) if err != nil { return "", ctxerr.Wrap(ctx, err, "marshaling software installer metadata") @@ -1235,7 +1348,7 @@ func (svc *Service) GetSoftwareInstallerTokenMetadata(ctx context.Context, token } func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, skipAuthz bool, alt string, titleID uint, - teamID *uint, + teamID *uint, installerID *uint, ) (*fleet.DownloadSoftwareInstallerPayload, error) { downloadRequested := alt == "media" if !downloadRequested { @@ -1248,9 +1361,28 @@ func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, skipAuthz boo return nil, fleet.NewInvalidArgumentError("fleet_id", "is required") } - meta, err := svc.GetSoftwareInstallerMetadata(ctx, skipAuthz, titleID, teamID) - if err != nil { - return nil, err + // When installerID is set, target the specific package on a multi-package + // title. Nil falls back to the first-added default (single-package titles + // and pre-multi-package callers). + var meta *fleet.SoftwareInstaller + var err error + if installerID != nil { + if !skipAuthz { + if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, err + } + } + // withScriptContents=false: only StorageID and Name are used below, so + // skip the script_contents join. + meta, err = svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx, teamID, titleID, *installerID, false) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting pinned software installer metadata") + } + } else { + meta, err = svc.GetSoftwareInstallerMetadata(ctx, skipAuthz, titleID, teamID) + if err != nil { + return nil, err + } } return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name) @@ -1366,6 +1498,60 @@ func (svc *Service) getSoftwareInstallerBinary(ctx context.Context, storageID st }, nil } +// resolveFirstAddedInScopeInstaller returns the first-added (smallest installer_id) package of the +// title that host is in label scope for — and, when requireSelfService is set, that is self-service +// enabled. This is the install-time precedence rule now that a title can hold multiple packages: +// admins scope labels to avoid overlap, but when a host still matches more than one package Fleet +// installs the first-added one. anyPackages reports whether the title has any active packages at all, +// so callers can distinguish "no package for this title" (fall through to VPP/in-house) from +// "packages exist but none is a match for this host" (reject). +func (svc *Service) resolveFirstAddedInScopeInstaller(ctx context.Context, host *fleet.Host, titleID uint, requireSelfService bool) (installer *fleet.SoftwareInstaller, anyPackages bool, err error) { + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, host.TeamID, titleID) + if err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "listing packages for install precedence") + } + anyPackages = len(pkgs) > 0 + + // pkgs are ordered installer_id ASC (first-added first). Prefer the first-added package the host + // can actually install (in scope and platform-compatible). Keep the first-added in-scope package + // of any platform as a fallback so a cross-platform title with no compatible package still hits + // the downstream platform error (matching single-package behavior). + var fallback *fleet.SoftwareInstaller + for _, pkg := range pkgs { + if requireSelfService && !pkg.SelfService { + continue + } + scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, pkg.InstallerID, host.ID) + if err != nil { + return nil, anyPackages, ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt") + } + if !scoped { + continue + } + if fallback == nil { + fallback = pkg + } + if installerCompatibleWithHost(pkg, host) { + return pkg, anyPackages, nil + } + } + + return fallback, anyPackages, nil +} + +// installerCompatibleWithHost reports whether the installer's package can run on the host's platform. +// Mirrors the platform gate in installSoftwareTitleUsingInstaller (.sh runs on any unix-like host). +func installerCompatibleWithHost(installer *fleet.SoftwareInstaller, host *fleet.Host) bool { + ext, requiredPlatform := installerRequiredPlatform(installer) + if requiredPlatform == "" { + return false + } + if host.FleetPlatform() == requiredPlatform { + return true + } + return ext == ".sh" && fleet.IsUnixLike(host.Platform) +} + func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error { // we need to use ds.Host because ds.HostLite doesn't return the orbit // node key @@ -1434,28 +1620,22 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw } if !mobileAppleDevice { - installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) + // Resolve the first-added package the host is in label scope for (first-added-wins when the + // host matches more than one package of the title). + installer, anyPackages, err := svc.resolveFirstAddedInScopeInstaller(ctx, host, softwareTitleID, false) if err != nil { - if !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "finding software installer for title") - } - installer = nil + return err } - // if we found an installer, use that - if installer != nil { - // check the label scoping for this installer and host - scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, hostID) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt") - } - - if !scoped { - return &fleet.BadRequestError{ - Message: "Couldn't install. Host isn't member of the labels defined for this software title.", - } + // The title has packages but the host isn't in scope for any of them. + if installer == nil && anyPackages { + return &fleet.BadRequestError{ + Message: "Couldn't install. Host isn't member of the labels defined for this software title.", } + } + // if we resolved an installer, use that + if installer != nil { lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID) if err != nil { return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID) @@ -3696,16 +3876,22 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f // self-service. The downstream VPP install flow handles user-scoped // licensing via clientUserIds. End-to-end success still depends on the // main install-gate removal landing (#31138 subtask 01). - installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false) + // Resolve the first-added self-service package the host is in label scope for (first-added-wins + // when the host matches more than one self-service package of the title). + installer, anyPackages, err := svc.resolveFirstAddedInScopeInstaller(ctx, host, softwareTitleID, true) if err != nil { - if !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "finding software installer for title") - } - installer = nil + return err } - if installer != nil { - if !installer.SelfService { + if installer == nil && anyPackages { + // The title has packages but none is available to this host through self-service. Distinguish + // "in scope but not self-service enabled" from "not a label member" to preserve the existing + // error messages. + inScopeInstaller, _, err := svc.resolveFirstAddedInScopeInstaller(ctx, host, softwareTitleID, false) + if err != nil { + return err + } + if inScopeInstaller != nil { return &fleet.BadRequestError{ Message: "Software title is not available through self-service", InternalErr: ctxerr.NewWithData( @@ -3714,18 +3900,12 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f ), } } - - scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, host.ID) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt") - } - - if !scoped { - return &fleet.BadRequestError{ - Message: "Couldn't install. Host isn't member of the labels defined for this software title.", - } + return &fleet.BadRequestError{ + Message: "Couldn't install. Host isn't member of the labels defined for this software title.", } + } + if installer != nil { ext, requiredPlatform := installerRequiredPlatform(installer) if requiredPlatform == "" { // this should never happen diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 365b5261b35..88383c9bf0a 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "crypto/rand" "crypto/rsa" @@ -23,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/s3" "github.com/fleetdm/fleet/v4/server/dev_mode" @@ -30,6 +32,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mock" redismock "github.com/fleetdm/fleet/v4/server/mock/redis" svcmock "github.com/fleetdm/fleet/v4/server/mock/service" + mocksoftware "github.com/fleetdm/fleet/v4/server/mock/software" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -261,6 +264,7 @@ func TestInstallUninstallAuth(t *testing.T) { TeamID: ptr.Uint(1), }, nil } + mockSoftwarePackagesFromMetadata(ds) ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) { return nil, nil } @@ -727,6 +731,19 @@ func newTestService(t *testing.T, ds fleet.Datastore) *Service { return svc } +// mockSoftwarePackagesFromMetadata wires GetSoftwarePackagesByTeamAndTitleID (used by the install +// precedence resolver) to return the single installer that GetSoftwareInstallerMetadataByTeamAndTitleID +// yields, so install-path unit tests keep their installer defined in one place. +func mockSoftwarePackagesFromMetadata(ds *mock.Store) { + ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + si, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) + if err != nil { + return nil, err + } + return []*fleet.SoftwareInstaller{si}, nil + } +} + func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmock.Service) { t.Helper() authorizer, err := authz.NewAuthorizer() @@ -741,6 +758,417 @@ func newTestServiceWithMock(t *testing.T, ds fleet.Datastore) (*Service, *svcmoc return svc, baseSvc } +func TestUpdateSoftwareInstallerMatchesSoftwareIdentity(t *testing.T) { + const ( + titleID = uint(42) + targetInstallerID = uint(2) + ) + + type testState struct { + svc *Service + ds *mock.Store + ctx context.Context + installer *fleet.SoftwareInstaller + teamID uint + } + + setup := func(t *testing.T, storedTitleName, filename, extension, platform, storageID string, packageIDs []string, multiplePackages bool) testState { + t.Helper() + ds := new(mock.Store) + svc, baseSvc := newTestServiceWithMock(t, ds) + teamID := uint(0) + installer := &fleet.SoftwareInstaller{ + TeamID: &teamID, + TitleID: new(titleID), + Name: filename, + Extension: extension, + Version: "0.9.0", + Platform: platform, + PackageIDList: strings.Join(packageIDs, ","), + InstallerID: targetInstallerID, + InstallScript: "install", + UninstallScript: "uninstall", + StorageID: storageID, + SoftwareTitle: storedTitleName, + } + + installerCount := 1 + firstInstaller := installer + if multiplePackages { + installerCount = 2 + firstInstaller = &fleet.SoftwareInstaller{ + TeamID: &teamID, + TitleID: new(titleID), + Name: filename, + Extension: extension, + Platform: platform, + InstallerID: 1, + StorageID: "first-installer-storage-id", + SoftwareTitle: storedTitleName, + } + ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, gotTeamID *uint, gotTitleID uint) ([]*fleet.SoftwareInstaller, error) { + require.Equal(t, &teamID, gotTeamID) + require.Equal(t, titleID, gotTitleID) + return []*fleet.SoftwareInstaller{firstInstaller, installer}, nil + } + } + + ds.ValidateEmbeddedSecretsFunc = func(context.Context, []string) error { return nil } + ds.SoftwareTitleByIDFunc = func(ctx context.Context, gotTitleID uint, gotTeamID *uint, _ fleet.TeamFilter) (*fleet.SoftwareTitle, error) { + require.Equal(t, titleID, gotTitleID) + require.Equal(t, &teamID, gotTeamID) + return &fleet.SoftwareTitle{ + ID: titleID, + Name: storedTitleName, + SoftwareInstallersCount: installerCount, + }, nil + } + ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, gotTeamID *uint, gotTitleID uint, withScripts bool) (*fleet.SoftwareInstaller, error) { + require.Equal(t, &teamID, gotTeamID) + require.Equal(t, titleID, gotTitleID) + require.True(t, withScripts) + return firstInstaller, nil + } + ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc = func(ctx context.Context, gotTeamID *uint, gotTitleID, gotInstallerID uint, withScripts bool) (*fleet.SoftwareInstaller, error) { + require.Equal(t, &teamID, gotTeamID) + require.Equal(t, titleID, gotTitleID) + require.Equal(t, targetInstallerID, gotInstallerID) + require.True(t, withScripts) + return installer, nil + } + ds.SaveInstallerUpdatesFunc = func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error { + require.Equal(t, targetInstallerID, payload.InstallerID) + installer.Name = payload.Filename + installer.Version = payload.Version + installer.PackageIDList = strings.Join(payload.PackageIDs, ",") + installer.UpgradeCode = payload.UpgradeCode + installer.StorageID = payload.StorageID + return nil + } + ds.ProcessInstallerUpdateSideEffectsFunc = func(ctx context.Context, installerID uint, metadataUpdated, packageUpdated bool) error { + require.Equal(t, targetInstallerID, installerID) + require.True(t, metadataUpdated) + require.True(t, packageUpdated) + return nil + } + ds.GetSummaryHostSoftwareInstallsFunc = func(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerStatusSummary, error) { + require.Equal(t, targetInstallerID, installerID) + return nil, nil + } + baseSvc.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails) error { return nil } + + store := &mocksoftware.SoftwareInstallerStore{ + ExistsFunc: func(context.Context, string) (bool, error) { return false, nil }, + PutFunc: func(context.Context, string, io.ReadSeeker) error { return nil }, + } + svc.softwareInstallStore = store + + ctx := authz_ctx.NewContext(t.Context(), &authz_ctx.AuthorizationContext{}) + ctx = viewer.NewContext(ctx, viewer.Viewer{ + User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}, + }) + return testState{svc: svc, ds: ds, ctx: ctx, installer: installer, teamID: teamID} + } + + readInstaller := func(t *testing.T, path string) ([]byte, string) { + t.Helper() + contents, err := os.ReadFile(path) + require.NoError(t, err) + sum := sha256.Sum256(contents) + return contents, hex.EncodeToString(sum[:]) + } + + newReplacement := func(t *testing.T, contents []byte) *fleet.TempFileReader { + t.Helper() + // XAR and MSI readers ignore trailing data, giving this test a distinct package hash + // while preserving the installer's extracted software identity. + replacement := append(bytes.Clone(contents), '\n') + tfr, err := fleet.NewTempFileReader(bytes.NewReader(replacement), t.TempDir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, tfr.Close()) }) + return tfr + } + + t.Run("bundle identifier allows a different title name on a targeted package", func(t *testing.T) { + contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") + state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, true) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { + require.Equal(t, "DummyApp", payload.Title) + require.Equal(t, "apps", payload.Source) + require.Equal(t, "com.example.dummy", payload.BundleIdentifier) + return titleID, nil + } + + updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + InstallerID: targetInstallerID, + Filename: "dummy_installer.pkg", + InstallerFile: newReplacement(t, contents), + }) + require.NoError(t, err) + require.Equal(t, targetInstallerID, updated.InstallerID) + require.NotEqual(t, storageID, updated.StorageID) + require.Equal(t, "1.0.0", updated.Version) + require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) + }) + + t.Run("upgrade code allows a different title name", func(t *testing.T) { + contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") + state := setup(t, "Fleet agent", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { + require.Equal(t, "Fleet osquery", payload.Title) + require.Equal(t, "programs", payload.Source) + require.NotEmpty(t, payload.UpgradeCode) + return titleID, nil + } + + updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "fleet-osquery.msi", + InstallerFile: newReplacement(t, contents), + }) + require.NoError(t, err) + require.NotEqual(t, storageID, updated.StorageID) + require.Equal(t, "1.0.0", updated.Version) + }) + + for _, tt := range []struct { + name string + resolvedTitleID uint + resolveErr error + }{ + {name: "not found", resolveErr: ¬FoundError{}}, + {name: "different title", resolvedTitleID: titleID + 1}, + } { + t.Run("different software is rejected when "+tt.name, func(t *testing.T) { + contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") + state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + return tt.resolvedTitleID, tt.resolveErr + } + + _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "dummy_installer.pkg", + InstallerFile: newReplacement(t, contents), + }) + require.ErrorContains(t, err, "The selected package is for different software.") + require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) + require.Equal(t, storageID, state.installer.StorageID) + }) + } + + t.Run("different upgrade code is rejected", func(t *testing.T) { + contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") + state := setup(t, "Fleet agent", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { + require.NotEmpty(t, payload.UpgradeCode) + return 0, ¬FoundError{} + } + + _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "fleet-osquery.msi", + InstallerFile: newReplacement(t, contents), + }) + require.ErrorContains(t, err, "The selected package is for different software.") + require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) + require.Equal(t, storageID, state.installer.StorageID) + }) + + t.Run("extension mismatch takes precedence", func(t *testing.T) { + _, storageID := readInstaller(t, "testdata/dummy_installer.pkg") + msiContents, _ := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") + state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + t.Fatal("identity resolver must not run before the extension check") + return 0, nil + } + + _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "fleet-osquery.msi", + InstallerFile: newReplacement(t, msiContents), + }) + require.ErrorContains(t, err, "The selected package is for a different file type.") + require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) + }) + + t.Run("non-file edit does not resolve identity", func(t *testing.T) { + _, storageID := readInstaller(t, "testdata/dummy_installer.pkg") + state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + t.Fatal("identity resolver must not run without a replacement file") + return 0, nil + } + state.ds.UpdateInstallerSelfServiceFlagFunc = func(ctx context.Context, selfService bool, installerID uint) error { + require.True(t, selfService) + require.Equal(t, targetInstallerID, installerID) + state.installer.SelfService = selfService + return nil + } + + updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + SelfService: new(true), + }) + require.NoError(t, err) + require.Equal(t, targetInstallerID, updated.InstallerID) + require.True(t, updated.SelfService) + require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) + }) + + t.Run("same-named package with an unresolved identity is accepted via the name fallback", func(t *testing.T) { + // A same-named Windows MSI whose upgrade_code changed resolves to not-found by identity; the + // name fallback must still accept it. + contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") + state := setup(t, "Fleet osquery", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { + require.Equal(t, "Fleet osquery", payload.Title) + return 0, ¬FoundError{} + } + + updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "fleet-osquery.msi", + InstallerFile: newReplacement(t, contents), + }) + require.NoError(t, err) + require.NotEqual(t, storageID, updated.StorageID) + require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) + }) + + t.Run("different software is rejected on a targeted multi-package installer", func(t *testing.T) { + contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") + state := setup(t, "Different Osquery Name", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, true) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + return titleID + 1, nil // resolves to a different title + } + + _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + InstallerID: targetInstallerID, + Filename: "dummy_installer.pkg", + InstallerFile: newReplacement(t, contents), + }) + require.ErrorContains(t, err, "The selected package is for different software.") + require.True(t, state.ds.GetSoftwarePackagesByTeamAndTitleIDFuncInvoked) + require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) + require.Equal(t, storageID, state.installer.StorageID) + }) + + t.Run("fleet-maintained app rejects a file change with the FMA message before identity resolution", func(t *testing.T) { + contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/EchoApp.pkg") + state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) + fmaID := uint(7) + state.installer.FleetMaintainedAppID = &fmaID + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + t.Fatal("identity resolver must not run for a fleet-maintained app") + return 0, nil + } + + _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "echoapp.pkg", + InstallerFile: newReplacement(t, contents), + }) + require.ErrorContains(t, err, "The package can't be changed for Fleet-maintained apps.") + require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) + require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) + }) + + t.Run("identity resolving to a different title is rejected even when the name matches", func(t *testing.T) { + // Guards the wrong-software overwrite: the title name equals the uploaded package's extracted + // name ("DummyApp"), but its identity resolves to a different title, so it must be rejected. + contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") + state := setup(t, "DummyApp", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + return titleID + 1, nil // resolves to a different existing title + } + + _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "dummy_installer.pkg", + InstallerFile: newReplacement(t, contents), + }) + require.ErrorContains(t, err, "The selected package is for different software.") + require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) + require.Equal(t, storageID, state.installer.StorageID) + }) + + t.Run("targeted installer upgrade code accepts a renamed title without a title lookup", func(t *testing.T) { + // A sibling MSI whose own upgrade_code differs from the title's: the title lookup can't see it + // (returns not-found), and the title was renamed so the name fallback also fails — but the + // edited installer's own upgrade_code matches, so the fast path accepts without hitting the DB. + contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/fleet-osquery.msi") + state := setup(t, "Renamed osquery title", "fleet-osquery.msi", "msi", "windows", storageID, []string{"{70A53353-01E5-424B-8819-ED882B3805D9}"}, false) + state.installer.UpgradeCode = "{B681CB20-107E-428A-9B14-2D3C1AFED244}" // fleet-osquery.msi's own upgrade code + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + t.Fatal("title lookup must be skipped when the upgrade code fast path matches") + return 0, nil + } + + updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "fleet-osquery.msi", + InstallerFile: newReplacement(t, contents), + }) + require.NoError(t, err) + require.NotEqual(t, storageID, updated.StorageID) + require.False(t, state.ds.GetExistingSoftwareInstallerTitleIDFuncInvoked) + require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) + }) + + t.Run("a datastore error resolving identity is propagated", func(t *testing.T) { + contents, storageID := readInstaller(t, "testdata/dummy_installer.pkg") + state := setup(t, "Dummy App", "dummy_installer.pkg", "pkg", "darwin", storageID, []string{"com.example.dummy"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + return 0, errors.New("datastore boom") + } + + _, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "dummy_installer.pkg", + InstallerFile: newReplacement(t, contents), + }) + require.ErrorContains(t, err, "resolving title for updated installer") + require.False(t, state.ds.SaveInstallerUpdatesFuncInvoked) + }) + + t.Run("name-only package resolving to a different title falls through to the name check", func(t *testing.T) { + // A name-only package (no bundle id / upgrade code): the resolver's name branch has no + // LIMIT/ORDER BY and can match multiple same-named titles ambiguously, so a "different title" + // result must not reject on its own — it falls through to the name check, which matches here. + contents, storageID := readInstaller(t, "../../../server/service/testdata/software-installers/vim.deb") + state := setup(t, "vim", "vim.deb", "deb", "linux", storageID, []string{"vim"}, false) + state.ds.GetExistingSoftwareInstallerTitleIDFunc = func(context.Context, *fleet.UploadSoftwareInstallerPayload) (uint, error) { + return titleID + 1, nil // ambiguous name-only match returns another same-named title + } + + updated, err := state.svc.UpdateSoftwareInstaller(state.ctx, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &state.teamID, + Filename: "vim.deb", + InstallerFile: newReplacement(t, contents), + }) + require.NoError(t, err) + require.NotEqual(t, storageID, updated.StorageID) + require.True(t, state.ds.SaveInstallerUpdatesFuncInvoked) + }) +} + // Software installer and setup-experience uploads validate referenced custom // host vitals, so mock-backed tests that don't care about it needn't stub it. func defaultMockCustomHostVitalsValidation(ds fleet.Datastore) { @@ -996,6 +1424,7 @@ func TestInstallShScriptOnDarwin(t *testing.T) { SelfService: false, }, nil } + mockSoftwarePackagesFromMetadata(ds) // Label scoping check passes ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { @@ -1059,6 +1488,7 @@ func TestInstallZipInstallerUsesStoredPlatform(t *testing.T) { SelfService: false, }, nil } + mockSoftwarePackagesFromMetadata(ds) ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil @@ -1155,6 +1585,7 @@ func TestSelfServiceInstallZipInstallerUsesStoredPlatform(t *testing.T) { SelfService: true, }, nil } + mockSoftwarePackagesFromMetadata(ds) ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { return true, nil @@ -1213,6 +1644,7 @@ func TestInstallShScriptOnWindowsFails(t *testing.T) { SelfService: false, }, nil } + mockSoftwarePackagesFromMetadata(ds) // Label scoping check passes ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { @@ -1252,6 +1684,11 @@ func TestSelfServiceInstallSoftwareTitleAllowsPersonallyEnrolledDevices(t *testi ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(_ context.Context, _ *uint, _ uint, _ bool) (*fleet.SoftwareInstaller, error) { return nil, ¬FoundError{} } + // The title has no packages, so the precedence resolver returns none and the flow falls through + // to the VPP/in-house lookups (both not found) — the same "not available" path as before. + ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(_ context.Context, _ *uint, _ uint) ([]*fleet.SoftwareInstaller, error) { + return nil, nil + } ds.GetVPPAppByTeamAndTitleIDFunc = func(_ context.Context, _ *uint, _ uint) (*fleet.VPPApp, error) { return nil, ¬FoundError{} } @@ -1595,6 +2032,14 @@ func TestSelfServiceInstallAllSoftwareTitles(t *testing.T) { } return &fleet.SoftwareInstaller{InstallerID: 1, SelfService: true, Name: "foo.pkg"}, nil } + // The per-title self-service install now resolves the package via the precedence resolver, + // so the per-title failure injection lives on this read. + ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + if fail.installTitle != nil { + return nil, fail.installTitle + } + return []*fleet.SoftwareInstaller{{InstallerID: 1, SelfService: true, Name: "foo.pkg"}}, nil + } ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID uint, hostID uint) (bool, error) { return true, nil } diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index a4c68ea27e6..3578993d724 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -224,6 +224,7 @@ const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = { name: "test.app", icon_url: null, software_package: null, + packages: null, app_store_app: null, source: "apps", hosts_count: 1, @@ -260,6 +261,7 @@ export const createMockSoftwareVersionResponse = ( }; const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { + installer_id: 1, name: "TestPackage-1.2.3.pkg", title_id: 2, version: "1.2.3", @@ -295,6 +297,7 @@ export const createMockSoftwarePackage = ( }; const DEFAULT_SOFTWARE_PACKAGE_IOS_MOCK: ISoftwarePackage = { + installer_id: 2, name: "MyApp-2.0.0.ipa", title_id: 10, version: "2.0.0", @@ -336,6 +339,7 @@ const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = { extension_for: "", versions: [createMockSoftwareTitleVersion()], software_package: createMockSoftwarePackage(), + packages: null, app_store_app: null, }; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx index 4e07eb49459..8f3f663aea7 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx @@ -7,6 +7,7 @@ import { getDefaultSoftwareInstallHandler, getSoftwareInstallHandlerNoOutputs, getSoftwareInstallHandlerOnlyInstallOutput, + getSoftwareInstallHandlerWithHash, getSoftwareInstallHandlerWithPreInstall, getSoftwareInstallHandlerOnlyPreInstallOutput, getSoftwareInstallResultHandlerPremiumRequired, @@ -414,4 +415,61 @@ describe("SoftwareInstallDetailsModal", () => { ); }); }); + + // The Package SHA-256 hash row is guarded on the payload's `hash_sha256` + // field. Backend hydrates it for package-backed installs; VPP / older + // results carry no hash and the row must stay out of the DOM. + describe("Package SHA-256 hash row", () => { + afterEach(() => { + mockServer.resetHandlers(); + }); + + it("renders the label, hash, and a copy button when the install result carries hash_sha256", async () => { + mockServer.use(getSoftwareInstallHandlerWithHash); + const renderWithServer = createCustomRenderer({ withBackendMock: true }); + + renderWithServer( + + ); + + expect( + await screen.findByText("Package SHA-256 hash:") + ).toBeInTheDocument(); + expect( + screen.getByText( + "e6ddb2dd089ecea38ab73ed12812df269f1447e750cf4355703340bb8aa1ad" + ) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Copy hash to clipboard/i }) + ).toBeInTheDocument(); + }); + + it("does not render the hash row when the install result has no hash_sha256", async () => { + mockServer.use(getDefaultSoftwareInstallHandler); + const renderWithServer = createCustomRenderer({ withBackendMock: true }); + + renderWithServer( + + ); + + // Wait for the modal to finish loading (status message is a good + // anchor — it renders after the useQuery resolves). + await screen.findByText(/Fleet installed/); + expect( + screen.queryByText("Package SHA-256 hash:") + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Copy hash to clipboard/i }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx index 83c1e839d7d..3845bb801e2 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx @@ -27,14 +27,17 @@ import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import Modal from "components/Modal"; import ModalFooter from "components/ModalFooter"; import Button from "components/buttons/Button"; +import CopyButton from "components/buttons/CopyButton"; import IconStatusMessage from "components/IconStatusMessage"; import Textarea from "components/Textarea"; import DataError from "components/DataError/DataError"; +import DataSet from "components/DataSet"; import DeviceUserError from "components/DeviceUserError"; import Spinner from "components/Spinner/Spinner"; import RevealButton from "components/buttons/RevealButton"; import CustomLink from "components/CustomLink"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; +import TooltipTruncatedText from "components/TooltipTruncatedText"; import { INSTALL_DETAILS_STATUS_ICONS, @@ -452,6 +455,30 @@ export const SoftwareInstallDetailsModal = ({ canOverrideFailureWithInstalled={canOverrideFailureWithInstalled} /> + {/* Package SHA-256 hash — backend hydrates `hash_sha256` on the + install result. Guarded so the row stays out of the DOM for + older results and VPP/App-Store paths whose payload doesn't + carry a package hash. */} + {swInstallResult?.hash_sha256 && ( +
+ + + + + } + /> +
+ )} + {shouldShowInventoryVersions && renderInventoryVersionsSection()} {isInstalledByFleet && !overrideFailedMessageWithInstalledMessage && diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss index 1eaf381b1d5..1be821a3ca7 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss @@ -13,4 +13,17 @@ .reveal-button { width: min-content; } + + // Hash + copy button sit inline. `min-width: 0` on the truncated text is + // load-bearing — flex items don't shrink below their content by default, + // which would push the copy button off-screen for long hashes. + &__hash-row .data-set dd { + align-items: center; + gap: $pad-xsmall; + } + + &__hash { + min-width: 0; + flex: 1; + } } diff --git a/frontend/components/InfoBanner/InfoBanner.tests.tsx b/frontend/components/InfoBanner/InfoBanner.tests.tsx index eadece8ec46..136febadecf 100644 --- a/frontend/components/InfoBanner/InfoBanner.tests.tsx +++ b/frontend/components/InfoBanner/InfoBanner.tests.tsx @@ -38,4 +38,19 @@ describe("InfoBanner - component", () => { const { container } = render(); expect(container.firstChild).toHaveClass("info-banner__icon"); }); + + it("uses the icon's default color when iconColor is omitted", () => { + // `info-outline` defaults to ui-fleet-black-75 per InfoOutline.tsx. + const { container } = render(); + const path = container.querySelector(".info-banner__leading-icon path"); + expect(path?.getAttribute("fill")).toMatch(/ui-fleet-black-75/); + }); + + it("forwards iconColor to the leading Icon's fill", () => { + const { container } = render( + + ); + const path = container.querySelector(".info-banner__leading-icon path"); + expect(path?.getAttribute("fill")).toMatch(/ui-fleet-black-50/); + }); }); diff --git a/frontend/components/InfoBanner/InfoBanner.tsx b/frontend/components/InfoBanner/InfoBanner.tsx index eea024a374b..6ae8d7b2e18 100644 --- a/frontend/components/InfoBanner/InfoBanner.tsx +++ b/frontend/components/InfoBanner/InfoBanner.tsx @@ -5,6 +5,7 @@ import Icon from "components/Icon"; import Button from "components/buttons/Button"; import { IconNames } from "components/icons"; import Card from "components/Card"; +import { Colors } from "styles/var/colors"; const baseClass = "info-banner"; @@ -20,7 +21,12 @@ export interface IInfoBannerProps { cta?: JSX.Element; /** closable and link are mutually exclusive */ closable?: boolean; - icon?: IconNames; // TODO: This is unused but several banners have icons within children that can be refactored to use this for consistent styling + /** Renders an icon to the left of the banner copy. When set, the banner + * switches from `space-between` to a left-aligned flex layout so the icon + * groups with the text rather than getting pushed to the opposite edge. */ + icon?: IconNames; + /** Overrides the icon's default color when `icon` is set. */ + iconColor?: Colors; } const InfoBanner = ({ @@ -32,6 +38,7 @@ const InfoBanner = ({ cta, closable, icon, + iconColor, }: IInfoBannerProps) => { const wrapperClasses = classNames( baseClass, @@ -46,6 +53,13 @@ const InfoBanner = ({ const content = ( <> + {icon && ( + + )}
{children}
{(cta || closable) && ( diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss index 4d75d8e9230..9e118ee042d 100644 --- a/frontend/components/InfoBanner/_styles.scss +++ b/frontend/components/InfoBanner/_styles.scss @@ -14,6 +14,18 @@ gap: $pad-small; } + // When a leading icon is rendered, group it with the copy on the left + // instead of pushing them to opposite edges with `space-between`. + &__icon { + justify-content: flex-start; + align-items: flex-start; + gap: $pad-small; + } + + &__leading-icon { + flex-shrink: 0; + } + &__info { // Do not use display flex as it will have adverse effects on spacing around HTML tags align-content: center; diff --git a/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx b/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx index 5c2b2b55d34..0f4aa34f11a 100644 --- a/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx +++ b/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx @@ -36,6 +36,7 @@ const SoftwareInstallPolicyBadges = ({ policyType }: IPatchBadgesProps) => { position="top" showArrow underline={false} + fixedPositionStrategy > diff --git a/frontend/components/SoftwareInstallPolicyBadges/_styles.scss b/frontend/components/SoftwareInstallPolicyBadges/_styles.scss new file mode 100644 index 00000000000..31b7053b31e --- /dev/null +++ b/frontend/components/SoftwareInstallPolicyBadges/_styles.scss @@ -0,0 +1,8 @@ +.software-install-policy-badges { + &__dynamic-policy-tooltip { + .component__tooltip-wrapper__element { + display: inline-flex; + align-items: center; + } + } +} diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 63c7ae6a838..7fa7035ce60 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -125,6 +125,12 @@ export interface IDropdownWrapper { * aligning right to fit text on screen */ nowrapMenu?: boolean; customNoOptionsMessage?: string; + /** Explicit accessible name for the combobox. When omitted, the resolved + * aria-label falls back to `placeholder`, then `name`, so existing call + * sites get at least a rough label without opting in. react-select does + * not infer any of these on its own; without a value here screen readers + * announce a bare "combobox". */ + ariaLabel?: string; } const getOptionBackgroundColor = ( @@ -452,6 +458,7 @@ const DropdownWrapper = ({ variant, nowrapMenu, customNoOptionsMessage, + ariaLabel, }: IDropdownWrapper) => { const wrapperClassNames = classnames(baseClass, className, { [`${baseClass}__table-filter`]: variant === "table-filter", @@ -559,6 +566,11 @@ const DropdownWrapper = ({ placeholder={placeholder} onMenuOpen={onMenuOpen} controlShouldRenderValue={variant !== "button"} // Control doesn't change placeholder to selected value + // Resolve accessible name: explicit prop wins, otherwise fall back + // to the placeholder (usually "Select X"), otherwise the required + // `name` (often a kebab-case identifier — least readable but + // guaranteed present). + aria-label={ariaLabel ?? placeholder ?? name} /> ); diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md index 5718c5df132..6739cad21db 100644 --- a/frontend/docs/patterns.md +++ b/frontend/docs/patterns.md @@ -553,6 +553,7 @@ Custom hook names should be camel-cased and use the `use` prefix, and should liv Current custom hooks include: +- [`useBlockNavigation`](../hooks/useBlockNavigation.ts) — Attaches a `beforeunload` handler while its `block` argument is true, prompting the user before tab close / hard navigation. - [`useCheckTruncatedElement`](../hooks/useCheckTruncatedElement.ts) — Returns whether a referenced element's content is overflowing/truncated, updating on resize. - [`useCheckboxListStateManagement`](../hooks/useCheckboxListStateManagement.tsx) — Manages checked/unchecked state for a list of policies with a toggle updater. - [`useDeepEffect`](../hooks/useDeepEffect.ts) — `useEffect` variant that does a deep (lodash `isEqual`) comparison of dependencies. diff --git a/frontend/hooks/useBlockNavigation.tests.ts b/frontend/hooks/useBlockNavigation.tests.ts new file mode 100644 index 00000000000..b961d376703 --- /dev/null +++ b/frontend/hooks/useBlockNavigation.tests.ts @@ -0,0 +1,72 @@ +import { renderHook } from "@testing-library/react"; + +import useBlockNavigation from "./useBlockNavigation"; + +describe("useBlockNavigation", () => { + let addSpy: jest.SpyInstance; + let removeSpy: jest.SpyInstance; + + beforeEach(() => { + addSpy = jest.spyOn(window, "addEventListener"); + removeSpy = jest.spyOn(window, "removeEventListener"); + }); + + afterEach(() => { + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it("attaches a beforeunload handler when block is true", () => { + renderHook(() => useBlockNavigation(true)); + const beforeunloadAdds = addSpy.mock.calls.filter( + ([evt]) => evt === "beforeunload" + ); + expect(beforeunloadAdds).toHaveLength(1); + }); + + it("does not attach a handler when block is false", () => { + renderHook(() => useBlockNavigation(false)); + const beforeunloadAdds = addSpy.mock.calls.filter( + ([evt]) => evt === "beforeunload" + ); + expect(beforeunloadAdds).toHaveLength(0); + }); + + it("removes the handler on unmount when block was true", () => { + const { unmount } = renderHook(() => useBlockNavigation(true)); + unmount(); + const beforeunloadRemoves = removeSpy.mock.calls.filter( + ([evt]) => evt === "beforeunload" + ); + expect(beforeunloadRemoves).toHaveLength(1); + }); + + it("removes the handler when block flips from true to false", () => { + const { rerender } = renderHook( + ({ block }: { block: boolean }) => useBlockNavigation(block), + { initialProps: { block: true } } + ); + rerender({ block: false }); + const beforeunloadRemoves = removeSpy.mock.calls.filter( + ([evt]) => evt === "beforeunload" + ); + expect(beforeunloadRemoves).toHaveLength(1); + }); + + it("preventDefault and returnValue are set by the attached handler", () => { + renderHook(() => useBlockNavigation(true)); + const handler = addSpy.mock.calls.find( + ([evt]) => evt === "beforeunload" + )?.[1] as (e: BeforeUnloadEvent) => void; + expect(handler).toBeDefined(); + + const preventDefault = jest.fn(); + const event = ({ + preventDefault, + returnValue: false, + } as unknown) as BeforeUnloadEvent; + handler(event); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(event.returnValue).toBe(true); + }); +}); diff --git a/frontend/hooks/useBlockNavigation.ts b/frontend/hooks/useBlockNavigation.ts new file mode 100644 index 00000000000..fd6b1e7bc1f --- /dev/null +++ b/frontend/hooks/useBlockNavigation.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; + +/** Browser navigation guard — shows the leave-confirm prompt while `block` + * is true. Tied to the `beforeunload` event, which fires on tab close, hard + * navigation, and reload. Used during multi-step uploads where losing the + * in-flight request would discard the user's work. + * + * Note: this does NOT block in-app react-router navigation; only browser-level + * navigation. Soft navigation between Fleet routes still proceeds. */ +const useBlockNavigation = (block: boolean): void => { + useEffect(() => { + if (!block) return undefined; + + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + // Legacy support for Chrome/Edge < 119, which only respect + // `returnValue` rather than the modern `preventDefault()`. + e.returnValue = true; + }; + + addEventListener("beforeunload", handler); + return () => removeEventListener("beforeunload", handler); + }, [block]); +}; + +export default useBlockNavigation; diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 3f895af51ca..ab482f933cb 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -82,6 +82,10 @@ export interface IPolicySoftwareToInstall { display_name?: string; software_title_id: number; icon_url?: string | null; + /** Present when the policy pins a specific package on a multi-package + * title. Absent for VPP-backed policies. When absent the automations UI + * falls back to auto-selecting the title's first-added package. */ + software_installer_id?: number; } // Used on the manage hosts page and other places where aggregate stats are displayed @@ -143,6 +147,10 @@ export interface IPolicyFormData { conditional_access_enabled?: boolean; continuous_automations_enabled?: boolean; software_title_id?: number | null; + /** Pins the policy to a specific package on a multi-package title. `null` + * on PATCH lets the backend fall back to the title's first-added package + * (mirrors `software_title_id`'s unset asymmetry). */ + software_installer_id?: number | null; // null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script script_id?: number | null; labels_include_any?: string[]; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 86d290bde1a..49db0aed3b6 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -124,6 +124,10 @@ export interface IFleetMaintainedVersion { } export interface ISoftwarePackage { + /** Per-installer id — distinct from `title_id`. Used by per-package edit + * and delete endpoints so the request targets one specific package on a + * title that may have several. */ + installer_id: number; name: string; /** Not included in SoftwareTitle software.software_package response, hoisted up one level * Custom name set per team by admin @@ -212,7 +216,11 @@ export interface ISoftwareTitle { extension_for?: SoftwareExtensionFor; hosts_count: number; versions: ISoftwareTitleVersion[] | null; + /** First-added; mirrors packages[0]. Retained for back-compat. */ software_package: ISoftwarePackage | null; + /** All custom packages on this title (trimmed shape on list responses). + * `null` when the title has no custom packages. */ + packages: ISoftwarePackage[] | null; app_store_app: IAppStoreApp | null; /** @deprecated Use extension_for instead */ browser?: string; @@ -225,7 +233,13 @@ export interface ISoftwareTitleDetails { /** Custom name set per team by admin */ display_name?: string; icon_url: string | null; + /** First-added; mirrors packages[0]. Retained for back-compat. */ software_package: ISoftwarePackage | null; + /** All custom packages on this title, in first-added order (smallest + * `installer_id` first). `null` when the title has no custom packages. + * When present, treat as the source of truth; `software_package` is a + * convenience alias to `packages[0]`. */ + packages: ISoftwarePackage[] | null; app_store_app: IAppStoreApp | null; source: SoftwareSource; extension_for?: SoftwareExtensionFor; @@ -338,6 +352,13 @@ export const INSTALLABLE_SOURCE_PLATFORM_CONVERSION = { export const SCRIPT_PACKAGE_SOURCES = ["sh_packages", "ps1_packages"]; +/** Mirrors `fleet.MaxPackagesPerTitle` in `server/fleet/software_installer.go`. + * The backend rejects the upload past this cap with the `SoftwarePackageLimitMessage` + * conflict error — the UI uses this constant to disable "+ Add package" and + * surface a matching tooltip before the user hits the API. Keep in sync if + * the backend limit changes. */ +export const MAX_PACKAGES_PER_TITLE = 10; + /** Sources that don't map cleanly to versions or hosts in software inventory. * UI behavior for these sources: * - Never shows “Update available” (no version to compare against the package version). @@ -525,6 +546,10 @@ export interface ISoftwareInstallResult { created_at: string; updated_at: string | null; self_service: boolean; + /** SHA-256 of the installer package. Present when the payload was + * hydrated from a package-backed install; absent for VPP / older results + * whose backend join hasn't been extended. */ + hash_sha256?: string; } // Script results are only install results, never uninstall diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx index f5a7247dd49..de51a53866b 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx @@ -9,6 +9,7 @@ import TextCell from "components/TableContainer/DataTable/TextCell"; import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell"; import Checkbox from "components/forms/fields/Checkbox"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import TooltipWrapper from "components/TooltipWrapper"; import { SetupExperiencePlatform } from "interfaces/platform"; import AndroidLatestVersionWithTooltip from "components/MDM/AndroidLatestVersionWithTooltip"; @@ -82,7 +83,20 @@ const generateTableConfig = ( sortType: "caseInsensitive", }, { - Header: "Version", + id: "version", + Header: () => ( + + For custom packages, the first +
+ added version will be installed. + + } + > + Version +
+ ), disableSortBy: true, Cell: (cellProps: ITableStringCellProps) => { if (platform === "android") { diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tests.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tests.tsx new file mode 100644 index 00000000000..78772ac44bc --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tests.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { GitOpsCustomPackageBanner } from "./SoftwareCustomPackage"; + +describe("GitOpsCustomPackageBanner", () => { + it("renders the shared GitOps banner copy", () => { + render(); + expect( + screen.getByText(/Add custom packages in GitOps mode/i) + ).toBeInTheDocument(); + expect( + screen.getByText( + /copy its SHA-256 hash into your YAML so the next GitOps workflow doesn.t delete it/i + ) + ).toBeInTheDocument(); + }); + + it("renders the YAML docs link pointing at learn-more-about/software-yaml", () => { + render(); + const link = screen.getByRole("link", { name: /YAML docs/i }); + expect(link).toHaveAttribute( + "href", + expect.stringMatching(/learn-more-about\/software-yaml$/) + ); + expect(link).toHaveAttribute("target", "_blank"); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx index 6ad47ddb1ac..cead14fb1c9 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx @@ -1,24 +1,29 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useContext, useState } from "react"; import { InjectedRouter } from "react-router"; import { useQuery, useQueryClient } from "react-query"; import PATHS from "router/paths"; -import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { + DEFAULT_USE_QUERY_OPTIONS, + LEARN_MORE_ABOUT_BASE_LINK, +} from "utilities/constants"; import { getFileDetails, IFileDetails } from "utilities/file/fileUtils"; import { getPathWithQueryParams, QueryParams } from "utilities/url"; import softwareAPI from "services/entities/software"; import labelsAPI, { getCustomLabels } from "services/entities/labels"; import { AppContext } from "context/app"; +import useBlockNavigation from "hooks/useBlockNavigation"; import useGitOpsMode from "hooks/useGitOpsMode"; import { ILabelSummary } from "interfaces/label"; import { notify } from "components/ToastNotification"; +import CustomLink from "components/CustomLink"; import FileProgressModal from "components/FileProgressModal"; +import InfoBanner from "components/InfoBanner"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; -import InfoBanner from "components/InfoBanner"; import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal"; import PackageForm from "pages/SoftwarePage/components/forms/PackageForm"; @@ -28,6 +33,25 @@ import { getErrorMessage } from "./helpers"; const baseClass = "software-custom-package"; +/** Shared GitOps-mode banner for the custom-package flows. Rendered by this + * page (single-package add) and by `PackageForm`'s multi-package Add modal. */ +export const GitOpsCustomPackageBanner = () => ( + + Add custom packages in GitOps mode so Fleet can host your software. After + adding, copy its SHA-256 hash into your YAML so the next GitOps workflow + doesn't delete it.{" "} + + +); + interface ISoftwarePackageProps { currentTeamId: number; router: InjectedRouter; @@ -72,26 +96,8 @@ const SoftwareCustomPackage = ({ } ); - useEffect(() => { - const beforeUnloadHandler = (e: BeforeUnloadEvent) => { - e.preventDefault(); - // Next line with e.returnValue is included for legacy support - // e.g.Chrome / Edge < 119 - e.returnValue = true; - }; - - // set up event listener to prevent user from leaving page while uploading - if (uploadDetails) { - addEventListener("beforeunload", beforeUnloadHandler); - } else { - removeEventListener("beforeunload", beforeUnloadHandler); - } - - // clean up event listener and timeout on component unmount - return () => { - removeEventListener("beforeunload", beforeUnloadHandler); - }; - }, [uploadDetails]); + // Block tab close / hard navigation while an upload is in flight. + useBlockNavigation(!!uploadDetails); const onClickPreviewEndUserExperience = (isIosOrIpadosApp = false) => { setShowPreviewEndUserExperience(!showPreviewEndUserExperience); @@ -150,7 +156,6 @@ const SoftwareCustomPackage = ({ const newQueryParams: QueryParams = { fleet_id: currentTeamId, - gitops_yaml: gitOpsModeEnabled ? "true" : undefined, }; router.push( getPathWithQueryParams( @@ -175,13 +180,7 @@ const SoftwareCustomPackage = ({ return ( <> - {gitOpsModeEnabled && ( - - Add custom packages in GitOps mode so Fleet can host your software. - After adding, copy its SHA-256 hash into your YAML so the next - GitOps workflow doesn't delete it. - - )} + {gitOpsModeEnabled && } > = {}, + gitOpsModeEnabled = false +) => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + config: { + gitops: { + gitops_mode_enabled: gitOpsModeEnabled, + repository_url: gitOpsModeEnabled ? "https://example.com/repo" : "", + }, + }, + }, + }, + }); + return render(); +}; + +describe("AddPackageModal", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("standard mode", () => { + it("renders with the 'Add package' title", () => { + renderModal(); + // Modal renders its title as a , not a heading; use getByText. + expect(screen.getByText("Add package")).toBeInTheDocument(); + }); + + it("renders the multi-package first-added banner in the target section", () => { + renderModal(); + expect( + screen.getByText( + /If multiple packages of the same software target the same host, Fleet will install the one that was added first\./i + ) + ).toBeInTheDocument(); + }); + + it("hides the GitOps banner copy", () => { + renderModal(); + expect( + screen.queryByText(/Add custom packages in GitOps mode/i) + ).not.toBeInTheDocument(); + expect(screen.queryByText("YAML docs")).not.toBeInTheDocument(); + }); + + it("derives the platform label from the existing package's filename", () => { + renderModal({ existingPackageName: "GlobalProtect-v6.3.2.pkg" }); + // `getFileTypeRestriction` returns label "macOS (.pkg)" — the modal + // forwards it to PackageForm's FileUploader message slot. + expect(screen.getByText("macOS (.pkg)")).toBeInTheDocument(); + }); + + it("falls back to the all-platforms file-type message when the existing name has no recognized extension", () => { + renderModal({ existingPackageName: "no-extension" }); + // PackageForm's default message lists every supported platform. + expect(screen.getByText(/macOS \(.pkg,/)).toBeInTheDocument(); + }); + + it("renders the form's Save button as 'Save' (not 'Add software')", async () => { + renderModal(); + // The button text comes from PackageForm — `multiPackageContext` flips + // it from "Add software" to "Save". The form mounts after labels load + // (an empty array via the optional-chained fallback), so await it. + const saveButton = await screen.findByRole("button", { name: "Save" }); + expect(saveButton).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add software" }) + ).not.toBeInTheDocument(); + }); + + it("preselects the Custom target radio (multi-package default per Figma)", () => { + renderModal(); + const customRadio = screen.getByLabelText("Custom"); + expect(customRadio).toBeChecked(); + }); + }); + + describe("GitOps mode", () => { + it("renders the GitOps banner copy", () => { + renderModal({}, true); + expect( + screen.getByText(/Add custom packages in GitOps mode/i) + ).toBeInTheDocument(); + expect( + screen.getByText( + /copy its SHA-256 hash into your YAML so the next GitOps workflow doesn.t delete it/i + ) + ).toBeInTheDocument(); + }); + + it("renders the YAML docs CustomLink", () => { + renderModal({}, true); + const link = screen.getByRole("link", { name: /YAML docs/i }); + expect(link).toHaveAttribute( + "href", + expect.stringMatching(/learn-more-about\/software-yaml$/) + ); + }); + + it("hides the standard multi-package banner copy in GitOps mode", () => { + renderModal({}, true); + expect( + screen.queryByText(/will install the one that was added first/) + ).not.toBeInTheDocument(); + }); + }); + + describe("file-type restriction (per-row)", () => { + it("constrains a Linux .deb title to .deb uploads", () => { + renderModal({ existingPackageName: "cinc_18.2.11-1_amd64.deb" }); + expect(screen.getByText("Linux (.deb)")).toBeInTheDocument(); + }); + + it("constrains a Windows .msi title to .msi uploads", () => { + renderModal({ existingPackageName: "ZoomInstaller.msi" }); + expect(screen.getByText("Windows (.msi)")).toBeInTheDocument(); + }); + + it("constrains a .sh script-only title to .sh uploads", () => { + renderModal({ existingPackageName: "setup.sh" }); + expect(screen.getByText("macOS & Linux (.sh)")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx new file mode 100644 index 00000000000..cab43486a44 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx @@ -0,0 +1,160 @@ +import React, { useState } from "react"; +import { useQuery, useQueryClient } from "react-query"; + +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { getFileDetails, IFileDetails } from "utilities/file/fileUtils"; +import softwareAPI from "services/entities/software"; +import labelsAPI, { getCustomLabels } from "services/entities/labels"; + +import useBlockNavigation from "hooks/useBlockNavigation"; +import useGitOpsMode from "hooks/useGitOpsMode"; +import { ILabelSummary } from "interfaces/label"; + +import { notify } from "components/ToastNotification"; +import Modal from "components/Modal"; +import FileProgressModal from "components/FileProgressModal"; +import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal"; + +import PackageForm from "pages/SoftwarePage/components/forms/PackageForm"; +import { IPackageFormData } from "pages/SoftwarePage/components/forms/PackageForm/PackageForm"; + +import { getErrorMessage } from "pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/helpers"; + +import { getFileTypeRestriction } from "./helpers"; + +const baseClass = "add-package-modal"; + +interface IAddPackageModalProps { + /** The id of the software title we're adding a package to (multi-package + * flow). The POST carries this as `software_title_id` so the new package + * attaches to an existing title instead of creating a new one. */ + softwareTitleId: number; + teamId: number; + /** File name of the title's first-added package — used to derive the + * platform/file-type restriction so the new upload matches the existing + * package's platform (e.g. ".pkg" only when the title is a macOS title). */ + existingPackageName: string; + onExit: () => void; + /** Fires after a successful upload so the caller can refetch the title's + * `packages[]` and surface the new row. */ + onSuccess: () => void; +} + +const AddPackageModal = ({ + softwareTitleId, + teamId, + existingPackageName, + onExit, + onSuccess, +}: IAddPackageModalProps) => { + const queryClient = useQueryClient(); + const { gitOpsModeEnabled } = useGitOpsMode("software"); + const restriction = getFileTypeRestriction(existingPackageName); + + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadDetails, setUploadDetails] = useState(null); + const [ + showPreviewEndUserExperience, + setShowPreviewEndUserExperience, + ] = useState(false); + const [ + isIpadOrIphoneSoftwareSource, + setIsIpadOrIphoneSoftwareSource, + ] = useState(false); + + const { data: labels } = useQuery( + ["custom_labels"], + () => labelsAPI.summary(teamId).then((res) => getCustomLabels(res.labels)), + { ...DEFAULT_USE_QUERY_OPTIONS } + ); + + // Block tab close / hard navigation while an upload is in flight so the + // user doesn't lose their work mid-request. + useBlockNavigation(!!uploadDetails); + + const onClickPreviewEndUserExperience = (isIosOrIpadosApp = false) => { + setShowPreviewEndUserExperience(!showPreviewEndUserExperience); + setIsIpadOrIphoneSoftwareSource(isIosOrIpadosApp); + }; + + const onSubmit = async (formData: IPackageFormData) => { + if (!formData.software) { + notify.error("Couldn't add. Please refresh the page and try again."); + return; + } + + setUploadDetails(getFileDetails(formData.software)); + + try { + await softwareAPI.addSoftwarePackage({ + data: formData, + teamId, + softwareTitleId, + onUploadProgress: (progressEvent) => { + const progress = progressEvent.progress || 0; + // Keep the progress bar at 97% until the server finalizes its + // response — large uploads stall on the last few percent otherwise. + setUploadProgress(Math.max(progress - 0.03, 0.01)); + }, + }); + + if (!gitOpsModeEnabled) { + notify.success( + <> + Successfully added new {formData.software.name} package. + + ); + } + + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-titles" }], + }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); + + onSuccess(); + } catch (e) { + notify.error(getErrorMessage(e), { response: e }); + } + setUploadDetails(null); + }; + + return ( + <> + + + + {uploadDetails && ( + + )} + {showPreviewEndUserExperience && ( + + )} + + ); +}; + +export default AddPackageModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts new file mode 100644 index 00000000000..f4949660012 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts @@ -0,0 +1,68 @@ +import { getFileTypeRestriction } from "./helpers"; + +describe("AddPackageModal helpers — getFileTypeRestriction", () => { + it("returns a macOS .pkg restriction for a .pkg filename", () => { + expect(getFileTypeRestriction("GlobalProtect-v6.3.2.pkg")).toEqual({ + accept: ".pkg", + label: "macOS (.pkg)", + }); + }); + + it("returns a Linux .deb restriction for a .deb filename", () => { + expect(getFileTypeRestriction("cinc_18.2.11-1_amd64.deb")).toEqual({ + accept: ".deb", + label: "Linux (.deb)", + }); + }); + + it("returns a Windows .msi restriction for a .msi filename", () => { + expect(getFileTypeRestriction("ZoomInstaller.msi")).toEqual({ + accept: ".msi", + label: "Windows (.msi)", + }); + }); + + // .tar.gz needs the dual MIME/extension workaround because browsers can't + // match the compound extension via `accept` alone. + it("uses the gzip MIME workaround for .tar.gz", () => { + expect(getFileTypeRestriction("bundle-1.0.0.tar.gz")).toEqual({ + accept: "application/gzip,.tgz", + label: "Linux (.tar.gz)", + }); + }); + + it("normalizes .tgz aliases through to .tar.gz", () => { + // `getExtensionFromFileName` rewrites .tgz → .tar.gz; the restriction + // should match. + expect(getFileTypeRestriction("bundle.tgz")).toEqual({ + accept: "application/gzip,.tgz", + label: "Linux (.tar.gz)", + }); + }); + + it("returns null for an unrecognized extension", () => { + expect(getFileTypeRestriction("README.txt")).toBeNull(); + }); + + it("returns null for a filename without an extension", () => { + expect(getFileTypeRestriction("installer")).toBeNull(); + }); + + it("returns null for an empty string", () => { + expect(getFileTypeRestriction("")).toBeNull(); + }); + + it("returns a macOS & Linux restriction for a .sh script package", () => { + expect(getFileTypeRestriction("setup.sh")).toEqual({ + accept: ".sh", + label: "macOS & Linux (.sh)", + }); + }); + + it("returns a Windows .ps1 restriction", () => { + expect(getFileTypeRestriction("setup.ps1")).toEqual({ + accept: ".ps1", + label: "Windows (.ps1)", + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts new file mode 100644 index 00000000000..0b8b72916c7 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts @@ -0,0 +1,39 @@ +import { + FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME, + getExtensionFromFileName, +} from "utilities/file/fileUtils"; + +/** What the file-uploader should accept and how the file-type hint reads when + * adding a new package to a multi-package title. The new upload must match the + * existing title's platform/file type, so we derive both from the first-added + * package's filename. Returns `null` when we can't determine the restriction + * (unknown extension or missing name) so callers fall back to PackageForm's + * full all-platforms accept + message. */ +export interface IFileTypeRestriction { + /** Value for `` — narrowed to a single extension + * (or, for tar.gz, the same MIME/extension pair PackageForm uses globally). */ + accept: string; + /** Display label, e.g. `"macOS (.pkg)"` or `"Linux (.tar.gz)"`. */ + label: string; +} + +export const getFileTypeRestriction = ( + existingPackageName: string +): IFileTypeRestriction | null => { + const extension = getExtensionFromFileName(existingPackageName); + if (!extension) return null; + + const platform = FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[extension]; + if (!platform) return null; + + // Browsers can't reliably match `.tar.gz` via extension alone (double- + // extension). Mirror PackageForm's global accept value for tar.gz so the + // file dialog filters correctly without us reimplementing the workaround. + const accept = + extension === "tar.gz" ? "application/gzip,.tgz" : `.${extension}`; + + return { + accept, + label: `${platform} (.${extension})`, + }; +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts new file mode 100644 index 00000000000..0deb5e114c7 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AddPackageModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx index a83caa3c5fb..ea166ee2ca3 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx @@ -59,4 +59,29 @@ describe("DeleteSoftwareModal", () => { expect(screen.getByText(/will be uninstalled/i)).toBeVisible(); }); + + describe("multi-package title", () => { + it("renders the 'Delete software' title and the custom-metadata warning by default (single-package legacy path)", () => { + renderModal(); + + expect(screen.getByText("Delete software")).toBeInTheDocument(); + expect(screen.queryByText("Delete package")).not.toBeInTheDocument(); + expect( + screen.getByText("Custom icon and display name will be deleted.") + ).toBeVisible(); + }); + + it("renders the 'Delete package' title and suppresses the custom-metadata warning when canActivateMultiplePackages is true", () => { + // On a multi-package title, only one installer is being deleted — the + // title-level custom icon and display name stay put, so the warning + // would be misleading. + renderModal({ canActivateMultiplePackages: true }); + + expect(screen.getByText("Delete package")).toBeInTheDocument(); + expect(screen.queryByText("Delete software")).not.toBeInTheDocument(); + expect( + screen.queryByText("Custom icon and display name will be deleted.") + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx index ee62855ff3f..58e5bf30077 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx @@ -65,28 +65,43 @@ const getPlatformMessage = (isAppStoreApp: boolean, isAndroidApp: boolean) => { interface IDeleteSoftwareModalProps { softwareId: number; teamId: number; + /** Per-installer id on a multi-package title. When set, only this + * specific package is deleted; otherwise the request deletes the legacy + * single-package row (or VPP/FMA installer slot). */ + installerId?: number; onExit: () => void; onSuccess: () => void; gitOpsModeEnabled?: boolean; isAppStoreApp?: boolean; isAndroidApp?: boolean; + /** When true, the modal title reads "Delete package" instead of "Delete + * software" and the title-level metadata warning is suppressed — we're + * deleting one specific installer on a title that can hold several, not + * the title itself. */ + canActivateMultiplePackages?: boolean; } const DeleteSoftwareModal = ({ softwareId, teamId, + installerId, onExit, onSuccess, gitOpsModeEnabled, isAppStoreApp = false, isAndroidApp = false, + canActivateMultiplePackages = false, }: IDeleteSoftwareModalProps) => { const [isDeleting, setIsDeleting] = useState(false); const onDeleteSoftware = useCallback(async () => { setIsDeleting(true); try { - await softwareAPI.deleteSoftwareInstaller(softwareId, teamId); + await softwareAPI.deleteSoftwareInstaller( + softwareId, + teamId, + installerId + ); notify.success("Successfully deleted software."); onSuccess(); } catch (error) { @@ -109,12 +124,12 @@ const DeleteSoftwareModal = ({ } setIsDeleting(false); onExit(); - }, [softwareId, teamId, onSuccess, onExit]); + }, [softwareId, teamId, installerId, onSuccess, onExit]); return ( @@ -125,7 +140,9 @@ const DeleteSoftwareModal = ({
)} {getPlatformMessage(isAppStoreApp, isAndroidApp)} -

Custom icon and display name will be deleted.

+ {!canActivateMultiplePackages && ( +

Custom icon and display name will be deleted.

+ )}
+ ) : ( + + )} + + ); + + // Self-service tooltip mirrors the SoftwareSummaryCard chip's copy so the + // per-row indicator says the same thing the title-level chip would. + const renderSelfServiceIcon = () => + renderRowActionIcon({ + iconName: "user", + tooltipContent: getSelfServiceTooltip( + !!isIosOrIpadosApp, + !!isAndroidPlayStoreApp + ), + // Same modal opens regardless of which icon is clicked; the icon glyph + // carries the contextual signal ("self-service is on for this package"). + ariaLabel: "Edit package", + onClick: onSelfServiceClick, + canClick: canEditSoftware, + }); + + // Auto-install icon navigates rather than edits — its label is verb-forward + // ("View") so it doesn't read as a state toggle. Custom packages only + // support auto-install policies (no patch policies), so there's no patch + // variant here. + const renderAutoInstallIcon = () => + renderRowActionIcon({ + iconName: "refresh", + tooltipContent: <>Policy triggers install., + ariaLabel: "View auto-install policies", + onClick: onAutoInstallClick, + }); + const renderHeaderBadges = () => { if (!isActive) return null; return (
- {badgeState === "latest" && renderStatusBadge("refresh", "Latest")} + {/* The "Latest" badge is FMA-specific — only Fleet-maintained apps + have a meaningful "latest available cached version" concept that + drives the badge state. VPP / App Store / Play Store / iOS + in-house and custom packages do not render this badge. + Pinned / Major version variants below also stay FMA-only by + construction (only FMA exposes version pinning). */} + {isFma && + badgeState === "latest" && + renderStatusBadge("refresh", "Latest")} + {canActivateMultiplePackages && + isSelfService && + renderSelfServiceIcon()} + {canActivateMultiplePackages && + hasAutoInstallPolicy && + renderAutoInstallIcon()} {badgeState === "pinned" && renderStatusBadge("pin", "Pinned")} {badgeState === "majorVersion" && renderStatusBadge("pin", "Major version")} @@ -465,11 +579,17 @@ const LibraryItemAccordion = ({ ); - // Only FMA and App Store / Play Store rows are GitOps-locked (those - // installer types can't be managed via YAML); custom packages stay - // deletable. The `software` entity exception is honored via the wrapper. + // GitOps-lock the trash button for installer types whose mutations should + // flow through YAML rather than the UI: + // - FMA and App Store / Play Store: can't be managed via YAML in the + // ordinary sense, so UI mutations would just be reverted on next run. + // - Custom multi-package titles: the Edit modal is already visible-but- + // disabled for these in GitOps mode; delete follows the same lock so + // the row's mutation affordances stay consistent. + // Single-package custom titles keep the shipped behavior — deletable via + // UI with a GitOps banner in the delete modal. const isAppStore = installerType === "app-store"; - const lockedByGitOpsMode = isFma || isAppStore; + const lockedByGitOpsMode = isFma || isAppStore || canActivateMultiplePackages; const renderTrashButton = () => lockedByGitOpsMode ? ( diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx index f7bd5b8dbdf..99d66c7ae9d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx @@ -44,7 +44,6 @@ describe("Software Summary Card", () => { softwareId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -87,8 +86,8 @@ describe("Software Summary Card", () => { return options.map((option) => option.textContent || ""); }; - it("displays Edit appearance and Edit software options for standard software packages", async () => { - const { user } = render( + it("collapses to a single pencil-icon Edit (appearance) button for standard custom software packages", () => { + render( { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} + canActivateMultiplePackages /> ); - const options = await getDropdownOptions(user); - - expect(options).toContain("Edit appearance"); - expect(options).toContain("Edit software"); - expect(options).not.toContain("Edit configuration"); - expect(options).not.toContain("Schedule auto updates"); + // Custom non-FMA macOS/Linux/Windows titles drop the Actions dropdown. + // Per-installer Edit moves to the Library accordion row; the page-level + // CTA collapses to a single pencil-icon "Edit" that opens the Edit + // Appearance modal directly. + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + const editButton = screen.getByRole("button", { name: /Edit/ }); + expect(editButton).toBeInTheDocument(); }); it("displays Edit appearance, Edit software, Edit configuration, and Schedule auto updates for iOS/iPadOS apps", async () => { @@ -121,7 +121,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -146,7 +145,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -173,7 +171,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -198,7 +195,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -210,8 +206,8 @@ describe("Software Summary Card", () => { expect(options).toContain("Edit configuration"); }); - it("does not display Edit configuration for macOS in-house (.pkg) apps", async () => { - const { user } = render( + it("collapses macOS .pkg titles to the single-Edit button (no Edit configuration, no Actions dropdown)", () => { + render( { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} + canActivateMultiplePackages /> ); - const options = await getDropdownOptions(user); - - expect(options).toContain("Edit appearance"); - expect(options).toContain("Edit software"); - expect(options).not.toContain("Edit configuration"); + // macOS in-house .pkg is a custom non-FMA, non-iOS title — collapses + // to the pencil Edit button. Edit configuration never applied here + // and the dropdown is gone entirely. + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + expect(screen.queryByText("Edit configuration")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument(); }); it("does not display Edit configuration for macOS VPP apps", async () => { @@ -239,13 +236,16 @@ describe("Software Summary Card", () => { ); @@ -269,7 +269,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -310,7 +309,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -319,8 +317,8 @@ describe("Software Summary Card", () => { expect(options).not.toContain("Versions"); }); - it("hides Versions option for non-FMA installers", async () => { - const { user } = render( + it("hides the Actions dropdown (and therefore Versions) for non-FMA custom installers", () => { + render( { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} + canActivateMultiplePackages /> ); - const options = await getDropdownOptions(user); - expect(options).not.toContain("Versions"); + // Non-FMA custom titles no longer use the Actions dropdown at all, + // so Versions is implicitly hidden — the whole dropdown is gone. + // (The `
Versions
` stat row in the description list remains; + // we're asserting against the dropdown item, not that stat.) + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /Versions/ }) + ).not.toBeInTheDocument(); }); it("still renders the Versions option when GitOps mode is on", async () => { @@ -365,7 +369,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -404,7 +407,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -444,7 +446,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -464,7 +465,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -484,7 +484,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -502,7 +501,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -511,16 +509,21 @@ describe("Software Summary Card", () => { }); it("renders the Self-service pill when self_service is true", () => { + // FMA mock — custom packages hide the title-level Self-service / + // Auto install / Patch chips (per-row icons take over). FMA titles are + // single-package and keep the chips. render( ); @@ -528,6 +531,34 @@ describe("Software Summary Card", () => { expect(screen.getByText("Self-service")).toBeInTheDocument(); }); + it("hides the Self-service / Auto install / Patch chips for custom packages", () => { + render( + + ); + + // Per-row icons on the Library accordion replace these for multi- + // package custom titles; the title-level chips would be misleading. + expect(screen.queryByText("Self-service")).not.toBeInTheDocument(); + expect(screen.queryByText("Auto install")).not.toBeInTheDocument(); + expect(screen.queryByText("Patch policy")).not.toBeInTheDocument(); + }); + it("does not render the Self-service pill when self_service is false", () => { render( { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -553,6 +583,7 @@ describe("Software Summary Card", () => { { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -576,6 +606,7 @@ describe("Software Summary Card", () => { { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -597,6 +627,7 @@ describe("Software Summary Card", () => { { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -626,7 +656,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -640,6 +669,7 @@ describe("Software Summary Card", () => { { teamId={3} router={pushedRouter} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -666,6 +695,7 @@ describe("Software Summary Card", () => { { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); @@ -698,7 +727,6 @@ describe("Software Summary Card", () => { teamId={1} router={router} refetchSoftwareTitle={jest.fn()} - onToggleViewYaml={jest.fn()} onClickVersions={jest.fn()} /> ); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx index dba5b814cdd..1330dfe54e2 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx @@ -6,6 +6,7 @@ import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { getPathWithQueryParams } from "utilities/url"; +import { pluralize } from "utilities/strings/stringUtils"; import { AppContext } from "context/app"; import { useSoftwareInstaller } from "hooks/useSoftwareInstallerMeta"; import { @@ -36,10 +37,14 @@ interface ISoftwareSummaryCard { teamId?: number; router: InjectedRouter; refetchSoftwareTitle: () => void; - onToggleViewYaml: () => void; /** Opens the page-owned Versions modal; the Actions item is gated here by * `canManageVersions`. */ onClickVersions: () => void; + /** Canonical "this title can hold multiple custom packages" flag. When + * true the card hides its Self-service / Auto install / Patch chips + * (Library accordion rows show per-package icons instead) and collapses + * the Actions dropdown into a single pencil-icon Edit-appearance button. */ + canActivateMultiplePackages?: boolean; } const baseClass = "software-summary-card"; @@ -64,8 +69,8 @@ const SoftwareSummaryCard = ({ teamId, router, refetchSoftwareTitle, - onToggleViewYaml, onClickVersions, + canActivateMultiplePackages = false, }: ISoftwareSummaryCard) => { const { isPremiumTier } = useContext(AppContext); const installerResult = useSoftwareInstaller(softwareTitle); @@ -129,6 +134,16 @@ const SoftwareSummaryCard = ({ // not by the host OS — VPP apps can be macOS too, and an iOS/iPadOS title can // ship as a custom package. const isAppleVpp = installerType === "app-store" && !isAndroidPlayStoreApp; + // Multi-package custom titles pluralize the kind chip — a title with two + // .pkg installers reads "Custom packages", not "Custom package". Falls back + // to singular for single-package titles (and back-compat responses where + // `packages` is still null — `pluralize(0)` also returns the plural form, + // but those titles never reach the custom-package branch of `find` below). + const customPackageCount = softwareTitle.packages?.length ?? 1; + const customPackageChipLabel = pluralize( + customPackageCount, + "Custom package" + ); // Order matters: `.find` returns the first truthy row. FMA is checked first // because a Fleet-maintained app uploaded as a custom package still counts // as FMA; Apple VPP precedes Play Store so cross-platform store titles label @@ -137,11 +152,19 @@ const SoftwareSummaryCard = ({ [isFleetMaintainedApp, "Fleet-maintained"], [isAppleVpp, "App Store (VPP)"], [isAndroidPlayStoreApp, "Play Store"], - [isCustomPackage, "Custom package"], + [isCustomPackage, customPackageChipLabel], ] as const).find(([flag]) => flag)?.[1]; + // Titles that can hold multiple custom packages move Self-service and + // Auto-install/Patch indicators down to per-row icons on the Library + // accordion. The title-level chips would be misleading when one package + // is self-service and another isn't. FMA and iOS in-house .ipa keep the + // chips since they're single-package — the flag is owned by the page. + const showSelfServiceChip = isSelfService && !canActivateMultiplePackages; + const showAutoInstallChip = hasLinkedPolicies && !canActivateMultiplePackages; + const showHeaderPills = - !!installerKindLabel || isSelfService || hasLinkedPolicies; + !!installerKindLabel || showSelfServiceChip || showAutoInstallChip; const headerPills = useMemo(() => { if (!showHeaderPills) { @@ -150,7 +173,7 @@ const SoftwareSummaryCard = ({ return ( <> {installerKindLabel && } - {isSelfService && ( + {showSelfServiceChip && ( )} - {hasLinkedPolicies && ( + {showAutoInstallChip && ( setShowEditSoftwareModal(false)} refetchSoftwareTitle={refetchSoftwareTitle} installerType={installerResult.meta.installerType} - openViewYamlModal={onToggleViewYaml} isFleetMaintainedApp={isFleetMaintainedApp} isIosOrIpadosApp={isIosOrIpadosApp} name={softwareTitle.name} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index 4b00fc57282..92fb9c2a63a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -17,23 +17,29 @@ import { aggregateInstallStatusCounts, IAppStoreApp, isIpadOrIphoneSoftwareSource, + ISoftwareInstallPolicyUI, ISoftwarePackage, ISoftwareTitleDetails, + MAX_PACKAGES_PER_TITLE, NO_VERSION_OR_HOST_DATA_SOURCES, } from "interfaces/software"; import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; -import { canWriteSoftware } from "utilities/permissions/permissions"; +import { + canDownloadSoftwareInstaller, + canWriteSoftware, +} from "utilities/permissions/permissions"; import softwareAPI, { ISoftwareTitleResponse, IGetSoftwareTitleQueryKey, } from "services/entities/software"; import { getPathWithQueryParams } from "utilities/url"; -import endpoints from "utilities/endpoints"; -import URL_PREFIX from "router/url_prefix"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import { notify } from "components/ToastNotification"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import TooltipWrapper from "components/TooltipWrapper"; import Spinner from "components/Spinner"; import MainContent from "components/MainContent"; import TeamsHeader from "components/TeamsHeader"; @@ -47,11 +53,17 @@ import LibraryItemAccordion, { import LibraryItemAccordionList from "./LibraryItemAccordion/LibraryItemAccordionList"; import EditSoftwareModal from "./EditSoftwareModal"; import DeleteSoftwareModal from "./DeleteSoftwareModal"; +import AddPackageModal from "./AddPackageModal"; +import PoliciesModal from "./PoliciesModal"; import VersionsModal from "./VersionsModal"; -import { getDisplayedSoftwareName } from "../helpers"; -import { buildLibraryVersionRows } from "./helpers"; +import { getDisplayedSoftwareName, mergePolicies } from "../helpers"; +import { + buildInstallerDownloadUrl, + buildLibraryVersionRows, + canDownloadInstallerRow, + resolveDownloadTarget, +} from "./helpers"; import TitleVersionsTable from "./TitleVersionsTable"; -import ViewYamlModal from "./ViewYamlModal"; const baseClass = "software-title-details-page"; @@ -89,8 +101,6 @@ const SoftwareTitleDetailsPage = ({ const softwareId = parseInt(routeParams.id, 10); const { gitOpsModeEnabled } = useGitOpsMode("software"); - const autoOpenGitOpsYamlModal = - location.query.gitops_yaml === "true" && gitOpsModeEnabled; const { currentTeamId, @@ -105,15 +115,28 @@ const SoftwareTitleDetailsPage = ({ }); const canEditSoftware = canWriteSoftware(currentUser, currentTeamId ?? null); - - // gitOpsYamlParam URL Param controls whether the View Yaml modal is opened on page load - // as it automatically opens from adding flow of custom software in gitOps mode - const [showViewYamlModal, setShowViewYamlModal] = useState( - autoOpenGitOpsYamlModal || false + const canDownloadInstaller = canDownloadSoftwareInstaller( + currentUser, + currentTeamId ?? null ); const [showLibraryEditModal, setShowLibraryEditModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showAddPackageModal, setShowAddPackageModal] = useState(false); + // When set, opens a PoliciesModal scoped to a single package's policies + // (the auto-install icon on a custom-package row). Distinct from the + // SoftwareSummaryCard's title-aggregate PoliciesModal — that one stays + // owned by the card and shows policies across all packages. + const [selectedPackagePolicies, setSelectedPackagePolicies] = useState< + ISoftwareInstallPolicyUI[] | null + >(null); + // Per-installer target for the currently-open Edit or Delete modal on a + // multi-package title. `null` means "fall back to first-added", which keeps + // single-package back-compat callers (and the page-level header Edit) + // pointing at `software_package` without any extra wiring. + const [selectedInstallerId, setSelectedInstallerId] = useState( + null + ); // Page-owned so both the Actions menu and the Library accordion badge open // the same Versions modal. const [showVersionsModal, setShowVersionsModal] = useState(false); @@ -150,9 +173,19 @@ const SoftwareTitleDetailsPage = ({ softwareTitle ?? ({} as ISoftwareTitleDetails) ); - const onToggleViewYaml = () => { - setShowViewYamlModal(!showViewYamlModal); - }; + // Canonical "this title can hold multiple custom packages" flag. + // Single source of truth for three coordinated behaviors: + // 1. Library section shows the "+ Add package" action + // 2. Accordion rows render the self-service / auto-install icons + // 3. SoftwareSummaryCard hides the Self-service / Auto install chips + // AND collapses the Actions dropdown into a pencil-icon Edit button + // True for custom non-FMA, non-iOS titles (Mac .pkg, Linux .deb/.rpm/ + // .tar.gz, Windows .msi/.exe, script-only .sh/.ps1). FMA, VPP, Google + // Play, and iOS in-house .ipa stay single-package. Premium-only. + const canActivateMultiplePackages = + !!isPremiumTier && + !!installerResult?.meta.isCustomPackage && + !installerResult.meta.isIosOrIpadosApp; const onDeleteInstaller = useCallback(() => { queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }] }); @@ -173,33 +206,38 @@ const SoftwareTitleDetailsPage = ({ ); }, [queryClient, refetchSoftwareTitle, router, softwareTitle, teamIdForApi]); - // Mints a one-shot download token and triggers the browser download via a - // synthetic `` click. The token-based URL is unauthenticated; - // we must build it client-side rather than redirecting. - const onDownloadInstaller = useCallback(async () => { - const pkg = softwareTitle?.software_package; - if (!pkg || typeof teamIdForApi !== "number") return; - try { - const resp = await softwareAPI.getSoftwarePackageToken( - softwareId, - teamIdForApi + // Mints a one-shot download token pinned to the clicked package and triggers + // the browser download via a synthetic `` click. The token-based + // URL is unauthenticated; we must build it client-side rather than + // redirecting. Falls back to the title's first-added `software_package` when + // no per-row pkg is supplied (single-package titles / legacy callers). + const onDownloadInstaller = useCallback( + async (pkg?: ISoftwarePackage) => { + const target = resolveDownloadTarget( + pkg, + softwareTitle?.software_package ); - if (!resp.token) { - throw new Error("No download token returned"); + if (!target || typeof teamIdForApi !== "number") return; + try { + const resp = await softwareAPI.getSoftwarePackageToken( + softwareId, + teamIdForApi, + target.installer_id + ); + if (!resp.token) { + throw new Error("No download token returned"); + } + const a = document.createElement("a"); + a.href = buildInstallerDownloadUrl(softwareId, resp.token); + a.download = target.name; + a.click(); + a.remove(); + } catch (e) { + notify.error("Couldn't download. Please try again."); } - const { origin } = global.window.location; - const url = `${origin}${URL_PREFIX}/api${endpoints.SOFTWARE_PACKAGE_TOKEN( - softwareId - )}/${resp.token}`; - const a = document.createElement("a"); - a.href = url; - a.download = pkg.name; - a.click(); - a.remove(); - } catch (e) { - notify.error("Couldn't download. Please try again."); - } - }, [softwareId, softwareTitle, teamIdForApi]); + }, + [softwareId, softwareTitle, teamIdForApi] + ); const onTeamChange = useCallback( (teamId: number) => { @@ -216,8 +254,8 @@ const SoftwareTitleDetailsPage = ({ teamId={teamIdForApi} router={router} refetchSoftwareTitle={refetchSoftwareTitle} - onToggleViewYaml={onToggleViewYaml} onClickVersions={() => setShowVersionsModal(true)} + canActivateMultiplePackages={canActivateMultiplePackages} /> ); }; @@ -229,7 +267,32 @@ const SoftwareTitleDetailsPage = ({ return null; } - const openEditModal = () => setShowLibraryEditModal(true); + // `packages` is the source of truth. The `software_package` alias is + // still returned by the API (points at `packages[0]`, first-added) — the + // fallback here is defense against a title with no custom packages (e.g. + // FMA / app-store branch) so downstream code can treat this as an array. + const packages = + title.packages ?? + (title.software_package ? [title.software_package] : []); + const appStore = title.app_store_app; + + // No installable to render at all. + if (packages.length === 0 && !appStore) { + return null; + } + + // Per-row callbacks set `selectedInstallerId` so the modals target the + // right package on a multi-package title. Called without an id (e.g. from + // the app-store branch or a back-compat single-package title), they fall + // back to first-added — same as legacy behavior. + const openEditModal = (id?: number) => { + setSelectedInstallerId(id ?? null); + setShowLibraryEditModal(true); + }; + const openDeleteModal = (id?: number) => { + setSelectedInstallerId(id ?? null); + setShowDeleteModal(true); + }; const statusPath = (software_status: "installed" | "pending" | "failed") => getPathWithQueryParams(paths.MANAGE_HOSTS, { @@ -238,43 +301,44 @@ const SoftwareTitleDetailsPage = ({ fleet_id: currentTeamId ?? APP_CONTEXT_NO_TEAM_ID, }); - const libraryAccordionList = () => { - const pkg = title.software_package; - const appStore = title.app_store_app; - - if (appStore) { - const { labels, kind } = pickLabels(appStore); - const isAndroidPlayStoreApp = appStore.platform === "android"; - const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source); - return ( - - setShowDeleteModal(true)} - /> - - ); - } + const renderAppStoreRow = () => { + if (!appStore) return null; + const { labels, kind } = pickLabels(appStore); + const isAndroidPlayStoreApp = appStore.platform === "android"; + const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source); + return ( + + ); + }; + // FMAs expand a single package into one badged "active" row plus dimmed + // rollback rows for every cached version. Custom packages render exactly + // one row each. With multi-package titles we run this per `pkg` so each + // top-level entry stays addressable by its own `installer_id`. + const renderPackageRows = (pkg: ISoftwarePackage) => { if (!pkg) return null; const { labels, kind } = pickLabels(pkg); const isFma = installerResult?.meta.isFleetMaintainedApp ?? false; @@ -282,64 +346,150 @@ const SoftwareTitleDetailsPage = ({ installerResult?.meta.isLatestFmaVersion ?? false; const isScriptPackage = installerResult?.cardInfo.isScriptPackage ?? false; + const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source); + const perPackagePolicies = mergePolicies({ + automaticInstallPolicies: pkg.automatic_install_policies, + patchPolicy: pkg.patch_policy, + }); + const hasAutoInstallPolicy = perPackagePolicies.length > 0; const { installed, pending, failed } = aggregateInstallStatusCounts( pkg.status ); - // FMAs list every cached version (active row badged from the pin, the - // rest dimmed rollback candidates); other packages render a single row. const rows = buildLibraryVersionRows({ fleetMaintainedVersions: pkg.fleet_maintained_versions, activeVersion: pkg.version, pinnedVersion: pkg.pinned_version, addedTimestamp: pkg.uploaded_at, }); - return ( - - {rows.map((row) => ( - setShowVersionsModal(true) - : undefined - } - onLabelCountClick={openEditModal} - onLabelsClick={openEditModal} - onDownloadClick={onDownloadInstaller} - onTrashClick={() => setShowDeleteModal(true)} - /> - ))} - - ); + return rows.map((row) => ( + setShowVersionsModal(true) + : undefined + } + onLabelCountClick={() => openEditModal(pkg.installer_id)} + onLabelsClick={() => openEditModal(pkg.installer_id)} + onDownloadClick={() => onDownloadInstaller(pkg)} + onTrashClick={() => openDeleteModal(pkg.installer_id)} + onSelfServiceClick={() => openEditModal(pkg.installer_id)} + onAutoInstallClick={() => { + // Single linked policy: jump straight to it (mirrors the chip's + // "Select to open policy" shortcut). Multiple: open the modal + // scoped to this specific package, not the aggregate. + if (perPackagePolicies.length === 1) { + router.push( + getPathWithQueryParams( + paths.POLICY_DETAILS(perPackagePolicies[0].id), + { fleet_id: teamIdForApi } + ) + ); + return; + } + setSelectedPackagePolicies(perPackagePolicies); + }} + /> + )); }; + // "Add package" lives on titles that can hold multiple custom packages — + // gated by the page-level `canActivateMultiplePackages` flag. FMA, VPP, + // Google Play, and iOS in-house .ipa are all single-package by design + // and so don't surface the action at all. The Library section already + // early-returns when there are no packages and no app-store app, so we + // don't need to re-check `packages.length` here. + const showAddPackageAction = canActivateMultiplePackages && canEditSoftware; + const atPackageLimit = packages.length >= MAX_PACKAGES_PER_TITLE; + const addPackageButton = showAddPackageAction && ( + + ); + const headerAction = + showAddPackageAction && atPackageLimit ? ( + + This title already has {MAX_PACKAGES_PER_TITLE} packages. +
+ Delete one you no longer use before adding. + + } + showArrow + position="left" + underline={false} + > + {addPackageButton} +
+ ) : ( + addPackageButton + ); + + // App-store and custom-package paths are mutually exclusive at the data + // layer (the backend rejects custom uploads against an FMA/VPP title), so + // only one branch ever renders rows. The wrapper stays the same shape. + // The "Add package" action sits on the description row (not the section + // header) so it visually aligns with the secondary copy rather than the + // h2 title — matches the Library row layout in Figma page 2:130. return (
- - {libraryAccordionList()} +
+ {/* The multi-package copy is an action prompt — only meaningful to + a user who can both edit software AND is on a multi-package- + eligible title. Read-only users and single-package types (FMA, + VPP, Google Play, iOS in-house .ipa) get the legacy + "available to be installed" wording. */} + + {headerAction} +
+ + {/* Row order = API response order. The API returns `packages[]` + sorted by `installer_id` ascending, so the top row is the + first-added package (smallest id = collision fallback). The + UI does not re-sort. */} + {appStore ? renderAppStoreRow() : packages.map(renderPackageRows)} +
); }; @@ -372,43 +522,48 @@ const SoftwareTitleDetailsPage = ({ ); }; - // Renders the YAML modal for custom (non-FMA) packages. Two flows open it: - // (1) `?gitops_yaml=true` redirect after add, and (2) editing a custom - // package in gitops mode — `EditSoftwareModal` calls `openViewYamlModal()` - // instead of flashing success. - const renderViewYamlModal = (title: ISoftwareTitleDetails) => { - if (!showViewYamlModal) return null; - const pkg = title.software_package; - // FMA packages don't expose YAML editing — only custom packages do. - if (!pkg || pkg.fleet_maintained_app_id) return null; + // Resolves the targeted package on a multi-package title. Returns the + // package matching `selectedInstallerId`, or `null` if none matches — in + // which case the caller falls back to the legacy `software_package` flow. + const findSelectedPackage = ( + title: ISoftwareTitleDetails + ): ISoftwarePackage | null => { + if (selectedInstallerId === null) return null; return ( - + title.packages?.find((p) => p.installer_id === selectedInstallerId) ?? + null ); }; + const closeDeleteModal = () => { + setShowDeleteModal(false); + setSelectedInstallerId(null); + }; + + const closeLibraryEditModal = () => { + setShowLibraryEditModal(false); + setSelectedInstallerId(null); + }; + // Delete modal for the active library row's installer. - const renderDeleteModal = () => { + const renderDeleteModal = (title: ISoftwareTitleDetails) => { if (!showDeleteModal || typeof teamIdForApi !== "number") return null; const meta = installerResult?.meta; const isAndroidApp = !!meta?.isAndroidPlayStoreApp; const isAppStoreApp = meta?.installerType === "app-store" && !isAndroidApp; + const selected = findSelectedPackage(title); return ( setShowDeleteModal(false)} + canActivateMultiplePackages={canActivateMultiplePackages} + onExit={closeDeleteModal} onSuccess={() => { - setShowDeleteModal(false); + closeDeleteModal(); onDeleteInstaller(); }} /> @@ -418,21 +573,60 @@ const SoftwareTitleDetailsPage = ({ const renderLibraryEditModal = (title: ISoftwareTitleDetails) => { if (!showLibraryEditModal || !installerResult) return null; const { meta } = installerResult; + // On a multi-package title, the row callback set `selectedInstallerId`; + // resolve it to the actual package so the modal edits the right one. + // Otherwise (single-package back-compat or app-store), `meta.softwareInstaller` + // already points at the only installer. + const selected = findSelectedPackage(title); return ( setShowLibraryEditModal(false)} + onExit={closeLibraryEditModal} installerType={meta.installerType} - openViewYamlModal={onToggleViewYaml} isFleetMaintainedApp={meta.isFleetMaintainedApp} isIosOrIpadosApp={meta.isIosOrIpadosApp} name={title.name} displayName={getDisplayedSoftwareName(title.name, title.display_name)} source={title.source} iconUrl={title.icon_url} + canActivateMultiplePackages={canActivateMultiplePackages} + /> + ); + }; + + const renderPackagePoliciesModal = () => { + if (!selectedPackagePolicies) return null; + return ( + setSelectedPackagePolicies(null)} + /> + ); + }; + + const renderAddPackageModal = (title: ISoftwareTitleDetails) => { + if (!showAddPackageModal || typeof teamIdForApi !== "number") return null; + // First-added is the canonical source for the file-type restriction. + // Multi-package titles always have `packages[0]`; back-compat titles fall + // back to `software_package`. The "+ Add package" button is gated on the + // section being visible, so we always have a name here. + const existingPackageName = + title.packages?.[0]?.name ?? title.software_package?.name ?? ""; + return ( + setShowAddPackageModal(false)} + onSuccess={() => { + setShowAddPackageModal(false); + refetchSoftwareTitle(); + }} /> ); }; @@ -482,8 +676,9 @@ const SoftwareTitleDetailsPage = ({ {renderLibrarySection(softwareTitle)} {renderInventorySection(softwareTitle)} {renderLibraryEditModal(softwareTitle)} - {renderDeleteModal()} - {renderViewYamlModal(softwareTitle)} + {renderDeleteModal(softwareTitle)} + {renderAddPackageModal(softwareTitle)} + {renderPackagePoliciesModal()} {renderVersionsModal(softwareTitle)} ); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/ViewYamlModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/ViewYamlModal.tsx deleted file mode 100644 index 2a3d513d995..00000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/ViewYamlModal.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useContext } from "react"; - -import { AppContext } from "context/app"; - -import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; -import { ISoftwarePackage } from "interfaces/software"; - -import Modal from "components/Modal"; -import Button from "components/buttons/Button"; -import CustomLink from "components/CustomLink"; -import InputField from "components/forms/fields/InputField"; -import Editor from "components/Editor"; - -import { hyphenateString } from "utilities/strings/stringUtils"; -import createPackageYaml from "./helpers"; - -const baseClass = "view-yaml-modal"; - -interface IViewYamlModalProps { - softwareTitleName: string; - iconUrl?: string | null; - displayName?: string; - softwarePackage: ISoftwarePackage; - onExit: () => void; - isScriptPackage?: boolean; -} - -const ViewYamlModal = ({ - softwareTitleName, - iconUrl, - displayName, - softwarePackage, - onExit, - isScriptPackage = false, -}: IViewYamlModalProps) => { - const { config } = useContext(AppContext); - const repositoryUrl = config?.gitops?.repository_url; - const { name, version, url, hash_sha256: sha256 } = softwarePackage; - - const packageYaml = createPackageYaml({ - softwareTitle: softwareTitleName, - packageName: name, - version, - url, - sha256, - iconUrl: iconUrl || null, - displayName, - isScriptPackage, - }); - - const hyphenatedSoftwareTitle = hyphenateString(softwareTitleName); - - return ( - - {repositoryUrl && ( -

- Manage in . -

- )} -
- - - If you added advanced options, learn how to{" "} - - . - - } - /> -
-
- -
-
- ); -}; - -export default ViewYamlModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/_styles.scss deleted file mode 100644 index 20083d8a6bd..00000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/_styles.scss +++ /dev/null @@ -1,13 +0,0 @@ -.view-yaml-modal { - overflow-wrap: anywhere; // Prevent long overflow - - .editor__label { - color: $core-fleet-black; - } - - &__form-fields { - display: flex; - flex-direction: column; - gap: $pad-medium; - } -} \ No newline at end of file diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tests.ts deleted file mode 100644 index 32e067e4d6f..00000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tests.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { createMockSoftwarePackage } from "__mocks__/softwareMock"; - -import createPackageYaml from "./helpers"; - -describe("createPackageYaml", () => { - const { - name, - version, - url, - icon_url: iconUrl, - display_name: displayName, - hash_sha256: sha256, - pre_install_query: preInstallQuery, - install_script: installScript, - post_install_script: postInstallScript, - uninstall_script: uninstallScript, - } = createMockSoftwarePackage(); - - it("generates YAML with all fields present", () => { - const yaml = createPackageYaml({ - softwareTitle: "Falcon Sensor Test Package", - packageName: name, - iconUrl, - displayName, - version, - url, - sha256, - preInstallQuery, - installScript, - postInstallScript, - uninstallScript, - }); - - expect(yaml) - .toBe(`# Falcon Sensor Test Package (TestPackage-1.2.3.pkg) version 1.2.3 -- url: https://fakeurl.testpackageurlforfalconapp.fake/test/package - hash_sha256: abcd1234 - pre_install_query: - path: ../queries/pre-install-query-falcon-sensor-test-package.yml - install_script: - path: ../scripts/install-falcon-sensor-test-package.sh - post_install_script: - path: ../scripts/post-install-falcon-sensor-test-package.sh - uninstall_script: - path: ../scripts/uninstall-falcon-sensor-test-package.sh`); - }); - - it("omits optional fields when not provided", () => { - const yaml = createPackageYaml({ - softwareTitle: "Falcon Sensor Test Package", - packageName: name, - iconUrl, - displayName, - version, - url: undefined, - sha256: undefined, - preInstallQuery: undefined, - installScript: undefined, - postInstallScript: undefined, - uninstallScript: undefined, - }); - - expect(yaml).toBe( - "# Falcon Sensor Test Package (TestPackage-1.2.3.pkg) version 1.2.3" - ); - }); - - it("handles some scripts/queries provided", () => { - const yaml = createPackageYaml({ - softwareTitle: "Falcon Sensor Test Package", - packageName: name, - iconUrl, - displayName, - version, - url: undefined, - sha256: undefined, - preInstallQuery, - installScript: undefined, - postInstallScript, - uninstallScript: undefined, - }); - - expect(yaml) - .toBe(`# Falcon Sensor Test Package (TestPackage-1.2.3.pkg) version 1.2.3 - pre_install_query: - path: ../queries/pre-install-query-falcon-sensor-test-package.yml - post_install_script: - path: ../scripts/post-install-falcon-sensor-test-package.sh`); - }); - - it("hyphenates name correctly for file paths", () => { - const yaml = createPackageYaml({ - softwareTitle: "Falcon Sensor Test Package", - packageName: name, - iconUrl, - displayName, - version, - url: undefined, - sha256: undefined, - preInstallQuery: undefined, - installScript, - postInstallScript: undefined, - uninstallScript: undefined, - }); - - expect(yaml) - .toBe(`# Falcon Sensor Test Package (TestPackage-1.2.3.pkg) version 1.2.3 - install_script: - path: ../scripts/install-falcon-sensor-test-package.sh`); - }); - - it("does not include hash_sha256 if sha256 is null or empty", () => { - const yamlNull = createPackageYaml({ - softwareTitle: "Null Hash", - packageName: name, - iconUrl, - displayName, - version, - url: undefined, - sha256: null, - preInstallQuery: undefined, - installScript, - postInstallScript: undefined, - uninstallScript: undefined, - }); - - const yamlEmpty = createPackageYaml({ - softwareTitle: "Empty Hash", - packageName: name, - iconUrl, - displayName, - version, - url: undefined, - sha256: "", - preInstallQuery: undefined, - installScript, - postInstallScript: undefined, - uninstallScript: undefined, - }); - - expect(yamlNull).toBe(`# Null Hash (TestPackage-1.2.3.pkg) version 1.2.3 - install_script: - path: ../scripts/install-null-hash.sh`); - expect(yamlEmpty).toBe(`# Empty Hash (TestPackage-1.2.3.pkg) version 1.2.3 - install_script: - path: ../scripts/install-empty-hash.sh`); - }); - - it("omits only install_script for script packages, keeping advanced options", () => { - const yaml = createPackageYaml({ - softwareTitle: "My Script Package", - packageName: "my-script.sh", - version: "1.0.0", - url: "https://example.com/my-script.sh", - sha256: "abc123", - preInstallQuery, - installScript, - postInstallScript, - uninstallScript, - iconUrl: null, - displayName, - isScriptPackage: true, - }); - - expect(yaml).toBe(`# My Script Package (my-script.sh) version 1.0.0 -- url: https://example.com/my-script.sh - hash_sha256: abc123 - pre_install_query: - path: ../queries/pre-install-query-my-script-package.yml - post_install_script: - path: ../scripts/post-install-my-script-package.sh - uninstall_script: - path: ../scripts/uninstall-my-script-package.sh`); - - // install_script is never emitted — the file is the install script. - expect(yaml).not.toContain(" install_script:"); - }); - - it("generates icon url and display name", () => { - const yaml = createPackageYaml({ - softwareTitle: "Falcon Sensor Test Package", - packageName: name, - iconUrl: "falcon", - displayName: "Falcon", - version, - url: undefined, - sha256, - preInstallQuery: undefined, - installScript: undefined, - postInstallScript: undefined, - uninstallScript: undefined, - }); - - expect(yaml) - .toBe(`# Falcon Sensor Test Package (TestPackage-1.2.3.pkg) version 1.2.3 -- hash_sha256: abcd1234 - display_name: Falcon - icon: - path: ./icons/falcon-sensor-test-package-icon.png`); - }); -}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tsx deleted file mode 100644 index 489b70ea03f..00000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { hyphenateString } from "utilities/strings/stringUtils"; - -interface CreatePackageYamlParams { - softwareTitle: string; - packageName: string; - version: string; - url?: string; - sha256?: string | null; - preInstallQuery?: string; - installScript?: string; - postInstallScript?: string; - uninstallScript?: string; - iconUrl: string | null; - displayName?: string; - isScriptPackage?: boolean; -} - -const createPackageYaml = ({ - softwareTitle, - packageName, - version, - url, - sha256, - preInstallQuery, - installScript, - postInstallScript, - uninstallScript, - iconUrl, - displayName, - isScriptPackage = false, -}: CreatePackageYamlParams): string => { - let yaml = `# ${softwareTitle} (${packageName}) version ${version} -`; - - if (url) { - yaml += `- url: ${url} -`; - } - - if (sha256) { - yaml += url ? " " : "- "; - yaml += `hash_sha256: ${sha256} -`; - } - - if (displayName) { - yaml += ` display_name: ${displayName} -`; - } - - const hyphenatedSWTitle = hyphenateString(softwareTitle); - - // Script packages don't emit install_script (the file is the install script); - // they do support pre_install_query, post_install_script, and uninstall_script. - if (preInstallQuery) { - yaml += ` pre_install_query: - path: ../queries/pre-install-query-${hyphenatedSWTitle}.yml -`; - } - - if (!isScriptPackage && installScript) { - yaml += ` install_script: - path: ../scripts/install-${hyphenatedSWTitle}.sh -`; - } - - if (postInstallScript) { - yaml += ` post_install_script: - path: ../scripts/post-install-${hyphenatedSWTitle}.sh -`; - } - - if (uninstallScript) { - yaml += ` uninstall_script: - path: ../scripts/uninstall-${hyphenatedSWTitle}.sh -`; - } - - if (iconUrl) { - yaml += ` icon: - path: ./icons/${hyphenatedSWTitle}-icon.png -`; - } - - return yaml.trim(); -}; - -export default createPackageYaml; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/index.ts deleted file mode 100644 index 94390bcf5f9..00000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ViewYamlModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss index 1d7f84dea5d..01e9730b695 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss @@ -15,4 +15,11 @@ flex-direction: column; gap: $pad-medium; } + + &__library-description-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: $pad-medium; + } } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index 57214dfe812..ebb9ec5d2d0 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -1,5 +1,12 @@ +import { createMockSoftwarePackage } from "__mocks__/softwareMock"; import { ISoftwareTitleDetails } from "interfaces/software"; -import { buildLibraryVersionRows, getInstallerCardInfo } from "./helpers"; +import { + buildInstallerDownloadUrl, + buildLibraryVersionRows, + canDownloadInstallerRow, + getInstallerCardInfo, + resolveDownloadTarget, +} from "./helpers"; const v = (id: number, version: string) => ({ id, @@ -93,6 +100,7 @@ describe("SoftwareTitleDetailsPage helpers", () => { icon_url: "https://example.com/icon.png", versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }], software_package: { + installer_id: 1, labels_include_any: null, labels_exclude_any: null, labels_include_all: null, @@ -114,6 +122,7 @@ describe("SoftwareTitleDetailsPage helpers", () => { automatic_install_policies: [], url: "", }, + packages: null, app_store_app: null, source: "apps", hosts_count: 10, @@ -146,6 +155,7 @@ describe("SoftwareTitleDetailsPage helpers", () => { icon_url: "https://example.com/icon.png", versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }], software_package: null, + packages: null, app_store_app: { app_store_id: "1", name: "Test App", @@ -188,4 +198,62 @@ describe("SoftwareTitleDetailsPage helpers", () => { }); }); }); + + describe("canDownloadInstallerRow", () => { + // Guards the observer-download regression: the button must stay hidden + // for any role that lacks installer read permission, even on the active + // row. Backend rejects observers with 403 (policy.rego installable_entity + // read), so the UI shouldn't offer the click. + it("shows the button only when the row is active AND the user has permission", () => { + expect(canDownloadInstallerRow(true, true)).toBe(true); + }); + + it("hides the button when the user lacks installer permission (e.g., observer)", () => { + expect(canDownloadInstallerRow(true, false)).toBe(false); + }); + + it("hides the button on inactive (rollback / older) rows even for authorized users", () => { + expect(canDownloadInstallerRow(false, true)).toBe(false); + }); + + it("hides the button when both conditions fail", () => { + expect(canDownloadInstallerRow(false, false)).toBe(false); + }); + }); + + describe("resolveDownloadTarget", () => { + // Guards the multi-package download flow: clicking a specific row must + // pin the download to that row's package, not the title's first-added. + const first = createMockSoftwarePackage({ + installer_id: 1, + name: "acme-1.pkg", + }); + const second = createMockSoftwarePackage({ + installer_id: 2, + name: "acme-2.pkg", + }); + + it("returns the clicked package when both are provided (#49239)", () => { + expect(resolveDownloadTarget(second, first)).toBe(second); + }); + + it("falls back to the title's software_package when no row pkg is passed", () => { + expect(resolveDownloadTarget(undefined, first)).toBe(first); + }); + + it("returns null when neither is available", () => { + expect(resolveDownloadTarget(undefined, null)).toBeNull(); + expect(resolveDownloadTarget(undefined, undefined)).toBeNull(); + }); + }); + + describe("buildInstallerDownloadUrl", () => { + it("assembles the token-based download URL for the given title id", () => { + expect( + buildInstallerDownloadUrl(42, "abc123", "https://fleet.example.com") + ).toBe( + "https://fleet.example.com/api/latest/fleet/software/titles/42/package/token/abc123" + ); + }); + }); }); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts index 612a626e571..6a35574972c 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts @@ -7,10 +7,42 @@ import { ISoftwarePackage, IFleetMaintainedVersion, } from "interfaces/software"; +import endpoints from "utilities/endpoints"; +import URL_PREFIX from "router/url_prefix"; import { getDisplayedSoftwareName } from "../helpers"; import { deriveAccordionRowState } from "./LibraryItemAccordion/helpers"; import { LibraryItemBadgeState } from "./LibraryItemAccordion/LibraryItemAccordion"; +/** Row-level gate for the LibraryItemAccordion download button: shown only + * when the row is the active (currently-serving) version AND the user has + * installer read permission. Observers and any role excluded from + * `installable_entity` read in policy.rego return `false` here so the button + * is hidden rather than clicked into a backend 403. */ +export const canDownloadInstallerRow = ( + rowIsActive: boolean, + hasInstallerReadPermission: boolean +): boolean => rowIsActive && hasInstallerReadPermission; + +/** Resolves which package to download: the explicitly-clicked row on a + * multi-package title, or the title's first-added `software_package` for + * single-package callers. Returns null when neither is available (e.g., + * an app-store title with no `software_package`). */ +export const resolveDownloadTarget = ( + clicked: ISoftwarePackage | undefined, + fallback: ISoftwarePackage | null | undefined +): ISoftwarePackage | null => clicked ?? fallback ?? null; + +/** Builds the unauthenticated token-based download URL that the browser hits + * via a synthetic `
` click. */ +export const buildInstallerDownloadUrl = ( + softwareTitleId: number, + token: string, + origin: string = global.window.location.origin +): string => + `${origin}${URL_PREFIX}/api${endpoints.SOFTWARE_PACKAGE_TOKEN( + softwareTitleId + )}/${token}`; + export interface InstallerCardInfo { softwareTitleName: string; softwareDisplayName: string; diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index 9cb19a012be..a2251096948 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -25,7 +25,10 @@ import useGitOpsMode from "hooks/useGitOpsMode"; import DataSet from "components/DataSet"; import LastUpdatedHostCount from "components/LastUpdatedHostCount"; +import Button from "components/buttons/Button"; import DropdownWrapper from "components/forms/fields/DropdownWrapper"; +import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import Icon from "components/Icon"; import TooltipWrapper from "components/TooltipWrapper"; import TooltipTruncatedText from "components/TooltipTruncatedText"; import CustomLink from "components/CustomLink"; @@ -201,6 +204,10 @@ interface ISoftwareDetailsSummaryProps { /** Apple VPP — gates Edit software behind the gitops tooltip. See * `BuildActionOptionsArgs.isAppleVpp` for the canonical computation. */ isAppleVpp?: boolean; + /** Custom non-FMA packages collapse the Actions dropdown into a single + * pencil-icon "Edit" button that opens the Edit Appearance modal directly. + * Per-installer Edit lives on the Library accordion row. */ + useSingleEditAppearanceButton?: boolean; } const SoftwareDetailsSummary = ({ @@ -226,6 +233,7 @@ const SoftwareDetailsSummary = ({ patchPolicyId, headerPills, isAppleVpp = false, + useSingleEditAppearanceButton = false, }: ISoftwareDetailsSummaryProps) => { const hostCountPath = getPathWithQueryParams(paths.MANAGE_HOSTS, queryParams); @@ -329,15 +337,35 @@ const SoftwareDetailsSummary = ({ {canManageSoftware && (
- + {useSingleEditAppearanceButton ? ( + // GitOps mode wraps the button so hover surfaces the + // "Managed by GitOps" tooltip + repo link, mirroring how the + // Actions dropdown's items are disabled with the same tip. + ( + + )} + /> + ) : ( + + )}
)}
diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tests.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tests.tsx new file mode 100644 index 00000000000..3907f7ff0b9 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tests.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer } from "test/test-utils"; +import { createMockSoftwarePackage } from "__mocks__/softwareMock"; + +import PackageForm from "./PackageForm"; + +const BASE_PROPS = { + labels: [], + onCancel: jest.fn(), + onSubmit: jest.fn(), + onClickPreviewEndUserExperience: jest.fn(), +}; + +const renderForm = ( + overrides: Partial> = {} +) => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + }, + }, + }); + return render(); +}; + +const TARGET_BANNER_COPY = /If multiple packages of the same software target the same host, Fleet will install the one that was added first\./i; + +describe("PackageForm", () => { + describe("Target section on the single-package Add flow", () => { + it("hides the Target section before a file is selected", () => { + renderForm(); + // Target selector and its info banner should be absent until upload. + expect(screen.queryByText(TARGET_BANNER_COPY)).not.toBeInTheDocument(); + expect(screen.queryByLabelText("All hosts")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Custom")).not.toBeInTheDocument(); + }); + + it("renders the Target section with the first-added banner once a file is selected", () => { + // `defaultSoftware` seeds initialFormData.software (the form casts it to + // File internally), which is the same signal the Add flow raises when + // the user picks a file. Uses the ISoftwarePackage mock to satisfy the + // prop's declared type. + renderForm({ defaultSoftware: createMockSoftwarePackage() }); + expect(screen.getByText(TARGET_BANNER_COPY)).toBeInTheDocument(); + expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); + expect(screen.getByLabelText("Custom")).toBeInTheDocument(); + }); + + it("omits the first-added banner on the Edit flow", () => { + renderForm({ + isEditingSoftware: true, + defaultSoftware: createMockSoftwarePackage(), + }); + // Target selector is present on Edit, but the banner is not — the + // install-order copy only applies to Add flows. + expect(screen.queryByText(TARGET_BANNER_COPY)).not.toBeInTheDocument(); + expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); + expect(screen.getByLabelText("Custom")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx index 77ace51a52d..5ffea8e6b4f 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -12,7 +12,11 @@ import getDefaultInstallScript from "utilities/software_install_scripts"; import getDefaultUninstallScript from "utilities/software_uninstall_scripts"; import { ILabelSummary } from "interfaces/label"; -import { SoftwareCategory } from "interfaces/software"; +import { + IAppStoreApp, + ISoftwarePackage, + SoftwareCategory, +} from "interfaces/software"; import { isScriptOnlyPackageType } from "interfaces/package_type"; import { notify } from "components/ToastNotification"; @@ -28,6 +32,7 @@ import { } from "pages/SoftwarePage/helpers"; import { DropdownTargetLabelSelector } from "components/TargetLabelSelector"; import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector"; +import { GitOpsCustomPackageBanner } from "pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage"; import InfoBanner from "components/InfoBanner"; import CustomLink from "components/CustomLink"; @@ -111,7 +116,11 @@ interface IPackageFormProps { onClickPreviewEndUserExperience: (isIosOrIpadosApp: boolean) => void; isEditingSoftware?: boolean; isFleetMaintainedApp?: boolean; - defaultSoftware?: any; // TODO + /** Installer being edited — seeds the form's target-type / label / category + * defaults. Only passed on the edit flow (`EditSoftwareModal`); the add flow + * leaves it undefined. Not a `File` — the user's newly-picked file lands in + * `formData.software` after `onFileSelect`. */ + defaultSoftware?: ISoftwarePackage | IAppStoreApp; defaultInstallScript?: string; defaultPreInstallQuery?: string; defaultPostInstallScript?: string; @@ -123,6 +132,20 @@ interface IPackageFormProps { gitopsCompatible?: boolean; /** When provided, the categories list is fetched dynamically for this fleet. */ teamId?: number; + /** Set when this form is mounted inside the multi-package add modal. + * Renders a contextual banner just under the file chooser — GitOps copy + * when GitOps mode is on, the first-added-wins copy otherwise. Other + * call sites (single-package add page, edit modal) leave it false. */ + multiPackageContext?: boolean; + /** Restricts the file picker to a specific platform/file type when set — + * used by the multi-package add modal so a second .pkg upload can't slip + * onto a Linux title. Falls back to PackageForm's full all-platforms accept + * + message when omitted. */ + restrictedFileAccept?: string; + restrictedFileTypeLabel?: React.ReactNode; + /** Overrides the initial `targetType` for new (non-editing) forms. The + * multi-package add modal preselects `"Custom"` per Figma. */ + initialTargetType?: string; } // application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly const ACCEPTED_EXTENSIONS = @@ -147,17 +170,28 @@ const PackageForm = ({ className, gitopsCompatible = false, teamId, + multiPackageContext = false, + restrictedFileAccept, + restrictedFileTypeLabel, + initialTargetType, }: IPackageFormProps) => { const { gitOpsModeEnabled, repoURL } = useGitOpsMode("software"); const initialFormData: IPackageFormData = { - software: defaultSoftware || null, + // `formData.software` is typed as `File | null` (its shape once a user + // picks a file), but on the edit flow we seed it with the existing + // installer so truthy-gated UI (advanced options, file details) reads + // correctly before any re-upload. `File` is not extended to include the + // installer types because `formData.software` becomes a real `File` the + // moment `onFileSelect` fires, and downstream (multipart upload) needs + // that shape. + software: ((defaultSoftware as unknown) as File) || null, installScript: defaultInstallScript || "", preInstallQuery: defaultPreInstallQuery || "", postInstallScript: defaultPostInstallScript || "", uninstallScript: defaultUninstallScript || "", selfService: defaultSelfService || false, - targetType: getTargetType(defaultSoftware), + targetType: initialTargetType ?? getTargetType(defaultSoftware), customTarget: getCustomTarget(defaultSoftware), labelTargets: generateSelectedLabels(defaultSoftware), automaticInstall: false, @@ -349,6 +383,7 @@ const PackageForm = ({ !!formData.software && // show after selection !gitOpsModeEnabled && // hide in gitOps mode !isEditingSoftware && // show only on add, not edit + !multiPackageContext && // hide in the multi-package add modal — per Figma 2:130 the modal omits the deploy slider // automatic install is not supported for ipa packages, exe, tarball, or script packages !isIpaPackage && !isExePackage && @@ -367,9 +402,15 @@ const PackageForm = ({ ); - // GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector - // 4.83 Removed option/targets from Add page - const showOptionsTargetsSelectors = !gitOpsModeEnabled && isEditingSoftware; + // GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector. + // Options selector (self-service + categories) stays edit-only. The target + // selector shows whenever a package is being staged — on Edit, in the + // multi-package Add modal, and on the single-package Add page once a file + // is chosen — because every package on a title needs its own label scope. + const showSoftwareOptionsSelector = !gitOpsModeEnabled && isEditingSoftware; + const showTargetLabelSelector = + !gitOpsModeEnabled && + (isEditingSoftware || multiPackageContext || !!formData.software); const renderSoftwareOptionsSelector = () => ( ( - + <> + {!isEditingSoftware && ( + + If multiple packages of the same software target the same host, Fleet + will install the one that was added first. + + )} + + ); return ( @@ -408,8 +462,8 @@ const PackageForm = ({ - {(showDeploySoftwareSlider || showOptionsTargetsSelectors) && ( // Only show container if one of the two components will be rendered to avoid extra gap spacing + {/* GitOps-mode banner lives under the file uploader because the + target section is hidden in GitOps mode. The non-GitOps + first-added-wins banner moved into `renderTargetLabelSelector` + per Figma 5944-4477. */} + {multiPackageContext && gitOpsModeEnabled && ( + + )} + {(showDeploySoftwareSlider || + showSoftwareOptionsSelector || + showTargetLabelSelector) && ( // Only show container if any one component will render — avoids stray gap spacing
{showDeploySoftwareSlider && renderSoftwareDeploySlider()} - {showOptionsTargetsSelectors && ( + {(showSoftwareOptionsSelector || showTargetLabelSelector) && (
- {renderSoftwareOptionsSelector()} - {renderTargetLabelSelector()} + {showSoftwareOptionsSelector && renderSoftwareOptionsSelector()} + {showTargetLabelSelector && renderTargetLabelSelector()}
)}
@@ -460,23 +523,32 @@ const PackageForm = ({ /> )}
- {submitTooltipContent ? ( - + {(() => { + // Single source of truth for the submit button — both the + // tooltipped and non-tooltipped branches need identical text, + // disabled state, and type. A previous duplication let the + // "Save" / "Add software" copy drift between branches. + const submitButton = ( - - ) : ( - - )} + ); + return submitTooltipContent ? ( + + {submitButton} + + ) : ( + submitButton + ); + })()}