Skip to content

Allow multiple custom packages per software title#49157

Open
cdcme wants to merge 9 commits into
mainfrom
feat/28108-multiple-custom-packages
Open

Allow multiple custom packages per software title#49157
cdcme wants to merge 9 commits into
mainfrom
feat/28108-multiple-custom-packages

Conversation

@cdcme

@cdcme cdcme commented Jul 10, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #28108

Adds support for uploading 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. The software title keeps a single first-added software_package for backwards compatibility, and first-added-wins resolves overlaps consistently across self-service, manual install, policy automation, and setup experience.

Feature branch combining the sub-PRs: migration (#48596), packages[] API and add/edit/delete-package endpoints (#48607), install-time precedence and setup experience (#48708), GitOps (#48710), Library and Add/Edit/Delete modals (#48520), secondary UI — policy automation, setup experience, and install-details hash (#49079), and the host install-result hash_sha256 field (#49085).

Checklist for submitter

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Database migrations

  • Checked schema for all modified table for columns that will auto-update timestamps during migration.
  • Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.
  • Ensured the correct collation is explicitly set for character columns (COLLATE utf8mb4_unicode_ci).

Summary by CodeRabbit

  • New Features

    • Added support for up to 10 custom packages under one software title.
    • Packages can be added, edited, deleted, and deployed individually.
    • Added package selection for software policies, with first-added package selection by default.
    • Added package-specific labels, categories, scripts, self-service, and automatic-install settings.
    • GitOps now supports multi-package YAML files and round-trip generation.
    • Added SHA-256 visibility and copy support in software installation details.
  • Bug Fixes

    • Improved package precedence, duplicate detection, policy targeting, and setup-experience handling.
    • Prevented duplicate software entries when titles contain multiple packages.

jkatz01 and others added 7 commits July 3, 2026 10:02
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #48396 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [ ] Timeouts are implemented and retries are limited to avoid infinite
loops
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
    - shouldn't update timestamps unless the installer was a duplicate
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

- [ ] Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [ ] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Software titles can now include multiple custom packages without
duplicating the title in lists.
* Added clearer package details when viewing software tied to a title or
team.

* **Bug Fixes**
* Improved installer conflict handling so uploads now show more specific
error messages.
* Prevented duplicate package entries from creating extra title rows or
incorrect counts.
* Fixed package counting and selection so the first-added package is
used consistently when multiple exist.

* **Chores**
* Added database changes to enforce the new package-deduplication
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
**Related issue:** Resolves #48397

  ## Summary

Adds the REST API layer for multiple custom packages per software title
(backend only):

- `GET /software/titles` and `GET /software/titles/:id` return a new
`packages[]` array; `software_package` is retained as the first-added
package for backwards compatibility.
- `POST /software/package` adds a package to an existing title and
returns the added package.
- `PATCH /software/titles/:id/package` targets a specific `installer_id`
and rejects a replacement whose hash matches a sibling package (409).
- `DELETE /software/titles/:id/available_for_install?installer_id=`
deletes one package; omitting `installer_id` deletes them all.

Builds on the data-model foundation (#48396). Install-time precedence
and the host-software endpoint are out of scope (#48398).

  # Checklist for submitter

  - [ ] Changes file added for user-visible changes in `changes/`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements).
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes.

  ## Testing

  - [x] Added/updated automated tests
  - [x] QA'd all new/changed functionality manually
**Related issue:** Resolves #48398

Applies first-added-wins install precedence everywhere a host can match
more than one package of a software title: manual install, self-service,
policy auto-install, setup experience, and the host-software read all
resolve the first-added package the host is in label scope for. Policy
automations can now target a specific package (defaulting to
first-added), and deleting a package re-points its automations to the
first-added surviving package.

  # Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements).

  ## Testing

  - [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)
  - [x] QA'd all new/changed functionality manually
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #48399
Summary:
- Allows multiple installers for the same title to be defined in a yaml
file
- `generate-gitops` generates a file like this if multiple installers
are available per title
- Allows labels, self_service, categories keys to be defined per package
- Inherits fleet-level keys only if they are not set at the
package-level
- Repoints policies.software_installer_id for a deleted installer to
either the first added installer for that title, or NULL if none are
available

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [ ] Timeouts are implemented and retries are limited to avoid infinite
loops
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* GitOps output now supports software titles that contain multiple
packages, generating a dedicated package file plus related assets.
* Software imports and updates now preserve package order and handle
multi-package titles more consistently.

* **Bug Fixes**
* Improved inheritance and validation for software fields so
package-level settings are respected and conflicting settings are
flagged.
* Fixed installer batch updates to better handle added, removed, and
reordered packages without disrupting related policies or pending
installs.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ctivity (#49085)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #
- Adds hash_sha256 field to host installer result and install software
activity so they can be used in combination to cover past activities
with existing installers + all new activities even if their installer
will get deleted

# Checklist for submitter


## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Software installation results now include the installer’s SHA-256 hash
when available.
* Installed-software activity records now include the corresponding
`hash_sha256` value.

* **Bug Fixes**
* Hash values are retained in activity history after an installer is
deleted, while live results correctly show the value as unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Copilot AI review requested due to automatic review settings July 10, 2026 18:36
@cdcme cdcme requested review from a team as code owners July 10, 2026 18:36
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5a7e3846-3e48-4159-b576-0da5ee42ae7b

📥 Commits

Reviewing files that changed from the base of the PR and between 78b9176 and 06863c4.

📒 Files selected for processing (4)
  • cmd/fleetctl/fleetctl/generate_gitops.go
  • cmd/fleetctl/fleetctl/generate_gitops_test.go
  • cmd/fleetctl/fleetctl/testing_utils/testing_utils.go
  • cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • cmd/fleetctl/fleetctl/testing_utils/testing_utils.go
  • cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go
  • cmd/fleetctl/fleetctl/generate_gitops_test.go
  • cmd/fleetctl/fleetctl/generate_gitops.go

Walkthrough

This change adds support for up to 10 custom packages per software title. Packages can be independently targeted, edited, deleted, exported, and selected for installation, with first-added precedence when multiple packages match. APIs, datastore queries, policy automation, setup experience, GitOps YAML parsing/generation, and software management UI now expose package-level data. Install results and activities also preserve package SHA-256 hashes.

Possibly related issues

Possibly related PRs

  • fleetdm/fleet#48596 — Implements related multi-package installer precedence, deduplication, and update behavior.
  • fleetdm/fleet#48710 — Shares the multi-package GitOps YAML generation path and tests.
  • fleetdm/fleet#47439 — Modifies the same GitOps software generation and package-category handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: support for multiple custom packages per software title.
Linked Issues check ✅ Passed The code changes implement multi-package upload, API/GitOps, precedence, UI, validation, and regression coverage required by #28108.
Out of Scope Changes check ✅ Passed The diff is tightly centered on multi-package software-title support, with no clear unrelated feature work beyond that scope.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/28108-multiple-custom-packages

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds end-to-end support for multiple custom software packages per software title (up to 10) within a team, while preserving the legacy software_package (first-added) field for backwards compatibility and applying “first-added-wins” precedence consistently.

Changes:

  • Backend: extend software title/package models, list/detail query paths, policy automation pinning (software_installer_id), and setup-experience dedupe to support multi-package titles.
  • GitOps: allow multi-package YAML parsing + stable generation/round-trip for multiple packages per title.
  • Frontend: add multi-package Library UX (add/edit/delete per package), policy automation “select package” picker, setup experience tooltip copy, and install-details hash display.

Reviewed changes

Copilot reviewed 92 out of 94 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
server/service/testing_client_test.go Adds installer_id multipart field support in test client update helper.
server/service/team_policies.go Threads software_installer_id through policy create/modify and validates chosen installer against title packages.
server/service/team_policies_test.go Extends policy automation population test to assert installer pin behavior.
server/service/software_titles.go Populates packages[] and keeps software_package as first-added for detail responses; per-package categories/policies.
server/service/software_installers.go Adds installer targeting on update and optional per-installer deletion on delete endpoint.
server/service/software_installers_test.go Updates auth test to reflect new package lookup + delete signature.
server/service/orbit.go Includes hash_sha256 in installed software activity details.
server/service/integration_vpp_install_test.go Updates expected conflict messages for VPP vs installer conflicts.
server/service/integration_mdm_setup_experience_test.go Extends setup experience activity assertions to include hash_sha256.
server/mock/service/service_mock.go Updates mock service interface for delete signature including optional installer id.
server/mock/datastore_mock.go Adds mocks for installer-scoped categories and multi-package fetch helpers.
server/fleet/software.go Adds packages to title/list shapes; clarifies back-compat software_package semantics.
server/fleet/software_installer.go Adds hash_sha256 to host installer results; introduces package list item type + validation helpers.
server/fleet/service.go Updates service interface for installer-targeted delete.
server/fleet/policies.go Adds software_installer_id to policy payloads and response shape for pinned package selection.
server/fleet/errors.go Adds new conflict/validation messages for multi-package + GitOps validations.
server/fleet/datastore.go Adds datastore APIs to fetch packages per title(s) and installer-scoped categories.
server/fleet/api_policies.go Adds software_installer_id to team policy request payload.
server/fleet/activities.go Adds hash_sha256 to installed-software activity detail.
server/datastore/mysql/software_titles.go Prevents duplicate title rows via MIN(installer_id) join; adds packages[] fetch per title; adds GetSoftwarePackagesForTitles.
server/datastore/mysql/software_titles_test.go Adds regression tests for multi-package title dedupe + per-installer policy dispatch.
server/datastore/mysql/software_test.go Adds host software list precedence test for multi-package label scoping + first-added-wins.
server/datastore/mysql/setup_experience.go Dedupes setup queue to first-added installer per title (no double-queue).
server/datastore/mysql/setup_experience_test.go Adds test ensuring only first-added package is queued during setup.
server/datastore/mysql/policies.go Makes GitOps policy resolution deterministic via first-added installer; adds installer join key.
server/datastore/mysql/policies_test.go Adds tests for installer join key and GitOps first-added resolution behavior.
server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go Migration adds dedup_token and new unique key; dedupes existing rows and repoints FKs before deletion.
server/datastore/mysql/in_house_apps.go Removes installer-by-name existence helper (now multi-package aware paths elsewhere).
pkg/spec/gitops.go Parses package YAML as list; validates fleet/package-level field placement; hydrates inheritance rules.
pkg/spec/gitops_test.go Adds tests for multi-package field placement rules + inheritance behaviors.
frontend/test/handlers/software-handlers.ts Adds MSW handler to return install results with hash_sha256.
frontend/services/entities/team_policies.ts Threads software_installer_id through team policy create/update API calls.
frontend/services/entities/software.ts Adds software_title_id to add-package flow; adds installer_id to edit/delete package calls.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/ViewYamlModal.tsx Removes View YAML modal (flow removed).
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/index.ts Removes View YAML modal export.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tsx Removes YAML-generation helper.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tests.ts Removes YAML-generation helper tests.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/_styles.scss Removes View YAML modal styles.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx Adjusts chips/actions for multi-package titles; pluralizes “Custom package(s)”.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx Adds per-row self-service/auto-install indicator icons + GitOps delete lock for multi-package custom titles.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx Adds tests for multi-package row icon behavior and Latest badge gating.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts Updates helper tests for new installer_id and packages nullability.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx Supports per-installer editing + “Edit package” title; uses shared navigation-block hook.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tests.tsx Adds tests for “Edit package” title and installerId prop acceptance.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx Supports per-installer deletion + “Delete package” title changes.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx Adds tests for multi-package delete title + warning suppression.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts Exports new AddPackageModal.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts Adds file-type restriction helper for adding packages to an existing title.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts Tests file-type restriction helper.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx New modal for adding a package to an existing multi-package title.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx Tests AddPackageModal behavior in standard and GitOps mode.
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss Adds layout styling for new library description row.
frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx Introduces shared GitOps banner component; switches to navigation-block hook.
frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tests.tsx Tests shared GitOps banner content/link.
frontend/pages/SoftwarePage/helpers.tsx Makes target helpers accept undefined installer data.
frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx Adds multi-package context banner + file accept restriction + GitOps banner reuse; adjusts save button text consistency.
frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx Collapses Actions into single Edit button when multi-package gate is enabled.
frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts Includes software_installer_id in policy automation update type.
frontend/pages/policies/helpers.ts Adds multi-package help text, package help text, and “first-added package” resolver.
frontend/pages/policies/helpers.tests.tsx Adds tests for multi-package “N versions” help text behavior.
frontend/pages/policies/edit/components/PolicyForm/_styles.scss Adds responsive layout tweaks for dual dropdowns when schema sidebar is open.
frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx Adds per-package dropdown + auto-select/validation logic + payload includes installer id.
frontend/pages/policies/components/PolicyAutomationsFields/_styles.scss Adds layout styling for dual dropdowns and tighter table layout.
frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx Adds tooltip to Version column describing first-added behavior for custom packages.
frontend/interfaces/software.ts Adds installer_id, packages, max-packages constant, and install result hash field.
frontend/interfaces/policy.ts Adds software_installer_id to policy software automation shape.
frontend/hooks/useBlockNavigation.ts New shared beforeunload blocking hook for uploads/patch flows.
frontend/hooks/useBlockNavigation.tests.ts Adds hook tests for add/remove handler behavior.
frontend/docs/patterns.md Documents useBlockNavigation hook.
frontend/components/InfoBanner/InfoBanner.tsx Adds optional leading icon support to InfoBanner component.
frontend/components/InfoBanner/_styles.scss Styles InfoBanner layout when leading icon is present.
frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx Adds ariaLabel prop and ensures combobox always has an accessible name.
frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx Adds guarded “Package SHA-256 hash” row + copy button.
frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx Adds tests for hash row presence/absence based on payload.
frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss Styles hash row to keep copy button visible with long hashes.
frontend/mocks/softwareMock.ts Updates software mocks with installer_id and packages fields.
ee/server/service/software_installers_test.go Updates EE install/uninstall tests for precedence resolver using packages list.
cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go Adds enterprise integration test covering multi-package apply/generate/reapply stability.
cmd/fleetctl/fleetctl/testing_utils/testing_utils.go Updates installer test server to serve distinct-hash variant package content.
cmd/fleetctl/fleetctl/get_test.go Updates get output tests to include packages: null in shapes.
cmd/fleetctl/fleetctl/generate_gitops.go Generates multi-package package YAML side file and references from fleet file; refactors label scoping helper.
cmd/fleetctl/fleetctl/generate_gitops_test.go Adds unit test verifying multi-package generate output structure + side files.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +695 to +699
if !p.SoftwareTitleID.Set && p.SoftwareInstallerID.Set && p.SoftwareInstallerID.Value != 0 {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: "software_installer_id can only be set together with software_title_id",
})
}
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;
…ustom-packages

# Conflicts:
#	frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
#	frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
#	pkg/spec/gitops.go
#	server/datastore/mysql/schema.sql
#	server/fleet/software_installer.go
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.90398% with 219 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.72%. Comparing base (e0c6411) to head (06863c4).

Files with missing lines Patch % Lines
server/datastore/mysql/software_installers.go 80.85% 37 Missing and 26 partials ⚠️
ee/server/service/software_installers.go 76.84% 12 Missing and 10 partials ⚠️
server/datastore/mysql/software.go 91.33% 13 Missing and 9 partials ⚠️
...tleDetailsPage/AddPackageModal/AddPackageModal.tsx 48.71% 20 Missing ⚠️
...s/20260710184259_MultipleCustomPackagesPerTitle.go 77.77% 8 Missing and 6 partials ⚠️
server/service/software_titles.go 68.18% 8 Missing and 6 partials ⚠️
server/datastore/mysql/software_titles.go 84.81% 6 Missing and 6 partials ⚠️
server/service/team_policies.go 67.74% 7 Missing and 3 partials ⚠️
...olicyAutomationsFields/PolicyAutomationsFields.tsx 80.43% 8 Missing and 1 partial ⚠️
cmd/fleetctl/fleetctl/generate_gitops.go 91.30% 3 Missing and 3 partials ⚠️
... and 9 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #49157      +/-   ##
==========================================
- Coverage   67.93%   65.72%   -2.21%     
==========================================
  Files        3764     3768       +4     
  Lines      238273   239399    +1126     
  Branches    12452    12555     +103     
==========================================
- Hits       161868   157345    -4523     
- Misses      61773    67232    +5459     
- Partials    14632    14822     +190     
Flag Coverage Δ
backend 66.77% <83.69%> (-2.73%) ⬇️
frontend 59.88% <79.09%> (+0.54%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx (1)

97-127: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a package-specific success message.

Line 105 still says “Successfully deleted software.” when multi-package mode deletes only one installer. Make the toast conditional, and include canActivateMultiplePackages in the callback dependencies.

Proposed fix
-      notify.success("Successfully deleted software.");
+      notify.success(
+        canActivateMultiplePackages
+          ? "Successfully deleted package."
+          : "Successfully deleted software."
+      );
@@
-  }, [softwareId, teamId, installerId, onSuccess, onExit]);
+  }, [
+    softwareId,
+    teamId,
+    installerId,
+    canActivateMultiplePackages,
+    onSuccess,
+    onExit,
+  ]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx`
around lines 97 - 127, Update onDeleteSoftware to conditionally display a
package-specific success toast when canActivateMultiplePackages is enabled,
while retaining the existing message otherwise. Add canActivateMultiplePackages
to the useCallback dependency array.
cmd/fleetctl/fleetctl/generate_gitops.go (2)

2148-2161: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

generateMultiPackage doesn't export Configuration (managed app config), unlike the single-package path.

The single-package branch above unwraps and writes softwareTitle.SoftwarePackage.Configuration to a side file (in-house app managed XML config), but the per-package loop in generateMultiPackage has no equivalent — any in-house app package with Configuration set that's part of a multi-package title will silently lose that config on GitOps export/round-trip.

♻️ Suggested fix: mirror the single-package Configuration handling per item
 		if key, names := scopeLabels(pkg.LabelsIncludeAny, pkg.LabelsExcludeAny, pkg.LabelsIncludeAll); key != "" {
 			item[key] = names
 		}
+		if len(pkg.Configuration) > 0 {
+			var xmlStr string
+			if err := json.Unmarshal(pkg.Configuration, &xmlStr); err != nil {
+				return nil, err
+			}
+			if xmlStr != "" {
+				item["configuration"] = map[string]any{
+					"path": writeSideFile("software", prefix+"-config.xml", []byte(xmlStr)),
+				}
+			}
+		}
 		items = append(items, item)

Also applies to: 2299-2336

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/fleetctl/fleetctl/generate_gitops.go` around lines 2148 - 2161, Update
generateMultiPackage to mirror the single-package Configuration handling for
each package item: JSON-unmarshal the package Configuration into XML, report and
return decoding errors, write non-empty XML to a package-specific config file
under the appropriate lib path, and set the item’s
softwareSpec["configuration"]["path"] accordingly. Ensure filenames are unique
per package and apply the same logic to the additional code path around the
referenced package loop.

