mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
merge main into 7766 (#14468)
This commit is contained in:
commit
ddd77efa9d
134 changed files with 7061 additions and 1406 deletions
93
.github/dependabot.yml
vendored
93
.github/dependabot.yml
vendored
|
|
@ -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
|
||||
2
.github/workflows/example-workflow.yaml
vendored
2
.github/workflows/example-workflow.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
changes/10102-host-script-details-api
Normal file
1
changes/10102-host-script-details-api
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added `GET /hosts/{id}/scripts` endpoint to retrieve status details of saved scripts applicable to a host.
|
||||
1
changes/13654-script-activity-logging
Normal file
1
changes/13654-script-activity-logging
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added activity logging for add, delete, and edit scripts.
|
||||
1
changes/bug-11314-disable-multicursor-editor
Normal file
1
changes/bug-11314-disable-multicursor-editor
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Fleet UI: Disable multicursor editing for SQL editors
|
||||
1
changes/issue-14406-implement-script-host-details
Normal file
1
changes/issue-14406-implement-script-host-details
Normal file
|
|
@ -0,0 +1 @@
|
|||
- implement scripts tab and table for host details page
|
||||
2
changes/issue-9829-scripts-api
Normal file
2
changes/issue-9829-scripts-api
Normal 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.
|
||||
1
changes/issue-9831-implement-scripts-page
Normal file
1
changes/issue-9831-implement-scripts-page
Normal file
|
|
@ -0,0 +1 @@
|
|||
- implement UI for scripts on the controls page
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ func TestRunScriptCommand(t *testing.T) {
|
|||
scriptPath: func() string {
|
||||
return writeTmpScriptContents(t, maxChars, ".sh")
|
||||
},
|
||||
expectErrMsg: `Script is too large. It’s 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",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
"metadata_url": "",
|
||||
"idp_name": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": null
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ spec:
|
|||
metadata: ""
|
||||
metadata_url: ""
|
||||
entity_id: ""
|
||||
scripts: null
|
||||
org_info:
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@
|
|||
"idp_name": ""
|
||||
}
|
||||
},
|
||||
"scripts": null,
|
||||
"sso_settings": {
|
||||
"enable_jit_provisioning": false,
|
||||
"enable_jit_role_sync": false,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ spec:
|
|||
metadata: ""
|
||||
metadata_url: ""
|
||||
entity_id: ""
|
||||
scripts: null
|
||||
license:
|
||||
expiration: "0001-01-01T00:00:00Z"
|
||||
tier: free
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ spec:
|
|||
metadata: ""
|
||||
metadata_url: ""
|
||||
entity_id: ""
|
||||
scripts: null
|
||||
org_info:
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ spec:
|
|||
metadata: ""
|
||||
metadata_url: ""
|
||||
entity_id: ""
|
||||
scripts: null
|
||||
org_info:
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@ spec:
|
|||
macos_updates:
|
||||
deadline: null
|
||||
minimum_version: null
|
||||
scripts: null
|
||||
name: tm1
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
24
ee/fleetd-chrome/package-lock.json
generated
24
ee/fleetd-chrome/package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
441
ee/server/service/scripts.go
Normal file
441
ee/server/service/scripts.go
Normal 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("Couldn’t 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-small;
|
||||
p {
|
||||
margin: $pad-small 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__cta {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ interface IDropdownCellProps {
|
|||
|
||||
const DropdownCell = ({
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: IDropdownCellProps): JSX.Element => {
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
}
|
||||
|
||||
.input-field {
|
||||
padding-right: 16%;
|
||||
|
||||
&--disabled {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 don’t 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;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.mac-os-scripts {
|
||||
font-size: $x-small;
|
||||
|
||||
&__description {
|
||||
margin: $pad-xxlarge 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./MacOSScripts";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
165
frontend/pages/ManageControlsPage/Scripts/Scripts.tsx
Normal file
165
frontend/pages/ManageControlsPage/Scripts/Scripts.tsx
Normal 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;
|
||||
11
frontend/pages/ManageControlsPage/Scripts/_styles.scss
Normal file
11
frontend/pages/ManageControlsPage/Scripts/_styles.scss
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.scripts {
|
||||
font-size: $x-small;
|
||||
|
||||
&__description {
|
||||
margin: $pad-xxlarge 0;
|
||||
}
|
||||
|
||||
&__premium-feature-message {
|
||||
margin-top: $pad-xxxlarge;
|
||||
}
|
||||
}
|
||||
|
|
@ -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", "Couldn’t 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"
|
||||
>
|
||||
|
|
@ -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`}
|
||||
>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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", "Couldn’t 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"
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ScriptListPagination";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.script-uploader {
|
||||
margin-top: $pad-xxlarge;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { AxiosResponse } from "axios";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
|
||||
const UPLOAD_ERROR_MESSAGES = {
|
||||
default: {
|
||||
message: "Couldn’t 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;
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ScriptUploader";
|
||||
1
frontend/pages/ManageControlsPage/Scripts/index.ts
Normal file
1
frontend/pages/ManageControlsPage/Scripts/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Scripts";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
128
frontend/pages/hosts/details/cards/Scripts/Scripts.tsx
Normal file
128
frontend/pages/hosts/details/cards/Scripts/Scripts.tsx
Normal 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;
|
||||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
};
|
||||
22
frontend/pages/hosts/details/cards/Scripts/_styles.scss
Normal file
22
frontend/pages/hosts/details/cards/Scripts/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.script-status-cell {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ScriptStatusCell";
|
||||
1
frontend/pages/hosts/details/cards/Scripts/index.ts
Normal file
1
frontend/pages/hosts/details/cards/Scripts/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Scripts";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue