From 57bab9e5ec5b671a5327d42ad0c97204de0a0003 Mon Sep 17 00:00:00 2001
From: Carlo <1778532+cdcme@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:20:03 -0400
Subject: [PATCH] Allow Python script-only packages (#49070)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Related issue:** Resolves #41470
Adds support for uploading Python (`.py`) script-only software packages
— accepted as script-only (the file contents become the install script;
advanced options and automatic install follow `.sh`/`.ps1`), assigned
the new `py_packages` source, and installable on macOS and Linux hosts
across the UI, REST API, and GitOps.
Feature branch combining the backend (#48942) and frontend (#48946)
sub-PRs.
# Checklist for submitter
- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] 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
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
## Summary by CodeRabbit
* **New Features**
* Added support for Python (`.py`) script-only software packages across
UI uploads, API/self-service installs, and GitOps parsing.
* Python installers now derive metadata correctly and render the proper
Python icon, with install eligibility for macOS & Linux.
* **Bug Fixes**
* Improved installer-script validation and “supported file types” error
messages to include `.py` (and consistent handling of related script
fields/options).
* **Tests**
* Expanded unit, integration, and GitOps tests to cover Python package
parsing, metadata derivation, platform/host eligibility, and UI
rendering.
---
changes/41470-python-script-only-packages | 1 +
cmd/fleetctl/fleetctl/generate_gitops_test.go | 56 +++-
.../integrationtest/gitops/software_test.go | 4 +-
ee/server/service/software_installers.go | 16 +-
ee/server/service/software_installers_test.go | 268 ++++++++++++++++++
.../SoftwareScriptDetailsModal.tsx | 2 +-
.../CommandPalette/groups/commands.ts | 2 +
.../CommandPalette/helpers.tests.ts | 13 +
frontend/interfaces/package_type.ts | 2 +-
frontend/interfaces/setup.ts | 4 +-
frontend/interfaces/software.ts | 8 +-
.../GlobalActivityItem.tests.tsx | 54 ++++
.../AddPackageModal/AddPackageModal.tests.tsx | 8 +-
.../LibraryItemAccordion.tsx | 7 +-
.../InstallerDetailsWidget.tests.tsx | 12 +
.../InstallerDetailsWidget.tsx | 8 +-
.../SoftwareTitleDetailsPage.tsx | 1 +
.../SoftwareTitleDetailsPage/helpers.tests.ts | 39 +++
.../SoftwareDetailsSummary.tsx | 4 +-
.../forms/PackageForm/PackageForm.tsx | 23 +-
.../details/DeviceUserPage/helpers.tests.ts | 25 ++
.../hosts/details/DeviceUserPage/helpers.ts | 5 +-
frontend/utilities/file/fileUtils.tests.tsx | 6 +
frontend/utilities/file/fileUtils.tsx | 1 +
.../utilities/software_install_scripts.ts | 1 +
.../utilities/software_uninstall_scripts.ts | 1 +
pkg/spec/gitops.go | 12 +-
pkg/spec/gitops_test.go | 55 +++-
pkg/spec/testdata/software/script-only.py | 3 +
server/fleet/software_installer.go | 8 +-
server/fleet/software_installer_test.go | 7 +
server/service/integration_enterprise_test.go | 123 ++++++++
.../testdata/software-installers/script.py | 3 +
33 files changed, 739 insertions(+), 43 deletions(-)
create mode 100644 changes/41470-python-script-only-packages
create mode 100644 frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts
create mode 100644 pkg/spec/testdata/software/script-only.py
create mode 100644 server/service/testdata/software-installers/script.py
diff --git a/changes/41470-python-script-only-packages b/changes/41470-python-script-only-packages
new file mode 100644
index 0000000000..12d43d136c
--- /dev/null
+++ b/changes/41470-python-script-only-packages
@@ -0,0 +1 @@
+- Added support for Python (`.py`) script-only software packages, which can be uploaded as custom packages (the file contents become the install script) and installed on macOS and Linux hosts, via the UI, REST API, and GitOps.
diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go
index 590b3c6499..5440418f73 100644
--- a/cmd/fleetctl/fleetctl/generate_gitops_test.go
+++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go
@@ -1924,10 +1924,10 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
packages, ok := software["packages"].([]interface{})
require.True(t, ok, "packages should be an array")
- require.Len(t, packages, 3, "should have 3 packages: 1 regular + 2 scripts (.sh and .ps1)")
+ require.Len(t, packages, 4, "should have 4 packages: 1 regular + 3 scripts (.sh, .ps1, and .py)")
// Identify by URL since hash_sha256 includes comment tokens
- var shScriptPkg, ps1ScriptPkg, regularPkg map[string]interface{}
+ var shScriptPkg, ps1ScriptPkg, pyScriptPkg, regularPkg map[string]any
for _, pkg := range packages {
p := pkg.(map[string]interface{})
url, ok := p["url"].(string)
@@ -1939,6 +1939,8 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
shScriptPkg = p
case "https://example.com/download/setup.ps1":
ps1ScriptPkg = p
+ case "https://example.com/download/install.py":
+ pyScriptPkg = p
case "https://example.com/download/regular-package.deb":
regularPkg = p
}
@@ -1946,6 +1948,7 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
require.NotNil(t, shScriptPkg, ".sh script package should exist")
require.NotNil(t, ps1ScriptPkg, ".ps1 script package should exist")
+ require.NotNil(t, pyScriptPkg, ".py script package should exist")
require.NotNil(t, regularPkg, "regular package should exist")
_, hasInstallScript := shScriptPkg["install_script"]
@@ -1972,10 +1975,24 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
_, hasPreInstallQuery = ps1ScriptPkg["pre_install_query"]
require.False(t, hasPreInstallQuery, ".ps1 script package should NOT have pre_install_query in YAML output")
+ _, hasInstallScript = pyScriptPkg["install_script"]
+ require.False(t, hasInstallScript, ".py script package should NOT have install_script in YAML output")
+
+ _, hasPostInstallScript = pyScriptPkg["post_install_script"]
+ require.False(t, hasPostInstallScript, ".py script package should NOT have post_install_script in YAML output")
+
+ _, hasUninstallScript = pyScriptPkg["uninstall_script"]
+ require.False(t, hasUninstallScript, ".py script package should NOT have uninstall_script in YAML output")
+
+ _, hasPreInstallQuery = pyScriptPkg["pre_install_query"]
+ require.False(t, hasPreInstallQuery, ".py script package should NOT have pre_install_query in YAML output")
+
require.Contains(t, shScriptPkg, "url", ".sh script package should have url")
require.Contains(t, shScriptPkg, "hash_sha256", ".sh script package should have hash_sha256")
require.Contains(t, ps1ScriptPkg, "url", ".ps1 script package should have url")
require.Contains(t, ps1ScriptPkg, "hash_sha256", ".ps1 script package should have hash_sha256")
+ require.Contains(t, pyScriptPkg, "url", ".py script package should have url")
+ require.Contains(t, pyScriptPkg, "hash_sha256", ".py script package should have hash_sha256")
require.Contains(t, regularPkg, "install_script", "regular package should have install_script")
require.Contains(t, regularPkg, "post_install_script", "regular package should have post_install_script")
@@ -1993,6 +2010,7 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
}
require.NotContains(t, commentFor("my-script.sh"), "version", ".sh script package comment should not mention version")
require.NotContains(t, commentFor("setup.ps1"), "version", ".ps1 script package comment should not mention version")
+ require.NotContains(t, commentFor("install.py"), "version", ".py script package comment should not mention version")
require.Contains(t, commentFor("regular-package.deb"), "version", "regular package comment should still mention version")
for filename := range cmd.FilesToWrite {
@@ -2005,6 +2023,11 @@ func TestGenerateSoftwareScriptPackages(t *testing.T) {
require.NotContains(t, filename, "powershell-script-windows-postinstall", "should not write post-install script file for .ps1 script package")
require.NotContains(t, filename, "powershell-script-windows-uninstall", "should not write uninstall script file for .ps1 script package")
require.NotContains(t, filename, "powershell-script-windows-preinstallquery", "should not write pre-install query file for .ps1 script package")
+
+ require.NotContains(t, filename, "python-script-linux-install", "should not write install script file for .py script package")
+ require.NotContains(t, filename, "python-script-linux-postinstall", "should not write post-install script file for .py script package")
+ require.NotContains(t, filename, "python-script-linux-uninstall", "should not write uninstall script file for .py script package")
+ require.NotContains(t, filename, "python-script-linux-preinstallquery", "should not write pre-install query file for .py script package")
}
}
@@ -2046,6 +2069,16 @@ func (c *MockClientWithScriptPackage) ListSoftwareTitles(query string) ([]fleet.
Version: "1.5",
},
},
+ {
+ ID: 6,
+ Name: "Python Script",
+ HashSHA256: new("py-script-hash"),
+ SoftwarePackage: &fleet.SoftwarePackageOrApp{
+ Name: "install.py",
+ Platform: "linux",
+ Version: "1.2",
+ },
+ },
}, nil
default:
return c.MockClient.ListSoftwareTitles(query)
@@ -2109,6 +2142,25 @@ func (c *MockClientWithScriptPackage) GetSoftwareTitleByID(id uint, teamID *uint
Name: "setup.ps1",
},
}, nil
+ case 6:
+ if *teamID != 2 {
+ return nil, errors.New("team ID mismatch")
+ }
+ // InstallScript is populated internally from file contents, but these fields
+ // should NOT be output in GitOps YAML
+ return &fleet.SoftwareTitle{
+ ID: 6,
+ SoftwarePackage: &fleet.SoftwareInstaller{
+ InstallScript: "#!/usr/bin/env python3\nprint('This is the Python script content')",
+ PostInstallScript: "",
+ UninstallScript: "",
+ PreInstallQuery: "",
+ SelfService: true,
+ Platform: "linux",
+ URL: "https://example.com/download/install.py",
+ Name: "install.py",
+ },
+ }, nil
default:
return c.MockClient.GetSoftwareTitleByID(id, teamID)
}
diff --git a/cmd/fleetctl/integrationtest/gitops/software_test.go b/cmd/fleetctl/integrationtest/gitops/software_test.go
index 8d483481e9..ca713bf931 100644
--- a/cmd/fleetctl/integrationtest/gitops/software_test.go
+++ b/cmd/fleetctl/integrationtest/gitops/software_test.go
@@ -35,7 +35,7 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) {
}{
{"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
{"testdata/gitops/team_software_installer_install_script_secret.yml", "environment variable \"FLEET_SECRET_NAME\" not set"},
- {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1."},
+ {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."},
{"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 513MiB"},
{"testdata/gitops/team_software_installer_valid.yml", ""},
{"testdata/gitops/team_software_installer_subdir.yml", ""},
@@ -427,7 +427,7 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
wantErr string
}{
{"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
- {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1."},
+ {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."},
{"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 513MiB"},
{"testdata/gitops/no_team_software_installer_valid.yml", ""},
{"testdata/gitops/no_team_software_installer_subdir.yml", ""},
diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go
index e56e928e43..17cd5f68e8 100644
--- a/ee/server/service/software_installers.go
+++ b/ee/server/service/software_installers.go
@@ -2033,8 +2033,8 @@ func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host
}
if host.FleetPlatform() != requiredPlatform {
- // Allow .sh scripts for any unix-like platform (linux and darwin)
- if !(ext == ".sh" && fleet.IsUnixLike(host.Platform)) {
+ // Allow .sh and .py scripts for any unix-like platform (linux and darwin)
+ if !((ext == ".sh" || ext == ".py") && fleet.IsUnixLike(host.Platform)) {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.NewWithData(
@@ -2365,7 +2365,7 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
if err != nil {
if errors.Is(err, file.ErrUnsupportedType) {
return "", &fleet.BadRequestError{
- Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1.",
+ Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
@@ -2539,6 +2539,8 @@ func (svc *Service) addScriptPackageMetadata(ctx context.Context, payload *fleet
payload.Source = "sh_packages"
case "ps1":
payload.Source = "ps1_packages"
+ case "py":
+ payload.Source = "py_packages"
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension(extension)
@@ -3228,7 +3230,7 @@ func (svc *Service) softwareBatchUpload(
ext = strings.TrimPrefix(ext, ".")
if !fleet.IsScriptPackage(ext) {
- return fmt.Errorf("script:// URL must reference a .sh or .ps1 file, got: %s", filename)
+ return fmt.Errorf("script:// URL must reference a .sh, .py, or .ps1 file, got: %s", filename)
}
if p.InstallScript == "" {
@@ -3913,8 +3915,8 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f
}
if host.FleetPlatform() != requiredPlatform {
- // Allow .sh scripts for any unix-like platform (linux and darwin)
- if !(ext == ".sh" && fleet.IsUnixLike(host.Platform)) {
+ // Allow .sh and .py scripts for any unix-like platform (linux and darwin)
+ if !((ext == ".sh" || ext == ".py") && fleet.IsUnixLike(host.Platform)) {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.WrapWithData(
@@ -4098,7 +4100,7 @@ func packageExtensionToPlatform(ext string) string {
requiredPlatform = "windows"
case ".pkg", ".dmg":
requiredPlatform = "darwin"
- case ".deb", ".rpm", ".gz", ".tgz", ".sh":
+ case ".deb", ".rpm", ".gz", ".tgz", ".sh", ".py":
requiredPlatform = "linux"
default:
return ""
diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go
index 88383c9bf0..759dfb874e 100644
--- a/ee/server/service/software_installers_test.go
+++ b/ee/server/service/software_installers_test.go
@@ -1241,6 +1241,82 @@ func TestAddScriptPackageMetadata(t *testing.T) {
require.NotEmpty(t, payload.StorageID)
})
+ t.Run("valid python script", func(t *testing.T) {
+ scriptContents := "#!/usr/bin/env python3\nprint('Installing software')\n"
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "install-app.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.NoError(t, err)
+ require.Equal(t, "install-app", payload.Title)
+ require.Empty(t, payload.Version)
+ require.Equal(t, scriptContents, payload.InstallScript)
+ require.Equal(t, "linux", payload.Platform)
+ require.Equal(t, "py_packages", payload.Source)
+ require.Empty(t, payload.BundleIdentifier)
+ require.Empty(t, payload.PackageIDs)
+ require.NotEmpty(t, payload.StorageID)
+ require.Equal(t, "py", payload.Extension)
+ })
+
+ t.Run("python script without shebang", func(t *testing.T) {
+ scriptContents := "print('hello')\n"
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "test.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Script validation failed")
+ require.Contains(t, err.Error(), "python shebang")
+ })
+
+ t.Run("python script with shell shebang", func(t *testing.T) {
+ scriptContents := "#!/bin/bash\necho 'hello'\n"
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "test.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Script validation failed")
+ require.Contains(t, err.Error(), "python shebang")
+ })
+
t.Run("invalid shebang", func(t *testing.T) {
scriptContents := "#!/usr/bin/python\nprint('hello')\n"
tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.sh")
@@ -1388,6 +1464,32 @@ func TestAddScriptPackageMetadataLargeScript(t *testing.T) {
require.NoError(t, err)
require.Equal(t, scriptContents, payload.InstallScript)
})
+
+ t.Run("large python script within saved limit", func(t *testing.T) {
+ t.Parallel()
+ scriptContents := "#!/usr/bin/env python3\n" + strings.Repeat("print('line')\n", 1000)
+ require.Greater(t, len(scriptContents), fleet.UnsavedScriptMaxRuneLen)
+ require.Less(t, len(scriptContents), fleet.SavedScriptMaxRuneLen)
+
+ tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.py")
+ require.NoError(t, err)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(scriptContents)
+ require.NoError(t, err)
+
+ tfr, err := fleet.NewKeepFileReader(tmpFile.Name())
+ require.NoError(t, err)
+ defer tfr.Close()
+
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallerFile: tfr,
+ Filename: "large-install.py",
+ }
+
+ err = svc.addScriptPackageMetadata(ctx, payload, "py")
+ require.NoError(t, err)
+ require.Equal(t, scriptContents, payload.InstallScript)
+ })
}
// TestInstallShScriptOnDarwin tests that .sh scripts (stored as platform='linux')
@@ -1671,6 +1773,172 @@ func TestInstallShScriptOnWindowsFails(t *testing.T) {
require.Contains(t, bre.Message, "can be installed only on linux hosts")
}
+// .py packages are stored with platform='linux', but the unix-like exception
+// must still let them install on darwin hosts.
+func TestInstallPyScriptOnUnixLike(t *testing.T) {
+ t.Parallel()
+
+ for _, platform := range []string{"linux", "darwin"} {
+ t.Run(platform, func(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return &fleet.Host{
+ ID: 1,
+ OrbitNodeKey: new("orbit_key"),
+ Platform: platform,
+ TeamID: new(uint(1)),
+ }, nil
+ }
+
+ ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
+ return nil, nil
+ }
+
+ ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{
+ InstallerID: 10,
+ Name: "script.py",
+ Extension: "py",
+ Platform: "linux",
+ TeamID: new(uint(1)),
+ TitleID: new(uint(100)),
+ SelfService: false,
+ }, nil
+ }
+ mockSoftwarePackagesFromMetadata(ds)
+
+ ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
+ return true, nil
+ }
+
+ ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
+ return nil, nil
+ }
+
+ ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error {
+ return nil
+ }
+
+ ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
+ return "install-uuid", nil
+ }
+
+ ctx := viewer.NewContext(context.Background(), viewer.Viewer{
+ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)},
+ })
+
+ err := svc.InstallSoftwareTitle(ctx, 1, 100)
+ require.NoError(t, err, ".py install on %s should succeed", platform)
+ require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created")
+ })
+ }
+}
+
+func TestInstallPyScriptOnWindowsFails(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return &fleet.Host{
+ ID: 1,
+ OrbitNodeKey: new("orbit_key"),
+ Platform: "windows",
+ TeamID: new(uint(1)),
+ }, nil
+ }
+
+ ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
+ return nil, nil
+ }
+
+ ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{
+ InstallerID: 10,
+ Name: "script.py",
+ Extension: "py",
+ Platform: "linux",
+ TeamID: new(uint(1)),
+ TitleID: new(uint(100)),
+ SelfService: false,
+ }, nil
+ }
+ mockSoftwarePackagesFromMetadata(ds)
+
+ ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
+ return true, nil
+ }
+
+ ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
+ return nil, nil
+ }
+
+ ctx := viewer.NewContext(context.Background(), viewer.Viewer{
+ User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)},
+ })
+
+ err := svc.InstallSoftwareTitle(ctx, 1, 100)
+ require.Error(t, err, ".py install on windows should fail")
+
+ var bre *fleet.BadRequestError
+ require.ErrorAs(t, err, &bre, "error should be BadRequestError")
+ require.NotNil(t, bre)
+ require.Contains(t, bre.Message, "can be installed only on linux hosts")
+}
+
+// .py packages are stored with platform='linux'; the self-service install path
+// must still allow them on darwin hosts via the unix-like exception.
+func TestSelfServiceInstallPyScriptOnUnixLike(t *testing.T) {
+ t.Parallel()
+
+ for _, platform := range []string{"linux", "darwin"} {
+ t.Run(platform, func(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{
+ InstallerID: 10,
+ Name: "script.py",
+ Extension: "py",
+ Platform: "linux",
+ TeamID: new(uint(1)),
+ TitleID: new(uint(100)),
+ SelfService: true,
+ }, nil
+ }
+ mockSoftwarePackagesFromMetadata(ds)
+
+ ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
+ return true, nil
+ }
+
+ ds.ResetNonPolicyInstallAttemptsFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint) error {
+ return nil
+ }
+
+ ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
+ return "install-uuid", nil
+ }
+
+ host := &fleet.Host{
+ ID: 1,
+ OrbitNodeKey: new("orbit_key"),
+ Platform: platform,
+ TeamID: new(uint(1)),
+ }
+
+ err := svc.SelfServiceInstallSoftwareTitle(context.Background(), host, 100)
+ require.NoError(t, err, ".py self-service install on %s should succeed", platform)
+ require.True(t, ds.InsertSoftwareInstallRequestFuncInvoked, "install request should be created")
+ })
+ }
+}
+
func TestSelfServiceInstallSoftwareTitleAllowsPersonallyEnrolledDevices(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx
index 32d89cbcad..ff8b4cad25 100644
--- a/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx
+++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx
@@ -1,5 +1,5 @@
/** This component is intentionally separate from SoftwareInstallDetailsModal
- * because it handles script-only package installs (e.g. sh_packages or ps1_packages)
+ * because it handles script-only package installs (e.g. sh_packages, ps1_packages, or py_packages)
*
* Key differences from SoftwareInstallDetailsModal:
* - Uses Script/Run/Rerun language in UI instead of Install/Retry.
diff --git a/frontend/components/CommandPalette/groups/commands.ts b/frontend/components/CommandPalette/groups/commands.ts
index e1cf1a5c32..f21d4e330b 100644
--- a/frontend/components/CommandPalette/groups/commands.ts
+++ b/frontend/components/CommandPalette/groups/commands.ts
@@ -291,6 +291,8 @@ const buildCommandsItems = (
"tar.gz",
"tarballs",
"sh",
+ "py",
+ "python",
],
},
{
diff --git a/frontend/components/CommandPalette/helpers.tests.ts b/frontend/components/CommandPalette/helpers.tests.ts
index e487ecfdbf..90342fcb77 100644
--- a/frontend/components/CommandPalette/helpers.tests.ts
+++ b/frontend/components/CommandPalette/helpers.tests.ts
@@ -329,6 +329,19 @@ describe("CommandPalette helpers", () => {
expect(keywords).not.toContain("sso");
});
+ it("surfaces Add custom package when searching py / python (.py is an accepted upload type)", () => {
+ const items = buildPaletteItems({
+ ...BASE_CONTEXT,
+ hasTeamSelected: true,
+ currentTeam: { id: 1, name: "Engineering" },
+ });
+ const addCustomPackage = items.find((i) => i.id === "add-custom-package");
+ expect(addCustomPackage).toBeDefined();
+ const keywords = addCustomPackage?.keywords ?? [];
+ expect(keywords).toContain("py");
+ expect(keywords).toContain("python");
+ });
+
it("hides calendar keywords on Unassigned (Calendar section is disabled there) but keeps conditional access", () => {
const items = buildPaletteItems({
...BASE_CONTEXT,
diff --git a/frontend/interfaces/package_type.ts b/frontend/interfaces/package_type.ts
index b66813200b..9049a8d114 100644
--- a/frontend/interfaces/package_type.ts
+++ b/frontend/interfaces/package_type.ts
@@ -1,7 +1,7 @@
const fleetMaintainedPackageTypes = ["dmg", "zip"] as const;
const unixPackageTypes = ["pkg", "deb", "rpm", "dmg", "zip", "tar.gz"] as const;
const windowsPackageTypes = ["msi", "exe", "zip"] as const;
-const scriptOnlyPackageTypes = ["sh", "ps1"] as const;
+const scriptOnlyPackageTypes = ["sh", "ps1", "py"] as const;
const iosIpadosPackageTypes = ["ipa"] as const;
export const packageTypes = [
...unixPackageTypes,
diff --git a/frontend/interfaces/setup.ts b/frontend/interfaces/setup.ts
index e5f5fb250d..95e245418f 100644
--- a/frontend/interfaces/setup.ts
+++ b/frontend/interfaces/setup.ts
@@ -13,7 +13,7 @@ export type SetupStepStatus = typeof SETUP_STEP_STATUSES[number];
/** These type extends onto API returned software steps */
export const SETUP_STEP_TYPES = [
"software_install", // API key: software
- "software_script_run", // API key: software, detected via source === "sh_packages" || "ps1_packages"
+ "software_script_run", // API key: software, detected via a script package source (see SCRIPT_PACKAGE_SOURCES)
"script_run", // API key: scripts
];
@@ -24,7 +24,7 @@ export interface ISetupStep {
status: SetupStepStatus;
type: SetupStepType;
error?: string | null;
- source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "apps")
+ source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "py_packages", "apps")
display_name?: string | null;
icon_url?: string | null;
}
diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts
index 49db0aed3b..e213c86430 100644
--- a/frontend/interfaces/software.ts
+++ b/frontend/interfaces/software.ts
@@ -313,6 +313,7 @@ export const SOURCE_TYPE_CONVERSION = {
vscode_extensions: "IDE extension", // vscode_extensions can include any vscode-based editor (e.g., Cursor, Trae, Windsurf), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
sh_packages: "Script-only package (macOS & Linux)",
ps1_packages: "Script-only package (Windows)",
+ py_packages: "Script-only package (macOS & Linux)",
jetbrains_plugins: "IDE extension", // jetbrains_plugins can include any JetBrains IDE (e.g., IntelliJ, PyCharm, WebStorm), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
go_binaries: "Binary (Go)",
} as const;
@@ -346,11 +347,16 @@ export const INSTALLABLE_SOURCE_PLATFORM_CONVERSION = {
vscode_extensions: null,
sh_packages: "linux", // 4.76 Added support for Linux hosts only
ps1_packages: "windows",
+ py_packages: "linux", // stored as linux; also runs on macOS via the unix-like install exception
jetbrains_plugins: null,
go_binaries: null,
} as const;
-export const SCRIPT_PACKAGE_SOURCES = ["sh_packages", "ps1_packages"];
+export const SCRIPT_PACKAGE_SOURCES = [
+ "sh_packages",
+ "ps1_packages",
+ "py_packages",
+];
/** Mirrors `fleet.MaxPackagesPerTitle` in `server/fleet/software_installer.go`.
* The backend rejects the upload past this cap with the `SoftwarePackageLimitMessage`
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx
index 3d22b80414..89b2c616e3 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx
@@ -1817,6 +1817,60 @@ describe("Activity Feed", () => {
expect(screen.getByText("Script-only Software")).toBeInTheDocument();
});
+ it("renders py script package ran status in InstalledSoftware activity", () => {
+ const activity = createMockActivity({
+ type: ActivityType.InstalledSoftware,
+ actor_full_name: "Script Admin",
+ details: {
+ software_title: "Python Script Software",
+ source: "py_packages",
+ status: "installed",
+ software_package: "install.py",
+ host_display_name: "Example Host",
+ },
+ });
+
+ render();
+ expect(screen.getByText(/ran/i)).toBeInTheDocument();
+ expect(screen.getByText("Python Script Software")).toBeInTheDocument();
+ });
+
+ it("renders py script package pending run status in InstalledSoftware activity", () => {
+ const activity = createMockActivity({
+ type: ActivityType.InstalledSoftware,
+ actor_full_name: "Script Admin",
+ details: {
+ software_title: "Python Script Software",
+ source: "py_packages",
+ status: "pending_install",
+ software_package: "install.py",
+ host_display_name: "Example Host",
+ },
+ });
+
+ render();
+ expect(screen.getByText(/told Fleet to run/i)).toBeInTheDocument();
+ expect(screen.getByText("Python Script Software")).toBeInTheDocument();
+ });
+
+ it("renders py script package failed run status in InstalledSoftware activity", () => {
+ const activity = createMockActivity({
+ type: ActivityType.InstalledSoftware,
+ actor_full_name: "Script Admin",
+ details: {
+ software_title: "Python Script Software",
+ source: "py_packages",
+ status: "failed_install",
+ software_package: "install.py",
+ host_display_name: "Example Host",
+ },
+ });
+
+ render();
+ expect(screen.getByText(/failed to run/i)).toBeInTheDocument();
+ expect(screen.getByText("Python Script Software")).toBeInTheDocument();
+ });
+
it("renders addedNdesScepProxy activity correctly", () => {
const activity = createMockActivity({
type: ActivityType.AddedNdesScepProxy,
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
index 667a83e803..f19d0d4538 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
@@ -74,8 +74,12 @@ describe("AddPackageModal", () => {
it("falls back to the all-platforms file-type message when the existing name has no recognized extension", () => {
renderModal({ existingPackageName: "no-extension" });
- // PackageForm's default message lists every supported platform.
- expect(screen.getByText(/macOS \(.pkg,/)).toBeInTheDocument();
+ // PackageForm's default message lists every supported platform as
+ // tooltip triggers (extensions live in the tooltips, not the label text).
+ expect(screen.getByText("macOS")).toBeInTheDocument();
+ expect(screen.getByText("iOS/iPadOS")).toBeInTheDocument();
+ expect(screen.getByText("Windows")).toBeInTheDocument();
+ expect(screen.getByText("Linux")).toBeInTheDocument();
});
it("renders the form's Save button as 'Save' (not 'Add software')", async () => {
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
index 26fe64bf00..7fc3c5c341 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
@@ -11,7 +11,7 @@ import TooltipWrapper from "components/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import TruncatedTextList from "components/TruncatedTextList";
import { ILabelSoftwareTitle } from "interfaces/label";
-import { InstallerType } from "interfaces/software";
+import { InstallerType, SoftwareSource } from "interfaces/software";
import { getSelfServiceTooltip } from "pages/SoftwarePage/helpers";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
@@ -49,6 +49,9 @@ export interface ILibraryItemAccordionProps {
isLatestFmaVersion?: boolean;
/** Hide the version entirely (script-only packages). */
isScriptPackage?: boolean;
+ /** Software source, threaded to the installer widget to pick the file icon
+ * (e.g. `file-py` for `py_packages`). */
+ source?: SoftwareSource;
isTarballPackage?: boolean;
/** Apple App Store app whose platform is iOS or iPadOS. Drops the
* "policy automation" leg from the info-icon tooltip — `automatic_install`
@@ -144,6 +147,7 @@ const LibraryItemAccordion = ({
isFma = false,
isLatestFmaVersion,
isScriptPackage = false,
+ source,
isTarballPackage = false,
isIosOrIpadosApp = false,
isActive,
@@ -643,6 +647,7 @@ const LibraryItemAccordion = ({
isFma={isFma}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
+ source={source}
androidPlayStoreId={androidPlayStoreId}
hideInstallerType
// Inactive rows surface a single hover tooltip (the rollback hint);
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx
index f13ef1a7fb..356ce2aeb1 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx
@@ -34,6 +34,18 @@ describe("InstallerDetailsWidget", () => {
expect(screen.queryByTestId("software-icon")).not.toBeInTheDocument();
});
+ it("renders the Python icon for a py_packages script package", () => {
+ render();
+ expect(screen.queryByTestId("file-py-graphic")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-pkg-graphic")).not.toBeInTheDocument();
+ });
+
+ it("renders the generic package icon for other script sources", () => {
+ render();
+ expect(screen.queryByTestId("file-pkg-graphic")).toBeInTheDocument();
+ expect(screen.queryByTestId("file-py-graphic")).not.toBeInTheDocument();
+ });
+
it("renders the software name", () => {
render();
expect(screen.getByText("Test Software")).toBeInTheDocument();
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx
index fbaa1a3aa4..b4149f1334 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx
@@ -8,7 +8,7 @@ import { internationalTimeFormat } from "utilities/helpers";
import { addedFromNow } from "utilities/date_format";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
-import { InstallerType } from "interfaces/software";
+import { InstallerType, SoftwareSource } from "interfaces/software";
import { isAndroidWebApp } from "pages/SoftwarePage/helpers";
@@ -74,6 +74,8 @@ interface IInstallerDetailsWidgetProps {
isFma: boolean;
isLatestFmaVersion?: boolean;
isScriptPackage: boolean;
+ /** Software source, used to pick the file icon (e.g. `file-py` for `py_packages`). */
+ source?: SoftwareSource;
androidPlayStoreId?: string;
customDetails?: string;
/** Suppress the leading installer-type label ("Custom package", "App Store (VPP)",
@@ -99,6 +101,7 @@ const InstallerDetailsWidget = ({
isFma,
isLatestFmaVersion = false,
isScriptPackage,
+ source,
androidPlayStoreId,
customDetails,
hideInstallerType = false,
@@ -113,6 +116,9 @@ const InstallerDetailsWidget = ({
}
return ;
}
+ if (source === "py_packages") {
+ return
;
+ }
return
;
};
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
index ded401c5f7..db0e726fdd 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
@@ -371,6 +371,7 @@ const SoftwareTitleDetailsPage = ({
isFma={isFma}
isLatestFmaVersion={row.isActive && isLatestFmaVersion}
isScriptPackage={isScriptPackage}
+ source={title.source}
isTarballPackage={title.source === "tgz_packages"}
isIosOrIpadosApp={isIosOrIpadosApp}
isActive={row.isActive}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
index ebb9ec5d2d..11886c9436 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
@@ -147,6 +147,45 @@ describe("SoftwareTitleDetailsPage helpers", () => {
isSelfService: true,
});
});
+ it("marks a py_packages title as a script package", () => {
+ const softwareTitle: ISoftwareTitleDetails = {
+ id: 1,
+ name: "Test Script",
+ icon_url: null,
+ versions: [],
+ software_package: {
+ labels_include_any: null,
+ labels_exclude_any: null,
+ labels_include_all: null,
+ name: "install.py",
+ installer_id: 1,
+ title_id: 2,
+ version: "",
+ self_service: false,
+ uploaded_at: "2021-01-01T00:00:00Z",
+ status: {
+ installed: 1,
+ pending_install: 0,
+ pending_uninstall: 0,
+ failed_install: 0,
+ failed_uninstall: 0,
+ },
+ install_script: "#!/usr/bin/env python3\nprint('hi')",
+ uninstall_script: "",
+ icon_url: null,
+ automatic_install_policies: [],
+ url: "",
+ },
+ packages: null,
+ app_store_app: null,
+ source: "py_packages",
+ hosts_count: 0,
+ };
+ const packageCardInfo = getInstallerCardInfo(softwareTitle);
+ expect(packageCardInfo.source).toEqual("py_packages");
+ expect(packageCardInfo.isScriptPackage).toBe(true);
+ expect(packageCardInfo.name).toEqual("install.py");
+ });
it("returns the correct data for an app store app (and with a custom display name)", () => {
const softwareTitle: ISoftwareTitleDetails = {
id: 1,
diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
index a225109694..951684796d 100644
--- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
+++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx
@@ -264,8 +264,8 @@ const SoftwareDetailsSummary = ({
}
};
- // Remove host count for tgz_packages, sh_packages, and ps1_packages only
- // or if viewing details summary from edit icon preview modal
+ // Remove host count for sources without version/host data (tgz and script
+ // packages) or if viewing details summary from edit icon preview modal
const showHostCount =
!!hostCount && !NO_VERSION_OR_HOST_DATA_SOURCES.includes(source || "");
diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx
index 5ffea8e6b4..b4a62aceac 100644
--- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx
+++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx
@@ -70,6 +70,8 @@ const getGraphicName = (ext: string) => {
return "file-sh";
} else if (ext === "ps1") {
return "file-ps1";
+ } else if (ext === "py") {
+ return "file-py";
}
return "file-pkg";
};
@@ -95,14 +97,17 @@ const renderSoftwareDeployWarningBanner = () => (
const renderFileTypeMessage = () => {
return (
<>
- macOS (.pkg,{" "}
- .sh),
- iOS/iPadOS (.ipa),
-
- Windows (.msi, .exe,{" "}
- .ps1),
- or Linux (.deb, .rpm, .tar.gz,{" "}
- .sh)
+
+ macOS
+
+ , iOS/iPadOS,{" "}
+
+ Windows
+
+ , or{" "}
+
+ Linux
+
>
);
};
@@ -149,7 +154,7 @@ interface IPackageFormProps {
}
// application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly
const ACCEPTED_EXTENSIONS =
- ".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1,.ipa";
+ ".pkg,.msi,.exe,.deb,.rpm,application/gzip,.tgz,.sh,.ps1,.py,.ipa";
const PackageForm = ({
labels,
diff --git a/frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts b/frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts
new file mode 100644
index 0000000000..8e838547a3
--- /dev/null
+++ b/frontend/pages/hosts/details/DeviceUserPage/helpers.tests.ts
@@ -0,0 +1,25 @@
+import { ISetupStep } from "interfaces/setup";
+import { isSoftwareScriptSetup } from "./helpers";
+
+const setupStep = (source?: ISetupStep["source"]): ISetupStep => ({
+ name: "test",
+ status: "success",
+ type: "software_script_run",
+ source,
+});
+
+describe("DeviceUserPage helpers - isSoftwareScriptSetup", () => {
+ it("returns true for script package sources (sh, ps1, py)", () => {
+ expect(isSoftwareScriptSetup(setupStep("sh_packages"))).toBe(true);
+ expect(isSoftwareScriptSetup(setupStep("ps1_packages"))).toBe(true);
+ expect(isSoftwareScriptSetup(setupStep("py_packages"))).toBe(true);
+ });
+
+ it("returns false for non-script sources", () => {
+ expect(isSoftwareScriptSetup(setupStep("apps"))).toBe(false);
+ });
+
+ it("returns false when source is missing", () => {
+ expect(isSoftwareScriptSetup(setupStep(undefined))).toBe(false);
+ });
+});
diff --git a/frontend/pages/hosts/details/DeviceUserPage/helpers.ts b/frontend/pages/hosts/details/DeviceUserPage/helpers.ts
index 289cba399c..d902ec11d7 100644
--- a/frontend/pages/hosts/details/DeviceUserPage/helpers.ts
+++ b/frontend/pages/hosts/details/DeviceUserPage/helpers.ts
@@ -1,4 +1,5 @@
import { ISetupStep } from "interfaces/setup";
+import { SCRIPT_PACKAGE_SOURCES } from "interfaces/software";
const DEFAULT_ERROR_MESSAGE = "refetch error.";
@@ -39,12 +40,12 @@ export const getFailedSoftwareInstall = (
return firstWithError ?? failedSoftware[0];
};
-/** Checks if the software is a script-only package (sh or ps1)
+/** Checks if the software is a script-only package (sh, ps1, or py)
* by examining the source field from the API */
export const isSoftwareScriptSetup = (s: ISetupStep) => {
if (!s.source) return false;
- return s.source === "sh_packages" || s.source === "ps1_packages";
+ return SCRIPT_PACKAGE_SOURCES.includes(s.source);
};
// Hosts after enrollment during which we suppress the "host is offline" banner.
diff --git a/frontend/utilities/file/fileUtils.tests.tsx b/frontend/utilities/file/fileUtils.tests.tsx
index a80f44bba8..ee1c492a98 100644
--- a/frontend/utilities/file/fileUtils.tests.tsx
+++ b/frontend/utilities/file/fileUtils.tests.tsx
@@ -17,6 +17,7 @@ describe("fileUtils", () => {
{ fileName: "test.deb", expectedExtension: "deb" },
{ fileName: "test.rpm", expectedExtension: "rpm" },
{ fileName: "test.tar", expectedExtension: "tar" },
+ { fileName: "test.py", expectedExtension: "py" },
// Compound extensions
{ fileName: "test.tar.gz", expectedExtension: "tar.gz" },
@@ -55,6 +56,10 @@ describe("fileUtils", () => {
fileName: "test.tar.gz",
expectedDetails: { name: "test.tar.gz", description: "Linux" },
},
+ {
+ fileName: "test.py",
+ expectedDetails: { name: "test.py", description: "macOS & Linux" },
+ },
{
fileName: "unknown.file",
expectedDetails: { name: "unknown.file", description: undefined },
@@ -79,6 +84,7 @@ describe("fileUtils", () => {
{ extension: "xml", platform: "Windows" },
{ extension: "deb", platform: "Linux" },
{ extension: "tar.gz", platform: "Linux" },
+ { extension: "py", platform: "macOS & Linux" },
{ extension: undefined, platform: undefined }, // no extension
{ extension: "unknown_ext", platform: undefined }, // unmapped extension
];
diff --git a/frontend/utilities/file/fileUtils.tsx b/frontend/utilities/file/fileUtils.tsx
index cbc154c915..62ed63a6ad 100644
--- a/frontend/utilities/file/fileUtils.tsx
+++ b/frontend/utilities/file/fileUtils.tsx
@@ -24,6 +24,7 @@ export const FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME: Record<
"tar.gz": "Linux",
sh: "macOS & Linux",
ps1: "Windows",
+ py: "macOS & Linux",
ipa: "iOS/iPadOS",
};
diff --git a/frontend/utilities/software_install_scripts.ts b/frontend/utilities/software_install_scripts.ts
index 196634d448..6a958f1b5c 100644
--- a/frontend/utilities/software_install_scripts.ts
+++ b/frontend/utilities/software_install_scripts.ts
@@ -30,6 +30,7 @@ const getDefaultInstallScript = (fileName: string): string => {
case "tar.gz":
case "sh":
case "ps1":
+ case "py":
case "ipa":
return "";
default:
diff --git a/frontend/utilities/software_uninstall_scripts.ts b/frontend/utilities/software_uninstall_scripts.ts
index 35432ce5ff..efb6019592 100644
--- a/frontend/utilities/software_uninstall_scripts.ts
+++ b/frontend/utilities/software_uninstall_scripts.ts
@@ -29,6 +29,7 @@ const getDefaultUninstallScript = (fileName: string): string => {
case "tar.gz":
case "sh":
case "ps1":
+ case "py":
case "ipa":
return "";
default:
diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
index 5eed9fbf48..4e93fb38b6 100644
--- a/pkg/spec/gitops.go
+++ b/pkg/spec/gitops.go
@@ -288,7 +288,7 @@ type SoftwarePackage struct {
}
func (spec SoftwarePackage) HydrateToPackageLevel(packageLevel fleet.SoftwarePackageSpec, ext string) (fleet.SoftwarePackageSpec, error) {
- isScript := ext == ".sh" || ext == ".ps1"
+ isScript := fleet.IsScriptPackage(ext)
// Script-only packages are configured inline in the team YAML, so their
// uninstall/post-install scripts and pre-install query are allowed here;
@@ -304,7 +304,7 @@ func (spec SoftwarePackage) HydrateToPackageLevel(packageLevel fleet.SoftwarePac
// Icon should be allowed at the team level yaml for script packages which must be specified as a path
if spec.Icon.Path != "" {
- if ext != ".sh" && ext != ".ps1" {
+ if !fleet.IsScriptPackage(ext) {
return packageLevel, fmt.Errorf("the software package defined in %s must not have icons, scripts, queries, URL, or hash specified at the team level", *spec.Path)
}
}
@@ -2347,8 +2347,8 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
}
ext := strings.ToLower(filepath.Ext(resolvedPath))
- switch ext {
- case ".sh", ".ps1":
+ switch {
+ case fleet.IsScriptPackage(ext):
// Script files: only gather FLEET_SECRET_ variables, don't expand
// regular env vars (they are shell variables meant for the endpoint).
if err := gatherFileSecrets(result, resolvedPath); err != nil {
@@ -2376,7 +2376,7 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
}
softwarePackageSpecs = append(softwarePackageSpecs, &scriptSpec)
- case ".yml", ".yaml":
+ case ext == ".yml" || ext == ".yaml":
// Replace $var and ${var} with env values in YAML files only.
fileBytes, err = ExpandEnvBytes(fileBytes)
if err != nil {
@@ -2423,7 +2423,7 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
softwarePackageSpecs = valid
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))
+ multiError = multierror.Append(multiError, fmt.Errorf("software package path %s has unsupported extension %q; only .yml, .yaml, .sh, .ps1, or .py files are supported", *teamLevelPackage.Path, ext))
continue
}
} else {
diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go
index 60ee9286cc..81934b23a6 100644
--- a/pkg/spec/gitops_test.go
+++ b/pkg/spec/gitops_test.go
@@ -2486,6 +2486,59 @@ software:
assert.Equal(t, filepath.Join(basePath, "foo", "bar.png"), gitops.Software.Packages[0].Icon.Path)
}
+// A path-referenced .py is a script-only package: it must be accepted and
+// treated like .sh/.ps1, not rejected as an unsupported extension.
+func TestScriptOnlyPackagesPathPy(t *testing.T) {
+ t.Parallel()
+ config := getTeamConfig([]string{"software"})
+ config += `
+software:
+ packages:
+ - path: software/script-only.py
+ self_service: true
+ icon:
+ path: ./foo/bar.png
+ uninstall_script:
+ path: software/uninstall.sh
+ post_install_script:
+ path: software/post-install.sh
+ pre_install_query:
+ path: software/preinstall-query.yml
+`
+
+ path, basePath := createTempFile(t, "", config)
+
+ copies := []struct{ src, dst string }{
+ {filepath.Join("testdata", "software", "script-only.py"), filepath.Join(basePath, "software", "script-only.py")},
+ {filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "uninstall.sh")},
+ {filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "post-install.sh")},
+ }
+ for _, c := range copies {
+ require.NoError(t, file.Copy(c.src, c.dst, os.FileMode(0o755)))
+ }
+ require.NoError(t, file.Copy(
+ filepath.Join("testdata", "lib", "preinstall-query.yml"),
+ filepath.Join(basePath, "software", "preinstall-query.yml"),
+ os.FileMode(0o644),
+ ))
+
+ appConfig := fleet.EnrichedAppConfig{}
+ appConfig.License = &fleet.LicenseInfo{
+ Tier: fleet.TierPremium,
+ }
+ gitops, err := GitOpsFromFile(path, basePath, &appConfig, nopLogf)
+ require.NoError(t, err)
+ require.Len(t, gitops.Software.Packages, 1)
+
+ pkg := gitops.Software.Packages[0]
+ assert.Equal(t, filepath.Join(basePath, "software", "script-only.py"), pkg.InstallScript.Path)
+ assert.Equal(t, filepath.Join(basePath, "foo", "bar.png"), pkg.Icon.Path)
+ assert.Equal(t, filepath.Join(basePath, "software", "uninstall.sh"), pkg.UninstallScript.Path)
+ assert.Equal(t, filepath.Join(basePath, "software", "post-install.sh"), pkg.PostInstallScript.Path)
+ assert.Equal(t, filepath.Join(basePath, "software", "preinstall-query.yml"), pkg.PreInstallQuery.Path)
+ assert.True(t, pkg.SelfService)
+}
+
func TestScriptOnlyPackagesWithAdvancedOptions(t *testing.T) {
t.Parallel()
config := getTeamConfig([]string{"software"})
@@ -4815,7 +4868,7 @@ software:
_, 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")
+ assert.ErrorContains(t, err, "only .yml, .yaml, .sh, .ps1, or .py files are supported")
})
t.Run("script_with_team_options", func(t *testing.T) {
diff --git a/pkg/spec/testdata/software/script-only.py b/pkg/spec/testdata/software/script-only.py
new file mode 100644
index 0000000000..0485176deb
--- /dev/null
+++ b/pkg/spec/testdata/software/script-only.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+
+print("hello world")
diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go
index 3e247c363d..9e298fb5d3 100644
--- a/server/fleet/software_installer.go
+++ b/server/fleet/software_installer.go
@@ -752,6 +752,8 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error
return "sh_packages", nil
case "ps1":
return "ps1_packages", nil
+ case "py":
+ return "py_packages", nil
default:
return "", fmt.Errorf("unsupported file type: %s", ext)
}
@@ -760,7 +762,7 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error
func SoftwareInstallerPlatformFromExtension(ext string) (string, error) {
ext = strings.TrimPrefix(ext, ".")
switch ext {
- case "deb", "rpm", "tar.gz", "sh":
+ case "deb", "rpm", "tar.gz", "sh", "py":
return "linux", nil
case "exe", "msi", "ps1", "zip":
return "windows", nil
@@ -774,10 +776,10 @@ func SoftwareInstallerPlatformFromExtension(ext string) (string, error) {
}
// IsScriptPackage returns true if the extension represents a script package
-// (.sh or .ps1 files where the file contents become the install script).
+// (.sh, .ps1, or .py files where the file contents become the install script).
func IsScriptPackage(ext string) bool {
ext = strings.TrimPrefix(ext, ".")
- return ext == "sh" || ext == "ps1"
+ return ext == "sh" || ext == "ps1" || ext == "py"
}
// CanonicalPlatform maps a user-friendly platform name to Fleet's canonical
diff --git a/server/fleet/software_installer_test.go b/server/fleet/software_installer_test.go
index d8637d96b0..a3b500ca89 100644
--- a/server/fleet/software_installer_test.go
+++ b/server/fleet/software_installer_test.go
@@ -160,6 +160,8 @@ func TestSoftwareInstallerPlatformFromExtension(t *testing.T) {
{"sh", "linux", false},
{".ps1", "windows", false},
{"ps1", "windows", false},
+ {".py", "linux", false},
+ {"py", "linux", false},
// Unsupported extensions (msix is fleet-maintained only, not custom upload)
{".msix", "", true},
@@ -212,6 +214,8 @@ func TestSofwareInstallerSourceFromExtensionAndName(t *testing.T) {
{"sh", "setup.sh", "sh_packages", false},
{".ps1", "script.ps1", "ps1_packages", false},
{"ps1", "setup.ps1", "ps1_packages", false},
+ {".py", "script.py", "py_packages", false},
+ {"py", "setup.py", "py_packages", false},
// Unsupported extensions (msix is fleet-maintained only, not custom upload)
{".msix", "app.msix", "", true},
@@ -244,6 +248,8 @@ func TestIsScriptPackage(t *testing.T) {
{"sh", true},
{".ps1", true},
{"ps1", true},
+ {".py", true},
+ {"py", true},
// Non-script extensions - should return false
{".pkg", false},
@@ -263,6 +269,7 @@ func TestIsScriptPackage(t *testing.T) {
{"", false},
{".SH", false}, // Case sensitive
{".PS1", false}, // Case sensitive
+ {".PY", false}, // Case sensitive
{".bash", false}, // Not recognized
}
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index b2265d11c1..8202d5beb2 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -17151,6 +17151,77 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() {
})
require.Empty(t, storedURL, "cache-hit re-apply must drop the placeholder url too")
+ pyContent := "#!/usr/bin/env python3\nprint('Installing...')\n"
+ pyFile, err := fleet.NewTempFileReader(strings.NewReader(pyContent), func() string { return t.TempDir() })
+ require.NoError(t, err)
+ defer pyFile.Close()
+
+ payload = &fleet.UploadSoftwareInstallerPayload{
+ Filename: "install-app.py",
+ TeamID: &team.ID,
+ AutomaticInstall: true,
+ InstallerFile: pyFile,
+ }
+ s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Fleet can't create a policy to detect existing installations for .py packages.")
+
+ err = pyFile.Rewind()
+ require.NoError(t, err)
+
+ badPyContent := "print('no shebang')\n"
+ badPyFile, err := fleet.NewTempFileReader(strings.NewReader(badPyContent), func() string { return t.TempDir() })
+ require.NoError(t, err)
+ defer badPyFile.Close()
+ payload = &fleet.UploadSoftwareInstallerPayload{
+ Filename: "no-shebang.py",
+ TeamID: &team.ID,
+ InstallerFile: badPyFile,
+ }
+ s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Script validation failed")
+
+ // install_script is derived from the file, so any install_script param is ignored.
+ payload = &fleet.UploadSoftwareInstallerPayload{
+ Filename: "install-app.py",
+ Title: "install-app.py",
+ TeamID: &team.ID,
+ InstallScript: "this should be ignored",
+ UninstallScript: "echo 'uninstall py'",
+ PostInstallScript: "echo 'post py'",
+ PreInstallQuery: "SELECT 1;",
+ InstallerFile: pyFile,
+ }
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
+
+ var pyStored struct {
+ Source string `db:"source"`
+ InstallScript string `db:"install_script"`
+ UninstallScript string `db:"uninstall_script"`
+ PostInstallScript string `db:"post_install_script"`
+ PreInstallQuery string `db:"pre_install_query"`
+ Platform string `db:"platform"`
+ }
+ mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(context.Background(), q, &pyStored, `
+ SELECT
+ st.source,
+ COALESCE(inst.contents, '') AS install_script,
+ COALESCE(uninst.contents, '') AS uninstall_script,
+ COALESCE(postinst.contents, '') AS post_install_script,
+ si.pre_install_query,
+ si.platform
+ FROM software_installers si
+ JOIN software_titles st ON st.id = si.title_id
+ LEFT JOIN script_contents inst ON inst.id = si.install_script_content_id
+ LEFT JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id
+ LEFT JOIN script_contents postinst ON postinst.id = si.post_install_script_content_id
+ WHERE si.global_or_team_id = ? AND si.filename = ?`, team.ID, payload.Filename)
+ })
+ require.Equal(t, "py_packages", pyStored.Source, "py package should be stored with py_packages source")
+ require.Equal(t, pyContent, pyStored.InstallScript, "install_script should be the .py file contents, not the ignored param")
+ require.Equal(t, "echo 'uninstall py'", pyStored.UninstallScript)
+ require.Equal(t, "echo 'post py'", pyStored.PostInstallScript)
+ require.Equal(t, "SELECT 1;", pyStored.PreInstallQuery)
+ require.Equal(t, "linux", pyStored.Platform, ".py packages are stored with the linux platform")
+
// Fresh team so filename collisions from earlier assertions don't leak in.
crossTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "cross"})
require.NoError(t, err)
@@ -22689,6 +22760,11 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploadValidation() {
err = os.WriteFile(ps1ScriptPath, ps1ScriptContent, 0o644)
require.NoError(t, err)
+ pyScriptPath := filepath.Join(tmpDir, "test-script.py")
+ pyScriptContent := []byte("#!/usr/bin/env python3\nprint('Installing...')\n")
+ err = os.WriteFile(pyScriptPath, pyScriptContent, 0o644)
+ require.NoError(t, err)
+
t.Run("sh script package preserves advanced options", func(t *testing.T) {
installerFile, err := fleet.NewKeepFileReader(shScriptPath)
require.NoError(t, err)
@@ -22780,6 +22856,53 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploadValidation() {
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, 204, "team_id", "0")
})
+
+ t.Run("py script package preserves advanced options", func(t *testing.T) {
+ installerFile, err := fleet.NewKeepFileReader(pyScriptPath)
+ require.NoError(t, err)
+ defer installerFile.Close()
+
+ // install_script is ignored (the file is the install script); the rest persist.
+ payload := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "print('install_script is ignored')",
+ PostInstallScript: "echo 'post-install'",
+ UninstallScript: "echo 'uninstall'",
+ PreInstallQuery: "SELECT 1",
+ Filename: "test-script.py",
+ InstallerFile: installerFile,
+ }
+
+ s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
+
+ var listResp listSoftwareTitlesResponse
+ s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listResp, "team_id", "0", "available_for_install", "true")
+
+ var found bool
+ var titleID uint
+ for _, sw := range listResp.SoftwareTitles {
+ if sw.SoftwarePackage != nil && sw.SoftwarePackage.Name == "test-script.py" {
+ found = true
+ titleID = sw.ID
+ break
+ }
+ }
+ require.True(t, found, "Script package should be created")
+
+ var titleResp getSoftwareTitleResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", "0")
+
+ require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage)
+ installer := titleResp.SoftwareTitle.SoftwarePackage
+
+ require.Equal(t, "py_packages", titleResp.SoftwareTitle.Source, ".py script package should have py_packages source")
+ require.Equal(t, string(pyScriptContent), installer.InstallScript, ".py script package should have install_script from file contents")
+ require.NotEqual(t, "print('install_script is ignored')", installer.InstallScript, "user-provided install_script should be overwritten")
+ require.Equal(t, "echo 'post-install'", installer.PostInstallScript, ".py script package should persist post_install_script")
+ require.Equal(t, "echo 'uninstall'", installer.UninstallScript, ".py script package should persist uninstall_script")
+ require.Equal(t, "SELECT 1", installer.PreInstallQuery, ".py script package should persist pre_install_query")
+
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, 204, "team_id", "0")
+ })
}
func (s *integrationEnterpriseTestSuite) TestBatchSoftwareUploadWithSHAs() {
diff --git a/server/service/testdata/software-installers/script.py b/server/service/testdata/software-installers/script.py
new file mode 100644
index 0000000000..9414dafe61
--- /dev/null
+++ b/server/service/testdata/software-installers/script.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+
+print("script")