1971-2015: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the policy’s installer ID when exporting install_software. In cmd/fleetctl/fleetctl/generate_gitops.go:1655, generatePolicies keys cmd.SoftwareList by SoftwareTitleID, so a multi-package policy pinned to a non-first package is serialized with the title’s first-added hash. Fall back to SoftwareTitleID only when SoftwareInstallerID is nil.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/fleetctl/fleetctl/generate_gitops.go` around lines 1971 - 2015, Use the
policy’s SoftwareInstallerID when looking up software entries for
install_software generation, since cmd.SoftwareList is populated by installer ID
in the software export logic. Update generatePolicies to key the lookup by
*SoftwareInstallerID when present, falling back to SoftwareTitleID only when it
is nil, ensuring multi-package policies use the pinned installer’s hash and
metadata.
🧹 Nitpick comments (3)
frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx (1)

63-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the installer-specific delete request.

Line 78 only verifies presentation; it neither supplies an installerId nor clicks Delete. Add a multi-package test that asserts the API receives the selected installer ID, since this is the destructive behavior introduced by the feature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx`
around lines 63 - 85, Extend the multi-package test coverage around
DeleteSoftwareModal to exercise the destructive action, not just rendered text.
Render with canActivateMultiplePackages true and a specific installerId, click
the Delete control, and assert the delete API/mock is called with that selected
installer ID. Reuse the existing modal render helper and API mock symbols to
verify the request payload.
frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx (1)

