merge main into 7766 (#14468)

This commit is contained in:
Jacob Shandling 2023-10-11 13:30:42 -07:00 committed by GitHub
commit ddd77efa9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
134 changed files with 7061 additions and 1406 deletions

View file

@ -1,5 +1,90 @@
# Basic set up for Actions and Docker. Security updates enabled via GitHub settings for other ecosystems.
version: 2
# updates intentionally left empty, as we were seeing too much volume of PRs, and breakages
# introduced by dependency version updates. Dependabot will continue to open security-related PRs,
# but non-security dependency updates must be done manually.
updates: []
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
pull-request-branch-name:
# Default is "/" which makes "docker tag" fail with
# "not a valid repository/tag: invalid reference format".
separator: "-"
# Add assignees
assignees:
- "lukeheath"
# Maintain dependencies for Dockerfiles
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
reviewers:
- "fleetdm/go"
- "fleetdm/infra"
pull-request-branch-name:
# Default is "/" which makes "docker tag" fail with
# "not a valid repository/tag: invalid reference format".
separator: "-"
# Add assignees
assignees:
- "fleetdm/go"
- "fleetdm/infra"
# Maintain dependencies for website NPM
- package-ecosystem: "npm"
directory: "/website"
labels:
- "website"
schedule:
interval: "daily"
# Disable version updates
open-pull-requests-limit: 0
allow:
- dependency-type: "production"
reviewers:
- "eashaw"
pull-request-branch-name:
# Default is "/" which makes "docker tag" fail with
# "not a valid repository/tag: invalid reference format".
separator: "-"
assignees:
- "eashaw"
# Maintain dependencies for Go
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
# Disable version updates
open-pull-requests-limit: 0
reviewers:
- lucasmrod
pull-request-branch-name:
# Default is "/" which makes "docker tag" fail with
# "not a valid repository/tag: invalid reference format".
separator: "-"
# Add assignees
assignees:
- lucasmrod
# Maintain dependencies for npm
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
# Disable version updates
open-pull-requests-limit: 0
reviewers:
- lukeheath
allow:
- dependency-type: "production"
pull-request-branch-name:
# Default is "/" which makes "docker tag" fail with
# "not a valid repository/tag: invalid reference format".
separator: "-"
# Add assignees
assignees:
- lukeheath

View file

@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply configuration profiles and updates
uses: fleetdm/fleet-mdm-gitops@026ee84a69cb89c869fedbe27c969bf89def418b
uses: fleetdm/fleet-mdm-gitops@15072f2739ef92c6357414ddd86e89b6bf302a2b
with:
FLEET_API_TOKEN: $FLEET_API_TOKEN
FLEET_URL: $FLEET_URL

View file

@ -0,0 +1 @@
- Added `GET /hosts/{id}/scripts` endpoint to retrieve status details of saved scripts applicable to a host.

View file

@ -0,0 +1 @@
- Added activity logging for add, delete, and edit scripts.

View file

@ -0,0 +1 @@
- Fleet UI: Disable multicursor editing for SQL editors

View file

@ -0,0 +1 @@
- implement scripts tab and table for host details page

View file

@ -0,0 +1,2 @@
* Added API endpoints for script management.
* Updated the `POST /scripts/run` and `POST /scripts/run/sync` endpoints to accept an optional saved script ID instead of the script contents.

View file

@ -0,0 +1 @@
- implement UI for scripts on the controls page

View file

@ -2021,6 +2021,9 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
ds.BulkSetPendingMDMAppleHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs, profileIDs []uint, uuids []string) error {
return nil
}
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
return nil
}
actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"})
yamlFilePath := writeTmpYml(t, actualYaml)

View file

@ -88,7 +88,7 @@ func TestRunScriptCommand(t *testing.T) {
scriptPath: func() string {
return writeTmpScriptContents(t, maxChars, ".sh")
},
expectErrMsg: `Script is too large. Its limited to 10,000 characters (approximately 125 lines).`,
expectErrMsg: `Script is too large. It's limited to 10,000 characters (approximately 125 lines).`,
},
{
name: "script empty",

View file

@ -111,6 +111,7 @@
"metadata_url": "",
"idp_name": ""
}
}
},
"scripts": null
}
}

View file

@ -39,6 +39,7 @@ spec:
metadata: ""
metadata_url: ""
entity_id: ""
scripts: null
org_info:
org_logo_url: ""
org_logo_url_light_background: ""

View file

@ -70,6 +70,7 @@
"idp_name": ""
}
},
"scripts": null,
"sso_settings": {
"enable_jit_provisioning": false,
"enable_jit_role_sync": false,

View file

@ -39,6 +39,7 @@ spec:
metadata: ""
metadata_url: ""
entity_id: ""
scripts: null
license:
expiration: "0001-01-01T00:00:00Z"
tier: free

View file

@ -38,6 +38,7 @@
"macos_setup_assistant": null
}
},
"scripts": null,
"user_count": 99,
"host_count": 42
}
@ -98,6 +99,7 @@
"macos_setup_assistant": null
}
},
"scripts": null,
"user_count": 87,
"host_count": 43
}

View file

@ -17,6 +17,7 @@ spec:
bootstrap_package:
enable_end_user_authentication: false
macos_setup_assistant:
scripts: null
name: team1
---
apiVersion: v1
@ -46,4 +47,5 @@ spec:
bootstrap_package:
enable_end_user_authentication: false
macos_setup_assistant:
scripts: null
name: team2

View file

@ -39,6 +39,7 @@ spec:
metadata: ""
metadata_url: ""
entity_id: ""
scripts: null
org_info:
org_logo_url: ""
org_logo_url_light_background: ""

View file

@ -39,6 +39,7 @@ spec:
metadata: ""
metadata_url: ""
entity_id: ""
scripts: null
org_info:
org_logo_url: ""
org_logo_url_light_background: ""

View file

@ -17,6 +17,7 @@ spec:
macos_updates:
deadline: null
minimum_version: null
scripts: null
name: tm1
---
apiVersion: v1
@ -36,4 +37,5 @@ spec:
macos_updates:
deadline: null
minimum_version: null
scripts: null
name: tm2

View file

@ -17,6 +17,7 @@ spec:
macos_updates:
deadline: null
minimum_version: null
scripts: null
name: tm1
---
apiVersion: v1
@ -36,4 +37,5 @@ spec:
macos_updates:
deadline: null
minimum_version: null
scripts: null
name: tm2

View file

@ -17,5 +17,6 @@ spec:
macos_updates:
deadline: null
minimum_version: null
scripts: null
name: tm1

View file

@ -246,6 +246,9 @@ spec:
- path/to/profile1.mobileconfig
- path/to/profile2.mobileconfig
enable_disk_encryption: true
scripts:
- path/to/script1.sh
- path/to/script2.sh
```
### Team agent options
@ -329,6 +332,23 @@ spec:
# the team-specific mdm options go here
```
### Team scripts
List of saved scripts that can be run on hosts that are part of the team.
- Default value: none
- Config file format:
```yaml
apiVersion: v1
kind: team
spec:
team:
name: Client Platform Engineering
scripts:
- path/to/script1.sh
- path/to/script2.sh
```
## Organization settings
The `config` YAML file controls Fleet's organization settings and MDM features for hosts assigned to "No team."
@ -1147,6 +1167,20 @@ If you're using Fleet Premium, this enforces disk encryption on all hosts assign
enable_disk_encryption: true
```
#### Scripts
List of saved scripts that can be run on all hosts.
> If you want to add scripts to hosts on a specific team in Fleet, use the `team` YAML document. Learn how to create one [here](#teams).
- Default value: none
- Config file format:
```yaml
scripts:
- path/to/script1.sh
- path/to/script2.sh
```
#### Advanced configuration
> **Note:** More settings are included in the [contributor documentation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Configuration-for-contributors.md). It's possible, although not recommended, to configure these settings in the YAML configuration file.

View file

@ -2945,11 +2945,11 @@ This content was moved to [Proxies](http://fleetdm.com/docs/deploy/proxies) on S
<h2 id="configuring-single-sign-on-sso">Configuring single sign-on (SSO)</h2>
This content was moved to [Single sign-on (SSO)](http://fleetdm.com/deploy/single-sign-on-sso) on Sept 6th, 2023.
This content was moved to [Single sign-on (SSO)](http://fleetdm.com/docs/deploy/single-sign-on-sso) on Sept 6th, 2023.
<h2 id="public-ips-of-devices">Public IPs of devices</h2>
This content was moved to [Public IPs](http://fleetdm.com/docs/deploy/public-ips) on Sept 6th, 2023.
This content was moved to [Public IPs](http://fleetdm.com/docs/deploy/public-ip) on Sept 6th, 2023.
<meta name="pageOrderInSection" value="100">

View file

@ -8,6 +8,7 @@
- [Device-authenticated routes](#device-authenticated-routes)
- [Downloadable installers](#downloadable-installers)
- [Setup](#setup)
- [Scripts](#scripts)
This document includes the internal Fleet API routes that are helpful when developing or contributing to Fleet.
@ -1276,6 +1277,7 @@ If the `name` is not already associated with an existing team, this API route cr
| 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 .mobileconfig files to apply to hosts that belong to this team. |
| scripts | list | body | A list of script files to add to this team so they can be executed at a later time. |
| mdm.macos_settings.enable_disk_encryption | bool | body | Whether disk encryption should be enabled for hosts that belong to this team. |
| force | bool | query | Force apply the spec even if there are (ignorable) validation errors. Those are unknown keys and agent options-related validations. |
| dry_run | bool | query | Validate the provided JSON for unknown keys and invalid value types and return any validation errors, but do not apply the changes. |
@ -1337,7 +1339,8 @@ If the `name` is not already associated with an existing team, this API route cr
"custom_settings": ["path/to/profile1.mobileconfig"],
"enable_disk_encryption": true
}
}
},
"scripts": ["path/to/script.sh"],
}
]
}
@ -2692,5 +2695,34 @@ If the Fleet instance is provided required parameters to complete setup.
```
## Scripts
### Batch-apply scripts
_Available in Fleet Premium_
`POST /api/v1/fleet/scripts/batch`
#### Parameters
| Name | Type | In | Description |
| --------- | ------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| team_id | number | query | The ID of the team to add the scripts to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`.
| team_id | number | query | The ID of the team to add the scripts to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`.
| dry_run | bool | query | Validate the provided scripts and return any validation errors, but do not apply the changes. |
| scripts | array | body | An array of objects with the scripts payloads. Each item must contain `name` with the script name and `script_contents` with the script contents encoded in base64 |
If both `team_id` and `team_name` parameters are included, this endpoint will respond with an error. If no `team_name` or `team_id` is provided, the scripts will be applied for **all hosts**.
> Note that this endpoint replaces all the active scripts for the specified team (or no team). Any existing script that is not included in the list will be removed, and existing scripts with the same name as a new script will be edited. Providing an empty list of scripts will remove existing scripts.
#### Example
`POST /api/v1/fleet/mdm/scripts/batch`
##### Default response
`204`
<meta name="pageOrderInSection" value="800">
<meta name="description" value="Read about Fleet API routes that are helpful when developing or contributing to Fleet.">

View file

