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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/fleetctl/fleetctl/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,7 @@ spec:
id: 0
name: foo
software_package: null
packages: null
source: chrome_extensions
extension_for: chrome
display_name: ""
Expand All @@ -1040,6 +1041,7 @@ spec:
id: 0
name: bar
software_package: null
packages: null
source: deb_packages
extension_for: ""
display_name: ""
Expand Down Expand Up @@ -1091,6 +1093,7 @@ spec:
}
],
"software_package": null,
"packages": null,
"app_store_app": null
},
{
Expand All @@ -1111,6 +1114,7 @@ spec:
}
],
"software_package": null,
"packages": null,
"app_store_app": null
}
]
Expand Down
89 changes: 81 additions & 8 deletions ee/server/service/software_installers.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ 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")
}
Expand Down Expand Up @@ -411,18 +412,49 @@ 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 && payload.InstallerID != existingInstaller.InstallerID:
var target *fleet.SoftwareInstaller
for _, p := range siblings {
if p.InstallerID == payload.InstallerID {
target = p
break
}
}
if target == nil {
return nil, ctxerr.Wrapf(ctx, &notFoundError{},
"installer %d does not belong to this title and team", payload.InstallerID)
}
// Icon is title-level; carry it from the first-added read for the activity.
target.IconUrl = existingInstaller.IconUrl
existingInstaller = target
}
}

if payload.IsNoopPayload(software) {
return existingInstaller, nil // no payload, noop
}
Expand Down Expand Up @@ -499,6 +531,15 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
}

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
Expand Down Expand Up @@ -824,8 +865,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")
}
Expand Down Expand Up @@ -916,7 +958,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")
}
Expand All @@ -927,7 +969,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)
Expand All @@ -941,9 +983,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, &notFoundError{}, "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, &notFoundError{}, "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:
Expand Down
22 changes: 0 additions & 22 deletions server/datastore/mysql/in_house_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -1653,28 +1653,6 @@ WHERE
return exists == 1, nil
}

func (ds *Datastore) checkInstallerExistsByName(ctx context.Context, q sqlx.QueryerContext, teamID *uint, name, source, platform string) (bool, error) {
const stmt = `
SELECT 1
FROM
software_titles st
INNER JOIN software_installers ON st.id = software_installers.title_id
AND software_installers.global_or_team_id = ?
WHERE
st.name = ?
AND st.source = ?
AND st.extension_for = ''
AND software_installers.platform = ?
`

var exists int
err := sqlx.GetContext(ctx, q, &exists, stmt, ptr.ValOrZero(teamID), name, source, platform)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, ctxerr.Wrap(ctx, err, "check installer exists by name")
}
return exists == 1, nil
}

func (ds *Datastore) checkInHouseAppExistsForAdamID(ctx context.Context, q sqlx.QueryerContext, teamID *uint, appID fleet.VPPAppID) (exists bool, title string, err error) {
const stmt = `
SELECT st.name
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package tables

import (
"database/sql"
"fmt"
)

func init() {
MigrationClient.AddMigration(Up_20260629163945, Down_20260629163945)
}

func Up_20260629163945(tx *sql.Tx) error {
// A title can now hold several packages. dedup_token drives the new unique key. Custom
// rows resolve it to storage_id so they dedupe by content hash, letting different builds of
// one version coexist. FMA rows resolve it to version, leaving the per-version rows that
// back version pinning unchanged. VIRTUAL keeps the add in-place. The collation is pinned
// to match storage_id and version so the migration matches what fresh installs get.
if _, err := tx.Exec(`
ALTER TABLE software_installers
ADD COLUMN dedup_token VARCHAR(255) COLLATE utf8mb4_unicode_ci
GENERATED ALWAYS AS (IF(fleet_maintained_app_id IS NULL, storage_id, version)) VIRTUAL
`); err != nil {
return fmt.Errorf("adding dedup_token column: %w", err)
}

// Collapse rows that would violate the new key: keep the lowest id per group and delete
// the rest. Re-point policies off the deleted rows first, since
// policies.software_installer_id is RESTRICT.
const dupGroups = `
SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id
FROM software_installers
WHERE title_id IS NOT NULL
GROUP BY global_or_team_id, title_id, dedup_token
HAVING COUNT(*) > 1`

if _, err := tx.Exec(fmt.Sprintf(`
UPDATE policies p
JOIN software_installers si ON si.id = p.software_installer_id
JOIN (%s) dup
ON si.global_or_team_id = dup.global_or_team_id
AND si.title_id = dup.title_id
AND si.dedup_token = dup.dedup_token
SET p.software_installer_id = dup.keep_id
WHERE si.id != dup.keep_id`, dupGroups)); err != nil {
return fmt.Errorf("re-pointing policies off duplicate installers: %w", err)
}

if _, err := tx.Exec(fmt.Sprintf(`
DELETE si FROM software_installers si
JOIN (%s) dup
ON si.global_or_team_id = dup.global_or_team_id
AND si.title_id = dup.title_id
AND si.dedup_token = dup.dedup_token
WHERE si.id != dup.keep_id`, dupGroups)); err != nil {
return fmt.Errorf("deleting duplicate installers: %w", err)
}

if _, err := tx.Exec(`
ALTER TABLE software_installers
DROP INDEX idx_software_installers_team_title_version,
ADD UNIQUE KEY idx_software_installers_dedup (global_or_team_id, title_id, dedup_token)
`); err != nil {
return fmt.Errorf("swapping software_installers unique key: %w", err)
}

return nil
}

func Down_20260629163945(tx *sql.Tx) error {
return nil
}
Loading
Loading