diff --git a/.github/ISSUE_TEMPLATE/digital-experience-request.md b/.github/ISSUE_TEMPLATE/digital-experience-request.md new file mode 100644 index 0000000000..97b697ec80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/digital-experience-request.md @@ -0,0 +1,28 @@ +--- +name: 🌐 Digital Experience request [FKA Website request] +about: Propose a new feature or enhancement to fleetdm.com. +title: 'TODO: ' +labels: '#g-digital-experience' +assignees: '' + +--- + +## Goal + +| User story | +|:---------------------------------------------------------------------------| +| As a _________________________________________, +| I want to _________________________________________ +| so that I can _________________________________________. + +>For help creating a user story, see ["Writing a good user story"](https://fleetdm.com/handbook/company/development-groups#writing-a-good-user-story) in the website handbook. + + +## How? + +- [ ] TODO + +### Context + + + diff --git a/.github/ISSUE_TEMPLATE/website-request.md b/.github/ISSUE_TEMPLATE/website-request.md deleted file mode 100644 index bb6e5ea7d9..0000000000 --- a/.github/ISSUE_TEMPLATE/website-request.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: 🌐 Website request -about: Propose a new feature or enhancement to fleetdm.com. -title: 'Request: __________________________' -labels: '#g-digital-experience' -assignees: '' - ---- - -> **This request is expected to be doable in ≀1 sprint. It would be valuable even if nothing else ships.** -> -> It will be reviewed by the acting PM for the #g-website product group, and then hopefully [prioritized, drafted, and implemented](https://fleetdm.com/handbook/company/development-groups#making-changes). - -## Goal - - - -| User story | -|:---------------------------------------------------------------------------| -| As a _________________________________________, -| I want to _________________________________________ -| so that I can _________________________________________. - ->For help creating a user story, see ["Writing a good user story"](https://fleetdm.com/handbook/company/development-groups#writing-a-good-user-story) in the website handbook. - -## Changes - -This issue's estimation includes completing the following: -- [ ] UI changes: TODO -- [ ] QA complete? - -## QA - ->The testing steps outlined below must be verified before the associated PR is merged. See ["Quality"](https://fleetdm.com/handbook/marketing/website-handbook#quality) in the website handbook for help. - -### Manual testing steps - - -1. Step 1 -2. Step 2 -3. Step 3 - ->In addition to the steps above, ensure changes have been checked at all breakpoints, and a [browser compatibility](https://fleetdm.com/handbook/marketing/website-handbook#browser-compatibility) test has been carried out on [supported browsers](https://fleetdm.com/docs/using-fleet/supported-browsers). - -### Testing notes - - diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 1d00e3c0d9..df6b9792b7 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -65,7 +65,7 @@ jobs: # Don't forget to update # docs/Contributing/Testing-and-local-development.md when this # version changes - go install github.com/golangci/golangci-lint/cmd/golangci-lint@411e0bbbd3096aa0ee2b924160629bdf2bc81d40 # v1.54.2 + go install github.com/golangci/golangci-lint/cmd/golangci-lint@e3c2265f4939976874989e159386b3bb7dcf8e1f # v1.55.2 make lint-go - name: Run cloner-check tool diff --git a/.github/workflows/test-go.yaml b/.github/workflows/test-go.yaml index eb82af7e21..bdae1ea90d 100644 --- a/.github/workflows/test-go.yaml +++ b/.github/workflows/test-go.yaml @@ -79,6 +79,9 @@ jobs: - name: Install Go Dependencies run: make deps-go + - name: Install ZSH + run: sudo apt update && sudo apt install -y zsh + - name: Generate static files run: | export PATH=$PATH:~/go/bin @@ -157,4 +160,4 @@ jobs: uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 with: name: summary-test-log - path: /tmp/summary.txt \ No newline at end of file + path: /tmp/summary.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aee9602607..1a3487ec1a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: hooks: - id: gitleaks - repo: https://github.com/golangci/golangci-lint - rev: v1.52.2 + rev: v1.55.2 hooks: - id: golangci-lint - repo: https://github.com/jumanjihouse/pre-commit-hooks diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d5d86e114..39b27e9716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Fleet 4.49.2 (Apr 30, 2024) + +### Bug fixes + +* Restored missing tooltips when hovering over the disabled "Calendar events" manage automations dropdown option. +* Fixed an issue on Windows hosts enrolled in MDM via Azure AD where the command to install Fleetd on the device was sent repeatedly, even though `fleetd` had been properly installed. +* Improved handling of different scenarios and edge cases when hosts turned on/off MDM. +* Fixed issue with uploading of some signed Apple mobileconfig profiles. +* Added an informative flash message when the user tries to save a query with invalid platform(s). +* Fixed bug where Linux host wipe would repeat if the host got re-enrolled. + ## Fleet 4.49.1 (Apr 26, 2024) ### Bug fixes diff --git a/CODEOWNERS b/CODEOWNERS index 81aa6a12bf..11b2d3d815 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -77,12 +77,12 @@ go.mod @fleetdm/go /handbook/company/pricing-features-table.yml @noahtalerman # Β« Head of Product Design is current DRI for features table ############################################################################################## -# 🦿 Repo automation and change control settings +# 🌐 Repo automation and change control settings ############################################################################################## # /CODEOWNERS @mikermcneil # Covered in DRIs ############################################################################################## -# 🦿 Handbook +# 🌐 Handbook # # (see website/config/custom.js for DRIs of other paths not listed here) ############################################################################################## @@ -102,9 +102,9 @@ go.mod @fleetdm/go /handbook/product-design @sampfluger88 ############################################################################################## -# 🦿 GitHub issue templates +# 🌐 GitHub issue templates ############################################################################################## -/.github/ISSUE_TEMPLATE @mikermcneil @sampfluger88 @lukeheath # See https://github.com/fleetdm/fleet/pull/16203 +#/.github/ISSUE_TEMPLATE @mikermcneil @sampfluger88 @lukeheath # Covered in custom.js See https://github.com/fleetdm/fleet/pull/18668 ############################################################################################## # 🌐 GitHub workflows diff --git a/README.md b/README.md index 36784d5fcf..7e8651a618 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Open-source platform for IT and security teams with thousands of computers. Designed for APIs, GitOps, webhooks, YAML, and humans. -A glass city in the clouds +A glass city in the clouds ## What's it for? diff --git a/articles/fleet-in-your-calendar-introducing-maintenance-windows.md b/articles/fleet-in-your-calendar-introducing-maintenance-windows.md new file mode 100644 index 0000000000..f24cb9e588 --- /dev/null +++ b/articles/fleet-in-your-calendar-introducing-maintenance-windows.md @@ -0,0 +1,35 @@ +# Fleet in your calendar: introducing maintenance windows + +![Fleet in your calendar: introducing maintenance windows](../website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-cover-900x450@2x.png) + +Fleet is proud to announce the release of a new feature as part of Fleet premium: _Maintenance windows._ Dubbed "Fleet in your Calendar," this feature aims to ensure that OS updates occur during times that least disrupt your workβ€”just like any good colleague, when Fleet needs some of your time, it puts it on your calendar. This approach avoids interrupting your key activities or important meetings, whether in the office, on the road, or working remotely. + +
+ +
+ +_Maintenance windows_ is designed to enhance your workday by scheduling security updates when they are least likely to interrupt your important tasks. By analyzing your calendar, Fleet identifies optimal times for these updatesβ€”times when your device is not in use. This could be during a lunch break, before the start of the workday, or during other low-activity periods, ensuring that your focus on critical tasks remains uninterrupted. + +![Example downtime maintenance window calendar event](../website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-1-900x450@2x.png "Example downtime maintenance window calendar event") + +Fleet provides AI-generated explanations directly in the calendar events, detailing why the updates are necessary and what changes to expect. This level of transparency helps demystify the update process, providing clarity and fostering trust between administrators and end-users. + +![Example downtime maintenance window calendar event](../website/assets/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-2-900x450@2x.png "Example downtime maintenance window calendar event") + +## _Maintenance windows_ include: + +* **Personalized scheduling:** Updates are timed based on individual calendar events, so interventions happen when they are least intrusive. +* **Automatic rescheduling:** If a scheduled update becomes impracticalβ€”due to changes in your calendar, for exampleβ€”Fleet automatically finds a new appropriate time. +* **Enhanced compliance:** With auto-scheduled maintenance windows, compliance with security protocols is maintained effortlessly, ensuring all devices are up to date without manual intervention. + +_Maintenance windows_ is a direct response to common challenges faced in workplace productivity, particularly unplanned disruptions from essential updates. Fleet aims to support smoother, more efficient work environments by incorporating user feedback and addressing these long-standing issues. + +We are excited for you to experience the benefits of _Maintenance windows_. We look forward to supporting your organization in achieving a balance between strong security practices and high productivity. + + + + + + + + diff --git a/articles/sysadmin-diaries-device-enrollment.md b/articles/sysadmin-diaries-device-enrollment.md new file mode 100644 index 0000000000..cc1ef0fe21 --- /dev/null +++ b/articles/sysadmin-diaries-device-enrollment.md @@ -0,0 +1,45 @@ +# Sysadmin diaries: device enrollment + +![Sysadmin diaries: passcode profiles](../website/assets/images/articles/sysadmin-diaries-1600x900@2x.png) + +As sysadmins, we're tasked with the critical responsibility of securely managing devices within our organizations. Central to this endeavor is device enrollment, which lays the foundation for effective device management and security protocols. The question, "What happens if an employee removes Fleet?" was posed. And, as with all great answers, it starts with, "Well, it depends." It depends on device enrollment. To answer this question, let's look into two primary device enrollment methods, Automatic Device Enrollment (ADE) and Bring Your Own Device (BYOD), exploring their nuances, strengths, and considerations for sysadmins. + + +## ADE + +Automatic Device Enrollment, often called DEP, empowers sysadmins to seamlessly maintain control over corporate devices. With ADE, devices are automatically provisioned and enrolled into management systems like Fleet. This automated process ensures that devices are configured with predefined settings and security measures, enhancing overall device security. ADE restricts users from unenrolling devices, providing sysadmins with the assurance of continued control even in scenarios such as device loss or theft. Removing Fleet in this scenario merely removes the `fleetd` agent, but we can still send MDM commands to the host, such as remote lock or wipe as long as the MDM enrollment profile remains untouched. Without the `fleetd` agent, the host will no longer respond to osquery requests such as a live query. In an upcoming entry, we will explore how we might go about redeploying the `fleet` agent using MDM commands. + + +## BYOD enrollment + +Bring Your Own Device enrollment is intended to allow users to enroll their personal devices into corporate management systems. While BYOD fosters user convenience and productivity, it introduces potential security vulnerabilities. Notably, the ability for users to unenroll their devices poses a significant challenge for sysadmins, as it compromises centralized device management and security measures. + +Our examination of BYOD enrollment underscores the importance of vigilance and proactive measures. As sysadmins, it is crucial to verify that all devices are appropriately registered within the Apple Business Manager (ABM) account. Devices not present in ABM should be [manually added](https://support.apple.com/guide/apple-business-manager/add-devices-from-apple-configurator-axm200a54d59/web) to ensure comprehensive device oversight and security. + + +## Differences and considerations + +The differences between ADE and BYOD enrollment methods are stark, each offering unique advantages and challenges for sysadmins. ADE prioritizes centralized control and security, making it the preferred choice for organizations seeking stringent device management protocols. In contrast, BYOD emphasizes user autonomy but necessitates heightened vigilance from sysadmins to mitigate security risks. + +When evaluating enrollment methods, sysadmins must consider organizational security policies, user preferences, and device management capabilities. Zero-trust data access controls combined with a proactive approach to enrollment ensures that devices remain securely integrated into corporate ecosystems, minimizing potential vulnerabilities. + + +## Resolving enrollment discrepancies + +Sysadmins must adopt a collaborative approach to addressing enrollment discrepancies, engaging with IT teams and end-users to standardize enrollment processes. Verifying device presence in ABM and implementing manual enrollment procedures where necessary are essential steps in bolstering device management strategies. + +Looking ahead, sysadmins must remain vigilant in monitoring advancements in device management technologies. Staying informed and adaptable will be paramount in maintaining robust device security protocols. + +We play a pivotal role in safeguarding organizational assets and data. The choice between ADE and BYOD enrollment methods underscores the balancing act between security and user autonomy. By understanding the nuances of each approach and implementing proactive measures, sysadmins can navigate device enrollment effectively, ensuring the integrity and security of corporate devices. + + + + + + + + + + + + diff --git a/assets/images/calendar-preview-720x436@2x.png b/assets/images/calendar-preview-720x436@2x.png new file mode 100644 index 0000000000..e49f79f905 Binary files /dev/null and b/assets/images/calendar-preview-720x436@2x.png differ diff --git a/changes/12619-fixed-activities-sort-buffer-overflow b/changes/12619-fixed-activities-sort-buffer-overflow new file mode 100644 index 0000000000..93ba207fad --- /dev/null +++ b/changes/12619-fixed-activities-sort-buffer-overflow @@ -0,0 +1 @@ +Fixed MySQL sort buffer overflow when fetching activities. This issue happened when activities contained very large details, such as large SQL queries. diff --git a/changes/15605-merge-inherited-and-team-queries-policies b/changes/15605-merge-inherited-and-team-queries-policies new file mode 100644 index 0000000000..0841ea667f --- /dev/null +++ b/changes/15605-merge-inherited-and-team-queries-policies @@ -0,0 +1,2 @@ +- UI Change: Team queries page renders team level and inherited queries in a single table set by a new merge_inherited API parameter +- UI Change: Team policies page renders team level and inherited policies in a single table set by a new merge_inherited API parameter diff --git a/changes/16562-policy_stats-lock b/changes/16562-policy_stats-lock new file mode 100644 index 0000000000..6c9b551037 --- /dev/null +++ b/changes/16562-policy_stats-lock @@ -0,0 +1 @@ +Optimized policy_stats updates to NOT lock the policy_membership table. This should improve performance on deployments with a large number of global policies and team hosts. diff --git a/changes/17110-win-os-reporting b/changes/17110-win-os-reporting new file mode 100644 index 0000000000..967432dafc --- /dev/null +++ b/changes/17110-win-os-reporting @@ -0,0 +1 @@ +- improved Windows OS version reporting \ No newline at end of file diff --git a/changes/17321-zsh-support b/changes/17321-zsh-support new file mode 100644 index 0000000000..9ade50c170 --- /dev/null +++ b/changes/17321-zsh-support @@ -0,0 +1 @@ +* Add support for uploading and running zsh scripts on macOS and Linux hosts diff --git a/changes/18039-private-IPv6-address b/changes/18039-private-IPv6-address new file mode 100644 index 0000000000..a07285324b --- /dev/null +++ b/changes/18039-private-IPv6-address @@ -0,0 +1 @@ +Fixed ingestion of private IPv6 address from agent. Host details page can now display private IPv6 address if private IPv4 does not exist. diff --git a/changes/18079-query-results-bug-transfer-hosts b/changes/18079-query-results-bug-transfer-hosts new file mode 100644 index 0000000000..1e114771da --- /dev/null +++ b/changes/18079-query-results-bug-transfer-hosts @@ -0,0 +1 @@ +* Fixed bug where hosts query results were not cleared after transferring the host to other teams. diff --git a/changes/18118-run-script-updates b/changes/18118-run-script-updates new file mode 100644 index 0000000000..a2a42465ad --- /dev/null +++ b/changes/18118-run-script-updates @@ -0,0 +1 @@ +Added `--async` and `--quiet` to `fleetctl run-script` as well as allowing the contents of the script to be inline. diff --git a/changes/18121-api-command b/changes/18121-api-command new file mode 100644 index 0000000000..48795b50ab --- /dev/null +++ b/changes/18121-api-command @@ -0,0 +1 @@ +Added a new command `fleetctl api` to be able to easily use fleetctl to hit any REST endpoint via the cli. diff --git a/changes/18187-ai-generated-backend b/changes/18187-ai-generated-backend new file mode 100644 index 0000000000..2ed32e723d --- /dev/null +++ b/changes/18187-ai-generated-backend @@ -0,0 +1,8 @@ +Added `/api/_version_/fleet/autofill/policy` endpoint to get autogenerated policy description and resolution for a given SQL query. +- Fleet server will communicate with https://fleetdm.com to get the AI generated policy description and resolution. + +Added `server_settings.ai_features_disabled` setting to disable the above endpoint. + +For Google calendar integration, +- changed the event title to: "πŸ’»πŸš« Scheduled maintenance" +- updated event description to include policy description and resolution if only one policy is failing diff --git a/changes/18601-add-ubuntu-oval b/changes/18601-add-ubuntu-oval new file mode 100644 index 0000000000..32538b41a5 --- /dev/null +++ b/changes/18601-add-ubuntu-oval @@ -0,0 +1 @@ +- now supporting oval vulnerability scanning on Ubuntu 22.10, 23.04, 23.10, and 24.04 \ No newline at end of file diff --git a/changes/18605-host-expiry-window-setting b/changes/18605-host-expiry-window-setting new file mode 100644 index 0000000000..594d274512 --- /dev/null +++ b/changes/18605-host-expiry-window-setting @@ -0,0 +1 @@ +- UI: Fix host expiry window setting to be able to save \ No newline at end of file diff --git a/changes/18640-gitops-remove-teams b/changes/18640-gitops-remove-teams new file mode 100644 index 0000000000..4fccef8b17 --- /dev/null +++ b/changes/18640-gitops-remove-teams @@ -0,0 +1,4 @@ +Improvements to `fleetctl gitops` command: +- Added the ability to pass multiple files, like `fleetctl gitops -f file1 -f file2`, where the first file must be the global configuration +- Added the ability to remove teams that were not specified in team configs using the switch `--delete-other-teams` +- When passing a global config and team config during initial configuration, the `org_settings.mdm.apple_bm_default_team` value can be set to match the team that will be created by the provided team config. diff --git a/charts/fleet/Chart.yaml b/charts/fleet/Chart.yaml index f810d5a1f8..5def1fa103 100644 --- a/charts/fleet/Chart.yaml +++ b/charts/fleet/Chart.yaml @@ -8,7 +8,7 @@ version: v6.0.2 home: https://github.com/fleetdm/fleet sources: - https://github.com/fleetdm/fleet.git -appVersion: v4.49.1 +appVersion: v4.49.2 dependencies: - name: mysql condition: mysql.enabled diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index efbb7fa256..60c338038d 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -2,7 +2,7 @@ # All settings related to how Fleet is deployed in Kubernetes hostName: fleet.localhost replicas: 3 # The number of Fleet instances to deploy -imageTag: v4.49.1 # Version of Fleet to deploy +imageTag: v4.49.2 # Version of Fleet to deploy podAnnotations: {} # Additional annotations to add to the Fleet pod serviceAccountAnnotations: {} # Additional annotations to add to the Fleet service account resources: diff --git a/cmd/fleetctl/api.go b/cmd/fleetctl/api.go index a722755391..5ba3081928 100644 --- a/cmd/fleetctl/api.go +++ b/cmd/fleetctl/api.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "runtime" + "strings" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/fleet" @@ -205,3 +206,112 @@ func clientConfigFromCLI(c *cli.Context) (Context, error) { } return cc, nil } + +// apiCommand fleetctl api [options] uri +// -F, --field +// Add a typed parameter in key=value format +// -H, --header +// Add a HTTP request header in key:value format +// -X, --method (default "GET") +// The HTTP method for the request +func apiCommand() *cli.Command { + var ( + flField []string + flHeader []string + flMethod string + ) + return &cli.Command{ + Name: "api", + Usage: "Run an api command by uri", + UsageText: `fleetctl api [options] [url]`, + Flags: []cli.Flag{ + &cli.StringSliceFlag{ + Name: "F", + Aliases: []string{"field"}, + Usage: "Add a typed parameter in key=value format", + }, + &cli.StringSliceFlag{ + Name: "H", + Aliases: []string{"header"}, + Usage: "Add a HTTP request header in key:value format", + }, + &cli.StringFlag{ + Name: "X", + Value: "GET", + Destination: &flMethod, + Usage: "The HTTP method for the request", + }, + configFlag(), + contextFlag(), + debugFlag(), + }, + Action: func(c *cli.Context) error { + uriString := c.Args().First() + params := url.Values{} + method := "GET" + // TODO add param for body for POST etc + + if uriString == "" { + return errors.New("must provide uri first argument") + } + + flField = c.StringSlice("F") + flHeader = c.StringSlice("H") + + if len(flField) > 0 { + for _, each := range flField { + k, v, found := strings.Cut(each, "=") + if !found { + continue + } + params.Add(k, v) + } + } + + headers := map[string]string{} + if len(flHeader) > 0 { + for _, each := range flHeader { + k, v, found := strings.Cut(each, ":") + if !found { + continue + } + headers[k] = v + } + } + + if flMethod != "" { + method = flMethod + } + + if !strings.HasPrefix(uriString, "/") { + uriString = fmt.Sprintf("/%s", uriString) + } + + if !strings.HasPrefix(uriString, "/api/v1/fleet") { + uriString = fmt.Sprintf("/api/v1/fleet%s", uriString) + } + + fleetClient, err := clientFromCLI(c) + if err != nil { + return err + } + + resp, err := fleetClient.AuthenticatedDoCustomHeaders(method, uriString, params.Encode(), nil, headers) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + _, err := io.Copy(c.App.Writer, resp.Body) + if err != nil { + return err + } + } else { + return fmt.Errorf("Got non 2XX return of %d", resp.StatusCode) + } + + return nil + }, + } +} diff --git a/cmd/fleetctl/api_test.go b/cmd/fleetctl/api_test.go new file mode 100644 index 0000000000..40be965d91 --- /dev/null +++ b/cmd/fleetctl/api_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/stretchr/testify/require" +) + +// TestRunApiCommand checks that the usage of `api` command works as expected +func TestRunApiCommand(t *testing.T) { + cfg := config.TestConfig() + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ + License: &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}, + FleetConfig: &cfg, + }) + + created_at, err := time.Parse(time.RFC3339, "1999-03-10T02:45:06.371Z") + require.NoError(t, err) + + type testCase struct { + name string + args []string + expectOutput string + expectErrMsg string + } + + expectedScripts := `{ + "meta": { + "has_next_results": false, + "has_previous_results": false + }, + "scripts": [ + { + "id": 23, + "team_id": null, + "name": "get_my_device_page.sh", + "created_at": "%s", + "updated_at": "%s" + } + ] +} +` + + expectedEmptyScripts := `{ + "meta": { + "has_next_results": false, + "has_previous_results": false + }, + "scripts": [] +} +` + + cases := []testCase{ + { + name: "get scripts", + args: []string{"scripts"}, + expectOutput: fmt.Sprintf( + expectedScripts, + created_at.Format(time.RFC3339Nano), + created_at.Format(time.RFC3339Nano)), + }, + { + name: "get /scripts", + args: []string{"/scripts"}, + expectOutput: fmt.Sprintf( + expectedScripts, + created_at.Format(time.RFC3339Nano), + created_at.Format(time.RFC3339Nano)), + }, + { + name: "get scripts full path", + args: []string{"/api/v1/fleet/scripts"}, + expectOutput: fmt.Sprintf( + expectedScripts, + created_at.Format(time.RFC3339Nano), + created_at.Format(time.RFC3339Nano)), + }, + { + name: "get scripts full path missing /", + args: []string{"api/v1/fleet/scripts"}, + expectOutput: fmt.Sprintf( + expectedScripts, + created_at.Format(time.RFC3339Nano), + created_at.Format(time.RFC3339Nano)), + }, + { + name: "get scripts team", + args: []string{"-F", "team_id=1", "scripts"}, + expectOutput: expectedEmptyScripts, + }, + { + name: "get scripts team no cache", + args: []string{"-H", "cache-control:no-cache", "-F", "team_id=1", "scripts"}, + expectOutput: expectedEmptyScripts, + }, + { + name: "get typo", + args: []string{"vresion"}, + expectErrMsg: "Got non 2XX return of 404", + }, + } + + setupDS := func(t *testing.T, c testCase) { + ds.ListScriptsFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { + if teamID == nil { + ret := []*fleet.Script{ + &fleet.Script{ + ID: 23, + Name: "get_my_device_page.sh", + CreatedAt: created_at, + UpdatedAt: created_at, + }, + } + page := fleet.PaginationMetadata{} + return ret, &page, nil + } + return []*fleet.Script{}, &fleet.PaginationMetadata{}, nil + } + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setupDS(t, c) + args := []string{"api"} + + args = append(args, c.args...) + + b, err := runAppNoChecks(args) + if c.expectErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), c.expectErrMsg) + } else { + require.NoError(t, err) + } + if c.expectOutput != "" { + out := b.String() + require.NoError(t, err) + require.NotEmpty(t, out) + require.Equal(t, c.expectOutput, out) + } else { + require.Empty(t, b.String()) + } + }) + } + +} diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go index b256bc4fa4..fa1f8dd769 100644 --- a/cmd/fleetctl/fleetctl.go +++ b/cmd/fleetctl/fleetctl.go @@ -70,6 +70,7 @@ func createApp( app.ErrWriter = stderr app.Commands = []*cli.Command{ + apiCommand(), applyCommand(), deleteCommand(), setupCommand(), diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go index e754a59ab1..dfa6836ea5 100644 --- a/cmd/fleetctl/gitops.go +++ b/cmd/fleetctl/gitops.go @@ -4,58 +4,67 @@ import ( "errors" "fmt" "github.com/fleetdm/fleet/v4/pkg/spec" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service" "github.com/urfave/cli/v2" + "golang.org/x/text/unicode/norm" "os" "path/filepath" + "slices" + "strings" ) func gitopsCommand() *cli.Command { var ( - flFilename string - flDryRun bool + flFilenames cli.StringSlice + flDryRun bool + flDeleteOtherTeams bool ) return &cli.Command{ Name: "gitops", Usage: "Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.", UsageText: `fleetctl gitops [options]`, Flags: []cli.Flag{ - &cli.StringFlag{ + &cli.StringSliceFlag{ Name: "f", + Required: true, EnvVars: []string{"FILENAME"}, - Value: "", - Destination: &flFilename, - Usage: "The file with the GitOps configuration", + Destination: &flFilenames, + Usage: "The file(s) with the GitOps configuration. If multiple files are provided, the first file must be the global configuration and the rest must be team configurations.", + }, + &cli.BoolFlag{ + Name: "delete-other-teams", + EnvVars: []string{"DELETE_OTHER_TEAMS"}, + Destination: &flDeleteOtherTeams, + Usage: "Delete other teams not present in the GitOps configuration", }, &cli.BoolFlag{ Name: "dry-run", EnvVars: []string{"DRY_RUN"}, Destination: &flDryRun, - Usage: "Do not apply the file, just validate it", + Usage: "Do not apply the file(s), just validate", }, configFlag(), contextFlag(), debugFlag(), }, Action: func(c *cli.Context) error { - if flFilename == "" { + totalFilenames := len(flFilenames.Value()) + if totalFilenames == 0 { return errors.New("-f must be specified") } - b, err := os.ReadFile(flFilename) - if err != nil { - return err + for _, flFilename := range flFilenames.Value() { + if strings.TrimSpace(flFilename) == "" { + return errors.New("file name cannot be empty") + } } + + // Check license fleetClient, err := clientFromCLI(c) if err != nil { return err } - baseDir := filepath.Dir(flFilename) - config, err := spec.GitOpsFromBytes(b, baseDir) - if err != nil { - return err - } - logf := func(format string, a ...interface{}) { - _, _ = fmt.Fprintf(c.App.Writer, format, a...) - } appConfig, err := fleetClient.GetAppConfig() if err != nil { return err @@ -63,10 +72,86 @@ func gitopsCommand() *cli.Command { if appConfig.License == nil { return errors.New("no license struct found in app config") } - err = fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun, appConfig) - if err != nil { - return err + + var appleBMDefaultTeam string + var appleBMDefaultTeamFound bool + var teamNames []string + var firstFileMustBeGlobal *bool + var teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions + if totalFilenames > 1 { + firstFileMustBeGlobal = ptr.Bool(true) } + for _, flFilename := range flFilenames.Value() { + b, err := os.ReadFile(flFilename) + if err != nil { + return err + } + baseDir := filepath.Dir(flFilename) + config, err := spec.GitOpsFromBytes(b, baseDir) + if err != nil { + return err + } + isGlobalConfig := config.TeamName == nil + if firstFileMustBeGlobal != nil { + switch { + case *firstFileMustBeGlobal && !isGlobalConfig: + return fmt.Errorf("first file %s must be the global config", flFilename) + case !*firstFileMustBeGlobal && isGlobalConfig: + return fmt.Errorf( + "the file %s cannot be the global config, only the first file can be the global config", flFilename, + ) + } + firstFileMustBeGlobal = ptr.Bool(false) + } + if isGlobalConfig && totalFilenames > 1 { + // Check if Apple BM default team already exists + appleBMDefaultTeam, appleBMDefaultTeamFound, err = checkAppleBMDefaultTeam(config, fleetClient) + if err != nil { + return err + } + } + logf := func(format string, a ...interface{}) { + _, _ = fmt.Fprintf(c.App.Writer, format, a...) + } + assumptions, err := fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun, teamDryRunAssumptions, appConfig) + if err != nil { + return err + } + if config.TeamName != nil { + teamNames = append(teamNames, *config.TeamName) + } else { + teamDryRunAssumptions = assumptions + } + } + if appleBMDefaultTeam != "" && !appleBMDefaultTeamFound { + // If the Apple BM default team did not exist earlier, check again and apply it if needed + err = applyAppleBMDefaultTeamIfNeeded(c, teamNames, appleBMDefaultTeam, flDryRun, fleetClient) + if err != nil { + return err + } + } + if flDeleteOtherTeams { + teams, err := fleetClient.ListTeams("") + if err != nil { + return err + } + for _, team := range teams { + if !slices.Contains(teamNames, team.Name) { + if appleBMDefaultTeam == team.Name { + return fmt.Errorf("apple_bm_default_team %s cannot be deleted", appleBMDefaultTeam) + } + if flDryRun { + _, _ = fmt.Fprintf(c.App.Writer, "[!] would delete team %s\n", team.Name) + } else { + _, _ = fmt.Fprintf(c.App.Writer, "[-] deleting team %s\n", team.Name) + if err := fleetClient.DeleteTeam(team.ID); err != nil { + return err + } + } + } + } + } + if flDryRun { _, _ = fmt.Fprintf(c.App.Writer, "[!] gitops dry run succeeded\n") } else { @@ -76,3 +161,55 @@ func gitopsCommand() *cli.Command { }, } } + +func checkAppleBMDefaultTeam(config *spec.GitOps, fleetClient *service.Client) ( + appleBMDefaultTeam string, appleBMDefaultTeamFound bool, err error, +) { + if mdm, ok := config.OrgSettings["mdm"]; ok { + if mdmMap, ok := mdm.(map[string]interface{}); ok { + if appleBMDT, ok := mdmMap["apple_bm_default_team"]; ok { + if appleBMDefaultTeam, ok = appleBMDT.(string); ok { + teams, err := fleetClient.ListTeams("") + if err != nil { + return "", false, err + } + // Normalize AppleBMDefaultTeam for Unicode support + appleBMDefaultTeam = norm.NFC.String(appleBMDefaultTeam) + for _, team := range teams { + if team.Name == appleBMDefaultTeam { + appleBMDefaultTeamFound = true + break + } + } + if !appleBMDefaultTeamFound { + // If team is not found, we need to remove the AppleBMDefaultTeam from the global config, and then apply it after teams are processed + mdmMap["apple_bm_default_team"] = "" + } + } + } + } + } + return appleBMDefaultTeam, appleBMDefaultTeamFound, nil +} + +func applyAppleBMDefaultTeamIfNeeded( + ctx *cli.Context, teamNames []string, appleBMDefaultTeam string, flDryRun bool, fleetClient *service.Client, +) error { + if !slices.Contains(teamNames, appleBMDefaultTeam) { + return fmt.Errorf("apple_bm_default_team %s not found in team configs", appleBMDefaultTeam) + } + appConfigUpdate := map[string]map[string]interface{}{ + "mdm": { + "apple_bm_default_team": appleBMDefaultTeam, + }, + } + if flDryRun { + _, _ = fmt.Fprintf(ctx.App.Writer, "[!] would apply apple_bm_default_team %s\n", appleBMDefaultTeam) + } else { + _, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying apple_bm_default_team %s\n", appleBMDefaultTeam) + if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil { + return fmt.Errorf("applying fleet config: %w", err) + } + } + return nil +} diff --git a/cmd/fleetctl/gitops_enterprise_integration_test.go b/cmd/fleetctl/gitops_enterprise_integration_test.go index 71c0a3e183..6383540216 100644 --- a/cmd/fleetctl/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/gitops_enterprise_integration_test.go @@ -6,6 +6,7 @@ import ( "os" "path" "path/filepath" + "strings" "testing" "github.com/fleetdm/fleet/v4/server/config" @@ -17,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/server/service" "github.com/fleetdm/fleet/v4/server/test" "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -146,21 +148,75 @@ contexts: teamsDir := path.Join(repoDir, "teams") teamFiles, err := os.ReadDir(teamsDir) require.NoError(t, err) + teamFileNames := make([]string, 0, len(teamFiles)) + for _, file := range teamFiles { + if filepath.Ext(file.Name()) == ".yml" { + teamFileNames = append(teamFileNames, path.Join(teamsDir, file.Name())) + } + } + + // Create a team to be deleted. + deletedTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + const deletedTeamName = "team_to_be_deleted" + + _, err = deletedTeamFile.WriteString( + fmt.Sprintf( + ` +controls: +queries: +policies: +agent_options: +name: %s +team_settings: + secrets: [{"secret":"deleted_team_secret"}] +`, deletedTeamName, + ), + ) + require.NoError(t, err) + // Apply the team to be deleted + _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", deletedTeamFile.Name()}) // Dry run _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"}) - for _, file := range teamFiles { - if filepath.Ext(file.Name()) == ".yml" { - _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", path.Join(teamsDir, file.Name()), "--dry-run"}) - } + for _, fileName := range teamFileNames { + _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "--dry-run"}) } - // Real run + // Dry run with all the files + args := []string{"gitops", "--config", fleetctlConfig.Name(), "--dry-run", "--delete-other-teams", "-f", globalFile} + for _, fileName := range teamFileNames { + args = append(args, "-f", fileName) + } + _ = runAppForTest(t, args) + + // Real run with all the files, but don't delete other teams + args = []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile} + for _, fileName := range teamFileNames { + args = append(args, "-f", fileName) + } + _ = runAppForTest(t, args) + + // Check that all the teams exist + teamsJSON := runAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"}) + assert.Equal(t, 3, strings.Count(teamsJSON, "team_id")) + + // Real run with all the files, and delete other teams + args = []string{"gitops", "--config", fleetctlConfig.Name(), "--delete-other-teams", "-f", globalFile} + for _, fileName := range teamFileNames { + args = append(args, "-f", fileName) + } + _ = runAppForTest(t, args) + + // Check that only the right teams exist + teamsJSON = runAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"}) + assert.Equal(t, 2, strings.Count(teamsJSON, "team_id")) + assert.NotContains(t, teamsJSON, deletedTeamName) + + // Real run with one file at a time _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}) - for _, file := range teamFiles { - if filepath.Ext(file.Name()) == ".yml" { - _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", path.Join(teamsDir, file.Name())}) - } + for _, fileName := range teamFileNames { + _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName}) } } diff --git a/cmd/fleetctl/gitops_enterprise_no_mdm_integration_test.go b/cmd/fleetctl/gitops_enterprise_no_mdm_integration_test.go index 424b2070a5..2bf46b45ea 100644 --- a/cmd/fleetctl/gitops_enterprise_no_mdm_integration_test.go +++ b/cmd/fleetctl/gitops_enterprise_no_mdm_integration_test.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "strings" "testing" "github.com/fleetdm/fleet/v4/server/config" @@ -17,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/server/service" "github.com/fleetdm/fleet/v4/server/test" "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -117,23 +119,42 @@ contexts: teamsDir := path.Join(repoDir, "teams") teamFiles, err := os.ReadDir(teamsDir) require.NoError(t, err) - - // Dry run - _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"}) + teamFileNames := make([]string, 0, len(teamFiles)) for _, file := range teamFiles { if filepath.Ext(file.Name()) == ".yml" { teamsFile := path.Join(teamsDir, file.Name()) s.removeControls(teamsFile) - _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", teamsFile, "--dry-run"}) + teamFileNames = append(teamFileNames, teamsFile) } } - // Real run + // Dry run with all the files + args := []string{"gitops", "--config", fleetctlConfig.Name(), "--dry-run", "--delete-other-teams", "-f", globalFile} + for _, fileName := range teamFileNames { + args = append(args, "-f", fileName) + } + _ = runAppForTest(t, args) + + // Dry run with one file at a time + _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"}) + for _, fileName := range teamFileNames { + _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "--dry-run"}) + } + + args = []string{"gitops", "--config", fleetctlConfig.Name(), "--delete-other-teams", "-f", globalFile} + for _, fileName := range teamFileNames { + args = append(args, "-f", fileName) + } + _ = runAppForTest(t, args) + + // Check that only the teams exist + teamsJSON := runAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"}) + assert.Equal(t, 2, strings.Count(teamsJSON, "team_id")) + + // Real run with one file at a time _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}) - for _, file := range teamFiles { - if filepath.Ext(file.Name()) == ".yml" { - _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", path.Join(teamsDir, file.Name())}) - } + for _, fileName := range teamFileNames { + _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName}) } } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index a380e885ba..5e87d4e2c2 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "os" "slices" "strings" @@ -22,6 +23,8 @@ import ( const teamName = "Team Test" func TestBasicGlobalGitOps(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables + _, ds := runServerWithMockedDS(t) ds.BatchSetMDMProfilesFunc = func( @@ -88,13 +91,19 @@ org_settings: // No file var errWriter strings.Builder _, err = runAppNoChecks([]string{"gitops", tmpFile.Name()}) - assert.Error(t, err) - assert.Equal(t, err.Error(), "-f must be specified") + require.Error(t, err) + assert.Equal(t, `Required flag "f" not set`, err.Error()) + + // Blank file + errWriter.Reset() + _, err = runAppNoChecks([]string{"gitops", "-f", ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "file name cannot be empty") // Bad file errWriter.Reset() _, err = runAppNoChecks([]string{"gitops", "-f", "fileDoesNotExist.yml"}) - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), "no such file or directory") // Empty file @@ -102,13 +111,13 @@ org_settings: badFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) _, err = runAppNoChecks([]string{"gitops", "-f", badFile.Name()}) - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), "errors occurred") // DoGitOps error t.Setenv("ORG_NAME", "") _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), "organization name must be present") // Dry run @@ -124,6 +133,7 @@ org_settings: } func TestBasicTeamGitOps(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ @@ -214,7 +224,7 @@ team_settings: // DoGitOps error t.Setenv("TEST_TEAM_NAME", "") _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), "'name' is required") // Dry run @@ -231,6 +241,7 @@ team_settings: } func TestFullGlobalGitOps(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) @@ -348,9 +359,16 @@ func TestFullGlobalGitOps(t *testing.T) { ) t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) + t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName) + file := "./testdata/gitops/global_config_no_paths.yml" + + // Dry run should fail because Apple BM Default Team does not exist and premium license is not set + _, err = runAppNoChecks([]string{"gitops", "-f", file, "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "missing or invalid license")) // Dry run - file := "./testdata/gitops/global_config_no_paths.yml" + t.Setenv("APPLE_BM_DEFAULT_TEAM", "") _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") assert.Len(t, enrolledSecrets, 0) @@ -377,9 +395,11 @@ func TestFullGlobalGitOps(t *testing.T) { assert.Equal(t, "service@example.com", savedAppConfig.Integrations.GoogleCalendar[0].ApiKey["client_email"]) assert.True(t, savedAppConfig.ActivityExpirySettings.ActivityExpiryEnabled) assert.Equal(t, 60, savedAppConfig.ActivityExpirySettings.ActivityExpiryWindow) + assert.True(t, savedAppConfig.ServerSettings.AIFeaturesDisabled) } func TestFullTeamGitOps(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} // mdm test configuration must be set so that activating windows MDM works. @@ -616,3 +636,384 @@ team_settings: assert.Empty(t, savedTeam.Config.MDM.MacOSSetup.BootstrapPackage.Value) assert.False(t, savedTeam.Config.MDM.EnableDiskEncryption) } + +func TestBasicGlobalAndTeamGitOps(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + }, + ) + + // Mock appConfig + savedAppConfig := &fleet.AppConfig{} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + savedAppConfig = config + return nil + } + + const ( + fleetServerURL = "https://fleet.example.com" + orgName = "GitOps Test" + secret = "TestSecret" + ) + var enrolledSecrets []*fleet.EnrollSecret + var enrolledTeamSecrets []*fleet.EnrollSecret + var savedTeam *fleet.Team + team := &fleet.Team{ + ID: 1, + CreatedAt: time.Now(), + Name: teamName, + } + + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + if teamID == nil { + enrolledSecrets = secrets + } else { + enrolledTeamSecrets = secrets + } + return nil + } + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, + ) error { + return nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) error { + return nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) + return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } + ds.ListTeamPoliciesFunc = func( + ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, + ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { + return nil, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + job.ID = 1 + return job, nil + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + if tid == team.ID { + return team, nil + } + return nil, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == teamName { + return team, nil + } + return nil, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) ( + *fleet.MDMAppleDeclaration, error, + ) { + declaration.DeclarationUUID = uuid.NewString() + return declaration, nil + } + + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + + t.Setenv("FLEET_SERVER_URL", fleetServerURL) + t.Setenv("ORG_NAME", orgName) + + _, err = globalFile.WriteString( + ` +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: +`, + ) + require.NoError(t, err) + + teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + + t.Setenv("TEST_TEAM_NAME", teamName) + t.Setenv("TEST_SECRET", secret) + + _, err = teamFile.WriteString( + ` +controls: +queries: +policies: +agent_options: +name: ${TEST_TEAM_NAME} +team_settings: + secrets: [{"secret":"${TEST_SECRET}"}] +`, + ) + require.NoError(t, err) + + // Files out of order + _, err = runAppNoChecks([]string{"gitops", "-f", teamFile.Name(), "-f", globalFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "must be the global config")) + + // Global file specified multiple times + _, err = runAppNoChecks([]string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "-f", globalFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "only the first file can be the global config")) + + // Dry run + _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + + // Dry run, deleting other teams + assert.False(t, ds.ListTeamsFuncInvoked) + _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run", "--delete-other-teams"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + assert.True(t, ds.ListTeamsFuncInvoked) + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name()}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Empty(t, enrolledSecrets) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledTeamSecrets, 1) + assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) + + // Now, set up a team to delete + teamToDeleteID := uint(999) + teamToDelete := &fleet.Team{ + ID: teamToDeleteID, + CreatedAt: time.Now(), + Name: "Team to delete", + } + ds.ListTeamsFuncInvoked = false + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{teamToDelete, team}, nil + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + switch tid { + case team.ID: + return team, nil + case teamToDeleteID: + return teamToDelete, nil + } + assert.Fail(t, fmt.Sprintf("unexpected team ID %d", tid)) + return teamToDelete, nil + } + ds.DeleteTeamFunc = func(ctx context.Context, tid uint) error { + assert.Equal(t, teamToDeleteID, tid) + return nil + } + ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { + return nil, nil + } + + // Real run, deleting other teams + _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--delete-other-teams"}) + assert.True(t, ds.ListTeamsFuncInvoked) + assert.True(t, ds.DeleteTeamFuncInvoked) + +} + +func TestFullGlobalAndTeamGitOps(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables + // mdm test configuration must be set so that activating windows MDM works. + testCert, testKey, err := apple_mdm.NewSCEPCACertKey() + require.NoError(t, err) + testCertPEM := tokenpki.PEMCertificate(testCert.Raw) + testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) + fleetCfg := config.TestConfig() + config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata") + + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + MDMStorage: new(mock.MDMAppleStore), + MDMPusher: mockPusher{}, + FleetConfig: &fleetCfg, + License: license, + NoCacheDatastore: true, + }, + ) + + // Mock appConfig + savedAppConfig := &fleet.AppConfig{ + MDM: fleet.MDM{ + EnabledAndConfigured: true, + }, + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + appConfigCopy := *savedAppConfig + return &appConfigCopy, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + appConfigCopy := *config + savedAppConfig = &appConfigCopy + return nil + } + + const ( + fleetServerURL = "https://fleet.example.com" + orgName = "GitOps Test" + ) + var enrolledSecrets []*fleet.EnrollSecret + var enrolledTeamSecrets []*fleet.EnrollSecret + var appliedPolicySpecs []*fleet.PolicySpec + var appliedQueries []*fleet.Query + var savedTeam *fleet.Team + + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + if teamID == nil { + enrolledSecrets = secrets + } else { + enrolledTeamSecrets = secrets + } + return nil + } + ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { + appliedPolicySpecs = specs + return nil + } + ds.ApplyQueriesFunc = func( + ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}, + ) error { + appliedQueries = queries + return nil + } + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, + ) error { + return nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) error { + return nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) + return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } + ds.ListTeamPoliciesFunc = func( + ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, + ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { + return nil, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + if savedTeam != nil { + return []*fleet.Team{savedTeam}, nil + } + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } + ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, p fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { + return nil, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + job.ID = 1 + return job, nil + } + ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + team.ID = 1 + savedTeam = team + enrolledTeamSecrets = team.Secrets + return savedTeam, nil + } + ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { + return nil, ¬FoundError{} + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + if savedTeam != nil && tid == savedTeam.ID { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if savedTeam != nil && name == teamName { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) ( + *fleet.MDMAppleDeclaration, error, + ) { + declaration.DeclarationUUID = uuid.NewString() + return declaration, nil + } + + t.Setenv("FLEET_SERVER_URL", fleetServerURL) + t.Setenv("ORG_NAME", orgName) + t.Setenv("TEST_TEAM_NAME", teamName) + t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName) + + globalFile := "./testdata/gitops/global_config_no_paths.yml" + teamFile := "./testdata/gitops/team_config_no_paths.yml" + + // Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided) + _, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "team name not found")) + + // Dry run + _ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--dry-run", "--delete-other-teams"}) + assert.False(t, ds.SaveAppConfigFuncInvoked) + assert.Len(t, enrolledSecrets, 0) + assert.Len(t, enrolledTeamSecrets, 0) + assert.Len(t, appliedPolicySpecs, 0) + assert.Len(t, appliedQueries, 0) + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--delete-other-teams"}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Len(t, enrolledSecrets, 2) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledTeamSecrets, 2) + +} diff --git a/cmd/fleetctl/scripts.go b/cmd/fleetctl/scripts.go index 984c2dd555..b38ca9e563 100644 --- a/cmd/fleetctl/scripts.go +++ b/cmd/fleetctl/scripts.go @@ -15,6 +15,14 @@ import ( "github.com/urfave/cli/v2" ) +// Helper function to convert a boolean to an integer +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + func runScriptCommand() *cli.Command { return &cli.Command{ Name: "run-script", @@ -42,6 +50,16 @@ func runScriptCommand() *cli.Command { Usage: `Available in Fleet Premium. ID of the team that the saved script belongs to. 0 targets hosts assigned to β€œNo team” (default: 0).`, Required: false, }, + &cli.BoolFlag{ + Name: "async", + Usage: `Queue the script and don't wait for the return.`, + Required: false, + }, + &cli.BoolFlag{ + Name: "quiet", + Usage: `Suppress messages that are not the script output / error`, + Required: false, + }, configFlag(), contextFlag(), debugFlag(), @@ -61,15 +79,22 @@ func runScriptCommand() *cli.Command { return errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg) } + async := c.Bool("async") + quiet := c.Bool("quiet") + + // Require 1 and only 1 of these 3 options path := c.String("script-path") name := c.String("script-name") + args := c.Args().Len() - if path == "" && name == "" { - return errors.New("One of '--script-path' or '--script-name' must be specified.") + notEmpty := boolToInt(path != "") + boolToInt(name != "") + boolToInt(args > 0) + + if notEmpty < 1 { + return errors.New("One of '--script-path' or '--script-name' or '-- ' must be specified.") } - if path != "" && name != "" { - return errors.New("Only one of '--script-path' or '--script-name' is allowed.") + if notEmpty > 1 { + return errors.New("Only one of '--script-path' or '--script-name' or '-- ' is allowed.") } if path != "" { @@ -99,10 +124,17 @@ func runScriptCommand() *cli.Command { } var b []byte - if path != "" { - b, err = os.ReadFile(path) - if err != nil { - return err + if path != "" || args > 0 { + if path != "" { + b, err = os.ReadFile(path) + if err != nil { + return err + } + } + + if args > 0 { + commandString := strings.Join(c.Args().Slice(), " ") + b = []byte(commandString) } // validate script contents with isSavedScript flag set to false so that we check @@ -115,7 +147,21 @@ func runScriptCommand() *cli.Command { } } - fmt.Println("\nScript is running. Please wait for it to finish...") + if async { + res, err := client.RunHostScriptAsync(h.ID, b, name, c.Uint("team")) + if err != nil { + if strings.Contains(err.Error(), `Only one of 'script_contents' or 'team_id' is allowed`) { + return errors.New("Only one of '--script-path' or '--team' is allowed.") + } + return err + } + fmt.Fprintf(c.App.Writer, "%s\n", res.ExecutionID) + return nil + } + + if !quiet { + fmt.Println("\nScript is running. Please wait for it to finish...") + } res, err := client.RunHostScriptSync(h.ID, b, name, c.Uint("team")) if err != nil { @@ -125,8 +171,12 @@ func runScriptCommand() *cli.Command { return err } - if err := renderScriptResult(c, res); err != nil { - return err + if !quiet { + if err := renderScriptResult(c, res); err != nil { + return err + } + } else { + fmt.Fprintf(c.App.Writer, "%s", res.Output) } return nil diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index aaeac54c2e..5730251fbe 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -70,6 +70,9 @@ hello world ------------------------------------------------------------------------------------- ` + expectedQuietOutputSuccess := `hello world +` + type testCase struct { name string scriptPath func() string @@ -77,6 +80,8 @@ hello world teamID *uint savedScriptContents func() ([]byte, error) scriptResult *fleet.HostScriptResult + quiet bool + async bool expectOutput string expectErrMsg string expectNotFound bool @@ -105,7 +110,48 @@ hello world { name: "invalid hashbang", scriptPath: func() string { return writeTmpScriptContents(t, "#! /foo/bar", ".sh") }, - expectErrMsg: `Interpreter not supported. Bash scripts must run in "#!/bin/sh”.`, + expectErrMsg: `Interpreter not supported. Shell scripts must run in "#!/bin/sh" or "#!/bin/zsh."`, + }, + { + name: "unsupported hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/ksh", ".sh") }, + expectErrMsg: `Interpreter not supported. Shell scripts must run in "#!/bin/sh" or "#!/bin/zsh."`, + }, + { + name: "posix shell hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/sh", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, + }, + { + name: "zsh hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/zsh", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, + }, + { + name: "usr zsh hashbang", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/usr/bin/zsh", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, + }, + { + name: "zsh hashbang with arguments", + scriptPath: func() string { return writeTmpScriptContents(t, "#!/bin/zsh -x", ".sh") }, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world", + }, + expectOutput: expectedOutputSuccess, }, { name: "script too long (unsaved)", @@ -149,11 +195,11 @@ hello world name: "script-path and script-name disallowed", scriptPath: generateValidPath, scriptName: "foo", - expectErrMsg: `Only one of '--script-path' or '--script-name' is allowed.`, + expectErrMsg: `Only one of '--script-path' or '--script-name' or '-- ' is allowed.`, }, { name: "missing one of script-path and script-nqme", - expectErrMsg: `One of '--script-path' or '--script-name' must be specified.`, + expectErrMsg: `One of '--script-path' or '--script-name' or '-- ' must be specified.`, }, { name: "script-path and team disallowed", @@ -186,6 +232,16 @@ hello world }, expectOutput: expectedOutputSuccess, }, + { + name: "script quiet", + scriptPath: generateValidPath, + scriptResult: &fleet.HostScriptResult{ + ExitCode: ptr.Int64(0), + Output: "hello world\n", + }, + expectOutput: expectedQuietOutputSuccess, + quiet: true, + }, { name: "script failed", scriptPath: generateValidPath, @@ -351,6 +407,14 @@ Fleet records the last 10,000 characters to prevent downtime. args = append(args, "--script-name", c.scriptName) } + if c.quiet { + args = append(args, "--quiet") + } + + if c.async { + args = append(args, "--async") + } + if c.teamID != nil { args = append(args, "--team", fmt.Sprintf("%d", *c.teamID)) } diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 6cd967ae3d..5827a3d776 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -14,7 +14,8 @@ "query_reports_disabled": false, "enable_analytics": false, "deferred_save_host": false, - "scripts_disabled": false + "scripts_disabled": false, + "ai_features_disabled": false }, "smtp_settings": { "enable_smtp": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 707bd618f0..bfb7b77d75 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -62,6 +62,7 @@ spec: query_reports_disabled: false server_url: "" scripts_disabled: false + ai_features_disabled: false smtp_settings: authentication_method: "" authentication_type: "" diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 2265c7b200..5df52f4c8e 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -14,7 +14,8 @@ "query_reports_disabled": false, "enable_analytics": false, "deferred_save_host": false, - "scripts_disabled": false + "scripts_disabled": false, + "ai_features_disabled": false }, "smtp_settings": { "enable_smtp": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 298366fc5c..ad517c6939 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -101,6 +101,7 @@ spec: query_reports_disabled: false server_url: "" scripts_disabled: false + ai_features_disabled: false smtp_settings: authentication_method: "" authentication_type: "" diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index d20cf84074..60d41d888a 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -104,6 +104,7 @@ org_settings: 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: "" @@ -144,7 +145,7 @@ org_settings: "private_key": "google_calendar_private_key", } mdm: - apple_bm_default_team: "" + apple_bm_default_team: $APPLE_BM_DEFAULT_TEAM end_user_authentication: entity_id: "" idp_name: "" diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 848e83bf17..c629253b19 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -62,6 +62,7 @@ spec: query_reports_disabled: false server_url: https://example.org scripts_disabled: false + ai_features_disabled: false smtp_settings: authentication_method: "" authentication_type: "" diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index fbe52c9190..e33de77f96 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -62,6 +62,7 @@ spec: query_reports_disabled: false server_url: https://example.org scripts_disabled: false + ai_features_disabled: false smtp_settings: authentication_method: "" authentication_type: "" diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index e7937927ed..c89046fefd 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -597,7 +597,7 @@ Once base64-decoded, they are PEM-encoded certificate and keys. | team_id | number | query | _Available in Fleet Premium_ The team ID to apply the custom settings to. Only one of `team_name`/`team_id` can be provided. | | team_name | string | query | _Available in Fleet Premium_ The name of the team to apply the custom settings to. Only one of `team_name`/`team_id` can be provided. | | dry_run | bool | query | Validate the provided profiles and return any validation errors, but do not apply the changes. | -| profiles | json | body | An array of objects, consisting of a `profile` base64-encoded .mobileconfig (macOS) or XML (Windows) file, `labels` array of strings (label names), and `name` display name (only for Windows configuration profiles). | +| profiles | json | body | An array of objects, consisting of a `profile` base64-encoded .mobileconfig or JSON for macOS and XML (Windows) file, `labels` array of strings (label names), and `name` display name (for Windows configuration profiles and macOS declaration profiles). | If no team (id or name) is provided, the profiles are applied for all hosts (for _Fleet Free_) or for hosts that are not assigned to any team (for _Fleet Premium_). After the call, the provided list of `profiles` will be the active profiles for that team (or no team) - that is, any existing profile that is not part of that list will be removed, and an existing profile with the same payload identifier (macOS) as a new profile will be edited. If the list of provided `profiles` is empty, all profiles are removed for that team (or no team). @@ -1281,7 +1281,7 @@ If the `name` is not already associated with an existing team, this API route cr | mdm.macos_updates.minimum_version | string | body | The required minimum operating system version. | | mdm.macos_updates.deadline | string | body | The required installation date for Nudge to enforce the operating system version. | | mdm.macos_settings | object | body | The macOS-specific MDM settings. | -| mdm.macos_settings.custom_settings | list | body | The list of objects consists of a `path` to .mobileconfig file and `labels` list of label names. | +| mdm.macos_settings.custom_settings | list | body | The list of objects consists of a `path` to .mobileconfig or JSON file and `labels` list of label names. | | mdm.windows_settings | object | body | The Windows-specific MDM settings. | | mdm.windows_settings.custom_settings | list | body | The list of objects consists of a `path` to XML files and `labels` list of label names. | | scripts | list | body | A list of script files to add to this team so they can be executed at a later time. | @@ -1347,11 +1347,15 @@ If the `name` is not already associated with an existing team, this API route cr "path": "path/to/profile1.mobileconfig" "labels": ["Label 1", "Label 2"] }, + { + "path": "path/to/profile2.json" + "labels": ["Label 3", "Label 4"] + }, "enable_disk_encryption": true }, "windows_settings": { "custom_settings": { - "path": "path/to/profile1.xml" + "path": "path/to/profile3.xml" "labels": ["Label 1", "Label 2"] }, } diff --git a/docs/Contributing/Testing-and-local-development.md b/docs/Contributing/Testing-and-local-development.md index 2ef4085be6..55b5e95e01 100644 --- a/docs/Contributing/Testing-and-local-development.md +++ b/docs/Contributing/Testing-and-local-development.md @@ -69,7 +69,7 @@ Check out [`/tools/osquery` directory instructions](https://github.com/fleetdm/f You must install the [`golangci-lint`](https://golangci-lint.run/) command to run `make test[-go]` or `make lint[-go]`, using: ```sh -go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.2 +go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 ``` Make sure it is available in your `PATH`. To execute the basic unit and integration tests, run the following from the root of the repository: diff --git a/docs/Get started/FAQ.md b/docs/Get started/FAQ.md index 8a4d5c2185..4634061c1f 100644 --- a/docs/Get started/FAQ.md +++ b/docs/Get started/FAQ.md @@ -47,7 +47,7 @@ You can test changes on a small subset of hosts first, then roll them out to the ## Is Fleet MIT licensed? -We have different licenses for portions of our software which are noted in the [LICENSE](https://github.com/fleetdm/fleet/blob/main/LICENSE) file in our docs. The majority of Fleet is MIT licensed. Paid features require a license key. +Different portions of the Fleet software are licensed differently, as noted in the [LICENSE](https://github.com/fleetdm/fleet/blob/main/LICENSE) file. The majority of Fleet is MIT licensed. Paid features require a license key. ## What is your commitment to open source stewardship? @@ -71,7 +71,9 @@ We have different licenses for portions of our software which are noted in the [ ## How do I contact Fleet for support? -For community support, visit https://fleetdm.com/support. +A lot of questions can be answered [in the documentation](https://fleetdm.com/docs). + +To get help from the community, visit https://fleetdm.com/support. If your organization has Fleet Premium, you can [access professional support](https://fleetdm.com/customers/login) with a guaranteed response time. diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 8bdaaacbfd..3bf6c6f1bf 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -1214,11 +1214,15 @@ Note that when making changes to the `integrations` object, all integrations mus "path": "path/to/profile1.mobileconfig", "labels": ["Label 1", "Label 2"] }, + { + "path": "path/to/profile2.json", + "labels": ["Label 3", "Label 4"] + }, "enable_disk_encryption": true }, "windows_settings": { "custom_settings": { - "path": "path/to/profile1.xml", + "path": "path/to/profile3.xml", "labels": ["Label 1", "Label 2"] } }, @@ -3258,10 +3262,14 @@ This report includes a subset of host vitals, and simplified policy and vulnerab "updated_at": "2023-09-16T18:52:19Z", "os_version": "CentOS Linux 8.3.2011", "disk_encryption_enabled": true, + "failing_policies_count": 1, + "failing_critical_policies_count": 1, // Fleet Premium only "failing_policies": [ { "id": 123, "name": "Google Chrome is up to date", + "critical": true, // Fleet Premium only + "resolution": "Follow the Update Google Chrome instructions here: https://support.google.com/chrome/answer/95414?sjid=6534253818042437614-NA" } ], "vulnerable_software": [ @@ -3512,6 +3520,28 @@ A `team_id` of `0` returns the statistics for hosts that are not part of any tea } ``` +### Resend host's configuration profile + +Resends a configuration profile for the specified host. + +`POST /api/v1/fleet/hosts/:id/configuration_profiles/resend/:profile_uuid` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | **Required.** The host's ID. | +| profile_uuid | string | path | **Required.** The UUID of the configuration profile to resend to the host. | + +#### Example + +`POST /api/v1/fleet/hosts/233/configuration_profiles/resend/fc14a20-84a2-42d8-9257-a425f62bb54d` + +##### Default response + +`Status: 202` + + ### List host OS versions Retrieves the aggregated host OS versions information. @@ -4201,8 +4231,8 @@ Note that if the host is online and the query times out, this endpoint will retu ## Labels -- [Create label](#create-label) -- [Modify label](#modify-label) +- [Add label](#add-label) +- [Update label](#update-label) - [Get label](#get-label) - [Get labels summary](#get-labels-summary) - [List labels](#list-labels) @@ -4210,9 +4240,9 @@ Note that if the host is online and the query times out, this endpoint will retu - [Delete label](#delete-label) - [Delete label by ID](#delete-label-by-id) -### Create label +### Add label -Creates a dynamic label. +Add a dynamic or manual label. `POST /api/v1/fleet/labels` @@ -4222,9 +4252,12 @@ Creates a dynamic label. | ----------- | ------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | name | string | body | **Required**. The label's name. | | description | string | body | The label's description. | -| query | string | body | **Required**. The query in SQL syntax used to filter the hosts. | +| query | string | body | The query in SQL syntax used to filter the hosts. Only one of either `query` (to create a dynamic label) or `hosts` (to create a manual label) can be included in the request. | +| hosts | array | body | The list of host identifiers (`hardware_serial`, `uuid`, `osquery_host_id`, `hostname`, or `name`) the label will apply to. Only one of either `query` (to create a dynamic label) or `hosts` (to create a manual label) can be included in the request. | | platform | string | body | The specific platform for the label to target. Provides an additional filter. Choices for platform are `darwin`, `windows`, `ubuntu`, and `centos`. All platforms are included by default and this option is represented by an empty string. | +If both `query` and `hosts` aren't specified, a manual label with no hosts will be created. + #### Example `POST /api/v1/fleet/labels` @@ -4262,9 +4295,9 @@ Creates a dynamic label. } ``` -### Modify label +### Update label -Modifies the specified label. Note: Label queries and platforms are immutable. To change these, you must delete the label and create a new label. +Updates the specified label. Note: Label queries and platforms are immutable. To change these, you must delete the label and create a new label. `PATCH /api/v1/fleet/labels/:id` @@ -4275,6 +4308,8 @@ Modifies the specified label. Note: Label queries and platforms are immutable. T | id | integer | path | **Required**. The label's id. | | name | string | body | The label's name. | | description | string | body | The label's description. | +| hosts | array | body | If updating a manual label: the list of host identifiers (`hardware_serial`, `uuid`, `osquery_host_id`, `hostname`, or `name`) the label will apply to. | + #### Example @@ -4684,9 +4719,10 @@ Add a configuration profile to enforce custom settings on macOS and Windows host | Name | Type | In | Description | | ------------------------- | -------- | ---- | ------------------------------------------------------------------------------------------------------------- | -| profile | file | form | **Required.** The .mobileconfig (macOS) or XML (Windows) file containing the profile. | +| profile | file | form | **Required.** The .mobileconfig and JSON for macOS or XML for Windows file containing the profile. | | team_id | string | form | _Available in Fleet Premium_. The team ID for the profile. If specified, the profile is applied to only hosts that are assigned to the specified team. If not specified, the profile is applied to only to hosts that are not assigned to any team. | -| labels | array | form | _Available in Fleet Premium_. An array of labels to filter hosts in a team (or no team) that should get a profile. | +| labels | array | form | _Available in Fleet Premium_. An array of labels to filter hosts in a team (or no team) that should get a profile. | + #### Example @@ -8708,7 +8744,7 @@ _Available in Fleet Premium_ |     deadline_days | integer | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before updates are installed on Windows. | |     grace_period_days | integer | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before Windows restarts to install updates. | |   macos_settings | object | body | macOS-specific settings. | -|     custom_settings | list | body | The list of objects where each object includes .mobileconfig file (configuration profile) and label name to apply to macOS hosts that belong to this team and are members of the specified label. | +|     custom_settings | list | body | The list of objects where each object includes .mobileconfig or JSON file (configuration profile) and label name to apply to macOS hosts that belong to this team and are members of the specified label. | |     enable_disk_encryption | boolean | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have disk encryption enabled if set to true. | |   windows_settings | object | body | Windows-specific settings. | |     custom_settings | list | body | The list of objects where each object includes XML file (configuration profile) and label name to apply to Windows hosts that belong to this team and are members of the specified label. | diff --git a/docs/Using Fleet/manage-access.md b/docs/Using Fleet/manage-access.md index fe6af40380..88ee5fdf65 100644 --- a/docs/Using Fleet/manage-access.md +++ b/docs/Using Fleet/manage-access.md @@ -80,7 +80,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | | βœ… | | | View disk encryption key for macOS and Windows hosts | βœ… | βœ… | βœ… | βœ… | | | Edit OS updates for macOS and Windows hosts | | | βœ… | βœ… | βœ… | -| Create edit and delete configuration profiles for macOS and Windows hosts | | | βœ… | βœ… | βœ… | +| Create, edit, resend and delete configuration profiles for macOS and Windows hosts | | | βœ… | βœ… | βœ… | | Execute MDM commands on macOS and Windows hosts\** | | | βœ… | βœ… | | | View results of MDM commands executed on macOS and Windows hosts\** | βœ… | βœ… | βœ… | βœ… | | | Edit [MDM settings](https://fleetdm.com/docs/using-fleet/mdm-macos-settings) | | | | βœ… | βœ… | @@ -152,7 +152,7 @@ Users with access to multiple teams can be assigned different roles for each tea | Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | | βœ… | βœ… | | | View disk encryption key for macOS hosts | βœ… | βœ… | βœ… | βœ… | | | Edit OS updates for macOS and Windows hosts | | | βœ… | βœ… | βœ… | -| Create edit and delete configuration profiles for macOS and Windows hosts | | | βœ… | βœ… | βœ… | +| Create, edit, resend and delete configuration profiles for macOS and Windows hosts | | | βœ… | βœ… | βœ… | | Execute MDM commands on macOS and Windows hosts* | | | βœ… | βœ… | | | View results of MDM commands executed on macOS and Windows hosts* | βœ… | βœ… | βœ… | βœ… | | | Edit [team MDM settings](https://fleetdm.com/docs/using-fleet/mdm-macos-settings) | | | | βœ… | βœ… | diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index acc2718510..c1350e5613 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -949,7 +949,7 @@ func (svc *Service) getOrCreatePreassignTeam(ctx context.Context, groups []strin }, }, } - if _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}); err != nil { + if _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplyTeamSpecOptions{}); err != nil { return nil, err } if err := svc.ds.CopyDefaultMDMAppleBootstrapPackage(ctx, ac, team.ID); err != nil { diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 652a23185b..e5bd1e4925 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -424,7 +424,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { spec := &fleet.TeamSpec{ Name: team2.Name, } - _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) + _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplyTeamSpecOptions{}) require.NoError(t, err) require.True(t, ds.SaveTeamFuncInvoked) require.True(t, ds.AppConfigFuncInvoked) @@ -523,7 +523,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { } // apply team spec creates new team without defaults - _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) + _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplyTeamSpecOptions{}) require.NoError(t, err) require.True(t, ds.NewTeamFuncInvoked) require.True(t, ds.AppConfigFuncInvoked) @@ -536,7 +536,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { // apply team spec edits existing team without applying defaults spec.MDM.MacOSUpdates.Deadline = optjson.SetString("2025-01-01") - _, err = svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) + _, err = svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplyTeamSpecOptions{}) require.NoError(t, err) require.True(t, ds.SaveTeamFuncInvoked) require.True(t, ds.AppConfigFuncInvoked) diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 4aba2df6cd..25f9f5d40e 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -728,7 +728,9 @@ func (svc *Service) checkAuthorizationForTeams(ctx context.Context, specs []*fle return nil } -func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, applyOpts fleet.ApplySpecOptions) (map[string]uint, error) { +func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, applyOpts fleet.ApplyTeamSpecOptions) ( + map[string]uint, error, +) { if len(specs) == 0 { setAuthCheckedOnPreAuthErr(ctx) // Nothing to do. @@ -814,7 +816,7 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, continue } - if err := svc.editTeamFromSpec(ctx, team, spec, appConfig, secrets, applyOpts.DryRun); err != nil { + if err := svc.editTeamFromSpec(ctx, team, spec, appConfig, secrets, applyOpts); err != nil { return nil, ctxerr.Wrap(ctx, err, "editing team from spec") } @@ -967,7 +969,7 @@ func (svc *Service) editTeamFromSpec( spec *fleet.TeamSpec, appCfg *fleet.AppConfig, secrets []*fleet.EnrollSecret, - dryRun bool, + opts fleet.ApplyTeamSpecOptions, ) error { team.Name = spec.Name @@ -1064,8 +1066,12 @@ func (svc *Service) editTeamFromSpec( } team.Config.MDM.MacOSSetup.EnableEndUserAuthentication = spec.MDM.MacOSSetup.EnableEndUserAuthentication + windowsEnabledAndConfigured := appCfg.MDM.WindowsEnabledAndConfigured + if opts.DryRunAssumptions != nil && opts.DryRunAssumptions.WindowsEnabledAndConfigured.Valid { + windowsEnabledAndConfigured = opts.DryRunAssumptions.WindowsEnabledAndConfigured.Value + } if spec.MDM.WindowsSettings.CustomSettings.Set { - if !appCfg.MDM.WindowsEnabledAndConfigured && + if !windowsEnabledAndConfigured && len(spec.MDM.WindowsSettings.CustomSettings.Value) > 0 && !fleet.MDMProfileSpecsMatch(team.Config.MDM.WindowsSettings.CustomSettings.Value, spec.MDM.WindowsSettings.CustomSettings.Value) { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_settings.custom_settings", @@ -1101,7 +1107,7 @@ func (svc *Service) editTeamFromSpec( } if spec.Integrations.GoogleCalendar != nil { - err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, dryRun, invalid) + err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, opts.DryRun, invalid) if err != nil { return ctxerr.Wrap(ctx, err, "validate team calendar integrations") } @@ -1112,7 +1118,7 @@ func (svc *Service) editTeamFromSpec( return ctxerr.Wrap(ctx, invalid) } - if dryRun { + if opts.DryRun { return nil } diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index af7bd63524..872f4baecf 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -14,6 +14,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { deferred_save_host: false, query_reports_disabled: false, scripts_disabled: false, + ai_features_disabled: false, }, smtp_settings: { enable_smtp: false, diff --git a/frontend/__mocks__/policyMock.ts b/frontend/__mocks__/policyMock.ts index c66c58a0bc..b14095463e 100644 --- a/frontend/__mocks__/policyMock.ts +++ b/frontend/__mocks__/policyMock.ts @@ -11,7 +11,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = { author_id: 1, author_name: "Test User", author_email: "test@user.com", - team_id: undefined, + team_id: null, resolution: "Ensure ClamAV and Freshclam are installed and running.", platform: "linux" as const, created_at: "2023-03-24T22:13:59Z", @@ -29,4 +29,87 @@ const createMockPolicy = (overrides?: Partial): IPolicyStats => { return { ...DEFAULT_POLICY_MOCK, ...overrides }; }; +export const createMockPoliciesResponse = ( + overrides?: Partial +) => { + const MOCK_POLICIES_RESPONSE: { policies: IPolicyStats[] } = { + policies: [ + { + id: 5, + name: "Gatekeeper enabled", + query: "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;", + description: "Checks if gatekeeper is enabled on macOS devices", + critical: true, + author_id: 42, + author_name: "John", + author_email: "john@example.com", + team_id: 2, + resolution: "Resolution steps", + platform: "darwin", + created_at: "2021-12-16T14:37:37Z", + updated_at: "2021-12-16T16:39:00Z", + passing_host_count: 2000, + failing_host_count: 300, + host_count_updated_at: "2023-12-20T15:23:57Z", + webhook: "Off", + has_run: true, + next_update_ms: 3600000, + calendar_events_enabled: false, + }, + { + id: 29090, + name: "Windows machines with encrypted hard disks", + query: "SELECT 1 FROM bitlocker_info WHERE protection_status = 1;", + description: "Checks if the hard disk is encrypted on Windows devices", + critical: false, + author_id: 43, + author_name: "Alice", + author_email: "alice@example.com", + team_id: 2, + resolution: "Resolution steps", + platform: "windows", + created_at: "2021-12-16T14:37:37Z", + updated_at: "2021-12-16T16:39:00Z", + passing_host_count: 2300, + failing_host_count: 0, + host_count_updated_at: "2023-12-20T15:23:57Z", + webhook: "Off", + has_run: true, + next_update_ms: 3600000, + calendar_events_enabled: false, + }, + { + id: 136, + name: "Arbitrary Test Policy (all platforms) (all teams)", + query: "SELECT 1 FROM osquery_info WHERE 1=1;", + description: + "If you're seeing this, mostly likely this is because someone is testing out failing policies in dogfood. You can ignore this.", + critical: true, + author_id: 77, + author_name: "Test Admin", + author_email: "test@admin.com", + team_id: null, + resolution: + 'To make it pass, change "1=0" to "1=1". To make it fail, change "1=1" to "1=0".', + platform: "darwin,windows,linux", + created_at: "2022-08-04T19:30:18Z", + updated_at: "2022-08-30T15:08:26Z", + passing_host_count: 10, + failing_host_count: 9, + host_count_updated_at: "2023-12-20T15:23:57Z", + webhook: "Off", + has_run: true, + next_update_ms: 3600000, + calendar_events_enabled: false, + }, + ], + }; + + if (overrides) { + MOCK_POLICIES_RESPONSE.policies.push(createMockPolicy(overrides)); + } + + return MOCK_POLICIES_RESPONSE; +}; + export default createMockPolicy; diff --git a/frontend/components/EmptyTable/_styles.scss b/frontend/components/EmptyTable/_styles.scss index 940afb6945..2aa30b7d68 100644 --- a/frontend/components/EmptyTable/_styles.scss +++ b/frontend/components/EmptyTable/_styles.scss @@ -39,10 +39,9 @@ &__info, &__additional-info { + @include help-text; line-height: 1.5; text-align: center; - color: $core-fleet-blue; - font-size: $x-small; margin: 0; } diff --git a/frontend/components/InheritedBadge/InheritedBadge.tsx b/frontend/components/InheritedBadge/InheritedBadge.tsx new file mode 100644 index 0000000000..0a098317c1 --- /dev/null +++ b/frontend/components/InheritedBadge/InheritedBadge.tsx @@ -0,0 +1,40 @@ +import { uniqueId } from "lodash"; +import React from "react"; +import { PlacesType, Tooltip as ReactTooltip5 } from "react-tooltip-5"; + +const baseClass = "inherited-badge"; + +interface IInheritedBadgeProps { + tooltipPosition?: PlacesType; + tooltipContent: React.ReactNode; +} + +const InheritedBadge = ({ + tooltipPosition = "top", + tooltipContent, +}: IInheritedBadgeProps) => { + const tooltipId = uniqueId(); + return ( +
+ + Inherited + + + {tooltipContent} + +
+ ); +}; + +export default InheritedBadge; diff --git a/frontend/components/InheritedBadge/_styles.scss b/frontend/components/InheritedBadge/_styles.scss new file mode 100644 index 0000000000..5c6bdba177 --- /dev/null +++ b/frontend/components/InheritedBadge/_styles.scss @@ -0,0 +1,19 @@ +.inherited-badge { + &__element-text { + font-weight: $bold; + font-size: $xxx-small; + color: $core-fleet-black; + line-height: 15px; + border-radius: 4px; + background: $ui-vibrant-blue-10; + padding: 4px; + } + + @include tooltip5-arrow-styles; + + .react-tooltip { + @include tooltip-text; + font-style: normal; + text-align: center; + } +} diff --git a/frontend/components/InheritedBadge/index.ts b/frontend/components/InheritedBadge/index.ts new file mode 100644 index 0000000000..1f70caec54 --- /dev/null +++ b/frontend/components/InheritedBadge/index.ts @@ -0,0 +1 @@ +export { default } from "./InheritedBadge"; diff --git a/frontend/components/PlatformSelector/PlatformSelector.tsx b/frontend/components/PlatformSelector/PlatformSelector.tsx index bb1a8ece85..4cbd7f5686 100644 --- a/frontend/components/PlatformSelector/PlatformSelector.tsx +++ b/frontend/components/PlatformSelector/PlatformSelector.tsx @@ -1,4 +1,5 @@ import React from "react"; +import classNames from "classnames"; import Checkbox from "components/forms/fields/Checkbox"; interface IPlatformSelectorProps { @@ -11,6 +12,7 @@ interface IPlatformSelectorProps { setCheckWindows: (val: boolean) => void; setCheckLinux: (val: boolean) => void; setCheckChrome: (val: boolean) => void; + disabled?: boolean; } export const PlatformSelector = ({ @@ -23,17 +25,23 @@ export const PlatformSelector = ({ setCheckWindows, setCheckLinux, setCheckChrome, + disabled = false, }: IPlatformSelectorProps): JSX.Element => { const baseClass = "platform-selector"; + const labelClasses = classNames("form-field__label", { + [`form-field__label--disabled`]: disabled, + }); + return (
- Checks on: + Checks on: setCheckDarwin(value)} wrapperClassName={`${baseClass}__platform-checkbox-wrapper`} + disabled={disabled} > macOS @@ -41,6 +49,7 @@ export const PlatformSelector = ({ value={checkWindows} onChange={(value: boolean) => setCheckWindows(value)} wrapperClassName={`${baseClass}__platform-checkbox-wrapper`} + disabled={disabled} > Windows @@ -48,6 +57,7 @@ export const PlatformSelector = ({ value={checkLinux} onChange={(value: boolean) => setCheckLinux(value)} wrapperClassName={`${baseClass}__platform-checkbox-wrapper`} + disabled={disabled} > Linux @@ -55,6 +65,7 @@ export const PlatformSelector = ({ value={checkChrome} onChange={(value: boolean) => setCheckChrome(value)} wrapperClassName={`${baseClass}__platform-checkbox-wrapper`} + disabled={disabled} > ChromeOS diff --git a/frontend/components/PlatformSelector/_styles.scss b/frontend/components/PlatformSelector/_styles.scss index c78115eccd..63fe84e7e9 100644 --- a/frontend/components/PlatformSelector/_styles.scss +++ b/frontend/components/PlatformSelector/_styles.scss @@ -7,4 +7,8 @@ align-items: center; gap: 12px; } + + .form-field__label--disabled { + color: $ui-fleet-black-50; + } } diff --git a/frontend/components/TableContainer/DataTable/DataTable.tsx b/frontend/components/TableContainer/DataTable/DataTable.tsx index 9d299b9867..89f8709f40 100644 --- a/frontend/components/TableContainer/DataTable/DataTable.tsx +++ b/frontend/components/TableContainer/DataTable/DataTable.tsx @@ -69,8 +69,9 @@ interface IHeaderGroup extends HeaderGroup { const CLIENT_SIDE_DEFAULT_PAGE_SIZE = 20; -// This data table uses react-table for implementation. The relevant documentation of the library -// can be found here https://react-table.tanstack.com/docs/api/useTable +// This data table uses react-table for implementation. The relevant v7 documentation of the library +// can be found here https://react-table-v7-docs.netlify.app/docs/api/usetable + const DataTable = ({ columns: tableColumns, data: tableData, diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index a78c023c1f..68fddb04cf 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -12,7 +12,7 @@ import Icon from "components/Icon/Icon"; import { COLORS } from "styles/var/colors"; import DataTable from "./DataTable/DataTable"; -import TableContainerUtils from "./TableContainerUtils"; +import TableContainerUtils from "./utilities/TableContainerUtils"; import { IActionButtonProps } from "./DataTable/ActionButton/ActionButton"; export interface ITableQueryData { @@ -100,6 +100,7 @@ interface ITableContainerProps { setExportRows?: (rows: Row[]) => void; resetPageIndex?: boolean; disableTableHeader?: boolean; + show0Count?: boolean; } const baseClass = "table-container"; @@ -156,6 +157,7 @@ const TableContainer = ({ setExportRows, resetPageIndex, disableTableHeader, + show0Count, }: ITableContainerProps) => { const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [sortHeader, setSortHeader] = useState(defaultSortHeader || ""); @@ -321,7 +323,7 @@ const TableContainer = ({ )} {!renderCount && !disableCount && - (isMultiColumnFilter || displayCount()) ? ( + (isMultiColumnFilter || displayCount() || show0Count) ? (
({ > {TableContainerUtils.generateResultsCountText( resultsTitle, - displayCount() + displayCount(), + show0Count )} {resultsHtml}
diff --git a/frontend/components/TableContainer/TableContainerUtils.ts b/frontend/components/TableContainer/utilities/TableContainerUtils.ts similarity index 90% rename from frontend/components/TableContainer/TableContainerUtils.ts rename to frontend/components/TableContainer/utilities/TableContainerUtils.ts index 4f883b678b..e825324eda 100644 --- a/frontend/components/TableContainer/TableContainerUtils.ts +++ b/frontend/components/TableContainer/utilities/TableContainerUtils.ts @@ -2,9 +2,10 @@ const DEFAULT_RESULTS_NAME = "results"; const generateResultsCountText = ( name: string = DEFAULT_RESULTS_NAME, - resultsCount: number + resultsCount: number, + show0Count = false ): string => { - if (resultsCount === 0) return `No ${name}`; + if (resultsCount === 0 && !show0Count) return `No ${name}`; // If there is 1 result and the last 3 letters in the result // name are "ies," we remove the "ies" and add "y" // to make the name singular diff --git a/frontend/components/TableContainer/utilities/config_utils.ts b/frontend/components/TableContainer/utilities/config_utils.ts new file mode 100644 index 0000000000..7acf97737d --- /dev/null +++ b/frontend/components/TableContainer/utilities/config_utils.ts @@ -0,0 +1,50 @@ +// from https://stackoverflow.com/a/68213902/15458245 + +import { HeaderProps, Row } from "react-table"; + +interface GetConditionalSelectHeaderCheckboxProps { + /** react-table header props */ + headerProps: React.PropsWithChildren>; + checkIfRowIsSelectable: (row: Row) => boolean; +} + +export const getConditionalSelectHeaderCheckboxProps = ({ + headerProps, + checkIfRowIsSelectable, +}: GetConditionalSelectHeaderCheckboxProps) => { + // Define if the checkbox should show as checked or indeterminate + const checkIfAllSelectableRowsSelected = (rows: Row[]) => + rows.filter(checkIfRowIsSelectable).every((row) => row.isSelected); + const allSelectableRowsSelected = checkIfAllSelectableRowsSelected( + headerProps.rows + ); + const indeterminate = + !allSelectableRowsSelected && + headerProps.rows.some((row) => row.isSelected); + + const onChange = () => { + if (checkIfAllSelectableRowsSelected(headerProps.rows)) { + headerProps.rows.forEach((row) => { + headerProps.toggleRowSelected(row.id, false); + }); + } else { + // Otherwise select every selectable row on the page + headerProps.page.forEach((row) => { + const rowChecked = checkIfRowIsSelectable(row); + headerProps.toggleRowSelected(row.id, rowChecked); + }); + } + }; + + // Usual checkbox props + const checkboxProps = headerProps.getToggleAllRowsSelectedProps(); + + return { + ...checkboxProps, + value: allSelectableRowsSelected, + indeterminate, + onChange, + }; +}; + +export default { getConditionalSelectHeaderCheckboxProps }; diff --git a/frontend/components/forms/fields/Checkbox/Checkbox.tsx b/frontend/components/forms/fields/Checkbox/Checkbox.tsx index 83ddcb43be..721e911a34 100644 --- a/frontend/components/forms/fields/Checkbox/Checkbox.tsx +++ b/frontend/components/forms/fields/Checkbox/Checkbox.tsx @@ -22,6 +22,7 @@ export interface ICheckboxProps { tooltipContent?: React.ReactNode; isLeftLabel?: boolean; helpText?: React.ReactNode; + smallTick?: boolean; } const Checkbox = (props: ICheckboxProps) => { @@ -39,6 +40,7 @@ const Checkbox = (props: ICheckboxProps) => { tooltipContent, isLeftLabel, helpText, + smallTick = false, } = props; const handleChange = () => { @@ -59,6 +61,7 @@ const Checkbox = (props: ICheckboxProps) => { const checkBoxTickClass = classnames(`${baseClass}__tick`, { [`${baseClass}__tick--disabled`]: disabled, [`${baseClass}__tick--indeterminate`]: indeterminate, + [`${baseClass}__tick--small`]: smallTick, }); const checkBoxLabelClass = classnames(checkBoxClass, { diff --git a/frontend/components/forms/fields/Checkbox/_styles.scss b/frontend/components/forms/fields/Checkbox/_styles.scss index 5c7da05188..88892a9fa6 100644 --- a/frontend/components/forms/fields/Checkbox/_styles.scss +++ b/frontend/components/forms/fields/Checkbox/_styles.scss @@ -60,7 +60,7 @@ &__tick { @include size(20px); - @include position(absolute, 0 null null 0); + position: absolute; display: inline-block; cursor: pointer; @@ -88,6 +88,14 @@ cursor: default; } + &--small { + @include size(16px); + + &::after { + @include size(16px); + } + } + &--indeterminate { &::after { background-color: $core-vibrant-blue; diff --git a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx index 8fc4da9dc5..5acdd89f26 100644 --- a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx +++ b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/DropdownOptionTooltipWrapper.tsx @@ -56,7 +56,6 @@ const DropdownOptionTooltipWrapper = ({ clickable={clickable} offset={offset} positionStrategy="fixed" - classNameArrow="tooltip-arrow" > {tipContent} diff --git a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss index c6f03b9cb7..c617631615 100644 --- a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss +++ b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss @@ -17,21 +17,5 @@ text-align: center; } - // arrow styles directly from react-tooltip-5 css - .tooltip-arrow { - width: 8px; - height: 8px; - } - [class*="react-tooltip__place-top"] > .styles-module_arrow__K0L3T { - transform: rotate(45deg); - } - [class*="react-tooltip__place-right"] > .styles-module_arrow__K0L3T { - transform: rotate(135deg); - } - [class*="react-tooltip__place-bottom"] > .styles-module_arrow__K0L3T { - transform: rotate(225deg); - } - [class*="react-tooltip__place-left"] > .styles-module_arrow__K0L3T { - transform: rotate(315deg); - } + @include tooltip5-arrow-styles; } diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx index 72969c256b..f05b097599 100644 --- a/frontend/components/forms/fields/InputField/InputField.jsx +++ b/frontend/components/forms/fields/InputField/InputField.jsx @@ -120,6 +120,10 @@ class InputField extends Component { [`${baseClass}__textarea`]: type === "textarea", }); + const inputWrapperClasses = classnames(inputWrapperClass, { + [`input-field--disabled`]: disabled, + }); + const formFieldProps = pick(this.props, [ "helpText", "label", @@ -144,7 +148,7 @@ class InputField extends Component {