@ -19,15 +19,16 @@ vagrant ssh
### Installing Fleet
To install Fleet, [download](https://github.com/fleetdm/fleet/releases) the file named `Source code
(zip)`, rename, unzip, and move the latest Fleet binary to your desired install location.
To install Fleet, [download](https://github.com/fleetdm/fleet/releases) the latest release from GitHub. The binary is in an archive that uses this naming convention, including the current version: `fleet_<version>_linux.tar.gz`.
For example, after downloading:
```sh
mv <filename>.zip fleet.zip
unzip fleet.zip -d fleet
sudo cp fleet /usr/bin/
sudo chmod u+x /usr/bin/fleet
Once the file is downloaded, extract the Fleet binary from the archive and copy it in to your desired location.
For example:
```console
> tar -xf fleet_<version>_linux.tar.gz
> sudo cp fleet_<version>_linux/fleet /usr/bin/
> /usr/bin/fleet version
fleet version <version>
```
### Installing and configuring dependencies

View file

@ -875,6 +875,7 @@ None.
"custom_settings": ["path/to/profile1.mobileconfig"],
"enable_disk_encryption": true
},
"scripts": ["path/to/script.sh"],
"end_user_authentication": {
"entity_id": "",
"issuer_uri": "",
@ -1074,6 +1075,7 @@ Modifies the Fleet's configuration with the supplied information.
| webhook_url | string | body | _mdm.macos_migration settings_. The webhook url configured to receive requests to unenroll devices migrating from your old MDM solution. **Requires Fleet Premium license** |
| custom_settings | list | body | _mdm.macos_settings settings_. Hosts that belong to no team and are enrolled into Fleet's MDM will have those custom profiles applied. |
| enable_disk_encryption | boolean | body | _mdm.macos_settings settings_. Hosts that belong to no team and are enrolled into Fleet's MDM will have disk encryption enabled if set to true. **Requires Fleet Premium license** |
| scripts | list | body | A list of script files to add so they can be executed at a later time. |
| enable_end_user_authentication | boolean | body | _mdm.macos_setup settings_. If set to true, end user authentication will be required during automatic MDM enrollment of new macOS devices. Settings for your IdP provider must also be [configured](https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience#end-user-authentication-and-eula). **Requires Fleet Premium license** |
| additional_queries | boolean | body | Whether or not additional queries are enabled on hosts. |
| force | bool | query | Force apply the agent options even if there are validation errors. |
@ -1849,8 +1851,11 @@ the `software` table.
| munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). |
| low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. |
| disable_failing_policies| boolean | query | If "true", hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. |
| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. |
| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. |
| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
If `additional_info_filters` is not specified, no `additional` information will be returned.
@ -2006,8 +2011,10 @@ Response payload with the `munki_issue_id` filter provided:
| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). |
| low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. |
| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** |
| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
If `additional_info_filters` is not specified, no `additional` information will be returned.
@ -3314,7 +3321,7 @@ requested by a web browser.
| munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). |
| low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. |
| label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `status`, `query` and `team_id`. |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| disable_failing_policies | boolean | query | If `true`, hosts will return failing policies as 0 (returned as the `issues` column) regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. |
If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results.
@ -3741,8 +3748,10 @@ Returns a list of the hosts that belong to the specified label.
| mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Can be one of 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. |
| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| low_disk_space | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. |
| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of `verified`, `verifying`, `action_required`, `enforcing`, `failed`, or `removing_enforcement`. |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of `installed`, `pending`, or `failed`. **Note: If this filter is used in Fleet Premium without a team id filter, the results include only hosts that are not assigned to any team.** |
| macos_settings_disk_encryption | string | query | Filters the hosts by the status of the macOS disk encryption MDM profile on the host. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. |
| bootstrap_package | string | query | _Available in Fleet Premium_ Filters the hosts by the status of the MDM bootstrap package on the host. Can be one of 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Can be one of 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
| os_settings_disk_encryption | string | query | Filters the hosts by the status of the disk encryption setting applied to the hosts. Can be one of 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** |
If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings_disk_encryption` is specified, then Windows Servers are excluded from the results.
@ -4106,7 +4115,7 @@ The summary can optionally be filtered by team ID.
| Name | Type | In | Description |
| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- |
| team_id | string | query | _Available in Fleet Premium_ The team id to filter the summary. |
| team_id | string | query | _Available in Fleet Premium_ The team ID to filter the summary. |
#### Example
@ -6394,7 +6403,12 @@ This allows you to easily configure scheduled queries that will impact a whole t
- [Run script asynchronously](#run-script-asynchronously)
- [Run script synchronously](#run-script-synchronously)
- [Get script result](#get-script-result)
- [Upload a script](#upload-a-script)
- [Delete a script](#delete-a-script)
- [List scripts](#list-scripts)
- [Get or download a script](#get-or-download-a-script)
- [Get script details by host](#get-script-details-by-host)
### Run script asynchronously
@ -6406,10 +6420,13 @@ Creates a script execution request and returns the execution identifier to retri
#### Parameters
| Name | Type | In | Description |
| ---- | ------- | ---- | -------------------------------------------- |
| host_id | integer | body | **Required**. The host id to run the script on. |
| script_contents | string | body | **Required**. The contents of the script to run. |
| Name | Type | In | Description |
| ---- | ------- | ---- | -------------------------------------------- |
| host_id | integer | body | **Required**. The ID of the host to run the script on. |
| script_id | integer | body | The ID of the existing saved script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_contents`. |
| script_contents | string | body | The contents of the script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_id`. |
> Note that if both `script_id` and `script_contents` are included in the request, this endpoint will respond with an error.
#### Example
@ -6439,7 +6456,10 @@ Creates a script execution request and waits for a result to return (up to a 1 m
| Name | Type | In | Description |
| ---- | ------- | ---- | -------------------------------------------- |
| host_id | integer | body | **Required**. The host id to run the script on. |
| script_contents | string | body | **Required**. The contents of the script to run. |
| script_id | integer | body | The ID of the existing saved script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_contents`. |
| script_contents | string | body | The contents of the script to run. Only one of either `script_id` or `script_contents` can be included in the request; omit this parameter if using `script_id`. |
> Note that if both `script_id` and `script_contents` are included in the request, this endpoint will respond with an error.
#### Example
@ -6498,6 +6518,239 @@ Gets the result of a script that was executed.
> Note: `exit_code` can be `null` if Fleet hasn't heard back from the host yet.
### Upload a script
_Available in Fleet Premium_
Uploads a script, making it available to run on hosts assigned to the specified team (or no team).
`POST /api/v1/fleet/scripts`
#### Parameters
| Name | Type | In | Description |
| ---- | ------- | ---- | -------------------------------------------- |
| script | file | form | **Required**. The file containing the script. |
| team_id | integer | form | The team ID. If specified, the script will only be available to hosts assigned to this team. If not specified, the script will only be available to hosts on **no team**. |
#### Example
`POST /api/v1/fleet/scripts`
##### Request headers
```http
Content-Length: 306
Content-Type: multipart/form-data; boundary=------------------------f02md47480und42y
```
##### Request body
```http
--------------------------f02md47480und42y
Content-Disposition: form-data; name="team_id"
1
--------------------------f02md47480und42y
Content-Disposition: form-data; name="script"; filename="myscript.sh"
Content-Type: application/octet-stream
echo "hello"
--------------------------f02md47480und42y--
```
##### Default response
`Status: 200`
```json
{
"script_id": 1227
}
```
### Delete a script
_Available in Fleet Premium_
Deletes an existing script.
`DELETE /api/v1/fleet/scripts/{id}`
#### Parameters
| Name | Type | In | Description |
| ---- | ------- | ---- | -------------------------------------------- |
| id | integer | path | **Required**. The ID of the script to delete. |
#### Example
`DELETE /api/v1/fleet/scripts/1`
##### Default response
`Status: 204`
### List scripts
_Available in Fleet Premium_
`GET /api/v1/fleet/scripts`
#### Parameters
| Name | Type | In | Description |
| --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- |
| page | integer | query | Page number of the results to fetch. |
| per_page | integer | query | Results per page. |
#### Example
`GET /api/v1/fleet/scripts`
##### Default response
`Status: 200`
```json
{
"scripts": [
{
"id": 1,
"team_id": null,
"name": "script_1.sh",
"created_at": "2023-07-30T13:41:07Z",
"updated_at": "2023-07-30T13:41:07Z"
},
{
"id": 2,
"team_id": null,
"name": "script_2.sh",
"created_at": "2023-08-30T13:41:07Z",
"updated_at": "2023-08-30T13:41:07Z"
}
],
"meta": {
"has_next_results": false,
"has_previous_results": false
}
}
```
### Get or download a script
_Available in Fleet Premium_
`GET /api/v1/fleet/scripts/{id}`
#### Parameters
| Name | Type | In | Description |
| ---- | ------- | ---- | ------------------------------------- |
| id | integer | path | **Required.** The desired script's ID. |
| alt | string | query | If specified and set to "media", downloads the script's contents. |
#### Example (get a script)
`GET /api/v1/fleet/scripts/123`
##### Default response
`Status: 200`
```json
{
"id": 123,
"team_id": null,
"name": "script_1.sh",
"created_at": "2023-07-30T13:41:07Z",
"updated_at": "2023-07-30T13:41:07Z"
}
```
#### Example (download a script's contents)
`GET /api/v1/fleet/scripts/123?alt=media`
##### Example response headers
```http
Content-Length: 13
Content-Type: application/octet-stream
Content-Disposition: attachment;filename="2023-09-27 script_1.sh"
```
###### Example response body
`Status: 200`
```
echo "hello"
```
### Get script details by host
_Available in Fleet Premium_
`GET /api/v1/fleet/hosts/{id}/scripts`
#### Parameters
| Name | Type | In | Description |
| --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- |
| page | integer | query | Page number of the results to fetch. |
| per_page | integer | query | Results per page. |
#### Example
`GET /api/v1/fleet/hosts/{id}/scripts`
##### Default response
`Status: 200`
```json
{
"scripts": [
{
"script_id": 3,
"name": "remove-zoom-artifacts.sh",
"last_execution": {
"execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002",
"executed_at": "2021-12-15T15:23:57Z",
"status": "error"
}
},
{
"script_id": 5,
"name": "set-timezone.sh",
"last_execution": {
"id": "e797d6c6-3aae-11ee-be56-0242ac120002",
"executed_at": "2021-12-15T15:23:57Z",
"status": "pending"
}
},
{
"script_id": 8,
"name": "uninstall-zoom.sh",
"last_execution": {
"id": "e797d6c6-3aae-11ee-be56-0242ac120002",
"executed_at": "2021-12-15T15:23:57Z",
"status": "ran"
}
}
],
"meta": {
"has_next_results": false,
"has_previous_results": false
}
}
```
---
## Sessions

View file

