Add support for install/uninstall script overrides, pre-install query, post-install script in FMA GitOps (#31803)

Also removed the automatic install flag on YAML FMAs as it's
undocumented/unspec'd

Fixes #25636.

# Checklist for submitter

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

- [x] 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.
- [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] QA'd all new/changed functionality manually
This commit is contained in:
Ian Littman
2025-08-13 07:48:36 -05:00
committed by GitHub
parent ccbdf6c02e
commit da9bac09eb
15 changed files with 146 additions and 62 deletions
+1
View File
@@ -0,0 +1 @@
* Allowed overriding install/uninstall scripts, and specifying pre-install queries and post-install scripts, for Fleet-maintained apps in GitOps
+6 -5
View File
@@ -1719,8 +1719,12 @@ func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payloa
if app.SHA256 != noCheckHash {
payload.SHA256 = app.SHA256
}
payload.InstallScript = app.InstallScript
payload.UninstallScript = app.UninstallScript
if payload.InstallScript == "" {
payload.InstallScript = app.InstallScript
}
if payload.UninstallScript == "" {
payload.UninstallScript = app.UninstallScript
}
payload.FleetMaintained = true
payload.MaintainedApp = app
if len(payload.Categories) == 0 {
@@ -2004,7 +2008,6 @@ func (svc *Service) softwareBatchUpload(
}
}
extension := strings.TrimLeft(filepath.Ext(installer.Filename), ".")
installer.AutomaticInstallQuery = p.MaintainedApp.AutomaticInstallQuery
installer.Title = appName
installer.Version = p.MaintainedApp.Version
@@ -2030,8 +2033,6 @@ func (svc *Service) softwareBatchUpload(
installer.BundleIdentifier = p.MaintainedApp.BundleIdentifier()
installer.StorageID = p.MaintainedApp.SHA256
installer.FleetMaintainedAppID = &p.MaintainedApp.ID
installer.AutomaticInstall = p.AutomaticInstall != nil && *p.AutomaticInstall
installer.AutomaticInstallQuery = p.MaintainedApp.AutomaticInstallQuery
}
var ext string
+28 -23
View File
@@ -162,9 +162,9 @@ type SoftwarePackage struct {
}
type Software struct {
Packages []SoftwarePackage `json:"packages"`
AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"`
FleetMaintainedApps []fleet.FleetMaintainedAppsSpec `json:"fleet_maintained_apps"`
Packages []SoftwarePackage `json:"packages"`
AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"`
FleetMaintainedApps []fleet.MaintainedAppSpec `json:"fleet_maintained_apps"`
}
type GitOps struct {
@@ -186,7 +186,7 @@ type GitOps struct {
type GitOpsSoftware struct {
Packages []*fleet.SoftwarePackageSpec
AppStoreApps []*fleet.TeamSpecAppStoreApp
FleetMaintainedApps []*fleet.FleetMaintainedAppsSpec
FleetMaintainedApps []*fleet.MaintainedAppSpec
}
type Logf func(format string, a ...interface{})
@@ -1095,6 +1095,28 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
continue
}
item = item.ResolveSoftwarePackagePaths(baseDir)
// handle secrets
if item.InstallScript.Path != "" {
if err := gatherFileSecrets(result, item.InstallScript.Path); err != nil {
multiError = multierror.Append(multiError, err)
continue
}
}
if item.PostInstallScript.Path != "" {
if err := gatherFileSecrets(result, item.PostInstallScript.Path); err != nil {
multiError = multierror.Append(multiError, err)
continue
}
}
if item.UninstallScript.Path != "" {
if err := gatherFileSecrets(result, item.UninstallScript.Path); err != nil {
multiError = multierror.Append(multiError, err)
continue
}
}
result.Software.FleetMaintainedApps = append(result.Software.FleetMaintainedApps, &item)
}
for _, item := range software.Packages {
@@ -1117,9 +1139,9 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
continue
}
softwarePackageSpec = resolveSoftwarePackagePaths(filepath.Dir(softwarePackageSpec.ReferencedYamlPath), softwarePackageSpec)
softwarePackageSpec = softwarePackageSpec.ResolveSoftwarePackagePaths(filepath.Dir(softwarePackageSpec.ReferencedYamlPath))
} else {
softwarePackageSpec = resolveSoftwarePackagePaths(baseDir, item.SoftwarePackageSpec)
softwarePackageSpec = item.SoftwarePackageSpec.ResolveSoftwarePackagePaths(baseDir)
}
if softwarePackageSpec.InstallScript.Path != "" {
if err := gatherFileSecrets(result, softwarePackageSpec.InstallScript.Path); err != nil {
@@ -1194,23 +1216,6 @@ func gatherFileSecrets(result *GitOps, filePath string) error {
return nil
}
func resolveSoftwarePackagePaths(baseDir string, softwareSpec fleet.SoftwarePackageSpec) fleet.SoftwarePackageSpec {
if softwareSpec.PreInstallQuery.Path != "" {
softwareSpec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, softwareSpec.PreInstallQuery.Path)
}
if softwareSpec.InstallScript.Path != "" {
softwareSpec.InstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.InstallScript.Path)
}
if softwareSpec.PostInstallScript.Path != "" {
softwareSpec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.PostInstallScript.Path)
}
if softwareSpec.UninstallScript.Path != "" {
softwareSpec.UninstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.UninstallScript.Path)
}
return softwareSpec
}
func getDuplicateNames[T any](slice []T, getComparableString func(T) string) []string {
// We are using the allKeys map as a set here. True means the item is a duplicate.
allKeys := make(map[string]bool)
+19 -1
View File
@@ -105,6 +105,9 @@ func TestValidGitOpsYaml(t *testing.T) {
"FLEET_SECRET_NAME": "secret_name",
"FLEET_SECRET_length": "10",
"FLEET_SECRET_BANANA": "bread",
"FLEET_SECRET_CLEMENTINE": "not-an-orange",
"FLEET_SECRET_DURIAN": "fruity", // not used
"FLEET_SECRET_EGGPLANT": "parmesan",
},
filePath: "testdata/team_config_no_paths.yml",
isTeam: true,
@@ -119,6 +122,9 @@ func TestValidGitOpsYaml(t *testing.T) {
"FLEET_SECRET_NAME": "secret_name",
"FLEET_SECRET_length": "10",
"FLEET_SECRET_BANANA": "bread",
"FLEET_SECRET_CLEMENTINE": "not-an-orange",
"FLEET_SECRET_DURIAN": "fruity", // not used
"FLEET_SECRET_EGGPLANT": "parmesan",
},
filePath: "testdata/team_config.yml",
isTeam: true,
@@ -133,6 +139,9 @@ func TestValidGitOpsYaml(t *testing.T) {
"FLEET_SECRET_NAME": "secret_name",
"FLEET_SECRET_length": "10",
"FLEET_SECRET_BANANA": "bread",
"FLEET_SECRET_CLEMENTINE": "not-an-orange",
"FLEET_SECRET_DURIAN": "fruity", // not used
"FLEET_SECRET_EGGPLANT": "parmesan",
},
filePath: "testdata/team_config_only_sha256.yml",
isTeam: true,
@@ -188,6 +197,7 @@ func TestValidGitOpsYaml(t *testing.T) {
assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
require.Len(t, gitops.Software.Packages, 2)
require.Len(t, gitops.FleetSecrets, 6)
for _, pkg := range gitops.Software.Packages {
if strings.Contains(pkg.URL, "MicrosoftTeams") {
assert.Equal(t, "testdata/lib/uninstall.sh", pkg.UninstallScript.Path)
@@ -200,8 +210,16 @@ func TestValidGitOpsYaml(t *testing.T) {
switch fma.Slug {
case "slack/darwin":
require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Communication"})
require.Empty(t, fma.PreInstallQuery)
require.Empty(t, fma.PostInstallScript)
require.Empty(t, fma.InstallScript)
require.Empty(t, fma.UninstallScript)
case "box-drive/windows":
require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Developer tools"})
require.NotEmpty(t, fma.PreInstallQuery)
require.NotEmpty(t, fma.PostInstallScript)
require.NotEmpty(t, fma.InstallScript)
require.NotEmpty(t, fma.UninstallScript)
default:
assert.FailNow(t, "unexpected slug found in gitops file", "slug: %s", fma.Slug)
}
@@ -240,6 +258,7 @@ func TestValidGitOpsYaml(t *testing.T) {
activityExpiryWindow, ok := activityExpirySettings["activity_expiry_window"].(float64)
require.True(t, ok)
require.Equal(t, 30, int(activityExpiryWindow))
require.Len(t, gitops.FleetSecrets, 4)
// Check labels
require.Len(t, gitops.Labels, 2)
@@ -274,7 +293,6 @@ func TestValidGitOpsYaml(t *testing.T) {
assert.True(t, ok, "windows_migration_enabled not found")
_, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
assert.True(t, ok, "windows_updates not found")
require.Len(t, gitops.FleetSecrets, 4)
assert.Equal(t, "fleet_secret", gitops.FleetSecrets["FLEET_SECRET_FLEET_SECRET_"])
assert.Equal(t, "secret_name", gitops.FleetSecrets["FLEET_SECRET_NAME"])
assert.Equal(t, "10", gitops.FleetSecrets["FLEET_SECRET_length"])
+1
View File
@@ -0,0 +1 @@
echo $FLEET_SECRET_EGGPLANT
+1
View File
@@ -0,0 +1 @@
echo $FLEET_SECRET_EGGPLANT
+1
View File
@@ -0,0 +1 @@
- query: SELECT 1 FROM osquery_info
+1
View File
@@ -0,0 +1 @@
echo $FLEET_SECRET_CLEMENTINE
+8
View File
@@ -52,6 +52,14 @@ software:
- Productivity
- Communication
- slug: box-drive/windows
install_script:
path: ./lib/install.sh
uninstall_script:
path: ./lib/uninstall-box.sh
post_install_script:
path: ./lib/post-install.sh
pre_install_query:
path: ./lib/preinstall-query.yml
self_service: true
categories:
- Productivity
+8
View File
@@ -154,6 +154,14 @@ software:
- Productivity
- Communication
- slug: box-drive/windows
install_script:
path: ./lib/install.sh
uninstall_script:
path: ./lib/uninstall-box.sh
post_install_script:
path: ./lib/post-install.sh
pre_install_query:
path: ./lib/preinstall-query.yml
self_service: true
categories:
- Productivity
+8
View File
@@ -56,3 +56,11 @@ software:
categories:
- Productivity
- Developer tools
install_script:
path: ./lib/install.sh
uninstall_script:
path: ./lib/uninstall-box.sh
post_install_script:
path: ./lib/post-install.sh
pre_install_query:
path: ./lib/preinstall-query.yml
+2 -3
View File
@@ -415,9 +415,8 @@ type SoftwareInstallerPayload struct {
SHA256 string `json:"sha256"`
Categories []string `json:"categories"`
// This is to support FMAs
Slug *string `json:"slug"`
AutomaticInstall *bool `json:"automatic_install"`
MaintainedApp *MaintainedApp `json:"-"`
Slug *string `json:"slug"`
MaintainedApp *MaintainedApp `json:"-"`
}
type HostLockWipeStatus struct {
+53 -12
View File
@@ -556,8 +556,7 @@ type SoftwarePackageSpec struct {
LabelsExcludeAny []string `json:"labels_exclude_any"`
// FMA
Slug *string `json:"slug"`
AutomaticInstall *bool `json:"automatic_install"`
Slug *string `json:"slug"`
// ReferencedYamlPath is the resolved path of the file used to fill the
// software package. Only present after parsing a GitOps file on the fleetctl
@@ -572,19 +571,61 @@ type SoftwarePackageSpec struct {
Categories []string `json:"categories"`
}
type FleetMaintainedAppsSpec struct {
Slug string `json:"slug"`
AutomaticInstall *bool `json:"automatic_install"`
SelfService bool `json:"self_service"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
Categories []string `json:"categories"`
func (spec SoftwarePackageSpec) ResolveSoftwarePackagePaths(baseDir string) SoftwarePackageSpec {
spec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, spec.PreInstallQuery.Path)
spec.InstallScript.Path = resolveApplyRelativePath(baseDir, spec.InstallScript.Path)
spec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, spec.PostInstallScript.Path)
spec.UninstallScript.Path = resolveApplyRelativePath(baseDir, spec.UninstallScript.Path)
return spec
}
func resolveApplyRelativePath(baseDir string, path string) string {
if path != "" && baseDir != "" && !filepath.IsAbs(path) {
return filepath.Join(baseDir, path)
}
return path
}
type MaintainedAppSpec struct {
Slug string `json:"slug"`
SelfService bool `json:"self_service"`
PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"`
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
Categories []string `json:"categories"`
}
func (spec MaintainedAppSpec) ToSoftwarePackageSpec() SoftwarePackageSpec {
return SoftwarePackageSpec{
Slug: &spec.Slug,
PreInstallQuery: spec.PreInstallQuery,
InstallScript: spec.InstallScript,
PostInstallScript: spec.PostInstallScript,
UninstallScript: spec.UninstallScript,
SelfService: spec.SelfService,
LabelsIncludeAny: spec.LabelsIncludeAny,
LabelsExcludeAny: spec.LabelsExcludeAny,
}
}
func (spec MaintainedAppSpec) ResolveSoftwarePackagePaths(baseDir string) MaintainedAppSpec {
spec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, spec.PreInstallQuery.Path)
spec.InstallScript.Path = resolveApplyRelativePath(baseDir, spec.InstallScript.Path)
spec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, spec.PostInstallScript.Path)
spec.UninstallScript.Path = resolveApplyRelativePath(baseDir, spec.UninstallScript.Path)
return spec
}
type SoftwareSpec struct {
Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"`
FleetMaintainedApps optjson.Slice[FleetMaintainedAppsSpec] `json:"fleet_maintained_apps,omitempty"`
AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"`
Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"`
FleetMaintainedApps optjson.Slice[MaintainedAppSpec] `json:"fleet_maintained_apps,omitempty"`
AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"`
}
// HostSoftwareInstall represents installation of software on a host from a
+2 -16
View File
@@ -1111,7 +1111,6 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
if si.Slug != nil {
softwarePayloads[i].Slug = si.Slug
softwarePayloads[i].AutomaticInstall = si.AutomaticInstall
}
}
@@ -1473,14 +1472,7 @@ func extractTmSpecsFleetMaintainedApps(tmSpecs []json.RawMessage) map[string][]f
packages = []fleet.SoftwarePackageSpec{}
} else {
for _, app := range software.FleetMaintainedApps.Value {
packages = append(packages, fleet.SoftwarePackageSpec{
Slug: &app.Slug,
AutomaticInstall: app.AutomaticInstall,
SelfService: app.SelfService,
LabelsIncludeAny: app.LabelsIncludeAny,
LabelsExcludeAny: app.LabelsExcludeAny,
Categories: app.Categories,
})
packages = append(packages, app.ToSoftwarePackageSpec())
}
}
m[spec.Name] = packages
@@ -2150,13 +2142,7 @@ func (c *Client) doGitOpsNoTeamSetupAndSoftware(
}
for _, software := range config.Software.FleetMaintainedApps {
if software != nil {
packages = append(packages, fleet.SoftwarePackageSpec{
Slug: &software.Slug,
AutomaticInstall: software.AutomaticInstall,
SelfService: software.SelfService,
LabelsIncludeAny: software.LabelsIncludeAny,
LabelsExcludeAny: software.LabelsExcludeAny,
})
packages = append(packages, software.ToSoftwarePackageSpec())
}
}
@@ -12096,9 +12096,9 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
})
require.NoError(t, err)
// basic fleet maintained app
// basic fleet maintained app with install script override
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{Slug: &maintained1.Slug},
{Slug: &maintained1.Slug, InstallScript: "echo 'Hello world'"},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
@@ -12107,6 +12107,11 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
require.NotNil(t, packages[0].URL)
require.Nil(t, packages[0].TeamID)
var softwareTitleResponse getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *packages[0].TitleID), nil, http.StatusOK, &softwareTitleResponse, "teamId", "0")
require.Equal(t, "echo 'Hello world'", softwareTitleResponse.SoftwareTitle.SoftwarePackage.InstallScript)
require.Contains(t, softwareTitleResponse.SoftwareTitle.SoftwarePackage.UninstallScript, "LOGGED_IN_USER") // should pass through FMA script
// with a team
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)