diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md
index 45439ae92c..7955082fc7 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.md
+++ b/.github/ISSUE_TEMPLATE/feature-request.md
@@ -11,6 +11,8 @@ assignees: ''
Thanks for filing an issue! Please use the prompts below to provide as much context as you can about your use case and motivations.
-->
+Gong snippet: TODO
+
## Problem
-- [ ] UI changes: TODO
-- [ ] CLI (fleetctl) usage changes: TODO
-- [ ] YAML changes: TODO
-- [ ] REST API changes: TODO
-- [ ] Fleet's agent (fleetd) changes: TODO
-- [ ] Permissions changes: TODO
+- [ ] UI changes: TODO
+- [ ] CLI (fleetctl) usage changes: TODO
+- [ ] YAML changes: TODO
+- [ ] REST API changes: TODO
+- [ ] Fleet's agent (fleetd) changes: TODO
+- [ ] Activity changes: TODO
+- [ ] Permissions changes: TODO
- [ ] Changes to paid features or tiers: TODO
### Engineering
diff --git a/CODEOWNERS b/CODEOWNERS
index fae91d00d0..5f1c7e9bca 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -95,13 +95,13 @@ go.mod @fleetdm/go
/handbook/README.md @mikermcneil
/handbook/company/open-positions.yml @sampfluger88
/handbook/company/product-groups.md @lukeheath
-/handbook/business-operations/README.md @sampfluger88
-/handbook/business-operations/business-operations.rituals.yml @sampfluger88
-/handbook/business-operations/Application-security.md @lukeheath
-/handbook/business-operations/security-audits.md @lukeheath
-/handbook/business-operations/security-policies.md @lukeheath
-/handbook/business-operations/security.md @lukeheath
-/handbook/business-operations/vendor-questionnaires.md @lukeheath
+/handbook/finance/README.md @sampfluger88
+/handbook/finance/finance.rituals.yml @sampfluger88
+/handbook/digital-experience/application-security.md @lukeheath
+/handbook/digital-experience/security-audits.md @lukeheath
+/handbook/digital-experience/security-policies.md @lukeheath
+/handbook/digital-experience/security.md @lukeheath
+/handbook/digital-experience/vendor-questionnaires.md @lukeheath
/handbook/digital-experience @sampfluger88
/handbook/customer-success @sampfluger88
/handbook/demand @sampfluger88
diff --git a/articles/configuring-default-teams-for-devices-in-fleet.md b/articles/configuring-default-teams-for-devices-in-fleet.md
new file mode 100644
index 0000000000..1b22d16424
--- /dev/null
+++ b/articles/configuring-default-teams-for-devices-in-fleet.md
@@ -0,0 +1,46 @@
+# Configuring default teams for macOS, iOS, and iPadOS devices in Fleet
+
+Fleet allows you to configure default teams for macOS, iOS, and iPadOS devices as they automatically enroll in your instance. This ensures that devices are assigned to the correct teams and receive the appropriate apps and configuration profiles at enrollment.
+
+## Why configure default teams?
+
+The ability to assign default teams during device enrollment helps streamline the deployment process. Each device is automatically placed in its correct group, ensuring it receives the necessary configuration profiles and apps without requiring manual assignment.
+
+### Configuring default teams in Fleet
+
+Follow these steps to assign default teams to your devices:
+
+1. **Navigate to automatic enrollment settings**:
+
+ - Go to **Settings > Integrations > Mobile device management (MDM)**, and locate the **Automatic enrollment** section.
+
+2. **Edit the ABM token**:
+
+ - Click **Edit** next to the ABM token for which you want to configure default teams.
+
+3. **Assign default teams**:
+
+ - In the modal, use the dropdowns to select the appropriate default team for each platform (macOS, iOS, and iPadOS).
+
+4. **Save your changes**:
+
+ - After selecting the teams, click **Save** to apply the changes. New devices will be automatically assigned to the selected teams upon enrollment.
+
+## Benefits of configuring default teams
+
+1. **Streamlined deployment**: Devices are configured and ready for use immediately after enrollment, reducing manual setup time.
+
+2. **Reduced errors**: Automating team assignments helps avoid misconfigurations and ensures that the right profiles and apps are installed on the correct devices.
+
+## Conclusion
+
+Configuring default teams in Fleet simplifies the enrollment and management of Apple devices, ensuring that each device is assigned to the correct team immediately upon enrollment. This feature reduces manual setup tasks for IT teams by automating the assignment of configuration profiles and apps based on team specifications. By streamlining the deployment process and minimizing errors, configuring default teams ensures that devices are ready to use right out of the box, helping organizations save time and maintain consistency across their device fleet.
+
+For organizations managing a large number of macOS, iOS, or iPadOS devices, this feature plays a crucial role in automating routine tasks, increasing efficiency, and improving the overall deployment experience. It enables teams to focus on more critical tasks and be confident that newly enrolled devices are correctly configured. For more information on using Fleet, please refer to the [Fleet documentation](https://fleetdm.com/docs) and [guides](https://fleetdm.com/guides).
+
+
+
+
+
+
+
diff --git a/articles/tales-from-fleet-security-soc2.md b/articles/tales-from-fleet-security-soc2.md
index c5b6d8aaaa..641583270a 100644
--- a/articles/tales-from-fleet-security-soc2.md
+++ b/articles/tales-from-fleet-security-soc2.md
@@ -43,7 +43,7 @@ One of the essential things about SOC 2 is having the right security policies. T
Writing policies from scratch can seem daunting. Many compliance automation products have templates you can use to get started, but there are excellent free and open resources online.
-As you can see, our policies are in our [handbook](https://fleetdm.com/handbook/business-operations/security-policies#information-security-policy-and-acceptable-use-policy), and we created most of them using this [free set of templates](https://github.com/JupiterOne/security-policy-templates) published by JupiterOne under Creative Commons licensing.
+As you can see, our policies are in our [handbook](https://fleetdm.com/handbook/digital-experience/security-policies#information-security-policy-and-acceptable-use-policy), and we created most of them using this [free set of templates](https://github.com/JupiterOne/security-policy-templates) published by JupiterOne under Creative Commons licensing.
We kept our policies as basic as possible to make sure everything in them is valuable and achievable. Having policies that state you must do the impossible is a surefire way of getting in trouble! The templates we used contained many processes and procedures as well. We used the policies and will eventually document more of our procedures in our handbook.
diff --git a/changes/20320-uninstall-packages b/changes/20320-uninstall-packages
new file mode 100644
index 0000000000..89ab892841
--- /dev/null
+++ b/changes/20320-uninstall-packages
@@ -0,0 +1 @@
+* Implement the ability to use Fleet to uninstall packages from hosts.
\ No newline at end of file
diff --git a/changes/21467-policies-for-no-team b/changes/21467-policies-for-no-team
new file mode 100644
index 0000000000..4613cd39ed
--- /dev/null
+++ b/changes/21467-policies-for-no-team
@@ -0,0 +1 @@
+* Added support for policies in "No team" that run on hosts that belong to "No team".
diff --git a/changes/21779-git-false-negative b/changes/21779-git-false-negative
new file mode 100644
index 0000000000..080dfe1a4e
--- /dev/null
+++ b/changes/21779-git-false-negative
@@ -0,0 +1 @@
+- fixed a false negative vulnerability for git
\ No newline at end of file
diff --git a/changes/21813-email-err b/changes/21813-email-err
new file mode 100644
index 0000000000..a9d25ecc21
--- /dev/null
+++ b/changes/21813-email-err
@@ -0,0 +1,2 @@
+- Fixed regression: we now check if the email used to get a signed CSR is invalid (i.e. is an email
+ from a free email provider).
\ No newline at end of file
diff --git a/changes/hosts-can-access-any-software b/changes/hosts-can-access-any-software
new file mode 100644
index 0000000000..0fbcae035a
--- /dev/null
+++ b/changes/hosts-can-access-any-software
@@ -0,0 +1 @@
+- Hosts can no longer access installers that aren't directly assigned to it
diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go
index a0db9f5d0d..151f32ae8d 100644
--- a/cmd/fleet/cron.go
+++ b/cmd/fleet/cron.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
eewebhooks "github.com/fleetdm/fleet/v4/ee/server/webhooks"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/config"
@@ -1012,7 +1013,6 @@ func verifyDiskEncryptionKeys(
logger kitlog.Logger,
ds fleet.Datastore,
) error {
-
appCfg, err := ds.AppConfig(ctx)
if err != nil {
logger.Log("err", "unable to get app config", "details", err)
@@ -1219,10 +1219,9 @@ func newMDMAPNsPusher(
commander *apple_mdm.MDMAppleCommander,
logger kitlog.Logger,
) (*schedule.Schedule, error) {
-
const name = string(fleet.CronAppleMDMAPNsPusher)
- var interval = 1 * time.Minute
+ interval := 1 * time.Minute
if intervalEnv := os.Getenv("FLEET_DEV_CUSTOM_APNS_PUSHER_INTERVAL"); intervalEnv != "" {
var err error
interval, err = time.ParseDuration(intervalEnv)
@@ -1396,6 +1395,31 @@ func newIPhoneIPadRefetcher(
return s, nil
}
+// cronUninstallSoftwareMigration will update uninstall scripts for software.
+// Once all customers are using on Fleet 4.57 or later, this job can be removed.
+func cronUninstallSoftwareMigration(
+ ctx context.Context,
+ instanceID string,
+ ds fleet.Datastore,
+ softwareInstallStore fleet.SoftwareInstallerStore,
+ logger kitlog.Logger,
+) (*schedule.Schedule, error) {
+ const (
+ name = string(fleet.CronUninstallSoftwareMigration)
+ defaultInterval = 24 * time.Hour
+ )
+ logger = kitlog.With(logger, "cron", name, "component", name)
+ s := schedule.New(
+ ctx, name, instanceID, defaultInterval, ds, ds,
+ schedule.WithLogger(logger),
+ schedule.WithRunOnce(true),
+ schedule.WithJob(name, func(ctx context.Context) error {
+ return eeservice.UninstallSoftwareMigration(ctx, ds, softwareInstallStore, logger)
+ }),
+ )
+ return s, nil
+}
+
func newMaintainedAppSchedule(
ctx context.Context,
instanceID string,
diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go
index edc4fd41a9..5b46f2d0f0 100644
--- a/cmd/fleet/serve.go
+++ b/cmd/fleet/serve.go
@@ -829,6 +829,16 @@ the way that the Fleet server works.
}
}()
+ if softwareInstallStore != nil {
+ if err := cronSchedules.StartCronSchedule(
+ func() (fleet.CronSchedule, error) {
+ return cronUninstallSoftwareMigration(ctx, instanceID, ds, softwareInstallStore, logger)
+ },
+ ); err != nil {
+ initFatal(err, fmt.Sprintf("failed to register %s", fleet.CronUninstallSoftwareMigration))
+ }
+ }
+
if config.Server.FrequentCleanupsEnabled {
if err := cronSchedules.StartCronSchedule(
func() (fleet.CronSchedule, error) {
diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go
index b593ebf929..fc9e3c7a83 100644
--- a/cmd/fleetctl/gitops.go
+++ b/cmd/fleetctl/gitops.go
@@ -77,6 +77,23 @@ func gitopsCommand() *cli.Command {
if appConfig.License == nil {
return errors.New("no license struct found in app config")
}
+ logf := func(format string, a ...interface{}) {
+ _, _ = fmt.Fprintf(c.App.Writer, format, a...)
+ }
+
+ // We need to extract the controls from no-team.yml to be able to apply them when applying the global app config.
+ var noTeamControls spec.Controls
+ for _, flFilename := range flFilenames.Value() {
+ if filepath.Base(flFilename) == "no-team.yml" {
+ baseDir := filepath.Dir(flFilename)
+ config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, logf)
+ if err != nil {
+ return err
+ }
+ noTeamControls = config.Controls
+ break
+ }
+ }
var originalABMConfig []any
var originalVPPConfig []any
@@ -92,7 +109,7 @@ func gitopsCommand() *cli.Command {
secrets := make(map[string]struct{})
for _, flFilename := range flFilenames.Value() {
baseDir := filepath.Dir(flFilename)
- config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig)
+ config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, logf)
if err != nil {
return err
}
@@ -109,6 +126,21 @@ func gitopsCommand() *cli.Command {
firstFileMustBeGlobal = ptr.Bool(false)
}
+ if isGlobalConfig {
+ if noTeamControls.Set() && config.Controls.Set() {
+ return errors.New("'controls' cannot be set on both global config and on no-team.yml")
+ }
+ if !noTeamControls.Defined && !config.Controls.Defined {
+ if appConfig.License.IsPremium() {
+ return errors.New("'controls' must be set on global config or no-team.yml")
+ }
+ return errors.New("'controls' must be set on global config")
+ }
+ if !config.Controls.Set() {
+ config.Controls = noTeamControls
+ }
+ }
+
// Special handling for tokens is required because they link to teams (by
// name.) Because teams can be created/deleted during the same gitops run, we
// grab some information to help us determine allowed/restricted actions and
@@ -160,9 +192,6 @@ func gitopsCommand() *cli.Command {
}
}
}
- logf := func(format string, a ...interface{}) {
- _, _ = fmt.Fprintf(c.App.Writer, format, a...)
- }
if flDryRun {
incomingSecrets := fleetClient.GetGitOpsSecrets(config)
for _, secret := range incomingSecrets {
diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go
index 58fb94b1c6..8a6d1aeeb8 100644
--- a/cmd/fleetctl/gitops_test.go
+++ b/cmd/fleetctl/gitops_test.go
@@ -13,6 +13,7 @@ import (
"testing"
"time"
+ "github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -141,6 +142,28 @@ org_settings:
require.Error(t, err)
assert.Contains(t, err.Error(), "organization name must be present")
+ // Missing controls.
+ tmpFile2, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+ _, err = tmpFile2.WriteString(
+ `
+queries:
+policies:
+agent_options:
+org_settings:
+ server_settings:
+ server_url: https://example.com
+ org_info:
+ contact_url: https://example.com/contact
+ org_name: Foobar
+ secrets:
+`,
+ )
+ require.NoError(t, err)
+ _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile2.Name()})
+ require.Error(t, err)
+ assert.Equal(t, `'controls' must be set on global config`, err.Error())
+
// Dry run
t.Setenv("ORG_NAME", orgName)
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
@@ -398,16 +421,15 @@ software:
require.Error(t, err)
assert.Contains(t, err.Error(), "'name' is required")
- // reserved team name; should error in both dry run and real
+ // Invalid name for "No team" file (dry and real).
t.Setenv("TEST_TEAM_NAME", "no TEam")
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
require.Error(t, err)
- assert.Contains(t, err.Error(), `"No team" is a reserved team name`)
-
+ assert.Contains(t, err.Error(), fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", tmpFile.Name()))
t.Setenv("TEST_TEAM_NAME", "no TEam")
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
require.Error(t, err)
- assert.Contains(t, err.Error(), `"No team" is a reserved team name`)
+ assert.Contains(t, err.Error(), fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", tmpFile.Name()))
t.Setenv("TEST_TEAM_NAME", "All teams")
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
@@ -781,7 +803,9 @@ func TestGitOpsFullTeam(t *testing.T) {
appliedQueries = queries
return nil
}
+ var appliedSoftwareInstallers []*fleet.UploadSoftwareInstallerPayload
ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) {
+ appliedSoftwareInstallers = installers
return nil, nil
}
ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error {
@@ -804,8 +828,8 @@ func TestGitOpsFullTeam(t *testing.T) {
// Dry run
const baseFilename = "team_config_no_paths.yml"
- file := "./testdata/gitops/" + baseFilename
- _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
+ gitopsFile := "./testdata/gitops/" + baseFilename
+ _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile, "--dry-run"})
assert.Nil(t, savedTeam)
assert.Len(t, enrolledSecrets, 0)
assert.Len(t, appliedPolicySpecs, 0)
@@ -813,13 +837,14 @@ func TestGitOpsFullTeam(t *testing.T) {
assert.Len(t, appliedScripts, 0)
assert.Len(t, appliedMacProfiles, 0)
assert.Len(t, appliedWinProfiles, 0)
+ assert.Empty(t, appliedSoftwareInstallers)
// Real run
// Setting global calendar config
appConfig.Integrations = fleet.Integrations{
GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}},
}
- _ = runAppForTest(t, []string{"gitops", "-f", file})
+ _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile})
require.NotNil(t, savedTeam)
assert.Equal(t, teamName, savedTeam.Name)
assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration")
@@ -839,21 +864,26 @@ func TestGitOpsFullTeam(t *testing.T) {
require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar)
assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable)
assert.Equal(t, baseFilename, *savedTeam.Filename)
+ require.Len(t, appliedSoftwareInstallers, 2)
+ packageID := `"ruby"`
+ uninstallScriptProcessed := strings.ReplaceAll(file.GetUninstallScript("deb"), "$PACKAGE_ID", packageID)
+ assert.ElementsMatch(t, []string{fmt.Sprintf("echo 'uninstall' %s\n", packageID), uninstallScriptProcessed},
+ []string{appliedSoftwareInstallers[0].UninstallScript, appliedSoftwareInstallers[1].UninstallScript})
// Change team name
newTeamName := "New Team Name"
t.Setenv("TEST_TEAM_NAME", newTeamName)
- _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
- _ = runAppForTest(t, []string{"gitops", "-f", file})
+ _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile, "--dry-run"})
+ _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile})
require.NotNil(t, savedTeam)
assert.Equal(t, newTeamName, savedTeam.Name)
assert.Equal(t, baseFilename, *savedTeam.Filename)
// Try to change team name again, but this time the new name conflicts with an existing team
t.Setenv("TEST_TEAM_NAME", "Conflict")
- _, err = runAppNoChecks([]string{"gitops", "-f", file, "--dry-run"})
+ _, err = runAppNoChecks([]string{"gitops", "-f", gitopsFile, "--dry-run"})
assert.ErrorContains(t, err, "team name already exists")
- _, err = runAppNoChecks([]string{"gitops", "-f", file})
+ _, err = runAppNoChecks([]string{"gitops", "-f", gitopsFile})
assert.ErrorContains(t, err, "team name already exists")
// Now clear the settings
@@ -1164,6 +1194,336 @@ software:
assert.True(t, ds.DeleteTeamFuncInvoked)
}
+func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) {
+ // Cannot run t.Parallel() because runServerWithMockedDS sets the FLEET_SERVER_ADDRESS
+ // environment variable.
+
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
+ _, ds := runServerWithMockedDS(
+ t, &service.TestServerOpts{
+ License: license,
+ },
+ )
+ // Mock appConfig
+ savedAppConfig := &fleet.AppConfig{}
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+ ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
+ savedAppConfig = config
+ return nil
+ }
+ ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error {
+ return nil
+ }
+ ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error {
+ return nil
+ }
+
+ const (
+ fleetServerURL = "https://fleet.example.com"
+ orgName = "GitOps Test"
+ secret = "TestSecret"
+ )
+ var enrolledSecrets []*fleet.EnrollSecret
+ var enrolledTeamSecrets []*fleet.EnrollSecret
+ var savedTeam *fleet.Team
+ team := &fleet.Team{
+ ID: 1,
+ CreatedAt: time.Now(),
+ Name: teamName,
+ }
+
+ ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) {
+ return true, nil
+ }
+ ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
+ if teamID == nil {
+ enrolledSecrets = secrets
+ } else {
+ enrolledTeamSecrets = secrets
+ }
+ return nil
+ }
+ ds.BatchSetMDMProfilesFunc = func(
+ ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
+ macDecls []*fleet.MDMAppleDeclaration,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ assert.Empty(t, macProfiles)
+ assert.Empty(t, winProfiles)
+ return fleet.MDMProfilesUpdates{}, nil
+ }
+ ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
+ assert.Empty(t, scripts)
+ return nil
+ }
+ ds.BulkSetPendingMDMHostProfilesFunc = func(
+ ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string,
+ ) (updates fleet.MDMProfilesUpdates, err error) {
+ assert.Empty(t, profileUUIDs)
+ return fleet.MDMProfilesUpdates{}, nil
+ }
+ ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
+ return nil
+ }
+ ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
+ require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus})
+ return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil
+ }
+ ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
+ ds.ListTeamPoliciesFunc = func(
+ ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
+ ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
+ return nil, nil, nil
+ }
+ ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
+ return nil, nil
+ }
+ ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil }
+ ds.NewActivityFunc = func(
+ ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
+ ) error {
+ return nil
+ }
+ ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
+ job.ID = 1
+ return job, nil
+ }
+ ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
+ if tid == team.ID {
+ return savedTeam, nil
+ }
+ return nil, nil
+ }
+ ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
+ if name == teamName && savedTeam != nil {
+ return savedTeam, nil
+ }
+ return nil, ¬FoundError{}
+ }
+ ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) {
+ if savedTeam != nil && *savedTeam.Filename == filename {
+ return savedTeam, nil
+ }
+ return nil, ¬FoundError{}
+ }
+ ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) {
+ newTeam.ID = team.ID
+ savedTeam = newTeam
+ enrolledTeamSecrets = newTeam.Secrets
+ return newTeam, nil
+ }
+ ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
+ savedTeam = team
+ return team, nil
+ }
+ ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) {
+ return nil, nil
+ }
+ ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
+ return nil, 0, nil, nil
+ }
+
+ globalFileBasic, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+
+ _, err = globalFileBasic.WriteString(fmt.Sprintf(
+ `
+controls:
+queries:
+policies:
+agent_options:
+org_settings:
+ server_settings:
+ server_url: %s
+ org_info:
+ contact_url: https://example.com/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: %s
+ secrets: [{"secret":"globalSecret"}]
+software:
+`, fleetServerURL, orgName),
+ )
+ require.NoError(t, err)
+
+ globalFileWithSoftware, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+ _, err = globalFileWithSoftware.WriteString(fmt.Sprintf(
+ `
+controls:
+queries:
+policies:
+agent_options:
+org_settings:
+ server_settings:
+ server_url: %s
+ org_info:
+ contact_url: https://example.com/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: %s
+ secrets: [{"secret":"globalSecret"}]
+software:
+ packages:
+ - url: https://example.com
+`, fleetServerURL, orgName),
+ )
+ require.NoError(t, err)
+
+ globalFileWithControls, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+ _, err = globalFileWithControls.WriteString(fmt.Sprintf(
+ `
+controls:
+ ios_updates:
+ deadline: "2022-02-02"
+ minimum_version: "17.6"
+queries:
+policies:
+agent_options:
+org_settings:
+ server_settings:
+ server_url: %s
+ org_info:
+ contact_url: https://example.com/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: %s
+ secrets: [{"secret":"globalSecret"}]
+software:
+`, fleetServerURL, orgName),
+ )
+ require.NoError(t, err)
+
+ globalFileWithoutControlsAndSoftwareKeys, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+ _, err = globalFileWithoutControlsAndSoftwareKeys.WriteString(fmt.Sprintf(
+ `
+queries:
+policies:
+agent_options:
+org_settings:
+ server_settings:
+ server_url: %s
+ org_info:
+ contact_url: https://example.com/contact
+ org_logo_url: ""
+ org_logo_url_light_background: ""
+ org_name: %s
+ secrets: [{"secret":"globalSecret"}]
+`, fleetServerURL, orgName),
+ )
+ require.NoError(t, err)
+
+ teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
+ require.NoError(t, err)
+ _, err = teamFile.WriteString(fmt.Sprintf(`
+controls:
+queries:
+policies:
+agent_options:
+name: %s
+team_settings:
+ secrets: [{"secret":"%s"}]
+software:
+`, teamName, secret),
+ )
+ require.NoError(t, err)
+
+ noTeamFilePath := filepath.Join(t.TempDir(), "no-team.yml")
+ noTeamFile, err := os.Create(noTeamFilePath)
+ require.NoError(t, err)
+ _, err = noTeamFile.WriteString(`
+controls:
+policies:
+name: No team
+software:
+`)
+ require.NoError(t, err)
+
+ noTeamFilePathWithControls := filepath.Join(t.TempDir(), "no-team.yml")
+ noTeamFileWithControls, err := os.Create(noTeamFilePathWithControls)
+ require.NoError(t, err)
+ _, err = noTeamFileWithControls.WriteString(`
+controls:
+ ipados_updates:
+ deadline: "2023-03-03"
+ minimum_version: "18.0"
+policies:
+name: No team
+software:
+`)
+ require.NoError(t, err)
+
+ noTeamFilePathWithoutControls := filepath.Join(t.TempDir(), "no-team.yml")
+ noTeamFileWithoutControls, err := os.Create(noTeamFilePathWithoutControls)
+ require.NoError(t, err)
+ _, err = noTeamFileWithoutControls.WriteString(`
+policies:
+name: No team
+software:
+`)
+ require.NoError(t, err)
+
+ // Dry run, global defines software, should fail.
+ _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"})
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "'software' cannot be set on global file"))
+ // Real run, global defines software, should fail.
+ _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()})
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "'software' cannot be set on global file"))
+
+ // Dry run, both global and no-team.yml define controls.
+ _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithControls.Name(), "--dry-run"})
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml"))
+ // Real run, both global and no-team.yml define controls.
+ _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithControls.Name(), "--dry-run"})
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml"))
+
+ // Dry run, controls should be defined somewhere, either in no-team.yml or global.
+ _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithoutControls.Name(), "--dry-run"})
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
+ // Real run, both global and no-team.yml define controls.
+ _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithoutControls.Name(), "--dry-run"})
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
+
+ // Dry run, global file without controls and software keys.
+ _ = runAppForTest(t, []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"})
+ assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
+
+ // Real run, global file without controls and software keys.
+ _ = runAppForTest(t, []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()})
+ assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
+ assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
+ assert.Len(t, enrolledSecrets, 1)
+ require.NotNil(t, savedTeam)
+ assert.Equal(t, teamName, savedTeam.Name)
+ require.Len(t, enrolledTeamSecrets, 1)
+ assert.Equal(t, secret, enrolledTeamSecrets[0].Secret)
+
+ // Restore to test below.
+ savedAppConfig = &fleet.AppConfig{}
+
+ // Dry run
+ _ = runAppForTest(t, []string{"gitops", "-f", globalFileBasic.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"})
+ assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
+ // Real run
+ _ = runAppForTest(t, []string{"gitops", "-f", globalFileBasic.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()})
+ assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
+ assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
+ assert.Len(t, enrolledSecrets, 1)
+ require.NotNil(t, savedTeam)
+ assert.Equal(t, teamName, savedTeam.Name)
+ require.Len(t, enrolledTeamSecrets, 1)
+ assert.Equal(t, secret, enrolledTeamSecrets[0].Secret)
+}
+
func TestGitOpsFullGlobalAndTeam(t *testing.T) {
// Cannot run t.Parallel() because it sets environment variables
// mdm test configuration must be set so that activating windows MDM works.
@@ -1260,6 +1620,7 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) {
{"testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml", "should have only one query."},
{"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"},
+ {"testdata/gitops/team_software_installer_uninstall_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"},
@@ -1299,8 +1660,8 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
startSoftwareInstallerServer(t)
cases := []struct {
- file string
- wantErr string
+ noTeamFile string
+ wantErr string
}{
{"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."},
{"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."},
@@ -1309,16 +1670,24 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
{"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."},
{"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"},
+ {"testdata/gitops/no_team_software_installer_uninstall_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"},
}
for _, c := range cases {
- t.Run(filepath.Base(c.file), func(t *testing.T) {
+ t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) {
setupFullGitOpsPremiumServer(t)
t.Setenv("APPLE_BM_DEFAULT_TEAM", "")
- _, err := runAppNoChecks([]string{"gitops", "-f", c.file})
+ globalFile := "./testdata/gitops/global_config_no_paths.yml"
+ dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml")
+ t.Cleanup(func() {
+ os.Remove(dstPath)
+ })
+ err := file.Copy(c.noTeamFile, dstPath, 0o755)
+ require.NoError(t, err)
+ _, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath})
if c.wantErr == "" {
require.NoError(t, err)
} else {
diff --git a/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh b/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh
new file mode 100644
index 0000000000..c6c41b5e01
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh
@@ -0,0 +1 @@
+echo 'uninstall' ${PACKAGE_ID}
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml
index d3bcada54e..58bae27ae9 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml
@@ -1,19 +1,8 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
- path: lib/notfound.sh
\ No newline at end of file
+ path: lib/notfound.sh
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml
index acee06d683..b333e7816e 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml
@@ -1,18 +1,7 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
- self_service: "not a boolean"
\ No newline at end of file
+ self_service: "not a boolean"
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml
index 6d83a9daed..d897af7b43 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml
@@ -1,17 +1,6 @@
-# Test config
+name: No TEAM
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- install_script:
@@ -19,4 +8,4 @@ software:
pre_install_query:
path: lib/query_ruby.yml
post_install_script:
- path: lib/post_install_ruby.sh
\ No newline at end of file
+ path: lib/post_install_ruby.sh
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml
index cd7332f91e..590458e78b 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml
@@ -1,17 +1,6 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
\ No newline at end of file
+ - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml
index ac0a436360..12b2598d59 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml
@@ -1,21 +1,10 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
post_install_script:
- path: lib/notfound.sh
\ No newline at end of file
+ path: lib/notfound.sh
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml
index a2b5419c05..15ddcb438c 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml
@@ -1,17 +1,6 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
@@ -20,4 +9,4 @@ software:
pre_install_query:
path: lib/query_multiple.yml
post_install_script:
- path: lib/post_install_ruby.sh
\ No newline at end of file
+ path: lib/post_install_ruby.sh
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml
index bafde42691..48e6ff42e5 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml
@@ -1,21 +1,10 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
install_script:
path: lib/install_ruby.sh
pre_install_query:
- path: lib/notfound.yml
\ No newline at end of file
+ path: lib/notfound.yml
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml
index db4ffd3211..23ba8dbe80 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml
@@ -1,17 +1,6 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
\ No newline at end of file
+ - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml
new file mode 100644
index 0000000000..c5c8838267
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml
@@ -0,0 +1,8 @@
+name: No team
+controls:
+policies:
+software:
+ packages:
+ - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
+ uninstall_script:
+ path: lib/notfound.sh
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml
index 2bc609b931..ace876a8d5 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml
@@ -1,17 +1,6 @@
-# Test config
+name: "No team"
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
\ No newline at end of file
+ - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml
index e0fcaa490e..4599698d1d 100644
--- a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml
+++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml
@@ -1,17 +1,6 @@
-# Test config
+name: No team
controls:
-queries:
policies:
-agent_options:
-org_settings:
- server_settings:
- server_url: $FLEET_SERVER_URL
- org_info:
- contact_url: https://example.com/contact
- org_logo_url: ""
- org_logo_url_light_background: ""
- org_name: ${ORG_NAME}
- secrets: [{"secret":"globalSecret"}]
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
@@ -21,5 +10,7 @@ software:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
+ uninstall_script:
+ path: lib/uninstall_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
- self_service: true
\ No newline at end of file
+ self_service: true
diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml
index 785ba5d215..e671d17d29 100644
--- a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml
+++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml
@@ -124,5 +124,7 @@ software:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
+ uninstall_script:
+ path: lib/uninstall_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true
diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml
new file mode 100644
index 0000000000..1fc9903d6b
--- /dev/null
+++ b/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml
@@ -0,0 +1,19 @@
+name: "${TEST_TEAM_NAME}"
+team_settings:
+ secrets:
+ - secret: "ABC"
+ features:
+ enable_host_users: true
+ enable_software_inventory: true
+ host_expiry_settings:
+ host_expiry_enabled: true
+ host_expiry_window: 30
+agent_options:
+controls:
+policies:
+queries:
+software:
+ packages:
+ - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
+ uninstall_script:
+ path: lib/notfound.sh
diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml
index e894112249..0733758ced 100644
--- a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml
+++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml
@@ -21,5 +21,7 @@ software:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
+ uninstall_script:
+ path: lib/uninstall_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true
diff --git a/codecov.yml b/codecov.yml
index f91f1aae40..cac1d5c0d1 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -25,3 +25,4 @@ flag_management:
ignore:
- "server/mock"
+ - "server/fleet/activities.go" # mostly contains code for documentation -- not interesting for tests
diff --git a/docs/Contributing/Audit-logs.md b/docs/Contributing/Audit-logs.md
index b2fe63528b..c4baf17b74 100644
--- a/docs/Contributing/Audit-logs.md
+++ b/docs/Contributing/Audit-logs.md
@@ -1170,6 +1170,29 @@ This activity contains the following fields:
}
```
+## uninstalled_software
+
+Generated when a software is uninstalled on a host.
+
+This activity contains the following fields:
+- "host_id": ID of the host.
+- "host_display_name": Display name of the host.
+- "software_title": Name of the software.
+- "script_execution_id": ID of the software uninstall script.
+- "status": Status of the software uninstallation.
+
+#### Example
+
+```json
+{
+ "host_id": 1,
+ "host_display_name": "Anna's MacBook Pro",
+ "software_title": "Falcon.app",
+ "script_execution_id": "ece8d99d-4313-446a-9af2-e152cd1bad1e",
+ "status": "uninstalled"
+}
+```
+
## added_software
Generated when a software installer is uploaded to Fleet.
diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go
index 281d1b05f9..5826f488a1 100644
--- a/ee/server/service/software_installers.go
+++ b/ee/server/service/software_installers.go
@@ -12,15 +12,19 @@ import (
"net/http"
"net/url"
"path/filepath"
+ "regexp"
+ "strings"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
+ "github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/ptr"
+ kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
@@ -44,6 +48,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
// shebang when the file is directly executed.
payload.InstallScript = file.Dos2UnixNewlines(payload.InstallScript)
payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript)
+ payload.UninstallScript = file.Dos2UnixNewlines(payload.UninstallScript)
if _, err := svc.addMetadataToSoftwarePayload(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "adding metadata to payload")
@@ -56,6 +61,9 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
// TODO: basic validation of install and post-install script (e.g., supported interpreters)?
// TODO: any validation of pre-install query?
+ // Update $PACKAGE_ID in uninstall script
+ preProcessUninstallScript(payload)
+
installerID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return ctxerr.Wrap(ctx, err, "matching or creating software installer")
@@ -87,6 +95,28 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
return nil
}
+var packageIDRegex = regexp.MustCompile(`((("\$PACKAGE_ID")|(\$PACKAGE_ID))(?P\W|$))|(("\${PACKAGE_ID}")|(\${PACKAGE_ID}))`)
+
+func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) {
+ // We assume that we already validated that payload.PackageIDs is not empty.
+ // Replace $PACKAGE_ID in the uninstall script with the package ID(s).
+ var packageID string
+ switch payload.Extension {
+ case "pkg":
+ var sb strings.Builder
+ _, _ = sb.WriteString("(\n")
+ for _, pkgID := range payload.PackageIDs {
+ _, _ = sb.WriteString(fmt.Sprintf(" \"%s\"\n", pkgID))
+ }
+ _, _ = sb.WriteString(")") // no ending newline
+ packageID = sb.String()
+ default:
+ packageID = fmt.Sprintf("\"%s\"", payload.PackageIDs[0])
+ }
+
+ payload.UninstallScript = packageIDRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("%s${suffix}", packageID))
+}
+
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
if teamID == nil {
return fleet.NewInvalidArgumentError("team_id", "is required")
@@ -301,11 +331,20 @@ func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installe
// this is not a user-authenticated endpoint
svc.authz.SkipAuthorization(ctx)
- _, ok := hostctx.FromContext(ctx)
+ host, ok := hostctx.FromContext(ctx)
if !ok {
return nil, fleet.OrbitError{Message: "internal error: missing host from request context"}
}
+ access, err := svc.ds.ValidateOrbitSoftwareInstallerAccess(ctx, host.ID, installerID)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "check software installer access")
+ }
+
+ if !access {
+ return nil, fleet.NewUserMessageError(errors.New("Host doesn't have access to this installer"), http.StatusForbidden)
+ }
+
// get the installer's metadata
meta, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
if err != nil {
@@ -389,11 +428,12 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
}
- if lastInstallRequest != nil && lastInstallRequest.Status != nil && *lastInstallRequest.Status == fleet.SoftwareInstallerPending {
+ if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
+ (*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
return &fleet.BadRequestError{
- Message: "Couldn't install software. Host has a pending install request.",
+ Message: "Couldn't install software. Host has a pending install/uninstall request.",
InternalErr: ctxerr.WrapWithData(
- ctx, err, "host already has a pending install for this installer",
+ ctx, err, "host already has a pending install/uninstall for this installer",
map[string]any{
"host_id": host.ID,
"software_installer_id": installer.InstallerID,
@@ -568,6 +608,152 @@ func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host
return ctxerr.Wrap(ctx, err, "inserting software install request")
}
+func (svc *Service) UninstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
+ // First check if scripts are disabled globally. If so, no need for further processing.
+ cfg, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ svc.authz.SkipAuthorization(ctx)
+ return err
+ }
+
+ if cfg.ServerSettings.ScriptsDisabled {
+ svc.authz.SkipAuthorization(ctx)
+ return fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden)
+ }
+
+ // we need to use ds.Host because ds.HostLite doesn't return the orbit node key
+ host, err := svc.ds.Host(ctx, hostID)
+ if err != nil {
+ // if error is because the host does not exist, check first if the user
+ // had access to install/uninstall software (to prevent leaking valid host ids).
+ if fleet.IsNotFound(err) {
+ if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
+ return err
+ }
+ }
+ svc.authz.SkipAuthorization(ctx)
+ return ctxerr.Wrap(ctx, err, "get host")
+ }
+
+ if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" {
+ // fleetd is required to install software so if the host is enrolled via plain osquery we return an error
+ svc.authz.SkipAuthorization(ctx)
+ return fleet.NewUserMessageError(errors.New("host does not have fleetd installed"), http.StatusUnprocessableEntity)
+ }
+
+ // If scripts are disabled (according to the last detail query), we return an error.
+ // host.ScriptsEnabled may be nil for older orbit versions.
+ if host.ScriptsEnabled != nil && !*host.ScriptsEnabled {
+ svc.authz.SkipAuthorization(ctx)
+ return fleet.NewUserMessageError(errors.New(fleet.RunScriptsOrbitDisabledErrMsg), http.StatusUnprocessableEntity)
+ }
+
+ // authorize with the host's team
+ if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
+ return err
+ }
+
+ installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ return &fleet.BadRequestError{
+ Message: "Couldn't uninstall software. Software title is not available for uninstall. Please add software package to install/uninstall.",
+ InternalErr: ctxerr.WrapWithData(
+ ctx, err, "couldn't find an installer for software title",
+ map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
+ ),
+ }
+ }
+ return ctxerr.Wrap(ctx, err, "finding software installer for title")
+ }
+
+ lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
+ if err != nil {
+ return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
+ }
+ if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
+ (*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
+ return &fleet.BadRequestError{
+ Message: "Couldn't uninstall software. Host has a pending install/uninstall request.",
+ InternalErr: ctxerr.WrapWithData(
+ ctx, err, "host already has a pending install/uninstall for this installer",
+ map[string]any{
+ "host_id": host.ID,
+ "software_installer_id": installer.InstallerID,
+ "team_id": host.TeamID,
+ "title_id": softwareTitleID,
+ "status": *lastInstallRequest.Status,
+ },
+ ),
+ }
+ }
+
+ // Validate platform
+ ext := filepath.Ext(installer.Name)
+ requiredPlatform := packageExtensionToPlatform(ext)
+ if requiredPlatform == "" {
+ // this should never happen
+ return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
+ }
+
+ if host.FleetPlatform() != requiredPlatform {
+ return &fleet.BadRequestError{
+ Message: fmt.Sprintf("Package (%s) can be uninstalled only on %s hosts.", ext, requiredPlatform),
+ InternalErr: ctxerr.NewWithData(
+ ctx, "invalid host platform for requested uninstall",
+ map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID},
+ ),
+ }
+ }
+
+ // Get the uninstall script and use the standard script infrastructure to run it.
+ contents, err := svc.ds.GetAnyScriptContents(ctx, installer.UninstallScriptContentID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ return ctxerr.Wrap(ctx,
+ fleet.NewInvalidArgumentError("software_title_id", `No uninstall script exists for the provided "software_title_id".`).
+ WithStatus(http.StatusNotFound), "getting uninstall script contents")
+ }
+ return err
+ }
+
+ var teamID uint
+ if host.TeamID != nil {
+ teamID = *host.TeamID
+ }
+ // create the script execution request, the host will be notified of the
+ // script execution request via the orbit config's Notifications mechanism.
+ request := fleet.HostScriptRequestPayload{
+ HostID: host.ID,
+ ScriptContents: string(contents),
+ ScriptContentID: installer.UninstallScriptContentID,
+ TeamID: teamID,
+ }
+ if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
+ request.UserID = &ctxUser.ID
+ }
+ scriptResult, err := svc.ds.NewHostScriptExecutionRequest(ctx, &request)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "create script execution request")
+ }
+
+ // Update the host software installs table with the uninstall request.
+ // Pending uninstalls will automatically show up in the UI Host Details -> Activity -> Upcoming tab.
+ if err = svc.insertSoftwareUninstallRequest(ctx, scriptResult.ExecutionID, host, installer); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (svc *Service) insertSoftwareUninstallRequest(ctx context.Context, executionID string, host *fleet.Host,
+ installer *fleet.SoftwareInstaller) error {
+ if err := svc.ds.InsertSoftwareUninstallRequest(ctx, executionID, host.ID, installer.InstallerID); err != nil {
+ return ctxerr.Wrap(ctx, err, "inserting software uninstall request")
+ }
+ return nil
+}
+
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
// Basic auth check
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
@@ -656,6 +842,13 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
}
}
+ if len(meta.PackageIDs) == 0 {
+ return "", &fleet.BadRequestError{
+ Message: fmt.Sprintf("Couldn't add. Fleet couldn't read the package IDs, product code, or name from %s.", payload.Filename),
+ InternalErr: ctxerr.New(ctx, "extracting package IDs from installer metadata"),
+ }
+ }
+
payload.Title = meta.Name
if payload.Title == "" {
// use the filename if no title from metadata
@@ -664,6 +857,8 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
payload.Version = meta.Version
payload.StorageID = hex.EncodeToString(meta.SHASum)
payload.BundleIdentifier = meta.BundleIdentifier
+ payload.PackageIDs = meta.PackageIDs
+ payload.Extension = meta.Extension
// reset the reader (it was consumed to extract metadata)
if _, err := payload.InstallerFile.Seek(0, 0); err != nil {
@@ -674,6 +869,10 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
payload.InstallScript = file.GetInstallScript(meta.Extension)
}
+ if payload.UninstallScript == "" {
+ payload.UninstallScript = file.GetUninstallScript(meta.Extension)
+ }
+
source, err := fleet.SofwareInstallerSourceFromExtensionAndName(meta.Extension, meta.Name)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining source from extension and name")
@@ -803,6 +1002,7 @@ func (svc *Service) BatchSetSoftwareInstallers(
InstallScript: p.InstallScript,
PreInstallQuery: p.PreInstallQuery,
PostInstallScript: p.PostInstallScript,
+ UninstallScript: p.UninstallScript,
InstallerFile: bytes.NewReader(bodyBytes),
SelfService: p.SelfService,
UserID: vc.UserID(),
@@ -825,6 +1025,9 @@ func (svc *Service) BatchSetSoftwareInstallers(
return err
}
+ // Update $PACKAGE_ID in uninstall script
+ preProcessUninstallScript(installer)
+
// if filename was empty, try to extract it from the URL with the
// now-known extension
if filename == "" {
@@ -963,3 +1166,68 @@ func packageExtensionToPlatform(ext string) string {
return requiredPlatform
}
+
+func UninstallSoftwareMigration(
+ ctx context.Context,
+ ds fleet.Datastore,
+ softwareInstallStore fleet.SoftwareInstallerStore,
+ logger kitlog.Logger,
+) error {
+ // Find software installers without package_id
+ idMap, err := ds.GetSoftwareInstallersWithoutPackageIDs(ctx)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "getting software installers without package_id")
+ }
+ if len(idMap) == 0 {
+ return nil
+ }
+
+ // Download each package and parse it
+ for id, storageID := range idMap {
+ // check if the installer exists in the store
+ exists, err := softwareInstallStore.Exists(ctx, storageID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "checking if installer exists")
+ }
+ if !exists {
+ level.Warn(logger).Log("msg", "software installer not found in store", "software_installer_id", id, "storage_id", storageID)
+ continue
+ }
+
+ // get the installer from the store
+ installer, _, err := softwareInstallStore.Get(ctx, storageID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "getting installer from store")
+ }
+
+ meta, err := file.ExtractInstallerMetadata(installer)
+ if err != nil {
+ level.Warn(logger).Log("msg", "extracting metadata from installer", "software_installer_id", id, "storage_id", storageID, "err",
+ err)
+ continue
+ }
+ if len(meta.PackageIDs) == 0 {
+ level.Warn(logger).Log("msg", "no package_id found in metadata", "software_installer_id", id, "storage_id", storageID)
+ continue
+ }
+ if meta.Extension == "" {
+ level.Warn(logger).Log("msg", "no extension found in metadata", "software_installer_id", id, "storage_id", storageID)
+ continue
+ }
+ payload := fleet.UploadSoftwareInstallerPayload{
+ PackageIDs: meta.PackageIDs,
+ Extension: meta.Extension,
+ }
+ payload.UninstallScript = file.GetUninstallScript(payload.Extension)
+
+ // Update $PACKAGE_ID in uninstall script
+ preProcessUninstallScript(&payload)
+
+ // Update the package_id in the software installer and the uninstall script
+ if err := ds.UpdateSoftwareInstallerWithoutPackageIDs(ctx, id, payload); err != nil {
+ return ctxerr.Wrap(ctx, err, "updating package_id in software installer")
+ }
+ }
+
+ return nil
+}
diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go
new file mode 100644
index 0000000000..6abd95085e
--- /dev/null
+++ b/ee/server/service/software_installers_test.go
@@ -0,0 +1,223 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/authz"
+ "github.com/fleetdm/fleet/v4/server/contexts/viewer"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPreProcessUninstallScript(t *testing.T) {
+ t.Parallel()
+ var input = `
+blah$PACKAGE_IDS
+pkgids=$PACKAGE_ID
+they are $PACKAGE_ID, right $MY_SECRET?
+quotes for "$PACKAGE_ID"
+blah${PACKAGE_ID}withConcat
+quotes and braces for "${PACKAGE_ID}"
+${PACKAGE_ID}`
+
+ payload := fleet.UploadSoftwareInstallerPayload{
+ Extension: "exe",
+ UninstallScript: input,
+ PackageIDs: []string{"com.foo"},
+ }
+
+ preProcessUninstallScript(&payload)
+ expected := `
+blah$PACKAGE_IDS
+pkgids="com.foo"
+they are "com.foo", right $MY_SECRET?
+quotes for "com.foo"
+blah"com.foo"withConcat
+quotes and braces for "com.foo"
+"com.foo"`
+ assert.Equal(t, expected, payload.UninstallScript)
+
+ payload = fleet.UploadSoftwareInstallerPayload{
+ Extension: "pkg",
+ UninstallScript: input,
+ PackageIDs: []string{"com.foo", "com.bar"},
+ }
+ preProcessUninstallScript(&payload)
+ expected = `
+blah$PACKAGE_IDS
+pkgids=(
+ "com.foo"
+ "com.bar"
+)
+they are (
+ "com.foo"
+ "com.bar"
+), right $MY_SECRET?
+quotes for (
+ "com.foo"
+ "com.bar"
+)
+blah(
+ "com.foo"
+ "com.bar"
+)withConcat
+quotes and braces for (
+ "com.foo"
+ "com.bar"
+)
+(
+ "com.foo"
+ "com.bar"
+)`
+ assert.Equal(t, expected, payload.UninstallScript)
+
+}
+
+func TestInstallUninstallAuth(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+ ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return &fleet.Host{
+ OrbitNodeKey: ptr.String("orbit_key"),
+ Platform: "darwin",
+ TeamID: ptr.Uint(1),
+ }, nil
+ }
+ ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint,
+ withScriptContents bool) (*fleet.SoftwareInstaller, error) {
+ return &fleet.SoftwareInstaller{
+ Name: "installer.pkg",
+ Platform: "darwin",
+ TeamID: ptr.Uint(1),
+ }, nil
+ }
+ ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) {
+ return nil, nil
+ }
+ ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string,
+ error) {
+ return "request_id", nil
+ }
+ ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
+ return []byte("script"), nil
+ }
+ ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult,
+ error) {
+ return &fleet.HostScriptResult{
+ ExecutionID: "execution_id",
+ }, nil
+ }
+ ds.InsertSoftwareUninstallRequestFunc = func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error {
+ return nil
+ }
+
+ testCases := []struct {
+ name string
+ user *fleet.User
+ shouldFail bool
+ }{
+ {
+ "global admin",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
+ false,
+ },
+ {
+ "global maintainer",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
+ false,
+ },
+ {
+ "global observer",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
+ true,
+ },
+ {
+ "team admin",
+ &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
+ false,
+ },
+ {
+ "team maintainer",
+ &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
+ false,
+ },
+ {
+ "team observer",
+ &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
+ true,
+ },
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user})
+ checkAuthErr(t, tt.shouldFail, svc.InstallSoftwareTitle(ctx, 1, 10))
+ checkAuthErr(t, tt.shouldFail, svc.UninstallSoftwareTitle(ctx, 1, 10))
+ })
+ }
+}
+
+// TestUninstallSoftwareTitle is mostly tested in enterprise integration test. This test hits a few edge cases.
+func TestUninstallSoftwareTitle(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc := newTestService(t, ds)
+
+ host := &fleet.Host{
+ OrbitNodeKey: ptr.String("orbit_key"),
+ Platform: "darwin",
+ TeamID: ptr.Uint(1),
+ }
+
+ ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return host, nil
+ }
+
+ // Scripts disabled
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{
+ ServerSettings: fleet.ServerSettings{
+ ScriptsDisabled: true,
+ },
+ }, nil
+ }
+ require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptScriptsDisabledGloballyErrMsg)
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+
+ // Host scripts disabled
+ host.ScriptsEnabled = ptr.Bool(false)
+ require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg)
+
+}
+
+func checkAuthErr(t *testing.T, shouldFail bool, err error) {
+ t.Helper()
+ if shouldFail {
+ require.Error(t, err)
+ var forbiddenError *authz.Forbidden
+ require.ErrorAs(t, err, &forbiddenError)
+ } else {
+ require.NoError(t, err)
+ }
+}
+
+func newTestService(t *testing.T, ds fleet.Datastore) *Service {
+ t.Helper()
+ authorizer, err := authz.NewAuthorizer()
+ require.NoError(t, err)
+ svc := &Service{
+ authz: authorizer,
+ ds: ds,
+ }
+ return svc
+}
diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go
index b03291ff46..d1f13bd555 100644
--- a/ee/server/service/vpp.go
+++ b/ee/server/service/vpp.go
@@ -149,8 +149,8 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string,
vppAppTeams = append(vppAppTeams, app.VPPAppTeam)
}
}
- }
+ }
if err := svc.ds.SetTeamVPPApps(ctx, &team.ID, vppAppTeams); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "no vpp token to set team vpp assets"), http.StatusUnprocessableEntity)
@@ -375,7 +375,7 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V
var apps []*fleet.VPPApp
// Map of adamID to platform, then to whether it's available as self-service.
- var adamIDMap = make(map[string]map[fleet.AppleDevicePlatform]bool)
+ adamIDMap := make(map[string]map[fleet.AppleDevicePlatform]bool)
for _, id := range ids {
if _, ok := adamIDMap[id.AdamID]; !ok {
adamIDMap[id.AdamID] = make(map[fleet.AppleDevicePlatform]bool, 1)
diff --git a/frontend/__mocks__/scriptMock.ts b/frontend/__mocks__/scriptMock.ts
index bb57b11857..ff8d635122 100644
--- a/frontend/__mocks__/scriptMock.ts
+++ b/frontend/__mocks__/scriptMock.ts
@@ -24,6 +24,7 @@ const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = {
runtime: 0,
host_timeout: false,
script_id: 1,
+ created_at: "2020-01-01T00:00:00.000Z",
};
export const createMockScriptResult = (
diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts
index 9ef0b14e9a..fb2fc313ba 100644
--- a/frontend/__mocks__/softwareMock.ts
+++ b/frontend/__mocks__/softwareMock.ts
@@ -204,8 +204,10 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
icon_url: null,
status: {
installed: 1,
- pending: 2,
- failed: 3,
+ pending_install: 2,
+ failed_install: 1,
+ pending_uninstall: 1,
+ failed_uninstall: 1,
},
};
diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx
index 61c2d5f04f..42ed1d6dd3 100644
--- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx
+++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { useQuery } from "react-query";
+import { formatDistanceToNow } from "date-fns";
import { IActivityDetails } from "interfaces/activity";
import {
@@ -29,7 +30,14 @@ export type IPackageInstallDetails = Pick<
>;
const StatusMessage = ({
- result: { host_display_name, software_package, software_title, status },
+ result: {
+ host_display_name,
+ software_package,
+ software_title,
+ status,
+ updated_at,
+ created_at,
+ },
}: {
result: ISoftwareInstallResult;
}) => {
@@ -38,13 +46,24 @@ const StatusMessage = ({
) : (
"the host"
);
+
+ const timeStamp = updated_at || created_at;
+ const displayTimeStamp = ["failed_install", "installed"].includes(
+ status || ""
+ )
+ ? ` (${formatDistanceToNow(new Date(timeStamp), {
+ includeSeconds: true,
+ addSuffix: true,
+ })})`
+ : "";
return (
Fleet {getInstallDetailsStatusPredicate(status)} {software_title}{" "}
({software_package}) on {formattedHost}
- {status === "pending" ? " when it comes online" : ""}.
+ {status === "pending_install" ? " when it comes online" : ""}
+ {displayTimeStamp}.
);
@@ -104,7 +123,7 @@ export const SoftwareInstallDetails = ({
result.host_display_name ? result : { ...result, host_display_name } // prefer result.host_display_name (it may be empty if the host was deleted) otherwise default to whatever we received via props
}
/>
- {result.status !== "pending" && (
+ {result.status !== "pending_install" && (
<>
{result.pre_install_query_output && (
diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal.tsx
new file mode 100644
index 0000000000..dfb4ef13c9
--- /dev/null
+++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal.tsx
@@ -0,0 +1,157 @@
+import Button from "components/buttons/Button";
+import DataError from "components/DataError";
+import Icon from "components/Icon";
+import Modal from "components/Modal";
+import Spinner from "components/Spinner";
+import Textarea from "components/Textarea";
+import { formatDistanceToNow } from "date-fns";
+import { IActivityDetails } from "interfaces/activity";
+import { isPendingStatus, SoftwareInstallStatus } from "interfaces/software";
+import React from "react";
+import { useQuery } from "react-query";
+import scriptsAPI, { IScriptResultResponse } from "services/entities/scripts";
+import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
+import {
+ getInstallDetailsStatusPredicate,
+ INSTALL_DETAILS_STATUS_ICONS,
+} from "../constants";
+
+const baseClass = "software-uninstall-details-modal";
+
+type ISoftwareUninstallDetails = Pick<
+ IActivityDetails,
+ "script_execution_id" | "host_display_name" | "software_title" | "status"
+>;
+// TODO - rely on activity created_at for timestamp? what else?
+
+interface IUninstallStatusMessage {
+ host_display_name: string;
+ // TODO - improve status typing
+ status: string;
+ software_title: string;
+ timestamp: string;
+}
+
+const StatusMessage = ({
+ host_display_name,
+ status,
+ software_title,
+ timestamp,
+}: IUninstallStatusMessage) => {
+ const formattedHost = host_display_name ? (
+ {host_display_name}
+ ) : (
+ "the host"
+ );
+
+ const isPending = isPendingStatus(status);
+ const displayTimeStamp =
+ !isPending && timestamp
+ ? ` (${formatDistanceToNow(new Date(timestamp), {
+ includeSeconds: true,
+ addSuffix: true,
+ })})`
+ : "";
+ return (
+
+
+
+ Fleet {getInstallDetailsStatusPredicate(status)} {software_title}{" "}
+ from {formattedHost}
+ {isPending ? " when it comes online" : ""}
+ {displayTimeStamp}.
+
+