@ -863,6 +863,61 @@ This activity contains the following fields:
}
```
### Type `added_script`
Generated when a script is added to a team (or no team).
This activity contains the following fields:
- "script_name": Name of the script.
- "team_id": The ID of the team that the script applies to, `null` if it applies to devices that are not in a team.
- "team_name": The name of the team that the script applies to, `null` if it applies to devices that are not in a team.
#### Example
```json
{
"script_name": "set-timezones.sh",
"team_id": 123,
"team_name": "Workstations"
}
```
### Type `deleted_script`
Generated when a script is deleted from a team (or no team).
This activity contains the following fields:
- "script_name": Name of the script.
- "team_id": The ID of the team that the script applies to, `null` if it applies to devices that are not in a team.
- "team_name": The name of the team that the script applies to, `null` if it applies to devices that are not in a team.
#### Example
```json
{
"script_name": "set-timezones.sh",
"team_id": 123,
"team_name": "Workstations"
}
```
### Type `edited_script`
Generated when a user edits the scripts of a team (or no team) via the fleetctl CLI.
This activity contains the following fields:
- "team_id": The ID of the team that the scripts apply to, `null` if they apply to devices that are not in a team.
- "team_name": The name of the team that the scripts apply to, `null` if they apply to devices that are not in a team.
#### Example
```json
{
"team_id": 123,
"team_name": "Workstations"
}
```
<meta name="title" value="Audit logs">
<meta name="pageOrderInSection" value="1400">

View file

@ -87,7 +87,10 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines.
| View metadata of MDM macOS bootstrap packages\* | | | ✅ | ✅ | |
| Edit/upload MDM macOS bootstrap packages\* | | | ✅ | ✅ | ✅ |
| Enable/disable MDM macOS setup end user authentication\* | | | ✅ | ✅ | ✅ |
| Run scripts on hosts\* | | | ✅ | ✅ | |
| Run arbitrary scripts on hosts\* | | | ✅ | ✅ | |
| View saved scripts\* | ✅ | ✅ | ✅ | ✅ | |
| Edit/upload saved scripts\* | | | ✅ | ✅ | |
| Run saved scripts on hosts\* | ✅ | ✅ | ✅ | ✅ | |
\* Applies only to Fleet Premium
@ -149,7 +152,12 @@ Users that are members of multiple teams can be assigned different roles for eac
| View metadata of MDM macOS bootstrap packages | | | ✅ | ✅ | |
| Edit/upload MDM macOS bootstrap packages | | | ✅ | ✅ | ✅ |
| Enable/disable MDM macOS setup end user authentication | | | ✅ | ✅ | ✅ |
| Run scripts on hosts | | | ✅ | ✅ | |
| Run arbitrary scripts on hosts | | | ✅ | ✅ | |
| View saved scripts | ✅ | ✅ | ✅ | ✅ | |
| Edit/upload saved scripts | | | ✅ | ✅ | |
| Run saved scripts on hosts | ✅ | ✅ | ✅ | ✅ | |
| View script details by host | ✅ | ✅ | ✅ | ✅ | |
\* Applies only to [Fleet REST API](https://fleetdm.com/docs/using-fleet/rest-api)

View file

@ -5510,10 +5510,16 @@
"dev": true
},
"node_modules/nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -6130,9 +6136,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
"integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true,
"funding": [
{
@ -6142,10 +6148,14 @@
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.4",
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},

View file

@ -3696,9 +3696,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.21:
version "8.4.24"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.24.tgz#f714dba9b2284be3cc07dbd2fc57ee4dc972d2df"
integrity sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==
version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"

View file

@ -2,11 +2,7 @@ package service
import (
"context"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
@ -23,144 +19,3 @@ func (svc *Service) HostByIdentifier(ctx context.Context, identifier string, opt
opts.IncludePolicies = true
return svc.Service.HostByIdentifier(ctx, identifier, opts)
}
func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
const maxPendingScriptAge = time.Minute // any script older than this is not considered pending anymore on that host
// must load the host to get the team (cannot use lite, the last seen time is
// required to check if it is online) to authorize with the proper team id.
// We cannot first authorize if the user can list hosts, in case we
// eventually allow a write-only role (e.g. gitops).
host, err := svc.ds.Host(ctx, request.HostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to run a script (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionWrite); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get host lite")
}
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return nil, err
}
if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil {
return nil, fleet.NewInvalidArgumentError("script_contents", err.Error())
}
// host must be online
if host.Status(time.Now()) != fleet.StatusOnline {
return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg)
}
pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID, maxPendingScriptAge)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list host pending script executions")
}
if len(pending) > 0 {
return nil, fleet.NewInvalidArgumentError(
"script_contents", fleet.RunScriptAlreadyRunningErrMsg,
).WithStatus(http.StatusConflict)
}
// create the script execution request, the host will be notified of the
// script execution request via the orbit config's Notifications mechanism.
script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create script execution request")
}
script.Hostname = host.DisplayName()
asyncExecution := waitForResult <= 0
err = svc.ds.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeRanScript{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
ScriptExecutionID: script.ExecutionID,
Async: asyncExecution,
},
)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for script execution request")
}
if asyncExecution {
// async execution, return
return script, nil
}
ctx, cancel := context.WithTimeout(ctx, waitForResult)
defer cancel()
// if waiting for a result times out, we still want to return the script's
// execution request information along with the error, so that the caller can
// use the execution id for later checks.
timeoutResult := script
checkInterval := time.Second
after := time.NewTimer(checkInterval)
for {
select {
case <-ctx.Done():
return timeoutResult, ctx.Err()
case <-after.C:
result, err := svc.ds.GetHostScriptExecutionResult(ctx, script.ExecutionID)
if err != nil {
// is that due to the context being canceled during the DB access?
if ctxErr := ctx.Err(); ctxErr != nil {
return timeoutResult, ctxErr
}
return nil, ctxerr.Wrap(ctx, err, "get script execution result")
}
if result.ExitCode != nil {
// a result was received from the host, return
result.Hostname = host.DisplayName()
return result, nil
}
// at a second to every attempt, until it reaches 5s (then check every 5s)
if checkInterval < 5*time.Second {
checkInterval += time.Second
}
after.Reset(checkInterval)
}
}
}
func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID)
if err != nil {
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get script result")
}
host, err := svc.ds.HostLite(ctx, scriptResult.HostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to run a script (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get host lite")
}
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionRead); err != nil {
return nil, err
}
scriptResult.Hostname = host.DisplayName()
return scriptResult, nil
}

View file

@ -0,0 +1,441 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) {
const maxPendingScriptAge = time.Minute // any script older than this is not considered pending anymore on that host
// must load the host to get the team (cannot use lite, the last seen time is
// required to check if it is online) to authorize with the proper team id.
// We cannot first authorize if the user can list hosts, in case we
// eventually allow a write-only role (e.g. gitops).
host, err := svc.ds.Host(ctx, request.HostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to run a script (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionWrite); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get host lite")
}
// must check that only one of script id or contents is provided before
// authorization, as the permissions are not the same if a script id is
// provided. There's no harm in returning the error if this validation fails,
// since both values are user-provided it doesn't leak any internal
// information.
if request.ScriptID != nil && request.ScriptContents != "" {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("script_id", `Only one of "script_id" or "script_contents" can be provided.`)
}
// authorize with the host's team and the script id provided, as both affect
// the permissions.
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID, ScriptID: request.ScriptID}, fleet.ActionWrite); err != nil {
return nil, err
}
if request.ScriptID != nil {
script, err := svc.ds.Script(ctx, *request.ScriptID)
if err != nil {
if fleet.IsNotFound(err) {
return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
WithStatus(http.StatusNotFound)
}
return nil, err
}
var scriptTmID, hostTmID uint
if script.TeamID != nil {
scriptTmID = *script.TeamID
}
if host.TeamID != nil {
hostTmID = *host.TeamID
}
if scriptTmID != hostTmID {
return nil, fleet.NewInvalidArgumentError("script_id", `The script does not belong to the same team (or no team) as the host.`)
}
contents, err := svc.ds.GetScriptContents(ctx, *request.ScriptID)
if err != nil {
if fleet.IsNotFound(err) {
return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`).
WithStatus(http.StatusNotFound)
}
return nil, err
}
request.ScriptContents = string(contents)
}
if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil {
return nil, fleet.NewInvalidArgumentError("script_contents", err.Error())
}
// host must be online
if host.Status(time.Now()) != fleet.StatusOnline {
return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg)
}
pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID, maxPendingScriptAge)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list host pending script executions")
}
if len(pending) > 0 {
return nil, fleet.NewInvalidArgumentError(
"script_contents", fleet.RunScriptAlreadyRunningErrMsg,
).WithStatus(http.StatusConflict)
}
// create the script execution request, the host will be notified of the
// script execution request via the orbit config's Notifications mechanism.
script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create script execution request")
}
script.Hostname = host.DisplayName()
asyncExecution := waitForResult <= 0
err = svc.ds.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeRanScript{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
ScriptExecutionID: script.ExecutionID,
Async: asyncExecution,
},
)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for script execution request")
}
if asyncExecution {
// async execution, return
return script, nil
}
ctx, cancel := context.WithTimeout(ctx, waitForResult)
defer cancel()
// if waiting for a result times out, we still want to return the script's
// execution request information along with the error, so that the caller can
// use the execution id for later checks.
timeoutResult := script
checkInterval := time.Second
after := time.NewTimer(checkInterval)
for {
select {
case <-ctx.Done():
return timeoutResult, ctx.Err()
case <-after.C:
result, err := svc.ds.GetHostScriptExecutionResult(ctx, script.ExecutionID)
if err != nil {
// is that due to the context being canceled during the DB access?
if ctxErr := ctx.Err(); ctxErr != nil {
return timeoutResult, ctxErr
}
return nil, ctxerr.Wrap(ctx, err, "get script execution result")
}
if result.ExitCode != nil {
// a result was received from the host, return
result.Hostname = host.DisplayName()
return result, nil
}
// at a second to every attempt, until it reaches 5s (then check every 5s)
if checkInterval < 5*time.Second {
checkInterval += time.Second
}
after.Reset(checkInterval)
}
}
}
func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID)
if err != nil {
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get script result")
}
host, err := svc.ds.HostLite(ctx, scriptResult.HostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to run a script (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get host lite")
}
if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionRead); err != nil {
return nil, err
}
scriptResult.Hostname = host.DisplayName()
return scriptResult, nil
}
func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) {
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
return nil, err
}
b, err := io.ReadAll(r)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "read script contents")
}
script := &fleet.Script{
TeamID: teamID,
Name: name,
ScriptContents: string(b),
}
if err := script.Validate(); err != nil {
return nil, fleet.NewInvalidArgumentError("script", err.Error())
}
savedScript, err := svc.ds.NewScript(ctx, script)
if err != nil {
var (
existsErr fleet.AlreadyExistsError
fkErr fleet.ForeignKeyError
)
if errors.As(err, &existsErr) {
err = fleet.NewInvalidArgumentError("script", "A script with this name already exists.").WithStatus(http.StatusConflict)
} else if errors.As(err, &fkErr) {
err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound)
}
return nil, ctxerr.Wrap(ctx, err, "create script")
}
var teamName *string
if teamID != nil && *teamID != 0 {
tm, err := svc.teamByIDOrName(ctx, teamID, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get team name for create script activity")
}
teamName = &tm.Name
}
if err := svc.ds.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeAddedScript{
TeamID: teamID,
TeamName: teamName,
ScriptName: script.Name,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "new activity for create script")
}
return savedScript, nil
}
func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error {
script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionWrite)
if err != nil {
return err
}
if err := svc.ds.DeleteScript(ctx, script.ID); err != nil {
return ctxerr.Wrap(ctx, err, "delete script")
}
var teamName *string
if script.TeamID != nil && *script.TeamID != 0 {
tm, err := svc.teamByIDOrName(ctx, script.TeamID, nil)
if err != nil {
return ctxerr.Wrap(ctx, err, "get team name for delete script activity")
}
teamName = &tm.Name
}
if err := svc.ds.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDeletedScript{
TeamID: script.TeamID,
TeamName: teamName,
ScriptName: script.Name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "new activity for delete script")
}
return nil
}
func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) {
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, nil, err
}
// cursor-based pagination is not supported for scripts
opt.After = ""
// custom ordering is not supported, always by name
opt.OrderKey = "name"
opt.OrderDirection = fleet.OrderAscending
// no matching query support
opt.MatchQuery = ""
// always include metadata for scripts
opt.IncludeMetadata = true
return svc.ds.ListScripts(ctx, teamID, opt)
}
func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) {
script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionRead)
if err != nil {
return nil, nil, err
}
var content []byte
if withContent {
content, err = svc.ds.GetScriptContents(ctx, scriptID)
if err != nil {
return nil, nil, err
}
}
return script, content, nil
}
func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) {
// first, get the script because we don't know which team id it is for.
script, err := svc.ds.Script(ctx, scriptID)
if err != nil {
if fleet.IsNotFound(err) {
// couldn't get the script to have its team, authorize with a no-team
// script as a fallback - the requested script does not exist so there's
// no way to know what team it would be for, and returning a 404 without
// authorization would leak the existing/non existing ids.
if err := svc.authz.Authorize(ctx, &fleet.Script{}, authzAction); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get script")
}
// do the actual authorization with the script's team id
if err := svc.authz.Authorize(ctx, script, authzAction); err != nil {
return nil, err
}
return script, nil
}
func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) {
h, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
if fleet.IsNotFound(err) {
// if error is because the host does not exist, check first if the user
// had global access (to prevent leaking valid host ids).
if err := svc.authz.Authorize(ctx, &fleet.Script{}, fleet.ActionRead); err != nil {
return nil, nil, err
}
}
return nil, nil, err
}
// cursor-based pagination is not supported for scripts
opt.After = ""
// custom ordering is not supported, always by name
opt.OrderKey = "name"
opt.OrderDirection = fleet.OrderAscending
// no matching query support
opt.MatchQuery = ""
// always include metadata for scripts
opt.IncludeMetadata = true
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: h.TeamID}, fleet.ActionRead); err != nil {
return nil, nil, err
}
return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt)
}
func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error {
if maybeTmID != nil && maybeTmName != nil {
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name"))
}
var teamID *uint
var teamName *string
if maybeTmID != nil || maybeTmName != nil {
team, err := svc.teamByIDOrName(ctx, maybeTmID, maybeTmName)
if err != nil {
return err
}
teamID = &team.ID
teamName = &team.Name
}
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err)
}
// any duplicate name in the provided set results in an error
scripts := make([]*fleet.Script, 0, len(payloads))
byName := make(map[string]bool, len(payloads))
for i, p := range payloads {
script := &fleet.Script{
ScriptContents: string(p.ScriptContents),
Name: p.Name,
TeamID: teamID,
}
if err := script.Validate(); err != nil {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error()))
}
if byName[script.Name] {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), fmt.Sprintf("Couldnt edit scripts. More than one script has the same file name: %q", script.Name)),
"duplicate script by name")
}
byName[script.Name] = true
scripts = append(scripts, script)
}
if dryRun {
return nil
}
if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil {
return ctxerr.Wrap(ctx, err, "batch saving scripts")
}
if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedScript{
TeamID: teamID,
TeamName: teamName,
}); err != nil {
return ctxerr.Wrap(ctx, err, "logging activity for edited scripts")
}
return nil
}

View file

@ -931,6 +931,10 @@ func (svc *Service) editTeamFromSpec(
}
team.Config.MDM.MacOSSetup.EnableEndUserAuthentication = spec.MDM.MacOSSetup.EnableEndUserAuthentication
if spec.Scripts.Set {
team.Config.Scripts = spec.Scripts
}
if len(secrets) > 0 {
team.Secrets = secrets
}

View file