35-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared banner to break the import cycle.

Line 35 imports from SoftwareCustomPackage, while that page imports PackageForm. Move GitOpsCustomPackageBanner into a standalone shared module and import it from both callers; the current cycle makes module initialization and testing more brittle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx` at
line 35, Move GitOpsCustomPackageBanner out of SoftwareCustomPackage into a
standalone shared module, then update both SoftwareCustomPackage and PackageForm
to import it from that module. Remove the direct PackageForm import from the
page component to eliminate the circular dependency.
server/service/software_titles.go (1)

229-234: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Optional: per-package status summary is an N+1.

GetSummaryHostSoftwareInstalls is called once per package inside the loop (up to 10 round-trips per title-detail request). If a batched variant keyed by installerID is (or can be) available, prefer it to collapse these into a single query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/software_titles.go` around lines 229 - 234, Replace the
per-package GetSummaryHostSoftwareInstalls call in the package loop with a
batched lookup keyed by installer ID, using or adding a data-store method that
retrieves all summaries in one query; then assign each returned summary to its
corresponding pkg.Status and preserve the existing error wrapping.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/pages/policies/components/PolicyAutomationsFields/_styles.scss`:
- Around line 95-102: Update the comment above the `&__row-picker` styles to
reference the actual fixed width of 225px instead of 300px.

In
`@frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx`:
- Around line 289-320: Wrap the callback props in renderAppStoreRow with
zero-argument functions so the click event is not passed as the installer ID:
use closures for onTrashClick, onLabelCountClick, and onLabelsClick that invoke
openDeleteModal/openEditModal with the intended app-store identifier or no
argument, matching renderPackageRows and the expected callback signatures.

In `@pkg/spec/gitops.go`:
- Around line 2267-2304: Empty package files or lists currently pass validation
without registering a package. Before the per-package loop in the
package-loading logic, add a len(softwarePackageSpecs) == 0 check and append an
appropriate validation error referencing the package path, then continue without
hydration.

In `@server/datastore/mysql/software.go`:
- Around line 3794-3908: The label CTEs in ListHostSoftware currently aggregate
all installers before applying fleet, active-status, and title filters. Add a
candidate-installer CTE (or equivalent filtered seed) using global_or_team_id,
is_active, and title_ids, then join each of no_labels, include_any, exclude_any,
and include_all to that candidate set before aggregation; retain the final
result and ordering semantics.

---

Outside diff comments:
In `@cmd/fleetctl/fleetctl/generate_gitops.go`:
- Around line 2148-2161: Update generateMultiPackage to mirror the
single-package Configuration handling for each package item: JSON-unmarshal the
package Configuration into XML, report and return decoding errors, write
non-empty XML to a package-specific config file under the appropriate lib path,
and set the item’s softwareSpec["configuration"]["path"] accordingly. Ensure
filenames are unique per package and apply the same logic to the additional code
path around the referenced package loop.
- Around line 1971-2015: Use the policy’s SoftwareInstallerID when looking up
software entries for install_software generation, since cmd.SoftwareList is
populated by installer ID in the software export logic. Update generatePolicies
to key the lookup by *SoftwareInstallerID when present, falling back to
SoftwareTitleID only when it is nil, ensuring multi-package policies use the
pinned installer’s hash and metadata.

In
`@frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx`:
- Around line 97-127: Update onDeleteSoftware to conditionally display a
package-specific success toast when canActivateMultiplePackages is enabled,
while retaining the existing message otherwise. Add canActivateMultiplePackages
to the useCallback dependency array.

---

Nitpick comments:
In `@frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx`:
- Line 35: Move GitOpsCustomPackageBanner out of SoftwareCustomPackage into a
standalone shared module, then update both SoftwareCustomPackage and PackageForm
to import it from that module. Remove the direct PackageForm import from the
page component to eliminate the circular dependency.

In
`@frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx`:
- Around line 63-85: Extend the multi-package test coverage around
DeleteSoftwareModal to exercise the destructive action, not just rendered text.
Render with canActivateMultiplePackages true and a specific installerId, click
the Delete control, and assert the delete API/mock is called with that selected
installer ID. Reuse the existing modal render helper and API mock symbols to
verify the request payload.

In `@server/service/software_titles.go`:
- Around line 229-234: Replace the per-package GetSummaryHostSoftwareInstalls
call in the package loop with a batched lookup keyed by installer ID, using or
adding a data-store method that retrieves all summaries in one query; then
assign each returned summary to its corresponding pkg.Status and preserve the
existing error wrapping.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 56ca2a3d-9e67-4f30-9fbc-e42eb5de7ef5

📥 Commits

Reviewing files that changed from the base of the PR and between e0c6411 and 78b9176.

⛔ Files ignored due to path filters (2)
  • frontend/docs/patterns.md is excluded by !**/*.md
  • server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz is excluded by !**/*.gz
📒 Files selected for processing (92)
  • changes/28108-multiple-custom-packages
  • cmd/fleetctl/fleetctl/generate_gitops.go
  • cmd/fleetctl/fleetctl/generate_gitops_test.go
  • cmd/fleetctl/fleetctl/get_test.go
  • cmd/fleetctl/fleetctl/testing_utils/testing_utils.go
  • cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go
  • ee/server/service/software_installers.go
  • ee/server/service/software_installers_test.go
  • frontend/__mocks__/softwareMock.ts
  • frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx
  • frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx
  • frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss
  • frontend/components/InfoBanner/InfoBanner.tsx
  • frontend/components/InfoBanner/_styles.scss
  • frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx
  • frontend/hooks/useBlockNavigation.tests.ts
  • frontend/hooks/useBlockNavigation.ts
  • frontend/interfaces/policy.ts
  • frontend/interfaces/software.ts
  • frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx
  • frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tests.tsx
  • frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tests.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tests.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tests.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/ViewYamlModal.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/_styles.scss
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tests.ts
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/index.ts
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
  • frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
  • frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx
  • frontend/pages/SoftwarePage/helpers.tsx
  • frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx
  • frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx
  • frontend/pages/policies/components/PolicyAutomationsFields/_styles.scss
  • frontend/pages/policies/edit/components/PolicyForm/_styles.scss
  • frontend/pages/policies/helpers.tests.tsx
  • frontend/pages/policies/helpers.ts
  • frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts
  • frontend/services/entities/software.ts
  • frontend/services/entities/team_policies.ts
  • frontend/test/handlers/software-handlers.ts
  • pkg/spec/gitops.go
  • pkg/spec/gitops_test.go
  • server/datastore/mysql/in_house_apps.go
  • server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go
  • server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle_test.go
  • server/datastore/mysql/policies.go
  • server/datastore/mysql/policies_test.go
  • server/datastore/mysql/schema.sql
  • server/datastore/mysql/setup_experience.go
  • server/datastore/mysql/setup_experience_test.go
  • server/datastore/mysql/software.go
  • server/datastore/mysql/software_installers.go
  • server/datastore/mysql/software_installers_test.go
  • server/datastore/mysql/software_test.go
  • server/datastore/mysql/software_titles.go
  • server/datastore/mysql/software_titles_test.go
  • server/fleet/activities.go
  • server/fleet/api_policies.go
  • server/fleet/datastore.go
  • server/fleet/errors.go
  • server/fleet/policies.go
  • server/fleet/service.go
  • server/fleet/software.go
  • server/fleet/software_installer.go
  • server/mock/datastore_mock.go
  • server/mock/service/service_mock.go
  • server/service/integration_enterprise_test.go
  • server/service/integration_mdm_setup_experience_test.go
  • server/service/integration_vpp_install_test.go
  • server/service/orbit.go
  • server/service/software_installers.go
  • server/service/software_installers_test.go
  • server/service/software_titles.go
  • server/service/team_policies.go
  • server/service/team_policies_test.go
  • server/service/testing_client_test.go
💤 Files with no reviewable changes (6)
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/_styles.scss
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tsx
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/index.ts
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/helpers.tests.ts
  • frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/ViewYamlModal/ViewYamlModal.tsx
  • server/datastore/mysql/in_house_apps.go

Comment on lines 95 to 102
&__row-picker {
max-width: 300px;
// Pin to a fixed 300px so the control doesn't wobble when the selected
// option's label changes length (short vs long package name).
width: 225px;
max-width: 100%; // Guard against narrow containers
margin-left: auto;
text-align: left;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Comment references the wrong pixel value.

The comment says "Pin to a fixed 300px" but the actual value set is 225px.

📝 Fix stale comment
   &__row-picker {
-    // Pin to a fixed 300px so the control doesn't wobble when the selected
-    // option's label changes length (short vs long package name).
+    // Pin to a fixed 225px so the control doesn't wobble when the selected
+    // option's label changes length (short vs long package name).
     width: 225px;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
&__row-picker {
max-width: 300px;
// Pin to a fixed 300px so the control doesn't wobble when the selected
// option's label changes length (short vs long package name).
width: 225px;
max-width: 100%; // Guard against narrow containers
margin-left: auto;
text-align: left;
}
&__row-picker {
// Pin to a fixed 225px so the control doesn't wobble when the selected
// option's label changes length (short vs long package name).
width: 225px;
max-width: 100%; // Guard against narrow containers
margin-left: auto;
text-align: left;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/policies/components/PolicyAutomationsFields/_styles.scss`
around lines 95 - 102, Update the comment above the `&__row-picker` styles to
reference the actual fixed width of 225px instead of 300px.

Comment on lines +289 to +320
const renderAppStoreRow = () => {
if (!appStore) return null;
const { labels, kind } = pickLabels(appStore);
const isAndroidPlayStoreApp = appStore.platform === "android";
const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source);
return (
<LibraryItemAccordion
filename={appStore.name}
version={appStore.latest_version}
addedAt={appStore.created_at}
installerType="app-store"
androidPlayStoreId={
isAndroidPlayStoreApp ? appStore.app_store_id : undefined
}
isIosOrIpadosApp={isIosOrIpadosApp}
isActive
badgeState="latest"
labels={labels}
labelKind={kind}
canEditSoftware={canEditSoftware}
installed={appStore.status?.installed ?? 0}
pending={appStore.status?.pending ?? 0}
failed={appStore.status?.failed ?? 0}
installedPath={statusPath("installed")}
pendingPath={statusPath("pending")}
failedPath={statusPath("failed")}
onLabelCountClick={openEditModal}
onLabelsClick={openEditModal}
onTrashClick={openDeleteModal}
/>
);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unwrapped callback refs risk leaking the click event as id into openDeleteModal/openEditModal.

onTrashClick={openDeleteModal}, onLabelCountClick={openEditModal}, and onLabelsClick={openEditModal} pass the functions directly instead of wrapping them (unlike renderPackageRows, which correctly uses () => openDeleteModal(pkg.installer_id) etc.). renderTrashButtonBody's <Button onClick={onTrashClick}> forwards the native click event as the first argument, so openDeleteModal/openEditModal would receive a MouseEvent as their id parameter here, not undefined.

