Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions changes/28108-multiple-custom-packages
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 101 additions & 34 deletions cmd/fleetctl/fleetctl/generate_gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2346,52 +2358,26 @@ 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
}
if crosses, ok := crossPlatformSelectionsByTitleID[softwareTitle.ID]; ok && len(crosses) > 0 {
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 {
Expand Down Expand Up @@ -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/<team>/software, so a sibling dir is reached with ../<dir>/<name>.
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 {
Expand Down
111 changes: 111 additions & 0 deletions cmd/fleetctl/fleetctl/generate_gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
4 changes: 4 additions & 0 deletions cmd/fleetctl/fleetctl/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@ spec:
id: 0
name: foo
software_package: null
packages: null
source: chrome_extensions
extension_for: chrome
display_name: ""
Expand All @@ -1046,6 +1047,7 @@ spec:
id: 0
name: bar
software_package: null
packages: null
source: deb_packages
extension_for: ""
display_name: ""
Expand Down Expand Up @@ -1097,6 +1099,7 @@ spec:
}
],
"software_package": null,
"packages": null,
"app_store_app": null
},
{
Expand All @@ -1117,6 +1120,7 @@ spec:
}
],
"software_package": null,
"packages": null,
"app_store_app": null
}
]
Expand Down
7 changes: 7 additions & 0 deletions cmd/fleetctl/fleetctl/testing_utils/testing_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
Loading
Loading