diff --git a/changes/41470-python-script-only-packages b/changes/41470-python-script-only-packages new file mode 100644 index 00000000000..12d43d136c1 --- /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 023dd9f4468..5005d7ffc59 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -1812,10 +1812,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) @@ -1827,6 +1827,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 } @@ -1834,6 +1836,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"] @@ -1860,10 +1863,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") @@ -1881,6 +1898,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 { @@ -1893,6 +1911,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") } } @@ -1934,6 +1957,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) @@ -1997,6 +2030,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 8d483481e9e..ca713bf9313 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 820bccf4ba0..4ed6a55e2b3 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -1799,8 +1799,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( @@ -2097,7 +2097,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"), } } @@ -2271,6 +2271,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) @@ -2956,7 +2958,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 == "" { @@ -3728,7 +3730,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 ad2a29ae722..a1dcac0ffac 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -803,6 +803,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") @@ -950,6 +1026,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') @@ -1229,6 +1331,120 @@ 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 + } + + 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 + } + + 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") +} + 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 33fb4d4c7cd..271f4572bba 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/interfaces/package_type.ts b/frontend/interfaces/package_type.ts index b66813200b0..9049a8d1149 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 e5f5fb250d7..95e245418f9 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 86d290bde1a..106dfdf6f30 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -299,6 +299,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; @@ -332,11 +333,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", +]; /** Sources that don't map cleanly to versions or hosts in software inventory. * UI behavior for these sources: diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx index 39b1c4dbe4d..d3ed83cefe0 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/LibraryItemAccordion/LibraryItemAccordion.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx index dd631847c3a..79484c89092 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx @@ -11,7 +11,7 @@ import TooltipTruncatedText from "components/TooltipTruncatedText"; import TruncatedTextList from "components/TruncatedTextList"; import { IconNames } from "components/icons"; import { ILabelSoftwareTitle } from "interfaces/label"; -import { InstallerType } from "interfaces/software"; +import { InstallerType, SoftwareSource } from "interfaces/software"; import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget"; const baseClass = "library-item-accordion"; @@ -48,6 +48,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` @@ -119,6 +122,7 @@ const LibraryItemAccordion = ({ isFma = false, isLatestFmaVersion, isScriptPackage = false, + source, isTarballPackage = false, isIosOrIpadosApp = false, isActive, @@ -523,6 +527,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 f13ef1a7fb0..356ce2aeb15 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 fbaa1a3aa4d..b4149f1334b 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 4b00fc57282..4cd69f44f53 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -305,6 +305,7 @@ const SoftwareTitleDetailsPage = ({ isFma={isFma} isLatestFmaVersion={row.isActive && isLatestFmaVersion} isScriptPackage={isScriptPackage} + source={title.source} isTarballPackage={title.source === "tgz_packages"} isIosOrIpadosApp={isIpadOrIphoneSoftwareSource(title.source)} isActive={row.isActive} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index 57214dfe812..337c12e823c 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -138,6 +138,43 @@ 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", + 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: "", + }, + 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 9cb19a012be..60c76dd72c6 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -256,8 +256,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 77ace51a52d..741e136de5b 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -65,6 +65,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"; }; @@ -90,14 +92,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 + ); }; @@ -126,7 +131,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 00000000000..8e838547a30 --- /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 289cba399c0..d902ec11d73 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 a80f44bba80..ee1c492a986 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 cbc154c9156..62ed63a6adf 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 196634d448f..6a958f1b5cf 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 35432ce5ffe..efb6019592a 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/server/fleet/software_installer.go b/server/fleet/software_installer.go index d1827410059..b9128260b36 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -736,6 +736,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) } @@ -744,7 +746,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 @@ -758,10 +760,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" } // HostSoftwareWithInstaller represents the list of software installed on a diff --git a/server/fleet/software_installer_test.go b/server/fleet/software_installer_test.go index d8637d96b03..a3b500ca892 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 bde0bdab2f6..6e9bccae1d6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -17118,6 +17118,77 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { return sqlx.GetContext(ctx, q, &storedURL, `SELECT url FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, &team.ID, "hello world.sh") }) 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") } // 1. host reports software @@ -22159,6 +22230,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) @@ -22250,6 +22326,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 00000000000..9414dafe61d --- /dev/null +++ b/server/service/testdata/software-installers/script.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +print("script")