This currently doesn't manifest as a visible bug — findSelectedPackage compares against title.packages, which is empty/null for app-store titles, so the mismatch resolves to the same fallback (null/meta.softwareInstaller) either way. Still, it's fragile and type-unsound; wrapping these consistently with the rest of the file would remove the latent risk.

🔧 Suggested fix
-          onLabelCountClick={openEditModal}
-          onLabelsClick={openEditModal}
+          onLabelCountClick={() => openEditModal()}
+          onLabelsClick={() => openEditModal()}
           ...
-          onTrashClick={openDeleteModal}
+          onTrashClick={() => openDeleteModal()}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const renderAppStoreRow = () => {
if (!appStore) return null;
const { labels, kind } = pickLabels(appStore);
const isAndroidPlayStoreApp = appStore.platform === "android";
const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source);
return (
<LibraryItemAccordion
filename={appStore.name}
version={appStore.latest_version}
addedAt={appStore.created_at}
installerType="app-store"
androidPlayStoreId={
isAndroidPlayStoreApp ? appStore.app_store_id : undefined
}
isIosOrIpadosApp={isIosOrIpadosApp}
isActive
badgeState="latest"
labels={labels}
labelKind={kind}
canEditSoftware={canEditSoftware}
installed={appStore.status?.installed ?? 0}
pending={appStore.status?.pending ?? 0}
failed={appStore.status?.failed ?? 0}
installedPath={statusPath("installed")}
pendingPath={statusPath("pending")}
failedPath={statusPath("failed")}
onLabelCountClick={openEditModal}
onLabelsClick={openEditModal}
onTrashClick={openDeleteModal}
/>
);
};
const renderAppStoreRow = () => {
if (!appStore) return null;
const { labels, kind } = pickLabels(appStore);
const isAndroidPlayStoreApp = appStore.platform === "android";
const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source);
return (
<LibraryItemAccordion
filename={appStore.name}
version={appStore.latest_version}
addedAt={appStore.created_at}
installerType="app-store"
androidPlayStoreId={
isAndroidPlayStoreApp ? appStore.app_store_id : undefined
}
isIosOrIpadosApp={isIosOrIpadosApp}
isActive
badgeState="latest"
labels={labels}
labelKind={kind}
canEditSoftware={canEditSoftware}
installed={appStore.status?.installed ?? 0}
pending={appStore.status?.pending ?? 0}
failed={appStore.status?.failed ?? 0}
installedPath={statusPath("installed")}
pendingPath={statusPath("pending")}
failedPath={statusPath("failed")}
onLabelCountClick={() => openEditModal()}
onLabelsClick={() => openEditModal()}
onTrashClick={() => openDeleteModal()}
/>
);
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx`
around lines 289 - 320, Wrap the callback props in renderAppStoreRow with
zero-argument functions so the click event is not passed as the installer ID:
use closures for onTrashClick, onLabelCountClick, and onLabelsClick that invoke
openDeleteModal/openEditModal with the intended app-store identifier or no
argument, matching renderPackageRows and the expected callback signatures.

Comment thread pkg/spec/gitops.go
Comment on lines 2267 to +2304

softwarePackageSpecs[i].ReferencedYamlPath = resolvedPath
}
// A package YAML file is a list of packages. A file written as a single
// object is treated as a one-element list.
var singlePackageSpec fleet.SoftwarePackageSpec
listErr := YamlUnmarshal(fileBytes, &softwarePackageSpecs)
if listErr == nil {
multiError = multierror.Append(multiError, validateYAMLKeys(fileBytes, reflect.TypeFor[[]fleet.SoftwarePackageSpec](), *teamLevelPackage.Path, []string{"software", "packages"})...)
} else if err := YamlUnmarshal(fileBytes, &singlePackageSpec); err == nil {
multiError = multierror.Append(multiError, validateYAMLKeys(fileBytes, reflect.TypeFor[fleet.SoftwarePackageSpec](), *teamLevelPackage.Path, []string{"software", "packages"})...)
softwarePackageSpecs = append(softwarePackageSpecs, &singlePackageSpec)
} else {
// If we reached here, we couldn't unmarshal as either format.
multiError = multierror.Append(multiError, MaybeParseTypeError(*teamLevelPackage.Path, []string{"software", "packages"}, err))
// couldn't unmarshal as a list or a single object
multiError = multierror.Append(multiError, MaybeParseTypeError(*teamLevelPackage.Path, []string{"software", "packages"}, listErr))
continue
}

for i, spec := range softwarePackageSpecs {
softwarePackageSpec := spec.ResolveSoftwarePackagePaths(filepath.Dir(spec.ReferencedYamlPath))
// Collect the packages that validate and hydrate, dropping any that fail.
multiple := len(softwarePackageSpecs) > 1
// This label rule is at the fleet level, so check it once here rather than per package.
if multiple && (len(teamLevelPackage.LabelsIncludeAny) > 0 || len(teamLevelPackage.LabelsExcludeAny) > 0 || len(teamLevelPackage.LabelsIncludeAll) > 0) {
multiError = multierror.Append(multiError, fmt.Errorf(fleet.SoftwareLabelsPackageLevelOnlyMessage, *teamLevelPackage.Path))
}
var valid []*fleet.SoftwarePackageSpec
for _, spec := range softwarePackageSpecs {
spec.ReferencedYamlPath = resolvedPath
if err := validatePackageFieldPlacement(teamLevelPackage, spec, multiple); err != nil {
multiError = multierror.Append(multiError, err)
continue
}
softwarePackageSpec := spec.ResolveSoftwarePackagePaths(filepath.Dir(resolvedPath))
softwarePackageSpec, err = teamLevelPackage.HydrateToPackageLevel(softwarePackageSpec, ext)
if err != nil {
multiError = multierror.Append(multiError, err)
continue
}
softwarePackageSpecs[i] = &softwarePackageSpec
valid = append(valid, &softwarePackageSpec)
}
softwarePackageSpecs = valid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate YamlUnmarshal and relevant package parsing/tests.
rg -n "func YamlUnmarshal|YamlUnmarshal\\(" -S .
rg -n "empty.*package|software package|packages.*empty|\\[\\]" pkg/spec -S

# Show the exact region around the snippet.
sed -n '2240,2325p' pkg/spec/gitops.go

Repository: fleetdm/fleet

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read YamlUnmarshal implementation and tests around empty input behavior.
files=$(rg -l "func YamlUnmarshal|YamlUnmarshal\\(" -S . | tr '\n' ' ')
for f in $files; do
  echo "=== $f ==="
  sed -n '1,220p' "$f" | sed -n '/func YamlUnmarshal/,/}/p'
done

echo "=== tests ==="
rg -n "YamlUnmarshal|empty file|empty list|\\[\\]" -S ./*test* pkg spec 2>/dev/null || true

Repository: fleetdm/fleet

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== gitops.go snippet =="
sed -n '2248,2318p' pkg/spec/gitops.go

echo
echo "== YamlUnmarshal definition(s) =="
rg -n "func YamlUnmarshal" -S .
for f in $(rg -l "func YamlUnmarshal" -S .); do
  echo "--- $f ---"
  line=$(rg -n "func YamlUnmarshal" "$f" | head -n1 | cut -d: -f1)
  start=$((line-20)); [ "$start" -lt 1 ] && start=1
  end=$((line+80))
  sed -n "${start},${end}p" "$f"
done

echo
echo "== tests mentioning empty yaml/list behavior =="
rg -n "empty|\\[\\]|YamlUnmarshal|single object|package list" pkg/spec -S

Repository: fleetdm/fleet

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the continuation after the snippet to see whether an empty spec slice is rejected later.
sed -n '2318,2385p' pkg/spec/gitops.go

# Also inspect any existing tests around package YAML files and empty inputs.
rg -n "softwarePackageSpecs|teamLevelPackage.Path|does not define any packages|empty" pkg/spec/*test* -S

Repository: fleetdm/fleet

Length of output: 5685


🌐 Web query:

gopkg.in/yaml.v3 YAMLToJSON empty input returns nil slice or null empty document behavior

💡 Result:

In the gopkg.in/yaml.v3 package, the behavior regarding empty input depends on the specific function being called. For the primary unmarshaling function, yaml.Unmarshal, providing an empty input (e.g., an empty byte slice) results in no action being taken, and it does not return an error [1]. The target variable remains unchanged. If you are referring to the YAMLToJSON utility, it is important to note that YAMLToJSON is not a function within the gopkg.in/yaml.v3 library itself. It is a common utility function provided by separate wrapper libraries, most notably sigs.k8s.io/yaml (formerly ghodss/yaml), which uses gopkg.in/yaml.v3 internally to perform the YAML-to-JSON conversion [2][3][4][5]. When using sigs.k8s.io/yaml's YAMLToJSON with empty input: 1. It calls yaml.Unmarshal internally [2][4]. 2. Because yaml.Unmarshal treats an empty input as a no-op (performing no changes to the target), the subsequent JSON marshaling process (via encoding/json) will encounter an uninitialized or empty interface variable. 3. This typically results in the JSON output being "null" (as an empty interface in Go marshals to the JSON null value). There is no "nil slice" behavior directly associated with the empty input in the sense of returning a specific error; rather, the library gracefully handles the absence of a YAML document by effectively treating it as a null/empty document [1][6]. If you encounter behavior where an empty input returns a nil slice, it is likely a result of the surrounding application logic or the specific implementation of the converter wrapper rather than the core gopkg.in/yaml.v3 package itself [1].

Citations:


Reject empty package lists in pkg/spec/gitops.go
A package file that decodes to zero entries (empty file or []) skips hydration and raises no error, so the path: entry silently registers nothing. Add an explicit len(softwarePackageSpecs) == 0 check before the per-package loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/spec/gitops.go` around lines 2267 - 2304, Empty package files or lists
currently pass validation without registering a package. Before the per-package
loop in the package-loading logic, add a len(softwarePackageSpecs) == 0 check
and append an appropriate validation error referencing the package path, then
continue without hydration.