@ -1,4 +1,20 @@
import { IScriptResultResponse } from "services/entities/scripts";
import {
IHostScript,
IScript,
IScriptResultResponse,
} from "services/entities/scripts";
const DEFAULT_SCRIPT_MOCK: IScript = {
id: 1,
team_id: null,
name: "test script",
created_at: "2020-01-01T00:00:00.000Z",
updated_at: "2020-01-01T00:00:00.000Z",
};
export const createMockScript = (overrides?: Partial<IScript>): IScript => {
return { ...DEFAULT_SCRIPT_MOCK, ...overrides };
};
const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = {
hostname: "Test Host",
@ -12,10 +28,24 @@ const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = {
host_timeout: false,
};
const createMockScriptResult = (
export const createMockScriptResult = (
overrides?: Partial<IScriptResultResponse>
): IScriptResultResponse => {
return { ...DEFAULT_SCRIPT_RESULT_MOCK, ...overrides };
};
export default createMockScriptResult;
const DEFAULT_HOST_SCRIPT_MOCK: IHostScript = {
script_id: 1,
name: "test script",
last_execution: {
execution_id: "123",
executed_at: "2020-01-01T00:00:00.000Z",
status: "ran",
},
};
export const createMockHostScript = (
overrides?: Partial<IHostScript>
): IHostScript => {
return { ...DEFAULT_HOST_SCRIPT_MOCK, ...overrides };
};

View file

@ -3,12 +3,17 @@ import classnames from "classnames";
const baseClass = "card";
type CardColors = "white" | "gray" | "purple" | "yellow";
type BorderRadiusSize = "small" | "medium" | "large";
type CardColor = "white" | "gray" | "purple" | "yellow";
interface ICardProps {
children?: React.ReactNode;
/** defaults to white */
color?: CardColors;
/** The size of the border radius. Defaults to `small` */
borderRadiusSize?: BorderRadiusSize;
/** Includes the card shadows. Defaults to `false` */
includeShadow?: boolean;
/** The color of the card. Defaults to `white` */
color?: CardColor;
className?: string;
}
@ -16,8 +21,20 @@ interface ICardProps {
* A generic card component that will be used to render content within a card with a border and
* and selected background color.
*/
const Card = ({ children, color = "white", className }: ICardProps) => {
const classNames = classnames(baseClass, `${baseClass}__${color}`, className);
const Card = ({
children,
borderRadiusSize = "small",
includeShadow = false,
color = "white",
className,
}: ICardProps) => {
const classNames = classnames(
baseClass,
`${baseClass}__${color}`,
`${baseClass}__radius-${borderRadiusSize}`,
{ [`${baseClass}__shadow`]: includeShadow },
className
);
return <div className={classNames}>{children}</div>;
};

View file

@ -3,6 +3,25 @@
border: 1px solid $ui-fleet-black-10;
padding: $pad-large;
// radius styles
&__radius-small {
border-radius: $border-radius;
}
&__radius-medium {
border-radius: $border-radius-large;
}
&__radius-large {
border-radius: $border-radius-xxlarge;
}
// box shadow styles
&__shadow {
box-shadow: $box-shadow;
}
// color styles
&__white {
background-color: $core-white;
}

View file

@ -46,6 +46,9 @@
display: flex;
flex-direction: column;
gap: $pad-small;
p {
margin: $pad-small 0 0 0;
}
}
&__cta {

View file

@ -16,8 +16,8 @@ interface IDropdownCellProps {
const DropdownCell = ({
options,
onChange,
placeholder,
onChange,
}: IDropdownCellProps): JSX.Element => {
return (
<div className={baseClass}>

View file

@ -4,7 +4,7 @@ import ReactTooltip from "react-tooltip";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
interface ITextCellProps {
value?: string | number | boolean | { timeString: string };
value?: string | number | boolean | { timeString: string } | null;
formatter?: (val: any) => JSX.Element | string; // string, number, or null
greyed?: boolean;
classes?: string;

View file

@ -21,6 +21,8 @@
}
.input-field {
padding-right: 16%;
&--disabled {
letter-spacing: 0;
}

View file

@ -49,6 +49,9 @@ export enum ActivityType {
EnabledWindowsMdm = "enabled_windows_mdm",
DisabledWindowsMdm = "disabled_windows_mdm",
RanScript = "ran_script",
AddedScript = "added_script",
DeletedScript = "deleted_script",
EditedScript = "edited_script",
}
export interface IActivity {
created_at: string;
@ -91,4 +94,5 @@ export interface IActivityDetails {
bootstrap_package_name?: string;
name?: string;
script_execution_id?: string;
script_name?: string;
}

View file

@ -103,17 +103,6 @@ export const isWindowsDiskEncryptionStatus = (
export const FLEET_FILEVAULT_PROFILE_DISPLAY_NAME = "Disk encryption";
// TODO: update when we have API
export interface IMdmScript {
id: number;
name: string;
ran: number;
pending: number;
errors: number;
created_at: string;
updated_at: string;
}
export interface IMdmSSOReponse {
url: string;
}

View file

@ -769,4 +769,117 @@ describe("Activity Feed", () => {
})
).toBeInTheDocument();
});
it("renders an 'added_script' type activity for a team", () => {
const activity = createMockActivity({
type: ActivityType.AddedScript,
details: { script_name: "foo.sh", team_name: "Alphas" },
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("added script ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument();
expect(
screen.getByText(" to the ", {
exact: false,
})
).toBeInTheDocument();
expect(screen.getByText("Alphas")).toBeInTheDocument();
expect(screen.getByText(" team.", { exact: false })).toBeInTheDocument();
const withNoTeams = screen.queryByText("no team");
expect(withNoTeams).toBeNull();
});
it("renders a 'deleted_script' type activity for a team", () => {
const activity = createMockActivity({
type: ActivityType.DeletedScript,
details: { script_name: "foo.sh", team_name: "Alphas" },
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("deleted script ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument();
expect(
screen.getByText(" from the ", {
exact: false,
})
).toBeInTheDocument();
expect(screen.getByText("Alphas")).toBeInTheDocument();
expect(screen.getByText(" team.", { exact: false })).toBeInTheDocument();
const withNoTeams = screen.queryByText("no team");
expect(withNoTeams).toBeNull();
});
it("renders an 'added_script' type activity for hosts with no team.", () => {
const activity = createMockActivity({
type: ActivityType.AddedScript,
details: { script_name: "foo.sh" },
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("added script ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument();
expect(
screen.getByText("to no team.", { exact: false })
).toBeInTheDocument();
});
it("renders a 'deleted_script' type activity for hosts with no team.", () => {
const activity = createMockActivity({
type: ActivityType.DeletedScript,
details: { script_name: "foo.sh" },
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("deleted script ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("foo.sh", { exact: false })).toBeInTheDocument();
expect(
screen.getByText("from no team.", { exact: false })
).toBeInTheDocument();
});
it("renders an 'edited_script' type activity for a team", () => {
const activity = createMockActivity({
type: ActivityType.EditedScript,
details: { team_name: "Alphas" },
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("edited scripts", { exact: false })
).toBeInTheDocument();
expect(
screen.getByText(" for the ", {
exact: false,
})
).toBeInTheDocument();
expect(screen.getByText("Alphas")).toBeInTheDocument();
expect(
screen.getByText(" team via fleetctl.", { exact: false })
).toBeInTheDocument();
const withNoTeams = screen.queryByText("no team");
expect(withNoTeams).toBeNull();
});
it("renders an 'edited_script' type activity for hosts with no team.", () => {
const activity = createMockActivity({
type: ActivityType.EditedScript,
details: {},
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("edited scripts", { exact: false })
).toBeInTheDocument();
expect(
screen.getByText("for no team via fleetctl.", { exact: false })
).toBeInTheDocument();
});
});

View file

@ -518,6 +518,72 @@ const TAGGED_TEMPLATES = {
</>
);
},
addedScript: (activity: IActivity) => {
const scriptName = activity.details?.script_name;
return (
<>
{" "}
added{" "}
{scriptName ? (
<>
script <b>{scriptName}</b>{" "}
</>
) : (
"a script "
)}
to{" "}
{activity.details?.team_name ? (
<>
the <b>{activity.details.team_name}</b> team
</>
) : (
"no team"
)}
.
</>
);
},
deletedScript: (activity: IActivity) => {
const scriptName = activity.details?.script_name;
return (
<>
{" "}
deleted{" "}
{scriptName ? (
<>
script <b>{scriptName}</b>{" "}
</>
) : (
"a script "
)}
from{" "}
{activity.details?.team_name ? (
<>
the <b>{activity.details.team_name}</b> team
</>
) : (
"no team"
)}
.
</>
);
},
editedScript: (activity: IActivity) => {
return (
<>
{" "}
edited scripts for{" "}
{activity.details?.team_name ? (
<>
the <b>{activity.details.team_name}</b> team
</>
) : (
"no team"
)}{" "}
via fleetctl.
</>
);
},
};
const getDetail = (
@ -634,6 +700,15 @@ const getDetail = (
case ActivityType.RanScript: {
return TAGGED_TEMPLATES.ranScript(activity, onDetailsClick);
}
case ActivityType.AddedScript: {
return TAGGED_TEMPLATES.addedScript(activity);
}
case ActivityType.DeletedScript: {
return TAGGED_TEMPLATES.deletedScript(activity);
}
case ActivityType.EditedScript: {
return TAGGED_TEMPLATES.editedScript(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}

View file

@ -104,6 +104,7 @@ const LabelForm = ({
const onLoad = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
enableMultiselect: false, // Disables command + click creating multiple cursors
});
// @ts-expect-error

View file

@ -1,111 +0,0 @@
import React, { useRef, useState } from "react";
import { IMdmScript } from "interfaces/mdm";
import CustomLink from "components/CustomLink";
import ScriptListHeading from "./components/ScriptListHeading";
import ScriptListItem from "./components/ScriptListItem";
import DeleteScriptModal from "./components/DeleteScriptModal";
import FileUploader from "../components/FileUploader";
import UploadList from "../components/UploadList";
import RerunScriptModal from "./components/RerunScriptModal";
// TODO: remove when get integrate with API.
const scripts = [
{
id: 1,
name: "Test.py",
ran: 57,
pending: 2304,
errors: 0,
created_at: new Date().toString(),
},
];
const baseClass = "mac-os-scripts";
const MacOSScripts = () => {
const [showRerunScriptModal, setShowRerunScriptModal] = useState(false);
const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
const selectedScript = useRef<IMdmScript | null>(null);
const onClickRerun = (script: IMdmScript) => {
selectedScript.current = script;
setShowRerunScriptModal(true);
};
const onClickDelete = (script: IMdmScript) => {
selectedScript.current = script;
setShowDeleteScriptModal(true);
};
const onCancelRerun = () => {
selectedScript.current = null;
setShowRerunScriptModal(false);
};
const onCancelDelete = () => {
selectedScript.current = null;
setShowDeleteScriptModal(false);
};
// TODO: change when integrating with API
const onRerunScript = (scriptId: number) => {
console.log("rerun", scriptId);
setShowRerunScriptModal(false);
};
// TODO: change when integrating with API
const onDeleteScript = (scriptId: number) => {
console.log("delete", scriptId);
setShowDeleteScriptModal(false);
};
return (
<div className={baseClass}>
<p className={`${baseClass}__description`}>
Upload scripts to change configuration and remediate issues on macOS
hosts. Each script runs once per host. All scripts can be rerun on end
users My device page. <CustomLink text="Learn more" url="#" newTab />
</p>
<UploadList
listItems={scripts}
HeadingComponent={ScriptListHeading}
ListItemComponent={({ listItem }) => (
<ScriptListItem
script={listItem}
onRerun={onClickRerun}
onDelete={onClickDelete}
/>
)}
/>
<FileUploader
icon="files"
message="Any type of script supported by macOS. If you If you dont specify a shell or interpreter (e.g. #!/bin/sh), the script will run in /bin/sh."
onFileUpload={() => {
return null;
}}
/>
{showRerunScriptModal && selectedScript.current && (
<RerunScriptModal
scriptName={selectedScript.current?.name}
scriptId={selectedScript.current?.id}
onCancel={onCancelRerun}
onRerun={onRerunScript}
/>
)}
{showDeleteScriptModal && selectedScript.current && (
<DeleteScriptModal
scriptName={selectedScript.current?.name}
scriptId={selectedScript.current?.id}
onCancel={onCancelDelete}
onDelete={onDeleteScript}
/>
)}
</div>
);
};
export default MacOSScripts;

View file

@ -1,7 +0,0 @@
.mac-os-scripts {
font-size: $x-small;
&__description {
margin: $pad-xxlarge 0;
}
}

View file

@ -1 +0,0 @@
export { default } from "./MacOSScripts";

View file

@ -28,6 +28,10 @@ const controlsSubNav: IControlsSubNavItem[] = [
name: "Setup experience",
pathname: PATHS.CONTROLS_SETUP_EXPERIENCE,
},
{
name: "Scripts",
pathname: PATHS.CONTROLS_SCRIPTS,
},
];
interface IManageControlsPageProps {
@ -38,6 +42,7 @@ interface IManageControlsPageProps {
hash?: string;
query: {
team_id?: string;
page?: string;
};
};
router: InjectedRouter; // v3
@ -60,6 +65,8 @@ const ManageControlsPage = ({
location,
router,
}: IManageControlsPageProps): JSX.Element => {
const page = parseInt(location?.query?.page || "", 10) || 0;
const {
isFreeTier,
isOnGlobalTeam,
@ -114,7 +121,7 @@ const ManageControlsPage = ({
</TabList>
</Tabs>
</TabsWrapper>
{React.cloneElement(children, { teamIdForApi })}
{React.cloneElement(children, { teamIdForApi, currentPage: page })}
</div>
);
};

View file

@ -0,0 +1,165 @@
import React, { useCallback, useContext, useRef, useState } from "react";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import { InjectedRouter } from "react-router";
import { AppContext } from "context/app";
import PATHS from "router/paths";
import scriptAPI, {
IListScriptsQueryKey,
IScript,
IScriptsResponse,
} from "services/entities/scripts";
import CustomLink from "components/CustomLink";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import ScriptListHeading from "./components/ScriptListHeading";
import ScriptListItem from "./components/ScriptListItem";
import ScriptListPagination from "./components/ScriptListPagination";
import DeleteScriptModal from "./components/DeleteScriptModal";
import UploadList from "../components/UploadList";
import ScriptUploader from "./components/ScriptUploader";
const baseClass = "scripts";
const SCRIPTS_PER_PAGE = 10; // TODO: confirm this is the desired default
interface IScriptsProps {
router: InjectedRouter; // v3
teamIdForApi: number;
currentPage: number;
}
const Scripts = ({ router, currentPage, teamIdForApi }: IScriptsProps) => {
const { isPremiumTier } = useContext(AppContext);
const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
const selectedScript = useRef<IScript | null>(null);
const {
data: { scripts, meta } = {},
isLoading,
isError,
refetch: refetchScripts,
} = useQuery<
IScriptsResponse,
AxiosError,
IScriptsResponse,
IListScriptsQueryKey[]
>(
[
{
scope: "scripts",
team_id: teamIdForApi,
page: currentPage,
per_page: SCRIPTS_PER_PAGE,
},
],
({ queryKey: [{ team_id, page, per_page }] }) =>
scriptAPI.getScripts({ team_id, page, per_page }),
{
retry: false,
refetchOnWindowFocus: false,
staleTime: 3000,
}
);
// pagination controls
const path = PATHS.CONTROLS_SCRIPTS.concat(`?team_id=${teamIdForApi}`);
const onPrevPage = useCallback(() => {
router.push(path.concat(`&page=${currentPage - 1}`));
}, [router, path, currentPage]);
const onNextPage = useCallback(() => {
router.push(path.concat(`&page=${currentPage + 1}`));
}, [router, path, currentPage]);
// The user is not a premium tier, so show the premium feature message.
if (!isPremiumTier) {
return (
<PremiumFeatureMessage
className={`${baseClass}__premium-feature-message`}
/>
);
}
const onClickDelete = (script: IScript) => {
selectedScript.current = script;
setShowDeleteScriptModal(true);
};
const onCancelDelete = () => {
selectedScript.current = null;
setShowDeleteScriptModal(false);
};
const onDeleteScript = () => {
selectedScript.current = null;
setShowDeleteScriptModal(false);
refetchScripts();
};
const onUploadScript = () => {
refetchScripts();
};
const renderScriptsList = () => {
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <DataError />;
}
if (currentPage === 0 && !scripts?.length) {
return null;
}
return (
<>
<UploadList
listItems={scripts || []}
HeadingComponent={ScriptListHeading}
ListItemComponent={({ listItem }) => (
<ScriptListItem script={listItem} onDelete={onClickDelete} />
)}
/>
<ScriptListPagination
meta={meta}
isLoading={isLoading}
onPrevPage={onPrevPage}
onNextPage={onNextPage}
/>
</>
);
};
return (
<div className={baseClass}>
<p className={`${baseClass}__description`}>
Upload scripts to change configuration and remediate issues on macOS
hosts. You can run scripts on individual hosts.{" "}
<CustomLink
text="Learn more"
url="https://fleetdm.com/docs/using-fleet/scripts"
newTab
/>
</p>
{renderScriptsList()}
<ScriptUploader currentTeamId={teamIdForApi} onUpload={onUploadScript} />
{showDeleteScriptModal && selectedScript.current && (
<DeleteScriptModal
scriptName={selectedScript.current?.name}
scriptId={selectedScript.current?.id}
onCancel={onCancelDelete}
onDone={onDeleteScript}
/>
)}
</div>
);
};
export default Scripts;

View file

@ -0,0 +1,11 @@
.scripts {
font-size: $x-small;
&__description {
margin: $pad-xxlarge 0;
}
&__premium-feature-message {
margin-top: $pad-xxxlarge;
}
}

View file

@ -1,4 +1,7 @@
import React from "react";
import React, { useContext } from "react";
import scriptAPI from "services/entities/scripts";
import { NotificationContext } from "context/notification";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
@ -9,21 +12,33 @@ interface IDeleteScriptModalProps {
scriptName: string;
scriptId: number;
onCancel: () => void;
onDelete: (scriptId: number) => void;
onDone: () => void;
}
const DeleteScriptModal = ({
scriptName,
scriptId,
onCancel,
onDelete,
onDone,
}: IDeleteScriptModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const onClickDelete = async (id: number) => {
try {
await scriptAPI.deleteScript(id);
renderFlash("success", "Successfully deleted!");
} catch {
renderFlash("error", "Couldnt delete. Please try again.");
}
onDone();
};
return (
<Modal
className={baseClass}
title={"Delete script"}
onExit={onCancel}
onEnter={() => onDelete(scriptId)}
onEnter={() => onClickDelete(scriptId)}
>
<>
<p>
@ -34,7 +49,7 @@ const DeleteScriptModal = ({
<div className="modal-cta-wrap">
<Button
type="button"
onClick={() => onDelete(scriptId)}
onClick={() => onClickDelete(scriptId)}
variant="alert"
className="delete-loading"
>

View file

@ -12,28 +12,6 @@ const ScriptListHeading = () => {
<div className={`${baseClass}__heading-group`}>
<span>Script</span>
</div>
<div
className={`${baseClass}__heading-group ${baseClass}__script-statuses`}
>
<div className={`${baseClass}__status`}>
<div data-tip data-for="ran">
<Icon name="success" />
<span>Ran</span>
</div>
</div>
<div className={`${baseClass}__status`}>
<div data-tip data-for="pending">
<Icon name="pending" />
<span>Pending</span>
</div>
</div>
<div className={`${baseClass}__status`}>
<div data-tip data-for="errors">
<Icon name="error" />
<span>Errors</span>
</div>
</div>
</div>
<div
className={`${baseClass}__heading-group ${baseClass}__actions-heading`}
>

View file

@ -15,7 +15,7 @@
text-align: right;
span {
margin-right: 95px; // align with left side of buttons below it
margin-right: $pad-xlarge; // align with left side of buttons below it
}
}

View file

@ -1,7 +1,9 @@
import React from "react";
import { formatDistanceToNow } from "date-fns";
import React, { useContext } from "react";
import { format, formatDistanceToNow } from "date-fns";
import FileSaver from "file-saver";
import { IMdmScript } from "interfaces/mdm";
import { NotificationContext } from "context/notification";
import scriptAPI, { IScript } from "services/entities/scripts";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
@ -9,15 +11,10 @@ import Button from "components/buttons/Button";
const baseClass = "script-list-item";
interface IScriptListItemProps {
script: IMdmScript;
onRerun: (script: IMdmScript) => void;
onDelete: (script: IMdmScript) => void;
script: IScript;
onDelete: (script: IScript) => void;
}
const getStatusClassName = (value: number) => {
return value !== 0 ? `${baseClass}__has-value` : "";
};
const getFileIconName = (fileName: string) => {
const fileExtension = fileName.split(".").pop();
@ -33,13 +30,19 @@ const getFileIconName = (fileName: string) => {
}
};
const ScriptListItem = ({
script,
onRerun,
onDelete,
}: IScriptListItemProps) => {
const onClickDownload = () => {
console.log("download");
const ScriptListItem = ({ script, onDelete }: IScriptListItemProps) => {
const { renderFlash } = useContext(NotificationContext);
const onClickDownload = async () => {
try {
const content = await scriptAPI.downloadScript(script.id);
const formatDate = format(new Date(), "yyyy-MM-dd");
const filename = `${formatDate}_${script.name}`;
const file = new File([content], filename);
FileSaver.saveAs(file);
} catch {
renderFlash("error", "Couldnt Download. Please try again.");
}
};
return (
@ -53,26 +56,7 @@ const ScriptListItem = ({
</span>
</div>
</div>
<div
className={`${baseClass}__value-group ${baseClass}__script-statuses`}
>
<span className={getStatusClassName(script.ran)}>{script.ran}</span>
<span className={getStatusClassName(script.pending)}>
{script.pending}
</span>
<span className={getStatusClassName(script.errors)}>
{script.errors}
</span>
</div>
<div className={`${baseClass}__value-group ${baseClass}__script-actions`}>
<Button
className={`${baseClass}__refresh-button`}
variant="text-icon"
onClick={() => onRerun(script)}
>
<Icon name="refresh" />
</Button>
<Button
className={`${baseClass}__download-button`}
variant="text-icon"

View file

@ -0,0 +1,49 @@
import Button from "components/buttons/Button";
import React from "react";
import { IScriptsResponse } from "services/entities/scripts";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
const baseClass = "script-list-pagination";
interface IScriptsListPaginationProps {
meta: IScriptsResponse["meta"] | undefined;
isLoading: boolean;
onPrevPage: () => void;
onNextPage: () => void;
}
const ScriptsListPagination = ({
meta,
isLoading,
onPrevPage,
onNextPage,
}: IScriptsListPaginationProps) => {
return (
<div className={baseClass}>
<Button
disabled={isLoading || !meta?.has_previous_results}
onClick={onPrevPage}
variant="unstyled"
className={`${baseClass}__button`}
>
<>
<FleetIcon name="chevronleft" /> Previous
</>
</Button>
<Button
disabled={isLoading || !meta?.has_next_results}
onClick={onNextPage}
variant="unstyled"
className={`${baseClass}__button`}
>
<>
Next <FleetIcon name="chevronright" />
</>
</Button>
</div>
);
};
export default ScriptsListPagination;

View file

@ -0,0 +1,42 @@
.script-list-pagination {
display: flex;
justify-content: end;
padding-top: $pad-small;
.button {
font-weight: $bold;
}
&__button {
color: $core-vibrant-blue;
vertical-align: bottom;
padding: 6px;
.fleeticon-chevronleft,
.fleeticon-chevronright {
&:before {
font-size: 0.5rem;
font-weight: $bold;
position: relative;
top: -2px;
}
}
.fleeticon-chevronleft {
margin-right: $pad-small;
}
.fleeticon-chevronright {
margin-left: $pad-small;
}
&:first-of-type {
margin-right: $pad-large;
}
&:hover:not(.button--disabled),
&:focus {
background-color: $ui-vibrant-blue-10;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./ScriptListPagination";

View file

@ -0,0 +1,58 @@
import React, { useContext, useState } from "react";
import { AxiosResponse } from "axios";
import { IApiError } from "interfaces/errors";
import { NotificationContext } from "context/notification";
import scriptAPI from "services/entities/scripts";
import FileUploader from "pages/ManageControlsPage/components/FileUploader";
import { getErrorMessage } from "./helpers";
const baseClass = "script-uploader";
interface IScriptPackageUploaderProps {
currentTeamId: number;
onUpload: () => void;
}
const ScriptPackageUploader = ({
currentTeamId,
onUpload,
}: IScriptPackageUploaderProps) => {
const { renderFlash } = useContext(NotificationContext);
const [showLoading, setShowLoading] = useState(false);
const onUploadFile = async (files: FileList | null) => {
if (!files || files.length === 0) {
return;
}
const file = files[0];
setShowLoading(true);
try {
await scriptAPI.uploadScript(file, currentTeamId);
renderFlash("success", "Successfully uploaded!");
onUpload();
} catch (e) {
const error = e as AxiosResponse<IApiError>;
renderFlash("error", `Couldn't upload. ${getErrorMessage(error)}`);
} finally {
setShowLoading(false);
}
};
return (
<FileUploader
className={baseClass}
icon="file-bash"
message="Script (.sh)"
additionalInfo="Script will run with “#!/bin/sh”."
accept=".sh"
onFileUpload={onUploadFile}
isLoading={showLoading}
/>
);
};
export default ScriptPackageUploader;

View file

@ -0,0 +1,3 @@
.script-uploader {
margin-top: $pad-xxlarge;
}

View file

@ -0,0 +1,14 @@
import { AxiosResponse } from "axios";
import { IApiError } from "interfaces/errors";
const UPLOAD_ERROR_MESSAGES = {
default: {
message: "Couldnt upload. Please try again.",
},
};
// eslint-disable-next-line import/prefer-default-export
export const getErrorMessage = (err: AxiosResponse<IApiError>) => {
const apiReason = err.data.errors[0].reason;
return apiReason || UPLOAD_ERROR_MESSAGES.default.message;
};

View file

@ -0,0 +1 @@
export { default } from "./ScriptUploader";

View file

@ -0,0 +1 @@
export { default } from "./Scripts";

View file

@ -4,13 +4,19 @@ import classnames from "classnames";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import { IconNames } from "components/icons";
import Card from "components/Card";
const baseClass = "file-uploader";
interface IFileUploaderProps {
icon: IconNames;
message: string;
additionalInfo?: string;
isLoading?: boolean;
/** A comma seperated string of one or more file types accepted to upload.
* This is the same as the html accept attribute.
* https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
*/
accept?: string;
className?: string;
onFileUpload: (files: FileList | null) => void;
@ -19,6 +25,7 @@ interface IFileUploaderProps {
const FileUploader = ({
icon,
message,
additionalInfo,
isLoading = false,
accept,
className,
@ -27,10 +34,15 @@ const FileUploader = ({
const classes = classnames(baseClass, className);
return (
<div className={classes}>
<Card color="gray" className={classes}>
<Icon name={icon} />
<p>{message}</p>
<Button variant="brand" isLoading={isLoading}>
<p className={`${baseClass}__message`}>{message}</p>
<p className={`${baseClass}__additional-info`}>{additionalInfo}</p>
<Button
className={`${baseClass}__upload-button`}
variant="brand"
isLoading={isLoading}
>
<label htmlFor="upload-profile">Upload</label>
</Button>
<input
@ -42,7 +54,7 @@ const FileUploader = ({
e.target.value = "";
}}
/>
</div>
</Card>
);
};

View file

@ -8,11 +8,23 @@
padding: $pad-xlarge $pad-large;
font-size: $x-small;
text-align: center;
gap: $pad-small;
&__message {
margin: 0;
}
&__additional-info {
margin: 0;
color: $ui-fleet-black-50;
}
input {
display: none;
}
.button {
&__upload-button {
margin-top: 8px;
padding: 0;
}

View file

@ -1,4 +1,5 @@
import React from "react";
import { buildQueryStringFromParams } from "utilities/url";
const baseClass = "upload-list";
@ -20,7 +21,6 @@ const UploadList = ({
</li>
);
});
return (
<div className={baseClass}>
{HeadingComponent && (

View file

@ -2,7 +2,6 @@
&__header {
padding: $pad-medium $pad-large;
font-size: $x-small;
font-weight: $bold;
border-bottom: 1px solid $ui-fleet-black-10;
}

View file

@ -54,6 +54,7 @@ import AboutCard from "../cards/About";
import AgentOptionsCard from "../cards/AgentOptions";
import LabelsCard from "../cards/Labels";
import MunkiIssuesCard from "../cards/MunkiIssues";
import ScriptsCard from "../cards/Scripts";
import SoftwareCard from "../cards/Software";
import UsersCard from "../cards/Users";
import PoliciesCard from "../cards/Policies";
@ -619,6 +620,11 @@ const HostDetailsPage = ({
title: "details",
pathname: PATHS.HOST_DETAILS(hostIdFromURL),
},
{
name: "Scripts",
title: "scripts",
pathname: PATHS.HOST_SCRIPTS(hostIdFromURL),
},
{
name: "Software",
title: "software",
@ -643,15 +649,24 @@ const HostDetailsPage = ({
},
];
// we want the scripts tabs on the list for only mac hosts and premium tier atm.
// We filter it out for other platforms and non premium.
// TODO: improve this code. We can pull the tab list component out
// into its own component later.
const filteredSubNavTabs =
host?.platform === "darwin" && isPremiumTier
? hostDetailsSubNav
: hostDetailsSubNav.filter((navItem) => navItem.title !== "scripts");
const getTabIndex = (path: string): number => {
return hostDetailsSubNav.findIndex((navItem) => {
return filteredSubNavTabs.findIndex((navItem) => {
// tab stays highlighted for paths that ends with same pathname
return path.endsWith(navItem.pathname);
});
};
const navigateToNav = (i: number): void => {
const navPath = hostDetailsSubNav[i].pathname;
const navPath = filteredSubNavTabs[i].pathname;
router.push(navPath);
};
@ -686,6 +701,8 @@ const HostDetailsPage = ({
name: host?.mdm.macos_setup?.bootstrap_package_name,
};
const page = (location.query.page && parseInt(location.query.page, 10)) || 0;
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
@ -734,7 +751,7 @@ const HostDetailsPage = ({
onSelect={(i) => navigateToNav(i)}
>
<TabList>
{hostDetailsSubNav.map((navItem) => {
{filteredSubNavTabs.map((navItem) => {
// Bolding text when the tab is active causes a layout shift
// so we add a hidden pseudo element with the same text string
return <Tab key={navItem.title}>{navItem.name}</Tab>;
@ -767,6 +784,16 @@ const HostDetailsPage = ({
hostUsersEnabled={featuresConfig?.enable_host_users}
/>
</TabPanel>
{host?.platform === "darwin" && isPremiumTier && (
<TabPanel>
<ScriptsCard
hostId={host?.id}
page={page}
router={router}
isHostOnline={host?.status === "online"}
/>
</TabPanel>
)}
<TabPanel>
<SoftwareCard
isLoading={isLoadingHost}

View file

@ -0,0 +1,128 @@
import React, { useContext, useRef, useState } from "react";
import { useQuery } from "react-query";
import { AxiosResponse } from "axios";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import scriptsAPI, {
IHostScript,
IHostScriptsResponse,
} from "services/entities/scripts";
import { IApiError, IError } from "interfaces/errors";
import { NotificationContext } from "context/notification";
import Card from "components/Card";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import DataError from "components/DataError";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal";
import { generateDataSet, generateTableHeaders } from "./ScriptsTableConfig";
const baseClass = "host-scripts-section";
interface IScriptsProps {
hostId?: number;
page?: number;
isHostOnline: boolean;
router: InjectedRouter;
}
const Scripts = ({ hostId, page = 0, isHostOnline, router }: IScriptsProps) => {
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
// used to track the current script execution id we want to show in the show
// details modal.
const scriptExecutionId = useRef<string | null>(null);
const { renderFlash } = useContext(NotificationContext);
const {
data: hostScriptResponse,
isLoading: isLoadingScriptData,
isError: isErrorScriptData,
refetch: refetchScriptsData,
} = useQuery<IHostScriptsResponse, IError>(
["scripts", hostId, page],
() => scriptsAPI.getHostScripts(hostId as number, page),
{
refetchOnWindowFocus: false,
retry: false,
enabled: Boolean(hostId),
}
);
if (!hostId) return null;
const onQueryChange = (data: ITableQueryData) => {
router.push(`${PATHS.HOST_SCRIPTS(hostId)}?page=${data.pageIndex}`);
};
const onActionSelection = async (action: string, script: IHostScript) => {
switch (action) {
case "showDetails":
if (!script.last_execution) return;
scriptExecutionId.current = script.last_execution.execution_id;
setShowScriptDetailsModal(true);
break;
case "run":
try {
await scriptsAPI.runScript(script.script_id);
refetchScriptsData();
} catch (e) {
const error = e as AxiosResponse<IApiError>;
renderFlash("error", error.data.errors[0].reason);
}
break;
default:
break;
}
};
const onCancelScriptDetailsModal = () => {
setShowScriptDetailsModal(false);
scriptExecutionId.current = null;
};
if (isErrorScriptData) {
return <DataError card />;
}
const scriptHeaders = generateTableHeaders(onActionSelection);
const data = generateDataSet(hostScriptResponse?.scripts || [], isHostOnline);
return (
<Card className={baseClass} borderRadiusSize="large" includeShadow>
<h2>Scripts</h2>
{data && data.length === 0 ? (
<EmptyTable
header="No scripts are available for this host"
info="Expecting to see scripts? Try selecting “Refetch” to ask this host to report new vitals."
/>
) : (
<TableContainer
resultsTitle=""
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
columns={scriptHeaders}
data={data}
isLoading={isLoadingScriptData}
onQueryChange={onQueryChange}
disableNextPage={hostScriptResponse?.meta.has_next_results}
defaultPageIndex={page}
disableCount
/>
)}
{showScriptDetailsModal && scriptExecutionId.current && (
<ScriptDetailsModal
scriptExecutionId={scriptExecutionId.current}
onCancel={onCancelScriptDetailsModal}
/>
)}
</Card>
);
};
export default Scripts;

View file

@ -0,0 +1,130 @@
import React from "react";
import ReactTooltip from "react-tooltip";
import { COLORS } from "styles/var/colors";
import { IDropdownOption } from "interfaces/dropdownOption";
import { IHostScript, ILastExecution } from "services/entities/scripts";
import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
import ScriptStatusCell from "./components/ScriptStatusCell";
interface IStatusCellProps {
cell: {
value: ILastExecution | null;
};
}
interface IDropdownCellProps {
cell: {
value: IDropdownOption[];
};
row: {
original: IHostScript;
};
}
const ScriptRunActionDropdownLabel = ({
scriptId,
disabled,
}: {
scriptId: number;
disabled: boolean;
}) => {
const tipId = `run-script-${scriptId}`;
return disabled ? (
<>
<span data-tip data-for={tipId}>
Run
</span>
<ReactTooltip
place="bottom"
type="dark"
effect="solid"
id={tipId}
backgroundColor={COLORS["tooltip-bg"]}
delayHide={100}
delayUpdate={500}
>
You can only run the script when the host is online.
</ReactTooltip>
</>
) : (
<>Run</>
);
};
// eslint-disable-next-line import/prefer-default-export
export const generateTableHeaders = (
actionSelectHandler: (value: string, script: IHostScript) => void
) => {
return [
{
title: "Name",
Header: "Name",
disableSortBy: true,
accessor: "name",
},
{
title: "Status",
Header: "Status",
disableSortBy: true,
accessor: "last_execution",
Cell: ({ cell: { value } }: IStatusCellProps) => {
return <ScriptStatusCell lastExecution={value} />;
},
},
{
title: "Actions",
Header: "",
disableSortBy: true,
accessor: "actions",
Cell: (cellProps: IDropdownCellProps) => (
<DropdownCell
options={cellProps.cell.value}
onChange={(value: string) =>
actionSelectHandler(value, cellProps.row.original)
}
placeholder={"Actions"}
/>
),
},
];
};
// NOTE: may need current user ID later for permission on actions.
const generateActionDropdownOptions = (
{ script_id, last_execution }: IHostScript,
isHostOnline: boolean
): IDropdownOption[] => {
return [
{
label: "Show details",
disabled: last_execution === null,
value: "showDetails",
},
{
label: (
<ScriptRunActionDropdownLabel
scriptId={script_id}
disabled={!isHostOnline}
/>
),
disabled: !isHostOnline,
value: "run",
},
];
};
export const generateDataSet = (
scripts: IHostScript[],
isHostOnline: boolean
) => {
return scripts.map((script) => {
return {
...script,
actions: generateActionDropdownOptions(script, isHostOnline),
};
});
};

View file

@ -0,0 +1,22 @@
.host-scripts-section {
margin-top: $pad-medium;
padding: $pad-xxlarge;
h2 {
font-size: $medium;
font-weight: $bold;
margin: 0 0 $pad-medium 0;
line-height: 1.5;
}
.table-container__header-left {
display: block;
}
.dropdown__option {
[data-id="tooltip"][id^="run-script"] {
font-size: $xx-small;
font-style: normal;
}
}
}

View file

@ -0,0 +1,72 @@
import React from "react";
import { formatDistanceToNow } from "date-fns";
import {
ILastExecution,
IScriptExecutionStatus,
} from "services/entities/scripts";
import StatusIndicatorWithIcon, {
IndicatorStatus,
} from "components/StatusIndicatorWithIcon/StatusIndicatorWithIcon";
import TextCell from "components/TableContainer/DataTable/TextCell";
interface IScriptStatusDisplayConfig {
displayText: string;
iconStatus: IndicatorStatus;
tooltip: (executedAt?: string) => string;
}
const STATUS_DISPLAY_CONFIG: Record<
IScriptExecutionStatus,
IScriptStatusDisplayConfig
> = {
ran: {
displayText: "Ran",
iconStatus: "success",
tooltip: (executedAt) =>
`Script ran and exited with exit code 0. (${executedAt} ago)`,
},
pending: {
displayText: "Pending",
iconStatus: "pendingPartial",
tooltip: () => "Script will run when the host comes online.",
},
error: {
displayText: "Error",
iconStatus: "error",
tooltip: (executedAt) =>
`Script ran and exited with a non-zero exit code. (${executedAt} ago)`,
},
};
interface IScriptStatusCellProps {
lastExecution: ILastExecution | null;
}
const ScriptStatusCell = ({ lastExecution }: IScriptStatusCellProps) => {
if (!lastExecution) {
return <TextCell value={null} />;
}
const { displayText, iconStatus, tooltip } = STATUS_DISPLAY_CONFIG[
lastExecution.status
];
const humanizedExecutedAt = formatDistanceToNow(
new Date(lastExecution.executed_at),
{ includeSeconds: true }
);
return (
<StatusIndicatorWithIcon
value={displayText}
status={iconStatus}
tooltip={{
tooltipText: tooltip(humanizedExecutedAt),
}}
/>
);
};
export default ScriptStatusCell;

View file

@ -0,0 +1,3 @@
.script-status-cell {
}

View file

@ -0,0 +1 @@
export { default } from "./ScriptStatusCell";

View file

@ -0,0 +1 @@
export { default } from "./Scripts";

View file

@ -179,6 +179,7 @@ const PolicyForm = ({
const onLoad = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
enableMultiselect: false, // Disables command + click creating multiple cursors
});
// @ts-expect-error

View file

@ -217,6 +217,7 @@ const EditQueryForm = ({
const onLoad = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
enableMultiselect: false, // Disables command + click creating multiple cursors
});
// @ts-expect-error

View file

@ -57,6 +57,7 @@ import OSSettings from "pages/ManageControlsPage/OSSettings";
import SetupExperience from "pages/ManageControlsPage/SetupExperience/SetupExperience";
import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage";
import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMdmPage";
import Scripts from "pages/ManageControlsPage/Scripts/Scripts";
import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage";
import PATHS from "router/paths";
@ -179,6 +180,7 @@ const routes = (
<IndexRedirect to=":host_id" />
<Route component={HostDetailsPage}>
<Route path=":host_id" component={HostDetailsPage}>
<Route path="scripts" component={HostDetailsPage} />
<Route path="software" component={HostDetailsPage} />
<Route path="policies" component={HostDetailsPage} />
<Route path="schedule" component={HostDetailsPage} />
@ -194,6 +196,7 @@ const routes = (
<Route path="os-settings" component={OSSettings} />
<Route path="os-settings/:section" component={OSSettings} />
<Route path="setup-experience" component={SetupExperience} />
<Route path="scripts" component={Scripts} />
<Route
path="setup-experience/:section"
component={SetupExperience}

View file

@ -14,7 +14,7 @@ export default {
CONTROLS_SETUP_EXPERIENCE: `${URL_PREFIX}/controls/setup-experience`,
CONTROLS_END_USER_AUTHENTICATION: `${URL_PREFIX}/controls/setup-experience/end-user-auth`,
CONTROLS_BOOTSTRAP_PACKAGE: `${URL_PREFIX}/controls/setup-experience/bootstrap-package`,
CONTROLS_MAC_SCRIPTS: `${URL_PREFIX}/controls/mac-scripts`,
CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`,
DASHBOARD: `${URL_PREFIX}/dashboard`,
DASHBOARD_LINUX: `${URL_PREFIX}/dashboard/linux`,
@ -86,6 +86,9 @@ export default {
HOST_DETAILS: (id: number): string => {
return `${URL_PREFIX}/hosts/${id}`;
},
HOST_SCRIPTS: (id: number): string => {
return `${URL_PREFIX}/hosts/${id}/scripts`;
},
HOST_SOFTWARE: (id: number): string => {
return `${URL_PREFIX}/hosts/${id}/software`;
},

View file

@ -1,6 +1,40 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
export interface IScript {
id: number;
team_id: number | null;
name: string;
created_at: string;
updated_at: string;
}
/** Single script response from GET /script/:id */
export type IScriptResponse = IScript;
/** All scripts response from GET /scripts */
export interface IScriptsResponse {
scripts: IScript[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export interface IListScriptsApiParams {
page?: number;
per_page?: number;
team_id?: number;
}
export interface IListScriptsQueryKey extends IListScriptsApiParams {
scope: "scripts";
}
/**
* Script Result response from GET /scripts/results/:id
*/
export interface IScriptResultResponse {
hostname: string;
host_id: number;
@ -13,9 +47,87 @@ export interface IScriptResultResponse {
host_timeout: boolean;
}
export type IScriptExecutionStatus = "ran" | "pending" | "error";
export interface ILastExecution {
execution_id: string;
executed_at: string;
status: IScriptExecutionStatus;
}
export interface IHostScript {
script_id: number;
name: string;
last_execution: ILastExecution | null;
}
/**
* Script response from GET /hosts/:id/scripts
*/
export interface IHostScriptsResponse {
scripts: IHostScript[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export default {
getHostScripts(id: number, page?: number) {
const { HOST_SCRIPTS } = endpoints;
let path = HOST_SCRIPTS(id);
if (page) {
path = `${path}?${buildQueryStringFromParams({ page })}`;
}
return sendRequest("GET", path);
},
getScripts(params: IListScriptsApiParams): Promise<IScriptsResponse> {
const { SCRIPTS } = endpoints;
const path = `${SCRIPTS}?${buildQueryStringFromParams({ ...params })}`;
return sendRequest("GET", path);
},
getScript(id: number) {
const { SCRIPT } = endpoints;
return sendRequest("GET", SCRIPT(id));
},
uploadScript(file: File, teamId?: number) {
const { SCRIPTS } = endpoints;
const formData = new FormData();
formData.append("script", file);
if (teamId) {
formData.append("team_id", teamId.toString());
}
return sendRequest("POST", SCRIPTS, formData);
},
downloadScript(id: number) {
const { SCRIPT } = endpoints;
const path = `${SCRIPT(id)}?${buildQueryStringFromParams({
alt: "media",
})}`;
return sendRequest("GET", path);
},
deleteScript(id: number) {
const { SCRIPT } = endpoints;
return sendRequest("DELETE", SCRIPT(id));
},
getScriptResult(executionId: string) {
const { SCRIPT_RESULT } = endpoints;
return sendRequest("GET", SCRIPT_RESULT(executionId));
},
runScript(id: number) {
const { SCRIPT_RUN } = endpoints;
return sendRequest("POST", SCRIPT_RUN, { script_id: id });
},
};

View file

@ -1,3 +1,8 @@
// border radius
$border-radius: 4px;
$border-radius-large: 6px;
$border-radius-xlarge: 10px;
$border-radius-xxlarge: 16px;
// box shadow
$box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4);

View file

@ -124,6 +124,10 @@ export default {
VERSION: `/${API_VERSION}/fleet/version`,
// SCRIPTS
HOST_SCRIPTS: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/scripts`,
SCRIPTS: `/${API_VERSION}/fleet/scripts`,
SCRIPT: (id: number) => `/${API_VERSION}/fleet/scripts/${id}`,
SCRIPT_RESULT: (executionId: string) =>
`/${API_VERSION}/fleet/scripts/results/${executionId}`,
SCRIPT_RUN: `/${API_VERSION}/fleet/scripts/run`,
};

View file

@ -77,7 +77,9 @@
tier: Premium
usualDepartment: IT
productCategories: [Device management]
- industryName: Safely execute custom scripts (macOS, Windows, and Linux)
- industryName: Script execution
fiendlyName: Safely execute custom scripts (macOS, Windows, and Linux)
documentationUrl: https://fleetdm.com/docs/using-fleet/scripts
tier: Premium
productCategories: [Device management, Endpoint operations]
- industryName: End-user macOS update reminders (via Nudge)

View file

@ -19,7 +19,7 @@ The 🚀 Engineering department at Fleet is directly responsible for writing and
-->
## Contact us
- Any community memeber can file a 🦟 ["Bug report"](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=)
- Any community member can file a 🦟 ["Bug report"](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=).
- Any Fleet team member can view the 🦟 ["Bugs" kanban board](https://app.zenhub.com/workspaces/-bugs-647f6d382e171b003416f51a/board) including the status on all reported bugs.
- If urgent, or if you need help submiting an issue, mention a [team member](#team) in the [#help-engineering](https://fleetdm.slack.com/archives/C019WG4GH0A) Slack channel.
- Any Fleet team member can view the dedicated sprint boards managed by this department:

View file

@ -92,3 +92,41 @@ func (b *Bool) UnmarshalJSON(data []byte) error {
b.Valid = true
return nil
}
type Slice[T any] struct {
Set bool
Valid bool
Value []T
}
func SetSlice[T any](s []T) Slice[T] {
return Slice[T]{Set: true, Valid: true, Value: s}
}
func (s Slice[T]) MarshalJSON() ([]byte, error) {
if !s.Valid {
return []byte("null"), nil
}
return json.Marshal(s.Value)
}
func (s *Slice[T]) UnmarshalJSON(data []byte) error {
// If this method was called, the value was set.
s.Set = true
s.Valid = false
if bytes.Equal(data, []byte("null")) {
// The key was set to null, blank the value
s.Value = []T{}
return nil
}
// The key isn't set to null
var v []T
if err := json.Unmarshal(data, &v); err != nil {
return err
}
s.Value = v
s.Valid = true
return nil
}

View file

@ -169,3 +169,190 @@ func TestBool(t *testing.T) {
}
})
}
func TestSlice(t *testing.T) {
t.Run("slice of ints", func(t *testing.T) {
cases := []struct {
data string
wantErr string
wantRes Slice[int]
marshalAs string
}{
{data: `[1,2,3]`, wantErr: "", wantRes: SetSlice([]int{1, 2, 3}), marshalAs: `[1,2,3]`},
{data: `[]`, wantErr: "", wantRes: SetSlice([]int{}), marshalAs: `[]`},
{data: `null`, wantErr: "", wantRes: Slice[int]{Set: true, Valid: false, Value: []int{}}, marshalAs: `null`},
{data: `[1,"2",3]`, wantErr: "cannot unmarshal string", wantRes: Slice[int]{Set: true, Valid: false, Value: nil}, marshalAs: `null`},
{data: `123`, wantErr: "cannot unmarshal number", wantRes: Slice[int]{Set: true, Valid: false, Value: []int(nil)}, marshalAs: `null`},
}
for _, c := range cases {
t.Run(c.data, func(t *testing.T) {
var s Slice[int]
err := json.Unmarshal([]byte(c.data), &s)
if c.wantErr != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, c.wantRes, s)
b, err := json.Marshal(s)
require.NoError(t, err)
require.Equal(t, c.marshalAs, string(b))
})
}
})
t.Run("slice of strings", func(t *testing.T) {
cases := []struct {
data string
wantErr string
wantRes Slice[string]
marshalAs string
}{
{data: `["foo", "bar"]`, wantErr: "", wantRes: SetSlice([]string{"foo", "bar"}), marshalAs: `["foo","bar"]`},
{data: `[""]`, wantErr: "", wantRes: SetSlice([]string{""}), marshalAs: `[""]`},
{data: `null`, wantErr: "", wantRes: Slice[string]{Set: true, Valid: false, Value: []string{}}, marshalAs: `null`},
{data: `["foo", 123]`, wantErr: "cannot unmarshal number", wantRes: Slice[string]{Set: true, Valid: false, Value: []string(nil)}, marshalAs: `null`},
}
for _, c := range cases {
t.Run(c.data, func(t *testing.T) {
var s Slice[string]
err := json.Unmarshal([]byte(c.data), &s)
if c.wantErr != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, c.wantRes, s)
b, err := json.Marshal(s)
require.NoError(t, err)
require.Equal(t, c.marshalAs, string(b))
})
}
})
t.Run("slice of bools", func(t *testing.T) {
cases := []struct {
data string
wantErr string
wantRes Slice[bool]
marshalAs string
}{
{data: `[true, false]`, wantErr: "", wantRes: SetSlice([]bool{true, false}), marshalAs: `[true,false]`},
{data: `[true, "false"]`, wantErr: "cannot unmarshal string", wantRes: Slice[bool]{Set: true, Valid: false, Value: []bool(nil)}, marshalAs: `null`},
{data: `null`, wantErr: "", wantRes: Slice[bool]{Set: true, Valid: false, Value: []bool{}}, marshalAs: `null`},
}
for _, c := range cases {
t.Run(c.data, func(t *testing.T) {
var s Slice[bool]
err := json.Unmarshal([]byte(c.data), &s)
if c.wantErr != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, c.wantRes, s)
b, err := json.Marshal(s)
require.NoError(t, err)
require.Equal(t, c.marshalAs, string(b))
})
}
})
}
func TestSliceWithinStruct(t *testing.T) {
type Nested struct {
Numbers Slice[int] `json:"numbers"`
Words Slice[string] `json:"words"`
Flags Slice[bool] `json:"flags"`
}
type Parent struct {
ID int `json:"id"`
Name string `json:"name"`
Nested Nested `json:"nested"`
}
t.Run("struct", func(t *testing.T) {
cases := []struct {
data string
wantErr string
wantRes Parent
marshalAs string
}{
{data: `{}`, wantErr: "", wantRes: Parent{}, marshalAs: `{"id": 0, "name": "", "nested": {"numbers": null, "words": null, "flags": null}}`},
{
data: `{"id": 1, "name": "test", "nested": {"numbers": [1, 2, 3], "words": ["one", "two"], "flags": [true, false]}}`,
wantErr: "",
wantRes: Parent{
ID: 1,
Name: "test",
Nested: Nested{
Numbers: SetSlice([]int{1, 2, 3}),
Words: SetSlice([]string{"one", "two"}),
Flags: SetSlice([]bool{true, false}),
},
},
marshalAs: `{"id": 1, "name": "test", "nested": {"numbers": [1,2,3], "words": ["one","two"], "flags": [true,false]}}`,
},
{
data: `{"id": 1, "name": "test", "nested": {"numbers": null, "words": ["one", "two"], "flags": [true, false]}}`,
wantErr: "",
wantRes: Parent{
ID: 1,
Name: "test",
Nested: Nested{
Numbers: Slice[int]{Set: true, Valid: false, Value: []int{}},
Words: SetSlice([]string{"one", "two"}),
Flags: SetSlice([]bool{true, false}),
},
},
marshalAs: `{"id": 1, "name": "test", "nested": {"numbers": null, "words": ["one","two"], "flags": [true,false]}}`,
},
{
data: `{"id": 1, "name": "test", "nested": {"numbers": [1, 2, 3], "words": null, "flags": [true, false]}}`,
wantErr: "",
wantRes: Parent{
ID: 1,
Name: "test",
Nested: Nested{
Numbers: SetSlice([]int{1, 2, 3}),
Words: Slice[string]{Set: true, Valid: false, Value: []string{}},
Flags: SetSlice([]bool{true, false}),
},
},
marshalAs: `{"id": 1, "name": "test", "nested": {"numbers": [1,2,3], "words": null, "flags": [true,false]}}`,
},
}
for _, c := range cases {
t.Run(c.data, func(t *testing.T) {
var p Parent
err := json.Unmarshal([]byte(c.data), &p)
if c.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), c.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, c.wantRes, p)
b, err := json.Marshal(p)
require.NoError(t, err)
require.JSONEq(t, c.marshalAs, string(b))
})
}
})
}

View file

@ -819,14 +819,25 @@ allow {
# Host Script Result (script execution and output)
##
# Global admins and maintainers can write (execute) scripts (not gitops as this
# is not something that relates to fleetctl apply).
# Global admins and maintainers can write (execute) anonymous scripts (not
# gitops as this is not something that relates to fleetctl apply).
allow {
object.type == "host_script_result"
is_null(object.script_id)
subject.global_role == [admin, maintainer][_]
action == write
}
# Global admins, maintainers, observer_plus and observers can write (execute)
# saved scripts (not gitops as this is not something that relates to fleetctl
# apply).
allow {
object.type == "host_script_result"
not is_null(object.script_id)
subject.global_role == [admin, maintainer, observer, observer_plus][_]
action == write
}
# Global admins, maintainers, observer_plus and observers can read scripts.
allow {
object.type == "host_script_result"
@ -834,15 +845,27 @@ allow {
action == read
}
# Team admin and maintainers can write (execute) scripts for their teams (not
# gitops as this is not something that relates to fleetctl apply).
# Team admin and maintainers can write (execute) anonymous scripts for their
# teams (not gitops as this is not something that relates to fleetctl apply).
allow {
object.type == "host_script_result"
is_null(object.script_id)
not is_null(object.team_id)
team_role(subject, object.team_id) == [admin, maintainer][_]
action == write
}
# Team admins, maintainers, observer_plus and observers can write (execute)
# saved scripts for their teams (not gitops as this is not something that
# relates to fleetctl apply).
allow {
object.type == "host_script_result"
not is_null(object.script_id)
not is_null(object.team_id)
team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_]
action == write
}
# Team admins, maintainers, observer_plus and observers can read scripts for their teams.
allow {
object.type == "host_script_result"
@ -850,3 +873,37 @@ allow {
team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_]
action == read
}
##
# Scripts (saved script)
##
# Global admins and maintainers can write (upload) saved scripts.
allow {
object.type == "script"
subject.global_role == [admin, maintainer][_]
action == write
}
# Global admins, maintainers, observer_plus and observers can read scripts.
allow {
object.type == "script"
subject.global_role == [admin, maintainer, observer, observer_plus][_]
action == read
}
# Team admin and maintainers can write (upload) saved scripts for their teams.
allow {
object.type == "script"
not is_null(object.team_id)
team_role(subject, object.team_id) == [admin, maintainer][_]
action == write
}
# Team admins, maintainers, observer_plus and observers can read scripts for their teams.
allow {
object.type == "script"
not is_null(object.team_id)
team_role(subject, object.team_id) == [admin, maintainer, observer_plus, observer][_]
action == read
}

View file

@ -1818,7 +1818,162 @@ func TestAuthorizeHostScriptResult(t *testing.T) {
t.Parallel()
globalScript := &fleet.HostScriptResult{}
team1Script := &fleet.HostScriptResult{
globalSavedScript := &fleet.HostScriptResult{ScriptID: ptr.Uint(1)}
team1Script := &fleet.HostScriptResult{TeamID: ptr.Uint(1)}
team1SavedScript := &fleet.HostScriptResult{TeamID: ptr.Uint(1), ScriptID: ptr.Uint(1)}
runTestCases(t, []authTestCase{
{user: test.UserNoRoles, object: globalScript, action: write, allow: false},
{user: test.UserNoRoles, object: globalScript, action: read, allow: false},
{user: test.UserNoRoles, object: globalSavedScript, action: write, allow: false},
{user: test.UserNoRoles, object: globalSavedScript, action: read, allow: false},
{user: test.UserNoRoles, object: team1Script, action: write, allow: false},
{user: test.UserNoRoles, object: team1Script, action: read, allow: false},
{user: test.UserNoRoles, object: team1SavedScript, action: write, allow: false},
{user: test.UserNoRoles, object: team1SavedScript, action: read, allow: false},
{user: test.UserAdmin, object: globalScript, action: write, allow: true},
{user: test.UserAdmin, object: globalScript, action: read, allow: true},
{user: test.UserAdmin, object: globalSavedScript, action: write, allow: true},
{user: test.UserAdmin, object: globalSavedScript, action: read, allow: true},
{user: test.UserAdmin, object: team1Script, action: write, allow: true},
{user: test.UserAdmin, object: team1Script, action: read, allow: true},
{user: test.UserAdmin, object: team1SavedScript, action: write, allow: true},
{user: test.UserAdmin, object: team1SavedScript, action: read, allow: true},
{user: test.UserMaintainer, object: globalScript, action: write, allow: true},
{user: test.UserMaintainer, object: globalScript, action: read, allow: true},
{user: test.UserMaintainer, object: globalSavedScript, action: write, allow: true},
{user: test.UserMaintainer, object: globalSavedScript, action: read, allow: true},
{user: test.UserMaintainer, object: team1Script, action: write, allow: true},
{user: test.UserMaintainer, object: team1Script, action: read, allow: true},
{user: test.UserMaintainer, object: team1SavedScript, action: write, allow: true},
{user: test.UserMaintainer, object: team1SavedScript, action: read, allow: true},
{user: test.UserObserver, object: globalScript, action: write, allow: false},
{user: test.UserObserver, object: globalScript, action: read, allow: true},
{user: test.UserObserver, object: globalSavedScript, action: write, allow: true},
{user: test.UserObserver, object: globalSavedScript, action: read, allow: true},
{user: test.UserObserver, object: team1Script, action: write, allow: false},
{user: test.UserObserver, object: team1Script, action: read, allow: true},
{user: test.UserObserver, object: team1SavedScript, action: write, allow: true},
{user: test.UserObserver, object: team1SavedScript, action: read, allow: true},
{user: test.UserObserverPlus, object: globalScript, action: write, allow: false},
{user: test.UserObserverPlus, object: globalScript, action: read, allow: true},
{user: test.UserObserverPlus, object: globalSavedScript, action: write, allow: true},
{user: test.UserObserverPlus, object: globalSavedScript, action: read, allow: true},
{user: test.UserObserverPlus, object: team1Script, action: write, allow: false},
{user: test.UserObserverPlus, object: team1Script, action: read, allow: true},
{user: test.UserObserverPlus, object: team1SavedScript, action: write, allow: true},
{user: test.UserObserverPlus, object: team1SavedScript, action: read, allow: true},
{user: test.UserGitOps, object: globalScript, action: write, allow: false},
{user: test.UserGitOps, object: globalScript, action: read, allow: false},
{user: test.UserGitOps, object: globalSavedScript, action: write, allow: false},
{user: test.UserGitOps, object: globalSavedScript, action: read, allow: false},
{user: test.UserGitOps, object: team1Script, action: write, allow: false},
{user: test.UserGitOps, object: team1Script, action: read, allow: false},
{user: test.UserGitOps, object: team1SavedScript, action: write, allow: false},
{user: test.UserGitOps, object: team1SavedScript, action: read, allow: false},
{user: test.UserTeamAdminTeam1, object: globalScript, action: write, allow: false},
{user: test.UserTeamAdminTeam1, object: globalScript, action: read, allow: false},
{user: test.UserTeamAdminTeam1, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamAdminTeam1, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamAdminTeam1, object: team1Script, action: write, allow: true},
{user: test.UserTeamAdminTeam1, object: team1Script, action: read, allow: true},
{user: test.UserTeamAdminTeam1, object: team1SavedScript, action: write, allow: true},
{user: test.UserTeamAdminTeam1, object: team1SavedScript, action: read, allow: true},
{user: test.UserTeamAdminTeam2, object: globalScript, action: write, allow: false},
{user: test.UserTeamAdminTeam2, object: globalScript, action: read, allow: false},
{user: test.UserTeamAdminTeam2, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamAdminTeam2, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamAdminTeam2, object: team1Script, action: write, allow: false},
{user: test.UserTeamAdminTeam2, object: team1Script, action: read, allow: false},
{user: test.UserTeamAdminTeam2, object: team1SavedScript, action: write, allow: false},
{user: test.UserTeamAdminTeam2, object: team1SavedScript, action: read, allow: false},
{user: test.UserTeamMaintainerTeam1, object: globalScript, action: write, allow: false},
{user: test.UserTeamMaintainerTeam1, object: globalScript, action: read, allow: false},
{user: test.UserTeamMaintainerTeam1, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamMaintainerTeam1, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamMaintainerTeam1, object: team1Script, action: write, allow: true},
{user: test.UserTeamMaintainerTeam1, object: team1Script, action: read, allow: true},
{user: test.UserTeamMaintainerTeam1, object: team1SavedScript, action: write, allow: true},
{user: test.UserTeamMaintainerTeam1, object: team1SavedScript, action: read, allow: true},
{user: test.UserTeamMaintainerTeam2, object: globalScript, action: write, allow: false},
{user: test.UserTeamMaintainerTeam2, object: globalScript, action: read, allow: false},
{user: test.UserTeamMaintainerTeam2, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamMaintainerTeam2, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamMaintainerTeam2, object: team1Script, action: write, allow: false},
{user: test.UserTeamMaintainerTeam2, object: team1Script, action: read, allow: false},
{user: test.UserTeamMaintainerTeam2, object: team1SavedScript, action: write, allow: false},
{user: test.UserTeamMaintainerTeam2, object: team1SavedScript, action: read, allow: false},
{user: test.UserTeamObserverTeam1, object: globalScript, action: write, allow: false},
{user: test.UserTeamObserverTeam1, object: globalScript, action: read, allow: false},
{user: test.UserTeamObserverTeam1, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamObserverTeam1, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamObserverTeam1, object: team1Script, action: write, allow: false},
{user: test.UserTeamObserverTeam1, object: team1Script, action: read, allow: true},
{user: test.UserTeamObserverTeam1, object: team1SavedScript, action: write, allow: true},
{user: test.UserTeamObserverTeam1, object: team1SavedScript, action: read, allow: true},
{user: test.UserTeamObserverTeam2, object: globalScript, action: write, allow: false},
{user: test.UserTeamObserverTeam2, object: globalScript, action: read, allow: false},
{user: test.UserTeamObserverTeam2, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamObserverTeam2, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamObserverTeam2, object: team1Script, action: write, allow: false},
{user: test.UserTeamObserverTeam2, object: team1Script, action: read, allow: false},
{user: test.UserTeamObserverTeam2, object: team1SavedScript, action: write, allow: false},
{user: test.UserTeamObserverTeam2, object: team1SavedScript, action: read, allow: false},
{user: test.UserTeamObserverPlusTeam1, object: globalScript, action: write, allow: false},
{user: test.UserTeamObserverPlusTeam1, object: globalScript, action: read, allow: false},
{user: test.UserTeamObserverPlusTeam1, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamObserverPlusTeam1, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamObserverPlusTeam1, object: team1Script, action: write, allow: false},
{user: test.UserTeamObserverPlusTeam1, object: team1Script, action: read, allow: true},
{user: test.UserTeamObserverPlusTeam1, object: team1SavedScript, action: write, allow: true},
{user: test.UserTeamObserverPlusTeam1, object: team1SavedScript, action: read, allow: true},
{user: test.UserTeamObserverPlusTeam2, object: globalScript, action: write, allow: false},
{user: test.UserTeamObserverPlusTeam2, object: globalScript, action: read, allow: false},
{user: test.UserTeamObserverPlusTeam2, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamObserverPlusTeam2, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamObserverPlusTeam2, object: team1Script, action: write, allow: false},
{user: test.UserTeamObserverPlusTeam2, object: team1Script, action: read, allow: false},
{user: test.UserTeamObserverPlusTeam2, object: team1SavedScript, action: write, allow: false},
{user: test.UserTeamObserverPlusTeam2, object: team1SavedScript, action: read, allow: false},
{user: test.UserTeamGitOpsTeam1, object: globalScript, action: write, allow: false},
{user: test.UserTeamGitOpsTeam1, object: globalScript, action: read, allow: false},
{user: test.UserTeamGitOpsTeam1, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamGitOpsTeam1, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamGitOpsTeam1, object: team1Script, action: write, allow: false},
{user: test.UserTeamGitOpsTeam1, object: team1Script, action: read, allow: false},
{user: test.UserTeamGitOpsTeam1, object: team1SavedScript, action: write, allow: false},
{user: test.UserTeamGitOpsTeam1, object: team1SavedScript, action: read, allow: false},
{user: test.UserTeamGitOpsTeam2, object: globalScript, action: write, allow: false},
{user: test.UserTeamGitOpsTeam2, object: globalScript, action: read, allow: false},
{user: test.UserTeamGitOpsTeam2, object: globalSavedScript, action: write, allow: false},
{user: test.UserTeamGitOpsTeam2, object: globalSavedScript, action: read, allow: false},
{user: test.UserTeamGitOpsTeam2, object: team1Script, action: write, allow: false},
{user: test.UserTeamGitOpsTeam2, object: team1Script, action: read, allow: false},
{user: test.UserTeamGitOpsTeam2, object: team1SavedScript, action: write, allow: false},
{user: test.UserTeamGitOpsTeam2, object: team1SavedScript, action: read, allow: false},
})
}
func TestAuthorizeScript(t *testing.T) {
t.Parallel()
globalScript := &fleet.Script{}
team1Script := &fleet.Script{
TeamID: ptr.Uint(1),
}
runTestCases(t, []authTestCase{

View file

@ -18,7 +18,6 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
@ -4366,109 +4365,3 @@ func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []strin
return result, nil
}
func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
const (
insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_contents, output) VALUES (?, ?, ?, '')`
getStmt = `SELECT id, host_id, execution_id, script_contents, created_at FROM host_script_results WHERE id = ?`
)
execID := uuid.New().String()
result, err := ds.writer(ctx).ExecContext(ctx, insStmt,
request.HostID,
execID,
request.ScriptContents,
)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "new host script execution request")
}
var script fleet.HostScriptResult
id, _ := result.LastInsertId()
if err := ds.writer(ctx).GetContext(ctx, &script, getStmt, id); err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting the created host script result to return")
}
return &script, nil
}
func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error {
const updStmt = `
UPDATE host_script_results SET
output = ?,
runtime = ?,
exit_code = ?
WHERE
host_id = ? AND
execution_id = ?`
const maxOutputRuneLen = 10000
output := result.Output
if len(output) > utf8.UTFMax*maxOutputRuneLen {
// truncate the bytes as we know the output is too long, no point
// converting more bytes than needed to runes.
output = output[len(output)-(utf8.UTFMax*maxOutputRuneLen):]
}
if utf8.RuneCountInString(output) > maxOutputRuneLen {
outputRunes := []rune(output)
output = string(outputRunes[len(outputRunes)-maxOutputRuneLen:])
}
if _, err := ds.writer(ctx).ExecContext(ctx, updStmt,
output,
result.Runtime,
result.ExitCode,
result.HostID,
result.ExecutionID,
); err != nil {
return ctxerr.Wrap(ctx, err, "update host script result")
}
return nil
}
func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) {
const listStmt = `
SELECT
id,
host_id,
execution_id,
script_contents
FROM
host_script_results
WHERE
host_id = ? AND
exit_code IS NULL AND
created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)`
var results []*fleet.HostScriptResult
seconds := int(ignoreOlder.Seconds())
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, hostID, seconds); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list pending host script results")
}
return results, nil
}
func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
const getStmt = `
SELECT
id,
host_id,
execution_id,
script_contents,
output,
runtime,
exit_code,
created_at
FROM
host_script_results
WHERE
execution_id = ?`
var result fleet.HostScriptResult
if err := sqlx.GetContext(ctx, ds.reader(ctx), &result, getStmt, execID); err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("HostScriptResult").WithName(execID))
}
return nil, ctxerr.Wrap(ctx, err, "get host script result")
}
return &result, nil
}

View file

@ -153,7 +153,6 @@ func TestHosts(t *testing.T) {
{"ListHostsLiteByUUIDs", testHostsListHostsLiteByUUIDs},
{"GetMatchingHostSerials", testGetMatchingHostSerials},
{"ListHostsLiteByIDs", testHostsListHostsLiteByIDs},
{"HostScriptResult", testHostScriptResult},
{"ListHostsWithPagination", testListHostsWithPagination},
}
for _, c := range cases {
@ -7415,121 +7414,6 @@ func testHostsListHostsLiteByIDs(t *testing.T, ds *Datastore) {
}
}
func testHostScriptResult(t *testing.T, ds *Datastore) {
ctx := context.Background()
// no script saved yet
pending, err := ds.ListPendingHostScriptExecutions(ctx, 1, time.Second)
require.NoError(t, err)
require.Empty(t, pending)
_, err = ds.GetHostScriptExecutionResult(ctx, "abc")
require.Error(t, err)
var nfe *notFoundError
require.ErrorAs(t, err, &nfe)
// create a createdScript execution request
createdScript, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
HostID: 1,
ScriptContents: "echo",
})
require.NoError(t, err)
require.NotZero(t, createdScript.ID)
require.NotEmpty(t, createdScript.ExecutionID)
require.Equal(t, uint(1), createdScript.HostID)
require.NotEmpty(t, createdScript.ExecutionID)
require.Equal(t, "echo", createdScript.ScriptContents)
require.Nil(t, createdScript.ExitCode)
require.Empty(t, createdScript.Output)
// the script execution is now listed as pending for this host
pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second)
require.NoError(t, err)
require.Len(t, pending, 1)
require.Equal(t, createdScript.ID, pending[0].ID)
// waiting for a second and an ignore of 0s ignores this script
time.Sleep(time.Second)
pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 0)
require.NoError(t, err)
require.Empty(t, pending)
// record a result for this execution
err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: 1,
ExecutionID: createdScript.ExecutionID,
Output: "foo",
Runtime: 2,
ExitCode: 0,
})
require.NoError(t, err)
// it is not pending anymore
pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second)
require.NoError(t, err)
require.Empty(t, pending)
// the script result can be retrieved
script, err := ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID)
require.NoError(t, err)
expectScript := *createdScript
expectScript.Output = "foo"
expectScript.Runtime = 2
expectScript.ExitCode = ptr.Int64(0)
require.Equal(t, &expectScript, script)
// create another script execution request
createdScript, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
HostID: 1,
ScriptContents: "echo2",
})
require.NoError(t, err)
require.NotZero(t, createdScript.ID)
require.NotEmpty(t, createdScript.ExecutionID)
// the script result can be retrieved even if it has no result yet
script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID)
require.NoError(t, err)
require.Equal(t, createdScript, script)
// record a result for this execution, with an output that is too large
largeOutput := strings.Repeat("a", 1000) +
strings.Repeat("b", 1000) +
strings.Repeat("c", 1000) +
strings.Repeat("d", 1000) +
strings.Repeat("e", 1000) +
strings.Repeat("f", 1000) +
strings.Repeat("g", 1000) +
strings.Repeat("h", 1000) +
strings.Repeat("i", 1000) +
strings.Repeat("j", 1000) +
strings.Repeat("k", 1000)
expectedOutput := strings.Repeat("b", 1000) +
strings.Repeat("c", 1000) +
strings.Repeat("d", 1000) +
strings.Repeat("e", 1000) +
strings.Repeat("f", 1000) +
strings.Repeat("g", 1000) +
strings.Repeat("h", 1000) +
strings.Repeat("i", 1000) +
strings.Repeat("j", 1000) +
strings.Repeat("k", 1000)
err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: 1,
ExecutionID: createdScript.ExecutionID,
Output: largeOutput,
Runtime: 10,
ExitCode: 1,
})
require.NoError(t, err)
// the script result can be retrieved
script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID)
require.NoError(t, err)
require.Equal(t, expectedOutput, script.Output)
}
func testListHostsWithPagination(t *testing.T, ds *Datastore) {
ctx := context.Background()

View file

@ -0,0 +1,28 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20231009094541, Down_20231009094541)
}
func Up_20231009094541(tx *sql.Tx) error {
sql := `
ALTER TABLE
host_script_results
ADD INDEX
idx_host_script_created_at (host_id, script_id, created_at);
`
if _, err := tx.Exec(sql); err != nil {
return fmt.Errorf("add index host_script_created_at to host_script_results: %w", err)
}
return nil
}
func Down_20231009094541(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,18 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20231009094542, Down_20231009094542)
}
func Up_20231009094542(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE scripts ADD UNIQUE KEY idx_scripts_team_name (team_id, name)`)
return err
}
func Down_20231009094542(tx *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show more