diff --git a/articles/macos-setup-experience.md b/articles/macos-setup-experience.md index 6d7f9cc2ef9a..3d8b0b29a711 100644 --- a/articles/macos-setup-experience.md +++ b/articles/macos-setup-experience.md @@ -10,6 +10,10 @@ In Fleet, you can customize the out-of-the-box macOS Setup Assistant with Remote * Install a bootstrap package to gain full control over the setup experience by installing tools like Puppet, Munki, DEP notify, custom scripts, and more. +* Install software (App Store apps, custom packages, and Fleet-maintained apps). + +* Run a script. + In addition to the customization above, Fleet automatically installs the fleetd agent during out-of-the-box macOS setup. This agent is responsible for reporting host vitals to Fleet and presenting Fleet Desktop to the end user. macOS setup features require connecting Fleet to Apple Business Manager (ABM). Learn how [here](https://fleetdm.com/guides/macos-mdm-setup#apple-business-manager-abm). @@ -161,6 +165,40 @@ Testing requires a test Mac that is present in your Apple Business Manager (ABM) 4. Boot up your test Mac and complete the custom out-of-the-box setup experience. +## Software and script + +You can configure software installations and a script to be executed during Setup Assistant. This capability allows you to configure your end users' machines during the unboxing experience, speeding up their onboarding and reducing setup time. + +If you configure software and/or a script for setup experience, users will see a window like this pop open after their device enrolls in MDM via ADE: + +![screen shot of Fleet setup experience window](../website/assets/images/install-software-preview.png) + +This window shows the status of the software installations as well as the script exectution. Once all steps have completed, the window can be closed and Setup Assistant will proceed as usual. + +### Install software + +To configure software to be installed during setup experience: + +1. Click on the "Controls" tab in the main navigation bar. Click on "Setup experience", and then on "4. Install software". + +2. Click the "Add software" button. In the modal, select the software that you want to have installed during the setup experience. You can search the list of software by using the search bar in the modal. Click "Save" to save your selection and close the modal. + +### Run script + +To configure a script to run during setup experience: + +1. Click on the "Controls" tab in the main navigation bar. Click on "Setup experience", and then on "5. Run script". + +2. Click "Upload" and select a script (.sh file) from the file picker modal. Once the script is uploaded, you can use the buttons on the script in the web UI to download or delete the script. + +### Configuring via REST API + +Fleet also provides a REST API for managing setup experience software and scripts programmatically. Learn more about Fleet's [REST API](https://fleetdm.com/docs/rest-api/rest-api). + +### Configuring via GitOps + +To manage setup experience software and script using Fleet's best practice GitOps, check out the `macos_setup` key in the GitOps reference documentation [here](https://fleetdm.com/docs/configuration/yaml-files#macos-setup) + diff --git a/assets/images/install-software-preview.png b/assets/images/install-software-preview.png new file mode 100644 index 000000000000..258c7479a90e Binary files /dev/null and b/assets/images/install-software-preview.png differ diff --git a/changes/22373-install-software-for-setup-experience-ui b/changes/22373-install-software-for-setup-experience-ui new file mode 100644 index 000000000000..86346c672189 --- /dev/null +++ b/changes/22373-install-software-for-setup-experience-ui @@ -0,0 +1 @@ +- add UI for the isntall software setup experience diff --git a/changes/22374-add-ui-for-setup-experience-script b/changes/22374-add-ui-for-setup-experience-script new file mode 100644 index 000000000000..c598cb0d8cde --- /dev/null +++ b/changes/22374-add-ui-for-setup-experience-script @@ -0,0 +1 @@ +- add UI for adding a setup experience script diff --git a/changes/22375-setup-experience-migration b/changes/22375-setup-experience-migration new file mode 100644 index 000000000000..7158083881e1 --- /dev/null +++ b/changes/22375-setup-experience-migration @@ -0,0 +1 @@ +- Add database migrations to support Setup Experience diff --git a/changes/22377-setup-experience-software-api b/changes/22377-setup-experience-software-api new file mode 100644 index 000000000000..386f68546619 --- /dev/null +++ b/changes/22377-setup-experience-software-api @@ -0,0 +1 @@ +- Add software experience software title selection API diff --git a/changes/22379-queue-setup-experience-software b/changes/22379-queue-setup-experience-software new file mode 100644 index 000000000000..a9b5b2b547b2 --- /dev/null +++ b/changes/22379-queue-setup-experience-software @@ -0,0 +1 @@ +- Add integration to queue setup experience software installation on automatic enrollment diff --git a/changes/22381-setup-experience-state-machine b/changes/22381-setup-experience-state-machine new file mode 100644 index 000000000000..35e126302bbe --- /dev/null +++ b/changes/22381-setup-experience-state-machine @@ -0,0 +1 @@ +- Add service to track install status diff --git a/changes/22382-prevent-delete-software-used-in-setup b/changes/22382-prevent-delete-software-used-in-setup new file mode 100644 index 000000000000..10c1183204cc --- /dev/null +++ b/changes/22382-prevent-delete-software-used-in-setup @@ -0,0 +1 @@ +* Added a validation to prevent removing a software package or a VPP app from a team if that software is selected to be installed during the setup experience. diff --git a/changes/22385-cli-gitops-macos-setup-software-and-script b/changes/22385-cli-gitops-macos-setup-software-and-script new file mode 100644 index 000000000000..09ea4359f317 --- /dev/null +++ b/changes/22385-cli-gitops-macos-setup-software-and-script @@ -0,0 +1 @@ +* Added support to `fleetctl gitops` to specify a setup experience script to run and software to install, for a team or no team. diff --git a/changes/22637-status b/changes/22637-status new file mode 100644 index 000000000000..e68062c162d8 --- /dev/null +++ b/changes/22637-status @@ -0,0 +1,2 @@ +- Adds an Orbit endpoint (`POST /orbit/setup_experience/status`) for checking the status of a macOS +host's setup experience steps. \ No newline at end of file diff --git a/changes/22783-release-ade-enrolled-device b/changes/22783-release-ade-enrolled-device new file mode 100644 index 000000000000..90e83023f391 --- /dev/null +++ b/changes/22783-release-ade-enrolled-device @@ -0,0 +1 @@ +* Deprecated the worker-based job to release macOS devices automatically after the setup experience, replace it with the fleetd-specific "/status" endpoint that is polled by the Setup Experience dialog controlled by Fleet during the setup flow. diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go index 4e29f15b18b6..237c46ab0b2f 100644 --- a/cmd/fleetctl/apply.go +++ b/cmd/fleetctl/apply.go @@ -94,7 +94,7 @@ func applyCommand() *cli.Command { teamsSoftwareInstallers := make(map[string][]fleet.SoftwarePackageResponse) teamsScripts := make(map[string][]fleet.ScriptResponse) - _, _, _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts, teamsSoftwareInstallers, teamsScripts) + _, _, _, err = fleetClient.ApplyGroup(c.Context, false, specs, baseDir, logf, nil, opts, teamsSoftwareInstallers, teamsScripts) if err != nil { return err } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 3d0b5eddbece..3dc253804192 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -438,6 +438,9 @@ func TestGitOpsBasicTeam(t *testing.T) { ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { return nil, 0, nil, nil } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { + return nil + } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -903,6 +906,9 @@ func TestGitOpsFullTeam(t *testing.T) { ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { return nil, 0, nil, nil } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { + return nil + } startSoftwareInstallerServer(t) @@ -1211,6 +1217,9 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) { ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { + return nil + } ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { var teamsSummary []*fleet.TeamSummary @@ -1532,6 +1541,9 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { + return nil + } globalFileBasic, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -1852,6 +1864,7 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) { func TestGitOpsTeamSofwareInstallers(t *testing.T) { startSoftwareInstallerServer(t) + startAndServeVPPServer(t) cases := []struct { file string @@ -1871,12 +1884,37 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) { {"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"}, + // team tests for setup experience software/script + {"testdata/gitops/team_setup_software_valid.yml", ""}, + {"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, + {"testdata/gitops/team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that team"}, + {"testdata/gitops/team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that team"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { - setupFullGitOpsPremiumServer(t) + ds, _, _ := setupFullGitOpsPremiumServer(t) + tokExpire := time.Now().Add(time.Hour) + token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca") + require.NoError(t, err) - _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) + 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 + } + ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{ + ID: 1, + OrgName: "Fleet", + Location: "Earth", + RenewDate: tokExpire, + Token: string(token), + Teams: nil, + }, nil + } + + _, err = runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) } else { @@ -1908,6 +1946,7 @@ func TestGitOpsTeamSoftwareInstallersQueryEnv(t *testing.T) { func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { startSoftwareInstallerServer(t) + startAndServeVPPServer(t) cases := []struct { noTeamFile string @@ -1925,18 +1964,48 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { {"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"}, + // No team tests for setup experience software/script + {"testdata/gitops/no_team_setup_software_valid.yml", ""}, + {"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, + {"testdata/gitops/no_team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that team"}, + // VPP apps for No Team is unsupported at the moment : https://github.com/fleetdm/fleet/issues/22970 + // {"testdata/gitops/no_team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that team"}, } for _, c := range cases { t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) { - setupFullGitOpsPremiumServer(t) + ds, _, _ := setupFullGitOpsPremiumServer(t) + tokExpire := time.Now().Add(time.Hour) + token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca") + require.NoError(t, err) + + 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 + } + ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{ + ID: 1, + OrgName: "Fleet", + Location: "Earth", + RenewDate: tokExpire, + Token: string(token), + Teams: nil, + }, nil + } t.Setenv("APPLE_BM_DEFAULT_TEAM", "") globalFile := "./testdata/gitops/global_config_no_paths.yml" + if strings.HasPrefix(filepath.Base(c.noTeamFile), "no_team_setup_software") { + // the controls section is in the no-team test file, so use a global file without that section + globalFile = "./testdata/gitops/global_config_no_paths_no_controls.yml" + } dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml") t.Cleanup(func() { os.Remove(dstPath) }) - err := file.Copy(c.noTeamFile, dstPath, 0o755) + err = file.Copy(c.noTeamFile, dstPath, 0o755) require.NoError(t, err) _, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath}) if c.wantErr == "" { @@ -1949,48 +2018,7 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { } func TestGitOpsTeamVPPApps(t *testing.T) { - config := &appleVPPConfigSrvConf{ - Assets: []vpp.Asset{ - { - AdamID: "1", - PricingParam: "STDQ", - AvailableCount: 12, - }, - { - AdamID: "2", - PricingParam: "STDQ", - AvailableCount: 3, - }, - }, - SerialNumbers: []string{"123", "456"}, - } - - startVPPApplyServer(t, config) - - appleITunesSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // a map of apps we can respond with - db := map[string]string{ - // macos app - "1": `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "App 1", "TrackID": 1}`, - // macos, ios, ipados app - "2": `{"bundleId": "b-2", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "App 2", "TrackID": 2, - "supportedDevices": ["MacDesktop-MacDesktop", "iPhone5s-iPhone5s", "iPadAir-iPadAir"] }`, - // ipados app - "3": `{"bundleId": "c-3", "artworkUrl512": "https://example.com/images/3", "version": "3.0.0", "trackName": "App 3", "TrackID": 3, - "supportedDevices": ["iPadAir-iPadAir"] }`, - } - - adamIDString := r.URL.Query().Get("id") - adamIDs := strings.Split(adamIDString, ",") - - var objs []string - for _, a := range adamIDs { - objs = append(objs, db[a]) - } - - _, _ = w.Write([]byte(fmt.Sprintf(`{"results": [%s]}`, strings.Join(objs, ",")))) - })) - t.Setenv("FLEET_DEV_ITUNES_URL", appleITunesSrv.URL) + startAndServeVPPServer(t) cases := []struct { file string @@ -2262,6 +2290,51 @@ func startVPPApplyServer(t *testing.T, config *appleVPPConfigSrvConf) { t.Cleanup(srv.Close) } +func startAndServeVPPServer(t *testing.T) { + config := &appleVPPConfigSrvConf{ + Assets: []vpp.Asset{ + { + AdamID: "1", + PricingParam: "STDQ", + AvailableCount: 12, + }, + { + AdamID: "2", + PricingParam: "STDQ", + AvailableCount: 3, + }, + }, + SerialNumbers: []string{"123", "456"}, + } + + startVPPApplyServer(t, config) + + appleITunesSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // a map of apps we can respond with + db := map[string]string{ + // macos app + "1": `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "App 1", "TrackID": 1}`, + // macos, ios, ipados app + "2": `{"bundleId": "b-2", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "App 2", "TrackID": 2, + "supportedDevices": ["MacDesktop-MacDesktop", "iPhone5s-iPhone5s", "iPadAir-iPadAir"] }`, + // ipados app + "3": `{"bundleId": "c-3", "artworkUrl512": "https://example.com/images/3", "version": "3.0.0", "trackName": "App 3", "TrackID": 3, + "supportedDevices": ["iPadAir-iPadAir"] }`, + } + + adamIDString := r.URL.Query().Get("id") + adamIDs := strings.Split(adamIDString, ",") + + var objs []string + for _, a := range adamIDs { + objs = append(objs, db[a]) + } + + _, _ = w.Write([]byte(fmt.Sprintf(`{"results": [%s]}`, strings.Join(objs, ",")))) + })) + t.Setenv("FLEET_DEV_ITUNES_URL", appleITunesSrv.URL) +} + func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, map[string]**fleet.Team) { testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) @@ -2449,6 +2522,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { + return nil + } + ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error { + return nil + } t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go index b009c0ca702b..cf5b28039f7c 100644 --- a/cmd/fleetctl/preview.go +++ b/cmd/fleetctl/preview.go @@ -389,7 +389,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st // so pass in the current working directory. teamsSoftwareInstallers := make(map[string][]fleet.SoftwarePackageResponse) teamsScripts := make(map[string][]fleet.ScriptResponse) - _, _, _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}, teamsSoftwareInstallers, teamsScripts) + _, _, _, err = client.ApplyGroup(c.Context, false, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}, teamsSoftwareInstallers, teamsScripts) if err != nil { return err } diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index ce6017b7da88..c3779641775a 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -130,7 +130,9 @@ "bootstrap_package": null, "enable_end_user_authentication": false, "macos_setup_assistant": null, - "enable_release_device_manually": false + "enable_release_device_manually": false, + "script": null, + "software": null }, "windows_settings": { "custom_settings": null diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 3637ccc5d74c..ff6fbaa22eae 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -50,6 +50,8 @@ spec: enable_end_user_authentication: false enable_release_device_manually: false macos_setup_assistant: + script: + software: windows_settings: custom_settings: null end_user_authentication: diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 598e99c53e58..dea76a995b16 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -82,7 +82,9 @@ "bootstrap_package": null, "enable_end_user_authentication": false, "macos_setup_assistant": null, - "enable_release_device_manually": false + "enable_release_device_manually": false, + "script": null, + "software": null }, "windows_settings": { "custom_settings": null diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 56d2d13eac93..f6b8136407cd 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -50,6 +50,8 @@ spec: enable_end_user_authentication: false enable_release_device_manually: false macos_setup_assistant: + script: + software: windows_settings: custom_settings: end_user_authentication: diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index de6669ac1259..2e38e6b7e7a8 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -54,7 +54,9 @@ "bootstrap_package": null, "enable_end_user_authentication": false, "macos_setup_assistant": null, - "enable_release_device_manually": false + "enable_release_device_manually": false, + "script": null, + "software": null }, "windows_settings": { "custom_settings": null @@ -137,7 +139,9 @@ "bootstrap_package": null, "enable_end_user_authentication": false, "macos_setup_assistant": null, - "enable_release_device_manually": false + "enable_release_device_manually": false, + "script": null, + "software": null }, "windows_settings": { "custom_settings": null diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index 422456f5b54e..fe37b4161566 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -34,6 +34,8 @@ spec: enable_end_user_authentication: false enable_release_device_manually: false macos_setup_assistant: + script: + software: scripts: null secrets: null webhook_settings: @@ -84,6 +86,8 @@ spec: enable_end_user_authentication: false enable_release_device_manually: false macos_setup_assistant: + script: + software: scripts: null webhook_settings: host_status_webhook: null diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths_no_controls.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths_no_controls.yml new file mode 100644 index 000000000000..48c6f6a19062 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths_no_controls.yml @@ -0,0 +1,164 @@ +# Test config +queries: + - name: Scheduled query stats + description: Collect osquery performance stats directly from osquery + query: SELECT *, + (SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter + FROM osquery_schedule; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: false + logging: snapshot + - name: orbit_info + query: SELECT * from orbit_info; + interval: 0 + platform: darwin,linux,windows + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot + - name: osquery_info + query: SELECT * from osquery_info; + interval: 604800 # 1 week + platform: darwin,linux,windows,chrome + min_osquery_version: all + observer_can_run: false + automations_enabled: true + logging: snapshot +policies: + - name: 😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: Passing policy + platform: linux,windows,darwin,chrome + description: This policy should always pass. + resolution: There is no resolution for this policy. + query: SELECT 1; + - name: No root logins (macOS, Linux) + platform: linux,darwin + query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last + WHERE username = "root" + AND time > (( SELECT unix_time FROM time ) - 3600 )) + critical: true + - name: 🔥 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: 😊😊 Failing policy + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; +agent_options: + command_line_flags: + distributed_denylist_duration: 0 + config: + decorators: + load: + - SELECT uuid AS host_uuid FROM system_info; + - SELECT hostname AS hostname FROM system_info; + options: + disable_distributed: false + distributed_interval: 10 + distributed_plugin: tls + distributed_tls_max_attempts: 3 + logger_tls_endpoint: /api/v1/osquery/log + pack_delimiter: / +org_settings: + server_settings: + debug_host_ids: + - 10728 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_report_cap: 2000 + query_reports_disabled: false + scripts_disabled: false + server_url: $FLEET_SERVER_URL + ai_features_disabled: true + org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: $ORG_NAME + smtp_settings: + authentication_method: authmethod_plain + authentication_type: authtype_username_password + configured: false + domain: "" + enable_smtp: false + enable_ssl_tls: true + enable_start_tls: true + password: "" + port: 587 + sender_address: "" + server: "" + user_name: "" + verify_ssl_certs: true + sso_settings: + enable_jit_provisioning: false + enable_jit_role_sync: false + enable_sso: true + enable_sso_idp_login: false + entity_id: https://saml.example.com/entityid + idp_image_url: "" + idp_name: MockSAML + issuer_uri: "" + metadata: "" + metadata_url: https://mocksaml.com/api/saml/metadata + integrations: + jira: [] + zendesk: [] + google_calendar: + - domain: example.com + api_key_json: { + "client_email": "service@example.com", + "private_key": "google_calendar_private_key", + } + mdm: + end_user_authentication: + entity_id: "" + idp_name: "" + issuer_uri: "" + metadata: "" + metadata_url: "" + webhook_settings: + activities_webhook: + enable_activities_webhook: true + destination_url: https://activities_webhook_url + failing_policies_webhook: + destination_url: https://host.docker.internal:8080/bozo + enable_failing_policies_webhook: false + host_batch_size: 0 + policy_ids: [] + host_status_webhook: + days_count: 0 + destination_url: "" + enable_host_status_webhook: false + host_percentage: 0 + interval: 24h0m0s + vulnerabilities_webhook: + destination_url: "" + enable_vulnerabilities_webhook: false + host_batch_size: 0 + fleet_desktop: # Applies to Fleet Premium only + transparency_url: https://fleetdm.com/transparency + host_expiry_settings: # Applies to all teams + host_expiry_enabled: false + activity_expiry_settings: + activity_expiry_enabled: true + activity_expiry_window: 60 + features: # Features added to all teams + enable_host_users: true + enable_software_inventory: true + vulnerability_settings: + databases_path: "" + secrets: # These secrets are used to enroll hosts to the "All teams" team + - secret: SampleSecret123 + - secret: ABC +software: diff --git a/cmd/fleetctl/testdata/gitops/lib/setup_script.sh b/cmd/fleetctl/testdata/gitops/lib/setup_script.sh new file mode 100644 index 000000000000..8006cb43bc46 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/setup_script.sh @@ -0,0 +1 @@ +echo "ok" diff --git a/cmd/fleetctl/testdata/gitops/lib/software_other.yml b/cmd/fleetctl/testdata/gitops/lib/software_other.yml new file mode 100644 index 000000000000..3e8511098ce4 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/software_other.yml @@ -0,0 +1,2 @@ +url: ${SOFTWARE_INSTALLER_URL}/other.deb +self_service: true diff --git a/cmd/fleetctl/testdata/gitops/lib/software_ruby.yml b/cmd/fleetctl/testdata/gitops/lib/software_ruby.yml new file mode 100644 index 000000000000..4b56fe3a8e4b --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/software_ruby.yml @@ -0,0 +1,9 @@ +url: ${SOFTWARE_INSTALLER_URL}/ruby.deb +install_script: + path: lib/install_ruby.sh +pre_install_query: + path: lib/query_ruby.yml +post_install_script: + path: lib/post_install_ruby.sh +uninstall_script: + path: lib/uninstall_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_script.yml b/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_script.yml new file mode 100644 index 000000000000..13da36eaa2c0 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_script.yml @@ -0,0 +1,9 @@ +name: No team +controls: + macos_setup: + script: lib/no_such_script.sh +policies: +software: + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_software_package.yml b/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_software_package.yml new file mode 100644 index 000000000000..8ec612710e47 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_software_package.yml @@ -0,0 +1,10 @@ +name: No team +controls: + macos_setup: + software: + - package_path: lib/no_such_software.yml +policies: +software: + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_vpp_app.yml b/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_vpp_app.yml new file mode 100644 index 000000000000..ea82a114d50f --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_setup_software_invalid_vpp_app.yml @@ -0,0 +1,14 @@ +name: No team +controls: + macos_setup: + software: + - app_store_id: "no_such_app" + - package_path: lib/software_ruby.yml + script: lib/setup_script.sh +policies: +software: + app_store_apps: + - app_store_id: "1" + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/gitops/no_team_setup_software_valid.yml b/cmd/fleetctl/testdata/gitops/no_team_setup_software_valid.yml new file mode 100644 index 000000000000..6aa1f8a5463d --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_setup_software_valid.yml @@ -0,0 +1,11 @@ +name: No team +controls: + macos_setup: + software: + - package_path: lib/software_ruby.yml + script: lib/setup_script.sh +policies: +software: + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_script.yml b/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_script.yml new file mode 100644 index 000000000000..d16e52931c0e --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_script.yml @@ -0,0 +1,22 @@ +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: + macos_setup: + script: lib/no_such_script.sh +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_software_package.yml b/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_software_package.yml new file mode 100644 index 000000000000..7091e9ddccef --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_software_package.yml @@ -0,0 +1,24 @@ +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: + macos_setup: + software: + - app_store_id: "1" + - package_path: lib/no_such_software.yml +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_vpp_app.yml b/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_vpp_app.yml new file mode 100644 index 000000000000..47db98a341e1 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_setup_software_invalid_vpp_app.yml @@ -0,0 +1,25 @@ +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: + macos_setup: + software: + - app_store_id: "no_such_app" + - package_path: lib/software_ruby.yml + script: lib/setup_script.sh +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/gitops/team_setup_software_valid.yml b/cmd/fleetctl/testdata/gitops/team_setup_software_valid.yml new file mode 100644 index 000000000000..f384dc836f7c --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_setup_software_valid.yml @@ -0,0 +1,25 @@ +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: + macos_setup: + software: + - app_store_id: "1" + - package_path: lib/software_ruby.yml + script: lib/setup_script.sh +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" + packages: + - path: lib/software_ruby.yml + - path: lib/software_other.yml diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index a158f27c1b52..49c129df695b 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -40,6 +40,8 @@ spec: enable_end_user_authentication: false macos_setup_assistant: null enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 4d9ffe1f50a0..27e6a2a5459e 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -40,6 +40,8 @@ spec: enable_end_user_authentication: false macos_setup_assistant: %s enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index f0aa275b56b3..7c8d9bb4865d 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -22,6 +22,8 @@ spec: enable_end_user_authentication: false macos_setup_assistant: null enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null @@ -62,6 +64,8 @@ spec: bootstrap_package: null macos_setup_assistant: null enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 1d2b07674017..45203bc28362 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -22,6 +22,8 @@ spec: enable_end_user_authentication: false macos_setup_assistant: %s enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null @@ -62,6 +64,8 @@ spec: bootstrap_package: %s macos_setup_assistant: %s enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 885d25482758..5e92912a19f1 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -20,6 +20,8 @@ spec: enable_end_user_authentication: false macos_setup_assistant: null enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index 76c45b10b2f2..2c9da92d8fe8 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -21,6 +21,8 @@ spec: enable_end_user_authentication: false macos_setup_assistant: %s enable_release_device_manually: false + script: null + software: null macos_updates: deadline: null minimum_version: null diff --git a/ee/server/service/orbit.go b/ee/server/service/orbit.go new file mode 100644 index 000000000000..3d23ea03d078 --- /dev/null +++ b/ee/server/service/orbit.go @@ -0,0 +1,212 @@ +package service + +import ( + "context" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/go-kit/log/level" + "github.com/google/uuid" +) + +func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNodeKey string, forceRelease bool) (*fleet.SetupExperienceStatusPayload, error) { + // this is not a user-authenticated endpoint + svc.authz.SkipAuthorization(ctx) + host, err := svc.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "loading host by orbit node key") + } + + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting app config") + } + + // get the status of the bootstrap package deployment + bootstrapPkg, err := svc.ds.GetHostMDMMacOSSetup(ctx, host.ID) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get bootstrap package status") + } + + // NOTE: bootstrapPkg can be nil if there was none to install. + var bootstrapPkgResult *fleet.SetupExperienceBootstrapPackageResult + if bootstrapPkg != nil { + bootstrapPkgResult = &fleet.SetupExperienceBootstrapPackageResult{ + Name: bootstrapPkg.BootstrapPackageName, + Status: bootstrapPkg.BootstrapPackageStatus, + } + } + + // get the status of the configuration profiles + cfgProfs, err := svc.ds.GetHostMDMAppleProfiles(ctx, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get configuration profiles status") + } + var cfgProfResults []*fleet.SetupExperienceConfigurationProfileResult + for _, prof := range cfgProfs { + // NOTE: DDM profiles (declarations) are ignored because while a device is + // awaiting to be released, it cannot process a DDM session (at least + // that's what we noticed during testing). + if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) { + continue + } + + status := fleet.MDMDeliveryPending + if prof.Status != nil { + status = *prof.Status + } + cfgProfResults = append(cfgProfResults, &fleet.SetupExperienceConfigurationProfileResult{ + ProfileUUID: prof.ProfileUUID, + Name: prof.Name, + Status: status, + }) + } + + // AccountConfiguration covers the (optional) command to setup SSO. + adminTeamFilter := fleet.TeamFilter{ + User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + } + acctCmds, err := svc.ds.ListMDMCommands(ctx, adminTeamFilter, &fleet.MDMCommandListOptions{ + Filters: fleet.MDMCommandFilters{ + HostIdentifier: host.UUID, + RequestType: "AccountConfiguration", + }, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list AccountConfiguration commands") + } + + var acctCfgResult *fleet.SetupExperienceAccountConfigurationResult + if len(acctCmds) > 0 { + // there may be more than one if e.g. the worker job that sends them had to + // retry, but they would all be processed anyway so we can only care about + // the first one. + acctCfgResult = &fleet.SetupExperienceAccountConfigurationResult{ + CommandUUID: acctCmds[0].CommandUUID, + Status: acctCmds[0].Status, + } + } + + // get status of software installs and script execution + res, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing setup experience results") + } + + payload := &fleet.SetupExperienceStatusPayload{ + BootstrapPackage: bootstrapPkgResult, + ConfigurationProfiles: cfgProfResults, + AccountConfiguration: acctCfgResult, + Software: make([]*fleet.SetupExperienceStatusResult, 0), + OrgLogoURL: appCfg.OrgInfo.OrgLogoURLLightBackground, + } + for _, r := range res { + if r.IsForScript() { + payload.Script = r + } + + if r.IsForSoftware() { + payload.Software = append(payload.Software, r) + } + } + + if forceRelease || isDeviceReadyForRelease(payload) { + manual, err := isDeviceReleasedManually(ctx, svc.ds, host) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "check if device is released manually") + } + if manual { + return payload, nil + } + + // otherwise the device is not released manually, proceed with automatic + // release + if forceRelease { + level.Warn(svc.logger).Log("msg", "force-releasing device, DEP enrollment commands, profiles, software installs and script execution may not have all completed", "host_uuid", host.UUID) + } else { + level.Info(svc.logger).Log("msg", "releasing device, all DEP enrollment commands, profiles, software installs and script execution have completed", "host_uuid", host.UUID) + } + + // Host will be marked as no longer "awaiting configuration" in the command handler + if err := svc.mdmAppleCommander.DeviceConfigured(ctx, host.UUID, uuid.NewString()); err != nil { + return nil, ctxerr.Wrap(ctx, err, "failed to enqueue DeviceConfigured command") + } + + } + + _, err = svc.SetupExperienceNextStep(ctx, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting next step for host setup experience") + } + + return payload, nil +} + +func isDeviceReleasedManually(ctx context.Context, ds fleet.Datastore, host *fleet.Host) (bool, error) { + var manualRelease bool + if host.TeamID == nil { + ac, err := ds.AppConfig(ctx) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "get AppConfig to read enable_release_device_manually") + } + manualRelease = ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value + } else { + tm, err := ds.Team(ctx, *host.TeamID) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "get Team to read enable_release_device_manually") + } + manualRelease = tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value + } + return manualRelease, nil +} + +func isDeviceReadyForRelease(payload *fleet.SetupExperienceStatusPayload) bool { + // default to "do release" and return false as soon as we find a reason not + // to. + + if payload.BootstrapPackage != nil { + if payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageFailed && + payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageInstalled { + // bootstrap package is still pending, not ready for release + return false + } + } + + if payload.AccountConfiguration != nil { + if payload.AccountConfiguration.Status != fleet.MDMAppleStatusAcknowledged && + payload.AccountConfiguration.Status != fleet.MDMAppleStatusError && + payload.AccountConfiguration.Status != fleet.MDMAppleStatusCommandFormatError { + // account configuration command is still pending, not ready for release + return false + } + } + + for _, prof := range payload.ConfigurationProfiles { + if prof.Status != fleet.MDMDeliveryFailed && + prof.Status != fleet.MDMDeliveryVerifying && + prof.Status != fleet.MDMDeliveryVerified { + // profile is still pending, not ready for release + return false + } + } + + for _, sw := range payload.Software { + if sw.Status != fleet.SetupExperienceStatusFailure && + sw.Status != fleet.SetupExperienceStatusSuccess { + // software is still pending, not ready for release + return false + } + } + + if payload.Script != nil { + if payload.Script.Status != fleet.SetupExperienceStatusFailure && + payload.Script.Status != fleet.SetupExperienceStatusSuccess { + // script is still pending, not ready for release + return false + } + } + + return true +} diff --git a/ee/server/service/service.go b/ee/server/service/service.go index fb66f21136ad..e5a28036ef69 100644 --- a/ee/server/service/service.go +++ b/ee/server/service/service.go @@ -86,6 +86,7 @@ func NewService( MDMWindowsEnableOSUpdates: eeservice.mdmWindowsEnableOSUpdates, MDMWindowsDisableOSUpdates: eeservice.mdmWindowsDisableOSUpdates, MDMAppleEditedAppleOSUpdates: eeservice.mdmAppleEditedAppleOSUpdates, + SetupExperienceNextStep: eeservice.SetupExperienceNextStep, }) return eeservice, nil diff --git a/ee/server/service/setup_experience.go b/ee/server/service/setup_experience.go new file mode 100644 index 000000000000..077630d3c46a --- /dev/null +++ b/ee/server/service/setup_experience.go @@ -0,0 +1,243 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "path/filepath" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" +) + +func (svc *Service) SetSetupExperienceSoftware(ctx context.Context, teamID uint, titleIDs []uint) error { + if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &teamID}, fleet.ActionWrite); err != nil { + return err + } + + if err := svc.ds.SetSetupExperienceSoftwareTitles(ctx, teamID, titleIDs); err != nil { + return ctxerr.Wrap(ctx, err, "setting setup experience titles") + } + + return nil +} + +func (svc *Service) ListSetupExperienceSoftware(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{ + TeamID: &teamID, + }, fleet.ActionRead); err != nil { + return nil, 0, nil, err + } + + titles, count, meta, err := svc.ds.ListSetupExperienceSoftwareTitles(ctx, teamID, opts) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "retrieving list of software setup experience titles") + } + + return titles, count, meta, nil +} + +func (svc *Service) GetSetupExperienceScript(ctx context.Context, teamID *uint, withContent bool) (*fleet.Script, []byte, error) { + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, nil, err + } + + script, err := svc.ds.GetSetupExperienceScript(ctx, teamID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get setup experience script") + } + + var content []byte + if withContent { + content, err = svc.ds.GetAnyScriptContents(ctx, script.ScriptContentID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get setup experience script contents") + } + } + + return script, content, nil +} + +func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error { + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { + return err + } + + b, err := io.ReadAll(r) + if err != nil { + return ctxerr.Wrap(ctx, err, "read setup experience script contents") + } + + script := &fleet.Script{ + TeamID: teamID, + Name: name, + ScriptContents: string(b), + } + + // setup experience is only supported for macOS currently so we need to override the file + // extension check in the general script validation + if filepath.Ext(script.Name) != ".sh" { + return fleet.NewInvalidArgumentError("script", "File type not supported. Only .sh file type is allowed.") + } + // now we can do our normal script validation + if err := script.ValidateNewScript(); err != nil { + return fleet.NewInvalidArgumentError("script", err.Error()) + } + + if err := svc.ds.SetSetupExperienceScript(ctx, script); err != nil { + var ( + existsErr fleet.AlreadyExistsError + fkErr fleet.ForeignKeyError + ) + if errors.As(err, &existsErr) { + err = fleet.NewInvalidArgumentError("script", err.Error()).WithStatus(http.StatusConflict) // TODO: confirm error message with product/frontend + } else if errors.As(err, &fkErr) { + err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound) + } + return ctxerr.Wrap(ctx, err, "create setup experience script") + } + + // NOTE: there is no activity specified for set setup experience script + + return nil +} + +func (svc *Service) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error { + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { + return err + } + + if err := svc.ds.DeleteSetupExperienceScript(ctx, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "delete setup experience script") + } + + // NOTE: there is no activity specified for delete setup experience script + + return nil +} + +func (svc *Service) SetupExperienceNextStep(ctx context.Context, hostUUID string) (bool, error) { + statuses, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step") + } + + var installersPending, appsPending, scriptsPending []*fleet.SetupExperienceStatusResult + var installersRunning, appsRunning, scriptsRunning int + + for _, status := range statuses { + if err := status.IsValid(); err != nil { + return false, ctxerr.Wrap(ctx, err, "invalid row") + } + + switch { + case status.SoftwareInstallerID != nil: + switch status.Status { + case fleet.SetupExperienceStatusPending: + installersPending = append(installersPending, status) + case fleet.SetupExperienceStatusRunning: + installersRunning++ + } + case status.VPPAppTeamID != nil: + switch status.Status { + case fleet.SetupExperienceStatusPending: + appsPending = append(appsPending, status) + case fleet.SetupExperienceStatusRunning: + appsRunning++ + } + case status.SetupExperienceScriptID != nil: + switch status.Status { + case fleet.SetupExperienceStatusPending: + scriptsPending = append(scriptsPending, status) + case fleet.SetupExperienceStatusRunning: + scriptsRunning++ + } + } + } + + // This step is called internally, not by a user + filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} + hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, []string{hostUUID}) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "fetching host details using UUID") + } + + if len(hosts) == 0 { + return false, ctxerr.Errorf(ctx, "could not find host id for host UUID %q", hostUUID) + } + + host := hosts[0] + + switch { + case len(installersPending) > 0: + // enqueue installers + for _, installer := range installersPending { + installUUID, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, *installer.SoftwareInstallerID, false, nil) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "queueing setup experience install request") + } + installer.HostSoftwareInstallsExecutionID = &installUUID + installer.Status = fleet.SetupExperienceStatusRunning + if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, installer); err != nil { + return false, ctxerr.Wrap(ctx, err, "updating setup experience result with install uuid") + } + } + case installersRunning == 0 && len(appsPending) > 0: + // enqueue vpp apps + for _, app := range appsPending { + vppAppID, err := app.VPPAppID() + if err != nil { + return false, ctxerr.Wrap(ctx, err, "constructing vpp app details for installation") + } + + if app.SoftwareTitleID == nil { + return false, ctxerr.Errorf(ctx, "setup experience software title id missing from vpp app install request: %d", app.ID) + } + + vppApp := &fleet.VPPApp{ + TitleID: *app.SoftwareTitleID, + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: *vppAppID, + }, + } + + cmdUUID, err := svc.installSoftwareFromVPP(ctx, host, vppApp, true, false) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "queueing vpp app installation") + } + app.NanoCommandUUID = &cmdUUID + app.Status = fleet.SetupExperienceStatusRunning + if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, app); err != nil { + return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid") + } + } + case installersRunning == 0 && appsRunning == 0 && len(scriptsPending) > 0: + // enqueue scripts + for _, script := range scriptsPending { + if script.ScriptContentID == nil { + return false, ctxerr.Errorf(ctx, "setup experience script missing content id: %d", *script.SetupExperienceScriptID) + } + req := &fleet.HostScriptRequestPayload{ + HostID: host.ID, + ScriptName: script.Name, + ScriptContentID: *script.ScriptContentID, + } + res, err := svc.ds.NewHostScriptExecutionRequest(ctx, req) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "queueing setup experience script execution request") + } + script.ScriptExecutionID = &res.ExecutionID + script.Status = fleet.SetupExperienceStatusRunning + if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, script); err != nil { + return false, ctxerr.Wrap(ctx, err, "updating setup experience script execution id") + } + } + case installersRunning == 0 && appsRunning == 0 && scriptsRunning == 0: + // finished + return true, nil + } + + return false, nil +} diff --git a/ee/server/service/setup_experience_test.go b/ee/server/service/setup_experience_test.go new file mode 100644 index 000000000000..444851c49e2b --- /dev/null +++ b/ee/server/service/setup_experience_test.go @@ -0,0 +1,197 @@ +package service + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetupExperienceNextStep(t *testing.T) { + ctx := context.Background() + ds := new(mock.Store) + svc := newTestService(t, ds) + + requestedInstalls := make(map[uint][]uint) + requestedUpdateSetupExperience := []*fleet.SetupExperienceStatusResult{} + requestedScriptExecution := []*fleet.HostScriptRequestPayload{} + resetIndicators := func() { + ds.InsertSoftwareInstallRequestFuncInvoked = false + ds.InsertHostVPPSoftwareInstallFuncInvoked = false + ds.NewHostScriptExecutionRequestFuncInvoked = false + ds.UpdateSetupExperienceStatusResultFuncInvoked = false + clear(requestedInstalls) + requestedUpdateSetupExperience = []*fleet.SetupExperienceStatusResult{} + requestedScriptExecution = []*fleet.HostScriptRequestPayload{} + } + + host1UUID := "123" + host1ID := uint(1) + installerID1 := uint(2) + scriptID1 := uint(3) + scriptContentID1 := uint(4) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{ + EnabledAndConfigured: true, + }, + }, nil + } + + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return true, nil + } + + var mockListSetupExperience []*fleet.SetupExperienceStatusResult + ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { + return mockListSetupExperience, nil + } + + var mockListHostsLite []*fleet.Host + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + return mockListHostsLite, nil + } + + ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID, softwareInstallerID uint, selfService bool, policyID *uint) (string, error) { + requestedInstalls[hostID] = append(requestedInstalls[hostID], softwareInstallerID) + return "install-uuid", nil + } + + ds.UpdateSetupExperienceStatusResultFunc = func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + requestedUpdateSetupExperience = append(requestedUpdateSetupExperience, status) + return nil + } + + ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { + requestedScriptExecution = append(requestedScriptExecution, request) + return &fleet.HostScriptResult{ + ExecutionID: "script-uuid", + }, nil + } + + // No host exists + _, err := svc.SetupExperienceNextStep(ctx, host1UUID) + require.Error(t, err) + + // Host exists, nothing to do + mockListHostsLite = append(mockListHostsLite, &fleet.Host{UUID: host1UUID, ID: host1ID}) + + finished, err := svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.True(t, finished) + assert.False(t, ds.InsertSoftwareInstallRequestFuncInvoked) + assert.False(t, ds.InsertHostVPPSoftwareInstallFuncInvoked) + assert.False(t, ds.NewHostScriptExecutionRequestFuncInvoked) + assert.False(t, ds.UpdateSetupExperienceStatusResultFuncInvoked) + resetIndicators() + + // Only installer queued + mockListSetupExperience = []*fleet.SetupExperienceStatusResult{ + { + HostUUID: host1UUID, + SoftwareInstallerID: &installerID1, + Status: fleet.SetupExperienceStatusPending, + }, + } + + finished, err = svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.False(t, finished) + assert.True(t, ds.InsertSoftwareInstallRequestFuncInvoked) + assert.False(t, ds.InsertHostVPPSoftwareInstallFuncInvoked) + assert.False(t, ds.NewHostScriptExecutionRequestFuncInvoked) + assert.True(t, ds.UpdateSetupExperienceStatusResultFuncInvoked) + assert.Len(t, requestedInstalls, 1) + assert.Len(t, requestedUpdateSetupExperience, 1) + assert.Equal(t, "install-uuid", *requestedUpdateSetupExperience[0].HostSoftwareInstallsExecutionID) + + mockListSetupExperience[0].Status = fleet.SetupExperienceStatusSuccess + finished, err = svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.True(t, finished) + + resetIndicators() + + // TODO VPP app queueing is better done in an integration + // test, the setup required would be too much + + // Only script queued + mockListSetupExperience = []*fleet.SetupExperienceStatusResult{ + { + HostUUID: host1UUID, + SetupExperienceScriptID: &scriptID1, + ScriptContentID: &scriptContentID1, + Status: fleet.SetupExperienceStatusPending, + }, + } + + finished, err = svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.False(t, finished) + assert.False(t, ds.InsertSoftwareInstallRequestFuncInvoked) + assert.False(t, ds.InsertHostVPPSoftwareInstallFuncInvoked) + assert.True(t, ds.NewHostScriptExecutionRequestFuncInvoked) + assert.True(t, ds.UpdateSetupExperienceStatusResultFuncInvoked) + assert.Len(t, requestedScriptExecution, 1) + assert.Len(t, requestedUpdateSetupExperience, 1) + assert.Equal(t, "script-uuid", *requestedUpdateSetupExperience[0].ScriptExecutionID) + + mockListSetupExperience[0].Status = fleet.SetupExperienceStatusSuccess + finished, err = svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.True(t, finished) + + resetIndicators() + + // Both installer and script + mockListSetupExperience = []*fleet.SetupExperienceStatusResult{ + { + HostUUID: host1UUID, + SoftwareInstallerID: &installerID1, + Status: fleet.SetupExperienceStatusPending, + }, + { + HostUUID: host1UUID, + SetupExperienceScriptID: &scriptID1, + ScriptContentID: &scriptContentID1, + Status: fleet.SetupExperienceStatusPending, + }, + } + + // Only installer is queued + finished, err = svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.False(t, finished) + assert.True(t, ds.InsertSoftwareInstallRequestFuncInvoked) + assert.False(t, ds.InsertHostVPPSoftwareInstallFuncInvoked) + assert.False(t, ds.NewHostScriptExecutionRequestFuncInvoked) + assert.True(t, ds.UpdateSetupExperienceStatusResultFuncInvoked) + assert.Len(t, requestedInstalls, 1) + assert.Len(t, requestedScriptExecution, 0) + assert.Len(t, requestedUpdateSetupExperience, 1) + + // install finished, call it again. This time script is queued + mockListSetupExperience[0].Status = fleet.SetupExperienceStatusSuccess + + finished, err = svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.False(t, finished) + assert.True(t, ds.InsertSoftwareInstallRequestFuncInvoked) + assert.False(t, ds.InsertHostVPPSoftwareInstallFuncInvoked) + assert.True(t, ds.NewHostScriptExecutionRequestFuncInvoked) + assert.True(t, ds.UpdateSetupExperienceStatusResultFuncInvoked) + assert.Len(t, requestedInstalls, 1) + assert.Len(t, requestedScriptExecution, 1) + assert.Len(t, requestedUpdateSetupExperience, 2) + + // both finished, now we're done + mockListSetupExperience[1].Status = fleet.SetupExperienceStatusFailure + + finished, err = svc.SetupExperienceNextStep(ctx, host1UUID) + require.NoError(t, err) + assert.True(t, finished) +} diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 8da5edddc6ef..045fa48ccdbf 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -691,12 +691,13 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw return ctxerr.Wrap(ctx, err, "finding VPP app for title") } - return svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.AppleDevicePlatform(platform) == fleet.MacOSPlatform, false) + _, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.AppleDevicePlatform(platform) == fleet.MacOSPlatform, false) + return err } -func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, appleDevice bool, selfService bool) error { +func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, appleDevice bool, selfService bool) (string, error) { if !appleDevice { - return &fleet.BadRequestError{ + return "", &fleet.BadRequestError{ Message: "VPP apps can only be installed only on Apple hosts.", InternalErr: ctxerr.NewWithData( ctx, "invalid host platform for requested installer", @@ -707,20 +708,20 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host config, err := svc.ds.AppConfig(ctx) if err != nil { - return ctxerr.Wrap(ctx, err, "fetching config to check MDM status") + return "", ctxerr.Wrap(ctx, err, "fetching config to check MDM status") } if !config.MDM.EnabledAndConfigured { - return fleet.NewUserMessageError(errors.New("Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps."), http.StatusUnprocessableEntity) + return "", fleet.NewUserMessageError(errors.New("Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps."), http.StatusUnprocessableEntity) } mdmConnected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) if err != nil { - return ctxerr.Wrapf(ctx, err, "checking MDM status for host %d", host.ID) + return "", ctxerr.Wrapf(ctx, err, "checking MDM status for host %d", host.ID) } if !mdmConnected { - return &fleet.BadRequestError{ + return "", &fleet.BadRequestError{ Message: "VPP apps can only be installed only on hosts enrolled in MDM.", InternalErr: ctxerr.NewWithData( ctx, "VPP install attempted on non-MDM host", @@ -731,7 +732,7 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host token, err := svc.getVPPToken(ctx, host.TeamID) if err != nil { - return ctxerr.Wrap(ctx, err, "getting VPP token") + return "", ctxerr.Wrap(ctx, err, "getting VPP token") } // at this moment, neither the UI or the back-end are prepared to @@ -747,7 +748,7 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host // [1]: https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3729433 assignments, err := vpp.GetAssignments(token, &vpp.AssignmentFilter{AdamID: vppApp.AdamID, SerialNumber: host.HardwareSerial}) if err != nil { - return ctxerr.Wrap(ctx, err, "getting assignments from VPP API") + return "", ctxerr.Wrap(ctx, err, "getting assignments from VPP API") } var eventID string @@ -757,7 +758,7 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host if len(assignments) == 0 { assets, err := vpp.GetAssets(token, &vpp.AssetFilter{AdamID: vppApp.AdamID}) if err != nil { - return ctxerr.Wrap(ctx, err, "getting assets from VPP API") + return "", ctxerr.Wrap(ctx, err, "getting assets from VPP API") } if len(assets) == 0 { @@ -766,18 +767,18 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host "adam_id", vppApp.AdamID, "host_serial", host.HardwareSerial, ) - return &fleet.BadRequestError{ + return "", &fleet.BadRequestError{ Message: "Couldn't add software. isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.", InternalErr: ctxerr.Errorf(ctx, "VPP API didn't return any assets for adamID %s", vppApp.AdamID), } } if len(assets) > 1 { - return ctxerr.Errorf(ctx, "VPP API returned more than one asset for adamID %s", vppApp.AdamID) + return "", ctxerr.Errorf(ctx, "VPP API returned more than one asset for adamID %s", vppApp.AdamID) } if assets[0].AvailableCount <= 0 { - return &fleet.BadRequestError{ + return "", &fleet.BadRequestError{ Message: "Couldn't install. No available licenses. Please purchase license in Apple Business Manager and try again.", InternalErr: ctxerr.NewWithData( ctx, "license available count <= 0", @@ -793,7 +794,7 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host eventID, err = vpp.AssociateAssets(token, &vpp.AssociateAssetsRequest{Assets: assets, SerialNumbers: []string{host.HardwareSerial}}) if err != nil { - return ctxerr.Wrapf(ctx, err, "associating asset with adamID %s to host %s", vppApp.AdamID, host.HardwareSerial) + return "", ctxerr.Wrapf(ctx, err, "associating asset with adamID %s to host %s", vppApp.AdamID, host.HardwareSerial) } } @@ -802,15 +803,15 @@ func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host cmdUUID := uuid.NewString() err = svc.mdmAppleCommander.InstallApplication(ctx, []string{host.UUID}, cmdUUID, vppApp.AdamID) if err != nil { - return ctxerr.Wrapf(ctx, err, "sending command to install VPP %s application to host with serial %s", vppApp.AdamID, host.HardwareSerial) + return "", ctxerr.Wrapf(ctx, err, "sending command to install VPP %s application to host with serial %s", vppApp.AdamID, host.HardwareSerial) } err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, vppApp.VPPAppID, cmdUUID, eventID, selfService) if err != nil { - return ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID) + return "", ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID) } - return nil + return cmdUUID, nil } func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host *fleet.Host, installer *fleet.SoftwareInstaller) error { @@ -1300,15 +1301,16 @@ func (svc *Service) softwareBatchUpload( } installer := &fleet.UploadSoftwareInstallerPayload{ - TeamID: teamID, - InstallScript: p.InstallScript, - PreInstallQuery: p.PreInstallQuery, - PostInstallScript: p.PostInstallScript, - UninstallScript: p.UninstallScript, - InstallerFile: bytes.NewReader(bodyBytes), - SelfService: p.SelfService, - UserID: userID, - URL: p.URL, + TeamID: teamID, + InstallScript: p.InstallScript, + PreInstallQuery: p.PreInstallQuery, + PostInstallScript: p.PostInstallScript, + UninstallScript: p.UninstallScript, + InstallerFile: bytes.NewReader(bodyBytes), + SelfService: p.SelfService, + UserID: userID, + URL: p.URL, + InstallDuringSetup: p.InstallDuringSetup, } // set the filename before adding metadata, as it is used as fallback @@ -1506,7 +1508,8 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f platform := host.FleetPlatform() mobileAppleDevice := fleet.AppleDevicePlatform(platform) == fleet.IOSPlatform || fleet.AppleDevicePlatform(platform) == fleet.IPadOSPlatform - return svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.AppleDevicePlatform(platform) == fleet.MacOSPlatform, true) + _, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.AppleDevicePlatform(platform) == fleet.MacOSPlatform, true) + return err } // packageExtensionToPlatform returns the platform name based on the diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 448abc367401..0952b972f336 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1396,6 +1396,15 @@ func (svc *Service) editTeamFromSpec( } } + // if the setup experience script was cleared, remove it for that team + if spec.MDM.MacOSSetup.Script.Set && + spec.MDM.MacOSSetup.Script.Value == "" && + oldMacOSSetup.Script.Value != "" { + if err := svc.DeleteSetupExperienceScript(ctx, &team.ID); err != nil { + return ctxerr.Wrapf(ctx, err, "clear setup experience script for team %d", team.ID) + } + } + if didUpdateMacOSEndUserAuth { if err := svc.updateMacOSSetupEnableEndUserAuth( ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name, diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index f818188daa8e..453bdcee132d 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -79,9 +79,10 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, SelfService: false, Platform: fleet.IPadOSPlatform, }, { - AppStoreID: payload.AppStoreID, - SelfService: payload.SelfService, - Platform: fleet.MacOSPlatform, + AppStoreID: payload.AppStoreID, + SelfService: payload.SelfService, + Platform: fleet.MacOSPlatform, + InstallDuringSetup: payload.InstallDuringSetup, }}...) } @@ -107,7 +108,14 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, return fleet.NewInvalidArgumentError("app_store_apps.platform", fmt.Sprintf("platform must be one of '%s', '%s', or '%s", fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform)) } - vppAppTeams = append(vppAppTeams, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: payload.AppStoreID, Platform: payload.Platform}, SelfService: payload.SelfService}) + vppAppTeams = append(vppAppTeams, fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: payload.AppStoreID, + Platform: payload.Platform, + }, + SelfService: payload.SelfService, + InstallDuringSetup: payload.InstallDuringSetup, + }) } var missingAssets []string @@ -378,14 +386,15 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.VPPApp, error) { var apps []*fleet.VPPApp - // Map of adamID to platform, then to whether it's available as self-service. - adamIDMap := make(map[string]map[fleet.AppleDevicePlatform]bool) + // Map of adamID to platform, then to whether it's available as self-service + // and installed during setup. + adamIDMap := make(map[string]map[fleet.AppleDevicePlatform]fleet.VPPAppTeam) for _, id := range ids { if _, ok := adamIDMap[id.AdamID]; !ok { - adamIDMap[id.AdamID] = make(map[fleet.AppleDevicePlatform]bool, 1) - adamIDMap[id.AdamID][id.Platform] = id.SelfService + adamIDMap[id.AdamID] = make(map[fleet.AppleDevicePlatform]fleet.VPPAppTeam, 1) + adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{SelfService: id.SelfService, InstallDuringSetup: id.InstallDuringSetup} } else { - adamIDMap[id.AdamID][id.Platform] = id.SelfService + adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{SelfService: id.SelfService, InstallDuringSetup: id.InstallDuringSetup} } } @@ -401,14 +410,15 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V for adamID, metadata := range assetMetatada { platforms := getPlatformsFromSupportedDevices(metadata.SupportedDevices) for platform := range platforms { - if selfService, ok := adamIDMap[adamID][platform]; ok { + if props, ok := adamIDMap[adamID][platform]; ok { app := &fleet.VPPApp{ VPPAppTeam: fleet.VPPAppTeam{ VPPAppID: fleet.VPPAppID{ AdamID: adamID, Platform: platform, }, - SelfService: selfService, + SelfService: props.SelfService, + InstallDuringSetup: props.InstallDuringSetup, }, BundleIdentifier: metadata.BundleID, IconURL: metadata.ArtworkURL, diff --git a/frontend/__mocks__/setupExperienceMock.ts b/frontend/__mocks__/setupExperienceMock.ts new file mode 100644 index 000000000000..a983aae993cc --- /dev/null +++ b/frontend/__mocks__/setupExperienceMock.ts @@ -0,0 +1,16 @@ +import { IGetSetupExperienceScriptResponse } from "services/entities/mdm"; + +const DEFAULT_SETUP_EXPIERENCE_SCRIPT: IGetSetupExperienceScriptResponse = { + id: 1, + team_id: null, + name: "Test Script.sh", + created_at: "2021-01-01T00:00:00Z", + updated_at: "2021-01-01T00:00:00Z", +}; + +// eslint-disable-next-line import/prefer-default-export +export const createMockSetupExperienceScript = ( + overrides?: Partial +): IGetSetupExperienceScriptResponse => { + return { ...DEFAULT_SETUP_EXPIERENCE_SCRIPT, ...overrides }; +}; diff --git a/frontend/components/LinkWithContext/LinkWithContext.tsx b/frontend/components/LinkWithContext/LinkWithContext.tsx index 9c3857573ec2..cabd65582cc3 100644 --- a/frontend/components/LinkWithContext/LinkWithContext.tsx +++ b/frontend/components/LinkWithContext/LinkWithContext.tsx @@ -1,11 +1,13 @@ import React from "react"; import { Link } from "react-router"; +import classnames from "classnames"; import { buildQueryStringFromParams, QueryParams } from "utilities/url"; import { pick } from "lodash"; +const baseClass = "link-with-context"; + interface ILinkWithContextProps { - className: string; children: React.ReactChild | React.ReactChild[]; currentQueryParams: QueryParams; to: string; @@ -13,15 +15,18 @@ interface ILinkWithContextProps { type: "query"; names: string[]; }; + className?: string; } const LinkWithContext = ({ - className, children, currentQueryParams, to, withParams, + className, }: ILinkWithContextProps): JSX.Element => { + const classNames = classnames(baseClass, className); + let queryString = ""; if (withParams.type === "query") { const newParams = pick(currentQueryParams, withParams.names); @@ -29,7 +34,7 @@ const LinkWithContext = ({ } return ( {children} diff --git a/frontend/components/TableContainer/DataTable/DataTable.tsx b/frontend/components/TableContainer/DataTable/DataTable.tsx index 43bde85890ef..e9a00b33f015 100644 --- a/frontend/components/TableContainer/DataTable/DataTable.tsx +++ b/frontend/components/TableContainer/DataTable/DataTable.tsx @@ -1,7 +1,13 @@ /* eslint-disable react/prop-types */ // disable this rule as it was throwing an error in Header and Cell component // definitions for the selection row for some reason when we dont really need it. -import React, { useMemo, useEffect, useCallback, useContext } from "react"; +import React, { + useMemo, + useEffect, + useCallback, + useContext, + useRef, +} from "react"; import classnames from "classnames"; import { Column, @@ -46,6 +52,7 @@ interface IDataTableProps { resultsTitle?: string; defaultPageSize: number; defaultPageIndex?: number; + defaultSelectedRows?: Record; primarySelectAction?: IActionButtonProps; secondarySelectActions?: IActionButtonProps[]; isClientSidePagination?: boolean; @@ -55,6 +62,8 @@ interface IDataTableProps { searchQuery?: string; searchQueryColumn?: string; selectedDropdownFilter?: string; + /** Set to true to persist the row selections across table data filters */ + persistSelectedRows?: boolean; onSelectSingleRow?: (value: Row) => void; onClickRow?: (value: any) => void; onResultsCountChange?: (value: number) => void; @@ -63,6 +72,7 @@ interface IDataTableProps { renderTableHelpText?: () => JSX.Element | null; renderPagination?: () => JSX.Element | null; setExportRows?: (rows: Row[]) => void; + onClearSelection?: () => void; } interface IHeaderGroup extends HeaderGroup { @@ -90,6 +100,7 @@ const DataTable = ({ resultsTitle = "results", defaultPageSize, defaultPageIndex, + defaultSelectedRows = {}, primarySelectAction, secondarySelectActions, isClientSidePagination, @@ -99,13 +110,18 @@ const DataTable = ({ searchQuery, searchQueryColumn, selectedDropdownFilter, + persistSelectedRows = false, onSelectSingleRow, onClickRow, onResultsCountChange, renderTableHelpText, renderPagination, setExportRows, + onClearSelection = noop, }: IDataTableProps): JSX.Element => { + // used to track the initial mount of the component. + const isInitialRender = useRef(true); + const { isOnlyObserver } = useContext(AppContext); const columns = useMemo(() => { @@ -151,6 +167,7 @@ const DataTable = ({ initialState: { sortBy: initialSortBy, pageIndex: defaultPageIndex, + selectedRowIds: defaultSelectedRows, }, disableMultiSort: true, disableSortRemove: true, @@ -256,10 +273,16 @@ const DataTable = ({ }, [isClientSideFilter, onResultsCountChange, rows.length]); useEffect(() => { - if (isClientSideFilter && searchQueryColumn) { - toggleAllRowsSelected(false); // Resets row selection on query change (client-side) + if (!isInitialRender.current && isClientSideFilter && searchQueryColumn) { setDebouncedClientFilter(searchQueryColumn, searchQuery || ""); } + + // we only want to reset the selected rows if we are not persisting them + // across table data filters + if (!isInitialRender.current && !persistSelectedRows) { + toggleAllRowsSelected(false); // Resets row selection on query change (client-side) + } + isInitialRender.current = false; }, [searchQuery, searchQueryColumn]); useEffect(() => { @@ -314,9 +337,10 @@ const DataTable = ({ }, [toggleAllPagesSelected]); const onClearSelectionClick = useCallback(() => { - toggleAllRowsSelected(false); - toggleAllPagesSelected(false); - }, [toggleAllPagesSelected, toggleAllRowsSelected]); + onClearSelection(); + toggleAllRowsSelected?.(false); + toggleAllPagesSelected?.(false); + }, [onClearSelection, toggleAllPagesSelected, toggleAllRowsSelected]); const onSelectRowClick = useCallback( (row: any) => { @@ -339,10 +363,13 @@ const DataTable = ({ }; const renderSelectedCount = (): JSX.Element => { + const selectedCount = Object.entries(selectedRowIds).filter( + ([, value]) => value + ).length; return (

- {selectedFlatRows.length} + {selectedCount} {isAllPagesSelected && "+"} {" "} selected diff --git a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx index 987054b2aabb..2b1ce645fc15 100644 --- a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx +++ b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx @@ -82,7 +82,7 @@ const SoftwareNameCell = ({ if (!router || !path) { return (

- + {name}
); diff --git a/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss b/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss index 0b9ede837162..a473f74a9c6a 100644 --- a/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss +++ b/frontend/components/TableContainer/DataTable/SoftwareNameCell/_styles.scss @@ -12,6 +12,11 @@ height: 24px; } + .software-name { + @include ellipse-text(); + max-width: 250px; + } + &__install-icon { // TODO: we do not want to use !important but have to for now. This is // the same issue as the .software-name-cell class display value. diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index 4ba6af202750..4a3ffadfe562 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -3,6 +3,7 @@ import classnames from "classnames"; import { Row } from "react-table"; import ReactTooltip from "react-tooltip"; import useDeepEffect from "hooks/useDeepEffect"; +import { noop } from "lodash"; import SearchField from "components/forms/fields/SearchField"; // @ts-ignore @@ -38,6 +39,7 @@ interface ITableContainerProps { defaultSortDirection?: string; defaultSearchQuery?: string; defaultPageIndex?: number; + defaultSelectedRows?: Record; /** Button visible above the table container next to search bar */ actionButton?: IActionButtonProps; inputPlaceHolder?: string; @@ -71,6 +73,7 @@ interface ITableContainerProps { isClientSidePagination?: boolean; /** Used to set URL to correct path and include page query param */ onClientSidePaginationChange?: (pageIndex: number) => void; + /** Sets the table to filter the data on the client */ isClientSideFilter?: boolean; /** isMultiColumnFilter is used to preserve the table headers in lieu of displaying the empty component when client-side filtering yields zero results */ @@ -102,6 +105,10 @@ interface ITableContainerProps { * bar and API call so TableContainer will reset its page state to 0 */ resetPageIndex?: boolean; disableTableHeader?: boolean; + /** Set to true to persist the row selections across table data filters */ + persistSelectedRows?: boolean; + /** handler called when the `clear selection` button is called */ + onClearSelection?: () => void; } const baseClass = "table-container"; @@ -119,6 +126,7 @@ const TableContainer = ({ defaultPageIndex = DEFAULT_PAGE_INDEX, defaultSortHeader = "name", defaultSortDirection = "asc", + defaultSelectedRows, inputPlaceHolder = "Search", additionalQueries, resultsTitle, @@ -157,6 +165,8 @@ const TableContainer = ({ setExportRows, resetPageIndex, disableTableHeader, + persistSelectedRows, + onClearSelection = noop, }: ITableContainerProps) => { const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [sortHeader, setSortHeader] = useState(defaultSortHeader || ""); @@ -496,6 +506,7 @@ const TableContainer = ({ resultsTitle={resultsTitle} defaultPageSize={pageSize} defaultPageIndex={defaultPageIndex} + defaultSelectedRows={defaultSelectedRows} primarySelectAction={primarySelectAction} secondarySelectActions={secondarySelectActions} onSelectSingleRow={onSelectSingleRow} @@ -513,6 +524,8 @@ const TableContainer = ({ isClientSidePagination ? undefined : renderPagination } setExportRows={setExportRows} + onClearSelection={onClearSelection} + persistSelectedRows={persistSelectedRows} /> diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index c81db2921f85..4c387d0a5387 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -71,6 +71,7 @@ export interface ISoftwarePackage { pending_uninstall: number; failed_uninstall: number; }; + install_during_setup?: boolean; } export const isSoftwarePackage = ( @@ -89,6 +90,7 @@ export interface IAppStoreApp { pending: number; failed: number; }; + install_during_setup?: boolean; } export interface ISoftwareTitle { diff --git a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperience.tsx b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperience.tsx index 1f7271ce1ada..8b2c359a2036 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperience.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperience.tsx @@ -88,8 +88,9 @@ const SetupExperience = ({ return (

- Customize the setup experience for hosts that automatically enroll to - this team. + Customize the setup experience for macOS hosts that automatically enroll + in this team. Each step runs sequentially and will be required if + enabled before the end user finishes setup.

[] = [ { - title: "End user authentication", + title: "1. End user authentication", urlSection: "end-user-auth", path: PATHS.CONTROLS_END_USER_AUTHENTICATION, Card: EndUserAuthentication, }, { - title: "Bootstrap package", + title: "2. Setup assistant", + urlSection: "setup-assistant", + path: PATHS.CONTROLS_SETUP_ASSITANT, + Card: SetupAssistant, + }, + { + title: "3. Bootstrap package", urlSection: "bootstrap-package", path: PATHS.CONTROLS_BOOTSTRAP_PACKAGE, Card: BootstrapPackage, }, { - title: "Setup assistant", - urlSection: "setup-assistant", - path: PATHS.CONTROLS_SETUP_ASSITANT, - Card: SetupAssistant, + title: "4. Install software", + urlSection: "install-software", + path: PATHS.CONTROLS_INSTALL_SOFTWARE, + Card: InstallSoftware, + }, + { + title: "5. Run script", + urlSection: "run-script", + path: PATHS.CONTROLS_RUN_SCRIPT, + Card: SetupExperienceScript, }, ]; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/BootstrapPackagePreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/BootstrapPackagePreview.tsx index 54587b425b09..4bc4441d7674 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/BootstrapPackagePreview.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/BootstrapPackagePreview.tsx @@ -7,7 +7,7 @@ const baseClass = "bootstrap-package-preview"; const BootstrapPackagePreview = () => { return (
-

End user experience

+

End user experience

The bootstrap package is automatically installed after the end user authenticates and agrees to the EULA during the Remote Management{" "} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss index 03d790b2f627..006c9da54454 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss @@ -5,16 +5,16 @@ padding: $pad-xxlarge; font-size: $x-small; - h2 { + h3 { margin: 0; font-size: $small; font-weight: normal; } &__preview-img { - margin-top: $pad-xxlarge; width: 100%; display: block; - margin: 40px auto 0; + margin: $pad-xxlarge auto 0; + border-radius: $border-radius-large; } } diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx index 6f114df0a676..b96c62b5c46c 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx @@ -10,12 +10,12 @@ import { ITeamConfig } from "interfaces/team"; import SectionHeader from "components/SectionHeader/SectionHeader"; import Spinner from "components/Spinner"; -import EndUserExperiencePreview from "pages/ManageControlsPage/components/EndUserExperiencePreview"; import RequireEndUserAuth from "./components/RequireEndUserAuth/RequireEndUserAuth"; import EndUserAuthForm from "./components/EndUserAuthForm/EndUserAuthForm"; import OsSetupPreview from "../../../../../../assets/images/os-setup-preview.gif"; +import EndUserExperiencePreview from "./components/EndUserExperiencePreview"; const baseClass = "end-user-authentication"; diff --git a/frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/EndUserExperiencePreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/EndUserExperiencePreview.tsx similarity index 89% rename from frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/EndUserExperiencePreview.tsx rename to frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/EndUserExperiencePreview.tsx index 9c0140a4d50e..d45c1f530db4 100644 --- a/frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/EndUserExperiencePreview.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/EndUserExperiencePreview.tsx @@ -10,7 +10,7 @@ interface IEndUserExperiencePreviewProps { className?: string; } -const EndUserExperiencePerview = ({ +const EndUserExperiencePreview = ({ previewImage, altText = "end user experience preview", children, @@ -31,4 +31,4 @@ const EndUserExperiencePerview = ({ ); }; -export default EndUserExperiencePerview; +export default EndUserExperiencePreview; diff --git a/frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/_styles.scss similarity index 81% rename from frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/_styles.scss rename to frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/_styles.scss index d71e0cee5d55..96a94e993430 100644 --- a/frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/_styles.scss +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/_styles.scss @@ -12,9 +12,9 @@ } &__preview-img { - margin-top: $pad-xxlarge; width: 100%; display: block; - margin: 40px auto 0; + margin: $pad-xxlarge auto 0; + border-radius: $border-radius-large; } } diff --git a/frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/index.ts similarity index 100% rename from frontend/pages/ManageControlsPage/components/EndUserExperiencePreview/index.ts rename to frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/components/EndUserExperiencePreview/index.ts diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tsx new file mode 100644 index 000000000000..99be0fe088f3 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import mdmAPI, { + IGetSetupExperienceSoftwareResponse, +} from "services/entities/mdm"; +import software, { ISoftwareTitle } from "interfaces/software"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import SectionHeader from "components/SectionHeader"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; + +import InstallSoftwarePreview from "./components/InstallSoftwarePreview"; +import AddInstallSoftware from "./components/AddInstallSoftware"; +import SelectSoftwareModal from "./components/SelectSoftwareModal"; + +const baseClass = "install-software"; + +// This is so large because we want to get all the software titles that are +// available for install so we can correctly display the selected count. +const PER_PAGE_SIZE = 3000; + +interface IInstallSoftwareProps { + currentTeamId: number; +} + +const InstallSoftware = ({ currentTeamId }: IInstallSoftwareProps) => { + const [showSelectSoftwareModal, setShowSelectSoftwareModal] = useState(false); + + const { + data: softwareTitles, + isLoading, + isError, + refetch: refetchSoftwareTitles, + } = useQuery< + IGetSetupExperienceSoftwareResponse, + AxiosError, + ISoftwareTitle[] + >( + ["install-software", currentTeamId], + () => + mdmAPI.getSetupExperienceSoftware({ + team_id: currentTeamId, + per_page: PER_PAGE_SIZE, + }), + { + ...DEFAULT_USE_QUERY_OPTIONS, + select: (res) => res.software_titles, + } + ); + + const onSave = async () => { + setShowSelectSoftwareModal(false); + refetchSoftwareTitles(); + }; + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + if (softwareTitles) { + return ( +

+ setShowSelectSoftwareModal(true)} + /> + +
+ ); + } + + return null; + }; + + return ( +
+ + <>{renderContent()} + {showSelectSoftwareModal && softwareTitles && ( + setShowSelectSoftwareModal(false)} + /> + )} +
+ ); +}; + +export default InstallSoftware; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/_styles.scss new file mode 100644 index 000000000000..c26b4906d2e0 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/_styles.scss @@ -0,0 +1,9 @@ +.install-software { + &__content { + max-width: $break-xxl; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $pad-xxlarge; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/AddInstallSoftware.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/AddInstallSoftware.tests.tsx new file mode 100644 index 000000000000..96ef67518fd4 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/AddInstallSoftware.tests.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { noop } from "lodash"; + +import { + createMockSoftwarePackage, + createMockSoftwareTitle, +} from "__mocks__/softwareMock"; + +import AddInstallSoftware from "./AddInstallSoftware"; + +describe("AddInstallSoftware", () => { + it("should render no software message if there are no software to select from", () => { + render( + + ); + + expect(screen.getByText(/No software available to add/i)).toBeVisible(); + expect(screen.getByText(/upload software/i)).toBeVisible(); + }); + + it("should render the correct messaging when there are software titles but none have been selected to install at setup", () => { + render( + + ); + + expect(screen.getByText(/No software added/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Add software" })).toBeVisible(); + }); + + it("should render the correct messaging when there are software titles that have been selected to install at setup", () => { + render( + + ); + + expect( + screen.getByText(/2 software will be installed during setup/) + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Show selected software" }) + ).toBeVisible(); + }); +}); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/AddInstallSoftware.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/AddInstallSoftware.tsx new file mode 100644 index 000000000000..70c7634eb4b9 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/AddInstallSoftware.tsx @@ -0,0 +1,81 @@ +import React from "react"; + +import PATHS from "router/paths"; + +import Button from "components/buttons/Button"; +import CustomLink from "components/CustomLink"; +import { ISoftwareTitle } from "interfaces/software"; +import LinkWithContext from "components/LinkWithContext"; + +const baseClass = "add-install-software"; + +interface IAddInstallSoftwareProps { + currentTeamId: number; + softwareTitles: ISoftwareTitle[]; + onAddSoftware: () => void; +} + +const AddInstallSoftware = ({ + currentTeamId, + softwareTitles, + onAddSoftware, +}: IAddInstallSoftwareProps) => { + const hasNoSoftware = softwareTitles.length === 0; + const installDuringSetupCount = softwareTitles.filter( + (software) => + software.software_package?.install_during_setup || + software.app_store_app?.install_during_setup + ).length; + + let addedText = <>; + let buttonText = ""; + + if (hasNoSoftware) { + addedText = ( + <> + No software available to add. Please{" "} + + upload software + {" "} + to be able to add during setup experience.{" "} + + ); + buttonText = "Add software"; + } else if (installDuringSetupCount === 0) { + addedText = <>No software added.; + buttonText = "Add software"; + } else { + addedText = ( + <>{installDuringSetupCount} software will be installed during setup. + ); + buttonText = "Show selected software"; + } + + return ( +
+
+

+ Install software on hosts that automatically enroll to Fleet. +

+ +
+ {addedText} +
+ +
+
+ ); +}; + +export default AddInstallSoftware; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/_styles.scss new file mode 100644 index 000000000000..08bd4fdecaa9 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/_styles.scss @@ -0,0 +1,17 @@ +.add-install-software { + display: flex; + flex-direction: column; + gap: $pad-large; + + &__description { + margin: 0; + } + + &__added-text { + font-size: $x-small; + } + + &__added-text { + font-weight: $bold; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/index.ts new file mode 100644 index 000000000000..3ba35f7ecb39 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/AddInstallSoftware/index.ts @@ -0,0 +1 @@ +export { default } from "./AddInstallSoftware"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/InstallSoftwarePreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/InstallSoftwarePreview.tsx new file mode 100644 index 000000000000..f2ac78cdd1a3 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/InstallSoftwarePreview.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import Card from "components/Card"; + +import InstallSoftwarePreviewImg from "../../../../../../../../assets/images/install-software-preview.png"; + +const baseClass = "install-software-preview"; + +const InstallSoftwarePreview = () => { + return ( + +

End user experience

+

+ After the Remote Management screen, the end user will see + software being installed. They will not be able to continue until + software is installed. +

+

+ If there are any errors, they will be able to continue and will be + instructed to contact their IT admin. +

+ install software preview +
+ ); +}; + +export default InstallSoftwarePreview; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/_styles.scss new file mode 100644 index 000000000000..e40ff232399b --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/_styles.scss @@ -0,0 +1,13 @@ +.install-software-preview { + h3 { + margin: 0; + font-size: $small; + font-weight: normal; + } + + &__preview-img { + margin-top: $pad-xxlarge; + width: 100%; + display: block; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/index.ts new file mode 100644 index 000000000000..7042c17761bb --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwarePreview/index.ts @@ -0,0 +1 @@ +export { default } from "./InstallSoftwarePreview"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/SelectSoftwareModal.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/SelectSoftwareModal.tsx new file mode 100644 index 000000000000..cb3aafe0b55d --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/SelectSoftwareModal.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useContext, useMemo, useState } from "react"; + +import { ISoftwareTitle } from "interfaces/software"; +import { NotificationContext } from "context/notification"; +import mdmAPI from "services/entities/mdm"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; + +import SelectSoftwareTable from "../SelectSoftwareTable"; + +const baseClass = "select-software-modal"; + +const initializeSelectedSoftwareIds = (softwareTitles: ISoftwareTitle[]) => { + return softwareTitles.reduce((acc, software) => { + if ( + software.software_package?.install_during_setup || + software.app_store_app?.install_during_setup + ) { + acc.push(software.id); + } + return acc; + }, []); +}; + +interface ISelectSoftwareModalProps { + currentTeamId: number; + softwareTitles: ISoftwareTitle[]; + onExit: () => void; + onSave: () => void; +} + +const SelectSoftwareModal = ({ + currentTeamId, + softwareTitles, + onExit, + onSave, +}: ISelectSoftwareModalProps) => { + const { renderFlash } = useContext(NotificationContext); + + const initalSelectedSoftware = useMemo( + () => initializeSelectedSoftwareIds(softwareTitles), + [softwareTitles] + ); + const [isSaving, setIsSaving] = useState(false); + const [selectedSoftwareIds, setSelectedSoftwareIds] = useState( + initalSelectedSoftware + ); + + const onSaveSelectedSoftware = async () => { + setIsSaving(true); + try { + await mdmAPI.updateSetupExperienceSoftware( + currentTeamId, + selectedSoftwareIds + ); + } catch (e) { + console.log("error"); + renderFlash("error", "Couldn't save software. Please try again."); + } + setIsSaving(false); + onSave(); + }; + + const onChangeSoftwareSelect = useCallback((select: boolean, id: number) => { + setSelectedSoftwareIds((prevSelectedSoftwareIds) => { + if (select) { + return [...prevSelectedSoftwareIds, id]; + } + return prevSelectedSoftwareIds.filter((selectedId) => selectedId !== id); + }); + }, []); + + const onChangeSelectAll = useCallback( + (selectAll: boolean) => { + setSelectedSoftwareIds(selectAll ? softwareTitles.map((s) => s.id) : []); + }, + [softwareTitles] + ); + + return ( + + <> + +
+ + +
+ +
+ ); +}; + +export default SelectSoftwareModal; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/_styles.scss new file mode 100644 index 000000000000..baa8f48114c5 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/_styles.scss @@ -0,0 +1,3 @@ +.select-software-modal { + +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/index.ts new file mode 100644 index 000000000000..c85241224598 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SelectSoftwareModal"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/SelectSoftwareTable.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/SelectSoftwareTable.tsx new file mode 100644 index 000000000000..c7958dd0f188 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/SelectSoftwareTable.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useMemo } from "react"; + +import { ISoftwareTitle } from "interfaces/software"; + +import TableContainer from "components/TableContainer"; +import EmptyTable from "components/EmptyTable"; +import TableCount from "components/TableContainer/TableCount"; + +import generateTableConfig from "./SelectSoftwareTableConfig"; + +const baseClass = "select-software-table"; + +const generateSelectedRows = (softwareTitles: ISoftwareTitle[]) => { + return softwareTitles.reduce>((acc, software, i) => { + if ( + software.software_package?.install_during_setup || + software.app_store_app?.install_during_setup + ) { + acc[i] = true; + } + return acc; + }, {}); +}; + +interface ISelectSoftwareTableProps { + softwareTitles: ISoftwareTitle[]; + onChangeSoftwareSelect: (select: boolean, id: number) => void; + onChangeSelectAll: (selectAll: boolean) => void; +} + +const SelectSoftwareTable = ({ + softwareTitles, + onChangeSoftwareSelect, + onChangeSelectAll, +}: ISelectSoftwareTableProps) => { + const tabelConfig = useMemo(() => { + return generateTableConfig(onChangeSelectAll, onChangeSoftwareSelect); + }, [onChangeSelectAll, onChangeSoftwareSelect]); + + const initialSelectedSoftwareRows = useMemo(() => { + return generateSelectedRows(softwareTitles); + }, [softwareTitles]); + + const renderCount = useCallback(() => { + if (softwareTitles.length === 0) { + return <>; + } + + return ; + }, [softwareTitles]); + + return ( + ( + + )} + renderCount={renderCount} + defaultSelectedRows={initialSelectedSoftwareRows} + showMarkAllPages + isAllPagesSelected={false} + persistSelectedRows + disablePagination + searchable + searchQueryColumn="name" + isClientSideFilter + onClearSelection={() => onChangeSelectAll(false)} + /> + ); +}; + +export default SelectSoftwareTable; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/SelectSoftwareTableConfig.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/SelectSoftwareTableConfig.tsx new file mode 100644 index 000000000000..85e5a996c3df --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/SelectSoftwareTableConfig.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { CellProps, Column } from "react-table"; + +import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config"; +import { ISoftwareTitle } from "interfaces/software"; +import { APPLE_PLATFORM_DISPLAY_NAMES } from "interfaces/platform"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell"; +import Checkbox from "components/forms/fields/Checkbox"; + +export interface EnhancedSoftwareTitle extends ISoftwareTitle { + isSelected: boolean; +} + +type ISelectSoftwareTableConfig = Column; +type ITableHeaderProps = IHeaderProps; +type ITableStringCellProps = IStringCellProps; +type ISelectionCellProps = CellProps; + +const generateTableConfig = ( + onSelectAll: (selectAll: boolean) => void, + onSelectSoftware: (select: boolean, id: number) => void +): ISelectSoftwareTableConfig[] => { + const headerConfigs: ISelectSoftwareTableConfig[] = [ + { + id: "selection", + disableSortBy: true, + Header: (cellProps: ITableHeaderProps) => { + const { + checked, + indeterminate, + } = cellProps.getToggleAllRowsSelectedProps(); + + const checkboxProps = { + value: checked, + indeterminate, + onChange: () => { + onSelectAll(!checked); + cellProps.toggleAllRowsSelected(); + }, + }; + return ; + }, + Cell: (cellProps: ISelectionCellProps) => { + const { checked } = cellProps.row.getToggleRowSelectedProps(); + const checkboxProps = { + value: checked, + onChange: () => { + onSelectSoftware(!checked, cellProps.row.original.id); + cellProps.row.toggleRowSelected(); + }, + }; + return ; + }, + }, + { + Header: "Name", + disableSortBy: true, + accessor: "name", + Cell: (cellProps: ITableStringCellProps) => { + const { name, source, app_store_app } = cellProps.row.original; + + const url = app_store_app?.icon_url; + + return ; + }, + sortType: "caseInsensitive", + }, + { + Header: "Platform", + disableSortBy: true, + accessor: "source", + Cell: (cellProps: ITableStringCellProps) => ( + // TODO: this will need to be updated when we add support for other platforms + + ), + sortType: "caseInsensitive", + }, + ]; + + return headerConfigs; +}; + +export default generateTableConfig; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/_styles.scss new file mode 100644 index 000000000000..2ae06f06437a --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/_styles.scss @@ -0,0 +1,7 @@ +.select-software-table { + + .data-table.data-table__wrapper { + max-height: 343px; + overflow-y: auto; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/index.ts new file mode 100644 index 000000000000..14b7bc4cb3ec --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/SelectSoftwareTable/index.ts @@ -0,0 +1 @@ +export { default } from "./SelectSoftwareTable"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/index.ts new file mode 100644 index 000000000000..16e791c512f1 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/index.ts @@ -0,0 +1 @@ +export { default } from "./InstallSoftware"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx index f670a8e710ab..fc6072abbc5f 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx @@ -17,7 +17,7 @@ import CustomLink from "components/CustomLink"; import SetupAssistantPreview from "./components/SetupAssistantPreview"; import SetupAssistantProfileUploader from "./components/SetupAssistantProfileUploader"; -import SetuAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard"; +import SetupAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard"; import DeleteAutoEnrollmentProfile from "./components/DeleteAutoEnrollmentProfile"; import AdvancedOptionsForm from "./components/AdvancedOptionsForm"; @@ -112,7 +112,7 @@ const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => { onUpload={onUpload} /> ) : ( - setShowDeleteProfileModal(true)} /> diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx index f9707579d524..8e65c5d6db16 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx @@ -9,7 +9,7 @@ const baseClass = "setup-assistant-preview"; const SetupAssistantPreview = () => { return ( -

End user experience

+

End user experience

After the end user continues past the Remote Management screen, macOS Setup Assistant displays several screens by default. diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss index 07f7e9b79782..ba465dd47bb3 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss @@ -1,16 +1,16 @@ .setup-assistant-preview { font-size: $x-small; - h2 { + h3 { margin: 0; font-size: $small; font-weight: normal; } &__preview-img { - margin-top: $pad-xxlarge; width: 100%; display: block; - margin: 40px auto 0; + margin: $pad-xxlarge auto 0; + border-radius: $border-radius-large; } } diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tests.tsx new file mode 100644 index 000000000000..33bda27b9d45 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tests.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import mockServer from "test/mock-server"; +import { createCustomRenderer } from "test/test-utils"; +import { + defaultSetupExperienceScriptHandler, + errorNoSetupExperienceScript, +} from "test/handlers/setup-experience-handlers"; + +import SetupExperienceScript from "./SetupExperienceScript"; + +describe("SetupExperienceScript", () => { + it("should render the script uploader when no script has been uploaded", async () => { + mockServer.use(errorNoSetupExperienceScript); + const render = createCustomRenderer({ withBackendMock: true }); + + render(); + + expect(await screen.findByRole("button", { name: "Upload" })).toBeVisible(); + }); + + it("should render the uploaded script uploader when a script has been uploaded", async () => { + mockServer.use(defaultSetupExperienceScriptHandler); + const render = createCustomRenderer({ withBackendMock: true }); + + render(); + + expect( + await screen.findByText("Script will run during setup:") + ).toBeVisible(); + expect(await screen.findByText("Test Script.sh")).toBeVisible(); + }); +}); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx new file mode 100644 index 000000000000..46dbc4696ce8 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/SetupExperienceScript.tsx @@ -0,0 +1,116 @@ +import React, { useState } from "react"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import mdmAPI, { + IGetSetupExperienceScriptResponse, +} from "services/entities/mdm"; + +import SectionHeader from "components/SectionHeader"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; + +import CustomLink from "components/CustomLink"; + +import SetupExperiencePreview from "./components/SetupExperienceScriptPreview"; +import SetupExperienceScriptUploader from "./components/SetupExperienceScriptUploader"; +import SetupExperienceScriptCard from "./components/SetupExperienceScriptCard"; +import DeleteSetupExperienceScriptModal from "./components/DeleteSetupExperienceScriptModal"; + +const baseClass = "setup-experience-script"; + +interface ISetupExperienceScriptProps { + currentTeamId: number; +} + +const SetupExperienceScript = ({ + currentTeamId, +}: ISetupExperienceScriptProps) => { + const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false); + + const { + data: script, + error: scriptError, + isLoading, + isError, + refetch: refetchScript, + remove: removeScriptFromCache, + } = useQuery( + ["setup-experience-script", currentTeamId], + () => mdmAPI.getSetupExperienceScript(currentTeamId), + { ...DEFAULT_USE_QUERY_OPTIONS, retry: false } + ); + + const onUpload = () => { + refetchScript(); + }; + + const onDelete = () => { + removeScriptFromCache(); + setShowDeleteScriptModal(false); + refetchScript(); + }; + + const scriptUploaded = true; + + const renderContent = () => { + if (isLoading) { + ; + } + + if (isError && scriptError.status !== 404) { + return ; + } + + return ( +

+
+

+ Upload a script to run on hosts that automatically enroll to Fleet. +

+ + {!scriptUploaded || !script ? ( + + ) : ( + <> +

+ Script will run during setup: +

+ setShowDeleteScriptModal(true)} + /> + + )} +
+ +
+ ); + }; + + return ( +
+ + <>{renderContent()} + {showDeleteScriptModal && script && ( + setShowDeleteScriptModal(false)} + /> + )} +
+ ); +}; + +export default SetupExperienceScript; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss new file mode 100644 index 000000000000..46d6c04ede05 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/_styles.scss @@ -0,0 +1,22 @@ +.setup-experience-script { + &__content { + max-width: $break-xxl; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $pad-xxlarge; + } + + &__description { + margin: 0; + } + + &__learn-how-link { + margin-bottom: $pad-large; + } + + &__run-message { + margin: 0 0 $pad-small; + font-weight: $bold; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx new file mode 100644 index 000000000000..3097a1992fd8 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx @@ -0,0 +1,60 @@ +import React, { useContext } from "react"; + +import mdmAPI from "services/entities/mdm"; +import { NotificationContext } from "context/notification"; + +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; + +const baseClass = "delete-setup-experience-script-modal"; + +interface IDeleteSetupExperienceScriptModalProps { + currentTeamId: number; + scriptName: string; + onExit: () => void; + onDeleted: () => void; +} + +const DeleteSetupExperienceScriptModal = ({ + currentTeamId, + scriptName, + onExit, + onDeleted, +}: IDeleteSetupExperienceScriptModalProps) => { + const { renderFlash } = useContext(NotificationContext); + + const onDelete = async () => { + try { + await mdmAPI.deleteSetupExperienceScript(currentTeamId); + renderFlash("success", "Setup script successfully deleted!"); + } catch (error) { + renderFlash( + "error", + "Couldn't delete the setup script. Please try again." + ); + console.error(error); + } + + onDeleted(); + }; + + return ( + + <> +

+ The script {scriptName} will still run on pending hosts. +

+
+ + +
+ +
+ ); +}; + +export default DeleteSetupExperienceScriptModal; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts new file mode 100644 index 000000000000..f608071e62f5 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/DeleteSetupExperienceScriptModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteSetupExperienceScriptModal"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx new file mode 100644 index 000000000000..9c83a3c5379a --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx @@ -0,0 +1,75 @@ +import React, { useContext } from "react"; +import FileSaver from "file-saver"; + +import mdmAPI, { + IGetSetupExperienceScriptResponse, +} from "services/entities/mdm"; + +import { uploadedFromNow } from "utilities/date_format"; + +import Button from "components/buttons/Button"; +import Card from "components/Card"; +import Graphic from "components/Graphic"; +import Icon from "components/Icon"; +import { NotificationContext } from "context/notification"; +import { API_NO_TEAM_ID } from "interfaces/team"; + +const baseClass = "setup-experience-script-card"; + +interface ISetupExperienceScriptCardProps { + script: IGetSetupExperienceScriptResponse; + onDelete: () => void; +} + +const SetupExperienceScriptCard = ({ + script, + onDelete, +}: ISetupExperienceScriptCardProps) => { + const { renderFlash } = useContext(NotificationContext); + + const onDownload = async () => { + try { + const teamId = script.team_id ?? API_NO_TEAM_ID; + const data = await mdmAPI.downloadSetupExperienceScript(teamId); + const date = new Date(); + const filename = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${ + script.name + }`; + const file = new global.window.File([data], filename); + + FileSaver.saveAs(file); + } catch (e) { + renderFlash("error", "Couldn't download script. Please try again."); + } + }; + + return ( + + +
+ {script.name} + + {uploadedFromNow(script.created_at)} + +
+
+ + +
+
+ ); +}; + +export default SetupExperienceScriptCard; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss new file mode 100644 index 000000000000..902afafabb0b --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/_styles.scss @@ -0,0 +1,32 @@ +.setup-experience-script-card { + display: flex; + gap: $pad-medium; + align-items: center; + + // TODO: create reusable list item component and use instead of all these styles. + &__info { + display: flex; + flex-direction: column; + } + + &__profile-name { + font-size: $x-small; + font-weight: $bold; + } + + &__uploaded-at { + font-size: $xx-small; + } + + &__actions { + display: flex; + gap: $pad-medium; + flex: 1; + justify-content: flex-end; + } + + &__download-button, + &__delete-button { + padding: 11px; // TODO: use a padding value from existing variables. talk to design. + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts new file mode 100644 index 000000000000..c7e125f47fa8 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptCard/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScriptCard"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx new file mode 100644 index 000000000000..ea05cdd386f2 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/SetupExperienceScriptPreview.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import Card from "components/Card"; + +import InstallSoftwarePreviewImg from "../../../../../../../../assets/images/install-software-preview.png"; + +const baseClass = "setup-experience-script-preview"; + +const SetupExperienceScriptPreview = () => { + return ( + +

End user experience

+

+ After software is installed, the end user will see the script being run. + They will not be able to continue until the script runs. +

+

+ If there are any errors, they will be able to continue and will be + instructed to contact their IT admin. +

+ install software preview +
+ ); +}; + +export default SetupExperienceScriptPreview; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss new file mode 100644 index 000000000000..b2fc9958164f --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/_styles.scss @@ -0,0 +1,13 @@ +.setup-experience-script-preview { + h3 { + margin: 0; + font-size: $small; + font-weight: normal; + } + + &__preview-img { + margin-top: $pad-xxlarge; + width: 100%; + display: block; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts new file mode 100644 index 000000000000..8cf535c231f0 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptPreview/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScriptPreview"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx new file mode 100644 index 000000000000..91a6c9a28efc --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx @@ -0,0 +1,63 @@ +import React, { useContext, useState } from "react"; +import classnames from "classnames"; + +import mdmAPI from "services/entities/mdm"; + +import { NotificationContext } from "context/notification"; +import FileUploader from "components/FileUploader"; +import { getErrorReason } from "interfaces/errors"; + +const baseClass = "setup-experience-script-uploader"; + +interface ISetupExperienceScriptUploaderProps { + currentTeamId: number; + onUpload: () => void; + className?: string; +} + +const SetupExperienceScriptUploader = ({ + currentTeamId, + onUpload, + className, +}: ISetupExperienceScriptUploaderProps) => { + const { renderFlash } = useContext(NotificationContext); + const [showLoading, setShowLoading] = useState(false); + + const classNames = classnames(baseClass, className); + + const onUploadFile = async (files: FileList | null) => { + setShowLoading(true); + + if (!files || files.length === 0) { + setShowLoading(false); + return; + } + + const file = files[0]; + + try { + await mdmAPI.uploadSetupExperienceScript(file, currentTeamId); + renderFlash("success", "Successfully uploaded!"); + onUpload(); + } catch (e) { + // TODO: what errors? + renderFlash("error", getErrorReason(e)); + } + + setShowLoading(false); + }; + + return ( + + ); +}; + +export default SetupExperienceScriptUploader; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts new file mode 100644 index 000000000000..e4bd667f020f --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/components/SetupExperienceScriptUploader/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScriptUploader"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts new file mode 100644 index 000000000000..143ecd15ab90 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupExperienceScript/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupExperienceScript"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx index fa75f1d5541d..3888638cad46 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx @@ -12,6 +12,9 @@ const baseClass = "delete-software-modal"; const DELETE_SW_USED_BY_POLICY_ERROR_MSG = "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."; +const DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG = + "Couldn't delete. This software is installed when new Macs boot. Please remove software in Controls > Setup experience and try again."; + interface IDeleteSoftwareModalProps { softwareId: number; teamId: number; @@ -36,6 +39,8 @@ const DeleteSoftwareModal = ({ const reason = getErrorReason(error); if (reason.includes("Policy automation uses this software")) { renderFlash("error", DELETE_SW_USED_BY_POLICY_ERROR_MSG); + } else if (reason.includes("This software is installed when")) { + renderFlash("error", DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG); } else { renderFlash("error", "Couldn't delete. Please try again."); } diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 15c9ab99c8a5..d7c4f9c4dd8a 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -17,6 +17,8 @@ export default { CONTROLS_END_USER_AUTHENTICATION: `${URL_PREFIX}/controls/setup-experience/end-user-auth`, CONTROLS_BOOTSTRAP_PACKAGE: `${URL_PREFIX}/controls/setup-experience/bootstrap-package`, CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`, + CONTROLS_INSTALL_SOFTWARE: `${URL_PREFIX}/controls/setup-experience/install-software`, + CONTROLS_RUN_SCRIPT: `${URL_PREFIX}/controls/setup-experience/run-script`, CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`, // Dashboard pages diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index c851bc7b7f72..5b4d5b3fd49c 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { createMockMdmProfile } from "__mocks__/mdmMock"; import { DiskEncryptionStatus, IHostMdmProfile, @@ -11,6 +9,7 @@ import { API_NO_TEAM_ID } from "interfaces/team"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; +import { ISoftwareTitlesResponse } from "./software"; export interface IEulaMetadataResponse { name: string; @@ -82,6 +81,21 @@ export interface IGetMdmCommandResultsResponse { results: IMdmCommandResult[]; } +export interface IGetSetupExperienceScriptResponse { + id: number; + team_id: number | null; // The API return null for no team in this case. + name: string; + created_at: string; + updated_at: string; +} + +interface IGetSetupExperienceSoftwareParams { + team_id: number; + per_page: number; +} + +export type IGetSetupExperienceSoftwareResponse = ISoftwareTitlesResponse; + const mdmService = { unenrollHostFromMdm: (hostId: number, timeout?: number) => { const { HOST_MDM_UNENROLL } = endpoints; @@ -276,6 +290,7 @@ const mdmService = { return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body); }, + getSetupEnrollmentProfile: (teamId?: number) => { const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; if (!teamId || teamId === API_NO_TEAM_ID) { @@ -287,6 +302,7 @@ const mdmService = { )}`; return sendRequest("GET", path); }, + uploadSetupEnrollmentProfile: (file: File, teamId: number) => { const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; @@ -313,6 +329,7 @@ const mdmService = { }); }); }, + deleteSetupEnrollmentProfile: (teamId: number) => { const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; if (teamId === API_NO_TEAM_ID) { @@ -324,6 +341,7 @@ const mdmService = { )}`; return sendRequest("DELETE", path); }, + getCommandResults: ( command_uuid: string ): Promise => { @@ -341,6 +359,83 @@ const mdmService = { "blob" ); }, + + getSetupExperienceSoftware: ( + params: IGetSetupExperienceSoftwareParams + ): Promise => { + const { MDM_SETUP_EXPERIENCE_SOFTWARE } = endpoints; + + const path = `${MDM_SETUP_EXPERIENCE_SOFTWARE}?${buildQueryStringFromParams( + { + ...params, + } + )}`; + + return sendRequest("GET", path); + }, + + updateSetupExperienceSoftware: ( + teamId: number, + softwareTitlesIds: number[] + ) => { + const { MDM_SETUP_EXPERIENCE_SOFTWARE } = endpoints; + + const path = `${MDM_SETUP_EXPERIENCE_SOFTWARE}?${buildQueryStringFromParams( + { + team_id: teamId, + } + )}`; + + return sendRequest("PUT", path, { + team_id: teamId, + software_title_ids: softwareTitlesIds, + }); + }, + + getSetupExperienceScript: ( + teamId: number + ): Promise => { + const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints; + + let path = MDM_SETUP_EXPERIENCE_SCRIPT; + if (teamId) { + path += `?${buildQueryStringFromParams({ team_id: teamId })}`; + } + + return sendRequest("GET", path); + }, + + downloadSetupExperienceScript: (teamId: number) => { + const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints; + + let path = MDM_SETUP_EXPERIENCE_SCRIPT; + path += `?${buildQueryStringFromParams({ team_id: teamId, alt: "media" })}`; + + return sendRequest("GET", path); + }, + + uploadSetupExperienceScript: (file: File, teamId: number) => { + const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints; + + const formData = new FormData(); + formData.append("script", file); + + if (teamId) { + formData.append("team_id", teamId.toString()); + } + + return sendRequest("POST", MDM_SETUP_EXPERIENCE_SCRIPT, formData); + }, + + deleteSetupExperienceScript: (teamId: number) => { + const { MDM_SETUP_EXPERIENCE_SCRIPT } = endpoints; + + const path = `${MDM_SETUP_EXPERIENCE_SCRIPT}?${buildQueryStringFromParams({ + team_id: teamId, + })}`; + + return sendRequest("DELETE", path); + }, }; export default mdmService; diff --git a/frontend/services/index.ts b/frontend/services/index.ts index a395d29b9191..8f850008a805 100644 --- a/frontend/services/index.ts +++ b/frontend/services/index.ts @@ -68,7 +68,7 @@ export const sendRequestWithProgress = async ({ }; export const sendRequest = async ( - method: "GET" | "POST" | "PATCH" | "DELETE" | "HEAD", + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD", path: string, data?: unknown, responseType: AxiosResponseType = "json", diff --git a/frontend/test/handlers/apple_mdm.ts b/frontend/test/handlers/apple_mdm.ts index 5f8be1c3764d..648a8eb64644 100644 --- a/frontend/test/handlers/apple_mdm.ts +++ b/frontend/test/handlers/apple_mdm.ts @@ -3,7 +3,6 @@ import { rest } from "msw"; import { createMockVppInfo } from "__mocks__/appleMdm"; import { baseUrl } from "test/test-utils"; -// eslint-disable-next-line import/prefer-default-export export const defaultVppInfoHandler = rest.get( baseUrl("/vpp"), (req, res, context) => { diff --git a/frontend/test/handlers/setup-experience-handlers.ts b/frontend/test/handlers/setup-experience-handlers.ts new file mode 100644 index 000000000000..23a1b8372469 --- /dev/null +++ b/frontend/test/handlers/setup-experience-handlers.ts @@ -0,0 +1,20 @@ +import { rest } from "msw"; + +import { baseUrl } from "test/test-utils"; +import { createMockSetupExperienceScript } from "__mocks__/setupExperienceMock"; + +const setupExperienceScriptUrl = baseUrl("/setup_experience/script"); + +export const defaultSetupExperienceScriptHandler = rest.get( + setupExperienceScriptUrl, + (req, res, context) => { + return res(context.json(createMockSetupExperienceScript())); + } +); + +export const errorNoSetupExperienceScript = rest.get( + setupExperienceScriptUrl, + (req, res, context) => { + return res(context.status(404)); + } +); diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 5833a486539c..bb6b3dea65b9 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -127,7 +127,6 @@ export default { MDM_BOOTSTRAP_PACKAGE: `/${API_VERSION}/fleet/mdm/bootstrap`, MDM_BOOTSTRAP_PACKAGE_SUMMARY: `/${API_VERSION}/fleet/mdm/bootstrap/summary`, MDM_SETUP: `/${API_VERSION}/fleet/mdm/apple/setup`, - MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`, MDM_EULA: (token: string) => `/${API_VERSION}/fleet/mdm/setup/eula/${token}`, MDM_EULA_UPLOAD: `/${API_VERSION}/fleet/mdm/setup/eula`, MDM_EULA_METADATA: `/${API_VERSION}/fleet/mdm/setup/eula/metadata`, @@ -139,6 +138,11 @@ export default { ME: `/${API_VERSION}/fleet/me`, + // Setup experiece endpoints + MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`, + MDM_SETUP_EXPERIENCE_SOFTWARE: `/${API_VERSION}/fleet/setup_experience/software`, + MDM_SETUP_EXPERIENCE_SCRIPT: `/${API_VERSION}/fleet/setup_experience/script`, + // OS Version endpoints OS_VERSIONS: `/${API_VERSION}/fleet/os_versions`, OS_VERSION: (id: number) => `/${API_VERSION}/fleet/os_versions/${id}`, diff --git a/orbit/changes/22383-swift-dialog b/orbit/changes/22383-swift-dialog new file mode 100644 index 000000000000..b7644a3ef8f3 --- /dev/null +++ b/orbit/changes/22383-swift-dialog @@ -0,0 +1,2 @@ +- Adds a UI for the Fleet setup experience to show users the status of software installs and script +executions during macOS Setup Assistant. \ No newline at end of file diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 5f44a7c2ba81..3edd3c159857 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -30,6 +30,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/osservice" "github.com/fleetdm/fleet/v4/orbit/pkg/platform" "github.com/fleetdm/fleet/v4/orbit/pkg/profiles" + setupexperience "github.com/fleetdm/fleet/v4/orbit/pkg/setup_experience" "github.com/fleetdm/fleet/v4/orbit/pkg/table" "github.com/fleetdm/fleet/v4/orbit/pkg/table/fleetd_logs" "github.com/fleetdm/fleet/v4/orbit/pkg/table/orbit_info" @@ -500,6 +501,7 @@ func main() { } targets := []string{"orbit", "osqueryd"} + if c.Bool("fleet-desktop") { targets = append(targets, "desktop") } @@ -857,7 +859,7 @@ func main() { ) scriptConfigReceiver, scriptsEnabledFn := update.ApplyRunScriptsConfigFetcherMiddleware( - c.Bool("enable-scripts"), orbitClient, + c.Bool("enable-scripts"), orbitClient, c.String("root-dir"), ) orbitClient.RegisterConfigReceiver(scriptConfigReceiver) @@ -869,7 +871,10 @@ func main() { orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{ UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval, })) + setupExperiencer := setupexperience.NewSetupExperiencer(orbitClient, c.String("root-dir")) + orbitClient.RegisterConfigReceiver(setupExperiencer) orbitClient.RegisterConfigReceiver(update.ApplySwiftDialogDownloaderMiddleware(updateRunner)) + case "windows": orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMEnrollmentFetcherMiddleware(windowsMDMEnrollmentCommandFrequency, orbitHostInfo.HardwareUUID, orbitClient)) orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMBitlockerFetcherMiddleware(windowsMDMBitlockerCommandFrequency, orbitClient)) @@ -1213,7 +1218,7 @@ func main() { } } - softwareRunner := installer.NewRunner(orbitClient, r.ExtensionSocketPath(), scriptsEnabledFn) + softwareRunner := installer.NewRunner(orbitClient, r.ExtensionSocketPath(), scriptsEnabledFn, c.String("root-dir")) orbitClient.RegisterConfigReceiver(softwareRunner) if runtime.GOOS == "darwin" { diff --git a/orbit/pkg/installer/installer.go b/orbit/pkg/installer/installer.go index e0faf63c0fdf..d580ec46beec 100644 --- a/orbit/pkg/installer/installer.go +++ b/orbit/pkg/installer/installer.go @@ -14,6 +14,7 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/orbit/pkg/scripts" + "github.com/fleetdm/fleet/v4/orbit/pkg/update" "github.com/fleetdm/fleet/v4/pkg/file" pkgscripts "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server/fleet" @@ -70,20 +71,30 @@ type Runner struct { scriptsEnabled func() bool osqueryConnectionMutex sync.Mutex + + rootDirPath string } -func NewRunner(client Client, socketPath string, scriptsEnabled func() bool) *Runner { +func NewRunner(client Client, socketPath string, scriptsEnabled func() bool, rootDirPath string) *Runner { r := &Runner{ OrbitClient: client, osquerySocketPath: socketPath, scriptsEnabled: scriptsEnabled, installerExecutionTimeout: pkgscripts.MaxHostSoftwareInstallExecutionTime, + rootDirPath: rootDirPath, } return r } func (r *Runner) Run(config *fleet.OrbitConfig) error { + if runtime.GOOS == "darwin" { + if config.Notifications.RunSetupExperience && !update.CanRun(r.rootDirPath, "swiftDialog", update.SwiftDialogMacOSTarget) { + log.Debug().Msg("exiting software installer config runner early during setup experience: swiftDialog is not installed") + return nil + } + } + connectOsqueryFn := r.connectOsquery if connectOsqueryFn == nil { connectOsqueryFn = connectOsquery diff --git a/orbit/pkg/setup_experience/setup_experience.go b/orbit/pkg/setup_experience/setup_experience.go new file mode 100644 index 000000000000..bc69884f4c76 --- /dev/null +++ b/orbit/pkg/setup_experience/setup_experience.go @@ -0,0 +1,295 @@ +package setupexperience + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/fleetdm/fleet/v4/orbit/pkg/swiftdialog" + "github.com/fleetdm/fleet/v4/orbit/pkg/update" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/rs/zerolog/log" +) + +const doneMessage = `### Setup is complete\n\nPlease contact your IT Administrator if there were any errors.` + +// Client is the minimal interface needed to communicate with the Fleet server. +type Client interface { + GetSetupExperienceStatus() (*fleet.SetupExperienceStatusPayload, error) +} + +// SetupExperiencer is the type that manages the Fleet setup experience flow during macOS Setup +// Assistant. It uses swiftDialog as a UI for showing the status of software installations and +// script execution that are configured to run before the user has full access to the device. +// If the setup experience is supposed to run, it will launch a single swiftDialog instance and then +// update that instance based on the results from the /orbit/setup_experience/status endpoint. +type SetupExperiencer struct { + OrbitClient Client + closeChan chan struct{} + rootDirPath string + // Note: this object is not safe for concurrent use. Since the SetupExperiencer is a singleton, + // its Run method is called within a WaitGroup, + // and no other parts of Orbit need access to this field (or any other parts of the + // SetupExperiencer), it's OK to not protect this with a lock. + sd *swiftdialog.SwiftDialog + // Name of each step -> is that step done + steps map[string]bool + started bool +} + +func NewSetupExperiencer(client Client, rootDirPath string) *SetupExperiencer { + return &SetupExperiencer{ + OrbitClient: client, + closeChan: make(chan struct{}), + steps: make(map[string]bool), + rootDirPath: rootDirPath, + } +} + +func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error { + if !oc.Notifications.RunSetupExperience { + log.Debug().Msg("skipping setup experience: notification flag is not set") + return nil + } + + _, binaryPath, _ := update.LocalTargetPaths( + s.rootDirPath, + "swiftDialog", + update.SwiftDialogMacOSTarget, + ) + + if _, err := os.Stat(binaryPath); err != nil { + log.Debug().Msg("skipping setup experience: swiftDialog is not installed") + return nil + } + + // Poll the status endpoint. This also releases the device if we're done. + payload, err := s.OrbitClient.GetSetupExperienceStatus() + if err != nil { + return err + } + + // If swiftDialog isn't up yet, then launch it + orgLogo := payload.OrgLogoURL + if orgLogo == "" { + orgLogo = "https://fleetdm.com/images/permanent/fleet-mark-color-40x40@4x.png" + } + + if err := s.startSwiftDialog(binaryPath, orgLogo); err != nil { + return err + } + + // Defer this so that s.started is only false the first time this function runs. + defer func() { s.started = true }() + + select { + case <-s.closeChan: + log.Debug().Str("receiver", "setup_experiencer").Msg("swiftDialog closed") + return nil + default: + // ok + } + + // We're rendering the initial loading UI (shown while there are still profiles, bootstrap package, + // and account configuration to verify) right off the bat, so we can just no-op if any of those + // are not terminal + + if payload.BootstrapPackage != nil { + if payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageFailed && payload.BootstrapPackage.Status != fleet.MDMBootstrapPackageInstalled { + return nil + } + } + + s.steps["bootstrap"] = true + + if anyProfilePending(payload.ConfigurationProfiles) { + return nil + } + + s.steps["config_profiles"] = true + + if payload.AccountConfiguration != nil { + if payload.AccountConfiguration.Status != fleet.MDMAppleStatusAcknowledged && + payload.AccountConfiguration.Status != fleet.MDMAppleStatusError && + payload.AccountConfiguration.Status != fleet.MDMAppleStatusCommandFormatError { + return nil + } + } + + s.steps["account_config"] = true + + // Now render the UI for the software and script. + if len(payload.Software) > 0 || payload.Script != nil { + var stepsDone int + var prog uint + var steps []*fleet.SetupExperienceStatusResult + if len(payload.Software) > 0 { + steps = payload.Software + } + + if payload.Script != nil { + steps = append(steps, payload.Script) + } + + for _, step := range steps { + item := resultToListItem(step) + if _, ok := s.steps[step.Name]; ok { + err = s.sd.UpdateListItemByTitle(item.Title, item.StatusText, item.Status) + if err != nil { + log.Info().Err(err).Msg("updating list item in setup experience UI") + } + } else { + err = s.sd.AddListItem(item) + if err != nil { + log.Info().Err(err).Msg("adding list item in setup experience UI") + } + s.steps[step.Name] = false + } + + if step.Status == fleet.SetupExperienceStatusFailure || step.Status == fleet.SetupExperienceStatusSuccess { + stepsDone++ + s.steps[step.Name] = true + // The swiftDialog progress bar is out of 100 + for range int(float32(1) / float32(len(steps)) * 100) { + prog++ + } + } + } + + if err = s.sd.UpdateProgress(prog); err != nil { + log.Info().Err(err).Msg("updating progress bar in setup experience UI") + } + + if err := s.sd.ShowList(); err != nil { + log.Info().Err(err).Msg("showing progress bar in setup experience UI") + } + + if err := s.sd.UpdateProgressText(fmt.Sprintf("%.0f%%", float32(stepsDone)/float32(len(steps))*100)); err != nil { + log.Info().Err(err).Msg("updating progress text in setup experience UI") + } + + } + + // If we get here, we can render the "done" UI. + + if s.allStepsDone() { + if err := s.sd.SetMessage(doneMessage); err != nil { + log.Info().Err(err).Msg("setting message in setup experience UI") + } + + if err := s.sd.CompleteProgress(); err != nil { + log.Info().Err(err).Msg("completing progress bar in setup experience UI") + } + + if len(payload.Software) > 0 || payload.Script != nil { + // need to call this because SetMessage removes the list from the view for some reason :( + if err := s.sd.ShowList(); err != nil { + log.Info().Err(err).Msg("showing list in setup experience UI") + } + } + + if err := s.sd.UpdateProgressText("100%"); err != nil { + log.Info().Err(err).Msg("updating progress text in setup experience UI") + } + + if err := s.sd.EnableButton1(true); err != nil { + log.Info().Err(err).Msg("enabling close button in setup experience UI") + } + } + + return nil +} + +func (s *SetupExperiencer) allStepsDone() bool { + for _, done := range s.steps { + if !done { + return false + } + } + + return true +} + +func anyProfilePending(profiles []*fleet.SetupExperienceConfigurationProfileResult) bool { + for _, p := range profiles { + if p.Status == fleet.MDMDeliveryPending { + return true + } + } + + return false +} + +func (s *SetupExperiencer) startSwiftDialog(binaryPath, orgLogo string) error { + if s.started { + return nil + } + + created := make(chan struct{}) + swiftDialog, err := swiftdialog.Create(context.Background(), binaryPath) + if err != nil { + return errors.New("creating swiftDialog instance: %w") + } + s.sd = swiftDialog + go func() { + initOpts := &swiftdialog.SwiftDialogOptions{ + Title: "none", + Message: "### Setting up your Mac...\n\nYour Mac is being configured by your organization using Fleet. This process may take some time to complete. Please don't attempt to restart or shut down the computer unless prompted to do so.", + Icon: orgLogo, + MessageAlignment: swiftdialog.AlignmentCenter, + CentreIcon: true, + Height: "625", + Big: true, + ProgressText: "Configuring your device...", + Button1Text: "Close", + Button1Disabled: true, + } + + if err := s.sd.Start(context.Background(), initOpts); err != nil { + log.Error().Err(err).Msg("starting swiftDialog instance") + } + + if err = s.sd.ShowProgress(); err != nil { + log.Error().Err(err).Msg("setting initial setup experience progress") + } + + if err := s.sd.SetIconSize(80); err != nil { + log.Error().Err(err).Msg("setting initial setup experience icon size") + } + + log.Debug().Msg("swiftDialog process started") + created <- struct{}{} + + if _, err = s.sd.Wait(); err != nil { + log.Error().Err(err).Msg("swiftdialog.Wait failed") + } + + s.closeChan <- struct{}{} + }() + <-created + return nil +} + +func resultToListItem(result *fleet.SetupExperienceStatusResult) swiftdialog.ListItem { + statusText := "Pending" + status := swiftdialog.StatusWait + + switch result.Status { + case fleet.SetupExperienceStatusFailure: + status = swiftdialog.StatusFail + statusText = "Failed" + case fleet.SetupExperienceStatusSuccess: + status = swiftdialog.StatusSuccess + statusText = "Installed" + if result.IsForScript() { + statusText = "Ran" + } + } + + return swiftdialog.ListItem{ + Title: result.Name, + Status: status, + StatusText: statusText, + } +} diff --git a/orbit/pkg/swiftdialog/options.go b/orbit/pkg/swiftdialog/options.go new file mode 100644 index 000000000000..9a7a416481ae --- /dev/null +++ b/orbit/pkg/swiftdialog/options.go @@ -0,0 +1,313 @@ +package swiftdialog + +type SwiftDialogOptions struct { + // Set the Dialog title + Title string `json:"title,omitempty"` + // Text to use as subtitle when sending a system notification + Subtitle string `json:"subtitle,omitempty"` + // Set the dialog message + Message string `json:"message,omitempty"` + // Configure a pre-set window style + Style Style `json:"style,omitempty"` + // Set the message alignment + MessageAlignment Alignment `json:"messagealignment,omitempty"` + // Set the message position + MessagePosition Position `json:"messageposition,omitempty"` + // Enable help button with content + HelpMessage string `json:"helpmessage,omitempty"` + // Set the dialog icon, accepts file path, url, or builtin + // See https://github.com/swiftDialog/swiftDialog/wiki/Customising-the-Icon + Icon string `json:"icon"` + // Set the dialog icon size + IconSize uint `json:"iconsize,omitempty"` + // Set the dialog icon transparancy + IconAlpha uint `json:"iconalpha,omitempty"` + // Set an image to display as an overlay to Icon, accepts file path or url + OverlayIcon string + // Enable banner image, accepts file path or url + BannerImage string `json:"bannerimage,omitempty"` + // Enable title within banner area + BannerTitle string `json:"bannertitle,omitempty"` + // Set text to display in banner area + BannerText string `json:"bannertext,omitempty"` + // Set the label for Button1 + Button1Text string `json:"button1text,omitempty"` + // Set the Button1 action, accepts url + Button1Action string `json:"button1action,omitempty"` + // Displays Button2 with text + Button2Text string `json:"button2text,omitempty"` + // Custom Actions For Button 2 Is Not Implemented + Button2Action string `json:"button2action,omitempty"` + // Displays info button with text + InfoButtonText string `json:"infobuttontext,omitempty"` + // Set the info button action, accepts URL + InfoButtonAction string `json:"infobuttonaction,omitempty"` + // Configure how the button area is displayed + ButtonStyle ButtonStyle `json:"buttonstyle,omitempty"` + // Select Lists and Radio Buttons + SelectItems []SelectItems `json:"selectitems,omitempty"` + // Lets you modify the title text of the dialog + TitleFont string `json:"titlefont,omitempty"` + // Set the message font of the dialog + MessageFont string `json:"messagefont,omitempty"` + // Enable a textfield with the specified label + TextField []TextField `json:"textfield,omitempty"` + // Enable a checkbox with the specified label + Checkbox []Checkbox `json:"checkbox,omitempty"` + // Change the appearance of checkboxes + CheckboxStyle CheckboxStyle `json:"checkboxstyle,omitempty"` + // Enable countdown timer (in seconds) + Timer uint `json:"timer,omitempty"` + // Enable interactive progress bar + Progress uint `json:"progress,omitempty"` + // Enable the progress text + ProgressText string `json:"progresstext,omitempty"` + // Display an image + Image []Image `json:"image,omitempty"` + // Set dialog window width + Width uint `json:"width,omitempty"` + // Set dialog window height + Height string `json:"height,omitempty"` + // Set a dialog background image, accepts file path + Background string `json:"background,omitempty"` + // Set background image transparancy + BackgroundAlpha uint `json:"bgalpha,omitempty"` + // Set background image position + BackgroundPosition FullPosition `json:"bgposition,omitempty"` + // Set background image fill type + BackgroundFill BackgroundFill `json:"bgfill,omitempty"` + // Enable background image scaling + BackgroundScale BackgroundFill `json:"bgscale,omitempty"` + // Set dialog window position + Position FullPosition `json:"position,omitempty"` + // Set dialog window position offset + PositionOffset uint `json:"positionoffset,omitempty"` + // Display a video, accepts file path or url + Video string `json:"video,omitempty"` + // Display a caption underneath a video + VideoCaption string `json:"videocaption,omitempty"` + // Enable a list item with the specified label + ListItem []ListItem `json:"listitem,omitempty"` + // Set list style + ListStyle ListStyle `json:"liststyle,omitempty"` + // Display in place of info button + InfoText string `json:"infotext,omitempty"` + // Display in info box + InfoBox string `json:"infobox,omitempty"` + // Set dialog quit key + QuitKey string `json:"quitkey,omitempty"` + // Display a web page, accepts url + WebContent string `json:"webcontent,omitempty"` + // Use the specified authentication key to allow dialog to launch + Key string `json:"key,omitempty"` + // Generate a SHA256 value + Checksum string `json:"checksum,omitempty"` + // Open a file and display the contents as it is being written, accepts file path + DisplayLog string `json:"displaylog,omitempty"` + // Change the order in which some items are displayed, comma separated list + ViewOrder string `json:"vieworder,omitempty"` + // Set the preferred window appearance + Appearance Appearance `json:"appearance,omitempty"` + // Disable Button1 + Button1Disabled bool `json:"button1disabled,omitempty"` + // Disable Button2 + Button2Disabled bool `json:"button2disabled,omitempty"` + // Displays Button2 + Button2 bool `json:"button2,omitempty"` + // Displays info button + InfoButton bool `json:"infobutton,omitempty"` + // Print version string + Version string `json:"version,omitempty"` + // Hides the icon from view + HideIcon bool `json:"hideicon,omitempty"` + // Set icon to be in the centre + CentreIcon bool `json:"centreicon,omitempty"` + // Hide countdown timer if enabled + HideTimerBar bool `json:"hidetimerbar,omitempty"` + // Enable video autoplay + Autoplay bool `json:"autoplay,omitempty"` + // Blur screen content behind dialog window + BlurScreen bool `json:"blurscreen,omitempty"` + // Send a system notification + Notification string `json:"notification,omitempty"` + // Enable dialog to be moveable + Moveable bool `json:"moveable,omitempty"` + // Enable dialog to be always positioned on top of other windows + OnTop bool `json:"ontop,omitempty"` + // Enable 25% decrease in default window size + Small bool `json:"small,omitempty"` + // Enable 25% increase in default window size + Big bool `json:"big,omitempty"` + // Enable full screen view + Fullscreen bool `json:"fullscreen,omitempty"` + // Quit when info button is selected + QuitonInfo bool `json:"quitoninfo,omitempty"` + // Enable mini mode + Mini bool `json:"mini,omitempty"` + // Enable presentation mode + Presentation bool `json:"presentation,omitempty"` + // Enables window buttons [close,min,max] + WindowButtons string `json:"windowbuttons,omitempty"` + // Enable the dialog window to be resizable + Resizable *bool `json:"resizable,omitempty"` + // Enable the dialog window to appear on all screens + ShowOnAllScreens *bool `json:"showonallscreens,omitempty"` + // Enable the dialog window to be shown at login + LoginWindow bool `json:"loginwindow,omitempty"` + // Hides the default behaviour of Return ↵ and Esc ⎋ keys + HideDefaultKeyboardAction bool `json:"hidedefaultkeyboardaction,omitempty"` +} + +type Style string + +const ( + StylePresentation Style = "presentation" + StyleMini Style = "mini" + StyleCentered Style = "centered" + StyleAlert Style = "alert" + StyleCaution Style = "caution" + StyleWarning Style = "warning" +) + +type Alignment string + +const ( + AlignmentLeft Alignment = "left" + AlignmentCenter Alignment = "center" + AlignmentRight Alignment = "right" +) + +type Position string + +const ( + PositionTop Position = "top" + PositionCenter Position = "center" + PositionBottom Position = "bottom" +) + +type ButtonStyle string + +const ( + ButtonStyleCenter ButtonStyle = "center" + ButtonStyleStack ButtonStyle = "stack" +) + +type Checkbox struct { + Label string `json:"label"` + Checked bool `json:"checked"` + Disabled bool `json:"disabled"` + Icon string `json:"icon,omitempty"` + EnableButton1 bool `json:"enableButton1,omitempty"` +} + +type Image struct { + // ImageName is a file path or url + ImageName string `json:"imagename"` + Caption string `json:"caption"` +} + +type FullPosition string + +const ( + FullPositionTopLeft FullPosition = "topleft" + FullPositionLeft FullPosition = "left" + FullPositionBottomLeft FullPosition = "bottomleft" + FullPositionTop FullPosition = "top" + FullPositionCenter FullPosition = "center" + FullPositionBottom FullPosition = "bottom" + FullPositionTopRight FullPosition = "topright" + FullPositionRight FullPosition = "right" + FullPositionBottomRight FullPosition = "bottomright" +) + +type BackgroundFill string + +const ( + BackgroundFillFill BackgroundFill = "fill" + BackgroundFillFit BackgroundFill = "fit" +) + +type ListStyle string + +const ( + ListStyleExpanded ListStyle = "expanded" + ListStyleCompact ListStyle = "compact" +) + +type Appearance string + +const ( + AppearanceDark Appearance = "dark" + AppearanceLight Appearance = "light" +) + +type ListItem struct { + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + Status Status `json:"status,omitempty"` + StatusText string `json:"statustext,omitempty"` +} + +type Status string + +const ( + StatusNone Status = "" + StatusWait Status = "wait" + StatusSuccess Status = "success" + StatusFail Status = "fail" + StatusError Status = "error" + StatusPending Status = "pending" + StatusProgress Status = "progress" +) + +type TextField struct { + Title string `json:"title"` + Confirm bool `json:"confirm,omitempty"` + Editor bool `json:"editor,omitempty"` + FileSelect bool `json:"fileselect,omitempty"` + FileType string `json:"filetype,omitempty"` + Name string `json:"name,omitempty"` + Prompt string `json:"prompt,omitempty"` + Regex string `json:"regex,omitempty"` + RegexError string `json:"regexerror,omitempty"` + Required bool `json:"required,omitempty"` + Secure bool `json:"secure,omitempty"` + Value string `json:"value,omitempty"` +} + +type CheckboxStyle struct { + Style string `json:"style"` + Size CheckboxStyleSize `json:"size"` +} + +type CheckboxStyleStyle string + +const ( + CheckboxDefault CheckboxStyleStyle = "default" + CheckboxCheckbox CheckboxStyleStyle = "checkbox" + CheckboxSwitch CheckboxStyleStyle = "switch" +) + +type CheckboxStyleSize string + +const ( + CheckboxMini CheckboxStyleSize = "mini" + CheckboxSmall CheckboxStyleSize = "small" + CheckboxRegular CheckboxStyleSize = "regular" + CheckboxLarge CheckboxStyleSize = "large" +) + +type SelectItems struct { + Title string `json:"title"` + Values []string `json:"values"` + Default string `json:"default,omitempty"` + Style SelectItemsStyle `json:"style,omitempty"` + Required bool `json:"required,omitempty"` +} + +type SelectItemsStyle string + +const ( + SelectItemsStyleDropdown SelectItemsStyle = "" + SelectItemsStyleRadio SelectItemsStyle = "radio" +) diff --git a/orbit/pkg/swiftdialog/run.go b/orbit/pkg/swiftdialog/run.go new file mode 100644 index 000000000000..29b89230f39b --- /dev/null +++ b/orbit/pkg/swiftdialog/run.go @@ -0,0 +1,495 @@ +package swiftdialog + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// SwiftDialog really wants the command file to be mode 666 for some reason +// https://github.com/swiftDialog/swiftDialog/wiki/Gotchas +var CommandFilePerms = fs.FileMode(0o666) + +var ( + ErrKilled = errors.New("process killed") + ErrWindowClosed = errors.New("window closed") +) + +type SwiftDialog struct { + cancel context.CancelCauseFunc + cmd *exec.Cmd + commandFile *os.File + context context.Context + output *bytes.Buffer + exitCode ExitCode + exitErr error + done chan struct{} + binPath string +} + +type SwiftDialogExit struct { + ExitCode ExitCode + Output map[string]any +} + +type ExitCode int + +const ( + ExitButton1 ExitCode = 0 + ExitButton2 ExitCode = 2 + ExitInfoButton ExitCode = 3 + ExitTimer ExitCode = 4 + ExitQuitCommand ExitCode = 5 + ExitQuitKey ExitCode = 10 + ExitKeyAuthFailed ExitCode = 30 + ExitImageResourceNotFound ExitCode = 201 + ExitFileNotFound ExitCode = 202 +) + +func Create(ctx context.Context, swiftDialogBin string) (*SwiftDialog, error) { + commandFile, err := os.CreateTemp("", "swiftDialogCommand") + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancelCause(ctx) + + if err := commandFile.Chmod(CommandFilePerms); err != nil { + commandFile.Close() + os.Remove(commandFile.Name()) + cancel(errors.New("could not create command file")) + return nil, err + } + + sd := &SwiftDialog{ + cancel: cancel, + commandFile: commandFile, + context: ctx, + done: make(chan struct{}), + binPath: swiftDialogBin, + } + + return sd, nil +} + +func (s *SwiftDialog) Start(ctx context.Context, opts *SwiftDialogOptions) error { + jsonBytes, err := json.Marshal(opts) + if err != nil { + return err + } + + cmd := exec.CommandContext( //nolint:gosec + ctx, + s.binPath, + "--jsonstring", string(jsonBytes), + "--commandfile", s.commandFile.Name(), + "--json", + ) + + s.cmd = cmd + + outBuf := &bytes.Buffer{} + cmd.Stdout = outBuf + + s.output = outBuf + + err = cmd.Start() + if err != nil { + s.cancel(errors.New("could not start swiftDialog")) + return err + } + + go func() { + if err := cmd.Wait(); err != nil { + errExit := &exec.ExitError{} + if errors.As(err, &errExit) && strings.Contains(errExit.Error(), "exit status") { + s.exitCode = ExitCode(errExit.ExitCode()) + } else { + s.exitErr = fmt.Errorf("waiting for swiftDialog: %w", err) + } + } + close(s.done) + s.cancel(ErrWindowClosed) + }() + + // This sleep makes sure that SD is fully up and running and has access to the command file. + // We've found that if we start sending commands to the command file without this sleep, the + // commands may be lost. + time.Sleep(500 * time.Millisecond) + + return nil +} + +func (s *SwiftDialog) finished() { + <-s.done +} + +func (s *SwiftDialog) Kill() error { + s.cancel(ErrKilled) + s.finished() + if err := s.cleanup(); err != nil { + return fmt.Errorf("Close cleaning up after swiftDialog: %w", err) + } + + return nil +} + +func (s *SwiftDialog) cleanup() error { + s.cancel(nil) + cmdFileName := s.commandFile.Name() + err := s.commandFile.Close() + if err != nil { + return fmt.Errorf("closing swiftDialog command file: %w", err) + } + err = os.Remove(cmdFileName) + if err != nil { + return fmt.Errorf("removing swiftDialog command file: %w", err) + } + + return nil +} + +func (s *SwiftDialog) Wait() (*SwiftDialogExit, error) { + s.finished() + + parsed := map[string]any{} + if s.output.Len() != 0 { + if err := json.Unmarshal(s.output.Bytes(), &parsed); err != nil { + return nil, fmt.Errorf("parsing swiftDialog output: %w", err) + } + } + + if err := s.cleanup(); err != nil { + return nil, fmt.Errorf("Wait cleaning up after swiftDialog: %w", err) + } + + return &SwiftDialogExit{ + ExitCode: s.exitCode, + Output: parsed, + }, s.exitErr +} + +func (s *SwiftDialog) sendCommand(command, arg string) error { + if err := s.context.Err(); err != nil { + return fmt.Errorf("could not send command: %w", context.Cause(s.context)) + } + + fullCommand := fmt.Sprintf("%s: %s", command, arg) + + return s.writeCommand(fullCommand) +} + +func (s *SwiftDialog) sendMultiCommand(commands ...string) error { + multiCommands := strings.Join(commands, "\n") + return s.writeCommand(multiCommands) +} + +func (s *SwiftDialog) writeCommand(fullCommand string) error { + // For some reason swiftDialog needs us to open and close the file + // to detect a new command, just writing to the file doesn't cause + // a change + + commandFile, err := os.OpenFile(s.commandFile.Name(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, CommandFilePerms) + if err != nil { + return fmt.Errorf("opening command file for writing: %w", err) + } + + _, err = fmt.Fprintf(commandFile, "%s\n", fullCommand) + if err != nil { + return fmt.Errorf("writing command to file: %w", err) + } + + err = commandFile.Close() + if err != nil { + return fmt.Errorf("closing command file: %w", err) + } + + return nil +} + +/////////// +// Title // +/////////// + +// Updates the dialog title +func (s *SwiftDialog) UpdateTitle(title string) error { + return s.sendCommand("title", title) +} + +// Hides the title area +func (s *SwiftDialog) HideTitle() error { + return s.sendCommand("title", "none") +} + +///////////// +// Message // +///////////// + +// Set the dialog messsage +func (s *SwiftDialog) SetMessage(text string) error { + return s.sendCommand("message", sanitize(text)) +} + +// Append to the dialog message +func (s *SwiftDialog) AppendMessage(text string) error { + return s.sendCommand("message", fmt.Sprintf("+ %s", sanitize(text))) +} + +// SetMessageKeepListItems sets the message to the given string while preserving the current list items. +func (s *SwiftDialog) SetMessageKeepListItems(message string) error { + return s.sendMultiCommand(fmt.Sprintf("message: %s", sanitize(message)), "list: show") +} + +/////////// +// Image // +/////////// + +// Displays the selected image +func (s *SwiftDialog) Image(pathOrUrl string) error { + return s.sendCommand("image", pathOrUrl) +} + +// Displays the specified text underneath any displayed image +func (s *SwiftDialog) SetImageCaption(caption string) error { + return s.sendCommand("imagecaption", caption) +} + +////////////// +// Progress // +////////////// + +// When Dialog is initiated with the Progress option, this will update the progress value +func (s *SwiftDialog) UpdateProgress(progress uint) error { + return s.sendCommand("progress", fmt.Sprintf("%d", progress)) +} + +// Increments the progress by one +func (s *SwiftDialog) IncrementProgress() error { + return s.sendCommand("progress", "increment") +} + +// Resets the progress bar to 0 +func (s *SwiftDialog) ResetProgress() error { + return s.sendCommand("progress", "reset") +} + +// Maxes out the progress bar +func (s *SwiftDialog) CompleteProgress() error { + return s.sendCommand("progress", "complete") +} + +// Hide the progress bar +func (s *SwiftDialog) HideProgress() error { + return s.sendCommand("progress", "hide") +} + +// Show the progress bar +func (s *SwiftDialog) ShowProgress() error { + return s.sendCommand("progress", "show") +} + +// Will update the label associated with the progress bar +func (s *SwiftDialog) UpdateProgressText(text string) error { + return s.sendCommand("progresstext", text) +} + +/////////// +// Lists // +/////////// + +// Create a list +func (s *SwiftDialog) SetList(items []string) error { + return s.sendCommand("list", strings.Join(items, ",")) +} + +// Clears the list and removes it from display +func (s *SwiftDialog) ClearList() error { + return s.sendCommand("list", "clear") +} + +// Add a new item to the end of the current list +func (s *SwiftDialog) AddListItem(item ListItem) error { + arg := fmt.Sprintf("add, title: %s", item.Title) + if item.Status != "" { + arg = fmt.Sprintf("%s, status: %s", arg, item.Status) + } + if item.StatusText != "" { + arg = fmt.Sprintf("%s, statustext: %s", arg, item.StatusText) + } + return s.sendCommand("listitem", arg) +} + +// Delete an item by name +func (s *SwiftDialog) DeleteListItemByTitle(title string) error { + return s.sendCommand("listitem", fmt.Sprintf("delete, title: %s", title)) +} + +// Delete an item by index number (starting at 0) +func (s *SwiftDialog) DeleteListItemByIndex(index uint) error { + return s.sendCommand("listitem", fmt.Sprintf("delete, index: %d", index)) +} + +// Update a list item by name +func (s *SwiftDialog) UpdateListItemByTitle(title, statusText string, status Status, progressPercent ...uint) error { + argStatus := string(status) + if len(progressPercent) == 1 && status == StatusProgress { + argStatus = fmt.Sprintf("progress, progress: %d", progressPercent[0]) + } + arg := fmt.Sprintf("title: %s, status: %s, statustext: %s", title, argStatus, statusText) + return s.sendCommand("listitem", arg) +} + +// Update a list item by index number (starting at 0) +func (s *SwiftDialog) UpdateListItemByIndex(index uint, statusText string, status Status, progressPercent ...uint) error { + argStatus := string(status) + if len(progressPercent) == 1 && status == StatusProgress { + argStatus = fmt.Sprintf("progress, progress: %d", progressPercent[0]) + } + arg := fmt.Sprintf("index: %d, status: %s, statustext: %s", index, argStatus, statusText) + return s.sendCommand("listitem", arg) +} + +// ShowList forces the list to render. +func (s *SwiftDialog) ShowList() error { + return s.sendCommand("list", "show") +} + +///////////// +// Buttons // +///////////// + +// Enable or disable button 1 +func (s *SwiftDialog) EnableButton1(enable bool) error { + arg := "disable" + if enable { + arg = "enable" + } + return s.sendCommand("button1", arg) +} + +// Enable or disable button 2 +func (s *SwiftDialog) EnableButton2(enable bool) error { + arg := "disable" + if enable { + arg = "enable" + } + return s.sendCommand("button2", arg) +} + +// Changes the button 1 label +func (s *SwiftDialog) SetButton1Text(text string) error { + return s.sendCommand("button1text", text) +} + +// Changes the button 2 label +func (s *SwiftDialog) SetButton2Text(text string) error { + return s.sendCommand("button2text", text) +} + +// Changes the info button label +func (s *SwiftDialog) SetInfoButtonText(text string) error { + return s.sendCommand("infobuttontext", text) +} + +////////////// +// Info box // +////////////// + +// Update the content in the info box +func (s *SwiftDialog) SetInfoBoxText(text string) error { + return s.sendCommand("infobox", sanitize(text)) +} + +// Append to the conteit in the info box +func (s *SwiftDialog) AppendInfoBoxText(text string) error { + return s.sendCommand("infobox", fmt.Sprintf("+ %s", sanitize(text))) +} + +////////// +// Icon // +////////// + +// Changes the displayed icon +// See https://github.com/swiftDialog/swiftDialog/wiki/Customising-the-Icon +func (s *SwiftDialog) SetIconLocation(location string) error { + return s.sendCommand("icon", location) +} + +// Moves the icon being shown +func (s *SwiftDialog) SetIconAlignment(alignment Alignment) error { + return s.sendCommand("icon", string(alignment)) +} + +// Hide the icon +func (s *SwiftDialog) HideIcon() error { + return s.sendCommand("icon", "hide") +} + +// Changes the size of the displayed icon +func (s *SwiftDialog) SetIconSize(size uint) error { + return s.sendCommand("icon", fmt.Sprintf("size: %d", size)) +} + +//////////// +// Window // +//////////// + +// Changes the width of the window maintaining the current position +func (s *SwiftDialog) SetWindowWidth(width uint) error { + return s.sendCommand("width", fmt.Sprintf("%d", width)) +} + +// Changes the height of the window maintaining the current position +func (s *SwiftDialog) SetWindowHeight(width uint) error { + return s.sendCommand("height", fmt.Sprintf("%d", width)) +} + +// Changes the window position +func (s *SwiftDialog) SetWindowPosition(position FullPosition) error { + return s.sendCommand("position", string(position)) +} + +// Display content from the specified URL +func (s *SwiftDialog) SetWebContent(url string) error { + return s.sendCommand("webcontent", url) +} + +// Hide web content +func (s *SwiftDialog) HideWebContent() error { + return s.sendCommand("webcontent", "none") +} + +// Display a video from the specified path or URL +func (s *SwiftDialog) SetVideo(location string) error { + return s.sendCommand("video", location) +} + +// Enables or disables the blur window layer +func (s *SwiftDialog) BlurScreen(enable bool) error { + blur := "disable" + if enable { + blur = "enable" + } + return s.sendCommand("blurscreen", blur) +} + +// Activates the dialog window and brings it to the forground +func (s *SwiftDialog) Activate() error { + return s.sendCommand("activate", "") +} + +// Quits dialog with exit code 5 (ExitQuitCommand) +func (s *SwiftDialog) Quit() error { + return s.sendCommand("quit", "") +} + +func sanitize(text string) string { + return strings.ReplaceAll(text, "\n", "\\n") +} diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go index fa5b6b993c6f..58d31cd0772e 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -293,15 +293,18 @@ type runScriptsConfigReceiver struct { // ensures only one script execution runs at a time mu sync.Mutex + + rootDirPath string } func ApplyRunScriptsConfigFetcherMiddleware( - scriptsEnabled bool, scriptsClient scripts.Client, + scriptsEnabled bool, scriptsClient scripts.Client, rootDirPath string, ) (fleet.OrbitConfigReceiver, func() bool) { scriptsFetcher := &runScriptsConfigReceiver{ ScriptsExecutionEnabled: scriptsEnabled, ScriptsClient: scriptsClient, dynamicScriptsEnabledCheckInterval: 5 * time.Minute, + rootDirPath: rootDirPath, } // start the dynamic check for scripts enabled if required scriptsFetcher.runDynamicScriptsEnabledCheck() @@ -352,6 +355,13 @@ func (h *runScriptsConfigReceiver) Run(cfg *fleet.OrbitConfig) error { timeout = time.Duration(cfg.ScriptExeTimeout) * time.Second } + if runtime.GOOS == "darwin" { + if cfg.Notifications.RunSetupExperience == true && !CanRun(h.rootDirPath, "swiftDialog", SwiftDialogMacOSTarget) { + log.Debug().Msg("exiting scripts config runner early during setup experience: swiftDialog is not installed") + return nil + } + } + if len(cfg.Notifications.PendingScriptExecutionIDs) > 0 { if h.mu.TryLock() { log.Debug().Msgf("received request to run scripts %v", cfg.Notifications.PendingScriptExecutionIDs) diff --git a/orbit/pkg/update/swift_dialog.go b/orbit/pkg/update/swift_dialog.go index bdbfde5e3ee3..306ab0872f28 100644 --- a/orbit/pkg/update/swift_dialog.go +++ b/orbit/pkg/update/swift_dialog.go @@ -36,8 +36,8 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error { // TODO: we probably want to ensure that swiftDialog is always installed if we're going to be // using it offline. - if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile { - log.Debug().Msg("got false needs migration and false renew enrollment") + if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile && !cfg.Notifications.RunSetupExperience { + log.Debug().Msg("skipping swiftDialog update") return nil } @@ -56,6 +56,15 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error { s.UpdateRunner.updater.RemoveTargetInfo("swiftDialog") return err } + + if cfg.Notifications.RunSetupExperience { + // Then update immediately, since we need to get swiftDialog quickly to show the setup + // experience + _, err := s.UpdateRunner.UpdateAction() + if err != nil { + return err + } + } } return nil diff --git a/orbit/pkg/update/update.go b/orbit/pkg/update/update.go index 56e4a79b7f4e..1b47a213f075 100644 --- a/orbit/pkg/update/update.go +++ b/orbit/pkg/update/update.go @@ -671,3 +671,17 @@ func (u *Updater) initializeDirectories() error { return nil } + +func CanRun(rootDirPath, targetName string, targetInfo TargetInfo) bool { + _, binaryPath, _ := LocalTargetPaths( + rootDirPath, + targetName, + targetInfo, + ) + + if _, err := os.Stat(binaryPath); err != nil { + return false + } + + return true +} diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index b455b4eddd19..46314d0354a1 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -210,7 +210,7 @@ func (c *TestAppleMDMClient) Enroll() error { if err := c.Authenticate(); err != nil { return fmt.Errorf("authenticate: %w", err) } - if err := c.TokenUpdate(); err != nil { + if err := c.TokenUpdate(true); err != nil { return fmt.Errorf("token update: %w", err) } return nil @@ -599,7 +599,7 @@ func (c *TestAppleMDMClient) Authenticate() error { } // TokenUpdate sends the TokenUpdate message to the MDM server (Check In protocol). -func (c *TestAppleMDMClient) TokenUpdate() error { +func (c *TestAppleMDMClient) TokenUpdate(awaitingConfiguration bool) error { payload := map[string]any{ "MessageType": "TokenUpdate", "UDID": c.UUID, @@ -609,6 +609,9 @@ func (c *TestAppleMDMClient) TokenUpdate() error { "PushMagic": "pushmagic" + c.SerialNumber, "Token": []byte("token" + c.SerialNumber), } + if awaitingConfiguration { + payload["AwaitingConfiguration"] = true + } _, err := c.request("application/x-apple-aspen-mdm-checkin", payload) return err } diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index f7aefa97d192..7c4618921998 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -757,9 +757,16 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin for _, item := range software.Packages { var softwarePackageSpec fleet.SoftwarePackageSpec if item.Path != nil { - fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) + softwarePackageSpec.ReferencedYamlPath = resolveApplyRelativePath(baseDir, *item.Path) + fileBytes, err := os.ReadFile(softwarePackageSpec.ReferencedYamlPath) if err != nil { - multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err)) + multiError = multierror.Append(multiError, fmt.Errorf("failed to read software package file %s: %v", *item.Path, err)) + continue + } + // Replace $var and ${var} with env values. + fileBytes, err = ExpandEnvBytes(fileBytes) + if err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to expand environmet in file %s: %v", *item.Path, err)) continue } if err := yaml.Unmarshal(fileBytes, &softwarePackageSpec); err != nil { diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index e5968c54211f..c2305dfe97ba 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -548,10 +548,12 @@ var hostRefs = []string{ // the host.uuid is not always named the same, so the map key is the table name // and the map value is the column name to match to the host.uuid. var additionalHostRefsByUUID = map[string]string{ - "host_mdm_apple_profiles": "host_uuid", - "host_mdm_apple_bootstrap_packages": "host_uuid", - "host_mdm_windows_profiles": "host_uuid", - "host_mdm_apple_declarations": "host_uuid", + "host_mdm_apple_profiles": "host_uuid", + "host_mdm_apple_bootstrap_packages": "host_uuid", + "host_mdm_windows_profiles": "host_uuid", + "host_mdm_apple_declarations": "host_uuid", + "host_mdm_apple_awaiting_configuration": "host_uuid", + "setup_experience_status_results": "host_uuid", } // additionalHostRefsSoftDelete are tables that reference a host but for which diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 24a52ef4c8e3..d87a880552a0 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -6769,6 +6769,18 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { _, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false, nil) require.NoError(t, err) + // Add an awaiting configuration entry + err = ds.SetHostAwaitingConfiguration(ctx, host.UUID, false) + require.NoError(t, err) + + // Add a setup experience status result + err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "test.sh", ScriptContents: "echo foo"}) + require.NoError(t, err) + + added, err := ds.EnqueueSetupExperienceItems(ctx, host.UUID, 0) + require.NoError(t, err) + require.True(t, added) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool @@ -9703,5 +9715,4 @@ func testGetHostEmails(t *testing.T, ds *Datastore) { emails, err = ds.GetHostEmails(ctx, host.UUID, fleet.DeviceMappingMDMIdpAccounts) require.NoError(t, err) assert.ElementsMatch(t, []string{"foo@example.com", "bar@example.com"}, emails) - } diff --git a/server/datastore/mysql/migrations/tables/20241025111236_AddInstallDuringSetupToSoftwareInstallers.go b/server/datastore/mysql/migrations/tables/20241025111236_AddInstallDuringSetupToSoftwareInstallers.go new file mode 100644 index 000000000000..b17cff74f354 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20241025111236_AddInstallDuringSetupToSoftwareInstallers.go @@ -0,0 +1,28 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20241025111236, Down_20241025111236) +} + +func Up_20241025111236(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE software_installers ADD COLUMN install_during_setup BOOL NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("failed to add install_during_setup to software_installers: %w", err) + } + + _, err = tx.Exec(`ALTER TABLE vpp_apps_teams ADD COLUMN install_during_setup BOOL NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("failed to add install_during_setup to vpp_apps_teams: %w", err) + } + + return nil +} + +func Down_20241025111236(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20241025112748_AddSetupExperienceResultsTable.go b/server/datastore/mysql/migrations/tables/20241025112748_AddSetupExperienceResultsTable.go new file mode 100644 index 000000000000..ad5f6e8125ae --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20241025112748_AddSetupExperienceResultsTable.go @@ -0,0 +1,101 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20241025112748, Down_20241025112748) +} + +func Up_20241025112748(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE setup_experience_scripts ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + team_id INT UNSIGNED DEFAULT NULL, + global_or_team_id INT UNSIGNED NOT NULL DEFAULT '0', + name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + script_content_id INT UNSIGNED DEFAULT NULL, + + PRIMARY KEY (id), + + UNIQUE KEY idx_setup_experience_scripts_global_or_team_id (global_or_team_id), + + KEY idx_script_content_id (script_content_id), + + CONSTRAINT fk_setup_experience_scripts_ibfk_1 FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_setup_experience_scripts_ibfk_2 FOREIGN KEY (script_content_id) REFERENCES script_contents (id) ON DELETE CASCADE +); + +`) + if err != nil { + return fmt.Errorf("failed to create setup_experience_scripts table: %w", err) + } + + _, err = tx.Exec(`ALTER TABLE host_script_results ADD setup_experience_script_id INT UNSIGNED DEFAULT NULL`) + if err != nil { + return fmt.Errorf("failed to add setup_experience_scripts_id key to host_script_results: %w", err) + } + + _, err = tx.Exec(` +ALTER TABLE host_script_results + ADD CONSTRAINT fk_host_script_results_setup_experience_id + FOREIGN KEY (setup_experience_script_id) + REFERENCES setup_experience_scripts (id) ON DELETE SET NULL`) + if err != nil { + return fmt.Errorf("failed to add foreign key constraint for host_script_resutls setup_experience column: %w", err) + } + + _, err = tx.Exec(` +CREATE TABLE setup_experience_status_results ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + host_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, + name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, + status ENUM('pending', 'running', 'success', 'failure') NOT NULL, + + -- Software installer reference + software_installer_id INT(10) UNSIGNED, + -- Software installs reference + host_software_installs_execution_id VARCHAR(255), + + -- VPP app reference + vpp_app_team_id INT(10) UNSIGNED, + -- VPP app install reference + nano_command_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci, + + -- Setup script reference + setup_experience_script_id INT(10) UNSIGNED, + -- Script execution reference + script_execution_id VARCHAR(255) COLLATE utf8mb4_unicode_ci, + error VARCHAR(255) COLLATE utf8mb4_unicode_ci, + + + PRIMARY KEY (id), + + KEY idx_setup_experience_scripts_host_uuid (host_uuid), + KEY idx_setup_experience_scripts_hsi_id (host_software_installs_execution_id), + KEY idx_setup_experience_scripts_nano_command_uuid (nano_command_uuid), + KEY idx_setup_experience_scripts_script_execution_id (script_execution_id), + + CONSTRAINT fk_setup_experience_status_results_si_id FOREIGN KEY (software_installer_id) REFERENCES software_installers(id) ON DELETE CASCADE, + CONSTRAINT fk_setup_experience_status_results_va_id FOREIGN KEY (vpp_app_team_id) REFERENCES vpp_apps_teams(id) ON DELETE CASCADE, + CONSTRAINT fk_setup_experience_status_results_ses_id FOREIGN KEY (setup_experience_script_id) REFERENCES setup_experience_scripts(id) ON DELETE CASCADE +) +`) + // Service layer state machine like SetupExperienceNestStep()? + // Called from each of the three endpoints (software install, vpp + // mdm, scripts) involved in the setup when an eligible installer + // writes its results + if err != nil { + return fmt.Errorf("failed to create setup_experience_status_results table: %w", err) + } + + return nil +} + +func Down_20241025112748(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20241025141855_CreateTableHostMDMAppleAwaitingConfiguration.go b/server/datastore/mysql/migrations/tables/20241025141855_CreateTableHostMDMAppleAwaitingConfiguration.go new file mode 100644 index 000000000000..433b8fffa79e --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20241025141855_CreateTableHostMDMAppleAwaitingConfiguration.go @@ -0,0 +1,27 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20241025141855, Down_20241025141855) +} + +func Up_20241025141855(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE host_mdm_apple_awaiting_configuration ( + host_uuid VARCHAR(255) NOT NULL PRIMARY KEY, + awaiting_configuration TINYINT(1) NOT NULL DEFAULT FALSE +)`) + if err != nil { + return fmt.Errorf("creating host_mdm_apple_awaiting_configuration table: %w", err) + } + + return nil +} + +func Down_20241025141855(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index db3384cc8fec..3fa9bfa65040 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -65,7 +65,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `calendar_events` ( @@ -394,6 +394,14 @@ CREATE TABLE `host_mdm_actions` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `host_mdm_apple_awaiting_configuration` ( + `host_uuid` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `awaiting_configuration` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`host_uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `host_mdm_apple_bootstrap_packages` ( `host_uuid` varchar(127) COLLATE utf8mb4_unicode_ci NOT NULL, `command_uuid` varchar(127) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -536,6 +544,7 @@ CREATE TABLE `host_script_results` ( `host_deleted_at` timestamp NULL DEFAULT NULL, `timeout` int DEFAULT NULL, `policy_id` int unsigned DEFAULT NULL, + `setup_experience_script_id` int unsigned DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_host_script_results_execution_id` (`execution_id`), KEY `idx_host_script_results_host_exit_created` (`host_id`,`exit_code`,`created_at`), @@ -544,7 +553,9 @@ CREATE TABLE `host_script_results` ( KEY `fk_host_script_results_user_id` (`user_id`), KEY `script_content_id` (`script_content_id`), KEY `fk_script_result_policy_id` (`policy_id`), + KEY `fk_host_script_results_setup_experience_id` (`setup_experience_script_id`), CONSTRAINT `fk_host_script_results_script_id` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_host_script_results_setup_experience_id` FOREIGN KEY (`setup_experience_script_id`) REFERENCES `setup_experience_scripts` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_host_script_results_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `host_script_results_ibfk_1` FOREIGN KEY (`script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE CASCADE, CONSTRAINT `host_script_results_ibfk_2` FOREIGN KEY (`policy_id`) REFERENCES `policies` (`id`) ON DELETE SET NULL @@ -1088,9 +1099,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=324 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=327 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1652,6 +1663,51 @@ CREATE TABLE `sessions` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `setup_experience_scripts` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `team_id` int unsigned DEFAULT NULL, + `global_or_team_id` int unsigned NOT NULL DEFAULT '0', + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `script_content_id` int unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_setup_experience_scripts_global_or_team_id` (`global_or_team_id`), + KEY `idx_script_content_id` (`script_content_id`), + KEY `fk_setup_experience_scripts_ibfk_1` (`team_id`), + CONSTRAINT `fk_setup_experience_scripts_ibfk_1` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_setup_experience_scripts_ibfk_2` FOREIGN KEY (`script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `setup_experience_status_results` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `host_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `status` enum('pending','running','success','failure') COLLATE utf8mb4_unicode_ci NOT NULL, + `software_installer_id` int unsigned DEFAULT NULL, + `host_software_installs_execution_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `vpp_app_team_id` int unsigned DEFAULT NULL, + `nano_command_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `setup_experience_script_id` int unsigned DEFAULT NULL, + `script_execution_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `error` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_setup_experience_scripts_host_uuid` (`host_uuid`), + KEY `idx_setup_experience_scripts_hsi_id` (`host_software_installs_execution_id`), + KEY `idx_setup_experience_scripts_nano_command_uuid` (`nano_command_uuid`), + KEY `idx_setup_experience_scripts_script_execution_id` (`script_execution_id`), + KEY `fk_setup_experience_status_results_si_id` (`software_installer_id`), + KEY `fk_setup_experience_status_results_va_id` (`vpp_app_team_id`), + KEY `fk_setup_experience_status_results_ses_id` (`setup_experience_script_id`), + CONSTRAINT `fk_setup_experience_status_results_ses_id` FOREIGN KEY (`setup_experience_script_id`) REFERENCES `setup_experience_scripts` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_setup_experience_status_results_si_id` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_setup_experience_status_results_va_id` FOREIGN KEY (`vpp_app_team_id`) REFERENCES `vpp_apps_teams` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `software` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -1742,6 +1798,7 @@ CREATE TABLE `software_installers` ( `uninstall_script_content_id` int unsigned NOT NULL, `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `fleet_library_app_id` int unsigned DEFAULT NULL, + `install_during_setup` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`), KEY `fk_software_installers_title` (`title_id`), @@ -1873,6 +1930,7 @@ CREATE TABLE `vpp_apps_teams` ( `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `self_service` tinyint(1) NOT NULL DEFAULT '0', `vpp_token_id` int unsigned NOT NULL, + `install_during_setup` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_global_or_team_id_adam_id` (`global_or_team_id`,`adam_id`,`platform`), KEY `team_id` (`team_id`), diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index 68ddf4d12861..9e2a94304602 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -1217,7 +1217,10 @@ WHERE SELECT 1 FROM fleet_library_apps fla WHERE script_contents.id IN (fla.install_script_content_id, fla.uninstall_script_content_id) ) - ` + AND NOT EXISTS ( + SELECT 1 FROM setup_experience_scripts WHERE script_content_id = script_contents.id + ) +` _, err := ds.writer(ctx).ExecContext(ctx, deleteStmt) if err != nil { return ctxerr.Wrap(ctx, err, "cleaning up unused script contents") diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go new file mode 100644 index 000000000000..328cbb12aec0 --- /dev/null +++ b/server/datastore/mysql/setup_experience.go @@ -0,0 +1,579 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +func (ds *Datastore) EnqueueSetupExperienceItems(ctx context.Context, hostUUID string, teamID uint) (bool, error) { + stmtClearSetupStatus := ` +DELETE FROM setup_experience_status_results +WHERE host_uuid = ?` + + stmtSoftwareInstallers := ` +INSERT INTO setup_experience_status_results ( + host_uuid, + name, + status, + software_installer_id +) SELECT + ?, + st.name, + 'pending', + si.id +FROM software_installers si +INNER JOIN software_titles st + ON si.title_id = st.id +WHERE install_during_setup = true +AND global_or_team_id = ?` + + stmtVPPApps := ` +INSERT INTO setup_experience_status_results ( + host_uuid, + name, + status, + vpp_app_team_id +) SELECT + ?, + st.name, + 'pending', + vat.id +FROM vpp_apps va +INNER JOIN vpp_apps_teams vat + ON vat.adam_id = va.adam_id + AND vat.platform = va.platform +INNER JOIN software_titles st + ON va.title_id = st.id +WHERE vat.install_during_setup = true +AND vat.global_or_team_id = ?` + + stmtSetupScripts := ` +INSERT INTO setup_experience_status_results ( + host_uuid, + name, + status, + setup_experience_script_id +) SELECT + ?, + name, + 'pending', + id +FROM setup_experience_scripts +WHERE global_or_team_id = ?` + + var totalInsertions uint + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // Clean out old statuses for the host + if _, err := tx.ExecContext(ctx, stmtClearSetupStatus, hostUUID); err != nil { + return ctxerr.Wrap(ctx, err, "removing stale setup experience entries") + } + + // Software installers + res, err := tx.ExecContext(ctx, stmtSoftwareInstallers, hostUUID, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "inserting setup experience software installers") + } + inserts, err := res.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving number of inserted software installers") + } + totalInsertions += uint(inserts) // nolint: gosec + + // VPP apps + res, err = tx.ExecContext(ctx, stmtVPPApps, hostUUID, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "inserting setup experience vpp apps") + } + inserts, err = res.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving number of inserted vpp apps") + } + totalInsertions += uint(inserts) // nolint: gosec + + // Scripts + res, err = tx.ExecContext(ctx, stmtSetupScripts, hostUUID, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "inserting setup experience scripts") + } + inserts, err = res.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving number of inserted setup experience scripts") + } + totalInsertions += uint(inserts) // nolint: gosec + + if err := setHostAwaitingConfiguration(ctx, tx, hostUUID, true); err != nil { + return ctxerr.Wrap(ctx, err, "setting host awaiting configuration to true") + } + + return nil + }); err != nil { + return false, ctxerr.Wrap(ctx, err, "enqueue setup experience") + } + + return totalInsertions > 0, nil +} + +func (ds *Datastore) SetSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, titleIDs []uint) error { + titleIDQuestionMarks := strings.Join(slices.Repeat([]string{"?"}, len(titleIDs)), ",") + + stmtSelectInstallersIDs := fmt.Sprintf(` +SELECT + st.id AS title_id, + si.id, + st.name, + si.platform +FROM + software_titles st +LEFT JOIN + software_installers si + ON st.id = si.title_id +WHERE + si.global_or_team_id = ? +AND + st.id IN (%s) +`, titleIDQuestionMarks) + + stmtSelectVPPAppsTeamsID := fmt.Sprintf(` +SELECT + st.id AS title_id, + vat.id, + st.name, + vat.platform +FROM + software_titles st +LEFT JOIN + vpp_apps va + ON st.id = va.title_id +LEFT JOIN + vpp_apps_teams vat + ON va.adam_id = vat.adam_id +WHERE + vat.global_or_team_id = ? +AND + st.id IN (%s) +`, titleIDQuestionMarks) + + stmtUnsetInstallers := ` +UPDATE software_installers +SET install_during_setup = false +WHERE global_or_team_id = ?` + + stmtUnsetVPPAppsTeams := ` +UPDATE vpp_apps_teams vat +SET install_during_setup = false +WHERE global_or_team_id = ?` + + stmtSetInstallers := ` +UPDATE software_installers +SET install_during_setup = true +WHERE id IN (%s)` + + stmtSetVPPAppsTeams := ` +UPDATE vpp_apps_teams +SET install_during_setup = true +WHERE id IN (%s)` + + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var softwareIDPlatforms []idPlatformTuple + var softwareIDs []any + var vppIDPlatforms []idPlatformTuple + var vppAppTeamIDs []any + // List of title IDs that were sent but aren't in the + // database. We add everything and then remove them + // from the list when we validate them below + missingTitleIDs := make(map[uint]struct{}) + // Arguments used for queries that select vpp apps/installers + titleIDAndTeam := []any{teamID} + for _, id := range titleIDs { + missingTitleIDs[id] = struct{}{} + titleIDAndTeam = append(titleIDAndTeam, id) + } + + // Select requested software installers + if len(titleIDs) > 0 { + if err := sqlx.SelectContext(ctx, tx, &softwareIDPlatforms, stmtSelectInstallersIDs, titleIDAndTeam...); err != nil { + return ctxerr.Wrap(ctx, err, "selecting software IDs using title IDs") + } + } + + // Validate only macOS software + for _, tuple := range softwareIDPlatforms { + delete(missingTitleIDs, tuple.TitleID) + if tuple.Platform != string(fleet.MacOSPlatform) { + return ctxerr.Errorf(ctx, "only MacOS supported, unsupported software installer: %d (%s, %s)", tuple.ID, tuple.Name, tuple.Platform) + } + softwareIDs = append(softwareIDs, tuple.ID) + } + + // Select requested VPP apps + if len(titleIDs) > 0 { + if err := sqlx.SelectContext(ctx, tx, &vppIDPlatforms, stmtSelectVPPAppsTeamsID, titleIDAndTeam...); err != nil { + return ctxerr.Wrap(ctx, err, "selecting vpp app team IDs using title IDs") + } + } + + // Validate only macOS VPPP apps + for _, tuple := range vppIDPlatforms { + delete(missingTitleIDs, tuple.TitleID) + if tuple.Platform != string(fleet.MacOSPlatform) { + return ctxerr.Errorf(ctx, "only MacOS supported, unsupported AppStoreApp title: %d (%s, %s)", tuple.ID, tuple.Name, tuple.Platform) + } + vppAppTeamIDs = append(vppAppTeamIDs, tuple.ID) + } + + // If we have any missing titles, return error + if len(missingTitleIDs) > 0 { + var keys []string + for k := range missingTitleIDs { + keys = append(keys, fmt.Sprintf("%d", k)) + } + return ctxerr.Errorf(ctx, "title IDs not available: %s", strings.Join(keys, ",")) + } + + // Unset all installers + if _, err := tx.ExecContext(ctx, stmtUnsetInstallers, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "unsetting software installers") + } + + // Unset all vpp apps + if _, err := tx.ExecContext(ctx, stmtUnsetVPPAppsTeams, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "unsetting vpp app teams") + } + + if len(softwareIDs) > 0 { + stmtSetInstallersLoop := fmt.Sprintf(stmtSetInstallers, questionMarks(len(softwareIDs))) + if _, err := tx.ExecContext(ctx, stmtSetInstallersLoop, softwareIDs...); err != nil { + return ctxerr.Wrap(ctx, err, "setting software installers") + } + } + + if len(vppAppTeamIDs) > 0 { + stmtSetVPPAppsTeamsLoop := fmt.Sprintf(stmtSetVPPAppsTeams, questionMarks(len(vppAppTeamIDs))) + if _, err := tx.ExecContext(ctx, stmtSetVPPAppsTeamsLoop, vppAppTeamIDs...); err != nil { + return ctxerr.Wrap(ctx, err, "setting vpp app teams") + } + } + + return nil + }); err != nil { + return ctxerr.Wrap(ctx, err, "setting setup experience software") + } + + return nil +} + +func (ds *Datastore) ListSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + opts.IncludeMetadata = true + opts.After = "" + + titles, count, meta, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + TeamID: &teamID, + ListOptions: opts, + Platform: string(fleet.MacOSPlatform), + AvailableForInstall: true, + }, fleet.TeamFilter{ + IncludeObserver: true, + TeamID: &teamID, + }) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "calling list software titles") + } + + return titles, count, meta, nil +} + +type idPlatformTuple struct { + ID uint `db:"id"` + TitleID uint `db:"title_id"` + Name string `db:"name"` + Platform string `db:"platform"` +} + +func questionMarks(number int) string { + return strings.Join(slices.Repeat([]string{"?"}, number), ",") +} + +func (ds *Datastore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { + const stmt = ` +SELECT + sesr.id, + sesr.host_uuid, + sesr.name, + sesr.status, + sesr.software_installer_id, + sesr.host_software_installs_execution_id, + sesr.vpp_app_team_id, + sesr.nano_command_uuid, + sesr.setup_experience_script_id, + sesr.script_execution_id, + sesr.error, + NULLIF(va.adam_id, '') AS vpp_app_adam_id, + NULLIF(va.platform, '') AS vpp_app_platform, + ses.script_content_id, + COALESCE(si.title_id, COALESCE(va.title_id, NULL)) AS software_title_id +FROM setup_experience_status_results sesr +LEFT JOIN setup_experience_scripts ses ON ses.id = sesr.setup_experience_script_id +LEFT JOIN software_installers si ON si.id = sesr.software_installer_id +LEFT JOIN vpp_apps_teams vat ON vat.id = sesr.vpp_app_team_id +LEFT JOIN vpp_apps va ON vat.adam_id = va.adam_id +WHERE host_uuid = ? + ` + var results []*fleet.SetupExperienceStatusResult + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostUUID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "select setup experience status results by host uuid") + } + return results, nil +} + +func (ds *Datastore) UpdateSetupExperienceStatusResult(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + const stmt = ` +UPDATE setup_experience_status_results +SET + host_uuid = ?, + name = ?, + status = ?, + software_installer_id = ?, + host_software_installs_execution_id = ?, + vpp_app_team_id = ?, + nano_command_uuid = ?, + setup_experience_script_id = ?, + script_execution_id = ?, + error = ? +WHERE id = ? +` + if err := status.IsValid(); err != nil { + return ctxerr.Wrap(ctx, err, "invalid status update") + } + + if _, err := ds.writer(ctx).ExecContext( + ctx, + stmt, + status.HostUUID, + status.Name, + status.Status, + status.SoftwareInstallerID, + status.HostSoftwareInstallsExecutionID, + status.VPPAppTeamID, + status.NanoCommandUUID, + status.SetupExperienceScriptID, + status.ScriptExecutionID, + status.Error, + status.ID, + ); err != nil { + return ctxerr.Wrap(ctx, err, "updating setup experience status result") + } + + return nil +} + +func (ds *Datastore) GetSetupExperienceScript(ctx context.Context, teamID *uint) (*fleet.Script, error) { + query := ` +SELECT + id, + team_id, + name, + script_content_id, + created_at, + updated_at +FROM + setup_experience_scripts +WHERE + global_or_team_id = ? +` + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + + var script fleet.Script + if err := sqlx.GetContext(ctx, ds.reader(ctx), &script, query, globalOrTeamID); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("SetupExperienceScript"), "get setup experience script") + } + return nil, ctxerr.Wrap(ctx, err, "get setup experience script") + } + + return &script, nil +} + +func (ds *Datastore) SetSetupExperienceScript(ctx context.Context, script *fleet.Script) error { + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var err error + + // first insert script contents + scRes, err := insertScriptContents(ctx, tx, script.ScriptContents) + if err != nil { + return err + } + id, _ := scRes.LastInsertId() + + // then create the script entity + _, err = insertSetupExperienceScript(ctx, tx, script, uint(id)) // nolint: gosec + return err + }) + + return err +} + +func insertSetupExperienceScript(ctx context.Context, tx sqlx.ExtContext, script *fleet.Script, scriptContentsID uint) (sql.Result, error) { + const insertStmt = ` +INSERT INTO + setup_experience_scripts ( + team_id, global_or_team_id, name, script_content_id + ) +VALUES + (?, ?, ?, ?) +` + var globalOrTeamID uint + if script.TeamID != nil { + globalOrTeamID = *script.TeamID + } + res, err := tx.ExecContext(ctx, insertStmt, + script.TeamID, globalOrTeamID, script.Name, scriptContentsID) + if err != nil { + + if IsDuplicate(err) { + // already exists for this team/no team + err = &existsError{ResourceType: "SetupExperienceScript", TeamID: &globalOrTeamID} + } else if isChildForeignKeyError(err) { + // team does not exist + err = foreignKey("setup_experience_scripts", fmt.Sprintf("team_id=%v", script.TeamID)) + } + return nil, ctxerr.Wrap(ctx, err, "insert setup experience script") + } + + return res, nil +} + +func (ds *Datastore) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error { + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + + _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM setup_experience_scripts WHERE global_or_team_id = ?`, globalOrTeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete setup experience script") + } + + // NOTE: CleanupUnusedScriptContents is responsible for removing any orphaned script_contents + // for setup experience scripts. + + return nil +} + +func (ds *Datastore) SetHostAwaitingConfiguration(ctx context.Context, hostUUID string, awaitingConfiguration bool) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + return setHostAwaitingConfiguration(ctx, tx, hostUUID, awaitingConfiguration) + }) +} + +func setHostAwaitingConfiguration(ctx context.Context, tx sqlx.ExtContext, hostUUID string, awaitingConfiguration bool) error { + const stmt = ` +INSERT INTO host_mdm_apple_awaiting_configuration (host_uuid, awaiting_configuration) +VALUES (?, ?) +ON DUPLICATE KEY UPDATE + awaiting_configuration = VALUES(awaiting_configuration) + ` + + _, err := tx.ExecContext(ctx, stmt, hostUUID, awaitingConfiguration) + if err != nil { + return ctxerr.Wrap(ctx, err, "setting host awaiting configuration") + } + + return nil +} + +func (ds *Datastore) GetHostAwaitingConfiguration(ctx context.Context, hostUUID string) (bool, error) { + const stmt = ` +SELECT + awaiting_configuration +FROM host_mdm_apple_awaiting_configuration +WHERE host_uuid = ? + ` + var awaitingConfiguration bool + + if err := sqlx.GetContext(ctx, ds.reader(ctx), &awaitingConfiguration, stmt, hostUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, ctxerr.Wrap(ctx, err, "getting host awaiting configuration") + } + + return awaitingConfiguration, nil +} + +func (ds *Datastore) MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, nanoCommandUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND nano_command_uuid = ?" + updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?" + + var id uint + if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, nanoCommandUUID); err != nil { + // TODO: maybe we can use the reader instead for this query + if errors.Is(err, sql.ErrNoRows) { + // return early if no results found + return false, nil + } + return false, err + } + res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + + return n > 0, nil +} + +func (ds *Datastore) MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND host_software_installs_execution_id = ?" + updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?" + + var id uint + if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, executionID); err != nil { + // TODO: maybe we can use the reader instead for this query + if errors.Is(err, sql.ErrNoRows) { + // return early if no results found + return false, nil + } + return false, err + } + res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + + return n > 0, nil +} + +func (ds *Datastore) MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND script_execution_id = ?" + updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?" + + var id uint + if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, executionID); err != nil { + // TODO: maybe we can use the reader instead for this query + if errors.Is(err, sql.ErrNoRows) { + // return early if no results found + return false, nil + } + return false, err + } + res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + + return n > 0, nil +} diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go new file mode 100644 index 000000000000..5d56b71ffcb9 --- /dev/null +++ b/server/datastore/mysql/setup_experience_test.go @@ -0,0 +1,893 @@ +package mysql + +import ( + "bytes" + "context" + "database/sql" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetupExperience(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"EnqueueSetupExperienceItems", testEnqueueSetupExperienceItems}, + {"GetSetupExperienceTitles", testGetSetupExperienceTitles}, + {"SetSetupExperienceTitles", testSetSetupExperienceTitles}, + {"ListSetupExperienceStatusResults", testSetupExperienceStatusResults}, + {"SetupExperienceScriptCRUD", testSetupExperienceScriptCRUD}, + {"TestHostInSetupExperience", testHostInSetupExperience}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + c.fn(t, ds) + }) + } +} + +func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) { + ctx := context.Background() + test.CreateInsertGlobalVPPToken(t, ds) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + team3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) + require.NoError(t, err) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "Software1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + Platform: string(fleet.MacOSPlatform), + }) + require.NoError(t, err) + + installerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "banana", + PreInstallQuery: "SELECT 3", + PostInstallScript: "apple", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage3", + Filename: "file3", + Title: "Software2", + Version: "3.0", + Source: "apps", + SelfService: true, + UserID: user1.ID, + TeamID: &team2.ID, + Platform: string(fleet.MacOSPlatform), + }) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?)", installerID1, installerID2) + return err + }) + + app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} + vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + require.NoError(t, err) + + app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b2"} + vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team2.ID) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?)", vpp1.AdamID, vpp2.AdamID) + return err + }) + + var script1ID, script2ID int64 + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + res, err := insertScriptContents(ctx, q, "SCRIPT 1") + if err != nil { + return err + } + id1, _ := res.LastInsertId() + res, err = insertScriptContents(ctx, q, "SCRIPT 2") + if err != nil { + return err + } + id2, _ := res.LastInsertId() + + res, err = q.ExecContext(ctx, "INSERT INTO setup_experience_scripts (team_id, global_or_team_id, name, script_content_id) VALUES (?, ?, ?, ?)", team1.ID, team1.ID, "script1", id1) + if err != nil { + return err + } + script1ID, _ = res.LastInsertId() + + res, err = q.ExecContext(ctx, "INSERT INTO setup_experience_scripts (team_id, global_or_team_id, name, script_content_id) VALUES (?, ?, ?, ?)", team2.ID, team2.ID, "script2", id2) + if err != nil { + return err + } + script2ID, _ = res.LastInsertId() + + return nil + }) + + hostTeam1 := "123" + hostTeam2 := "456" + hostTeam3 := "789" + + anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, hostTeam1, team1.ID) + require.NoError(t, err) + require.True(t, anythingEnqueued) + + anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam2, team2.ID) + require.NoError(t, err) + require.True(t, anythingEnqueued) + + anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam3, team3.ID) + require.NoError(t, err) + require.False(t, anythingEnqueued) + + seRows := []setupExperienceInsertTestRows{} + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.SelectContext(ctx, q, &seRows, "SELECT host_uuid, name, status, software_installer_id, setup_experience_script_id, vpp_app_team_id FROM setup_experience_status_results") + }) + + require.Len(t, seRows, 6) + + for _, tc := range []setupExperienceInsertTestRows{ + { + HostUUID: hostTeam1, + Name: "Software1", + Status: "pending", + SoftwareInstallerID: nullableUint(installerID1), + }, + { + HostUUID: hostTeam2, + Name: "Software2", + Status: "pending", + SoftwareInstallerID: nullableUint(installerID2), + }, + { + HostUUID: hostTeam1, + Name: app1.Name, + Status: "pending", + VPPAppTeamID: nullableUint(1), + }, + { + HostUUID: hostTeam2, + Name: app2.Name, + Status: "pending", + VPPAppTeamID: nullableUint(2), + }, + { + HostUUID: hostTeam1, + Name: "script1", + Status: "pending", + ScriptID: nullableUint(uint(script1ID)), // nolint: gosec + }, + { + HostUUID: hostTeam2, + Name: "script2", + Status: "pending", + ScriptID: nullableUint(uint(script2ID)), // nolint: gosec + }, + } { + var found bool + for _, row := range seRows { + if row == tc { + found = true + break + } + } + if !found { + t.Errorf("Couldn't find entry in setup_experience_status_results table: %#v", tc) + } + } + + for _, row := range seRows { + if row.HostUUID == hostTeam3 { + t.Error("team 3 shouldn't have any any entries") + } + } + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "DELETE FROM setup_experience_scripts WHERE global_or_team_id = ?", team2.ID) + if err != nil { + return err + } + + _, err = q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = false WHERE global_or_team_id = ?", team2.ID) + if err != nil { + return err + } + + _, err = q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = false WHERE global_or_team_id = ?", team2.ID) + if err != nil { + return err + } + + return nil + }) + + anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam1, team1.ID) + require.NoError(t, err) + require.True(t, anythingEnqueued) + + anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam2, team2.ID) + require.NoError(t, err) + require.False(t, anythingEnqueued) + + anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, hostTeam3, team3.ID) + require.NoError(t, err) + require.False(t, anythingEnqueued) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.SelectContext(ctx, q, &seRows, "SELECT host_uuid, name, status, software_installer_id, setup_experience_script_id, vpp_app_team_id FROM setup_experience_status_results") + }) + + require.Len(t, seRows, 3) + + for _, tc := range []setupExperienceInsertTestRows{ + { + HostUUID: hostTeam1, + Name: "Software1", + Status: "pending", + SoftwareInstallerID: nullableUint(installerID1), + }, + { + HostUUID: hostTeam1, + Name: app1.Name, + Status: "pending", + VPPAppTeamID: nullableUint(1), + }, + { + HostUUID: hostTeam1, + Name: "script1", + Status: "pending", + ScriptID: nullableUint(uint(script1ID)), // nolint: gosec + }, + } { + var found bool + for _, row := range seRows { + if row == tc { + found = true + break + } + } + if !found { + t.Errorf("Couldn't find entry in setup_experience_status_results table: %#v", tc) + } + } + + for _, row := range seRows { + if row.HostUUID == hostTeam3 || row.HostUUID == hostTeam2 { + team := 2 + if row.HostUUID == hostTeam3 { + team = 3 + } + t.Errorf("team %d shouldn't have any any entries", team) + } + } +} + +type setupExperienceInsertTestRows struct { + HostUUID string `db:"host_uuid"` + Name string `db:"name"` + Status string `db:"status"` + SoftwareInstallerID sql.NullInt64 `db:"software_installer_id"` + ScriptID sql.NullInt64 `db:"setup_experience_script_id"` + VPPAppTeamID sql.NullInt64 `db:"vpp_app_team_id"` +} + +func nullableUint(val uint) sql.NullInt64 { + return sql.NullInt64{Int64: int64(val), Valid: true} // nolint: gosec +} + +func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) { + ctx := context.Background() + test.CreateInsertGlobalVPPToken(t, ds) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + Platform: string(fleet.MacOSPlatform), + }) + require.NoError(t, err) + + installerID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "banana", + PreInstallQuery: "SELECT 3", + PostInstallScript: "apple", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage3", + Filename: "file3", + Title: "file3", + Version: "3.0", + Source: "apps", + SelfService: true, + UserID: user1.ID, + TeamID: &team2.ID, + Platform: string(fleet.MacOSPlatform), + }) + require.NoError(t, err) + + installerID4, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "pear", + PreInstallQuery: "SELECT 4", + PostInstallScript: "apple", + InstallerFile: bytes.NewReader([]byte("hello2")), + StorageID: "storage3", + Filename: "file4", + Title: "file4", + Version: "4.0", + Source: "apps", + SelfService: true, + UserID: user1.ID, + TeamID: &team2.ID, + Platform: string(fleet.IOSPlatform), + }) + require.NoError(t, err) + + titles, count, meta, err := ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 1) + assert.Equal(t, 1, count) + assert.NotNil(t, meta) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?, ?)", installerID1, installerID3, installerID4) + return err + }) + + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 1) + assert.Equal(t, installerID1, titles[0].ID) + assert.Equal(t, 1, count) + assert.NotNil(t, meta) + + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team2.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 1) + assert.Equal(t, installerID3, titles[0].ID) + assert.Equal(t, 1, count) + assert.NotNil(t, meta) + + app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + require.NoError(t, err) + + app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.IOSPlatform}}, BundleIdentifier: "b2"} + _, err = ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID) + require.NoError(t, err) + + app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b3"} + _, err = ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID) + require.NoError(t, err) + + vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + require.NoError(t, err) + + vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID) + require.NoError(t, err) + + vpp3, err := ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?, ?)", vpp1.AdamID, vpp2.AdamID, vpp3.AdamID) + return err + }) + + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 2) + assert.Equal(t, vpp1.AdamID, titles[1].AppStoreApp.AppStoreID) + assert.Equal(t, 2, count) + assert.NotNil(t, meta) + + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team2.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 2) + assert.Equal(t, vpp3.AdamID, titles[1].AppStoreApp.AppStoreID) + assert.Equal(t, 2, count) + assert.NotNil(t, meta) +} + +func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) { + ctx := context.Background() + test.CreateInsertGlobalVPPToken(t, ds) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + Platform: string(fleet.MacOSPlatform), + }) + _ = installerID1 + require.NoError(t, err) + + installerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "world", + PreInstallQuery: "SELECT 2", + PostInstallScript: "hello", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage2", + Filename: "file2", + Title: "file2", + Version: "2.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + Platform: string(fleet.MacOSPlatform), + }) + _ = installerID2 + require.NoError(t, err) + + installerID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "banana", + PreInstallQuery: "SELECT 3", + PostInstallScript: "apple", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage3", + Filename: "file3", + Title: "file3", + Version: "3.0", + Source: "apps", + SelfService: true, + UserID: user1.ID, + TeamID: &team2.ID, + Platform: string(fleet.MacOSPlatform), + }) + _ = installerID3 + require.NoError(t, err) + + installerID4, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "pear", + PreInstallQuery: "SELECT 4", + PostInstallScript: "apple", + InstallerFile: bytes.NewReader([]byte("hello2")), + StorageID: "storage3", + Filename: "file4", + Title: "file4", + Version: "4.0", + Source: "apps", + SelfService: true, + UserID: user1.ID, + TeamID: &team2.ID, + Platform: string(fleet.IOSPlatform), + }) + _ = installerID4 + require.NoError(t, err) + + titles, count, meta, err := ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 2) + assert.Equal(t, 2, count) + assert.NotNil(t, meta) + + app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + require.NoError(t, err) + + app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.IOSPlatform}}, BundleIdentifier: "b2"} + _, err = ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID) + require.NoError(t, err) + + app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b3"} + _, err = ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID) + require.NoError(t, err) + + vpp1, err := ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + _ = vpp1 + require.NoError(t, err) + + vpp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID) + _ = vpp2 + require.NoError(t, err) + + vpp3, err := ds.InsertVPPAppWithTeam(ctx, app3, &team2.ID) + _ = vpp3 + require.NoError(t, err) + + titleSoftware := make(map[string]uint) + titleVPP := make(map[string]uint) + + softwareTitles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team1.ID}, fleet.TeamFilter{TeamID: &team1.ID}) + require.NoError(t, err) + + for _, title := range softwareTitles { + if title.AppStoreApp != nil { + titleVPP[title.AppStoreApp.AppStoreID] = title.ID + } else if title.SoftwarePackage != nil { + titleSoftware[title.SoftwarePackage.Name] = title.ID + } + } + + softwareTitles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team2.ID}, fleet.TeamFilter{TeamID: &team2.ID}) + require.NoError(t, err) + + for _, title := range softwareTitles { + if title.AppStoreApp != nil { + titleVPP[title.AppStoreApp.AppStoreID] = title.ID + } else if title.SoftwarePackage != nil { + titleSoftware[title.SoftwarePackage.Name] = title.ID + } + } + + // Single installer + err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleSoftware["file1"]}) + require.NoError(t, err) + + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 3) + assert.Equal(t, 3, count) + assert.Equal(t, "file1", titles[0].SoftwarePackage.Name) + assert.Equal(t, "file2", titles[1].SoftwarePackage.Name) + assert.Equal(t, "1", titles[2].AppStoreApp.AppStoreID) + assert.NotNil(t, meta) + + assert.True(t, *titles[0].SoftwarePackage.InstallDuringSetup) + assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup) + assert.False(t, *titles[2].AppStoreApp.InstallDuringSetup) + + // Single vpp app replaces installer + err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleVPP["1"]}) + require.NoError(t, err) + + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, titles, 3) + require.Equal(t, 3, count) + assert.Equal(t, "file1", titles[0].SoftwarePackage.Name) + assert.Equal(t, "file2", titles[1].SoftwarePackage.Name) + assert.Equal(t, "1", titles[2].AppStoreApp.AppStoreID) + assert.NotNil(t, meta) + + assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup) + assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup) + assert.True(t, *titles[2].AppStoreApp.InstallDuringSetup) + + // Team 2 unaffected + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team2.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, titles, 2) + require.Equal(t, 2, count) + assert.Equal(t, "file3", titles[0].SoftwarePackage.Name) + assert.Equal(t, "3", titles[1].AppStoreApp.AppStoreID) + require.NotNil(t, meta) + + assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup) + assert.False(t, *titles[1].AppStoreApp.InstallDuringSetup) + + // iOS software + err = ds.SetSetupExperienceSoftwareTitles(ctx, team2.ID, []uint{titleSoftware["file4"]}) + require.ErrorContains(t, err, "unsupported") + + // ios vpp app + err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleVPP["2"]}) + require.ErrorContains(t, err, "unsupported") + + // wrong team + err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{titleVPP["3"]}) + require.ErrorContains(t, err, "not available") + + // good other team assignment + err = ds.SetSetupExperienceSoftwareTitles(ctx, team2.ID, []uint{titleVPP["3"]}) + require.NoError(t, err) + + // non-existent title ID + err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{999}) + require.ErrorContains(t, err, "not available") + + // Failures and other team assignments didn't affected the number of apps on team 1 + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 3) + assert.Equal(t, 3, count) + assert.NotNil(t, meta) + + // Empty slice removes all tiles + err = ds.SetSetupExperienceSoftwareTitles(ctx, team1.ID, []uint{}) + require.NoError(t, err) + + titles, count, meta, err = ds.ListSetupExperienceSoftwareTitles(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + assert.Len(t, titles, 3) + assert.Equal(t, 3, count) + assert.NotNil(t, meta) + + assert.False(t, *titles[0].SoftwarePackage.InstallDuringSetup) + assert.False(t, *titles[1].SoftwarePackage.InstallDuringSetup) + assert.False(t, *titles[2].AppStoreApp.InstallDuringSetup) + +} + +func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) { + ctx := context.Background() + hostUUID := uuid.NewString() + + // Create a software installer + // We need a new user first + user, err := ds.NewUser(ctx, &fleet.User{Name: "Foo", Email: "foo@example.com", GlobalRole: ptr.String("admin"), Password: []byte("12characterslong!")}) + require.NoError(t, err) + installerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID}) + require.NoError(t, err) + installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) + require.NoError(t, err) + + // VPP setup: create a token so that we can insert a VPP app + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle") + require.NoError(t, err) + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) + assert.NoError(t, err) + vppApp, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{BundleIdentifier: "com.test.test", Name: "test.app", LatestVersion: "1.0.0"}, nil) + require.NoError(t, err) + var vppAppsTeamsID uint + err = sqlx.GetContext(context.Background(), ds.reader(ctx), + &vppAppsTeamsID, `SELECT id FROM vpp_apps_teams WHERE adam_id = ?`, + vppApp.AdamID, + ) + require.NoError(t, err) + + // TODO: use DS methods once those are written + var scriptID uint + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + res, err := q.ExecContext(ctx, `INSERT INTO setup_experience_scripts (name) VALUES (?)`, + "test_script") + require.NoError(t, err) + id, err := res.LastInsertId() + require.NoError(t, err) + scriptID = uint(id) // nolint: gosec + return nil + }) + + insertSetupExperienceStatusResult := func(sesr *fleet.SetupExperienceStatusResult) { + stmt := `INSERT INTO setup_experience_status_results (id, host_uuid, name, status, software_installer_id, host_software_installs_execution_id, vpp_app_team_id, nano_command_uuid, setup_experience_script_id, script_execution_id, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + res, err := q.ExecContext(ctx, stmt, + sesr.ID, sesr.HostUUID, sesr.Name, sesr.Status, sesr.SoftwareInstallerID, sesr.HostSoftwareInstallsExecutionID, sesr.VPPAppTeamID, sesr.NanoCommandUUID, sesr.SetupExperienceScriptID, sesr.ScriptExecutionID, sesr.Error) + require.NoError(t, err) + id, err := res.LastInsertId() + require.NoError(t, err) + sesr.ID = uint(id) // nolint: gosec + return nil + }) + } + + expRes := []*fleet.SetupExperienceStatusResult{ + { + HostUUID: hostUUID, + Name: "software", + Status: fleet.SetupExperienceStatusPending, + SoftwareInstallerID: ptr.Uint(installerID), + SoftwareTitleID: installer.TitleID, + }, + { + HostUUID: hostUUID, + Name: "vpp", + Status: fleet.SetupExperienceStatusPending, + VPPAppTeamID: ptr.Uint(vppAppsTeamsID), + SoftwareTitleID: ptr.Uint(vppApp.TitleID), + }, + { + HostUUID: hostUUID, + Name: "script", + Status: fleet.SetupExperienceStatusPending, + SetupExperienceScriptID: ptr.Uint(scriptID), + }, + } + + for _, r := range expRes { + insertSetupExperienceStatusResult(r) + } + + res, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) + require.NoError(t, err) + require.Len(t, res, 3) + for i, s := range expRes { + require.Equal(t, s, res[i]) + } +} + +func testSetupExperienceScriptCRUD(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + // create a script for team1 + wantScript1 := &fleet.Script{ + Name: "script", + TeamID: &team1.ID, + ScriptContents: "echo foo", + } + + err = ds.SetSetupExperienceScript(ctx, wantScript1) + require.NoError(t, err) + + // get the script for team1 + gotScript1, err := ds.GetSetupExperienceScript(ctx, &team1.ID) + require.NoError(t, err) + require.NotNil(t, gotScript1) + require.Equal(t, wantScript1.Name, gotScript1.Name) + require.Equal(t, wantScript1.TeamID, gotScript1.TeamID) + require.NotZero(t, gotScript1.ScriptContentID) + + b, err := ds.GetAnyScriptContents(ctx, gotScript1.ScriptContentID) + require.NoError(t, err) + require.Equal(t, wantScript1.ScriptContents, string(b)) + + // create a script for team2 + wantScript2 := &fleet.Script{ + Name: "script", + TeamID: &team2.ID, + ScriptContents: "echo bar", + } + + err = ds.SetSetupExperienceScript(ctx, wantScript2) + require.NoError(t, err) + + // get the script for team2 + gotScript2, err := ds.GetSetupExperienceScript(ctx, &team2.ID) + require.NoError(t, err) + require.NotNil(t, gotScript2) + require.Equal(t, wantScript2.Name, gotScript2.Name) + require.Equal(t, wantScript2.TeamID, gotScript2.TeamID) + require.NotZero(t, gotScript2.ScriptContentID) + require.NotEqual(t, gotScript1.ScriptContentID, gotScript2.ScriptContentID) + + b, err = ds.GetAnyScriptContents(ctx, gotScript2.ScriptContentID) + require.NoError(t, err) + require.Equal(t, wantScript2.ScriptContents, string(b)) + + // create a script with no team id + wantScriptNoTeam := &fleet.Script{ + Name: "script", + ScriptContents: "echo bar", + } + + err = ds.SetSetupExperienceScript(ctx, wantScriptNoTeam) + require.NoError(t, err) + + // get the script nil team id is equivalent to team id 0 + gotScriptNoTeam, err := ds.GetSetupExperienceScript(ctx, nil) + require.NoError(t, err) + require.NotNil(t, gotScriptNoTeam) + require.Equal(t, wantScriptNoTeam.Name, gotScriptNoTeam.Name) + require.Nil(t, gotScriptNoTeam.TeamID) + require.NotZero(t, gotScriptNoTeam.ScriptContentID) + require.Equal(t, gotScript2.ScriptContentID, gotScriptNoTeam.ScriptContentID) // should be the same as team2 + + b, err = ds.GetAnyScriptContents(ctx, gotScriptNoTeam.ScriptContentID) + require.NoError(t, err) + require.Equal(t, wantScriptNoTeam.ScriptContents, string(b)) + + // try to create another with name "script" and no team id + var existsErr fleet.AlreadyExistsError + err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script", ScriptContents: "echo baz"}) + require.Error(t, err) + require.ErrorAs(t, err, &existsErr) + + // try to create another script with no team id and a different name + err = ds.SetSetupExperienceScript(ctx, &fleet.Script{Name: "script2", ScriptContents: "echo baz"}) + require.Error(t, err) + require.ErrorAs(t, err, &existsErr) + + // try to add a script for a team that doesn't exist + var fkErr fleet.ForeignKeyError + err = ds.SetSetupExperienceScript(ctx, &fleet.Script{TeamID: ptr.Uint(42), Name: "script", ScriptContents: "echo baz"}) + require.Error(t, err) + require.ErrorAs(t, err, &fkErr) + + // delete the script for team1 + err = ds.DeleteSetupExperienceScript(ctx, &team1.ID) + require.NoError(t, err) + + // get the script for team1 + _, err = ds.GetSetupExperienceScript(ctx, &team1.ID) + require.Error(t, err) + require.ErrorIs(t, err, sql.ErrNoRows) + + // try to delete script for team1 again + err = ds.DeleteSetupExperienceScript(ctx, &team1.ID) + require.NoError(t, err) // TODO: confirm if we want to return not found on deletes + + // try to delete script for team that doesn't exist + err = ds.DeleteSetupExperienceScript(ctx, ptr.Uint(42)) + require.NoError(t, err) // TODO: confirm if we want to return not found on deletes + + // add same script for team1 again + err = ds.SetSetupExperienceScript(ctx, wantScript1) + require.NoError(t, err) + + // get the script for team1 + oldScript1 := gotScript1 + newScript1, err := ds.GetSetupExperienceScript(ctx, &team1.ID) + require.NoError(t, err) + require.NotNil(t, newScript1) + require.Equal(t, wantScript1.Name, newScript1.Name) + require.Equal(t, wantScript1.TeamID, newScript1.TeamID) + require.NotZero(t, newScript1.ScriptContentID) + // script contents are deleted by CleanupUnusedScriptContents not by DeleteSetupExperienceScript + // so the content id should be the same as the old + require.Equal(t, oldScript1.ScriptContentID, newScript1.ScriptContentID) +} + +func testHostInSetupExperience(t *testing.T, ds *Datastore) { + ctx := context.Background() + err := ds.SetHostAwaitingConfiguration(ctx, "abc", true) + require.NoError(t, err) + + inSetupExperience, err := ds.GetHostAwaitingConfiguration(ctx, "abc") + require.NoError(t, err) + require.True(t, inSetupExperience) + + err = ds.SetHostAwaitingConfiguration(ctx, "abc", false) + require.NoError(t, err) + + inSetupExperience, err = ds.GetHostAwaitingConfiguration(ctx, "abc") + require.NoError(t, err) + require.False(t, inSetupExperience) +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 9ab5a1cfecc3..04069f9bf780 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -408,10 +408,14 @@ WHERE return &dest, nil } -var errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."} +var ( + errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."} + errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed when new Macs boot. Please remove software in Controls > Setup experience and try again."} +) func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error { - res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_installers WHERE id = ?`, id) + // allow delete only if install_during_setup is false + res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_installers WHERE id = ? AND install_during_setup = 0`, id) if err != nil { if isMySQLForeignKey(err) { // Check if the software installer is referenced by a policy automation. @@ -428,6 +432,16 @@ func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error rows, _ := res.RowsAffected() if rows == 0 { + // could be that the software installer does not exist, or it is installed + // during setup, do additional check. + var installDuringSetup bool + if err := sqlx.GetContext(ctx, ds.reader(ctx), &installDuringSetup, + `SELECT install_during_setup FROM software_installers WHERE id = ?`, id); err != nil && !errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, err, "check if software installer is installed during setup") + } + if installDuringSetup { + return errDeleteInstallerInstalledDuringSetup + } return notFound("SoftwareInstaller").WithID(id) } @@ -853,6 +867,17 @@ WHERE ) ` + const countInstallDuringSetupNotInList = ` +SELECT + COUNT(*) +FROM + software_installers +WHERE + global_or_team_id = ? AND + title_id NOT IN (?) AND + install_during_setup = 1 +` + const deleteInstallersNotInList = ` DELETE FROM software_installers @@ -865,7 +890,7 @@ WHERE SELECT id, storage_id != ? is_package_modified, install_script_content_id != ? OR uninstall_script_content_id != ? OR pre_install_query != ? OR -COALESCE(post_install_script_content_id != ? OR +COALESCE(post_install_script_content_id != ? OR (post_install_script_content_id IS NULL AND ? IS NOT NULL) OR (? IS NULL AND post_install_script_content_id IS NOT NULL) , FALSE) is_metadata_modified FROM software_installers @@ -877,7 +902,7 @@ INSERT INTO software_installers ( team_id, global_or_team_id, storage_id, - filename, + filename, extension, version, install_script_content_id, @@ -891,11 +916,12 @@ INSERT INTO software_installers ( user_name, user_email, url, - package_ids + package_ids, + install_during_setup ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''), - ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ? + ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false) ) ON DUPLICATE KEY UPDATE install_script_content_id = VALUES(install_script_content_id), @@ -911,7 +937,8 @@ ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), user_name = VALUES(user_name), user_email = VALUES(user_email), - url = VALUES(url) + url = VALUES(url), + install_during_setup = COALESCE(?, install_during_setup) ` // use a team id of 0 if no-team @@ -920,6 +947,15 @@ ON DUPLICATE KEY UPDATE globalOrTeamID = *tmID } + // if we're batch-setting installers and replacing the ones installed during + // setup in the same go, no need to validate that we don't delete one marked + // as install during setup (since we're overwriting those). This is always + // called from fleetctl gitops, so it should always be the case anyway. + var replacingInstallDuringSetup bool + if len(installers) == 0 || installers[0].InstallDuringSetup != nil { + replacingInstallDuringSetup = true + } + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { // if no installers are provided, just delete whatever was in // the table @@ -959,6 +995,21 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "unset obsolete software installers from policies") } + // check if any in the list are install_during_setup, fail if there is one + if !replacingInstallDuringSetup { + stmt, args, err = sqlx.In(countInstallDuringSetupNotInList, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to check installers install_during_setup") + } + var countInstallDuringSetup int + if err := sqlx.GetContext(ctx, tx, &countInstallDuringSetup, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "check installers installed during setup") + } + if countInstallDuringSetup > 0 { + return errDeleteInstallerInstalledDuringSetup + } + } + stmt, args, err = sqlx.In(deleteInstallersNotInList, globalOrTeamID, titleIDs) if err != nil { return ctxerr.Wrap(ctx, err, "build statement to delete obsolete installers") @@ -1041,6 +1092,8 @@ ON DUPLICATE KEY UPDATE installer.UserID, installer.URL, strings.Join(installer.PackageIDs, ","), + installer.InstallDuringSetup, + installer.InstallDuringSetup, } upsertQuery := insertNewOrEditedInstaller if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index e5ff78d9c929..d33a7941c10e 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -32,7 +32,7 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallers", testBatchSetSoftwareInstallers}, {"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID}, {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, - {"DeleteSoftwareInstallersAssignedToPolicy", testDeleteSoftwareInstallersAssignedToPolicy}, + {"DeleteSoftwareInstallers", testDeleteSoftwareInstallers}, {"GetHostLastInstallData", testGetHostLastInstallData}, {"GetOrGenerateSoftwareInstallerTitleID", testGetOrGenerateSoftwareInstallerTitleID}, } @@ -719,21 +719,23 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { }) // add a new installer + ins0 installer + // mark ins0 as install_during_setup ins1 := "installer1" ins1File := bytes.NewReader([]byte("installer1")) err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ { - InstallScript: "install", - InstallerFile: ins0File, - StorageID: ins0, - Filename: ins0, - Title: ins0, - Source: "apps", - Version: "1", - PreInstallQuery: "select 0 from foo;", - UserID: user1.ID, - Platform: "darwin", - URL: "https://example.com", + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: ins0, + Title: ins0, + Source: "apps", + Version: "1", + PreInstallQuery: "select 0 from foo;", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example.com", + InstallDuringSetup: ptr.Bool(true), }, { InstallScript: "install", @@ -767,6 +769,91 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { {Name: ins1, Source: "apps", Browser: ""}, }) + // remove ins0 fails due to install_during_setup + err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + InstallScript: "install", + PostInstallScript: "post-install", + InstallerFile: ins1File, + StorageID: ins1, + Filename: ins1, + Title: ins1, + Source: "apps", + Version: "2", + PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, + }, + }) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup) + + // batch-set both installers again, this time with nil install_during_setup for ins0, + // will keep it as true. + err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: ins0, + Title: ins0, + Source: "apps", + Version: "1", + PreInstallQuery: "select 0 from foo;", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example.com", + InstallDuringSetup: nil, + }, + { + InstallScript: "install", + PostInstallScript: "post-install", + InstallerFile: ins1File, + StorageID: ins1, + Filename: ins1, + Title: ins1, + Source: "apps", + Version: "2", + PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example2.com", + }, + }) + require.NoError(t, err) + + // mark ins0 as NOT install_during_setup + err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: ins0, + Title: ins0, + Source: "apps", + Version: "1", + PreInstallQuery: "select 0 from foo;", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example.com", + InstallDuringSetup: ptr.Bool(false), + }, + { + InstallScript: "install", + PostInstallScript: "post-install", + InstallerFile: ins1File, + StorageID: ins1, + Filename: ins1, + Title: ins1, + Source: "apps", + Version: "2", + PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example2.com", + }, + }) + require.NoError(t, err) + // remove ins0 err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ { @@ -958,7 +1045,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { assert.True(t, hasSelfService) } -func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) { +func testDeleteSoftwareInstallers(t *testing.T, ds *Datastore) { ctx := context.Background() dir := t.TempDir() @@ -1003,8 +1090,29 @@ func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) { _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID}) require.NoError(t, err) + // mark the installer as "installed during setup", which prevents deletion + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE software_installers SET install_during_setup = 1 WHERE id = ?`, softwareInstallerID) + return err + }) + + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup) + + // clear "installed during setup", which allows deletion + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE software_installers SET install_during_setup = 0 WHERE id = ?`, softwareInstallerID) + return err + }) + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) require.NoError(t, err) + + // deleting again returns an error, no such installer + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + var nfe *notFoundError + require.ErrorAs(t, err, &nfe) } func testGetHostLastInstallData(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index e62cb4d7a76c..817b83308b4e 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -108,14 +108,16 @@ func (ds *Datastore) ListSoftwareTitles( // grab titles that match the list options type softwareTitle struct { fleet.SoftwareTitleListResult - PackageSelfService *bool `db:"package_self_service"` - PackageName *string `db:"package_name"` - PackageVersion *string `db:"package_version"` - PackageURL *string `db:"package_url"` - VPPAppSelfService *bool `db:"vpp_app_self_service"` - VPPAppAdamID *string `db:"vpp_app_adam_id"` - VPPAppVersion *string `db:"vpp_app_version"` - VPPAppIconURL *string `db:"vpp_app_icon_url"` + PackageSelfService *bool `db:"package_self_service"` + PackageName *string `db:"package_name"` + PackageVersion *string `db:"package_version"` + PackageURL *string `db:"package_url"` + PackageInstallDuringSetup *bool `db:"package_install_during_setup"` + VPPAppSelfService *bool `db:"vpp_app_self_service"` + VPPAppAdamID *string `db:"vpp_app_adam_id"` + VPPAppVersion *string `db:"vpp_app_version"` + VPPAppIconURL *string `db:"vpp_app_icon_url"` + VPPInstallDuringSetup *bool `db:"vpp_install_during_setup"` } var softwareList []*softwareTitle getTitlesStmt, args = appendListOptionsWithCursorToSQL(getTitlesStmt, args, &opt.ListOptions) @@ -150,10 +152,11 @@ func (ds *Datastore) ListSoftwareTitles( version = *title.PackageVersion } title.SoftwarePackage = &fleet.SoftwarePackageOrApp{ - Name: *title.PackageName, - Version: version, - SelfService: title.PackageSelfService, - PackageURL: title.PackageURL, + Name: *title.PackageName, + Version: version, + SelfService: title.PackageSelfService, + PackageURL: title.PackageURL, + InstallDuringSetup: title.PackageInstallDuringSetup, } } @@ -164,10 +167,11 @@ func (ds *Datastore) ListSoftwareTitles( version = *title.VPPAppVersion } title.AppStoreApp = &fleet.SoftwarePackageOrApp{ - AppStoreID: *title.VPPAppAdamID, - Version: version, - SelfService: title.VPPAppSelfService, - IconURL: title.VPPAppIconURL, + AppStoreID: *title.VPPAppAdamID, + Version: version, + SelfService: title.VPPAppSelfService, + IconURL: title.VPPAppIconURL, + InstallDuringSetup: title.VPPInstallDuringSetup, } } @@ -265,8 +269,10 @@ SELECT si.filename as package_name, si.version as package_version, si.url AS package_url, + si.install_during_setup as package_install_during_setup, vat.self_service as vpp_app_self_service, vat.adam_id as vpp_app_adam_id, + vat.install_during_setup as vpp_install_during_setup, vap.latest_version as vpp_app_version, vap.icon_url as vpp_app_icon_url FROM software_titles st @@ -280,7 +286,7 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, package_self_service, package_name, package_version, package_url, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url` +GROUP BY st.id, package_self_service, package_name, package_version, package_url, package_install_during_setup, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url, vpp_install_during_setup` cveJoinType := "LEFT" if opt.VulnerableOnly { @@ -359,6 +365,11 @@ GROUP BY st.id, package_self_service, package_name, package_version, package_url args = append(args, match, match) } + if opt.Platform != "" { + additionalWhere += ` AND ( si.platform = ? OR vap.platform = ? )` + args = append(args, opt.Platform, opt.Platform) + } + // default to "a software installer or VPP app exists", and see next condition. defaultFilter := fmt.Sprintf(` ((si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND %s) diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 175d49f8eed7..df4b7bc2d782 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -642,6 +642,8 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { BootstrapPackage: optjson.SetString("bootstrap"), MacOSSetupAssistant: optjson.SetString("assistant"), EnableReleaseDeviceManually: optjson.SetBool(false), + Script: optjson.String{Set: true}, + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo"}, {Path: "bar"}}), diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index a53fb79b9f2a..03a4c78706c5 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -169,10 +169,19 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets return ctxerr.Wrap(ctx, err, "SetTeamVPPApps getting list of existing apps") } + // if we're batch-setting apps and replacing the ones installed during setup + // in the same go, no need to validate that we don't delete one marked as + // install during setup (since we're overwriting those). This is always + // called from fleetctl gitops, so it should always be the case anyway. + var replacingInstallDuringSetup bool + if len(appFleets) == 0 || appFleets[0].InstallDuringSetup != nil { + replacingInstallDuringSetup = true + } + var toAddApps []fleet.VPPAppTeam var toRemoveApps []fleet.VPPAppID - for existingApp := range existingApps { + for existingApp, appTeamInfo := range existingApps { var found bool for _, appFleet := range appFleets { // Self service value doesn't matter for removing app from team @@ -181,12 +190,19 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets } } if !found { + // if app is marked as install during setup, prevent deletion unless we're replacing those. + if !replacingInstallDuringSetup && appTeamInfo.InstallDuringSetup != nil && *appTeamInfo.InstallDuringSetup { + return errDeleteInstallerInstalledDuringSetup + } toRemoveApps = append(toRemoveApps, existingApp) } } for _, appFleet := range appFleets { - if existingFleet, ok := existingApps[appFleet.VPPAppID]; !ok || existingFleet.SelfService != appFleet.SelfService { + // upsert it if it does not exist or SelfService or InstallDuringSetup flags are changed + if existingFleet, ok := existingApps[appFleet.VPPAppID]; !ok || existingFleet.SelfService != appFleet.SelfService || + appFleet.InstallDuringSetup != nil && + existingFleet.InstallDuringSetup != nil && *appFleet.InstallDuringSetup != *existingFleet.InstallDuringSetup { toAddApps = append(toAddApps, appFleet) } } @@ -250,7 +266,7 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp func (ds *Datastore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]fleet.VPPAppTeam, error) { stmt := ` SELECT - adam_id, platform, self_service + adam_id, platform, self_service, install_during_setup FROM vpp_apps_teams vat WHERE @@ -305,11 +321,13 @@ ON DUPLICATE KEY UPDATE func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID uint) error { stmt := ` INSERT INTO vpp_apps_teams - (adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id) + (adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id, install_during_setup) VALUES - (?, ?, ?, ?, ?, ?) -ON DUPLICATE KEY UPDATE self_service = VALUES(self_service) - ` + (?, ?, ?, ?, ?, ?, COALESCE(?, false)) +ON DUPLICATE KEY UPDATE + self_service = VALUES(self_service), + install_during_setup = COALESCE(?, install_during_setup) +` var globalOrTmID uint if teamID != nil { @@ -320,7 +338,7 @@ ON DUPLICATE KEY UPDATE self_service = VALUES(self_service) } } - _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID) + _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID, appID.InstallDuringSetup, appID.InstallDuringSetup) if IsDuplicate(err) { err = &existsError{ Identifier: fmt.Sprintf("%s %s self_service: %v", appID.AdamID, appID.Platform, appID.SelfService), @@ -415,7 +433,8 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s } func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, appID fleet.VPPAppID) error { - const stmt = `DELETE FROM vpp_apps_teams WHERE global_or_team_id = ? AND adam_id = ? AND platform = ?` + // allow delete only if install_during_setup is false + const stmt = `DELETE FROM vpp_apps_teams WHERE global_or_team_id = ? AND adam_id = ? AND platform = ? AND install_during_setup = 0` var globalOrTeamID uint if teamID != nil { @@ -428,6 +447,16 @@ func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, app rows, _ := res.RowsAffected() if rows == 0 { + // could be that the VPP app does not exist, or it is installed during + // setup, do additional check. + var installDuringSetup bool + if err := sqlx.GetContext(ctx, ds.reader(ctx), &installDuringSetup, + `SELECT install_during_setup FROM vpp_apps_teams WHERE global_or_team_id = ? AND adam_id = ? AND platform = ?`, globalOrTeamID, appID.AdamID, appID.Platform); err != nil && !errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, err, "check if vpp app is installed during setup") + } + if installDuringSetup { + return errDeleteInstallerInstalledDuringSetup + } return notFound("VPPApp").WithMessage(fmt.Sprintf("adam id %s platform %s for team id %d", appID.AdamID, appID.Platform, globalOrTeamID)) } diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 78cab8acba22..a2f9ca9b5dbf 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -182,6 +182,16 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + // mark it as install_during_setup for team 2 + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE global_or_team_id = ? AND adam_id = ?`, team2.ID, vpp2.AdamID) + return err + }) + // this prevents its deletion + err = ds.DeleteVPPAppFromTeam(ctx, &team2.ID, vpp2) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup) + // delete vpp1 again fails, not found err = ds.DeleteVPPAppFromTeam(ctx, nil, vpp1) require.Error(t, err) @@ -501,11 +511,17 @@ func testVPPApps(t *testing.T, ds *Datastore) { // Check that getting the assigned apps works appSet, err := ds.GetAssignedVPPApps(ctx, &team.ID) require.NoError(t, err) - assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{app1.VPPAppID: {VPPAppID: app1.VPPAppID}, app2.VPPAppID: {VPPAppID: app2.VPPAppID}}, appSet) + assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{ + app1.VPPAppID: {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(false)}, + app2.VPPAppID: {VPPAppID: app2.VPPAppID, InstallDuringSetup: ptr.Bool(false)}, + }, appSet) appSet, err = ds.GetAssignedVPPApps(ctx, nil) require.NoError(t, err) - assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{appNoTeam1.VPPAppID: {VPPAppID: appNoTeam1.VPPAppID}, appNoTeam2.VPPAppID: {VPPAppID: appNoTeam2.VPPAppID}}, appSet) + assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{ + appNoTeam1.VPPAppID: {VPPAppID: appNoTeam1.VPPAppID, InstallDuringSetup: ptr.Bool(false)}, + appNoTeam2.VPPAppID: {VPPAppID: appNoTeam2.VPPAppID, InstallDuringSetup: ptr.Bool(false)}, + }, appSet) var appTitles []fleet.SoftwareTitle err = sqlx.SelectContext(ctx, ds.reader(ctx), &appTitles, `SELECT name, bundle_identifier FROM software_titles WHERE bundle_identifier IN (?,?) ORDER BY bundle_identifier`, app1.BundleIdentifier, app2.BundleIdentifier) @@ -531,7 +547,7 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) assert.NoError(t, err) - // Insert some VPP apps for the team + // Insert some VPP apps for no team app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} _, err = ds.InsertVPPAppWithTeam(ctx, app1, nil) require.NoError(t, err) @@ -550,8 +566,9 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { require.Len(t, assigned, 0) // Assign 2 apps + // make app1 install_during_setup for that team err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ - {VPPAppID: app1.VPPAppID}, + {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(true)}, {VPPAppID: app2.VPPAppID, SelfService: true}, }) require.NoError(t, err) @@ -562,10 +579,11 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { assert.Contains(t, assigned, app1.VPPAppID) assert.Contains(t, assigned, app2.VPPAppID) assert.True(t, assigned[app2.VPPAppID].SelfService) + assert.True(t, *assigned[app1.VPPAppID].InstallDuringSetup) // Assign an additional app err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ - {VPPAppID: app1.VPPAppID}, + {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(true)}, {VPPAppID: app2.VPPAppID}, {VPPAppID: app3.VPPAppID}, }) @@ -578,10 +596,11 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { require.Contains(t, assigned, app2.VPPAppID) require.Contains(t, assigned, app3.VPPAppID) assert.False(t, assigned[app2.VPPAppID].SelfService) + assert.True(t, *assigned[app1.VPPAppID].InstallDuringSetup) // Swap one app out for another err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ - {VPPAppID: app1.VPPAppID}, + {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(true)}, {VPPAppID: app2.VPPAppID, SelfService: true}, {VPPAppID: app4.VPPAppID}, }) @@ -594,6 +613,30 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { require.Contains(t, assigned, app2.VPPAppID) require.Contains(t, assigned, app4.VPPAppID) assert.True(t, assigned[app2.VPPAppID].SelfService) + assert.True(t, *assigned[app1.VPPAppID].InstallDuringSetup) + + // Remove app1 fails because it is installed during setup + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app2.VPPAppID, SelfService: true}, + {VPPAppID: app4.VPPAppID}, + }) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteInstallerInstalledDuringSetup) + + // make app1 NOT install_during_setup for that team + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app1.VPPAppID, InstallDuringSetup: ptr.Bool(false)}, + {VPPAppID: app2.VPPAppID, SelfService: true}, + {VPPAppID: app4.VPPAppID}, + }) + require.NoError(t, err) + + // Remove app1 now works + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app2.VPPAppID, SelfService: true}, + {VPPAppID: app4.VPPAppID}, + }) + require.NoError(t, err) // Remove all apps err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{}) diff --git a/server/fleet/app.go b/server/fleet/app.go index f0810e352df6..483dd5fe796a 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -420,10 +420,19 @@ func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, erro // MacOSSetup contains settings related to the setup of DEP enrolled devices. type MacOSSetup struct { - BootstrapPackage optjson.String `json:"bootstrap_package"` - EnableEndUserAuthentication bool `json:"enable_end_user_authentication"` - MacOSSetupAssistant optjson.String `json:"macos_setup_assistant"` - EnableReleaseDeviceManually optjson.Bool `json:"enable_release_device_manually"` + BootstrapPackage optjson.String `json:"bootstrap_package"` + EnableEndUserAuthentication bool `json:"enable_end_user_authentication"` + MacOSSetupAssistant optjson.String `json:"macos_setup_assistant"` + EnableReleaseDeviceManually optjson.Bool `json:"enable_release_device_manually"` + Script optjson.String `json:"script"` + Software optjson.Slice[*MacOSSetupSoftware] `json:"software"` +} + +// MacOSSetupSoftware represents a VPP app or a software package to install +// during the setup experience of a macOS device. +type MacOSSetupSoftware struct { + AppStoreID string `json:"app_store_id"` + PackagePath string `json:"package_path"` } // MacOSMigration contains settings related to the MDM migration work flow. @@ -670,6 +679,15 @@ func (c *AppConfig) Copy() *AppConfig { clone.MDM.VolumePurchasingProgram = optjson.SetSlice(vpp) } + if c.MDM.MacOSSetup.Software.Set { + sw := make([]*MacOSSetupSoftware, len(c.MDM.MacOSSetup.Software.Value)) + for i, s := range c.MDM.MacOSSetup.Software.Value { + s := *s + sw[i] = &s + } + clone.MDM.MacOSSetup.Software = optjson.SetSlice(sw) + } + return &clone } diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index e73159d7c5db..84ab0f7636aa 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -24,6 +24,7 @@ type MDMAppleCommandIssuer interface { EraseDevice(ctx context.Context, host *Host, uuid string) error InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error InstallApplication(ctx context.Context, hostUUIDs []string, uuid string, adamID string) error + DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error } // MDMAppleEnrollmentType is the type for Apple MDM enrollments. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 415334b6c831..93a4d6ff2207 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1746,7 +1746,32 @@ type Datastore interface { GetVPPTokenByLocation(ctx context.Context, loc string) (*VPPTokenDB, error) + //////////////////////////////////////////////////////////////////////////////////// + // Setup Experience + SetSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, titleIDs []uint) error + ListSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, opts ListOptions) ([]SoftwareTitleListResult, int, *PaginationMetadata, error) + + // SetHostAwaitingConfiguration sets a boolean indicating whether or not the given host is + // in the setup experience flow (which runs during macOS Setup Assistant). + SetHostAwaitingConfiguration(ctx context.Context, hostUUID string, inSetupExperience bool) error + // GetHostAwaitingConfiguration returns a boolean indicating whether or not the given host is + // in the setup experience flow (which runs during macOS Setup Assistant). + GetHostAwaitingConfiguration(ctx context.Context, hostUUID string) (bool, error) + /////////////////////////////////////////////////////////////////////////////// + // Setup Experience + // + + ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*SetupExperienceStatusResult, error) + UpdateSetupExperienceStatusResult(ctx context.Context, status *SetupExperienceStatusResult) error + EnqueueSetupExperienceItems(ctx context.Context, hostUUID string, teamID uint) (bool, error) + GetSetupExperienceScript(ctx context.Context, teamID *uint) (*Script, error) + SetSetupExperienceScript(ctx context.Context, script *Script) error + DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error + MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status SetupExperienceStatusResultStatus) (bool, error) + MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status SetupExperienceStatusResultStatus) (bool, error) + MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, commandUUID string, status SetupExperienceStatusResultStatus) (bool, error) + // Fleet-maintained apps // diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go index 936c2678c987..1d340b1e7b34 100644 --- a/server/fleet/orbit.go +++ b/server/fleet/orbit.go @@ -36,6 +36,10 @@ type OrbitConfigNotifications struct { // PendingSoftwareInstallerIDs contains a list of software install_ids queued for installation PendingSoftwareInstallerIDs []string `json:"pending_software_installer_ids,omitempty"` + + // RunSetupExperience indicates whether or not Orbit should run the Fleet setup experience + // during macOS Setup Assistant. + RunSetupExperience bool `json:"run_setup_experience,omitempty"` } type OrbitConfig struct { diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 1dcbb343e9bc..28bba6ad8cf7 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -373,14 +373,15 @@ type ScriptPayload struct { } type SoftwareInstallerPayload struct { - URL string `json:"url"` - PreInstallQuery string `json:"pre_install_query"` - InstallScript string `json:"install_script"` - UninstallScript string `json:"uninstall_script"` - PostInstallScript string `json:"post_install_script"` - SelfService bool `json:"self_service"` - FleetMaintained bool `json:"-"` - Filename string `json:"-"` + URL string `json:"url"` + PreInstallQuery string `json:"pre_install_query"` + InstallScript string `json:"install_script"` + UninstallScript string `json:"uninstall_script"` + PostInstallScript string `json:"post_install_script"` + SelfService bool `json:"self_service"` + FleetMaintained bool `json:"-"` + Filename string `json:"-"` + InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it } type HostLockWipeStatus struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index 029f22cbdc77..e7f70e807260 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -35,6 +35,7 @@ type EnterpriseOverrides struct { MDMWindowsEnableOSUpdates func(ctx context.Context, teamID *uint, updates WindowsUpdates) error MDMWindowsDisableOSUpdates func(ctx context.Context, teamID *uint) error MDMAppleEditedAppleOSUpdates func(ctx context.Context, teamID *uint, appleDevice AppleDevice, updates AppleOSUpdateSettings) error + SetupExperienceNextStep func(ctx context.Context, hostUUID string) (bool, error) } type OsqueryService interface { @@ -1120,6 +1121,24 @@ type Service interface { teamID *uint) (*DownloadSoftwareInstallerPayload, error) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*DownloadSoftwareInstallerPayload, error) + //////////////////////////////////////////////////////////////////////////////// + // Setup Experience + + SetSetupExperienceSoftware(ctx context.Context, teamID uint, titleIDs []uint) error + ListSetupExperienceSoftware(ctx context.Context, teamID uint, opts ListOptions) ([]SoftwareTitleListResult, int, *PaginationMetadata, error) + // GetOrbitSetupExperienceStatus gets the current status of a macOS setup experience for the given host. + GetOrbitSetupExperienceStatus(ctx context.Context, orbitNodeKey string, forceRelease bool) (*SetupExperienceStatusPayload, error) + // GetSetupExperienceScript gets the current setup experience script for the given team. + GetSetupExperienceScript(ctx context.Context, teamID *uint, downloadRequested bool) (*Script, []byte, error) + // SetSetupExperienceScript sets the setup experience script for the given team. + SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error + // DeleteSetupExperienceScript deletes the setup experience script for the given team. + DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error + // SetupExperienceNextStep is a callback that processes the + // setup experience status results table and enqueues the next + // step. It returns true when there is nothing left to do (setup finished) + SetupExperienceNextStep(ctx context.Context, hostUUID string) (bool, error) + /////////////////////////////////////////////////////////////////////////////// // Fleet-maintained apps diff --git a/server/fleet/setup_experience.go b/server/fleet/setup_experience.go new file mode 100644 index 000000000000..7486e776e5a8 --- /dev/null +++ b/server/fleet/setup_experience.go @@ -0,0 +1,191 @@ +package fleet + +import ( + "errors" + "fmt" +) + +type SetupExperienceStatusResultStatus string + +const ( + SetupExperienceStatusPending SetupExperienceStatusResultStatus = "pending" + SetupExperienceStatusRunning SetupExperienceStatusResultStatus = "running" + SetupExperienceStatusSuccess SetupExperienceStatusResultStatus = "success" + SetupExperienceStatusFailure SetupExperienceStatusResultStatus = "failure" +) + +func (s SetupExperienceStatusResultStatus) IsValid() bool { + switch s { + case SetupExperienceStatusPending, SetupExperienceStatusRunning, SetupExperienceStatusSuccess, SetupExperienceStatusFailure: + return true + default: + return false + } +} + +func (s SetupExperienceStatusResultStatus) IsTerminalStatus() bool { + switch s { + case SetupExperienceStatusSuccess, SetupExperienceStatusFailure: + return true + default: + return false + } +} + +// SetupExperienceStatusResult represents the status of a particular step in the macOS setup +// experience process for a particular host. These steps can either be a software installer +// installation, a VPP app installation, or a script execution. +type SetupExperienceStatusResult struct { + ID uint `db:"id" json:"-" ` + HostUUID string `db:"host_uuid" json:"-" ` + Name string `db:"name" json:"name,omitempty" ` + Status SetupExperienceStatusResultStatus `db:"status" json:"status,omitempty" ` + SoftwareInstallerID *uint `db:"software_installer_id" json:"-" ` + HostSoftwareInstallsExecutionID *string `db:"host_software_installs_execution_id" json:"-" ` + VPPAppTeamID *uint `db:"vpp_app_team_id" json:"-" ` + VPPAppAdamID *string `db:"vpp_app_adam_id" json:"-"` + VPPAppPlatform *string `db:"vpp_app_platform" json:"-"` + NanoCommandUUID *string `db:"nano_command_uuid" json:"-" ` + SetupExperienceScriptID *uint `db:"setup_experience_script_id" json:"-" ` + ScriptContentID *uint `db:"script_content_id" json:"-"` + ScriptExecutionID *string `db:"script_execution_id" json:"execution_id,omitempty" ` + Error *string `db:"error" json:"-" ` + // SoftwareTitleID must be filled through a JOIN + SoftwareTitleID *uint `json:"software_title_id,omitempty" db:"software_title_id"` +} + +func (s *SetupExperienceStatusResult) IsValid() error { + var colsSet uint + if s.SoftwareInstallerID != nil { + colsSet++ + if s.NanoCommandUUID != nil || s.ScriptExecutionID != nil { + return fmt.Errorf("invalid setup experience staus row, software_installer_id set with incorrect secondary value column: %d", s.ID) + } + } + if s.VPPAppTeamID != nil { + colsSet++ + if s.HostSoftwareInstallsExecutionID != nil || s.ScriptExecutionID != nil { + return fmt.Errorf("invalid setup experience staus row, vpp_app_team set with incorrect secondary value column: %d", s.ID) + } + } + if s.SetupExperienceScriptID != nil { + colsSet++ + if s.HostSoftwareInstallsExecutionID != nil || s.NanoCommandUUID != nil { + return fmt.Errorf("invalid setup experience staus row, setip_experience_script_id set with incorrect secondary value column: %d", s.ID) + } + } + if colsSet > 1 { + return fmt.Errorf("invalid setup experience status row, multiple underlying value columns set: %d", s.ID) + } + if colsSet == 0 { + return fmt.Errorf("invalid setup experience status row, no underlying value colunm set: %d", s.ID) + } + + return nil + +} + +func (s *SetupExperienceStatusResult) VPPAppID() (*VPPAppID, error) { + if s.VPPAppAdamID == nil || s.VPPAppPlatform == nil { + return nil, errors.New("not a VPP app") + } + + return &VPPAppID{ + AdamID: *s.VPPAppAdamID, + Platform: AppleDevicePlatform(*s.VPPAppPlatform), + }, nil +} + +// IsForScript indicates if this result is for a setup experience script step. +func (s *SetupExperienceStatusResult) IsForScript() bool { + return s.SetupExperienceScriptID != nil +} + +// IsForSoftware indicates if this result is for a setup experience software step: either a software +// installer or a VPP app. +func (s *SetupExperienceStatusResult) IsForSoftware() bool { + return s.VPPAppTeamID != nil || s.SoftwareInstallerID != nil +} + +type SetupExperienceBootstrapPackageResult struct { + Name string `json:"name"` + Status MDMBootstrapPackageStatus `json:"status"` +} + +type SetupExperienceConfigurationProfileResult struct { + ProfileUUID string `json:"profile_uuid"` + Name string `json:"name"` + Status MDMDeliveryStatus `json:"status"` +} + +type SetupExperienceAccountConfigurationResult struct { + CommandUUID string `json:"command_uuid"` + Status string `json:"status"` +} + +type SetupExperienceVPPInstallResult struct { + HostUUID string + CommandUUID string + CommandStatus string +} + +func (r SetupExperienceVPPInstallResult) SetupExperienceStatus() SetupExperienceStatusResultStatus { + switch r.CommandStatus { + case MDMAppleStatusAcknowledged: + return SetupExperienceStatusSuccess + case MDMAppleStatusError, MDMAppleStatusCommandFormatError: + return SetupExperienceStatusFailure + default: + // TODO: is this what we want as the default, what about other possible statuses? + return SetupExperienceStatusPending + } +} + +type SetupExperienceSoftwareInstallResult struct { + HostUUID string + ExecutionID string + InstallerStatus SoftwareInstallerStatus +} + +func (r SetupExperienceSoftwareInstallResult) SetupExperienceStatus() SetupExperienceStatusResultStatus { + switch r.InstallerStatus { + case SoftwareInstalled: + return SetupExperienceStatusSuccess + case SoftwareFailed, SoftwareInstallFailed: + return SetupExperienceStatusFailure + default: + // TODO: is this what we want as the default, what about other possible statuses (uninstall)? + return SetupExperienceStatusPending + } +} + +type SetupExperienceScriptResult struct { + HostUUID string + ExecutionID string + ExitCode int +} + +func (r SetupExperienceScriptResult) SetupExperienceStatus() SetupExperienceStatusResultStatus { + if r.ExitCode == 0 { + return SetupExperienceStatusSuccess + } + // TODO: what about other possible script statuses? seems like pending/running is never a + // possibility here (exit code can't be null)? + return SetupExperienceStatusFailure +} + +// SetupExperienceStatusPayload is the payload we send to Orbit to tell it what the current status +// of the setup experience is for that host. +type SetupExperienceStatusPayload struct { + Script *SetupExperienceStatusResult `json:"script,omitempty"` + Software []*SetupExperienceStatusResult `json:"software,omitempty"` + BootstrapPackage *SetupExperienceBootstrapPackageResult `json:"bootstrap_package,omitempty"` + ConfigurationProfiles []*SetupExperienceConfigurationProfileResult `json:"configuration_profiles,omitempty"` + AccountConfiguration *SetupExperienceAccountConfigurationResult `json:"account_configuration,omitempty"` + OrgLogoURL string `json:"org_logo_url"` +} + +func IsSetupExperienceSupported(hostPlatform string) bool { + // TODO: confirm we aren't supporting any other Apple platforms + return hostPlatform == "darwin" +} diff --git a/server/fleet/setup_experience_test.go b/server/fleet/setup_experience_test.go new file mode 100644 index 000000000000..8117d049aff9 --- /dev/null +++ b/server/fleet/setup_experience_test.go @@ -0,0 +1,144 @@ +package fleet + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/require" +) + +func TestSetupExperienceStatusResultIsValid(t *testing.T) { + id := ptr.Uint(1) + str := ptr.String("x") + for _, tc := range []struct { + Name string + Case SetupExperienceStatusResult + Valid bool + }{ + { + Case: SetupExperienceStatusResult{ + SoftwareInstallerID: id, + }, + Valid: true, + Name: "just software installer", + }, + { + Case: SetupExperienceStatusResult{ + SoftwareInstallerID: id, + HostSoftwareInstallsExecutionID: str, + }, + Valid: true, + Name: "software and result", + }, + { + Case: SetupExperienceStatusResult{ + SoftwareInstallerID: id, + NanoCommandUUID: str, + }, + Valid: false, + Name: "installer and vpp secondary", + }, + { + Case: SetupExperienceStatusResult{ + SoftwareInstallerID: id, + ScriptExecutionID: str, + }, + Valid: false, + Name: "installer and script secondary", + }, + + { + Case: SetupExperienceStatusResult{ + VPPAppTeamID: id, + }, + Valid: true, + Name: "just vpp app team", + }, + { + Case: SetupExperienceStatusResult{ + VPPAppTeamID: id, + NanoCommandUUID: str, + }, + Valid: true, + Name: "vpp app and result", + }, + { + Case: SetupExperienceStatusResult{ + VPPAppTeamID: id, + HostSoftwareInstallsExecutionID: str, + }, + Valid: false, + Name: "vpp and installer secondary", + }, + { + Case: SetupExperienceStatusResult{ + VPPAppTeamID: id, + ScriptExecutionID: str, + }, + Valid: false, + Name: "vpp and script secondary", + }, + { + Case: SetupExperienceStatusResult{ + SetupExperienceScriptID: id, + }, + Valid: true, + Name: "just script id", + }, + { + Case: SetupExperienceStatusResult{ + SetupExperienceScriptID: id, + ScriptExecutionID: str, + }, + Valid: true, + Name: "script and result", + }, + { + Case: SetupExperienceStatusResult{ + SetupExperienceScriptID: id, + HostSoftwareInstallsExecutionID: str, + }, + Valid: false, + Name: "script and installer secondary", + }, + { + Case: SetupExperienceStatusResult{ + SetupExperienceScriptID: id, + NanoCommandUUID: str, + }, + Valid: false, + Name: "script and vpp secondary", + }, + { + Case: SetupExperienceStatusResult{ + SoftwareInstallerID: id, + VPPAppTeamID: id, + }, + Valid: false, + Name: "installer and vpp", + }, + { + Case: SetupExperienceStatusResult{ + SoftwareInstallerID: id, + SetupExperienceScriptID: id, + }, + Valid: false, + Name: "installer and script", + }, + { + Case: SetupExperienceStatusResult{ + VPPAppTeamID: id, + SetupExperienceScriptID: id, + }, + Valid: false, + Name: "vpp and script", + }, + } { + err := tc.Case.IsValid() + if tc.Valid { + require.NoError(t, err, tc.Name) + } else { + require.Error(t, err, tc.Name) + } + } +} diff --git a/server/fleet/software.go b/server/fleet/software.go index a75538d03b56..0f9eeab4f21f 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -227,6 +227,7 @@ type SoftwareTitleListOptions struct { MinimumCVSS float64 `query:"min_cvss_score,optional"` MaximumCVSS float64 `query:"max_cvss_score,optional"` PackagesOnly bool `query:"packages_only,optional"` + Platform string `query:"platform,optional"` } type HostSoftwareTitleListOptions struct { @@ -426,12 +427,14 @@ func SoftwareFromOsqueryRow(name, version, source, vendor, installedPath, releas } type VPPBatchPayload struct { - AppStoreID string `json:"app_store_id"` - SelfService bool `json:"self_service"` + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` + InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated } type VPPBatchPayloadWithPlatform struct { - AppStoreID string `json:"app_store_id"` - SelfService bool `json:"self_service"` - Platform AppleDevicePlatform `json:"platform"` + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` + Platform AppleDevicePlatform `json:"platform"` + InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 47e7ed91fe89..d715e1ddc5cf 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -308,25 +308,26 @@ func (s *HostSoftwareInstallerResultAuthz) AuthzType() string { } type UploadSoftwareInstallerPayload struct { - TeamID *uint - InstallScript string - PreInstallQuery string - PostInstallScript string - InstallerFile io.ReadSeeker // TODO: maybe pull this out of the payload and only pass it to methods that need it (e.g., won't be needed when storing metadata in the database) - StorageID string - Filename string - Title string - Version string - Source string - Platform string - BundleIdentifier string - SelfService bool - UserID uint - URL string - FleetLibraryAppID *uint - PackageIDs []string - UninstallScript string - Extension string + TeamID *uint + InstallScript string + PreInstallQuery string + PostInstallScript string + InstallerFile io.ReadSeeker // TODO: maybe pull this out of the payload and only pass it to methods that need it (e.g., won't be needed when storing metadata in the database) + StorageID string + Filename string + Title string + Version string + Source string + Platform string + BundleIdentifier string + SelfService bool + UserID uint + URL string + FleetLibraryAppID *uint + PackageIDs []string + UninstallScript string + Extension string + InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated } type UpdateSoftwareInstallerPayload struct { @@ -426,6 +427,9 @@ type SoftwarePackageOrApp struct { LastInstall *HostSoftwareInstall `json:"last_install"` LastUninstall *HostSoftwareUninstall `json:"last_uninstall"` PackageURL *string `json:"package_url"` + // InstallDuringSetup is a boolean that indicates if the package + // will be installed during the macos setup experience. + InstallDuringSetup *bool `json:"install_during_setup,omitempty" db:"install_during_setup"` } type SoftwarePackageSpec struct { @@ -435,6 +439,16 @@ type SoftwarePackageSpec struct { InstallScript TeamSpecSoftwareAsset `json:"install_script"` PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` + + // ReferencedYamlPath is the resolved path of the file used to fill the + // software package. Only present after parsing a GitOps file on the fleetctl + // side of processing. This is required to match a macos_setup.software to + // its corresponding software package, as we do this matching by yaml path. + // + // It must be JSON-marshaled because it gets set during gitops file processing, + // which is then re-marshaled to JSON from this struct and later re-unmarshaled + // during ApplyGroup... + ReferencedYamlPath string `json:"referenced_yaml_path"` } type SoftwareSpec struct { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 68cde3072dcc..4ee8b53aad65 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -239,6 +239,14 @@ func (t *TeamMDM) Copy() *TeamMDM { } clone.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) } + if t.MacOSSetup.Software.Set { + sw := make([]*MacOSSetupSoftware, len(t.MacOSSetup.Software.Value)) + for i, s := range t.MacOSSetup.Software.Value { + s := *s + sw[i] = &s + } + clone.MacOSSetup.Software = optjson.SetSlice(sw) + } return &clone } @@ -518,5 +526,7 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { HostExpirySettings: &t.Config.HostExpirySettings, WebhookSettings: webhookSettings, Integrations: integrations, + Scripts: t.Config.Scripts, + Software: t.Config.Software, }, nil } diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index 4e0803edfea6..a5ecc83ca6ef 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -17,6 +17,12 @@ type VPPAppTeam struct { VPPAppID SelfService bool `db:"self_service" json:"self_service"` + + // InstallDuringSetup is either the stored value of that flag for the VPP app + // or the value to set to that VPP app when batch-setting it. When used to + // set the value, if nil it will keep the currently saved value (or default + // to false), while if not nil, it will update the flag's value in the DB. + InstallDuringSetup *bool `db:"install_during_setup" json:"-"` } // VPPApp represents a VPP (Volume Purchase Program) application, diff --git a/server/mdm/nanomdm/mdm/mdm.go b/server/mdm/nanomdm/mdm/mdm.go index e3ec762bd39a..ac429a69b0e5 100644 --- a/server/mdm/nanomdm/mdm/mdm.go +++ b/server/mdm/nanomdm/mdm/mdm.go @@ -9,12 +9,13 @@ import ( // Enrollment represents the various enrollment-related data sent with requests. type Enrollment struct { - UDID string `plist:",omitempty"` - UserID string `plist:",omitempty"` - UserShortName string `plist:",omitempty"` - UserLongName string `plist:",omitempty"` - EnrollmentID string `plist:",omitempty"` - EnrollmentUserID string `plist:",omitempty"` + AwaitingConfiguration bool `plist:",omitempty"` + UDID string `plist:",omitempty"` + UserID string `plist:",omitempty"` + UserShortName string `plist:",omitempty"` + UserLongName string `plist:",omitempty"` + EnrollmentID string `plist:",omitempty"` + EnrollmentUserID string `plist:",omitempty"` } // EnrollID contains the custom enrollment IDs derived from enrollment diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 66d60f95368e..93b125a007ad 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1101,6 +1101,32 @@ type GetPastActivityDataForVPPAppInstallFunc func(ctx context.Context, commandRe type GetVPPTokenByLocationFunc func(ctx context.Context, loc string) (*fleet.VPPTokenDB, error) +type SetSetupExperienceSoftwareTitlesFunc func(ctx context.Context, teamID uint, titleIDs []uint) error + +type ListSetupExperienceSoftwareTitlesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) + +type SetHostAwaitingConfigurationFunc func(ctx context.Context, hostUUID string, inSetupExperience bool) error + +type GetHostAwaitingConfigurationFunc func(ctx context.Context, hostUUID string) (bool, error) + +type ListSetupExperienceResultsByHostUUIDFunc func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) + +type UpdateSetupExperienceStatusResultFunc func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error + +type EnqueueSetupExperienceItemsFunc func(ctx context.Context, hostUUID string, teamID uint) (bool, error) + +type GetSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) (*fleet.Script, error) + +type SetSetupExperienceScriptFunc func(ctx context.Context, script *fleet.Script) error + +type DeleteSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) error + +type MaybeUpdateSetupExperienceScriptStatusFunc func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) + +type MaybeUpdateSetupExperienceSoftwareInstallStatusFunc func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) + +type MaybeUpdateSetupExperienceVPPStatusFunc func(ctx context.Context, hostUUID string, commandUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) + type ListAvailableFleetMaintainedAppsFunc func(ctx context.Context, teamID uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) type GetMaintainedAppByIDFunc func(ctx context.Context, appID uint) (*fleet.MaintainedApp, error) @@ -2734,6 +2760,45 @@ type DataStore struct { GetVPPTokenByLocationFunc GetVPPTokenByLocationFunc GetVPPTokenByLocationFuncInvoked bool + SetSetupExperienceSoftwareTitlesFunc SetSetupExperienceSoftwareTitlesFunc + SetSetupExperienceSoftwareTitlesFuncInvoked bool + + ListSetupExperienceSoftwareTitlesFunc ListSetupExperienceSoftwareTitlesFunc + ListSetupExperienceSoftwareTitlesFuncInvoked bool + + SetHostAwaitingConfigurationFunc SetHostAwaitingConfigurationFunc + SetHostAwaitingConfigurationFuncInvoked bool + + GetHostAwaitingConfigurationFunc GetHostAwaitingConfigurationFunc + GetHostAwaitingConfigurationFuncInvoked bool + + ListSetupExperienceResultsByHostUUIDFunc ListSetupExperienceResultsByHostUUIDFunc + ListSetupExperienceResultsByHostUUIDFuncInvoked bool + + UpdateSetupExperienceStatusResultFunc UpdateSetupExperienceStatusResultFunc + UpdateSetupExperienceStatusResultFuncInvoked bool + + EnqueueSetupExperienceItemsFunc EnqueueSetupExperienceItemsFunc + EnqueueSetupExperienceItemsFuncInvoked bool + + GetSetupExperienceScriptFunc GetSetupExperienceScriptFunc + GetSetupExperienceScriptFuncInvoked bool + + SetSetupExperienceScriptFunc SetSetupExperienceScriptFunc + SetSetupExperienceScriptFuncInvoked bool + + DeleteSetupExperienceScriptFunc DeleteSetupExperienceScriptFunc + DeleteSetupExperienceScriptFuncInvoked bool + + MaybeUpdateSetupExperienceScriptStatusFunc MaybeUpdateSetupExperienceScriptStatusFunc + MaybeUpdateSetupExperienceScriptStatusFuncInvoked bool + + MaybeUpdateSetupExperienceSoftwareInstallStatusFunc MaybeUpdateSetupExperienceSoftwareInstallStatusFunc + MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked bool + + MaybeUpdateSetupExperienceVPPStatusFunc MaybeUpdateSetupExperienceVPPStatusFunc + MaybeUpdateSetupExperienceVPPStatusFuncInvoked bool + ListAvailableFleetMaintainedAppsFunc ListAvailableFleetMaintainedAppsFunc ListAvailableFleetMaintainedAppsFuncInvoked bool @@ -6535,6 +6600,97 @@ func (s *DataStore) GetVPPTokenByLocation(ctx context.Context, loc string) (*fle return s.GetVPPTokenByLocationFunc(ctx, loc) } +func (s *DataStore) SetSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, titleIDs []uint) error { + s.mu.Lock() + s.SetSetupExperienceSoftwareTitlesFuncInvoked = true + s.mu.Unlock() + return s.SetSetupExperienceSoftwareTitlesFunc(ctx, teamID, titleIDs) +} + +func (s *DataStore) ListSetupExperienceSoftwareTitles(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + s.mu.Lock() + s.ListSetupExperienceSoftwareTitlesFuncInvoked = true + s.mu.Unlock() + return s.ListSetupExperienceSoftwareTitlesFunc(ctx, teamID, opts) +} + +func (s *DataStore) SetHostAwaitingConfiguration(ctx context.Context, hostUUID string, inSetupExperience bool) error { + s.mu.Lock() + s.SetHostAwaitingConfigurationFuncInvoked = true + s.mu.Unlock() + return s.SetHostAwaitingConfigurationFunc(ctx, hostUUID, inSetupExperience) +} + +func (s *DataStore) GetHostAwaitingConfiguration(ctx context.Context, hostUUID string) (bool, error) { + s.mu.Lock() + s.GetHostAwaitingConfigurationFuncInvoked = true + s.mu.Unlock() + return s.GetHostAwaitingConfigurationFunc(ctx, hostUUID) +} + +func (s *DataStore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { + s.mu.Lock() + s.ListSetupExperienceResultsByHostUUIDFuncInvoked = true + s.mu.Unlock() + return s.ListSetupExperienceResultsByHostUUIDFunc(ctx, hostUUID) +} + +func (s *DataStore) UpdateSetupExperienceStatusResult(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + s.mu.Lock() + s.UpdateSetupExperienceStatusResultFuncInvoked = true + s.mu.Unlock() + return s.UpdateSetupExperienceStatusResultFunc(ctx, status) +} + +func (s *DataStore) EnqueueSetupExperienceItems(ctx context.Context, hostUUID string, teamID uint) (bool, error) { + s.mu.Lock() + s.EnqueueSetupExperienceItemsFuncInvoked = true + s.mu.Unlock() + return s.EnqueueSetupExperienceItemsFunc(ctx, hostUUID, teamID) +} + +func (s *DataStore) GetSetupExperienceScript(ctx context.Context, teamID *uint) (*fleet.Script, error) { + s.mu.Lock() + s.GetSetupExperienceScriptFuncInvoked = true + s.mu.Unlock() + return s.GetSetupExperienceScriptFunc(ctx, teamID) +} + +func (s *DataStore) SetSetupExperienceScript(ctx context.Context, script *fleet.Script) error { + s.mu.Lock() + s.SetSetupExperienceScriptFuncInvoked = true + s.mu.Unlock() + return s.SetSetupExperienceScriptFunc(ctx, script) +} + +func (s *DataStore) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error { + s.mu.Lock() + s.DeleteSetupExperienceScriptFuncInvoked = true + s.mu.Unlock() + return s.DeleteSetupExperienceScriptFunc(ctx, teamID) +} + +func (s *DataStore) MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + s.mu.Lock() + s.MaybeUpdateSetupExperienceScriptStatusFuncInvoked = true + s.mu.Unlock() + return s.MaybeUpdateSetupExperienceScriptStatusFunc(ctx, hostUUID, executionID, status) +} + +func (s *DataStore) MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + s.mu.Lock() + s.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = true + s.mu.Unlock() + return s.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc(ctx, hostUUID, executionID, status) +} + +func (s *DataStore) MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, commandUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + s.mu.Lock() + s.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = true + s.mu.Unlock() + return s.MaybeUpdateSetupExperienceVPPStatusFunc(ctx, hostUUID, commandUUID, status) +} + func (s *DataStore) ListAvailableFleetMaintainedApps(ctx context.Context, teamID uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) { s.mu.Lock() s.ListAvailableFleetMaintainedAppsFuncInvoked = true diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 44d81c756671..87081047c61e 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -908,8 +908,14 @@ func TestMDMAppleConfig(t *testing.T) { name: "nochange", licenseTier: "free", expectedMDM: fleet.MDM{ - AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + MacOSSetup: fleet.MacOSSetup{ + BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, + Script: optjson.String{Set: true}, + }, MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, @@ -943,12 +949,18 @@ func TestMDMAppleConfig(t *testing.T) { expectedMDM: fleet.MDM{ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, DeprecatedAppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + MacOSSetup: fleet.MacOSSetup{ + BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, + Script: optjson.String{Set: true}, + }, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -962,12 +974,18 @@ func TestMDMAppleConfig(t *testing.T) { expectedMDM: fleet.MDM{ AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, DeprecatedAppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + MacOSSetup: fleet.MacOSSetup{ + BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, + Script: optjson.String{Set: true}, + }, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -985,9 +1003,15 @@ func TestMDMAppleConfig(t *testing.T) { newMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, oldMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, expectedMDM: fleet.MDM{ - AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, - EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, + MacOSSetup: fleet.MacOSSetup{ + BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, + Script: optjson.String{Set: true}, + }, MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, @@ -1015,7 +1039,13 @@ func TestMDMAppleConfig(t *testing.T) { MetadataURL: "http://isser.metadata.com", IDPName: "onelogin", }}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSSetup: fleet.MacOSSetup{ + BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, + Script: optjson.String{Set: true}, + }, MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, @@ -1075,9 +1105,15 @@ func TestMDMAppleConfig(t *testing.T) { EnableDiskEncryption: optjson.SetBool(false), }, expectedMDM: fleet.MDM{ - AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, - EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, + MacOSSetup: fleet.MacOSSetup{ + BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, + Script: optjson.String{Set: true}, + }, MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 7115c06ce0f8..9567bd97e301 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2751,6 +2751,15 @@ func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm. return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs") } + if m.AwaitingConfiguration { + // Enqueue setup experience items and mark the host as being in setup experience + _, err := svc.ds.EnqueueSetupExperienceItems(r.Context, r.ID, info.TeamID) + if err != nil { + return ctxerr.Wrap(r.Context, err, "queueing setup experience tasks") + } + + } + return svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{ Action: mdmlifecycle.HostActionTurnOn, Platform: info.Platform, @@ -2895,7 +2904,20 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ err := svc.ds.MDMAppleSetPendingDeclarationsAs(r.Context, cmdResult.UDID, status, detail) return nil, ctxerr.Wrap(r.Context, err, "update declaration status on DeclarativeManagement ack") case "InstallApplication": - // Create an activity for installing only if we're in a terminal state + // this might be a setup experience VPP install, so we'll try to update setup experience status + // TODO: consider limiting this to only macOS hosts + if updated, err := maybeUpdateSetupExperienceStatus(r.Context, svc.ds, fleet.SetupExperienceVPPInstallResult{ + HostUUID: cmdResult.UDID, + CommandUUID: cmdResult.CommandUUID, + CommandStatus: cmdResult.Status, + }, true); err != nil { + return nil, ctxerr.Wrap(r.Context, err, "updating setup experience status from VPP install result") + } else if updated { + // TODO: call next step of setup experience? + level.Debug(svc.logger).Log("msg", "setup experience script result updated", "host_uuid", cmdResult.UDID, "execution_id", cmdResult.CommandUUID) + } + + // create an activity for installing only if we're in a terminal state if cmdResult.Status == fleet.MDMAppleStatusAcknowledged || cmdResult.Status == fleet.MDMAppleStatusError || cmdResult.Status == fleet.MDMAppleStatusCommandFormatError { @@ -2913,6 +2935,10 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ return nil, ctxerr.Wrap(r.Context, err, "creating activity for installed app store app") } } + case "DeviceConfigured": + if err := svc.ds.SetHostAwaitingConfiguration(r.Context, r.ID, false); err != nil { + return nil, ctxerr.Wrap(r.Context, err, "failed to mark host as non longer awaiting configuration") + } } return nil, nil diff --git a/server/service/client.go b/server/service/client.go index db0fdf8c2612..dd975b9567a5 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -389,9 +389,32 @@ func getProfilesContents(baseDir string, macProfiles []fleet.MDMProfileSpec, win return result, nil } +// fileContent is used to store the name of a file and its content. +type fileContent struct { + Filename string + Content []byte +} + +// TODO: as confirmed by Noah and Marko on Slack: +// +// > from Noah: "We want to support existing features w/ fleetctl apply for +// > backwards compatibility GitOps but we don’t need to add new features." +// +// We should deprecate ApplyGroup and use it only for `fleetctl apply` (and +// its current minimal use in `preview`), and have a distinct implementation +// that is `gitops`-only, because both uses have subtle differences in +// behaviour that make it hard to reuse a single implementation (e.g. a missing +// key in gitops means "remove what is absent" while in apply it means "leave +// as-is"). +// +// For now I'm just passing a "gitops" bool for a quick fix, but we should +// properly plan that separation and refactor so that gitops can be +// significantly cleaned up and simplified going forward. + // ApplyGroup applies the given spec group to Fleet. func (c *Client) ApplyGroup( ctx context.Context, + viaGitOps bool, specs *spec.Group, baseDir string, logf func(format string, args ...interface{}), @@ -553,6 +576,15 @@ func (c *Client) ApplyGroup( tmMacSetup := extractTmSpecsMacOSSetup(specs.Teams) tmBootstrapPackages := make(map[string]*fleet.MDMAppleBootstrapPackage, len(tmMacSetup)) tmMacSetupAssistants := make(map[string][]byte, len(tmMacSetup)) + + // those are gitops-only features + tmMacSetupScript := make(map[string]fileContent, len(tmMacSetup)) + tmMacSetupSoftware := make(map[string][]*fleet.MacOSSetupSoftware, len(tmMacSetup)) + // this is a set of software packages or VPP apps that are configured as + // install_during_setup, by team. This is a gitops-only setting, so it will + // only be filled when called via this command. + tmSoftwareMacOSSetup := make(map[string]map[fleet.MacOSSetupSoftware]struct{}, len(tmMacSetup)) + for k, setup := range tmMacSetup { if setup.BootstrapPackage.Value != "" { bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value) @@ -568,6 +600,21 @@ func (c *Client) ApplyGroup( } tmMacSetupAssistants[k] = b } + if setup.Script.Value != "" { + b, err := c.validateMacOSSetupScript(resolveApplyRelativePath(baseDir, setup.Script.Value)) + if err != nil { + return nil, nil, nil, fmt.Errorf("applying teams: %w", err) + } + tmMacSetupScript[k] = fileContent{Filename: filepath.Base(setup.Script.Value), Content: b} + } + if viaGitOps { + m, err := extractTeamOrNoTeamMacOSSetupSoftware(baseDir, setup.Software.Value) + if err != nil { + return nil, nil, nil, err + } + tmSoftwareMacOSSetup[k] = m + tmMacSetupSoftware[k] = setup.Software.Value + } } tmScripts := extractTmSpecsScripts(specs.Teams) @@ -589,24 +636,59 @@ func (c *Client) ApplyGroup( tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams) tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmSoftwarePackages)) + tmSoftwarePackageByPath := make(map[string]map[string]fleet.SoftwarePackageSpec, len(tmSoftwarePackages)) for tmName, software := range tmSoftwarePackages { - softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software) + installDuringSetupKeys := tmSoftwareMacOSSetup[tmName] + softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software, installDuringSetupKeys) if err != nil { return nil, nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } tmSoftwarePackagesPayloads[tmName] = softwarePayloads + for _, swSpec := range software { + if swSpec.ReferencedYamlPath != "" { + // can be referenced by macos_setup.software.package_path + if tmSoftwarePackageByPath[tmName] == nil { + tmSoftwarePackageByPath[tmName] = make(map[string]fleet.SoftwarePackageSpec, len(software)) + } + tmSoftwarePackageByPath[tmName][swSpec.ReferencedYamlPath] = swSpec + } + } } tmSoftwareApps := extractTmSpecsSoftwareApps(specs.Teams) tmSoftwareAppsPayloads := make(map[string][]fleet.VPPBatchPayload) + tmSoftwareAppsByAppID := make(map[string]map[string]fleet.TeamSpecAppStoreApp, len(tmSoftwareApps)) for tmName, apps := range tmSoftwareApps { + installDuringSetupKeys := tmSoftwareMacOSSetup[tmName] appPayloads := make([]fleet.VPPBatchPayload, 0, len(apps)) for _, app := range apps { - appPayloads = append(appPayloads, fleet.VPPBatchPayload{AppStoreID: app.AppStoreID, SelfService: app.SelfService}) + var installDuringSetup *bool + if installDuringSetupKeys != nil { + _, ok := installDuringSetupKeys[fleet.MacOSSetupSoftware{AppStoreID: app.AppStoreID}] + installDuringSetup = &ok + } + appPayloads = append(appPayloads, fleet.VPPBatchPayload{ + AppStoreID: app.AppStoreID, + SelfService: app.SelfService, + InstallDuringSetup: installDuringSetup, + }) + // can be referenced by macos_setup.software.app_store_id + if tmSoftwareAppsByAppID[tmName] == nil { + tmSoftwareAppsByAppID[tmName] = make(map[string]fleet.TeamSpecAppStoreApp, len(apps)) + } + tmSoftwareAppsByAppID[tmName][app.AppStoreID] = app } tmSoftwareAppsPayloads[tmName] = appPayloads } + // if macos_setup.software has some values, they must exist in the software + // packages or vpp apps. + for tmName, setupSw := range tmMacSetupSoftware { + if err := validateTeamOrNoTeamMacOSSetupSoftware(tmName, setupSw, tmSoftwarePackageByPath[tmName], tmSoftwareAppsByAppID[tmName]); err != nil { + return nil, nil, nil, err + } + } + // Next, apply the teams specs before saving the profiles, so that any // non-existing team gets created. var err error @@ -674,6 +756,19 @@ func (c *Client) ApplyGroup( } } } + if viaGitOps && !opts.DryRun { + for tmName, tmID := range teamIDsByName { + if fc, ok := tmMacSetupScript[tmName]; ok { + if err := c.uploadMacOSSetupScript(fc.Filename, fc.Content, &tmID); err != nil { + return nil, nil, nil, fmt.Errorf("uploading setup experience script for team %q: %w", tmName, err) + } + } else { + if err := c.deleteMacOSSetupScript(&tmID); err != nil { + return nil, nil, nil, fmt.Errorf("deleting setup experience script for team %q: %w", tmName, err) + } + } + } + } if len(tmScriptsPayloads) > 0 { for tmName, scripts := range tmScriptsPayloads { // For non-dry run, currentTeamName and tmName are the same @@ -701,6 +796,7 @@ func (c *Client) ApplyGroup( for tmName, apps := range tmSoftwareAppsPayloads { // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) + logfn("[+] applying %d app store apps for team %s\n", len(apps), tmName) if err := c.ApplyTeamAppStoreAppsAssociation(currentTeamName, apps, opts.ApplySpecOptions); err != nil { return nil, nil, nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) } @@ -751,7 +847,45 @@ func (c *Client) ApplyGroup( return teamIDsByName, teamsSoftwareInstallers, teamsScripts, nil } -func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec) ([]fleet.SoftwareInstallerPayload, error) { +func extractTeamOrNoTeamMacOSSetupSoftware(baseDir string, software []*fleet.MacOSSetupSoftware) (map[fleet.MacOSSetupSoftware]struct{}, error) { + m := make(map[fleet.MacOSSetupSoftware]struct{}, len(software)) + for _, sw := range software { + if sw.AppStoreID != "" && sw.PackagePath != "" { + return nil, errors.New("applying teams: only one of app_store_id or package_path can be set") + } + if sw.PackagePath != "" { + sw.PackagePath = resolveApplyRelativePath(baseDir, sw.PackagePath) + } + m[*sw] = struct{}{} + } + return m, nil +} + +func validateTeamOrNoTeamMacOSSetupSoftware(teamName string, macOSSetupSoftware []*fleet.MacOSSetupSoftware, packagesByPath map[string]fleet.SoftwarePackageSpec, vppAppsByAppID map[string]fleet.TeamSpecAppStoreApp) error { + // if macos_setup.software has some values, they must exist in the software + // packages or vpp apps. + for _, ssw := range macOSSetupSoftware { + var valid bool + if ssw.AppStoreID != "" { + // check that it exists in the team's Apps + _, valid = vppAppsByAppID[ssw.AppStoreID] + } else if ssw.PackagePath != "" { + // check that it exists in the team's Software installers (PackagePath is + // already resolved to abs dir) + _, valid = packagesByPath[ssw.PackagePath] + } + if !valid { + label := ssw.AppStoreID + if label == "" { + label = ssw.PackagePath + } + return fmt.Errorf("applying macOS setup experience software for team %q: software %q does not exist for that team", teamName, label) + } + } + return nil +} + +func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec, installDuringSetupKeys map[fleet.MacOSSetupSoftware]struct{}) ([]fleet.SoftwareInstallerPayload, error) { softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(specs)) for i, si := range specs { var qc string @@ -837,15 +971,20 @@ func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageS } } + var installDuringSetup *bool + if installDuringSetupKeys != nil { + _, ok := installDuringSetupKeys[fleet.MacOSSetupSoftware{PackagePath: si.ReferencedYamlPath}] + installDuringSetup = &ok + } softwarePayloads[i] = fleet.SoftwareInstallerPayload{ - URL: si.URL, - SelfService: si.SelfService, - PreInstallQuery: qc, - InstallScript: string(ic), - PostInstallScript: string(pc), - UninstallScript: string(us), + URL: si.URL, + SelfService: si.SelfService, + PreInstallQuery: qc, + InstallScript: string(ic), + PostInstallScript: string(pc), + UninstallScript: string(us), + InstallDuringSetup: installDuringSetup, } - } return softwarePayloads, nil @@ -1482,7 +1621,7 @@ func (c *Client) DoGitOps( } // Apply org settings, scripts, enroll secrets, team entities (software, scripts, etc.), and controls. - teamIDsByName, teamsSoftwareInstallers, teamsScripts, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ + teamIDsByName, teamsSoftwareInstallers, teamsScripts, err := c.ApplyGroup(ctx, true, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ ApplySpecOptions: fleet.ApplySpecOptions{ DryRun: dryRun, }, @@ -1539,30 +1678,89 @@ func (c *Client) DoGitOps( return teamAssumptions, nil } -func (c *Client) doGitOpsNoTeamSoftware(config *spec.GitOps, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) ([]fleet.SoftwarePackageResponse, error) { +func (c *Client) doGitOpsNoTeamSoftware( + config *spec.GitOps, + baseDir string, + appconfig *fleet.EnrichedAppConfig, + logFn func(format string, args ...interface{}), + dryRun bool, +) ([]fleet.SoftwarePackageResponse, error) { + if !config.IsNoTeam() || appconfig == nil || !appconfig.License.IsPremium() { + return nil, nil + } + var softwareInstallers []fleet.SoftwarePackageResponse - if config.IsNoTeam() && appconfig != nil && appconfig.License.IsPremium() { - packages := make([]fleet.SoftwarePackageSpec, 0, len(config.Software.Packages)) - for _, software := range config.Software.Packages { - if software != nil { - packages = append(packages, *software) + + packages := make([]fleet.SoftwarePackageSpec, 0, len(config.Software.Packages)) + packagesByPath := make(map[string]fleet.SoftwarePackageSpec, len(config.Software.Packages)) + for _, software := range config.Software.Packages { + if software != nil { + packages = append(packages, *software) + if software.ReferencedYamlPath != "" { + // can be referenced by macos_setup.software + packagesByPath[software.ReferencedYamlPath] = *software } } - payload, err := buildSoftwarePackagesPayload(baseDir, packages) - if err != nil { - return nil, fmt.Errorf("applying software installers: %w", err) - } - logFn("[+] applying %d software packages for 'No team'\n", len(payload)) - softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}) + } + + // marshaling dance to get the macos_setup data - config.Controls.MacOSSetup + // is of type any and contains a generic map[string]any. By + // marshal-unmarshaling it into a properly typed struct, we avoid having to + // do a bunch of error-prone and unmaintainable type-assertions to walk down + // the untyped map. + b, err := json.Marshal(config.Controls.MacOSSetup) + if err != nil { + return nil, fmt.Errorf("applying software installers: json-encode controls.macos_setup: %w", err) + } + var macOSSetup fleet.MacOSSetup + if err := json.Unmarshal(b, &macOSSetup); err != nil { + return nil, fmt.Errorf("applying software installers: json-decode controls.macos_setup: %w", err) + } + + // load the no-team macos_setup.script if any + var macosSetupScript *fileContent + if macOSSetup.Script.Value != "" { + b, err := c.validateMacOSSetupScript(resolveApplyRelativePath(baseDir, macOSSetup.Script.Value)) if err != nil { - return nil, fmt.Errorf("applying software installers: %w", err) + return nil, fmt.Errorf("applying no team macos_setup.script: %w", err) } + macosSetupScript = &fileContent{Filename: filepath.Base(macOSSetup.Script.Value), Content: b} + } - if dryRun { - logFn("[+] would've applied 'No Team' software packages\n") - } else { - logFn("[+] applied 'No Team' software packages\n") + noTeamSoftwareMacOSSetup, err := extractTeamOrNoTeamMacOSSetupSoftware(baseDir, macOSSetup.Software.Value) + if err != nil { + return nil, err + } + + // TODO: note that VPP apps are not validated nor taken into account at the moment, + // tracked with issue https://github.com/fleetdm/fleet/issues/22970 + if err := validateTeamOrNoTeamMacOSSetupSoftware(*config.TeamName, macOSSetup.Software.Value, packagesByPath, nil); err != nil { + return nil, err + } + payload, err := buildSoftwarePackagesPayload(baseDir, packages, noTeamSoftwareMacOSSetup) + if err != nil { + return nil, fmt.Errorf("applying software installers: %w", err) + } + + if macosSetupScript != nil { + logFn("[+] applying macos setup experience script for 'No team'\n") + if err := c.uploadMacOSSetupScript(macosSetupScript.Filename, macosSetupScript.Content, nil); err != nil { + return nil, fmt.Errorf("uploading setup experience script for No team: %w", err) } + } else if err := c.deleteMacOSSetupScript(nil); err != nil { + return nil, fmt.Errorf("deleting setup experience script for No team: %w", err) + } + + logFn("[+] applying %d software packages for 'No team'\n", len(payload)) + softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}) + if err != nil { + return nil, fmt.Errorf("applying software installers: %w", err) + } + + if dryRun { + logFn("[+] would've applied 'No Team' software packages\n") + } else { + logFn("[+] applied 'No Team' software packages\n") } return softwareInstallers, nil } diff --git a/server/service/client_scripts.go b/server/service/client_scripts.go index 7a47c162e908..9761fe24b214 100644 --- a/server/service/client_scripts.go +++ b/server/service/client_scripts.go @@ -1,11 +1,15 @@ package service import ( + "bytes" + "context" "encoding/json" "errors" "fmt" "io" + "mime/multipart" "net/http" + "os" "strings" "time" @@ -139,3 +143,78 @@ func (c *Client) ApplyNoTeamScripts(scripts []fleet.ScriptPayload, opts fleet.Ap return resp.Scripts, err } + +func (c *Client) validateMacOSSetupScript(fileName string) ([]byte, error) { + if err := c.CheckAppleMDMEnabled(); err != nil { + return nil, err + } + + b, err := os.ReadFile(fileName) + if err != nil { + return nil, err + } + return b, nil +} + +func (c *Client) deleteMacOSSetupScript(teamID *uint) error { + var query string + if teamID != nil { + query = fmt.Sprintf("team_id=%d", *teamID) + } + + verb, path := "DELETE", "/api/latest/fleet/setup_experience/script" + var delResp deleteSetupExperienceScriptResponse + return c.authenticatedRequestWithQuery(nil, verb, path, &delResp, query) +} + +func (c *Client) uploadMacOSSetupScript(filename string, data []byte, teamID *uint) error { + // there is no "replace setup experience script" endpoint, and none was + // planned, so to avoid delaying the feature I'm doing DELETE then SET, but + // that's not ideal (will always re-create the script when apply/gitops is + // run with the same yaml). Note though that we also redo software installers + // downloads on each run, so the churn of this one is minor in comparison. + if err := c.deleteMacOSSetupScript(teamID); err != nil { + return err + } + + verb, path := "POST", "/api/latest/fleet/setup_experience/script" + + var b bytes.Buffer + w := multipart.NewWriter(&b) + + fw, err := w.CreateFormFile("script", filename) + if err != nil { + return err + } + if _, err := io.Copy(fw, bytes.NewBuffer(data)); err != nil { + return err + } + + // add the team_id field + if teamID != nil { + if err := w.WriteField("team_id", fmt.Sprint(*teamID)); err != nil { + return err + } + } + w.Close() + + response, err := c.doContextWithBodyAndHeaders(context.Background(), verb, path, "", + b.Bytes(), + map[string]string{ + "Content-Type": w.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", c.token), + }, + ) + if err != nil { + return fmt.Errorf("do multipart request: %w", err) + } + defer response.Body.Close() + + var resp setSetupExperienceScriptResponse + if err := c.parseResponse(verb, path, response, &resp); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + return nil +} diff --git a/server/service/handler.go b/server/service/handler.go index 19135d7bbce0..56cc734eca0f 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -391,6 +391,13 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/software/app_store_apps", getAppStoreAppsEndpoint, getAppStoreAppsRequest{}) ue.POST("/api/_version_/fleet/software/app_store_apps", addAppStoreAppEndpoint, addAppStoreAppRequest{}) + // Setup Experience + ue.PUT("/api/_version_/fleet/setup_experience/software", putSetupExperienceSoftware, putSetupExperienceSoftwareRequest{}) + ue.GET("/api/_version_/fleet/setup_experience/software", getSetupExperienceSoftware, getSetupExperienceSoftwareRequest{}) + ue.GET("/api/_version_/fleet/setup_experience/script", getSetupExperienceScriptEndpoint, getSetupExperienceScriptRequest{}) + ue.POST("/api/_version_/fleet/setup_experience/script", setSetupExperienceScriptEndpoint, setSetupExperienceScriptRequest{}) + ue.DELETE("/api/_version_/fleet/setup_experience/script", deleteSetupExperienceScriptEndpoint, deleteSetupExperienceScriptRequest{}) + // Fleet-maintained apps ue.POST("/api/_version_/fleet/software/fleet_maintained_apps", addFleetMaintainedAppEndpoint, addFleetMaintainedAppRequest{}) ue.GET("/api/_version_/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsEndpoint, listFleetMaintainedAppsRequest{}) @@ -859,11 +866,12 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC oe.POST("/api/fleet/orbit/scripts/result", postOrbitScriptResultEndpoint, orbitPostScriptResultRequest{}) oe.PUT("/api/fleet/orbit/device_mapping", putOrbitDeviceMappingEndpoint, orbitPutDeviceMappingRequest{}) oe.POST("/api/fleet/orbit/software_install/result", postOrbitSoftwareInstallResultEndpoint, orbitPostSoftwareInstallResultRequest{}) - oe.POST("/api/fleet/orbit/software_install/package", orbitDownloadSoftwareInstallerEndpoint, orbitDownloadSoftwareInstallerRequest{}) - oe.POST("/api/fleet/orbit/software_install/details", getOrbitSoftwareInstallDetails, orbitGetSoftwareInstallRequest{}) + oeAppleMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM()) + oeAppleMDM.POST("/api/fleet/orbit/setup_experience/status", getOrbitSetupExperienceStatusEndpoint, getOrbitSetupExperienceStatusRequest{}) + oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{}) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 801fc2086775..a8106dd270bf 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12,7 +12,6 @@ import ( "errors" "fmt" "io" - "mime/multipart" "net/http" "net/http/httptest" "os" @@ -236,6 +235,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSSetupAssistant: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false), + Script: optjson.String{Set: true}, + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, }, // because the WindowsSettings was marshalled to JSON to be saved in the DB, // it did get marshalled, and then when unmarshalled it was set (but @@ -338,6 +339,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSSetupAssistant: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false), + Script: optjson.String{Set: true}, + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -368,6 +371,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSSetupAssistant: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false), + Script: optjson.String{Set: true}, + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -400,6 +405,8 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSSetupAssistant: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false), + Script: optjson.String{Set: true}, + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -2366,6 +2373,8 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() { MacOSSetupAssistant: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false), + Script: optjson.String{Set: true}, + Software: optjson.Slice[*fleet.MacOSSetupSoftware]{Set: true, Value: []*fleet.MacOSSetupSoftware{}}, }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -8596,14 +8605,14 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { SelfService: false, TeamID: &team1.ID, } - s.uploadSoftwareInstaller(payloadRubyTm1, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payloadRubyTm1, http.StatusOK, "") payloadEmacs := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "emacs.deb", SelfService: true, } - s.uploadSoftwareInstaller(payloadEmacs, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payloadEmacs, http.StatusOK, "") payloadVim := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", @@ -8611,7 +8620,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { SelfService: true, TeamID: ptr.Uint(0), } - s.uploadSoftwareInstaller(payloadVim, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payloadVim, http.StatusOK, "") resp = listSoftwareTitlesResponse{} s.DoJSON( @@ -8631,7 +8640,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { Filename: "ruby_arm64.deb", TeamID: &team2.ID, } - s.uploadSoftwareInstaller(payloadRubyTm2, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payloadRubyTm2, http.StatusOK, "") // We should only see the one we uploaded to team 1 resp = listSoftwareTitlesResponse{} @@ -10056,7 +10065,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { Filename: "ruby.deb", Version: "1:2.5.1", } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages") // update it to be self-service @@ -10198,7 +10207,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { Filename: "dummy_installer.pkg", Version: "0.0.2", // The version can be anything -- we match on title } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") // Get software available for install getHostSw = getHostSoftwareResponse{} @@ -10320,7 +10329,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD Platform: "linux", } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") // check activity s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": false}`, 0) @@ -10329,7 +10338,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD _, titleID := checkSoftwareInstaller(t, payload) // upload again fails - s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists") // orbit-downloading fails with invalid orbit node key s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ @@ -10368,7 +10377,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD Platform: "linux", SelfService: true, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") // check the software installer installerID, titleID := checkSoftwareInstaller(t, payload) @@ -10377,7 +10386,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) // upload again fails - s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists") // download the installer r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID)) @@ -10483,7 +10492,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD Platform: "linux", SelfService: true, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") // check the software installer installerID, titleID := checkSoftwareInstaller(t, payload) @@ -10492,7 +10501,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`), 0) // upload again fails - s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists") // download the installer r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", 0)) @@ -10578,7 +10587,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", Platform: "linux", } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") logger := kitlog.NewLogfmtLogger(os.Stderr) @@ -11592,7 +11601,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { Title: "ruby", TeamID: teamID, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") // Get title with software installer @@ -11609,7 +11618,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { Title: "DummyApp.app", TeamID: teamID, } - s.uploadSoftwareInstaller(payloadDummy, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "") pkgTitleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps") s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", pkgTitleID), nil, http.StatusOK, &respTitle, "team_id", fmt.Sprintf("%d", *teamID)) @@ -11964,7 +11973,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() { Title: "ruby", SelfService: false, } - s.uploadSoftwareInstaller(payloadNoSS, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payloadNoSS, http.StatusOK, "") titleIDNoSS := getSoftwareTitleID(t, s.ds, payloadNoSS.Title, "deb_packages") payloadSS := &fleet.UploadSoftwareInstallerPayload{ @@ -11975,7 +11984,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() { Title: "emacs", SelfService: true, } - s.uploadSoftwareInstaller(payloadSS, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payloadSS, http.StatusOK, "") titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages") // cannot self-install if software installer does not allow it @@ -12045,7 +12054,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { Filename: "ruby.deb", Title: "ruby", } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") payload2 := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install script 2", @@ -12054,7 +12063,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { Filename: "vim.deb", Title: "vim", } - s.uploadSoftwareInstaller(payload2, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload2, http.StatusOK, "") titleID2 := getSoftwareTitleID(t, s.ds, payload2.Title, "deb_packages") payload3 := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install script 3", @@ -12063,7 +12072,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { Filename: "emacs.deb", Title: "emacs", } - s.uploadSoftwareInstaller(payload3, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload3, http.StatusOK, "") titleID3 := getSoftwareTitleID(t, s.ds, payload3.Title, "deb_packages") latestInstallUUID := func() string { @@ -12299,64 +12308,6 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() { require.EqualValues(t, 0, *scriptRes.ExitCode) } -func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller( - payload *fleet.UploadSoftwareInstallerPayload, - expectedStatus int, - expectedError string, -) { - t := s.T() - t.Helper() - openFile := func(name string) *os.File { - f, err := os.Open(filepath.Join("testdata", "software-installers", name)) - require.NoError(t, err) - return f - } - - f := openFile(payload.Filename) - defer f.Close() - - payload.InstallerFile = f - - var b bytes.Buffer - w := multipart.NewWriter(&b) - - // add the software field - fw, err := w.CreateFormFile("software", payload.Filename) - require.NoError(t, err) - n, err := io.Copy(fw, payload.InstallerFile) - require.NoError(t, err) - require.NotZero(t, n) - - // add the team_id field - if payload.TeamID != nil { - require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", *payload.TeamID))) - } - // add the remaining fields - require.NoError(t, w.WriteField("install_script", payload.InstallScript)) - require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery)) - require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript)) - require.NoError(t, w.WriteField("uninstall_script", payload.UninstallScript)) - if payload.SelfService { - require.NoError(t, w.WriteField("self_service", "true")) - } - - w.Close() - - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - "Accept": "application/json", - "Authorization": fmt.Sprintf("Bearer %s", s.token), - } - - r := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers) - defer r.Body.Close() - - if expectedError != "" { - errMsg := extractServerErrorText(r.Body) - require.Contains(t, errMsg, expectedError) - } -} - func getSoftwareTitleID(t *testing.T, ds *mysql.Datastore, title, source string) uint { var id uint mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -12582,7 +12533,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { Filename: "dummy_installer.pkg", TeamID: &team.ID, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") resp := listSoftwareTitlesResponse{} s.DoJSON( @@ -12686,7 +12637,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() { Filename: "no_version.pkg", TeamID: &team.ID, } - s.uploadSoftwareInstaller(payload, http.StatusBadRequest, "Couldn't add. Fleet couldn't read the version from no_version.pkg.") + s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, "Couldn't add. Fleet couldn't read the version from no_version.pkg.") } // 1. host reports software @@ -12754,7 +12705,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareAlreadyReported() { Filename: "dummy_installer.pkg", TeamID: &team.ID, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") resp = listSoftwareTitlesResponse{} s.DoJSON( @@ -12818,7 +12769,7 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() { Filename: "dummy_installer.pkg", TeamID: &team.ID, } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") resp := listSoftwareTitlesResponse{} s.DoJSON( @@ -13727,7 +13678,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers Filename: "dummy_installer.pkg", TeamID: &team1.ID, } - s.uploadSoftwareInstaller(pkgPayload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, pkgPayload, http.StatusOK, "") // Get software title ID of the uploaded installer. resp := listSoftwareTitlesResponse{} s.DoJSON( @@ -13790,7 +13741,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers s.token = adminToken }) s.token = adminTeam1Session.Key - s.uploadSoftwareInstaller(rubyPayload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "") s.token = adminToken err = s.ds.DeleteUser(ctx, adminTeam1.ID) require.NoError(t, err) @@ -13835,7 +13786,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers // author (the admin that uploaded the installer). SelfService: true, } - s.uploadSoftwareInstaller(fleetOsqueryPayload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, fleetOsqueryPayload, http.StatusOK, "") // Get software title ID of the uploaded installer. resp = listSoftwareTitlesResponse{} s.DoJSON( @@ -14852,7 +14803,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIden Filename: "dummy_installer.pkg", Version: "0.0.2", } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") } func (s *integrationEnterpriseTestSuite) TestSoftwareUploadRPM() { @@ -14870,7 +14821,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareUploadRPM() { Filename: "ruby.rpm", Title: "ruby", } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, payload.Title, "rpm_packages") latestInstallUUID := func() string { diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index fd37517c4677..670d1de5dbe8 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -271,10 +271,11 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) require.Len(t, listHostsRes.Hosts, 1) require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, device.SerialNumber) + enrolledHost := listHostsRes.Hosts[0].Host t.Cleanup(func() { // delete the enrolled host - err := s.ds.DeleteHost(ctx, listHostsRes.Hosts[0].ID) + err := s.ds.DeleteHost(ctx, enrolledHost.ID) require.NoError(t, err) }) @@ -353,6 +354,11 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de require.True(t, profileFleetCASeen) require.True(t, profileFileVaultSeen) + // simulate fleetd being installed and the host being orbit-enrolled now + enrolledHost.OsqueryHostID = ptr.String(mdmDevice.UUID) + orbitKey := setOrbitEnrollment(t, enrolledHost, s.ds) + enrolledHost.OrbitNodeKey = &orbitKey + if enableReleaseManually { // get the worker's pending job from the future, there should not be any // because it needs to be released manually @@ -360,12 +366,14 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de require.NoError(t, err) require.Empty(t, pending) } else { - // there should be a Release Device pending job in the near future, expect - // it and schedule it to run now. - s.expectAndScheduleReleaseDeviceJob(t) + // there shouldn't be a Release Device pending job anymore + pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) + require.NoError(t, err) + require.Len(t, pending, 0) - // run the worker to process the DEP release - s.runWorker() + // call the /status endpoint to automatically release the host + var statusResp getOrbitSetupExperienceStatusResponse + s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) // make the device process the commands, it should receive the // DeviceConfigured one. @@ -395,24 +403,6 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de } } -func (s *integrationMDMTestSuite) expectAndScheduleReleaseDeviceJob(t *testing.T) { - ctx := context.Background() - - // get the worker's pending job from the future, there should be a DEP - // release device task - pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) - require.NoError(t, err) - require.Len(t, pending, 1) - releaseJob := pending[0] - require.Equal(t, 0, releaseJob.Retries) - require.Contains(t, string(*releaseJob.Args), worker.AppleMDMPostDEPReleaseDeviceTask) - - // update the job so that it can run immediately - releaseJob.NotBefore = time.Now().UTC().Add(-time.Minute) - _, err = s.ds.UpdateJob(ctx, releaseJob.ID, releaseJob) - require.NoError(t, err) -} - func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { t := s.T() @@ -813,7 +803,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { require.NoError(t, err) // send a TokenUpdate command, it shouldn't re-send the post-enrollment commands - err = mdmDevice.TokenUpdate() + err = mdmDevice.TokenUpdate(false) require.NoError(t, err) checkPostEnrollmentCommands(mdmDevice, false) @@ -1536,3 +1526,599 @@ func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() { s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) require.Equal(t, tm.Name, acResp.MDM.DeprecatedAppleBMDefaultTeam) } + +func (s *integrationMDMTestSuite) TestSetupExperienceScript() { + t := s.T() + + tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name(), + Description: "desc", + }) + require.NoError(t, err) + + // create new team script + var newScriptResp setSetupExperienceScriptResponse + body, headers := generateNewScriptMultipartRequest(t, + "script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}}) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers) + err = json.NewDecoder(res.Body).Decode(&newScriptResp) + require.NoError(t, err) + + // get team script metadata + var getScriptResp getSetupExperienceScriptResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK, &getScriptResp) + require.Equal(t, "script42.sh", getScriptResp.Name) + require.NotNil(t, getScriptResp.TeamID) + require.Equal(t, tm.ID, *getScriptResp.TeamID) + require.NotZero(t, getScriptResp.ID) + require.NotZero(t, getScriptResp.CreatedAt) + require.NotZero(t, getScriptResp.UpdatedAt) + + // get team script contents + res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d&alt=media", tm.ID), nil, http.StatusOK) + b, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, `echo "hello"`, string(b)) + require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength) + require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script42.sh"), res.Header.Get("Content-Disposition")) + + // try to create script with same name, should fail because already exists with this name for this team + body, headers = generateNewScriptMultipartRequest(t, + "script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}}) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusConflict, headers) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "already exists") // TODO: confirm expected error message with product/frontend + + // try to create with a different name for this team, should fail because another script already exists + // for this team + body, headers = generateNewScriptMultipartRequest(t, + "different.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}}) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusConflict, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "already exists") // TODO: confirm expected error message with product/frontend + + // create no-team script + body, headers = generateNewScriptMultipartRequest(t, + "script42.sh", []byte(`echo "hello"`), s.token, nil) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers) + err = json.NewDecoder(res.Body).Decode(&newScriptResp) + require.NoError(t, err) + // // TODO: confirm if we will allow team_id=0 requests + // noTeamID := uint(0) // TODO: confirm if we will allow team_id=0 requests + // body, headers = generateNewScriptMultipartRequest(t, + // "script42.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", noTeamID)}}) + + // get no-team script metadata + s.DoJSON("GET", "/api/latest/fleet/setup_experience/script", nil, http.StatusOK, &getScriptResp) + require.Equal(t, "script42.sh", getScriptResp.Name) + require.Nil(t, getScriptResp.TeamID) + require.NotZero(t, getScriptResp.ID) + require.NotZero(t, getScriptResp.CreatedAt) + require.NotZero(t, getScriptResp.UpdatedAt) + // // TODO: confirm if we will allow team_id=0 requests + // s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", noTeamID), nil, http.StatusOK, &getScriptResp) + + // get no-team script contents + res = s.Do("GET", "/api/latest/fleet/setup_experience/script?alt=media", nil, http.StatusOK) + b, err = io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, `echo "hello"`, string(b)) + require.Equal(t, int64(len(`echo "hello"`)), res.ContentLength) + require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script42.sh"), res.Header.Get("Content-Disposition")) + // // TODO: confirm if we will allow team_id=0 requests + // res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d&alt=media", noTeamID), nil, http.StatusOK) + + // delete the no-team script + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script"), nil, http.StatusOK) + + // try get the no-team script + s.Do("GET", "/api/latest/fleet/setup_experience/script", nil, http.StatusNotFound) + + // try deleting the no-team script again + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script"), nil, http.StatusOK) // TODO: confirm if we want to return not found + + // // TODO: confirm if we will allow team_id=0 requests + // s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script/?team_id=%d", noTeamID), nil, http.StatusOK) + + // delete the team script + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK) + + // try get the team script + s.Do("GET", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusNotFound) + + // try deleting the team script again + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/script?team_id=%d", tm.ID), nil, http.StatusOK) // TODO: confirm if we want to return not found +} + +func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript() (device godep.Device, host *fleet.Host, tm *fleet.Team) { + t := s.T() + ctx := context.Background() + + // enroll a device in a team with software to install and a script to execute + s.enableABM("fleet-setup-experience") + tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + + teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"} + + // add a team profile + teamProfile := mobileconfigForTest("N1", "I1") + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) + + // add a macOS software to install + payloadDummy := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "dummy_installer.pkg", + Title: "DummyApp.app", + TeamID: &tm.ID, + } + s.uploadSoftwareInstaller(t, payloadDummy, http.StatusOK, "") + titleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps") + var swInstallResp putSetupExperienceSoftwareResponse + s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: tm.ID, TitleIDs: []uint{titleID}}, http.StatusOK, &swInstallResp) + + // add a script to execute + body, headers := generateNewScriptMultipartRequest(t, + "script.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}}) + s.DoRawWithHeaders("POST", "/api/latest/fleet/setup_experience/script", body.Bytes(), http.StatusOK, headers) + + // no bootstrap package, no custom setup assistant (those are already tested + // in the DEPEnrollReleaseDevice tests). + + s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + return map[string]*push.Response{}, nil + } + + s.mockDEPResponse("fleet-setup-experience", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + err := encoder.Encode(map[string]string{"auth_session_token": "xyz"}) + require.NoError(t, err) + case "/profile": + err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()}) + require.NoError(t, err) + case "/server/devices": + err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}}) + require.NoError(t, err) + case "/devices/sync": + // This endpoint is polled over time to sync devices from + // ABM, send a repeated serial + err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{teamDevice}, Cursor: "foo"}) + require.NoError(t, err) + case "/profile/devices": + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var prof profileAssignmentReq + require.NoError(t, json.Unmarshal(b, &prof)) + + var resp godep.ProfileResponse + resp.ProfileUUID = prof.ProfileUUID + resp.Devices = make(map[string]string, len(prof.Devices)) + for _, device := range prof.Devices { + resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess) + } + err = encoder.Encode(resp) + require.NoError(t, err) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) + + // trigger a profile sync + s.runDEPSchedule() + + // the (ghost) host now exists + listHostsRes := listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, 1) + require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, teamDevice.SerialNumber) + enrolledHost := listHostsRes.Hosts[0].Host + + // transfer it to the team + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &tm.ID, HostIDs: []uint{enrolledHost.ID}}, http.StatusOK) + + return teamDevice, enrolledHost, tm +} + +func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAutoRelease() { + t := s.T() + ctx := context.Background() + + teamDevice, enrolledHost, _ := s.createTeamDeviceForSetupExperienceWithProfileSoftwareAndScript() + + // enroll the host + depURLToken := loadEnrollmentProfileDEPToken(t, s.ds) + mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) + mdmDevice.SerialNumber = teamDevice.SerialNumber + err := mdmDevice.Enroll() + require.NoError(t, err) + + // run the worker to process the DEP enroll request + s.runWorker() + // run the worker to assign configuration profiles + s.awaitTriggerProfileSchedule(t) + + var cmds []*micromdm.CommandPayload + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + + // Can be useful for debugging + // switch cmd.Command.RequestType { + // case "InstallProfile": + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload)) + // case "InstallEnterpriseApplication": + // if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil { + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL) + // } else { + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + // } + // default: + // fmt.Println(">>>> device received command: ", cmd.Command.RequestType) + // } + + cmds = append(cmds, &fullCmd) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + // expected commands: install fleetd (install enterprise), install profiles + // (custom one, fleetd configuration, fleet CA root) + require.Len(t, cmds, 4) + var installProfileCount, installEnterpriseCount, otherCount int + var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool + for _, cmd := range cmds { + switch cmd.Command.RequestType { + case "InstallProfile": + installProfileCount++ + switch { + case strings.Contains(string(cmd.Command.InstallProfile.Payload), "I1"): + profileCustomSeen = true + case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetdConfigPayloadIdentifier)): + profileFleetdSeen = true + case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetCARootConfigPayloadIdentifier)): + profileFleetCASeen = true + case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload)) + // case "InstallEnterpriseApplication": + // if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil { + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL) + // } else { + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + // } + // default: + // fmt.Println(">>>> device received command: ", cmd.Command.RequestType) + // } + + cmds = append(cmds, &fullCmd) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + // expected commands: install fleetd (install enterprise), install profiles + // (custom one, fleetd configuration, fleet CA root) + require.Len(t, cmds, 4) + var installProfileCount, installEnterpriseCount, otherCount int + var profileCustomSeen, profileFleetdSeen, profileFleetCASeen, profileFileVaultSeen bool + for _, cmd := range cmds { + switch cmd.Command.RequestType { + case "InstallProfile": + installProfileCount++ + switch { + case strings.Contains(string(cmd.Command.InstallProfile.Payload), "I1"): + profileCustomSeen = true + case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetdConfigPayloadIdentifier)): + profileFleetdSeen = true + case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetCARootConfigPayloadIdentifier)): + profileFleetCASeen = true + case strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s 0 { + teamID, err := strconv.ParseUint(val[0], 10, 64) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode team_id in multipart form: %s", err.Error())} + } + // // TODO: do we want to allow end users to specify team_id=0? if so, we'll need to convert it to nil here so that we can + // // use it in the auth layer where team_id=0 is not allowed? + decoded.TeamID = ptr.Uint(uint(teamID)) + } + + fhs, ok := r.MultipartForm.File["script"] + if !ok || len(fhs) < 1 { + return nil, &fleet.BadRequestError{Message: "no file headers for script"} + } + decoded.Script = fhs[0] + + return &decoded, nil +} + +type setSetupExperienceScriptResponse struct { + Err error `json:"error,omitempty"` +} + +func (r setSetupExperienceScriptResponse) error() error { return r.Err } + +func setSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*setSetupExperienceScriptRequest) + + scriptFile, err := req.Script.Open() + if err != nil { + return setSetupExperienceScriptResponse{Err: err}, nil + } + defer scriptFile.Close() + + if err := svc.SetSetupExperienceScript(ctx, req.TeamID, filepath.Base(req.Script.Filename), scriptFile); err != nil { + return setSetupExperienceScriptResponse{Err: err}, nil + } + + return setSetupExperienceScriptResponse{}, nil +} + +func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} + +type deleteSetupExperienceScriptRequest struct { + TeamID *uint `query:"team_id,optional"` +} + +type deleteSetupExperienceScriptResponse struct { + Err error `json:"error,omitempty"` +} + +func (r deleteSetupExperienceScriptResponse) error() error { return r.Err } + +// func (r deleteSetupExperienceScriptResponse) Status() int { return http.StatusNoContent } + +func deleteSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*deleteSetupExperienceScriptRequest) + // // TODO: do we want to allow end users to specify team_id=0? if so, we'll need convert it to nil here so that we can + // // use it in the auth layer where team_id=0 is not allowed? + if err := svc.DeleteSetupExperienceScript(ctx, req.TeamID); err != nil { + return deleteSetupExperienceScriptResponse{Err: err}, nil + } + + return deleteSetupExperienceScriptResponse{}, nil +} + +func (svc *Service) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} + +func (svc *Service) SetupExperienceNextStep(ctx context.Context, hostUUID string) (bool, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return false, fleet.ErrMissingLicense +} + +// maybeUpdateSetupExperienceStatus attempts to update the status of a setup experience result in +// the database. If the given result is of a supported type (namely SetupExperienceScriptResult, +// SetupExperienceSoftwareInstallResult, and SetupExperienceVPPInstallResult), it returns a boolean +// indicating whether the datastore was updated and an error if one occurred. If the result is not of a +// supported type, it returns false and an error indicated that the type is not supported. +// If the skipPending parameter is true, the datastore will only be updated if the given result +// status is not pending. +func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, result interface{}, requireTerminalStatus bool) (bool, error) { + switch v := result.(type) { + case fleet.SetupExperienceScriptResult: + status := v.SetupExperienceStatus() + if !status.IsValid() { + return false, fmt.Errorf("invalid status: %s", status) + } else if requireTerminalStatus && !status.IsTerminalStatus() { + return false, nil + } + return ds.MaybeUpdateSetupExperienceScriptStatus(ctx, v.HostUUID, v.ExecutionID, status) + + case fleet.SetupExperienceSoftwareInstallResult: + status := v.SetupExperienceStatus() + fmt.Println(status) + if !status.IsValid() { + return false, fmt.Errorf("invalid status: %s", status) + } else if requireTerminalStatus && !status.IsTerminalStatus() { + return false, nil + } + return ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, v.HostUUID, v.ExecutionID, status) + + case fleet.SetupExperienceVPPInstallResult: + // NOTE: this case is also implemented in the CommandAndReportResults method of + // MDMAppleCheckinAndCommandService + status := v.SetupExperienceStatus() + if !status.IsValid() { + return false, fmt.Errorf("invalid status: %s", status) + } else if requireTerminalStatus && !status.IsTerminalStatus() { + return false, nil + } + return ds.MaybeUpdateSetupExperienceVPPStatus(ctx, v.HostUUID, v.CommandUUID, status) + + default: + return false, fmt.Errorf("unsupported result type: %T", result) + } +} diff --git a/server/service/setup_experience_test.go b/server/service/setup_experience_test.go new file mode 100644 index 000000000000..af1ca985ae2a --- /dev/null +++ b/server/service/setup_experience_test.go @@ -0,0 +1,440 @@ +package service + +import ( + "context" + "strings" + "testing" + "time" + + "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/require" +) + +func TestSetupExperienceAuth(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + teamID := uint(1) + teamScriptID := uint(1) + noTeamScriptID := uint(2) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error { + return nil + } + + ds.GetSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) (*fleet.Script, error) { + if teamID == nil { + return &fleet.Script{ID: noTeamScriptID}, nil + } + switch *teamID { + case uint(1): + return &fleet.Script{ID: teamScriptID, TeamID: teamID}, nil + default: + return nil, newNotFoundError() + } + } + ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { + return []byte("echo"), nil + } + ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { + if teamID == nil { + return nil + } + switch *teamID { + case uint(1): + return nil + default: + return newNotFoundError() // TODO: confirm if we want to return not found on deletes + } + } + ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { + return &fleet.Team{ID: id}, nil + } + + testCases := []struct { + name string + user *fleet.User + shouldFailTeamWrite bool + shouldFailGlobalWrite bool + shouldFailTeamRead bool + shouldFailGlobalRead bool + }{ + { + name: "global admin", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global maintainer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global observer+", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: false, + }, + { + name: "global gitops", + user: &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: false, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team admin, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team maintainer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team observer, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team observer+, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: false, + shouldFailGlobalRead: true, + }, + { + name: "team gitops, belongs to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, + shouldFailTeamWrite: false, + shouldFailGlobalWrite: true, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team admin, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team maintainer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team observer, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team observer+, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + { + name: "team gitops, DOES NOT belong to team", + user: &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}}, + shouldFailTeamWrite: true, + shouldFailGlobalWrite: true, + shouldFailTeamRead: true, + shouldFailGlobalRead: true, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx = viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + + t.Run("setup experience script", func(t *testing.T) { + err := svc.SetSetupExperienceScript(ctx, nil, "test.sh", strings.NewReader("echo")) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + err = svc.DeleteSetupExperienceScript(ctx, nil) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + _, _, err = svc.GetSetupExperienceScript(ctx, nil, false) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, _, err = svc.GetSetupExperienceScript(ctx, nil, true) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + + err = svc.SetSetupExperienceScript(ctx, &teamID, "test.sh", strings.NewReader("echo")) + checkAuthErr(t, tt.shouldFailTeamWrite, err) + err = svc.DeleteSetupExperienceScript(ctx, &teamID) + checkAuthErr(t, tt.shouldFailTeamWrite, err) + _, _, err = svc.GetSetupExperienceScript(ctx, &teamID, false) + checkAuthErr(t, tt.shouldFailTeamRead, err) + _, _, err = svc.GetSetupExperienceScript(ctx, &teamID, true) + checkAuthErr(t, tt.shouldFailTeamRead, err) + }) + }) + } +} + +func TestMaybeUpdateSetupExperience(t *testing.T) { + ds := new(mock.Store) + // _, ctx := newTestService(t, ds, nil, nil, nil) + ctx := context.Background() + + hostUUID := "host-uuid" + scriptUUID := "script-uuid" + softwareUUID := "software-uuid" + vppUUID := "vpp-uuid" + + t.Run("unsupported result type", func(t *testing.T) { + _, err := maybeUpdateSetupExperienceStatus(ctx, ds, map[string]interface{}{"key": "value"}, true) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported result type") + }) + + t.Run("script results", func(t *testing.T) { + testCases := []struct { + name string + exitCode int + expected fleet.SetupExperienceStatusResultStatus + alwaysUpdated bool + }{ + { + name: "success", + exitCode: 0, + expected: fleet.SetupExperienceStatusSuccess, + alwaysUpdated: true, + }, + { + name: "failure", + exitCode: 1, + expected: fleet.SetupExperienceStatusFailure, + alwaysUpdated: true, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ds.MaybeUpdateSetupExperienceScriptStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hostUUID) + require.Equal(t, executionID, scriptUUID) + require.Equal(t, tt.expected, status) + require.True(t, status.IsValid()) + return true, nil + } + ds.MaybeUpdateSetupExperienceScriptStatusFuncInvoked = false + + result := fleet.SetupExperienceScriptResult{ + HostUUID: hostUUID, + ExecutionID: scriptUUID, + ExitCode: tt.exitCode, + } + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, true) + require.NoError(t, err) + require.Equal(t, tt.alwaysUpdated, updated) + require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceScriptStatusFuncInvoked) + }) + } + }) + + t.Run("software install results", func(t *testing.T) { + testCases := []struct { + name string + status fleet.SoftwareInstallerStatus + expectStatus fleet.SetupExperienceStatusResultStatus + alwaysUpdated bool + }{ + { + name: "success", + status: fleet.SoftwareInstalled, + expectStatus: fleet.SetupExperienceStatusSuccess, + alwaysUpdated: true, + }, + { + name: "failure", + status: fleet.SoftwareInstallFailed, + expectStatus: fleet.SetupExperienceStatusFailure, + alwaysUpdated: true, + }, + { + name: "pending", + status: fleet.SoftwareInstallPending, + expectStatus: fleet.SetupExperienceStatusPending, + alwaysUpdated: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + requireTerminalStatus := true // when this flag is true, we don't expect pending status to update + + ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hostUUID) + require.Equal(t, executionID, softwareUUID) + require.Equal(t, tt.expectStatus, status) + require.True(t, status.IsValid()) + require.True(t, status.IsTerminalStatus()) + return true, nil + } + ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = false + + result := fleet.SetupExperienceSoftwareInstallResult{ + HostUUID: hostUUID, + ExecutionID: softwareUUID, + InstallerStatus: tt.status, + } + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) + require.NoError(t, err) + require.Equal(t, tt.alwaysUpdated, updated) + require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked) + + requireTerminalStatus = false // when this flag is false, we do expect pending status to update + + ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hostUUID) + require.Equal(t, executionID, softwareUUID) + require.Equal(t, tt.expectStatus, status) + require.True(t, status.IsValid()) + if status.IsTerminalStatus() { + require.True(t, status == fleet.SetupExperienceStatusSuccess || status == fleet.SetupExperienceStatusFailure) + } else { + require.True(t, status == fleet.SetupExperienceStatusPending || status == fleet.SetupExperienceStatusRunning) + } + return true, nil + } + ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = false + updated, err = maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) + require.NoError(t, err) + shouldUpdate := tt.alwaysUpdated + if tt.expectStatus == fleet.SetupExperienceStatusPending || tt.expectStatus == fleet.SetupExperienceStatusRunning { + shouldUpdate = true + } + require.Equal(t, shouldUpdate, updated) + require.Equal(t, shouldUpdate, ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked) + }) + } + }) + + t.Run("vpp install results", func(t *testing.T) { + testCases := []struct { + name string + status string + expected fleet.SetupExperienceStatusResultStatus + alwaysUpdated bool + }{ + { + name: "success", + status: fleet.MDMAppleStatusAcknowledged, + expected: fleet.SetupExperienceStatusSuccess, + alwaysUpdated: true, + }, + { + name: "failure", + status: fleet.MDMAppleStatusError, + expected: fleet.SetupExperienceStatusFailure, + alwaysUpdated: true, + }, + { + name: "format error", + status: fleet.MDMAppleStatusCommandFormatError, + expected: fleet.SetupExperienceStatusFailure, + alwaysUpdated: true, + }, + { + name: "pending", + status: fleet.MDMAppleStatusNotNow, + expected: fleet.SetupExperienceStatusPending, + alwaysUpdated: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + requireTerminalStatus := true // when this flag is true, we don't expect pending status to update + + ds.MaybeUpdateSetupExperienceVPPStatusFunc = func(ctx context.Context, hostUUID string, cmdUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hostUUID) + require.Equal(t, cmdUUID, vppUUID) + require.Equal(t, tt.expected, status) + require.True(t, status.IsValid()) + return true, nil + } + ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = false + + result := fleet.SetupExperienceVPPInstallResult{ + HostUUID: hostUUID, + CommandUUID: vppUUID, + CommandStatus: tt.status, + } + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) + require.NoError(t, err) + require.Equal(t, tt.alwaysUpdated, updated) + require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked) + + requireTerminalStatus = false // when this flag is false, we do expect pending status to update + + ds.MaybeUpdateSetupExperienceVPPStatusFunc = func(ctx context.Context, hostUUID string, cmdUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hostUUID) + require.Equal(t, cmdUUID, vppUUID) + require.Equal(t, tt.expected, status) + require.True(t, status.IsValid()) + if status.IsTerminalStatus() { + require.True(t, status == fleet.SetupExperienceStatusSuccess || status == fleet.SetupExperienceStatusFailure) + } else { + require.True(t, status == fleet.SetupExperienceStatusPending || status == fleet.SetupExperienceStatusRunning) + } + return true, nil + } + ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = false + + updated, err = maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) + require.NoError(t, err) + shouldUpdate := tt.alwaysUpdated + if tt.expected == fleet.SetupExperienceStatusPending || tt.expected == fleet.SetupExperienceStatusRunning { + shouldUpdate = true + } + require.Equal(t, shouldUpdate, updated) + require.Equal(t, shouldUpdate, ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked) + }) + } + }) +} diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 91c5ce98e038..5d78ae8960a7 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -42,6 +42,13 @@ func listSoftwareTitlesEndpoint(ctx context.Context, request interface{}, svc fl if sw.CountsUpdatedAt != nil && !sw.CountsUpdatedAt.IsZero() && sw.CountsUpdatedAt.After(latest) { latest = *sw.CountsUpdatedAt } + // we dont want to include the InstallDuringSetup field in the response + // for software titles list. + if sw.SoftwarePackage != nil { + sw.SoftwarePackage.InstallDuringSetup = nil + } else if sw.AppStoreApp != nil { + sw.AppStoreApp.InstallDuringSetup = nil + } } if len(titles) == 0 { titles = []fleet.SoftwareTitleListResult{} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 8450808a94e5..fa49385ba816 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -6,11 +6,13 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" + "path/filepath" "regexp" "sync" "testing" @@ -527,3 +529,61 @@ func (ts *withServer) lastActivityOfTypeDoesNotMatch(name, details string, id ui } } } + +func (ts *withServer) uploadSoftwareInstaller( + t *testing.T, + payload *fleet.UploadSoftwareInstallerPayload, + expectedStatus int, + expectedError string, +) { + t.Helper() + openFile := func(name string) *os.File { + f, err := os.Open(filepath.Join("testdata", "software-installers", name)) + require.NoError(t, err) + return f + } + + f := openFile(payload.Filename) + defer f.Close() + + payload.InstallerFile = f + + var b bytes.Buffer + w := multipart.NewWriter(&b) + + // add the software field + fw, err := w.CreateFormFile("software", payload.Filename) + require.NoError(t, err) + n, err := io.Copy(fw, payload.InstallerFile) + require.NoError(t, err) + require.NotZero(t, n) + + // add the team_id field + if payload.TeamID != nil { + require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", *payload.TeamID))) + } + // add the remaining fields + require.NoError(t, w.WriteField("install_script", payload.InstallScript)) + require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery)) + require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript)) + require.NoError(t, w.WriteField("uninstall_script", payload.UninstallScript)) + if payload.SelfService { + require.NoError(t, w.WriteField("self_service", "true")) + } + + w.Close() + + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", ts.token), + } + + r := ts.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers) + defer r.Body.Close() + + if expectedError != "" { + errMsg := extractServerErrorText(r.Body) + require.Contains(t, errMsg, expectedError) + } +} diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index b9a1a7152abf..055bbe6dbba7 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -743,6 +743,7 @@ func mdmConfigurationRequiredEndpoints() []struct { {"GET", "/api/latest/fleet/bootstrap/summary", false, true}, {"PATCH", "/api/latest/fleet/mdm/apple/setup", false, true}, {"PATCH", "/api/latest/fleet/setup_experience", false, true}, + {"POST", "/api/fleet/orbit/setup_experience/status", false, true}, } } diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index 52bf530d89b8..50c166a024f3 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "strings" - "time" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -29,7 +28,9 @@ type AppleMDMTask string const ( AppleMDMPostDEPEnrollmentTask AppleMDMTask = "post_dep_enrollment" AppleMDMPostManualEnrollmentTask AppleMDMTask = "post_manual_enrollment" - AppleMDMPostDEPReleaseDeviceTask AppleMDMTask = "post_dep_release_device" + // deprecated job, not enqueued anymore but remains for backward + // compatibility (processing existing jobs after a fleet upgrade) + DeprecatedAppleMDMPostDEPReleaseDeviceTask AppleMDMTask = "post_dep_release_device" ) // AppleMDM is the job processor for the apple_mdm job. @@ -78,8 +79,8 @@ func (a *AppleMDM) Run(ctx context.Context, argsJSON json.RawMessage) error { err := a.runPostManualEnrollment(ctx, args) return ctxerr.Wrap(ctx, err, "running post Apple manual enrollment task") - case AppleMDMPostDEPReleaseDeviceTask: - err := a.runPostDEPReleaseDevice(ctx, args) + case DeprecatedAppleMDMPostDEPReleaseDeviceTask: + err := a.deprecatedRunPostDEPReleaseDevice(ctx, args) return ctxerr.Wrap(ctx, err, "running post Apple DEP release device task") default: @@ -104,22 +105,16 @@ func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArg } func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) error { - var awaitCmdUUIDs []string - if isMacOS(args.Platform) { - fleetdCmdUUID, err := a.installFleetd(ctx, args.HostUUID) + _, err := a.installFleetd(ctx, args.HostUUID) if err != nil { return ctxerr.Wrap(ctx, err, "installing post-enrollment packages") } - awaitCmdUUIDs = append(awaitCmdUUIDs, fleetdCmdUUID) - bootstrapCmdUUID, err := a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID) + _, err = a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID) if err != nil { return ctxerr.Wrap(ctx, err, "installing post-enrollment packages") } - if bootstrapCmdUUID != "" { - awaitCmdUUIDs = append(awaitCmdUUIDs, bootstrapCmdUUID) - } } if ref := args.EnrollReference; ref != "" { @@ -155,40 +150,19 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) ); err != nil { return ctxerr.Wrap(ctx, err, "sending AccountConfiguration command") } - awaitCmdUUIDs = append(awaitCmdUUIDs, cmdUUID) - } - } - - var manualRelease bool - if args.TeamID == nil { - ac, err := a.Datastore.AppConfig(ctx) - if err != nil { - return ctxerr.Wrap(ctx, err, "get AppConfig to read enable_release_device_manually") - } - manualRelease = ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value - } else { - tm, err := a.Datastore.Team(ctx, *args.TeamID) - if err != nil { - return ctxerr.Wrap(ctx, err, "get Team to read enable_release_device_manually") - } - manualRelease = tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value - } - - if !manualRelease { - // send all command uuids for the commands sent here during post-DEP - // enrollment and enqueue a job to look for the status of those commands to - // be final and same for MDM profiles of that host; it means the DEP - // enrollment process is done and the device can be released. - if err := QueueAppleMDMJob(ctx, a.Datastore, a.Log, AppleMDMPostDEPReleaseDeviceTask, - args.HostUUID, args.Platform, args.TeamID, args.EnrollReference, awaitCmdUUIDs...); err != nil { - return ctxerr.Wrap(ctx, err, "queue Apple Post-DEP release device job") } } return nil } -func (a *AppleMDM) runPostDEPReleaseDevice(ctx context.Context, args appleMDMArgs) error { +// This job is deprecated because releasing devices is now done via the orbit +// endpoint /setup_experience/status that is polled by a swift dialog UI window +// during the setup process, and automatically releases the device once all +// pending setup tasks are done. However, it must remain implemented in case +// there are such jobs to process after a Fleet migration to a new version; we +// just don't enqueue that job anymore. +func (a *AppleMDM) deprecatedRunPostDEPReleaseDevice(ctx context.Context, args appleMDMArgs) error { // Edge cases: // - if the device goes offline for a long time, should we go ahead and // release after a while? @@ -367,12 +341,7 @@ func QueueAppleMDMJob( Platform: platform, } - // the release device task is always added with a delay - var delay time.Duration - if task == AppleMDMPostDEPReleaseDeviceTask { - delay = 30 * time.Second - } - job, err := QueueJobWithDelay(ctx, ds, appleMDMJobName, args, delay) + job, err := QueueJobWithDelay(ctx, ds, appleMDMJobName, args, 0) if err != nil { return ctxerr.Wrap(ctx, err, "queueing job") } diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go index fadd38dd2423..8b497379aba0 100644 --- a/server/worker/apple_mdm_test.go +++ b/server/worker/apple_mdm_test.go @@ -218,11 +218,8 @@ func TestAppleMDM(t *testing.T) { jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - // the post-DEP release device job is pending - require.Len(t, jobs, 1) - require.Equal(t, appleMDMJobName, jobs[0].Name) - require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) - require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + // there is no post-DEP release device job anymore + require.Len(t, jobs, 0) require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) }) @@ -298,11 +295,8 @@ func TestAppleMDM(t *testing.T) { jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - // the post-DEP release device job is pending - require.Len(t, jobs, 1) - require.Equal(t, appleMDMJobName, jobs[0].Name) - require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) - require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + // the post-DEP release device job is not queued anymore + require.Len(t, jobs, 0) require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) @@ -350,11 +344,8 @@ func TestAppleMDM(t *testing.T) { jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - // the post-DEP release device job is pending - require.Len(t, jobs, 1) - require.Equal(t, appleMDMJobName, jobs[0].Name) - require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) - require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + // the post-DEP release device job is not queued anymore + require.Len(t, jobs, 0) require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) @@ -484,11 +475,8 @@ func TestAppleMDM(t *testing.T) { jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - // the post-DEP release device job is pending, having failed its first attempt - require.Len(t, jobs, 1) - require.Equal(t, appleMDMJobName, jobs[0].Name) - require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) - require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + // the post-DEP release device job is not queued anymore + require.Len(t, jobs, 0) // confirm that AccountConfiguration command was not enqueued require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) @@ -540,11 +528,8 @@ func TestAppleMDM(t *testing.T) { jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - // the post-DEP release device job is pending - require.Len(t, jobs, 1) - require.Equal(t, appleMDMJobName, jobs[0].Name) - require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) - require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + // the post-DEP release device job is not queued anymore + require.Len(t, jobs, 0) require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "AccountConfiguration"}, getEnqueuedCommandTypes(t)) }) diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index bc3e7c0a22d2..dee903783df5 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -146,6 +146,13 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Script optjson.String +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Software optjson.Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Value []*fleet.MacOSSetupSoftware +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware AppStoreID string +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware PackagePath string github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSMigration fleet.MacOSMigration github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Enable bool github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Mode fleet.MacOSMigrationMode string diff --git a/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt index 9c92a3696ee0..f21250550da8 100644 --- a/tools/cloner-check/generated_files/teammdm.txt +++ b/tools/cloner-check/generated_files/teammdm.txt @@ -28,6 +28,13 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Script optjson.String +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Software optjson.Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Value []*fleet.MacOSSetupSoftware +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware AppStoreID string +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetupSoftware PackagePath string github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool diff --git a/website/assets/images/install-software-preview.png b/website/assets/images/install-software-preview.png new file mode 100644 index 000000000000..ea0647dcdbd8 Binary files /dev/null and b/website/assets/images/install-software-preview.png differ