Comment on lines +3794 to +3908
include_any AS (
SELECT
software_installers.id AS installer_id,
COUNT(*) AS count_installer_labels,
COUNT(label_membership.label_id) AS count_host_labels,
0 AS count_host_updated_after_labels
FROM
software_installers
INNER JOIN software_installer_labels
ON software_installer_labels.software_installer_id = software_installers.id
AND software_installer_labels.exclude = 0
AND software_installer_labels.require_all = 0
LEFT JOIN label_membership
ON label_membership.label_id = software_installer_labels.label_id
AND label_membership.host_id = :host_id
GROUP BY
software_installers.id
HAVING
count_installer_labels > 0 AND count_host_labels > 0
),
exclude_any AS (
SELECT
software_installers.id AS installer_id,
COUNT(software_installer_labels.label_id) AS count_installer_labels,
COUNT(label_membership.label_id) AS count_host_labels,
SUM(
CASE
WHEN labels.created_at IS NOT NULL AND (
labels.label_membership_type = 1 OR
(labels.label_membership_type = 0 AND :host_label_updated_at >= labels.created_at)
) THEN 1
ELSE 0
END
) AS count_host_updated_after_labels
FROM
software_installers
INNER JOIN software_installer_labels
ON software_installer_labels.software_installer_id = software_installers.id
AND software_installer_labels.exclude = 1
AND software_installer_labels.require_all = 0
INNER JOIN labels
ON labels.id = software_installer_labels.label_id
LEFT JOIN label_membership
ON label_membership.label_id = software_installer_labels.label_id
AND label_membership.host_id = :host_id
GROUP BY
software_installers.id
HAVING
count_installer_labels > 0
AND count_installer_labels = count_host_updated_after_labels
AND count_host_labels = 0
),
include_all AS (
SELECT
software_installers.id AS installer_id,
COUNT(*) AS count_installer_labels,
COUNT(label_membership.label_id) AS count_host_labels,
0 AS count_host_updated_after_labels
FROM
software_installers
INNER JOIN software_installer_labels
ON software_installer_labels.software_installer_id = software_installers.id
AND software_installer_labels.exclude = 0
AND software_installer_labels.require_all = 1
LEFT JOIN label_membership
ON label_membership.label_id = software_installer_labels.label_id
AND label_membership.host_id = :host_id
GROUP BY
software_installers.id
HAVING
count_installer_labels > 0
AND count_host_labels = count_installer_labels
)
SELECT
software_installers.id AS installer_id,
software_installers.title_id AS title_id,
software_installers.self_service AS self_service,
software_installers.platform AS platform,
software_installers.extension AS extension,
(
no_labels.installer_id IS NOT NULL
OR include_any.installer_id IS NOT NULL
OR exclude_any.installer_id IS NOT NULL
OR include_all.installer_id IS NOT NULL
) AS in_scope
FROM
software_installers
LEFT JOIN no_labels
ON no_labels.installer_id = software_installers.id
LEFT JOIN include_any
ON include_any.installer_id = software_installers.id
LEFT JOIN exclude_any
ON exclude_any.installer_id = software_installers.id
LEFT JOIN include_all
ON include_all.installer_id = software_installers.id
WHERE
software_installers.global_or_team_id = :global_or_team_id
AND software_installers.is_active = 1
AND software_installers.title_id IN (:title_ids)
ORDER BY software_installers.title_id ASC, software_installers.id ASC

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Scope the label CTEs before aggregating.

Each label CTE scans and aggregates every installer across all fleets/titles, then the final query discards unrelated rows. ListHostSoftware can therefore scale with total global installer/label membership rather than this host’s relevant titles. Seed these CTEs from a candidate installer set filtered by global_or_team_id, is_active, and title_ids.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/datastore/mysql/software.go` around lines 3794 - 3908, The label CTEs
in ListHostSoftware currently aggregate all installers before applying fleet,
active-status, and title filters. Add a candidate-installer CTE (or equivalent
filtered seed) using global_or_team_id, is_active, and title_ids, then join each
of no_labels, include_any, exclude_any, and include_all to that candidate set
before aggregation; retain the final result and ordering semantics.

Source: Path instructions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add multiple custom packages for the same software title in the same fleet

4 participants