Add path support to script files (#40821)

Fixes #38659 Enables IT admins to reference `.sh` or `.ps1` script files directly in the GitOps `path` field for software packages.
This commit is contained in:
Carlo 2026-03-04 13:22:44 -05:00 committed by GitHub
parent f6da7974b2
commit 328f4d5079
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 304 additions and 76 deletions

View file

@ -0,0 +1 @@
- Added support for referencing `.sh` or `.ps1` script files directly in the GitOps `path` field for software packages.

View file

@ -1,6 +1,7 @@
package service
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
@ -2408,42 +2409,70 @@ func (svc *Service) softwareBatchUpload(
return fmt.Errorf("package not found with hash %s", p.SHA256)
}
var filename string
var tfr *fleet.TempFileReader
var resp *http.Response
err = retry.Do(func() error {
var retryErr error
resp, tfr, retryErr = downloadURLFn(ctx, p.URL)
if retryErr != nil {
return retryErr
// Handle script packages from path (script:// URL scheme)
if filename, ok := strings.CutPrefix(p.URL, "script://"); ok {
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
if !fleet.IsScriptPackage(ext) {
return fmt.Errorf("script:// URL must reference a .sh or .ps1 file, got: %s", filename)
}
return nil
}, retry.WithMaxAttempts(fleet.BatchDownloadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
if err != nil {
return err
}
if p.InstallScript == "" {
return fmt.Errorf("script package %s has no install script content", filename)
}
installer.InstallerFile = tfr
toBeClosedTFRs[i] = tfr
scriptContent := []byte(p.InstallScript)
tfr, err = fleet.NewTempFileReader(bytes.NewReader(scriptContent), nil)
if err != nil {
return fmt.Errorf("creating temp file for script package %s: %w", filename, err)
}
filename = maintained_apps.FilenameFromResponse(resp)
installer.Filename = filename
installer.InstallerFile = tfr
toBeClosedTFRs[i] = tfr
installer.Filename = filename
// For script packages (.sh and .ps1) and in-house apps (.ipa), clear
// unsupported fields early. Determine extension from filename to
// validate before metadata extraction.
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
if fleet.IsScriptPackage(ext) {
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
} else if ext == "ipa" {
installer.InstallScript = ""
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
} else {
var resp *http.Response
err = retry.Do(func() error {
var retryErr error
resp, tfr, retryErr = downloadURLFn(ctx, p.URL)
if retryErr != nil {
return retryErr
}
return nil
}, retry.WithMaxAttempts(fleet.BatchDownloadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
if err != nil {
return err
}
installer.InstallerFile = tfr
toBeClosedTFRs[i] = tfr
filename := maintained_apps.FilenameFromResponse(resp)
installer.Filename = filename
// For script packages (.sh and .ps1) and in-house apps (.ipa), clear
// unsupported fields early. Determine extension from filename to
// validate before metadata extraction.
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
if fleet.IsScriptPackage(ext) {
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
} else if ext == "ipa" {
installer.InstallScript = ""
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
}
}
}

View file

@ -1537,8 +1537,8 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
// A single item in Packages can result in multiple SoftwarePackageSpecs being generated
var softwarePackageSpecs []*fleet.SoftwarePackageSpec
if teamLevelPackage.Path != nil {
yamlPath := resolveApplyRelativePath(baseDir, *teamLevelPackage.Path)
fileBytes, err := os.ReadFile(yamlPath)
resolvedPath := resolveApplyRelativePath(baseDir, *teamLevelPackage.Path)
fileBytes, err := os.ReadFile(resolvedPath)
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("failed to read software package file %s: %w", *teamLevelPackage.Path, err))
continue
@ -1549,38 +1549,60 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
multiError = multierror.Append(multiError, fmt.Errorf("failed to expand environment in file %s: %w", *teamLevelPackage.Path, err))
continue
}
var singlePackageSpec SoftwarePackage
singlePackageSpec.ReferencedYamlPath = yamlPath
if err := YamlUnmarshal(fileBytes, &singlePackageSpec); err == nil {
if singlePackageSpec.IncludesFieldsDisallowedInPackageFile() {
multiError = multierror.Append(multiError, fmt.Errorf("labels, categories, setup_experience, and self_service values must be specified at the team level; package-level specified in %s", *teamLevelPackage.Path))
continue
}
softwarePackageSpecs = append(softwarePackageSpecs, &singlePackageSpec.SoftwarePackageSpec)
} else if err = YamlUnmarshal(fileBytes, &softwarePackageSpecs); err == nil {
// Failing that, try to unmarshal as a list of SoftwarePackageSpecs
for i, spec := range softwarePackageSpecs {
if spec.IncludesFieldsDisallowedInPackageFile() {
multiError = multierror.Append(multiError, fmt.Errorf("labels, categories, setup_experience, and self_service values must be specified at the team level; package-level specified in %s", *teamLevelPackage.Path))
continue
}
softwarePackageSpecs[i].ReferencedYamlPath = yamlPath
ext := strings.ToLower(filepath.Ext(resolvedPath))
switch ext {
case ".sh", ".ps1":
// Script file becomes the install script for a script-only package
scriptSpec := fleet.SoftwarePackageSpec{
InstallScript: fleet.TeamSpecSoftwareAsset{Path: resolvedPath},
ReferencedYamlPath: resolvedPath,
}
} else {
// If we reached here, we couldn't unmarshal as either format.
multiError = multierror.Append(multiError, MaybeParseTypeError(*teamLevelPackage.Path, []string{"software", "packages"}, err))
continue
}
for i, spec := range softwarePackageSpecs {
softwarePackageSpec := spec.ResolveSoftwarePackagePaths(filepath.Dir(spec.ReferencedYamlPath))
softwarePackageSpec, err = teamLevelPackage.HydrateToPackageLevel(softwarePackageSpec)
scriptSpec, err = teamLevelPackage.HydrateToPackageLevel(scriptSpec)
if err != nil {
multiError = multierror.Append(multiError, err)
continue
}
softwarePackageSpecs[i] = &softwarePackageSpec
softwarePackageSpecs = append(softwarePackageSpecs, &scriptSpec)
case ".yml", ".yaml":
var singlePackageSpec SoftwarePackage
singlePackageSpec.ReferencedYamlPath = resolvedPath
if err := YamlUnmarshal(fileBytes, &singlePackageSpec); err == nil {
if singlePackageSpec.IncludesFieldsDisallowedInPackageFile() {
multiError = multierror.Append(multiError, fmt.Errorf("labels, categories, setup_experience, and self_service values must be specified at the team level; package-level specified in %s", *teamLevelPackage.Path))
continue
}
softwarePackageSpecs = append(softwarePackageSpecs, &singlePackageSpec.SoftwarePackageSpec)
} else if err = YamlUnmarshal(fileBytes, &softwarePackageSpecs); err == nil {
// Failing that, try to unmarshal as a list of SoftwarePackageSpecs
for i, spec := range softwarePackageSpecs {
if spec.IncludesFieldsDisallowedInPackageFile() {
multiError = multierror.Append(multiError, fmt.Errorf("labels, categories, setup_experience, and self_service values must be specified at the team level; package-level specified in %s", *teamLevelPackage.Path))
continue
}
softwarePackageSpecs[i].ReferencedYamlPath = resolvedPath
}
} else {
// If we reached here, we couldn't unmarshal as either format.
multiError = multierror.Append(multiError, MaybeParseTypeError(*teamLevelPackage.Path, []string{"software", "packages"}, err))
continue
}
for i, spec := range softwarePackageSpecs {
softwarePackageSpec := spec.ResolveSoftwarePackagePaths(filepath.Dir(spec.ReferencedYamlPath))
softwarePackageSpec, err = teamLevelPackage.HydrateToPackageLevel(softwarePackageSpec)
if err != nil {
multiError = multierror.Append(multiError, err)
continue
}
softwarePackageSpecs[i] = &softwarePackageSpec
}
default:
multiError = multierror.Append(multiError, fmt.Errorf("software package path %s has unsupported extension %q; only .yml, .yaml, .sh, or .ps1 files are supported", *teamLevelPackage.Path, ext))
continue
}
} else {
softwarePackageSpec := teamLevelPackage.SoftwarePackageSpec.ResolveSoftwarePackagePaths(baseDir)
@ -1614,7 +1636,9 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
multiError = multierror.Append(multiError, fmt.Errorf("hash_sha256 value %q must be a valid lower-case hex-encoded (64-character) SHA-256 hash value", softwarePackageSpec.SHA256))
continue
}
if softwarePackageSpec.SHA256 == "" && softwarePackageSpec.URL == "" {
// Script packages from path don't require URL or hash_sha256
isScriptPackageFromPath := fleet.IsScriptPackage(filepath.Ext(softwarePackageSpec.ReferencedYamlPath))
if !isScriptPackageFromPath && softwarePackageSpec.SHA256 == "" && softwarePackageSpec.URL == "" {
errorMessage := "at least one of hash_sha256 or url is required for each software package"
if softwarePackageSpec.ReferencedYamlPath != "" {
errorMessage += fmt.Sprintf("; missing in %s", softwarePackageSpec.ReferencedYamlPath)
@ -1626,25 +1650,29 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
multiError = multierror.Append(multiError, errors.New(errorMessage))
continue
}
if len(softwarePackageSpec.URL) > fleet.SoftwareInstallerURLMaxLength {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be %d characters or less", softwarePackageSpec.URL, fleet.SoftwareInstallerURLMaxLength))
continue
}
parsedUrl, err := url.Parse(softwarePackageSpec.URL)
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s is not a valid URL", softwarePackageSpec.URL))
continue
}
if softwarePackageSpec.InstallScript.Path == "" || softwarePackageSpec.UninstallScript.Path == "" {
// URL checks won't catch everything, but might as well include a lightweight check here to fail fast if it's
// certain that the package will fail later.
if strings.HasSuffix(parsedUrl.Path, ".exe") {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s refers to an .exe package, which requires both install_script and uninstall_script", softwarePackageSpec.URL))
// Skip URL-related validations for script packages from path
if !isScriptPackageFromPath {
if len(softwarePackageSpec.URL) > fleet.SoftwareInstallerURLMaxLength {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be %d characters or less", softwarePackageSpec.URL, fleet.SoftwareInstallerURLMaxLength))
continue
} else if strings.HasSuffix(parsedUrl.Path, ".tar.gz") || strings.HasSuffix(parsedUrl.Path, ".tgz") {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s refers to a .tar.gz archive, which requires both install_script and uninstall_script", softwarePackageSpec.URL))
}
parsedUrl, err := url.Parse(softwarePackageSpec.URL)
if err != nil {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s is not a valid URL", softwarePackageSpec.URL))
continue
}
if softwarePackageSpec.InstallScript.Path == "" || softwarePackageSpec.UninstallScript.Path == "" {
// URL checks won't catch everything, but might as well include a lightweight check here to fail fast if it's
// certain that the package will fail later.
if strings.HasSuffix(parsedUrl.Path, ".exe") {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s refers to an .exe package, which requires both install_script and uninstall_script", softwarePackageSpec.URL))
continue
} else if strings.HasSuffix(parsedUrl.Path, ".tar.gz") || strings.HasSuffix(parsedUrl.Path, ".tgz") {
multiError = multierror.Append(multiError, fmt.Errorf("software URL %s refers to a .tar.gz archive, which requires both install_script and uninstall_script", softwarePackageSpec.URL))
continue
}
}
}
// Validate display_name length (matches database VARCHAR(255))

View file

@ -1929,3 +1929,149 @@ func TestGitOpsGlobScripts(t *testing.T) {
assert.Equal(t, filepath.Join(scriptsDir, "beta.sh"), *result.Controls.Scripts[1].Path)
assert.Equal(t, filepath.Join(scriptsDir, "gamma.ps1"), *result.Controls.Scripts[2].Path)
}
func TestSoftwarePackagesScriptPath(t *testing.T) {
t.Parallel()
appConfig := &fleet.EnrichedAppConfig{}
appConfig.License = &fleet.LicenseInfo{
Tier: fleet.TierPremium,
}
t.Run("valid_sh_script_path", func(t *testing.T) {
config := getTeamConfig([]string{"software"})
config += `
software:
packages:
- path: software/install-app.sh
categories:
- Utilities
self_service: true
`
path, basePath := createTempFile(t, "", config)
err := file.Copy(
filepath.Join("testdata", "software", "install-app.sh"),
filepath.Join(basePath, "software", "install-app.sh"),
os.FileMode(0o755),
)
require.NoError(t, err)
result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf)
require.NoError(t, err)
require.Len(t, result.Software.Packages, 1)
assert.True(t, strings.HasSuffix(result.Software.Packages[0].InstallScript.Path, "install-app.sh"))
assert.Equal(t, []string{"Utilities"}, result.Software.Packages[0].Categories)
assert.True(t, result.Software.Packages[0].SelfService)
assert.Empty(t, result.Software.Packages[0].URL)
assert.Empty(t, result.Software.Packages[0].SHA256)
})
t.Run("valid_ps1_script_path", func(t *testing.T) {
config := getTeamConfig([]string{"software"})
config += `
software:
packages:
- path: software/install-app.ps1
self_service: false
`
path, basePath := createTempFile(t, "", config)
// Copy the test script file
err := file.Copy(
filepath.Join("testdata", "software", "install-app.ps1"),
filepath.Join(basePath, "software", "install-app.ps1"),
os.FileMode(0o755),
)
require.NoError(t, err)
result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf)
require.NoError(t, err)
require.Len(t, result.Software.Packages, 1)
assert.True(t, strings.HasSuffix(result.Software.Packages[0].InstallScript.Path, "install-app.ps1"))
})
t.Run("invalid_extension_error", func(t *testing.T) {
config := getTeamConfig([]string{"software"})
config += `
software:
packages:
- path: software/install-app.txt
`
path, basePath := createTempFile(t, "", config)
// Create a .txt file
err := os.MkdirAll(filepath.Join(basePath, "software"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(basePath, "software", "install-app.txt"), []byte("test"), 0o644)
require.NoError(t, err)
_, err = GitOpsFromFile(path, basePath, appConfig, nopLogf)
assert.ErrorContains(t, err, "unsupported extension")
assert.ErrorContains(t, err, "only .yml, .yaml, .sh, or .ps1 files are supported")
})
t.Run("script_with_team_options", func(t *testing.T) {
config := getTeamConfig([]string{"software"})
config += `
software:
packages:
- path: software/install-app.sh
categories:
- Browsers
- Productivity
self_service: true
setup_experience: true
labels_include_any:
- include_label
`
path, basePath := createTempFile(t, "", config)
err := file.Copy(
filepath.Join("testdata", "software", "install-app.sh"),
filepath.Join(basePath, "software", "install-app.sh"),
os.FileMode(0o755),
)
require.NoError(t, err)
result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf)
require.NoError(t, err)
require.Len(t, result.Software.Packages, 1)
pkg := result.Software.Packages[0]
assert.Equal(t, []string{"Browsers", "Productivity"}, pkg.Categories)
assert.True(t, pkg.SelfService)
assert.True(t, pkg.InstallDuringSetup.Value)
assert.Equal(t, []string{"include_label"}, pkg.LabelsIncludeAny)
})
t.Run("mixed_yaml_and_script_paths", func(t *testing.T) {
config := getTeamConfig([]string{"software"})
config += `
software:
packages:
- path: software/single-package.yml
- path: software/install-app.sh
self_service: true
`
path, basePath := createTempFile(t, "", config)
err := file.Copy(
filepath.Join("testdata", "software", "single-package.yml"),
filepath.Join(basePath, "software", "single-package.yml"),
os.FileMode(0o755),
)
require.NoError(t, err)
err = file.Copy(
filepath.Join("testdata", "software", "install-app.sh"),
filepath.Join(basePath, "software", "install-app.sh"),
os.FileMode(0o755),
)
require.NoError(t, err)
result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf)
require.NoError(t, err)
require.Len(t, result.Software.Packages, 2)
assert.NotEmpty(t, result.Software.Packages[0].SHA256)
assert.True(t, strings.HasSuffix(result.Software.Packages[1].InstallScript.Path, "install-app.sh"))
assert.True(t, result.Software.Packages[1].SelfService)
})
}

View file

@ -0,0 +1,4 @@
# Test PowerShell script for script-only software packages
Write-Host "Installing application..."
# Simulated installation steps
exit 0

View file

@ -0,0 +1,3 @@
#!/bin/bash
echo "Installing application..."
exit 0

View file

@ -423,8 +423,13 @@ type ScriptPayload struct {
}
type SoftwareInstallerPayload struct {
URL string `json:"url"`
PreInstallQuery string `json:"pre_install_query"`
// URL is the download URL for the installer. For script packages specified via
// the path field, this uses "script://filename" to pass the filename; in that
// case InstallScript contains the script content directly.
URL string `json:"url"`
PreInstallQuery string `json:"pre_install_query"`
// InstallScript is the script to run after downloading the installer. For script
// packages via "script://" URL, this contains the package content itself.
InstallScript string `json:"install_script"`
UninstallScript string `json:"uninstall_script"`
PostInstallScript string `json:"post_install_script"`

View file

@ -1257,8 +1257,20 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
installDuringSetup = &si.InstallDuringSetup.Value
}
// For script packages from path, use "script://" URL scheme to pass the filename
urlValue := si.URL
sha256Value := si.SHA256
if fleet.IsScriptPackage(filepath.Ext(si.ReferencedYamlPath)) && si.URL == "" {
scriptFilename := filepath.Base(si.ReferencedYamlPath)
urlValue = "script://" + scriptFilename
if sha256Value == "" && len(ic) > 0 {
hash := sha256.Sum256(ic)
sha256Value = hex.EncodeToString(hash[:])
}
}
softwarePayloads[i] = fleet.SoftwareInstallerPayload{
URL: si.URL,
URL: urlValue,
SelfService: si.SelfService,
PreInstallQuery: qc,
InstallScript: string(ic),
@ -1267,7 +1279,7 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
InstallDuringSetup: installDuringSetup,
LabelsIncludeAny: si.LabelsIncludeAny,
LabelsExcludeAny: si.LabelsExcludeAny,
SHA256: si.SHA256,
SHA256: sha256Value,
Categories: si.Categories,
DisplayName: si.DisplayName,
IconPath: si.Icon.Path,