diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ced61d69..2d4cf9147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,28 @@ changes since the last release, see the [diff on GitHub][unreleased]. ### Added +- Added command plug-ins, which add a top-level command to `dsc` without being part of the DSC + executable. A plug-in is defined by a plug-in manifest, a file whose name ends with + `.dsc.plugin.json`, `.dsc.plugin.yaml`, or `.dsc.plugin.yml`, or by the new `plugins` property + of a manifest list. When the first argument to `dsc` isn't a built-in command, DSC runs the + plug-in that defines the command with the remaining arguments and the standard streams of `dsc`, + and exits with the plug-in's exit code. Built-in commands always take precedence, and DSC only + searches for plug-ins when it needs them. The new `dsc plugin list` command lists the discovered + plug-ins, the top-level help lists plug-in commands after the built-in commands, + `dsc help ` displays the help of a plug-in command, and the scripts from + `dsc completer` complete the names of plug-in commands. DSC tells a plug-in how it was invoked + with the `DSC_EXE`, `DSC_VERSION`, `DSC_PLUGIN_COMMAND`, and `DSC_PLUGIN_DIR` environment + variables and makes the options specified before the command available as `DSC_TRACE_LEVEL`, + `DSC_TRACE_FORMAT`, and `DSC_PROGRESS_FORMAT`. The new `plugins.enabled` setting turns plug-ins + off, and the `dsc schema` command exposes the new `plugin-manifest` schema. + +
Related work items + + - Issues: [#461][#461] + - PRs: + +
+ - Added the `Microsoft.Windows/AuditPolicy`, `Microsoft.Windows/AuditPolicyList`, and `Microsoft.Windows/AuditPolicyOption` built-in resources for managing the Windows advanced audit policy natively, as a replacement for the `AuditPolicyDsc` PowerShell module. `AuditPolicy` diff --git a/Cargo.lock b/Cargo.lock index 5d5522668..8f819ec35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "tempfile", "test-case", "thiserror 2.0.21", "tokio", diff --git a/docs/reference/cli/completer/index.md b/docs/reference/cli/completer/index.md index b86ae1e57..1d8798cd3 100644 --- a/docs/reference/cli/completer/index.md +++ b/docs/reference/cli/completer/index.md @@ -31,6 +31,11 @@ given shell. DSC can generate completion scripts for the following shells: The output for this command is the script itself. To register completions for DSC, execute the script. +The script completes the commands that [plug-ins][06] provide in the same way as the built-in +commands. DSC discovers the available plug-ins when it generates the script, so generate the script +again after you install or remove a plug-in. The script doesn't complete the arguments of a plug-in +command, except for file paths. + > [!WARNING] > Always review scripts before executing them, especially in an elevated execution context. @@ -111,3 +116,4 @@ ShortSyntax : -h [03]: https://fishshell.com/ [04]: /powershell/scripting/overview [05]: https://zsh.sourceforge.io/ +[06]: ../index.md#plug-in-commands diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index f0823aa37..78ec4b0df 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -47,6 +47,15 @@ For more information, see [dsc function][03] The `mcp` command starts DSC as a Model Context Protocol (MCP) server. For more information, see [dsc mcp][04]. +### plugin + +The `plugin` command manages DSC command plug-ins. You can use it to: + +- List the available plug-ins with the command each one provides. + +For more information, see [dsc plugin][07]. For information about running a command that a plug-in +provides, see [Plug-in commands](#plug-in-commands). + ### resource The `resource` command manages a DSC Resource. You can use it to: @@ -80,6 +89,65 @@ gets the help for the `config set` subcommand. You can also use the [--help](#--help) option on a command to display the help information. For example, `dsc config --help` or `dsc config set --help`. +For a command that a plug-in provides, `dsc help ` runs the plug-in with the `--help` +option, so the plug-in displays its own help. + +## Plug-in commands + +A command plug-in adds a command to `dsc` without being part of the DSC executable. When the first +argument to `dsc` isn't one of the built-in [commands](#commands), DSC looks for a plug-in that +defines a command with that name and runs it: + +```sh +dsc [...] +``` + +For example, a plug-in that defines the command `lint` is invoked as `dsc lint`. Use the +[dsc plugin list][08] command to see the available plug-ins, or `dsc --help`, which lists the +plug-in commands after the built-in commands. + +A plug-in is defined by a plug-in manifest, a file whose name ends with `.dsc.plugin.json`, +`.dsc.plugin.yaml`, or `.dsc.plugin.yml`. DSC discovers plug-in manifests in the same directories +as resource and extension manifests. For more information about defining a plug-in, see the +[DSC plug-in manifest schema reference][09]. + +DSC runs a plug-in command with the following behavior: + +- Built-in commands always take precedence. A plug-in can't replace a built-in command. +- DSC only searches for plug-ins when the command isn't built in, when it displays the top-level + help, when it lists plug-ins, and when it generates a completion script. Built-in commands never + search for plug-ins. +- DSC passes every argument after the command name to the plug-in as written, including `--help`. + DSC doesn't interpret those arguments. +- The plug-in runs in the current working directory with the standard input, output, and error + streams of `dsc`. DSC doesn't modify the output of the plug-in. +- DSC exits with the exit code of the plug-in. The meaning of an exit code is defined by the + plug-in, not by the [exit codes](#exit-codes) of `dsc`. +- When more than one plug-in defines the same command name, the plug-in DSC discovers first + provides the command and DSC writes a warning that names the others, which are shadowed. To run + a specific plug-in, shadowed or not, specify its fully qualified type name instead of the command + name, like `dsc OpenDSC/Lint`. DSC doesn't write the warning when you use the type name. +- When no plug-in defines the command, DSC reports the command as an unrecognized subcommand and + exits with code `2`. + +Options for `dsc` itself, like [`--trace-level`](#--trace-level), must be specified before the +command name. DSC makes them available to the plug-in as environment variables. DSC sets the +following environment variables for a plug-in: + +| Environment variable | Value | +| :------------------------- | :---------------------------------------------------------------------------------- | +| `DSC_EXE` | The absolute path to the `dsc` executable that started the plug-in. | +| `DSC_VERSION` | The semantic version of that `dsc` executable. | +| `DSC_PLUGIN_COMMAND` | The name the plug-in was invoked as: its command name or its type name. | +| `DSC_PLUGIN_DIR` | The directory that contains the plug-in manifest. | +| `DSC_TRACE_LEVEL` | The trace level in effect, from the `--trace-level` option or the settings. | +| `DSC_TRACE_FORMAT` | The trace format in effect, from the `--trace-format` option or the settings. | +| `DSC_PROGRESS_FORMAT` | The value of the `--progress-format` option. Only set when the option is specified. | +| `DSC_IGNORE_SETTINGS_FILE` | Set to `1` or `0` when the `--ignore-settings-file` option is specified. | + +To prevent DSC from discovering and running plug-ins, set `plugins.enabled` to `false` in a DSC +settings file. When the setting is defined in the policy settings file, users can't override it. + ## Options ### -l, --trace-level @@ -230,7 +298,7 @@ trace level: The `dsc` command uses semantic exit codes. Each exit code represents a different result for the execution of the command. -| Exit Code | Meaning | +| Exit Code | Meaning | | :-------: | :------------------------------------------------------------------------------------------------------ | | `0` | The command executed successfully without any errors. | | `1` | The command failed because it received invalid arguments. | @@ -252,3 +320,6 @@ file, ensure that the input is encoded as UTF-8. [04]: server/index.md [05]: resource/index.md [06]: schema/index.md +[07]: plugin/index.md +[08]: plugin/list.md +[09]: ../schemas/plugin/manifest/root.md diff --git a/docs/reference/cli/plugin/index.md b/docs/reference/cli/plugin/index.md new file mode 100644 index 000000000..22e8d422b --- /dev/null +++ b/docs/reference/cli/plugin/index.md @@ -0,0 +1,72 @@ +--- +description: Command line reference for the 'dsc plugin' command +ms.date: 11/13/2026 +ms.topic: reference +title: dsc plugin +--- + +# dsc plugin + +## Synopsis + +Operations on DSC plug-ins. + +## Syntax + +```sh +dsc plugin [Options] +``` + +## Description + +The `dsc plugin` command contains a subcommand for listing DSC command plug-ins. + +A command plug-in adds a top-level command to DSC without being part of the DSC executable. For +example, a plug-in that defines the command `lint` is invoked as `dsc lint`. For more information +about how DSC discovers and runs plug-ins, see [Plug-in commands][01]. + +## Commands + +### list + +The `list` command returns the list of available DSC plug-ins with an optional filter. For more +information, see [dsc plugin list][02]. + +### help + +The `help` command returns help information for this command or a subcommand. + +To get the help for a command or subcommand, use the syntax: + +```sh +dsc plugin help [] +``` + +For example, `dsc plugin help` gets the help for this command. `dsc plugin help list` gets the help +for the `list` subcommand. + +You can also use the [--help](#--help) option on the command or subcommand to display the help +information. For example, `dsc plugin --help` or `dsc plugin list --help`. + +To get the help for a command that a plug-in provides, use `dsc help ` or +`dsc --help`. The plug-in prints its own help. + +## Options + +### -h, --help + + + + +Displays the help for the current command or subcommand. When you specify this option, the +application ignores all other options and arguments. + +```yaml +Type : boolean +Mandatory : false +LongSyntax : --help +ShortSyntax : -h +``` + +[01]: ../index.md#plug-in-commands +[02]: ./list.md diff --git a/docs/reference/cli/plugin/list.md b/docs/reference/cli/plugin/list.md new file mode 100644 index 000000000..bdffb1847 --- /dev/null +++ b/docs/reference/cli/plugin/list.md @@ -0,0 +1,206 @@ +--- +description: Command line reference for the 'dsc plugin list' command +ms.date: 11/13/2026 +ms.topic: reference +title: dsc plugin list +--- + +# dsc plugin list + +## Synopsis + +Retrieves the list of available DSC plug-ins with an optional filter. + +## Syntax + +```sh +dsc plugin list [Options] +``` + +## Description + +The `list` subcommand searches for available DSC command plug-ins and returns their information. +DSC discovers plug-ins by searching the directories it searches for resource and extension +manifests, which by default are the directory that contains `dsc` and the directories in the `PATH` +or [`DSC_RESOURCE_PATH`][01] environment variable. DSC reads the files whose name ends with +`.dsc.plugin.json`, `.dsc.plugin.yaml`, or `.dsc.plugin.yml`, and the `plugins` property of manifest +lists. + +DSC returns the plug-ins in the order it discovered them. That order decides which plug-in provides +a command when more than one plug-in defines the same command name: the plug-in DSC discovered first +provides the command, and the others are reported as shadowed. + +DSC doesn't return a plug-in when: + +- The manifest can't be loaded or isn't valid. DSC writes a warning that names the file. +- The `condition` of the manifest evaluates to `false`. +- The command name of the plug-in is the name or the alias of a built-in command, like `config`. + DSC writes a warning, because built-in commands always take precedence and the plug-in could + never run. +- Another manifest defines a higher version of the same plug-in type. + +When the `plugins.enabled` setting is `false`, DSC doesn't discover plug-ins and the command +returns nothing. + +If the command includes the `PLUGIN_NAME` argument, DSC filters the list of discovered plug-ins +before returning them. Filters are always applied after plug-in discovery. + +Listing plug-ins never runs a plug-in. + +## Examples + +### Example 1 - List all plug-ins + +Without any filters, the command returns every discovered DSC plug-in. + +```sh +dsc plugin list +``` + +```Output +Command Type Version Description +---------------------------------------------------------------------------------------------------- +lint Microsoft.DSC/Lint 3.4.0 Check configuration documents for problems without applying them +graph Contoso.DSC/Graph 0.1.0 Show the dependency graph of a configuration document +``` + +### Example 2 - List plug-ins with a matching type name + +When the `PLUGIN_NAME` argument includes a wildcard, the command returns every plug-in with a +matching type name. + +```sh +dsc plugin list Contoso* +``` + +```Output +Command Type Version Description +------------------------------------------------------------------------------------- +graph Contoso.DSC/Graph 0.1.0 Show the dependency graph of a configuration document +``` + +### Example 3 - Find out which plug-in provides a command + +When two plug-ins define the same command name, the summary table marks the plug-in that doesn't +provide the command as shadowed. + +```sh +dsc plugin list +``` + +```Output +Command Type Version Description +-------------------------------------------------------------------------------------------------- +graph Contoso.DSC/Graph 0.1.0 Show the dependency graph of a configuration document +graph (shadowed) Tailspin.DSC/Graph 1.2.0 Render a configuration document as a diagram +``` + +To see where each plug-in is defined, return the data as JSON or YAML. The `shadowed` property +indicates whether the plug-in is shadowed and the `path` property is the path to its manifest. + +To run a shadowed plug-in, specify its type name instead of the command name, like +`dsc Tailspin.DSC/Graph`. + +```sh +dsc plugin list --output-format yaml +``` + +## Arguments + +### PLUGIN_NAME + +Specifies an optional filter to apply for the type names of discovered DSC plug-ins. The filter can +include wildcards (`*`). The filter isn't case-sensitive. + +When this argument is specified, DSC filters the results to include only plug-ins where the plug-in +type name matches the filter. + +For example, specifying the filter `Microsoft.*` returns only the plug-ins published by Microsoft. +Specifying the filter `*Lint*` returns any plug-in with the string `Lint` in its type name, +regardless of the casing. + +The filter applies to the type name of the plug-in, not to the name of the command it provides. + +The value for this argument must be either a valid [fully qualified type name][02] or a valid +wildcard type name. + +```yaml +Type : string +Mandatory : false +``` + +## Options + +### -o, --output-format + + + + +The `--output-format` option controls which format DSC uses for the data the command returns. The +available formats are: + +- `json` to emit the data as a [JSON Line][03]. +- `pretty-json` to emit the data as JSON with newlines, indentation, and spaces for readability. +- `yaml` to emit the data as YAML. +- `table-no-truncate` to emit the data as a summary table without truncating each line to the + current console width. + +The default output format depends on whether DSC detects that the output is being redirected or +captured as a variable: + +- If the command isn't being redirected or captured, DSC displays the output as a summary table + described in the [Output](#output) section of this document. +- If the command output is redirected or captured, DSC emits the data as the `json` format to + stdout. + +When you use this option, DSC uses the specified format regardless of whether the command is being +redirected or captured. + +```yaml +Type : string +Mandatory : false +ValidValues : [json, pretty-json, yaml, table-no-truncate] +LongSyntax : --output-format +ShortSyntax : -o +``` + +### -h, --help + + + + +Displays the help for the current command or subcommand. When you specify this option, the +application ignores all other options and arguments. + +```yaml +Type : boolean +Mandatory : false +LongSyntax : --help +ShortSyntax : -h +``` + +## Output + +This command returns a formatted array containing an object for each plug-in that includes the +plug-in's type, version, command name, manifest, and other metadata. For more information, see +[dsc plugin list result schema][04]. + +If the output of the command isn't captured or redirected, it displays in the console by default as +a summary table for the returned plug-ins. The summary table includes the following columns, +displayed in the listed order: + +- **Command** - The name of the command the plug-in defines, which you specify after `dsc` to run + the plug-in. The name is followed by `(shadowed)` when a plug-in that DSC discovered earlier + provides the same command. +- **Type** - The fully qualified type name of the plug-in. +- **Version** - The semantic version of the plug-in. +- **Description** - The short description of the command. + +For more information about the formatting of the output data, see the +[--output-format option](#--output-format). + + +[01]: ../index.md#dsc_resource_path +[02]: ../../schemas/definitions/resourceType.md +[03]: https://jsonlines.org/ +[04]: ../../schemas/outputs/plugin/list.md diff --git a/docs/reference/cli/schema/index.md b/docs/reference/cli/schema/index.md index 1de0753b5..6968ab157 100644 --- a/docs/reference/cli/schema/index.md +++ b/docs/reference/cli/schema/index.md @@ -189,6 +189,7 @@ schema the application returns: - `get-result` ([reference documentation][ai]) - Represents the output from the `dsc resource get` command. - `include` ([reference documentation][aj]) - represents the instance schema for the built-in `Microsoft.DSC/Include` importer resource. - `manifest-list` ([reference documentation][ak]) +- `plugin-manifest` ([reference documentation][as]) - Validates a plug-in's manifest. DSC skips a plug-in with an invalid manifest and writes a warning. - `resolve-result` ([reference documentation][al]) - Represents the resolved form of the configuration document an `importer` resource emits. - `resource` ([reference documentation][am]) - Represents an instance of a resource in a configuration document. - `resource-manifest` ([reference documentation][an]) - Validates a command resource's manifest. If the manifest is invalid, DSC raises an error. @@ -211,6 +212,7 @@ ValidValues: [ get-result include manifest-list + plugin-manifest resolve-result resource-manifest restart-required @@ -297,3 +299,4 @@ For more information about the formatting of the output data, see the [ap]: ../../schemas/outputs/resource/set.md [aq]: ../../schemas/outputs/resource/test.md [ar]: https://jsonlines.org/ +[as]: ../../schemas/plugin/manifest/root.md diff --git a/docs/reference/schemas/outputs/plugin/list.md b/docs/reference/schemas/outputs/plugin/list.md new file mode 100644 index 000000000..8977c2b18 --- /dev/null +++ b/docs/reference/schemas/outputs/plugin/list.md @@ -0,0 +1,183 @@ +--- +description: JSON schema reference for the data returned by the 'dsc plugin list' command. +ms.date: 11/13/2026 +ms.topic: reference +title: dsc plugin list result schema reference +--- + +# dsc plugin list result schema reference + +## Synopsis + +The result output from the `dsc plugin list` command. + +## Metadata + +```yaml +SchemaDialect: https://json-schema.org/draft/2020-12/schema +SchemaID: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.4.0/outputs/plugin/list.json +Type: object +``` + +## Description + +The output from the `dsc plugin list` command includes a representation of discovered DSC command +plug-ins as a series of [JSON Lines][01]. This schema describes the JSON object returned for each +plug-in. + +DSC returns the plug-ins in the order it discovered them, including the plug-ins that are +[shadowed](#shadowed). + +## Examples + +### Example 1 - Plug-in that provides a command + +This example shows the output for a plug-in defined in the file `/opt/dsc/dsclint.dsc.plugin.json`. + +```json +{ + "type": "Microsoft.DSC/Lint", + "version": "3.4.0", + "command": "lint", + "description": "Check configuration documents for problems without applying them", + "deprecationMessage": null, + "path": "/opt/dsc/dsclint.dsc.plugin.json", + "directory": "/opt/dsc", + "shadowed": false, + "manifest": { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Microsoft.DSC/Lint", + "version": "3.4.0", + "description": "Check configuration documents for problems without applying them", + "command": { + "name": "lint", + "executable": "dsclint" + } + } +} +``` + +## Required properties + +Each plug-in in the output always includes these properties: + +- [type](#type) +- [version](#version) +- [command](#command) +- [description](#description) +- [deprecationMessage](#deprecationmessage) +- [path](#path) +- [directory](#directory) +- [shadowed](#shadowed) +- [manifest](#manifest) + +## Properties + +### type + +Identifies the fully qualified type name of the plug-in. Plug-in type names use the same syntax as +resource type names. For more information, see +[DSC Resource fully qualified type name schema reference][02]. + +```yaml +Type: string +Required: true +Pattern: ^\w+(\.\w+)*\/\w+$ +``` + +### version + +Represents the current version of the plug-in as a valid semantic version (SemVer) string. When DSC +discovers more than one manifest with the same type, it only returns the plug-in with the highest +version. + +```yaml +Type: string +Required: true +Pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ +``` + +### command + +Indicates the name of the command the plug-in adds to DSC. For example, a plug-in with the command +`lint` is invoked as `dsc lint`. + +```yaml +Type: string +Required: true +Pattern: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ +``` + +### description + +Defines a short description of the command. The value is `null` when the manifest doesn't define a +description. + +```yaml +Type: [string, 'null'] +Required: true +``` + +### deprecationMessage + +Indicates that the plug-in is deprecated. The value is `null` when the plug-in isn't deprecated. +When the value is a string, DSC writes the message as a warning each time the command is invoked. + +```yaml +Type: [string, 'null'] +Required: true +``` + +### path + +Represents the path to the file that defines the plug-in manifest. For a plug-in defined in a +manifest list, this is the path to the manifest list. + +```yaml +Type: string +Required: true +``` + +### directory + +Represents the path to the folder that contains the plug-in manifest. DSC resolves the +manifest-relative path arguments of the plug-in against this folder and passes it to the plug-in in +the `DSC_PLUGIN_DIR` environment variable. + +```yaml +Type: string +Required: true +``` + +### shadowed + +Indicates whether another plug-in that DSC discovered earlier provides the same command. DSC never +runs a shadowed plug-in for its command name. + +Plug-ins are shadowed when plug-ins with different types define the same command name. DSC searches +the directories for manifests in a fixed order, and the plug-in in the first directory provides the +command. When both plug-ins are in the same directory, the plug-in whose type name sorts first +provides the command. + +To run a shadowed plug-in, specify its type name instead of the command name, like +`dsc OpenDSC/Lint`. + +```yaml +Type: boolean +Required: true +``` + +### manifest + +Represents the values defined in the plug-in manifest that defines the plug-in. For more +information on the value for this property, see [DSC plug-in manifest schema reference][03]. + +```yaml +Type: object +Required: true +``` + + +[01]: https://jsonlines.org/ +[02]: ../../definitions/resourceType.md +[03]: ../../plugin/manifest/root.md diff --git a/docs/reference/schemas/overview.md b/docs/reference/schemas/overview.md index 632d00182..39f93b682 100644 --- a/docs/reference/schemas/overview.md +++ b/docs/reference/schemas/overview.md @@ -51,6 +51,13 @@ can define: - [DSC extension manifest discover property schema reference][11] - [DSC extension discover operation stdout schema reference][12] +## Plug-in schemas + +The article [DSC plug-in manifest schema reference][59] describes the root JSON schema for plug-in +manifests. The following article describes the subschema for the command a plug-in defines: + +- [DSC plug-in manifest command property schema reference][60] + ## Resource schemas The article [DSC command resource manifest schema reference][13] describes the root JSON schema for @@ -104,6 +111,7 @@ DSC returns for its commands: | `dsc config test` | [dsc config test result schema reference][43] | | `dsc extension list` | [dsc extension list result schema reference][44] | | `dsc function list` | [dsc function list result schema reference][45] | +| `dsc plugin list` | [dsc plugin list result schema reference][61] | | `dsc resource get` | [dsc resource get result schema reference][46] | | `dsc resource list` | [dsc resource list result schema reference][47] | | `dsc resource set` | [dsc resource set result schema reference][48] | @@ -189,3 +197,6 @@ throughout DSC. [56]: ./config/function.md [57]: ./config/output.md [58]: ./config/executionInformation.md +[59]: ./plugin/manifest/root.md +[60]: ./plugin/manifest/command.md +[61]: ./outputs/plugin/list.md diff --git a/docs/reference/schemas/plugin/manifest/command.md b/docs/reference/schemas/plugin/manifest/command.md new file mode 100644 index 000000000..ff2938c31 --- /dev/null +++ b/docs/reference/schemas/plugin/manifest/command.md @@ -0,0 +1,147 @@ +--- +description: JSON schema reference for the 'command' property in a DSC plug-in manifest +ms.date: 11/13/2026 +ms.topic: reference +title: DSC plug-in manifest command property schema reference +--- + +# DSC plug-in manifest command property schema reference + +## Synopsis + +Defines the command a plug-in adds to DSC and how DSC runs it. + +## Metadata + +```yaml +SchemaDialect: https://json-schema.org/draft/2020-12/schema +SchemaID: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.4.0/plugin/manifest.command.json +Type: object +``` + +## Description + +The `command` property of a plug-in manifest defines the name of the command the plug-in adds to +DSC and the executable that implements it. + +When the user specifies the command name as the first argument to `dsc`, DSC: + +1. Resolves the [executable](#executable). +1. Starts the executable in the current working directory with the standard input, output, and + error streams of `dsc`. DSC passes the arguments defined in [args](#args), followed by every + argument the user specified after the command name, exactly as the user specified them. +1. Waits for the executable to exit and exits with the same exit code. + +DSC sets environment variables that tell the plug-in how it was invoked, like `DSC_EXE`, +`DSC_VERSION`, `DSC_PLUGIN_COMMAND`, and `DSC_PLUGIN_DIR`. For the full list, see +[Plug-in commands][01]. + +A plug-in command must display its help and exit with code `0` when it receives the `--help` +option. DSC passes that option when the user requests help with `dsc help `. + +## Required properties + +The `command` definition must include these properties: + +- [name](#name) +- [executable](#executable) + +## Properties + +### name + +The `name` property defines the name of the command, which the user specifies after `dsc`. For +example, a plug-in with the command name `lint` is invoked as `dsc lint`. + +A command name must start with a lowercase ASCII letter and can only contain lowercase ASCII +letters, ASCII digits, and hyphens. It can't end with a hyphen or contain consecutive hyphens. DSC +compares command names case-sensitively. + +The name can't be the name or the alias of a built-in command, like `config`, `resource`, or +`help`. DSC always matches built-in commands first. When it discovers a plug-in that uses the name +of a built-in command, it writes a warning and skips the plug-in. + +When plug-ins with different [types][02] define the same command name, the plug-in DSC discovers +first provides the command. The `dsc plugin list` command reports the others as shadowed. A +shadowed plug-in can still be run by its type name, like `dsc OpenDSC/Lint`. + +A manifest defines exactly one command. A plug-in can implement nested commands, like +`dsc package build`, by parsing the arguments DSC passes to it. + +```yaml +Type: string +Required: true +Pattern: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ +``` + +### executable + +The `executable` property defines the name of the executable to run for the command. The value must +be one of the following: + +- The name of an executable discoverable in the system's `PATH` environment variable. +- The name of an executable in the same directory as the manifest. +- The full path to the executable. + +A file extension is only required when the operating system doesn't recognize the file as an +executable without it. + +To implement a plug-in as a script, define the interpreter as the executable and pass the script +in [args](#args) as a [manifest-relative path argument](#manifest-relative-path-argument). + +When the `resourcePath.restricted` setting is `true`, the executable must be in one of the +directories defined by the `resourcePath.directories` setting. + +```yaml +Type: string +Required: true +``` + +### args + +The `args` property defines the list of arguments to pass to the executable before the arguments +the user specified after the command name. Each item in the array can be a string representing a +static argument or a [manifest-relative path argument](#manifest-relative-path-argument) object. + +```yaml +Type: array +Required: false +ItemsType: [string, object(Manifest-relative path argument)] +``` + +#### String arguments + +Any item in the argument array can be a string representing a static argument to pass to the +executable, like `-NoProfile` or `-File`. DSC passes the string as written. + +```yaml +Type: string +``` + +#### Manifest-relative path argument + +Defines an argument that is the path to a file that ships with the plug-in. DSC replaces the item +with the absolute path to the file, resolved against the directory that contains the manifest. + +DSC runs a plug-in in the current working directory of the user, not in the directory that contains +the manifest, so a relative path defined as a string argument wouldn't resolve to a file next to +the manifest. Use this argument to pass such a file, like the script that implements the plug-in. + +A manifest-relative path argument is defined as a JSON object with the following properties: + +- `manifestRelativePath` (required) - The path to the file, relative to the directory that contains + the manifest, like `graph.ps1` or `scripts/graph.py`. The value can't be empty, an absolute path, + or a path that starts with a drive or a root. DSC doesn't load a manifest that defines such a + path. + +The object must not define any other properties. + +```yaml +Type: object +RequiredProperties: [manifestRelativePath] +AdditionalProperties: false +``` + + +[01]: ../../../cli/index.md#plug-in-commands +[02]: ./root.md#type diff --git a/docs/reference/schemas/plugin/manifest/root.md b/docs/reference/schemas/plugin/manifest/root.md new file mode 100644 index 000000000..1f898330b --- /dev/null +++ b/docs/reference/schemas/plugin/manifest/root.md @@ -0,0 +1,238 @@ +--- +description: JSON schema reference for a DSC plug-in manifest +ms.date: 11/13/2026 +ms.topic: reference +title: DSC plug-in manifest schema reference +--- + +# DSC plug-in manifest schema reference + +## Synopsis + +The data file that defines a DSC command plug-in. + +## Metadata + +```yaml +SchemaDialect: https://json-schema.org/draft/2020-12/schema +SchemaID: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3.4.0/plugin/manifest.json +Type: object +``` + +## Description + +A command plug-in adds a top-level command to DSC without being part of the DSC executable. Every +plug-in must have a manifest that defines: + +- The type name and version that identify the plug-in. +- The name of the command the plug-in adds to DSC. +- The executable DSC runs for the command, and any arguments DSC passes to it before the arguments + the user specified. + +When the first argument to `dsc` isn't a built-in command, DSC runs the plug-in that defines a +command with that name. For more information about how DSC runs a plug-in command, see +[Plug-in commands][01]. + +DSC discovers plug-in manifests in the same directories as resource and extension manifests. The +file name of a plug-in manifest must end with one of the following suffixes: + +- `.dsc.plugin.json` +- `.dsc.plugin.yaml` +- `.dsc.plugin.yml` + +You can also define plug-ins in the `plugins` property of a manifest list, a file whose name ends +with `.dsc.manifests.json`, `.dsc.manifests.yaml`, or `.dsc.manifests.yml`. The value of the +property is an array of plug-in manifests. + +DSC doesn't load a plug-in manifest that isn't valid for this schema. It writes a warning that names +the file when it discovers plug-ins. + +The rest of this document describes the manifest's schema. + +## Examples + +### Example 1 - Plug-in implemented as an executable + +This manifest defines the `lint` command, implemented by the `dsclint` executable. DSC finds the +executable in the `PATH` environment variable or in the same directory as the manifest. + +```json +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Microsoft.DSC/Lint", + "version": "3.4.0", + "description": "Check configuration documents for problems without applying them", + "command": { + "name": "lint", + "executable": "dsclint" + } +} +``` + +### Example 2 - Plug-in implemented as a script + +This manifest defines the `graph` command, implemented by a PowerShell script in the same directory +as the manifest. The `condition` makes the plug-in available only when `pwsh` is installed. The +`manifestRelativePath` argument passes the full path to the script, because DSC runs a plug-in in +the working directory of the user, not in the directory that contains the manifest. + +```json +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Contoso.DSC/Graph", + "version": "0.1.0", + "description": "Show the dependency graph of a configuration document", + "condition": "[not(equals(tryWhich('pwsh'), null()))]", + "command": { + "name": "graph", + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-File", + { "manifestRelativePath": "graph.ps1" } + ] + } +} +``` + +## Required properties + +The manifest must include these properties: + +- [$schema](#schema) +- [type](#type) +- [version](#version) +- [command](#command) + +## Properties + +### $schema + +The `$schema` property indicates the canonical URI of this schema that the manifest validates +against. This property is mandatory. DSC uses this value to validate the manifest against the +correct JSON schema and doesn't load a manifest with a URI it doesn't recognize. + +For every version of the schema, there are three valid urls: + +- `.../plugin/manifest.json` + + The URL to the canonical non-bundled schema. When it's used for validation, the validating client + needs to retrieve this schema and every schema it references. + +- `.../bundled/plugin/manifest.json` + + The URL to the bundled schema. When it's used for validation, the validating client only needs to + retrieve this schema. + +- `.../bundled/plugin/manifest.vscode.json` + + The URL to the enhanced authoring schema. This schema is much larger than the other schemas, as + it includes additional definitions that provide contextual help and snippets that the others + don't include. + +```yaml +Type: string +Required: true +Format: URI +``` + +### type + +The `type` property represents the fully qualified type name of the plug-in. It identifies the +plug-in. When DSC discovers more than one manifest with the same type, it uses the manifest with +the highest [version](#version). + +The type name of a plug-in uses the same syntax as the type name of a resource. For more +information, see [DSC Resource fully qualified type name schema reference][02]. + +```yaml +Type: string +Required: true +Pattern: ^\w+(\.\w+)*\/\w+$ +``` + +### version + +The `version` property must be the current version of the plug-in as a valid semantic version +(semver) string. + +```yaml +Type: string +Required: true +Pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ +``` + +### condition + +The `condition` property defines a [DSC expression][03] that must evaluate to a boolean. When the +expression evaluates to `false`, DSC treats the plug-in as not installed. The command isn't listed +in the help or by the `dsc plugin list` command and can't be invoked. + +Use a condition for a plug-in that depends on a tool that might not be installed, like in +[Example 2](#example-2---plug-in-implemented-as-a-script). + +```yaml +Type: string +Required: false +``` + +### deprecationMessage + +The `deprecationMessage` property defines a message that indicates the plug-in is deprecated. When +this property is defined, DSC writes the message as a warning each time the command is invoked. + +```yaml +Type: string +Required: false +``` + +### description + +The `description` property defines a one-line description of the command. DSC displays the +description next to the command in the output of `dsc --help` and `dsc plugin list`, and in the +completion scripts that `dsc completer` generates. + +```yaml +Type: string +Required: false +``` + +### tags + +The `tags` property defines a list of searchable terms for the plug-in. + +```yaml +Type: array +Required: false +ItemsMustBeUnique: true +ItemsType: string +ItemsPattern: ^\w+$ +``` + +### command + +The `command` property defines the command the plug-in adds to DSC and how DSC runs it. For more +information, see [DSC plug-in manifest command property schema reference][04]. + +```yaml +Type: object +Required: true +``` + +### metadata + +The `metadata` property defines arbitrary data for the plug-in. DSC doesn't interpret the metadata. +It's returned with the manifest in the output of the `dsc plugin list` command. + +```yaml +Type: object +Required: false +``` + + +[01]: ../../../cli/index.md#plug-in-commands +[02]: ../../definitions/resourceType.md +[03]: ../../config/functions/overview.md +[04]: ./command.md diff --git a/dsc/dsc.settings.json b/dsc/dsc.settings.json index 19acd63ea..bce06cab8 100644 --- a/dsc/dsc.settings.json +++ b/dsc/dsc.settings.json @@ -8,5 +8,8 @@ "level": "WARN", "format": "Default", "allowOverride": true + }, + "plugins": { + "enabled": true } } diff --git a/dsc/dsc_default.settings.json b/dsc/dsc_default.settings.json index fe37d458f..ceabf5cc6 100644 --- a/dsc/dsc_default.settings.json +++ b/dsc/dsc_default.settings.json @@ -9,6 +9,9 @@ "level": "WARN", "format": "Default", "allowOverride": true + }, + "plugins": { + "enabled": true } } } diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 94c4eba8f..2d6f4b97c 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -35,6 +35,8 @@ getAll = "Get all instances of the resource" resource = "The name of the resource to invoke" functionAbout = "Operations on DSC functions" listFunctionAbout = "List or find functions" +pluginAbout = "Operations on DSC plug-ins" +listPluginAbout = "List or find plug-ins" functionCategory = "Category to filter functions on; specify multiple times to require all categories" functionDescription = "Description to search for in the function description, accepts wildcards" version = "The version requirement for the resource, like '^1.2' or '>1.2.3, <1.3'" @@ -65,6 +67,20 @@ Press any key to close this window""" failedToStartServer = "Failed to start server: %{error}" failedToGetSystem = "Failed to get system information" +[plugin] +discoveryFailed = "Failed to discover plug-ins: %{error}" +shadowedPlugin = "Command '%{command}' is provided by plug-in '%{plugin}'. Plug-in '%{shadowed}' defines the same command and is shadowed. To run it, use 'dsc %{shadowed}'" +deprecationMessage = "Plug-in '%{plugin}' is deprecated: %{message}" +failedToResolve = "Failed to run command '%{command}' of plug-in '%{plugin}': %{error}" +failedToGetExePath = "Could not get the path of the DSC executable for the plug-in: %{error}" +invoking = "Running command '%{command}' of plug-in '%{plugin}' with executable '%{executable}'" +failedToStart = "Failed to start executable '%{executable}' for command '%{command}': %{error}" +helpHeadingCommands = "Commands" +helpHeadingPluginCommands = "Plug-in commands" +helpHeadingOptions = "Options" +helpOption = "Print help" +unrecognizedSubcommand = "unrecognized subcommand '%{command}'" + [resolve] processingInclude = "Processing Include input" invalidInclude = "Failed to deserialize Include input" @@ -171,6 +187,7 @@ tableHeader_type = "Type" tableHeader_kind = "Kind" tableHeader_version = "Version" tableHeader_capabilities = "Capabilities" +tableHeader_command = "Command" tableHeader_adapter = "RequireAdapter" tableHeader_description = "Description" tableHeader_functionName = "Function" @@ -181,6 +198,8 @@ invalidFunctionDescriptionFilter = "Invalid function description filter" maxInt = "maxInt" invalidManifest = "Error in manifest for" jsonArrayNotSupported = "JSON array output format is only supported for `--all'" +failedDiscoverPlugins = "Failed to discover plug-ins" +pluginShadowed = "%{command} (shadowed)" yes = "Yes" no = "No" none = "None" diff --git a/dsc/src/args.rs b/dsc/src/args.rs index f95a8c50b..b1b0e0b05 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -10,6 +10,7 @@ use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter} use rust_i18n::t; use schemars::JsonSchema; use serde::Deserialize; +use std::ffi::OsString; #[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] pub enum OutputFormat { @@ -123,6 +124,11 @@ pub enum SubCommand { #[clap(subcommand)] subcommand: FunctionSubCommand, }, + #[clap(name = "plugin", about = t!("args.pluginAbout").to_string())] + Plugin { + #[clap(subcommand)] + subcommand: PluginSubCommand, + }, #[clap(name = "server", alias = "mcp", about = t!("args.serverAbout").to_string())] Server, #[clap(name = "resource", about = t!("args.resourceAbout").to_string())] @@ -137,6 +143,11 @@ pub enum SubCommand { #[clap(short = 'o', long, help = t!("args.outputFormat").to_string(), value_enum)] output_format: Option, }, + /// A command that isn't built in, which a command plug-in may provide. The first item is the + /// command name and the rest are the arguments for the plug-in, exactly as the user specified + /// them. + #[clap(external_subcommand)] + External(Vec), } #[derive(Debug, PartialEq, Eq, Subcommand)] @@ -229,6 +240,18 @@ pub enum ExtensionSubCommand { }, } +#[derive(Debug, PartialEq, Eq, Subcommand)] +pub enum PluginSubCommand { + #[clap(name = "list", about = t!("args.listPluginAbout").to_string())] + List { + /// Optional plug-in type name to filter the list + #[clap(default_value_t)] + plugin_name: TypeNameFilter, + #[clap(short = 'o', long, help = t!("args.outputFormat").to_string())] + output_format: Option, + }, +} + #[derive(Debug, PartialEq, Eq, Subcommand)] pub enum FunctionSubCommand { #[clap(name = "list", about = t!("args.listFunctionAbout").to_string())] @@ -358,6 +381,7 @@ pub enum SchemaType { GetResult, Include, ManifestList, + PluginManifest, ResolveResult, Resource, ResourceGetResult, diff --git a/dsc/src/main.rs b/dsc/src/main.rs index 7104e139c..6078b1c9d 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use args::{Args, SubCommand}; -use clap::{CommandFactory, Parser}; +use args::SubCommand; use clap_complete::generate; use dsc_lib::{progress::ProgressFormat, settings::{DscSettings, DscSettingsScope, global as global_settings, sources::EnvironmentData}}; use server::start_server; @@ -19,6 +18,7 @@ use crossterm::event; use std::env; pub mod args; +pub mod plugin; pub mod resolve; pub mod resource_command; pub mod server; @@ -76,7 +76,7 @@ fn dsc_main() -> Result<(), ExitCode> { error!("{}", t!("main.failedCtrlCHandler")); } - let args = Args::parse(); + let args = plugin::parse_args(); // Initialize with local settings; reload for global after tracing init // to emit messages for settings loading/resolution. @@ -115,7 +115,8 @@ fn dsc_main() -> Result<(), ExitCode> { match args.subcommand { SubCommand::Completer { shell } => { info!("{} {:?}", t!("main.generatingCompleter"), shell); - let mut cmd = Args::command(); + // Include the plug-in commands, so the script completes them like built-in commands. + let mut cmd = plugin::command_with_plugins(&plugin::discover()); generate(shell, &mut cmd, "dsc", &mut io::stdout()); }, SubCommand::Config { subcommand, parameters, parameters_file, system_root, as_group, as_assert, as_include } => { @@ -155,6 +156,14 @@ fn dsc_main() -> Result<(), ExitCode> { SubCommand::Function { subcommand } => { subcommand::function(&subcommand)?; }, + SubCommand::Plugin { subcommand } => { + subcommand::plugin(&subcommand)?; + }, + SubCommand::External(external_args) => { + // Only returns when the plug-in couldn't be started; otherwise it exits with the + // plug-in's exit code. + plugin::dispatch(&external_args, args.progress_format)?; + }, SubCommand::Server => { if let Err(err) = start_server() { error!("{}", t!("main.failedToStartServer", error = err)); diff --git a/dsc/src/plugin.rs b/dsc/src/plugin.rs new file mode 100644 index 000000000..4f5e34830 --- /dev/null +++ b/dsc/src/plugin.rs @@ -0,0 +1,486 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Command plug-in support for the `dsc` command line. +//! +//! A command plug-in adds a top-level command to `dsc` without being compiled into it. This module +//! parses the command line with the adjustments plug-in commands need, runs the plug-in that +//! provides a command, and adds plug-in commands to the top-level help and the completion scripts. +//! +//! Plug-ins are only discovered when the result is needed: when the first argument isn't a +//! built-in command, when the top-level help is shown, when plug-ins are listed, and when a +//! completion script is generated. A built-in command never triggers plug-in discovery. + +use crate::args::{Args, SubCommand}; +use crate::util::{current_dsc_version, EXIT_DSC_ERROR}; +use clap::{Arg, ArgAction, Command, CommandFactory, FromArgMatches, ValueEnum, ValueHint}; +use clap::builder::ValueParser; +use clap::error::ErrorKind; +use dsc_lib::plugins::discovery::{discover_plugins, find_plugin, find_plugin_by_type}; +use dsc_lib::plugins::dscplugin::DscPlugin; +use dsc_lib::progress::ProgressFormat; +use dsc_lib::settings::{global as global_settings, sources::EnvironmentData}; +use dsc_lib::util::get_exe_path; +use rust_i18n::t; +use std::env; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::process::{exit, ExitCode, ExitStatus}; +use tracing::{debug, error, warn}; + +/// The environment variable that holds the absolute path of the `dsc` executable that started a +/// plug-in. +pub const DSC_EXE_ENV_VAR: &str = "DSC_EXE"; +/// The environment variable that holds the semantic version of the `dsc` executable that started +/// a plug-in. +pub const DSC_VERSION_ENV_VAR: &str = "DSC_VERSION"; +/// The environment variable that holds the command name a plug-in was invoked as. +pub const DSC_PLUGIN_COMMAND_ENV_VAR: &str = "DSC_PLUGIN_COMMAND"; +/// The environment variable that holds the directory that contains the manifest of a plug-in. +pub const DSC_PLUGIN_DIR_ENV_VAR: &str = "DSC_PLUGIN_DIR"; +/// The environment variable that holds the value of the `--progress-format` option, when the user +/// specified it. +pub const DSC_PROGRESS_FORMAT_ENV_VAR: &str = "DSC_PROGRESS_FORMAT"; + +/// The name of the help subcommand that clap adds to every command with subcommands. +const HELP_COMMAND: &str = "help"; +/// The exit code clap uses for a usage error, like an unrecognized subcommand. +const EXIT_USAGE_ERROR: i32 = 2; + +/// Parses the command line arguments of the current process. +/// +/// This function behaves like [`clap::Parser::parse()`], with two additions for plug-ins: +/// +/// - `dsc help ` for a command that isn't built in is parsed as the plug-in command +/// followed by `--help`, so the plug-in prints its own help. +/// - The top-level help lists the discovered plug-in commands after the built-in commands. +/// +/// Like `parse()`, this function prints the message and exits the process when the arguments are +/// invalid or ask for help or the version. +/// +/// # Returns +/// +/// The parsed arguments. An argument that isn't a built-in command is returned as +/// [`SubCommand::External`] without checking whether a plug-in provides it. [`dispatch()`] does +/// that. +pub fn parse_args() -> Args { + let raw_args: Vec = env::args_os().collect(); + + if let Some(args) = parse_plugin_help_request(&raw_args) { + return args; + } + + let mut command = Args::command(); + let mut matches = match command.try_get_matches_from_mut(&raw_args) { + Ok(matches) => matches, + Err(err) => { + if is_top_level_help(&err, &mut command) { + print_top_level_help_with_plugins(&raw_args); + } + err.exit() + } + }; + + match Args::from_arg_matches_mut(&mut matches) { + Ok(args) => args, + Err(err) => err.format(&mut command).exit(), + } +} + +/// Returns the names and aliases of the commands that are built into `dsc`. +/// +/// A plug-in can't use any of these names, because built-in commands are matched first. +/// +/// # Returns +/// +/// The name and every alias of each built-in command, and the `help` command that clap adds. +#[must_use] +pub fn reserved_commands() -> Vec { + let command = Args::command(); + let mut reserved: Vec = command + .get_subcommands() + .flat_map(|subcommand| { + std::iter::once(subcommand.get_name()).chain(subcommand.get_all_aliases()) + }) + .map(str::to_string) + .collect(); + reserved.push(HELP_COMMAND.to_string()); + reserved +} + +/// Discovers the plug-ins that are available to `dsc`. +/// +/// # Returns +/// +/// The discovered plug-ins, including shadowed plug-ins. When discovery fails, the function logs +/// the error as a warning and returns an empty list, so that a problem with the resource path +/// settings doesn't hide the built-in commands. +#[must_use] +pub fn discover() -> Vec { + match discover_plugins(&reserved_commands()) { + Ok(plugins) => plugins, + Err(err) => { + warn!("{}", t!("plugin.discoveryFailed", error = err)); + Vec::new() + } + } +} + +/// Returns the `dsc` command definition with a subcommand for every plug-in command. +/// +/// The returned definition describes plug-in commands the way it describes built-in commands, so +/// clap can suggest them for a mistyped command and include them in completion scripts. It doesn't +/// accept other external subcommands. It must not be used to run a plug-in, because the +/// subcommands only describe the name and description of each plug-in command, not its arguments. +/// +/// # Arguments +/// +/// * `plugins` - The discovered plug-ins. Shadowed plug-ins are ignored. +#[must_use] +pub fn command_with_plugins(plugins: &[DscPlugin]) -> Command { + // Clap turns external subcommands back on while it builds a command that has a value parser + // for them, so the value parser has to be reset together with the setting. + let mut command = Args::command() + .allow_external_subcommands(false) + .external_subcommand_value_parser(None::); + for plugin in plugins.iter().filter(|plugin| !plugin.shadowed) { + command = command.subcommand(plugin_subcommand(plugin)); + } + command +} + +/// Runs the plug-in that provides a command and exits with the plug-in's exit code. +/// +/// The plug-in runs in the caller's working directory with the standard streams of `dsc`. DSC +/// passes the arguments from the manifest followed by the arguments the user specified, and sets +/// the environment variables that tell the plug-in how it was invoked. +/// +/// When no plug-in provides the command, the function reports the same unrecognized subcommand +/// error as for any unknown command and exits with the exit code for a usage error. +/// +/// # Arguments +/// +/// * `external_args` - The command name followed by the arguments the user specified after it. +/// * `progress_format` - The value of the `--progress-format` option, if the user specified it. +/// +/// # Returns +/// +/// This function only returns when the plug-in couldn't be started. Otherwise it exits the +/// process. +/// +/// # Errors +/// +/// Returns the exit code for a DSC error when the executable of the plug-in can't be resolved or +/// started. +pub fn dispatch(external_args: &[OsString], progress_format: Option) -> Result<(), ExitCode> { + let plugins = discover(); + + let invoked_as = external_args.first().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); + // A type name always contains a slash and a command name never does, so the name itself + // tells which of the two the user specified. + let by_type_name = invoked_as.contains('/'); + let found = if by_type_name { + find_plugin_by_type(&plugins, &invoked_as) + } else { + find_plugin(&plugins, &invoked_as) + }; + let Some(plugin) = found else { + exit_with_unrecognized_subcommand(&plugins); + }; + let user_args = &external_args[1..]; + + // Naming a plug-in by its type is unambiguous, so only warn when the command name was used. + if !by_type_name { + for shadowed in plugins.iter().filter(|other| other.shadowed && other.command == plugin.command) { + warn!("{}", t!( + "plugin.shadowedPlugin", + command = plugin.command, + plugin = plugin.type_name, + shadowed = shadowed.type_name + )); + } + } + + if let Some(message) = &plugin.deprecation_message { + warn!("{}", t!("plugin.deprecationMessage", plugin = plugin.type_name, message = message)); + } + + let invocation = match plugin.resolve_invocation() { + Ok(invocation) => invocation, + Err(err) => { + error!("{}", t!("plugin.failedToResolve", command = plugin.command, plugin = plugin.type_name, error = err)); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + } + }; + + let mut process = std::process::Command::new(&invocation.executable); + process + .args(&invocation.args) + .args(user_args) + .env(DSC_VERSION_ENV_VAR, current_dsc_version().to_string()) + .env(DSC_PLUGIN_COMMAND_ENV_VAR, &invoked_as) + .env(DSC_PLUGIN_DIR_ENV_VAR, &plugin.directory) + .env( + EnvironmentData::DSC_TRACE_FORMAT_ENV_VAR, + global_settings::get_or_init().resolved().tracing.format.value.to_string() + ); + match get_exe_path() { + Ok(exe_path) => { process.env(DSC_EXE_ENV_VAR, exe_path); }, + Err(err) => debug!("{}", t!("plugin.failedToGetExePath", error = err)), + } + if let Some(name) = progress_format.and_then(|format| format.to_possible_value()) { + process.env(DSC_PROGRESS_FORMAT_ENV_VAR, name.get_name()); + } + + debug!("{}", t!( + "plugin.invoking", + command = plugin.command, + plugin = plugin.type_name, + executable = invocation.executable.display() + )); + match process.status() { + Ok(status) => exit(exit_code_for(status)), + Err(err) => { + error!("{}", t!( + "plugin.failedToStart", + command = plugin.command, + executable = invocation.executable.display(), + error = err + )); + Err(ExitCode::from(EXIT_DSC_ERROR)) + } + } +} + +/// Parses `dsc help ` for a command that isn't built in. +/// +/// Clap's `help` subcommand only knows the built-in commands. To recognize the request, this +/// function parses the arguments with the `help` subcommand disabled, which turns `help` into an +/// external subcommand whose arguments are the command path to show help for. +/// +/// # Arguments +/// +/// * `raw_args` - The arguments of the process, including the program name. +/// +/// # Returns +/// +/// The parsed arguments with the plug-in command as the subcommand and `--help` appended to its +/// arguments, or [`None`] when the arguments aren't a help request for a command that isn't built +/// in. Whether a plug-in provides the command is checked by [`dispatch()`]. +fn parse_plugin_help_request(raw_args: &[OsString]) -> Option { + // Building the command definition a second time isn't free, so only do it when the arguments + // can be a help request. + if !raw_args.iter().any(|arg| arg == HELP_COMMAND) { + return None; + } + + let mut command = Args::command().disable_help_subcommand(true); + let mut matches = command.try_get_matches_from_mut(raw_args).ok()?; + let mut args = Args::from_arg_matches_mut(&mut matches).ok()?; + + let SubCommand::External(external_args) = &args.subcommand else { + return None; + }; + let (first, command_path) = external_args.split_first()?; + if first != HELP_COMMAND { + return None; + } + let target = command_path.first()?.to_string_lossy(); + if reserved_commands().iter().any(|reserved| *reserved == target) { + return None; + } + + let mut plugin_args = command_path.to_vec(); + plugin_args.push(OsString::from("--help")); + args.subcommand = SubCommand::External(plugin_args); + Some(args) +} + +/// Indicates whether a parsing error is the request to show the top-level help of `dsc`. +/// +/// Clap reports a help request as an error that carries the rendered help, without saying which +/// command the help is for. This function compares that text to the help of the top-level command. +/// +/// # Arguments +/// +/// * `err` - The error that parsing the arguments returned. +/// * `command` - The command definition that parsed the arguments. It must be the same instance, +/// because parsing records the program name the usage line shows. +fn is_top_level_help(err: &clap::Error, command: &mut Command) -> bool { + if err.kind() != ErrorKind::DisplayHelp { + return false; + } + + let rendered = err.render().to_string(); + rendered == command.render_help().to_string() || rendered == command.render_long_help().to_string() +} + +/// Prints the top-level help with the plug-in commands listed after the built-in commands, then +/// exits the process. +/// +/// # Arguments +/// +/// * `raw_args` - The arguments of the process, including the program name. They are parsed again +/// so that clap renders the short or the long help, as requested. +/// +/// # Returns +/// +/// This function returns without printing anything when there are no plug-in commands to list, so +/// the caller can print the unmodified help. +fn print_top_level_help_with_plugins(raw_args: &[OsString]) { + // The arguments never got parsed, so the settings haven't been loaded yet. Tracing isn't + // enabled either, which keeps messages about invalid manifests out of the help. + let mut settings = global_settings::get_or_init(); + settings.load(); + settings.resolve_all(); + drop(settings); + + let plugins = discover(); + let mut provided: Vec<&DscPlugin> = plugins.iter().filter(|plugin| !plugin.shadowed).collect(); + if provided.is_empty() { + return; + } + provided.sort_by(|a, b| a.command.cmp(&b.command)); + + let command = Args::command(); + let template = top_level_help_template(&command, &provided); + if let Err(err) = command.help_template(template).try_get_matches_from_mut(raw_args) { + err.exit(); + } +} + +/// Builds the help template for the top-level command with a section for plug-in commands. +/// +/// The template reproduces clap's default layout and adds the plug-in commands between the +/// built-in commands and the options. +/// +/// # Arguments +/// +/// * `command` - The top-level command definition, for its styles and the names of its +/// subcommands. +/// * `plugins` - The plug-ins that provide a command, in the order to list them. +fn top_level_help_template(command: &Command, plugins: &[&DscPlugin]) -> String { + let styles = command.get_styles(); + let header = styles.get_header(); + let literal = styles.get_literal(); + + // Use the same column for the descriptions as the built-in commands when the names allow it. + let name_width = command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .map(|subcommand| subcommand.get_name().len()) + .chain(std::iter::once(HELP_COMMAND.len())) + .chain(plugins.iter().map(|plugin| plugin.command.len())) + .max() + .unwrap_or_default(); + + let mut plugin_lines = String::new(); + for plugin in plugins { + let name = plugin.command.as_ref(); + let description = plugin.description.as_deref().unwrap_or_default(); + let line = format!(" {literal}{name}{literal:#}{:width$} {description}", "", width = name_width - name.len()); + let _ = writeln!(plugin_lines, "{}", line.trim_end()); + } + + format!( + "{{before-help}}{{about-with-newline}}\n\ + {{usage-heading}} {{usage}}\n\ + \n\ + {header}{commands}:{header:#}\n\ + {{subcommands}}\n\ + \n\ + {header}{plugin_commands}:{header:#}\n\ + {plugin_lines}\ + \n\ + {header}{options}:{header:#}\n\ + {{options}}{{after-help}}", + commands = t!("plugin.helpHeadingCommands"), + plugin_commands = t!("plugin.helpHeadingPluginCommands"), + options = t!("plugin.helpHeadingOptions"), + ) +} + +/// Returns the subcommand definition that describes a plug-in command. +/// +/// The definition has the name and description of the plug-in command. It accepts `--help`, which +/// every plug-in must support, and any other arguments, completed as paths because DSC doesn't +/// know the arguments of the plug-in. +/// +/// # Arguments +/// +/// * `plugin` - The plug-in that provides the command. +fn plugin_subcommand(plugin: &DscPlugin) -> Command { + // Clap only takes names with a static lifetime unless its `string` feature is enabled. The + // process ends right after the command definition is used, so leaking a few short names is + // cheaper than enabling the feature for every command definition in `dsc`. + let name: &'static str = Box::leak(plugin.command.to_string().into_boxed_str()); + + let mut subcommand = Command::new(name) + .disable_help_flag(true) + .arg( + Arg::new("help") + .long("help") + .action(ArgAction::SetTrue) + .help(t!("plugin.helpOption").to_string()) + ) + .arg( + Arg::new("args") + .num_args(0..) + .trailing_var_arg(true) + .allow_hyphen_values(true) + .value_hint(ValueHint::AnyPath) + ); + if let Some(description) = &plugin.description { + subcommand = subcommand.about(description.clone()); + } + subcommand +} + +/// Reports the unrecognized subcommand error for the arguments of the process and exits. +/// +/// The arguments are parsed again with a command definition that doesn't accept external +/// subcommands, so clap reports the error exactly as it does for any unknown command. The +/// definition includes the plug-in commands so the suggestion in the error covers them. +/// +/// # Arguments +/// +/// * `plugins` - The discovered plug-ins. +fn exit_with_unrecognized_subcommand(plugins: &[DscPlugin]) -> ! { + let raw_args: Vec = env::args_os().collect(); + match command_with_plugins(plugins).try_get_matches_from_mut(&raw_args) { + Err(err) => err.exit(), + Ok(_) => { + // The arguments only parse when they name a built-in or plug-in command, and this + // function is only called when they don't. + let name = raw_args.get(1).map(|arg| arg.to_string_lossy().into_owned()).unwrap_or_default(); + error!("{}", t!("plugin.unrecognizedSubcommand", command = name)); + exit(EXIT_USAGE_ERROR) + } + } +} + +/// Returns the exit code for `dsc` to exit with after a plug-in finished. +/// +/// # Arguments +/// +/// * `status` - The exit status of the plug-in process. +/// +/// # Returns +/// +/// The exit code of the plug-in. When a signal ended the plug-in, the function returns `128` plus +/// the signal number, which is how shells report it. +fn exit_code_for(status: ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + + i32::from(EXIT_DSC_ERROR) +} diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 3603f0294..58da83100 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::args::{ConfigSubCommand, SchemaType, ActionSubCommand, ExtensionSubCommand, FunctionSubCommand, GetOutputFormat, ListOutputFormat, OutputFormat, ResourceSubCommand}; +use crate::args::{ConfigSubCommand, SchemaType, ActionSubCommand, ExtensionSubCommand, FunctionSubCommand, GetOutputFormat, ListOutputFormat, OutputFormat, PluginSubCommand, ResourceSubCommand}; +use crate::plugin::reserved_commands; use crate::resolve::{get_contents, Include}; use crate::resource_command::{get_resource, self}; use crate::tablewriter::Table; @@ -18,6 +19,7 @@ use dsc_lib::{configure::{config_doc::{Configuration, ExecutionKind, Resource}, dscresources::dscresource::{validate_json, validate_properties}, extensions::dscextension::Capability as ExtensionCapability, functions::{FunctionCategory, FunctionDispatcher}, + plugins::discovery::discover_plugins, progress::ProgressFormat, resources::invoke_result::{TestResult, ValidateResult, ResolveResult}, util::convert_wildcard_to_regex, @@ -559,6 +561,24 @@ pub fn extension(subcommand: &ExtensionSubCommand, progress_format: ProgressForm Ok(()) } +/// Runs a subcommand of the `plugin` command. +/// +/// # Arguments +/// +/// * `subcommand` - The subcommand to run. +/// +/// # Errors +/// +/// Returns the exit code for the failure when the subcommand fails. +pub fn plugin(subcommand: &PluginSubCommand) -> Result<(), ExitCode> { + match subcommand { + PluginSubCommand::List { plugin_name, output_format } => { + list_plugins(plugin_name, output_format.as_ref())?; + }, + } + Ok(()) +} + pub fn function(subcommand: &FunctionSubCommand) -> Result<(), ExitCode> { let functions = FunctionDispatcher::new(); match subcommand { @@ -804,6 +824,84 @@ fn list_extensions(dsc: &mut DscManager, extension_name: &TypeNameFilter, format Ok(()) } +/// Lists the discovered command plug-ins. +/// +/// The plug-ins are listed in the order DSC found them, which is the order that decides which +/// plug-in provides a command when more than one defines it. A plug-in that doesn't provide its +/// command because of that is marked as shadowed. +/// +/// # Arguments +/// +/// * `plugin_name` - The filter for the type name of the plug-ins to list. +/// * `format` - The output format, if the user specified one. +/// +/// # Errors +/// +/// Returns the exit code for a DSC error when DSC can't resolve the directories to search, and the +/// exit code for a JSON error when a plug-in can't be serialized. +fn list_plugins(plugin_name: &TypeNameFilter, format: Option<&ListOutputFormat>) -> Result<(), ExitCode> { + let write_table = should_write_table(format); + + let mut table = Table::new(&[ + t!("subcommand.tableHeader_command").to_string().as_ref(), + t!("subcommand.tableHeader_type").to_string().as_ref(), + t!("subcommand.tableHeader_version").to_string().as_ref(), + t!("subcommand.tableHeader_description").to_string().as_ref(), + ]); + + let plugins = match discover_plugins(&reserved_commands()) { + Ok(plugins) => plugins, + Err(err) => { + error!("{}: {err}", t!("subcommand.failedDiscoverPlugins")); + return Err(ExitCode::from(EXIT_DSC_ERROR)); + } + }; + + let mut include_separator = false; + + for plugin in plugins.iter().filter(|plugin| plugin_name.is_match(&plugin.type_name)) { + if write_table { + let command = if plugin.shadowed { + t!("subcommand.pluginShadowed", command = plugin.command).to_string() + } else { + plugin.command.to_string() + }; + table.add_row(vec![ + command, + plugin.type_name.to_string(), + plugin.version.to_string(), + plugin.description.clone().unwrap_or_default() + ]); + } + else { + // convert to json + let json = match serde_json::to_string(plugin) { + Ok(json) => json, + Err(err) => { + error!("JSON: {err}"); + return Err(ExitCode::from(EXIT_JSON_ERROR)); + } + }; + let format = match format { + Some(ListOutputFormat::Json) => Some(OutputFormat::Json), + Some(ListOutputFormat::PrettyJson) => Some(OutputFormat::PrettyJson), + Some(ListOutputFormat::Yaml) => Some(OutputFormat::Yaml), + _ => None, + }; + write_object(&json, format.as_ref(), include_separator)?; + include_separator = true; + // insert newline separating instances if writing to console + if io::stdout().is_terminal() { println!(); } + } + } + + if write_table { + let truncate = format != Some(&ListOutputFormat::TableNoTruncate); + table.print(truncate); + } + Ok(()) +} + fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String>, category: &[FunctionCategory], description: Option<&String>, output_format: Option<&ListOutputFormat>) -> Result<(), ExitCode> { let write_table = should_write_table(output_format); let mut table = Table::new(&[ diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 874cdc4a0..8e3c0980d 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -36,6 +36,7 @@ use dsc_lib::{ extension_manifest::ExtensionManifest, }, functions::FunctionDefinition, + plugins::plugin_manifest::PluginManifest, resources::invoke_result::{GetResult, ResolveResult, SetResult, TestResult}, schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix}, types::SemanticVersion, @@ -202,6 +203,9 @@ pub fn get_schema(schema: SchemaType) -> Schema { SchemaType::ManifestList => { schema_for!(ManifestList) }, + SchemaType::PluginManifest => { + repo_schema::() + }, SchemaType::ResolveResult => { repo_schema::() }, diff --git a/dsc/tests/other/dsc_plugin.tests.ps1 b/dsc/tests/other/dsc_plugin.tests.ps1 new file mode 100644 index 000000000..6c7a9f114 --- /dev/null +++ b/dsc/tests/other/dsc_plugin.tests.ps1 @@ -0,0 +1,657 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Command plug-in tests' { + BeforeAll { + $script:oldPath = $env:PATH + $script:pathSeparator = [System.IO.Path]::PathSeparator + $script:manifestSchema = 'https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json' + + # A plug-in that reports how it was invoked as one line of JSON. + $script:echoScript = @' +if ($args -contains '--help') { + "Usage: dsc $env:DSC_PLUGIN_COMMAND [OPTIONS]" + "HELP_ARGS: $($args -join '|')" + exit 0 +} + +$exitCode = 0 +$stdin = $null +for ($i = 0; $i -lt $args.Count; $i++) { + if ($args[$i] -eq '--exit-code') { $exitCode = [int]$args[$i + 1] } + if ($args[$i] -eq '--read-stdin') { $stdin = @($input) -join "`n" } +} + +[ordered]@{ + args = @($args) + # Report a marker file instead of the path, which can differ in form from the path the test + # used when a parent directory is a symbolic link. + cwdHasMarker = Test-Path -Path (Join-Path (Get-Location).Path 'plugin-cwd-marker.txt') + stdin = $stdin + env = [ordered]@{ + DSC_EXE = $env:DSC_EXE + DSC_VERSION = $env:DSC_VERSION + DSC_PLUGIN_COMMAND = $env:DSC_PLUGIN_COMMAND + DSC_PLUGIN_DIR = $env:DSC_PLUGIN_DIR + DSC_TRACE_LEVEL = $env:DSC_TRACE_LEVEL + DSC_TRACE_FORMAT = $env:DSC_TRACE_FORMAT + DSC_PROGRESS_FORMAT = $env:DSC_PROGRESS_FORMAT + DSC_IGNORE_SETTINGS_FILE = $env:DSC_IGNORE_SETTINGS_FILE + } +} | ConvertTo-Json -Compress -Depth 5 + +exit $exitCode +'@ + + function New-TestPluginManifest { + <# + .SYNOPSIS + Returns the manifest for a plug-in that runs the echo script next to the manifest. + #> + param( + [Parameter(Mandatory)][string]$Type, + [Parameter(Mandatory)][string]$Command, + [string]$Version = '1.0.0', + [string]$Description = 'Report how the plug-in was invoked', + [hashtable]$Extra = @{} + ) + + $manifest = [ordered]@{ + '$schema' = $script:manifestSchema + type = $Type + version = $Version + description = $Description + command = [ordered]@{ + name = $Command + executable = 'pwsh' + args = @( + '-NoLogo' + '-NoProfile' + '-NonInteractive' + '-File' + @{ manifestRelativePath = 'echo.ps1' } + ) + } + } + foreach ($key in $Extra.Keys) { + $manifest[$key] = $Extra[$key] + } + $manifest + } + + function New-TestPlugin { + <# + .SYNOPSIS + Writes a plug-in manifest and the echo script to a directory. + #> + param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][string]$Type, + [Parameter(Mandatory)][string]$Command, + [string]$Version = '1.0.0', + [string]$Description = 'Report how the plug-in was invoked', + [hashtable]$Extra = @{} + ) + + $null = New-Item -ItemType Directory -Force -Path $Directory + Set-Content -Path (Join-Path $Directory 'echo.ps1') -Value $script:echoScript + $fileName = '{0}.{1}.dsc.plugin.json' -f ($Type -replace '/', '.'), $Command + New-TestPluginManifest -Type $Type -Command $Command -Version $Version -Description $Description -Extra $Extra | + ConvertTo-Json -Depth 10 | + Set-Content -Path (Join-Path $Directory $fileName) + } + + function Use-PluginDirectory { + <# + .SYNOPSIS + Makes DSC search the directories for manifests, before every other directory. + #> + param([Parameter(Mandatory)][string[]]$Directory) + + $env:PATH = ($Directory + $script:oldPath) -join $script:pathSeparator + } + + $script:pluginDir = Join-Path $TestDrive 'plugins' + New-TestPlugin -Directory $script:pluginDir -Type 'Test/Echo' -Command 'echo' -Version '1.2.3' + Use-PluginDirectory -Directory $script:pluginDir + } + + AfterAll { + $env:PATH = $script:oldPath + } + + Context 'Listing plug-ins' { + It 'Lists a plug-in with its command and manifest' { + $out = dsc plugin list 'Test/Echo' 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + @($out).Count | Should -Be 1 + $out.type | Should -BeExactly 'Test/Echo' + $out.version | Should -BeExactly '1.2.3' + $out.command | Should -BeExactly 'echo' + $out.description | Should -BeExactly 'Report how the plug-in was invoked' + $out.deprecationMessage | Should -BeNullOrEmpty + $out.path | Should -BeExactly (Join-Path $pluginDir 'Test.Echo.echo.dsc.plugin.json') + $out.directory | Should -BeExactly $pluginDir + $out.shadowed | Should -BeFalse + $out.manifest.command.name | Should -BeExactly 'echo' + $out.manifest.command.executable | Should -BeExactly 'pwsh' + } + + It 'Filters the list with a wildcard: ' -TestCases @( + @{ filter = 'Test/*'; expected = 1 } + @{ filter = '*echo'; expected = 1 } + @{ filter = 'Test/DoesNotExist'; expected = 0 } + ) { + param($filter, $expected) + + $out = dsc plugin list $filter 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + @($out).Count | Should -Be $expected + } + + It 'Writes a table when asked to' { + $out = dsc plugin list 'Test/Echo' -o table-no-truncate 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Match 'Command\s+Type\s+Version\s+Description' + $out | Should -Match 'echo\s+Test/Echo\s+1\.2\.3\s+Report how the plug-in was invoked' + } + + It 'Finds plug-ins in YAML manifests and manifest lists' { + $directory = Join-Path $TestDrive 'formats' + New-TestPlugin -Directory $directory -Type 'Test.Formats/Json' -Command 'formats-json' + @" +`$schema: $manifestSchema +type: Test.Formats/Yaml +version: 1.0.0 +command: + name: formats-yaml + executable: pwsh +"@ | Set-Content -Path (Join-Path $directory 'yaml.dsc.plugin.yaml') + @{ + plugins = @( + New-TestPluginManifest -Type 'Test.Formats/ListOne' -Command 'formats-list-one' + New-TestPluginManifest -Type 'Test.Formats/ListTwo' -Command 'formats-list-two' + ) + } | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $directory 'tools.dsc.manifests.json') + + try { + Use-PluginDirectory -Directory $directory + $out = dsc plugin list 'Test.Formats/*' 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.command | Sort-Object | Should -Be @( + 'formats-json' + 'formats-list-one' + 'formats-list-two' + 'formats-yaml' + ) + + $result = dsc formats-list-two 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $result.env.DSC_PLUGIN_COMMAND | Should -BeExactly 'formats-list-two' + } finally { + Use-PluginDirectory -Directory $pluginDir + } + } + } + + Context 'Running a plug-in command' { + It 'Passes the arguments after the command name to the plug-in as written' { + $out = dsc echo one --two 'three four' -l trace --help-me -- five 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.args | Should -Be @('one', '--two', 'three four', '-l', 'trace', '--help-me', '--', 'five') + } + + It 'Runs the plug-in in the working directory of the caller' { + $out = dsc echo 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.cwdHasMarker | Should -BeFalse + + $workingDirectory = New-Item -ItemType Directory -Force -Path (Join-Path $TestDrive 'working') + $null = New-Item -ItemType File -Force -Path (Join-Path $workingDirectory 'plugin-cwd-marker.txt') + Push-Location $workingDirectory + try { + $out = dsc echo 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.cwdHasMarker | Should -BeTrue + } finally { + Pop-Location + } + } + + It 'Passes standard input to the plug-in' { + $out = 'hello from stdin' | dsc echo --read-stdin 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.stdin | Should -BeExactly 'hello from stdin' + } + + It 'Exits with the exit code of the plug-in: ' -TestCases @( + @{ exitCode = 0 } + @{ exitCode = 1 } + @{ exitCode = 5 } + @{ exitCode = 42 } + ) { + param($exitCode) + + $null = dsc echo --exit-code $exitCode 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be $exitCode -Because (Get-Content -Raw $TestDrive/error.txt) + } + + It 'Exits with an exit code above 255 on Windows' -Skip:(!$IsWindows) { + $null = dsc echo --exit-code 1000 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 1000 -Because (Get-Content -Raw $TestDrive/error.txt) + } + + It 'Tells the plug-in how it was invoked through environment variables' { + $out = dsc echo 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $dscVersion = (dsc --version) -replace '^dsc\s+', '' + $out.env.DSC_EXE | Should -Not -BeNullOrEmpty + (Split-Path -Leaf $out.env.DSC_EXE) | Should -Match '^dsc(\.exe)?$' + Test-Path -Path $out.env.DSC_EXE -PathType Leaf | Should -BeTrue + $out.env.DSC_VERSION | Should -BeExactly $dscVersion + $out.env.DSC_PLUGIN_COMMAND | Should -BeExactly 'echo' + $out.env.DSC_PLUGIN_DIR | Should -BeExactly $pluginDir + # The values depend on the settings in effect, so only check that DSC defines them. + $out.env.DSC_TRACE_LEVEL | Should -Not -BeNullOrEmpty + $out.env.DSC_TRACE_FORMAT | Should -Not -BeNullOrEmpty + $out.env.DSC_PROGRESS_FORMAT | Should -BeNullOrEmpty + } + + It 'Hands the options before the command to the plug-in as environment variables' { + $out = dsc --trace-level info --trace-format json --progress-format none echo --trace-level error 2> $TestDrive/error.txt | + ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.args | Should -Be @('--trace-level', 'error') + $out.env.DSC_TRACE_LEVEL | Should -Be 'info' + $out.env.DSC_TRACE_FORMAT | Should -Be 'json' + $out.env.DSC_PROGRESS_FORMAT | Should -Be 'none' + } + + It 'Tells the plug-in to ignore the settings files when the user asked for that' { + $out = dsc --ignore-settings-file echo 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.env.DSC_IGNORE_SETTINGS_FILE | Should -Be '1' + } + + It 'Fails when the executable of the plug-in cannot be found' { + $directory = Join-Path $TestDrive 'missing-executable' + $null = New-Item -ItemType Directory -Force -Path $directory + $manifest = New-TestPluginManifest -Type 'Test/MissingExecutable' -Command 'missing-executable' + $manifest.command.executable = 'dsc-test-executable-that-does-not-exist' + $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $directory 'missing.dsc.plugin.json') + + try { + Use-PluginDirectory -Directory $directory + $out = dsc missing-executable 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + $out | Should -BeNullOrEmpty + $stderr = Get-Content -Raw $TestDrive/error.txt + $stderr | Should -Match "missing-executable" + $stderr | Should -Match "dsc-test-executable-that-does-not-exist" + } finally { + Use-PluginDirectory -Directory $pluginDir + } + } + + It 'Warns when the plug-in is deprecated' { + $directory = Join-Path $TestDrive 'deprecated' + New-TestPlugin -Directory $directory -Type 'Test/Deprecated' -Command 'deprecated' -Extra @{ + deprecationMessage = 'Use the echo command instead.' + } + + try { + Use-PluginDirectory -Directory $directory + $out = dsc deprecated 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.env.DSC_PLUGIN_COMMAND | Should -BeExactly 'deprecated' + $stderr = Get-Content -Raw $TestDrive/error.txt + $stderr | Should -Match 'WARN' + $stderr | Should -Match "Test/Deprecated.*Use the echo command instead\." + } finally { + Use-PluginDirectory -Directory $pluginDir + } + } + } + + Context 'Help' { + It 'Passes --help after the command name to the plug-in' { + $out = dsc echo --help 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out[0] | Should -BeExactly 'Usage: dsc echo [OPTIONS]' + $out[1] | Should -BeExactly 'HELP_ARGS: --help' + } + + It 'Shows the help of the plug-in for: dsc help ' -TestCases @( + @{ arguments = @('echo'); expected = '--help' } + @{ arguments = @('echo', 'sub'); expected = 'sub|--help' } + @{ arguments = @('echo', 'sub', 'command'); expected = 'sub|command|--help' } + ) { + param($arguments, $expected) + + $out = dsc help @arguments 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out[0] | Should -BeExactly 'Usage: dsc echo [OPTIONS]' + $out[1] | Should -BeExactly "HELP_ARGS: $expected" + } + + It 'Lists plug-in commands in the top-level help for: dsc ' -TestCases @( + @{ arguments = @('--help') } + @{ arguments = @('-h') } + @{ arguments = @('help') } + @{ arguments = @('--trace-level', 'info', '--help') } + ) { + param($arguments) + + $out = dsc @arguments 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + Get-Content -Raw $TestDrive/error.txt | Should -BeNullOrEmpty + $text = $out | Out-String + $text | Should -Match '(?m)^Commands:\r?$' + $text | Should -Match '(?m)^Plug-in commands:\r?$' + $text | Should -Match '(?m)^ echo\s+Report how the plug-in was invoked\r?$' + $text | Should -Match '(?m)^Options:\r?$' + # The plug-in commands are listed between the built-in commands and the options. + $text.IndexOf('Commands:') | Should -BeLessThan $text.IndexOf('Plug-in commands:') + $text.IndexOf('Plug-in commands:') | Should -BeLessThan $text.IndexOf('Options:') + } + + It 'Keeps the help of built-in commands unchanged for: dsc ' -TestCases @( + @{ arguments = @('config', '--help') } + @{ arguments = @('help', 'config') } + @{ arguments = @('plugin', 'list', '--help') } + ) { + param($arguments) + + $out = dsc @arguments 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Not -Match 'Plug-in commands:' + $out | Should -Match 'Usage: dsc(\.exe)? (config|plugin list)' + } + + It 'Lists the plugin command with the built-in commands' { + $out = dsc --help 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Match '(?m)^ plugin\s+Operations on DSC plug-ins\r?$' + } + } + + Context 'Commands that no plug-in provides' { + It 'Reports an unrecognized subcommand and suggests a similar plug-in command' { + $out = dsc ech 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + $out | Should -BeNullOrEmpty + $stderr = Get-Content -Raw $TestDrive/error.txt + $stderr | Should -Match "error: unrecognized subcommand 'ech'" + $stderr | Should -Match "tip: a similar subcommand exists: 'echo'" + } + + It 'Reports an unrecognized subcommand and suggests a similar built-in command' { + $null = dsc confg get 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + $stderr = Get-Content -Raw $TestDrive/error.txt + $stderr | Should -Match "error: unrecognized subcommand 'confg'" + $stderr | Should -Match "tip: a similar subcommand exists: 'config'" + } + + It 'Reports an unrecognized subcommand for help on an unknown command' { + $null = dsc help does-not-exist 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + Get-Content -Raw $TestDrive/error.txt | Should -Match "error: unrecognized subcommand 'does-not-exist'" + } + + It 'Compares command names case-sensitively' { + $null = dsc Echo 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + Get-Content -Raw $TestDrive/error.txt | Should -Match "error: unrecognized subcommand 'Echo'" + } + } + + Context 'Completion scripts' { + BeforeAll { + # A command name that can't appear in a completion script for any other reason. + $script:completionDir = Join-Path $TestDrive 'completion' + New-TestPlugin -Directory $completionDir -Type 'Test/Completion' -Command 'completion-probe' -Description 'Probe for completion scripts' + Use-PluginDirectory -Directory $completionDir + } + + AfterAll { + Use-PluginDirectory -Directory $pluginDir + } + + It 'Includes plug-in commands in the completion script for ' -TestCases @( + @{ shell = 'bash' } + @{ shell = 'elvish' } + @{ shell = 'fish' } + @{ shell = 'powershell' } + @{ shell = 'zsh' } + ) { + param($shell) + + $out = dsc completer $shell 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Match 'completion-probe' + } + + It 'Describes plug-in commands in the completion script' { + $out = dsc completer powershell 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Match "'completion-probe', 'completion-probe', \[CompletionResultType\]::ParameterValue, 'Probe for completion scripts'" + } + } + + Context 'Manifests that are skipped' { + BeforeAll { + $script:skippedDir = Join-Path $TestDrive 'skipped' + New-TestPlugin -Directory $skippedDir -Type 'Test.Skipped/Reserved' -Command 'config' + New-TestPlugin -Directory $skippedDir -Type 'Test.Skipped/ReservedAlias' -Command 'mcp' + New-TestPlugin -Directory $skippedDir -Type 'Test.Skipped/ReservedHelp' -Command 'help' + New-TestPlugin -Directory $skippedDir -Type 'Test.Skipped/ConditionFalse' -Command 'condition-false' -Extra @{ + condition = '[equals(1, 2)]' + } + New-TestPlugin -Directory $skippedDir -Type 'Test.Skipped/ConditionTrue' -Command 'condition-true' -Extra @{ + condition = '[equals(1, 1)]' + } + '{ this is not a manifest' | Set-Content -Path (Join-Path $skippedDir 'broken.dsc.plugin.json') + Use-PluginDirectory -Directory $skippedDir + } + + AfterAll { + Use-PluginDirectory -Directory $pluginDir + } + + It 'Lists only the plug-ins that can provide a command' { + $out = dsc plugin list 'Test.Skipped/*' 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + @($out).Count | Should -Be 1 + $out.type | Should -BeExactly 'Test.Skipped/ConditionTrue' + } + + It 'Warns about a plug-in that uses the name of a built-in command: ' -TestCases @( + @{ command = 'config'; type = 'Test.Skipped/Reserved' } + @{ command = 'mcp'; type = 'Test.Skipped/ReservedAlias' } + @{ command = 'help'; type = 'Test.Skipped/ReservedHelp' } + ) { + param($command, $type) + + $null = dsc plugin list 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 + $stderr = Get-Content -Raw $TestDrive/error.txt + $stderr | Should -Match "WARN.*'$type'.*'$command'.*built-in command" + } + + It 'Warns about a manifest that cannot be loaded' { + $null = dsc plugin list 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 + Get-Content -Raw $TestDrive/error.txt | Should -Match 'WARN.*broken\.dsc\.plugin\.json' + } + + It 'Never runs a plug-in in place of a built-in command' { + $out = dsc config --help 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 + $out | Should -Match 'Apply a configuration document' + $out | Should -Not -Match 'HELP_ARGS' + } + + It 'Does not provide the command of a plug-in whose condition is false' { + $null = dsc condition-false 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + Get-Content -Raw $TestDrive/error.txt | Should -Match "unrecognized subcommand 'condition-false'" + } + + It 'Provides the command of a plug-in whose condition is true' { + $out = dsc condition-true 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.env.DSC_PLUGIN_COMMAND | Should -BeExactly 'condition-true' + } + + It 'Keeps warnings about manifests out of the top-level help' { + $out = dsc --help 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 + Get-Content -Raw $TestDrive/error.txt | Should -BeNullOrEmpty + $out | Should -Match '(?m)^ condition-true\s' + $out | Should -Not -Match '(?m)^ condition-false\s' + } + } + + Context 'Precedence between plug-ins' { + BeforeAll { + $script:firstDir = Join-Path $TestDrive 'first' + $script:secondDir = Join-Path $TestDrive 'second' + # The same type in two directories: the highest version wins, wherever it is. + New-TestPlugin -Directory $firstDir -Type 'Test.Precedence/Versioned' -Command 'versioned' -Version '1.0.0' + New-TestPlugin -Directory $secondDir -Type 'Test.Precedence/Versioned' -Command 'versioned' -Version '2.0.0' + # Different types with the same command: the first directory wins. + New-TestPlugin -Directory $firstDir -Type 'Test.Precedence/Zeta' -Command 'shared' -Version '1.0.0' + New-TestPlugin -Directory $secondDir -Type 'Test.Precedence/Alpha' -Command 'shared' -Version '9.0.0' + Use-PluginDirectory -Directory $firstDir, $secondDir + } + + AfterAll { + Use-PluginDirectory -Directory $pluginDir + } + + It 'Uses the highest version of a plug-in type' { + $out = dsc plugin list 'Test.Precedence/Versioned' 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + @($out).Count | Should -Be 1 + $out.version | Should -BeExactly '2.0.0' + $out.directory | Should -BeExactly $secondDir + + $result = dsc versioned 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $result.env.DSC_PLUGIN_DIR | Should -BeExactly $secondDir + } + + It 'Reports the plug-in found later as shadowed' { + $out = dsc plugin list 'Test.Precedence/*' 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + ($out | Where-Object type -EQ 'Test.Precedence/Zeta').shadowed | Should -BeFalse + ($out | Where-Object type -EQ 'Test.Precedence/Alpha').shadowed | Should -BeTrue + } + + It 'Marks the shadowed plug-in in the table' { + $out = dsc plugin list 'Test.Precedence/*' -o table-no-truncate 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Match 'shared \(shadowed\)\s+Test\.Precedence/Alpha' + $out | Should -Match 'shared\s+Test\.Precedence/Zeta' + } + + It 'Runs the plug-in found first and warns about the shadowed plug-in' { + $out = dsc shared 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.env.DSC_PLUGIN_DIR | Should -BeExactly $firstDir + $out.env.DSC_PLUGIN_COMMAND | Should -BeExactly 'shared' + $stderr = Get-Content -Raw $TestDrive/error.txt + $stderr | Should -Match "WARN.*'Test\.Precedence/Zeta'.*'Test\.Precedence/Alpha'" + $stderr | Should -Match "'dsc Test\.Precedence/Alpha'" + } + + It 'Runs a shadowed plug-in by its type name without a warning' { + $out = dsc Test.Precedence/Alpha one --two 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.args | Should -Be @('one', '--two') + $out.env.DSC_PLUGIN_DIR | Should -BeExactly $secondDir + $out.env.DSC_PLUGIN_COMMAND | Should -BeExactly 'Test.Precedence/Alpha' + Get-Content -Raw $TestDrive/error.txt | Should -Not -Match 'shadowed' + } + + It 'Runs the plug-in that provides the command by its type name without a warning' { + $out = dsc Test.Precedence/Zeta 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.env.DSC_PLUGIN_DIR | Should -BeExactly $firstDir + Get-Content -Raw $TestDrive/error.txt | Should -Not -Match 'shadowed' + } + + It 'Compares the type name case-insensitively' { + $out = dsc test.precedence/ALPHA 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.env.DSC_PLUGIN_DIR | Should -BeExactly $secondDir + } + + It 'Shows the help of a shadowed plug-in by its type name' { + $out = dsc help Test.Precedence/Alpha 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out[0] | Should -BeExactly 'Usage: dsc Test.Precedence/Alpha [OPTIONS]' + $out[1] | Should -BeExactly 'HELP_ARGS: --help' + } + + It 'Reports an unrecognized subcommand for a type name that no plug-in has' { + $null = dsc Test.Precedence/DoesNotExist 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + Get-Content -Raw $TestDrive/error.txt | Should -Match "unrecognized subcommand 'Test\.Precedence/DoesNotExist'" + } + + It 'Lists a shadowed command once in the top-level help' { + $out = dsc --help 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + @($out | Where-Object { $_ -match '^ shared\s' }).Count | Should -Be 1 + } + } + + Context 'Disabling plug-ins' { + BeforeAll { + # A settings file in the working directory is the workspace settings file. + $script:workspace = New-Item -ItemType Directory -Force -Path (Join-Path $TestDrive 'workspace') + @{ plugins = @{ enabled = $false } } | ConvertTo-Json | Set-Content -Path (Join-Path $workspace 'dsc.settings.json') + Push-Location $workspace + } + + AfterAll { + Pop-Location + } + + It 'Lists no plug-ins' { + $out = dsc plugin list 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -BeNullOrEmpty + } + + It 'Reports a plug-in command as an unrecognized subcommand' { + $out = dsc echo 2> $TestDrive/error.txt + $LASTEXITCODE | Should -Be 2 + $out | Should -BeNullOrEmpty + $stderr = Get-Content -Raw $TestDrive/error.txt + $stderr | Should -Match "error: unrecognized subcommand 'echo'" + $stderr | Should -Not -Match 'HELP_ARGS' + } + + It 'Does not list plug-in commands in the top-level help' { + $out = dsc --help 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Not -Match 'Plug-in commands:' + $out | Should -Match '(?m)^Commands:\r?$' + } + + It 'Does not include plug-in commands in the completion script' { + $out = dsc completer powershell 2> $TestDrive/error.txt | Out-String + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out | Should -Not -Match "'echo'" + } + + It 'Runs plug-ins again when the settings files are ignored' { + $out = dsc --ignore-settings-file echo 2> $TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.txt) + $out.env.DSC_PLUGIN_COMMAND | Should -BeExactly 'echo' + } + } +} diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index ea1de3f57..5224a48e0 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -64,6 +64,8 @@ pretty_assertions = { workspace = true } test-case = { workspace = true } # Enables serializing test execution for shared context stubs serial_test = { workspace = true } +# Enables creating temporary directories for discovery tests +tempfile = { workspace = true } [target.'cfg(not(target_os = "windows"))'.dev-dependencies] nix = { workspace = true, features = ["user"] } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index dc66170cc..9dcb3f89e 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -934,6 +934,30 @@ parsingExpression = "Parsing expression" unknownExpressionType = "Unknown expression type: %{kind}" skippingExpressionProcessing = "Skipping expression processing" +[plugins.command_name] +invalidName = "invalid plug-in command name '%{name}': %{err}" +emptyName = "command name cannot be an empty string" +invalidFirstCharacter = "command name must start with a lowercase ASCII letter, but starts with '%{character}'" +invalidCharacters = "command name can only contain lowercase ASCII letters, ASCII digits, and hyphens, but contains '%{characters}'" +misplacedHyphen = "command name cannot end with a hyphen or contain consecutive hyphens" + +[plugins.discovery] +disabled = "Skipping plug-in discovery because the `%{setting}` setting is `false`" +directoryNotReadable = "Skipping directory '%{path}' because it can't be read" +failedLoadManifest = "Failed to load plug-in manifest '%{path}': %{err}" +failedLoadManifestList = "Skipping manifest list '%{path}' for plug-in discovery because it can't be parsed: %{err}" +pluginsNotArray = "Skipping the plug-ins in manifest list '%{path}' because the `plugins` property isn't an array" +conditionNotMet = "Condition '%{condition}' not met, skipping plug-in '%{plugin}' in manifest '%{path}'" +reservedCommand = "Skipping plug-in '%{plugin}' in manifest '%{path}' because its command name '%{command}' is the name of a built-in command" +newerVersionFound = "Using version %{version} of plug-in '%{plugin}' from manifest '%{path}'" +pluginFound = "Plug-in '%{plugin}' version %{version} found for command '%{command}'" +pluginShadowed = "Plug-in '%{plugin}' is shadowed because a plug-in found earlier provides the command '%{command}'" + +[plugins.plugin_manifest] +pluginManifestSchemaTitle = "Plug-in manifest schema URI" +pluginManifestSchemaDescription = "Defines the JSON Schema the plug-in manifest adheres to." +pathNotRelative = "The `manifestRelativePath` argument '%{path}' of plug-in '%{plugin}' must be a non-empty path relative to the directory that contains the manifest" + [dscerror] adapterNotFound = "Adapter not found" booleanConversion = "Function boolean argument conversion" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index 9fa0c5f17..57589ed41 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -87,6 +87,26 @@ schemas: Invalid exit code. Each exit code must be defined as a string representing a 32-bit signed integer, like `5` or `-2147024891`. + pluginCommandName: + title: + en-us: Plug-in command name + description: + en-us: >- + Defines the name of the command a plug-in adds to DSC. + markdownDescription: + en-us: |- + Defines the name of the command a plug-in adds to DSC. The user specifies the command + name after `dsc`, like `lint` in `dsc lint`. + + A command name must start with a lowercase ASCII letter and can only contain lowercase + ASCII letters, ASCII digits, and hyphens. It can't end with a hyphen or contain + consecutive hyphens. DSC compares command names case-sensitively. + patternErrorMessage: + en-us: >- + Invalid plug-in command name. A command name must start with a lowercase ASCII letter + and can only contain lowercase ASCII letters, ASCII digits, and hyphens, like `lint` or + `resource-kit`. It can't end with a hyphen or contain consecutive hyphens. + resourceType: title: en-us: Fully qualified type name diff --git a/lib/dsc-lib/locales/schemas.outputs.yaml b/lib/dsc-lib/locales/schemas.outputs.yaml new file mode 100644 index 000000000..04f340c80 --- /dev/null +++ b/lib/dsc-lib/locales/schemas.outputs.yaml @@ -0,0 +1,102 @@ +_version: 2 +schemas: + outputs: + plugin: + list: + title: + en-us: Plug-in list output + description: + en-us: >- + Describes a command plug-in that DSC discovered. + markdownDescription: + en-us: |- + Describes a command plug-in that DSC discovered. The `dsc plugin list` command returns + one of these objects for every discovered plug-in, including plug-ins that are shadowed + by another plug-in that provides the same command. + + For more information, see the [plug-in list output reference][01]. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/outputs/plugin/list + fields: + type: + title: + en-us: Type + description: + en-us: >- + The fully qualified type name that identifies the plug-in. + markdownDescription: + en-us: |- + Identifies the plug-in by its fully qualified type name, like `Microsoft.DSC/Lint`. + version: + title: + en-us: Version + description: + en-us: >- + The semantic version of the plug-in. + markdownDescription: + en-us: |- + Indicates the semantic version of the plug-in. When DSC finds more than one + manifest with the same type, it only returns the plug-in with the highest version. + command: + title: + en-us: Command + description: + en-us: >- + The name of the command the plug-in adds to DSC. + markdownDescription: + en-us: |- + Indicates the name of the command the plug-in adds to DSC. For example, a plug-in + with the command `lint` is invoked as `dsc lint`. + description: + title: + en-us: Description + description: + en-us: >- + The description of the command. + markdownDescription: + en-us: |- + Describes the command. The value is `null` when the manifest doesn't define a + description. + deprecationMessage: + title: + en-us: Deprecation message + description: + en-us: >- + The message that indicates the plug-in is deprecated. + markdownDescription: + en-us: |- + Indicates that the plug-in is deprecated. The value is `null` when the plug-in + isn't deprecated. DSC writes the message as a warning each time the command is + invoked. + path: + title: + en-us: Path + description: + en-us: >- + The path of the file that defines the plug-in manifest. + markdownDescription: + en-us: |- + Indicates the path of the file that defines the plug-in manifest. For a plug-in + defined in a manifest list, this is the path of the manifest list. + directory: + title: + en-us: Directory + description: + en-us: >- + The directory that contains the plug-in manifest. + markdownDescription: + en-us: |- + Indicates the directory that contains the plug-in manifest. DSC resolves the + `manifestRelativePath` arguments of the plug-in against this directory. + shadowed: + title: + en-us: Shadowed + description: + en-us: >- + Indicates whether another plug-in provides the same command. + markdownDescription: + en-us: |- + Indicates whether another plug-in that DSC discovered earlier provides the same + command. DSC never runs a shadowed plug-in for its command name. To run a + shadowed plug-in, specify its type name instead of the command name, like + `dsc OpenDSC/Lint`. diff --git a/lib/dsc-lib/locales/schemas.plugin.yaml b/lib/dsc-lib/locales/schemas.plugin.yaml new file mode 100644 index 000000000..b97d6f21b --- /dev/null +++ b/lib/dsc-lib/locales/schemas.plugin.yaml @@ -0,0 +1,188 @@ +_version: 2 +schemas: + plugin: + manifest: + title: + en-us: Plug-in manifest + description: + en-us: >- + Defines a command plug-in: the command it adds to DSC and how DSC runs it. + markdownDescription: + en-us: |- + Defines a command plug-in. A plug-in adds a top-level command to DSC without being part + of the DSC executable. The manifest names the command and the executable that implements + it. + + DSC discovers plug-in manifests in the same directories as resource and extension + manifests. The file name of a plug-in manifest must end with `.dsc.plugin.json`, + `.dsc.plugin.yaml`, or `.dsc.plugin.yml`. A manifest list can define plug-ins in its + `plugins` property. + + When the first argument to DSC isn't a built-in command, DSC runs the plug-in that + provides the command with the remaining arguments and exits with the plug-in's exit code. + + For more information, see the [plug-in manifest reference][01]. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/plugin/manifest/root + fields: + type: + title: + en-us: Type + description: + en-us: >- + The fully qualified type name that identifies the plug-in. + markdownDescription: + en-us: |- + Defines the fully qualified type name that identifies the plug-in, like + `Microsoft.DSC/Lint`. The name uses the same syntax as the type name of a resource + or an extension. + + When DSC finds more than one manifest with the same type, it uses the manifest with + the highest version. + version: + title: + en-us: Version + description: + en-us: >- + The semantic version of the plug-in. + markdownDescription: + en-us: |- + Defines the semantic version of the plug-in. When DSC finds more than one manifest + with the same type, it uses the manifest with the highest version. + condition: + title: + en-us: Condition + description: + en-us: >- + An expression that defines whether the plug-in is available. + markdownDescription: + en-us: |- + Defines a DSC expression that must evaluate to a boolean. When the expression + evaluates to `false`, DSC treats the plug-in as not installed: the command isn't + listed and can't be invoked. + + Use a condition for a plug-in that depends on a tool that might not be installed. For + example, the following condition makes a plug-in available only when `pwsh` is + discoverable: + + ```json + "condition": "[not(equals(tryWhich('pwsh'), null()))]" + ``` + deprecationMessage: + title: + en-us: Deprecation message + description: + en-us: >- + A message that indicates the plug-in is deprecated. + markdownDescription: + en-us: |- + Defines a message that indicates the plug-in is deprecated. When this property is + defined, DSC writes the message as a warning each time the command is invoked. + description: + title: + en-us: Description + description: + en-us: >- + A one-line description of the command. + markdownDescription: + en-us: |- + Defines a one-line description of the command. DSC shows the description next to the + command in `dsc --help` and in the output of `dsc plugin list`. + tags: + title: + en-us: Tags + description: + en-us: >- + Defines a list of searchable terms for the plug-in. + markdownDescription: + en-us: |- + Defines a list of searchable terms for the plug-in. + metadata: + title: + en-us: Metadata + description: + en-us: >- + Arbitrary data for the plug-in that DSC doesn't interpret. + markdownDescription: + en-us: |- + Defines arbitrary data for the plug-in. DSC doesn't interpret the metadata. It's + returned with the manifest in the output of `dsc plugin list`. + command: + title: + en-us: Command + description: + en-us: >- + Defines the command the plug-in adds to DSC and how DSC runs it. + markdownDescription: + en-us: |- + Defines the command the plug-in adds to DSC and how DSC runs it. + + DSC starts the executable in the caller's working directory, with the standard input, + output, and error streams of DSC. It passes the arguments defined in `args` followed by + the arguments the user specified after the command name. DSC exits with the exit code + of the executable. + + DSC sets environment variables that tell the plug-in how it was invoked, like + `DSC_EXE`, `DSC_VERSION`, `DSC_PLUGIN_COMMAND`, and `DSC_PLUGIN_DIR`. + + For more information, see the [command property reference][01]. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/plugin/manifest/command + fields: + name: + title: + en-us: Command name + description: + en-us: >- + The name of the command, which the user specifies after `dsc`. + markdownDescription: + en-us: |- + Defines the name of the command, which the user specifies after `dsc`. For example, + a plug-in with the command name `lint` is invoked as `dsc lint`. + + The name can't be the name or the alias of a built-in command, like `config` or + `resource`. DSC always matches built-in commands first and skips a plug-in that + uses one of their names. + + When plug-ins with different types define the same command name, the plug-in DSC + discovers first provides the command. The others are reported as shadowed in the + output of `dsc plugin list` and can be run by their type name, like `dsc OpenDSC/Lint`. + executable: + title: + en-us: Executable + description: + en-us: >- + The name of the executable to run for the command. + markdownDescription: + en-us: |- + Defines the name of the executable to run for the command. The value must be the + name of an executable discoverable in the system's `PATH` environment variable, + the name of an executable in the same directory as the manifest, or the full path + to the executable. A file extension is only required when the operating system + doesn't recognize the file as an executable without it. + + To implement a plug-in as a script, define the interpreter as the executable and + pass the script in `args` with the `manifestRelativePath` argument kind. + args: + title: + en-us: Arguments + description: + en-us: >- + The arguments to pass to the executable before the arguments the user specified. + markdownDescription: + en-us: |- + Defines the list of arguments to pass to the executable before the arguments the + user specified after the command name. Each item is one of the following: + + - A string, which DSC passes as written. + - An object with the `manifestRelativePath` property. DSC replaces the item with + the absolute path of the file, resolved against the directory that contains the + manifest. The value must be a relative path. + + A plug-in runs in the caller's working directory, not in the directory that + contains the manifest. Use `manifestRelativePath` to pass a file that ships with + the plug-in, like a script: + + ```json + "args": ["-NoProfile", "-File", { "manifestRelativePath": "graph.ps1" }] + ``` diff --git a/lib/dsc-lib/locales/schemas.settings.yaml b/lib/dsc-lib/locales/schemas.settings.yaml index 4d522e7d1..0e0c9acbf 100644 --- a/lib/dsc-lib/locales/schemas.settings.yaml +++ b/lib/dsc-lib/locales/schemas.settings.yaml @@ -228,7 +228,29 @@ schemas: markdownDescription: en-us: | Append environment path to resource paths. If this is set to true, the environment path will be appended to the resource paths. - + plugins: + title: + en-us: "Plug-ins" + description: + en-us: "Settings related to command plug-ins" + markdownDescription: + en-us: | + Settings related to command plug-ins. A command plug-in adds a top-level command to + `dsc` without being compiled into it. + fields: + enabled: + title: + en-us: "Enabled" + description: + en-us: "Whether DSC discovers and runs command plug-ins" + markdownDescription: + en-us: | + Whether DSC discovers and runs command plug-ins. The default value is `true`. + + If this is set to `false`, DSC doesn't discover plug-in manifests. A command that + isn't built in fails as an unrecognized subcommand, the help doesn't list plug-in + commands, and listing the plug-ins returns nothing. + sources: default: title: @@ -343,6 +365,14 @@ schemas: markdownDescription: en-us: | Settings related to the resource path. This represents the values for the settings that control the resource path. + plugins: + title: + en-us: "Plug-ins" + description: + en-us: "Settings related to command plug-ins" + markdownDescription: + en-us: | + Settings related to command plug-ins. This represents the values for the settings that control whether DSC discovers and runs command plug-ins. policyFile: title: en-us: "Policy settings file" @@ -383,7 +413,15 @@ schemas: markdownDescription: en-us: | Settings related to the resource path. This represents the values for the settings that control the resource path. - + plugins: + title: + en-us: "Plug-ins" + description: + en-us: "Settings related to command plug-ins" + markdownDescription: + en-us: | + Settings related to command plug-ins. This represents the values for the settings that control whether DSC discovers and runs command plug-ins. A value defined as policy can't be overridden, so setting `enabled` to `false` keeps DSC to the built-in commands. + resolved: field: title: @@ -436,6 +474,14 @@ schemas: markdownDescription: en-us: | Settings related to the resource path. This represents the values for the settings that control the resource path. + plugins: + title: + en-us: "Plug-ins" + description: + en-us: "Settings related to command plug-ins" + markdownDescription: + en-us: | + Settings related to command plug-ins. This represents the values for the settings that control whether DSC discovers and runs command plug-ins. all: title: en-us: "DSC settings" @@ -483,6 +529,8 @@ schemas: directories: [] append_env_path: true restricted: false + plugins: + enabled: true ``` machine: title: diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index b1a254f5d..56683463f 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -13,6 +13,7 @@ use crate::dscresources::command_resource::invoke_command; use crate::dscerror::DscError; use crate::extensions::dscextension::{self, DscExtension, Capability as ExtensionCapability}; use crate::extensions::extension_manifest::ExtensionManifest; +use crate::plugins::plugin_manifest::PluginManifest; use crate::progress::{ProgressBar, ProgressFormat}; use crate::schemas::dsc_repo::DscRepoSchema; use crate::schemas::transforms::idiomaticize_externally_tagged_enum; @@ -53,6 +54,10 @@ pub struct ManifestList { pub adapted_resources: Option>, pub resources: Option>, pub extensions: Option>, + /// The command plug-ins the list defines. Resource discovery doesn't load plug-ins. They are + /// discovered separately, only when a plug-in command is needed, by + /// [`discover_plugins()`](crate::plugins::discovery::discover_plugins). + pub plugins: Option>, } #[allow(clippy::large_enum_variant)] @@ -133,7 +138,7 @@ impl CommandDiscovery { /// - When joining the directories into a new `PATH` fails, which could /// occur due to invalid paths in `resourcePath.directories` or system /// limitations. - fn get_resource_paths() -> Result, DscError> { + pub(crate) fn get_resource_paths() -> Result, DscError> { // Check if the resource paths have already been resolved and cached; // if so, return the cached paths immediately to avoid redundant // computation and potentially mutating PATH repeatedly. @@ -776,7 +781,21 @@ fn insert_resource(resources: &mut DiscoveryCache, resource: Box) -> Result { +/// Evaluates the condition of a manifest. +/// +/// # Arguments +/// +/// * `condition` - The expression from the `condition` property of a manifest, if it defines one. +/// +/// # Returns +/// +/// The result of the expression, or `true` when the manifest doesn't define a condition. +/// +/// # Errors +/// +/// Returns a [`DscError`] when the expression can't be parsed or evaluated, or when its result +/// isn't a boolean. +pub(crate) fn evaluate_condition(condition: Option<&str>) -> Result { if let Some(cond) = condition { let mut statement = Statement::new()?; let result = statement.parse_and_execute(cond, &Context::new())?; diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index d97ebe78e..88f253166 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -122,6 +122,9 @@ pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.operation"))] Operation(String), + #[error(transparent)] + PluginCommandName(#[from] crate::plugins::command_name::PluginCommandNameError), + #[error("{t}: {0}", t = t!("dscerror.parser"))] Parser(String), diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs index 20ce69d02..df3fe9f7c 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -18,6 +18,7 @@ pub mod dscresources; pub mod extensions; pub mod functions; pub mod parser; +pub mod plugins; pub mod progress; pub mod resources; pub mod schemas; diff --git a/lib/dsc-lib/src/plugins/command_name.rs b/lib/dsc-lib/src/plugins/command_name.rs new file mode 100644 index 000000000..19f8e4080 --- /dev/null +++ b/lib/dsc-lib/src/plugins/command_name.rs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fmt::{Display, Formatter}, ops::Deref, str::FromStr}; + +use miette::Diagnostic; +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; + +/// Defines the name of the command a plug-in adds to a DSC host. +/// +/// A command name is what the user types after the name of the host, like `lint` in `dsc lint`. +/// A valid command name: +/// +/// - Starts with a lowercase ASCII letter. +/// - Contains only lowercase ASCII letters, ASCII digits, and hyphens. +/// - Doesn't end with a hyphen or contain consecutive hyphens. +/// +/// These rules are the same as the [`VALIDATING_PATTERN`]. DSC compares command names +/// case-sensitively, and because a valid name never contains an uppercase letter, two names are +/// equal only when their text is identical. +/// +/// Every constructor validates these rules, and deserializing a name that breaks them fails, so an +/// instance is always valid. +/// +/// # Examples +/// +/// ```rust +/// use dsc_lib::plugins::command_name::PluginCommandName; +/// +/// let name = PluginCommandName::parse("lint").unwrap(); +/// assert_eq!(name, "lint"); +/// +/// assert!(PluginCommandName::parse("Lint").is_err()); +/// assert!(PluginCommandName::parse("resource-kit").is_ok()); +/// assert!(PluginCommandName::parse("resource--kit").is_err()); +/// ``` +/// +/// [`VALIDATING_PATTERN`]: PluginCommandName::VALIDATING_PATTERN +#[derive( + Clone, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, + DscRepoSchema +)] +#[dsc_repo_schema(base_name = "pluginCommandName", folder_path = "definitions")] +#[serde(try_from = "String", into = "String")] +#[schemars( + transform = PluginCommandName::transform_export_schema_uris, + transform = PluginCommandName::transform_schema_docs_strict, + extend( + "pattern" = PluginCommandName::VALIDATING_PATTERN, + "patternErrorMessage" = schema_i18n!("patternErrorMessage"), + ) +)] +pub struct PluginCommandName(String); + +/// Defines the errors that can occur when parsing a [`PluginCommandName`]. +#[derive(Debug, Clone, PartialEq, Eq, Error, Diagnostic)] +pub enum PluginCommandNameError { + /// Indicates that the text isn't a valid command name for one or more reasons. + /// + /// The `errors` field lists every rule the text breaks, so the author of a plug-in manifest + /// can review all of them at once instead of discovering them one at a time. + #[error("{t}", t = t!( + "plugins.command_name.invalidName", + name = text, + err = errors.iter().map(ToString::to_string).collect::>().join(", ") + ))] + InvalidName { + /// The text that failed to parse as a command name. + text: String, + /// The specific rules the text breaks. + #[related] + errors: Vec, + }, + + /// Indicates that the text is an empty string. + /// + /// Validation raises this error in the `errors` field of the main [`InvalidName`] error. + /// + /// [`InvalidName`]: Self::InvalidName + #[error("{t}", t = t!("plugins.command_name.emptyName"))] + EmptyName, + + /// Indicates that the text doesn't start with a lowercase ASCII letter. + /// + /// Validation raises this error in the `errors` field of the main [`InvalidName`] error. + /// + /// [`InvalidName`]: Self::InvalidName + #[error("{t}", t = t!("plugins.command_name.invalidFirstCharacter", character = character))] + InvalidFirstCharacter { + /// The first character of the text. + character: char, + }, + + /// Indicates that the text contains characters other than lowercase ASCII letters, ASCII + /// digits, and hyphens. + /// + /// Validation raises this error in the `errors` field of the main [`InvalidName`] error. + /// + /// [`InvalidName`]: Self::InvalidName + #[error("{t}", t = t!("plugins.command_name.invalidCharacters", characters = characters))] + InvalidCharacters { + /// The distinct invalid characters, in the order they first appear in the text. + characters: String, + }, + + /// Indicates that the text ends with a hyphen or contains consecutive hyphens. + /// + /// Validation raises this error in the `errors` field of the main [`InvalidName`] error. + /// + /// [`InvalidName`]: Self::InvalidName + #[error("{t}", t = t!("plugins.command_name.misplacedHyphen"))] + MisplacedHyphen, +} + +impl PluginCommandName { + /// Defines the regular expression that a valid command name matches. + /// + /// The name must start with a lowercase ASCII letter, followed by any number of lowercase + /// ASCII letters and ASCII digits. A hyphen may separate groups of letters and digits, but the + /// name can't end with a hyphen or contain consecutive hyphens. + /// + /// [`parse()`] checks the same rules without the regular expression so it can report every + /// rule a name breaks. The pattern is published in the JSON Schema for the type. + /// + /// [`parse()`]: Self::parse + pub const VALIDATING_PATTERN: &str = r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$"; + + /// Parses a string as a command name. + /// + /// # Arguments + /// + /// * `text` - The text to parse as a command name. + /// + /// # Errors + /// + /// Returns [`PluginCommandNameError::InvalidName`] when the text breaks any of the rules for + /// a command name. The error lists every rule the text breaks. + pub fn parse(text: &str) -> Result { + Self::validate(text)?; + Ok(Self(text.to_string())) + } + + /// Validates a string as a command name without creating an instance. + /// + /// # Arguments + /// + /// * `text` - The text to validate as a command name. + /// + /// # Errors + /// + /// Returns [`PluginCommandNameError::InvalidName`] when the text breaks any of the rules for + /// a command name. The error lists every rule the text breaks: + /// + /// * [`PluginCommandNameError::EmptyName`] when the text is an empty string. No other rule is + /// checked for an empty string. + /// * [`PluginCommandNameError::InvalidFirstCharacter`] when the text doesn't start with a + /// lowercase ASCII letter. + /// * [`PluginCommandNameError::InvalidCharacters`] when the text contains characters other + /// than lowercase ASCII letters, ASCII digits, and hyphens. + /// * [`PluginCommandNameError::MisplacedHyphen`] when the text ends with a hyphen or contains + /// consecutive hyphens. + pub fn validate(text: &str) -> Result<(), PluginCommandNameError> { + let mut errors = Vec::new(); + + match text.chars().next() { + None => errors.push(PluginCommandNameError::EmptyName), + Some(first) => { + if !first.is_ascii_lowercase() { + errors.push(PluginCommandNameError::InvalidFirstCharacter { character: first }); + } + + let mut invalid_characters = String::new(); + for character in text.chars() { + let is_valid = character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '-'; + if !is_valid && !invalid_characters.contains(character) { + invalid_characters.push(character); + } + } + if !invalid_characters.is_empty() { + errors.push(PluginCommandNameError::InvalidCharacters { + characters: invalid_characters, + }); + } + + if text.ends_with('-') || text.contains("--") { + errors.push(PluginCommandNameError::MisplacedHyphen); + } + }, + } + + if errors.is_empty() { + Ok(()) + } else { + Err(PluginCommandNameError::InvalidName { text: text.to_string(), errors }) + } + } +} + +// Enables using command names in `format!()` and similar macros. +impl Display for PluginCommandName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +// Enables passing a command name as `&str`. +impl AsRef for PluginCommandName { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +// Enables directly accessing string methods on a command name, like `starts_with()`. +impl Deref for PluginCommandName { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.deref() + } +} + +impl FromStr for PluginCommandName { + type Err = PluginCommandNameError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +// Enables converting from a `String` and raising the appropriate error for an invalid name. Serde +// uses this implementation to validate a name while deserializing it. +impl TryFrom for PluginCommandName { + type Error = PluginCommandNameError; + + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(value)) + } +} + +// Enables converting from a string slice and raising the appropriate error for an invalid name. +impl TryFrom<&str> for PluginCommandName { + type Error = PluginCommandNameError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +// Enables infallibly converting a command name into the underlying string. +impl From for String { + fn from(value: PluginCommandName) -> Self { + value.0 + } +} + +// Enable comparing the newtype with strings in either direction. +impl PartialEq for PluginCommandName { + fn eq(&self, other: &String) -> bool { + &self.0 == other + } +} + +impl PartialEq for String { + fn eq(&self, other: &PluginCommandName) -> bool { + self == &other.0 + } +} + +impl PartialEq for PluginCommandName { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq for str { + fn eq(&self, other: &PluginCommandName) -> bool { + self == other.0 + } +} + +impl PartialEq<&str> for PluginCommandName { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for &str { + fn eq(&self, other: &PluginCommandName) -> bool { + *self == other.0 + } +} diff --git a/lib/dsc-lib/src/plugins/discovery.rs b/lib/dsc-lib/src/plugins/discovery.rs new file mode 100644 index 000000000..e5c39da7d --- /dev/null +++ b/lib/dsc-lib/src/plugins/discovery.rs @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde_json::Value; +use std::collections::HashSet; +use std::ffi::OsStr; +use std::fs::read_to_string; +use std::path::{Path, PathBuf}; +use tracing::{debug, trace, warn}; + +use crate::discovery::command_discovery::{ + CommandDiscovery, + DSC_MANIFEST_LIST_EXTENSIONS, + evaluate_condition, +}; +use crate::dscerror::DscError; +use crate::plugins::command_name::PluginCommandName; +use crate::plugins::dscplugin::DscPlugin; +use crate::plugins::plugin_manifest::PluginManifest; +use crate::settings::global as global_settings; + +/// The file name suffixes that identify a plug-in manifest. +pub const DSC_PLUGIN_EXTENSIONS: [&str; 3] = [".dsc.plugin.json", ".dsc.plugin.yaml", ".dsc.plugin.yml"]; + +/// Discovers the command plug-ins that are available to a DSC host. +/// +/// DSC searches the same directories it searches for resource and extension manifests, in the +/// same order, and applies the rules that [`discover_plugins_in()`] describes. +/// +/// When the `plugins.enabled` setting is `false`, the function doesn't search and returns an empty +/// list. +/// +/// # Arguments +/// +/// * `reserved_commands` - The names and aliases of the commands that are built into the host. +/// +/// # Returns +/// +/// The discovered plug-ins in the order DSC found them, including shadowed plug-ins. +/// +/// # Errors +/// +/// Returns a [`DscError`] when DSC can't resolve the directories to search, for example when the +/// `resourcePath.restricted` setting is `true` and `resourcePath.directories` is empty. +pub fn discover_plugins>(reserved_commands: &[S]) -> Result, DscError> { + if !global_settings::get_or_init().resolved().plugins.enabled.value { + debug!("{}", t!("plugins.discovery.disabled", setting = "plugins.enabled")); + return Ok(Vec::new()); + } + + let directories = CommandDiscovery::get_resource_paths()?; + Ok(discover_plugins_in(&directories, reserved_commands)) +} + +/// Discovers the command plug-ins defined in a list of directories. +/// +/// DSC reads the files whose name ends with one of the [`DSC_PLUGIN_EXTENSIONS`] and the `plugins` +/// property of manifest lists. Subdirectories aren't searched. Discovery never runs a plug-in and +/// never asks a discovery extension for manifests. +/// +/// The function applies these rules to what it finds: +/// +/// 1. A manifest that fails to load or validate is skipped with a warning. +/// 1. A manifest whose `condition` evaluates to `false` is skipped. +/// 1. A manifest whose command name is one of the `reserved_commands` is skipped with a warning, +/// because the host matches its own commands first and the plug-in could never run. +/// 1. When the same type is found more than once, the highest version is kept. +/// 1. When plug-ins of different types use the same command name, the one found first provides +/// the command and the others are marked as [`shadowed`]. Within one directory, the type name +/// that sorts first is found first. +/// +/// Unlike [`discover_plugins()`], this function doesn't consult the DSC settings. +/// +/// # Arguments +/// +/// * `directories` - The directories to search, in the order to search them. +/// * `reserved_commands` - The names and aliases of the commands that are built into the host. +/// +/// # Returns +/// +/// The discovered plug-ins in the order DSC found them, including shadowed plug-ins. +/// +/// [`shadowed`]: DscPlugin::shadowed +pub fn discover_plugins_in>(directories: &[PathBuf], reserved_commands: &[S]) -> Vec { + let mut plugins: Vec = Vec::new(); + for directory in directories { + let mut found = load_plugins_in_directory(directory); + found.sort_by(|a, b| a.type_name.cmp(&b.type_name)); + + for plugin in found { + if reserved_commands.iter().any(|reserved| &*plugin.command == reserved.as_ref()) { + warn!("{}", t!( + "plugins.discovery.reservedCommand", + plugin = plugin.type_name, + path = plugin.path.display(), + command = plugin.command + )); + continue; + } + + if let Some(existing) = plugins.iter_mut().find(|p| p.type_name == plugin.type_name) { + if plugin.version > existing.version { + trace!("{}", t!( + "plugins.discovery.newerVersionFound", + plugin = plugin.type_name, + version = plugin.version, + path = plugin.path.display() + )); + *existing = plugin; + } + continue; + } + + trace!("{}", t!( + "plugins.discovery.pluginFound", + plugin = plugin.type_name, + version = plugin.version, + command = plugin.command + )); + plugins.push(plugin); + } + } + + let mut provided: HashSet = HashSet::new(); + for plugin in &mut plugins { + plugin.shadowed = !provided.insert(plugin.command.clone()); + if plugin.shadowed { + debug!("{}", t!( + "plugins.discovery.pluginShadowed", + plugin = plugin.type_name, + command = plugin.command + )); + } + } + + plugins +} + +/// Finds the plug-in that provides a command. +/// +/// # Arguments +/// +/// * `plugins` - The plug-ins that [`discover_plugins()`] returned. +/// * `command` - The command name to look up. The comparison is case-sensitive. +/// +/// # Returns +/// +/// The plug-in that provides the command, or [`None`] when no plug-in does. A shadowed plug-in is +/// never returned. +#[must_use] +pub fn find_plugin<'a>(plugins: &'a [DscPlugin], command: &str) -> Option<&'a DscPlugin> { + plugins.iter().find(|plugin| !plugin.shadowed && &*plugin.command == command) +} + +/// Finds a plug-in by its fully qualified type name. +/// +/// Unlike [`find_plugin()`], this function also returns a shadowed plug-in, which is how a user +/// runs a plug-in that doesn't provide its command. +/// +/// # Arguments +/// +/// * `plugins` - The plug-ins that [`discover_plugins()`] returned. +/// * `type_name` - The type name to look up, like `OpenDSC/Lint`. The comparison is +/// case-insensitive. +/// +/// # Returns +/// +/// The plug-in with the type name, or [`None`] when no plug-in has it. +#[must_use] +pub fn find_plugin_by_type<'a>(plugins: &'a [DscPlugin], type_name: &str) -> Option<&'a DscPlugin> { + plugins.iter().find(|plugin| plugin.type_name == type_name) +} + +/// Loads every plug-in defined in a directory. +/// +/// Manifests that can't be loaded, don't validate, or whose condition isn't met are skipped. A +/// directory that can't be read yields no plug-ins. +/// +/// # Arguments +/// +/// * `directory` - The directory to search. Subdirectories aren't searched. +fn load_plugins_in_directory(directory: &Path) -> Vec { + let Ok(entries) = directory.read_dir() else { + trace!("{}", t!("plugins.discovery.directoryNotReadable", path = directory.display())); + return Vec::new(); + }; + + // Sort the candidates so the result doesn't depend on the order the file system lists them. + let mut candidates: Vec<(PathBuf, bool)> = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + let file_name = path.file_name().and_then(OsStr::to_str)?.to_lowercase(); + if DSC_PLUGIN_EXTENSIONS.iter().any(|ext| file_name.ends_with(ext)) { + Some((path, false)) + } else if DSC_MANIFEST_LIST_EXTENSIONS.iter().any(|ext| file_name.ends_with(ext)) { + Some((path, true)) + } else { + None + } + }) + .collect(); + candidates.sort(); + + let mut plugins = Vec::new(); + for (path, is_manifest_list) in candidates { + let manifests = if is_manifest_list { + load_manifests_from_list(&path) + } else { + match load_manifest_file(&path) { + Ok(manifest) => vec![manifest], + Err(err) => { + warn!("{}", t!("plugins.discovery.failedLoadManifest", path = path.display(), err = err)); + continue; + } + } + }; + + for manifest in manifests { + match load_plugin(&path, manifest) { + Ok(Some(plugin)) => plugins.push(plugin), + Ok(None) => {}, + Err(err) => { + warn!("{}", t!("plugins.discovery.failedLoadManifest", path = path.display(), err = err)); + } + } + } + } + + plugins +} + +/// Reads a plug-in manifest from a file. +/// +/// # Arguments +/// +/// * `path` - The path of a file whose name ends with one of the [`DSC_PLUGIN_EXTENSIONS`]. +/// +/// # Errors +/// +/// Returns a [`DscError`] when the file can't be read or its content isn't a valid plug-in +/// manifest. +fn load_manifest_file(path: &Path) -> Result { + let contents = read_to_string(path)?; + if is_json_file(path) { + Ok(serde_json::from_str::(&contents)?) + } else { + Ok(serde_yaml::from_str::(&contents)?) + } +} + +/// Reads the plug-in manifests from the `plugins` property of a manifest list. +/// +/// Only the `plugins` property is interpreted, so an invalid resource or extension in the same +/// list doesn't hide the plug-ins. Resource discovery reports a list that can't be parsed, so this +/// function only logs that at the debug level. An invalid entry in `plugins` is skipped with a +/// warning. +/// +/// # Arguments +/// +/// * `path` - The path of a manifest list file. +fn load_manifests_from_list(path: &Path) -> Vec { + let list = read_to_string(path).map_err(DscError::from).and_then(|contents| { + if is_json_file(path) { + Ok(serde_json::from_str::(&contents)?) + } else { + Ok(serde_yaml::from_str::(&contents)?) + } + }); + let list = match list { + Ok(list) => list, + Err(err) => { + debug!("{}", t!("plugins.discovery.failedLoadManifestList", path = path.display(), err = err)); + return Vec::new(); + } + }; + + let Some(entries) = list.get("plugins").filter(|plugins| !plugins.is_null()) else { + return Vec::new(); + }; + let Some(entries) = entries.as_array() else { + warn!("{}", t!("plugins.discovery.pluginsNotArray", path = path.display())); + return Vec::new(); + }; + + let mut manifests = Vec::with_capacity(entries.len()); + for entry in entries { + match serde_json::from_value::(entry.clone()) { + Ok(manifest) => manifests.push(manifest), + Err(err) => { + warn!("{}", t!("plugins.discovery.failedLoadManifest", path = path.display(), err = err)); + } + } + } + + manifests +} + +/// Validates a manifest and evaluates its condition. +/// +/// # Arguments +/// +/// * `path` - The path of the file that defines the manifest. +/// * `manifest` - The manifest to turn into a plug-in. +/// +/// # Returns +/// +/// The plug-in, or [`None`] when the condition of the manifest evaluates to `false`. +/// +/// # Errors +/// +/// Returns a [`DscError`] when the manifest doesn't validate or its condition can't be evaluated +/// as a boolean. +fn load_plugin(path: &Path, manifest: PluginManifest) -> Result, DscError> { + manifest.validate()?; + + if !evaluate_condition(manifest.condition.as_deref())? { + debug!("{}", t!( + "plugins.discovery.conditionNotMet", + path = path.display(), + condition = manifest.condition.as_deref().unwrap_or_default(), + plugin = manifest.r#type + )); + return Ok(None); + } + + Ok(Some(DscPlugin::from_manifest(path, manifest))) +} + +/// Indicates whether a manifest file is JSON, as opposed to YAML, based on its file extension. +/// +/// # Arguments +/// +/// * `path` - The path of the manifest file. +fn is_json_file(path: &Path) -> bool { + path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("json")) +} diff --git a/lib/dsc-lib/src/plugins/dscplugin.rs b/lib/dsc-lib/src/plugins/dscplugin.rs new file mode 100644 index 000000000..03a11e239 --- /dev/null +++ b/lib/dsc-lib/src/plugins/dscplugin.rs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use crate::dscerror::DscError; +use crate::dscresources::command_resource::verify_executable_is_invokable; +use crate::plugins::command_name::PluginCommandName; +use crate::plugins::plugin_manifest::PluginManifest; +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; +use crate::types::{FullyQualifiedTypeName, SemanticVersion}; +use crate::util::canonicalize_which; + +/// Describes a discovered command plug-in. +/// +/// This is the data a host reports when it lists the available plug-ins, and what it needs to run +/// the plug-in's command. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[dsc_repo_schema(base_name = "list", folder_path = "outputs/plugin")] +#[schemars( + transform = DscPlugin::transform_export_schema_uris, + transform = DscPlugin::transform_schema_docs +)] +pub struct DscPlugin { + /// The namespaced name that identifies the plug-in. + #[serde(rename = "type")] + #[schemars(extend( + "title" = schema_i18n!("fields.type.title"), + "description" = schema_i18n!("fields.type.description"), + "markdownDescription" = schema_i18n!("fields.type.markdownDescription"), + ))] + pub type_name: FullyQualifiedTypeName, + /// The version of the plug-in. + #[schemars(extend( + "title" = schema_i18n!("fields.version.title"), + "description" = schema_i18n!("fields.version.description"), + "markdownDescription" = schema_i18n!("fields.version.markdownDescription"), + ))] + pub version: SemanticVersion, + /// The name of the command the plug-in adds. + #[schemars(extend( + "title" = schema_i18n!("fields.command.title"), + "description" = schema_i18n!("fields.command.description"), + "markdownDescription" = schema_i18n!("fields.command.markdownDescription"), + ))] + pub command: PluginCommandName, + /// The description of the command. + #[schemars(extend( + "title" = schema_i18n!("fields.description.title"), + "description" = schema_i18n!("fields.description.description"), + "markdownDescription" = schema_i18n!("fields.description.markdownDescription"), + ))] + pub description: Option, + /// An optional message indicating the plug-in is deprecated. + #[schemars(extend( + "title" = schema_i18n!("fields.deprecationMessage.title"), + "description" = schema_i18n!("fields.deprecationMessage.description"), + "markdownDescription" = schema_i18n!("fields.deprecationMessage.markdownDescription"), + ))] + pub deprecation_message: Option, + /// The path of the file that defines the plug-in manifest. + #[schemars(extend( + "title" = schema_i18n!("fields.path.title"), + "description" = schema_i18n!("fields.path.description"), + "markdownDescription" = schema_i18n!("fields.path.markdownDescription"), + ))] + pub path: PathBuf, + /// The directory that contains the plug-in manifest. + #[schemars(extend( + "title" = schema_i18n!("fields.directory.title"), + "description" = schema_i18n!("fields.directory.description"), + "markdownDescription" = schema_i18n!("fields.directory.markdownDescription"), + ))] + pub directory: PathBuf, + /// Indicates whether another plug-in that DSC found earlier provides the same command. A + /// shadowed plug-in is never run for its command. + #[schemars(extend( + "title" = schema_i18n!("fields.shadowed.title"), + "description" = schema_i18n!("fields.shadowed.description"), + "markdownDescription" = schema_i18n!("fields.shadowed.markdownDescription"), + ))] + pub shadowed: bool, + /// The manifest of the plug-in. + pub manifest: PluginManifest, +} + +/// Defines the executable and the leading arguments to run for a plug-in command. +/// +/// The host appends the arguments the user specified after [`args`] before it starts +/// [`executable`]. +/// +/// [`args`]: Self::args +/// [`executable`]: Self::executable +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginInvocation { + /// The resolved path of the executable to run. + pub executable: PathBuf, + /// The arguments from the manifest, to pass before the arguments the user specified. + pub args: Vec, +} + +impl DscPlugin { + /// Creates the description of a plug-in from its manifest. + /// + /// The plug-in isn't marked as shadowed. Discovery decides that after it has found every + /// plug-in. + /// + /// # Arguments + /// + /// * `manifest_path` - The path of the file that defines the manifest. + /// * `manifest` - The manifest of the plug-in. + #[must_use] + pub fn from_manifest(manifest_path: &Path, manifest: PluginManifest) -> Self { + Self { + type_name: manifest.r#type.clone(), + version: manifest.version.clone(), + command: manifest.command.name.clone(), + description: manifest.description.clone(), + deprecation_message: manifest.deprecation_message.clone(), + path: manifest_path.to_path_buf(), + directory: manifest_path.parent().map(Path::to_path_buf).unwrap_or_default(), + shadowed: false, + manifest, + } + } + + /// Resolves the executable and the leading arguments to run for the plug-in's command. + /// + /// DSC resolves the executable the way it does for resource and extension manifests: first in + /// the `PATH` environment variable, then relative to the directory that contains the + /// manifest. + /// + /// # Errors + /// + /// * [`DscError::CommandOperation`] when the executable can't be found. + /// * [`DscError::CommandNotInRestrictedDirs`] when the `resourcePath.restricted` setting is + /// `true` and the executable isn't in one of the `resourcePath.directories`. + pub fn resolve_invocation(&self) -> Result { + let executable = PathBuf::from( + canonicalize_which(&self.manifest.command.executable, Some(&self.directory))? + ); + verify_executable_is_invokable(&executable)?; + + Ok(PluginInvocation { + executable, + args: self.manifest.command.to_command_args(&self.directory), + }) + } +} diff --git a/lib/dsc-lib/src/plugins/mod.rs b/lib/dsc-lib/src/plugins/mod.rs new file mode 100644 index 000000000..54a58a382 --- /dev/null +++ b/lib/dsc-lib/src/plugins/mod.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines command plug-ins for DSC. +//! +//! A command plug-in adds a top-level command to a DSC host, like the `dsc` CLI, without being +//! compiled into the host. A plug-in is described by a [`PluginManifest`], which names the command +//! and the executable that implements it. The host discovers the manifests with +//! [`discover_plugins()`], finds the plug-in that provides a command with [`find_plugin()`], and +//! runs the executable that [`DscPlugin::resolve_invocation()`] returns. +//! +//! Plug-ins differ from extensions. DSC calls an extension with a fixed contract while it +//! processes an operation. A person or a script calls a plug-in command, and the plug-in defines +//! its own arguments and output. +//! +//! [`PluginManifest`]: plugin_manifest::PluginManifest +//! [`discover_plugins()`]: discovery::discover_plugins +//! [`find_plugin()`]: discovery::find_plugin +//! [`DscPlugin::resolve_invocation()`]: dscplugin::DscPlugin::resolve_invocation + +pub mod command_name; +pub mod discovery; +pub mod dscplugin; +pub mod plugin_manifest; diff --git a/lib/dsc-lib/src/plugins/plugin_manifest.rs b/lib/dsc-lib/src/plugins/plugin_manifest.rs new file mode 100644 index 000000000..768cfde8b --- /dev/null +++ b/lib/dsc-lib/src/plugins/plugin_manifest.rs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; + +use crate::dscerror::DscError; +use crate::plugins::command_name::PluginCommandName; +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; +use crate::types::{FullyQualifiedTypeName, SemanticVersion, TagList}; + +/// Defines a command plug-in: the command it adds to a DSC host and how to run it. +/// +/// DSC discovers plug-in manifests in files whose name ends with `.dsc.plugin.json`, +/// `.dsc.plugin.yaml`, or `.dsc.plugin.yml`, and in the `plugins` property of a manifest list. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[schemars( + transform = PluginManifest::transform_export_schema_uris, + transform = PluginManifest::transform_schema_docs +)] +#[dsc_repo_schema( + base_name = "manifest", + folder_path = "plugin", + should_bundle = true, + schema_field( + name = schema_version, + title = t!("plugins.plugin_manifest.pluginManifestSchemaTitle"), + description = t!("plugins.plugin_manifest.pluginManifestSchemaDescription"), + ) +)] +pub struct PluginManifest { + /// The version of the plug-in manifest schema. + #[serde(rename = "$schema")] + #[schemars(schema_with = "PluginManifest::recognized_schema_uris_subschema")] + pub schema_version: String, + /// The namespaced name that identifies the plug-in. + #[serde(rename = "type")] + #[schemars(extend( + "title" = schema_i18n!("fields.type.title"), + "description" = schema_i18n!("fields.type.description"), + "markdownDescription" = schema_i18n!("fields.type.markdownDescription"), + ))] + pub r#type: FullyQualifiedTypeName, + /// The version of the plug-in using semantic versioning. + #[schemars(extend( + "title" = schema_i18n!("fields.version.title"), + "description" = schema_i18n!("fields.version.description"), + "markdownDescription" = schema_i18n!("fields.version.markdownDescription"), + ))] + pub version: SemanticVersion, + /// An optional condition for the plug-in to be available. If the condition evaluates to + /// false, DSC treats the plug-in as not installed. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(extend( + "title" = schema_i18n!("fields.condition.title"), + "description" = schema_i18n!("fields.condition.description"), + "markdownDescription" = schema_i18n!("fields.condition.markdownDescription"), + ))] + pub condition: Option, + /// An optional message indicating the plug-in is deprecated. If provided, DSC shows the + /// message as a warning each time the command is invoked. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(extend( + "title" = schema_i18n!("fields.deprecationMessage.title"), + "description" = schema_i18n!("fields.deprecationMessage.description"), + "markdownDescription" = schema_i18n!("fields.deprecationMessage.markdownDescription"), + ))] + pub deprecation_message: Option, + /// A one-line description of the command, shown in help and in the list of plug-ins. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(extend( + "title" = schema_i18n!("fields.description.title"), + "description" = schema_i18n!("fields.description.description"), + "markdownDescription" = schema_i18n!("fields.description.markdownDescription"), + ))] + pub description: Option, + /// Tags for the plug-in. + #[serde(default, skip_serializing_if = "TagList::is_empty")] + #[schemars(extend( + "title" = schema_i18n!("fields.tags.title"), + "description" = schema_i18n!("fields.tags.description"), + "markdownDescription" = schema_i18n!("fields.tags.markdownDescription"), + ))] + pub tags: TagList, + /// The command the plug-in adds and how DSC runs it. + pub command: PluginCommand, + /// Arbitrary data for the plug-in that DSC doesn't interpret. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(extend( + "title" = schema_i18n!("fields.metadata.title"), + "description" = schema_i18n!("fields.metadata.description"), + "markdownDescription" = schema_i18n!("fields.metadata.markdownDescription"), + ))] + pub metadata: Option>, +} + +impl PluginManifest { + /// Validates the rules for a plug-in manifest that deserialization doesn't enforce. + /// + /// # Errors + /// + /// * [`DscError::UnrecognizedSchemaUri`] when the `$schema` property isn't one of the + /// recognized URIs for the plug-in manifest schema. + /// * [`DscError::InvalidManifest`] when a `manifestRelativePath` argument is empty or isn't a + /// relative path. + pub fn validate(&self) -> Result<(), DscError> { + self.validate_schema_uri()?; + + for arg in self.command.args.iter().flatten() { + if let PluginArgKind::ManifestRelativePath { manifest_relative_path } = arg + && !is_manifest_relative(manifest_relative_path) { + return Err(DscError::InvalidManifest(t!( + "plugins.plugin_manifest.pathNotRelative", + plugin = self.r#type, + path = manifest_relative_path.display() + ).to_string())); + } + } + + Ok(()) + } +} + +/// Defines the command a plug-in adds to a DSC host and how to run it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[schemars( + transform = PluginCommand::transform_export_schema_uris, + transform = PluginCommand::transform_schema_docs +)] +#[dsc_repo_schema(base_name = "manifest.command", folder_path = "plugin")] +pub struct PluginCommand { + /// The name of the command, which the user types after the name of the host. + #[schemars(extend( + "title" = schema_i18n!("fields.name.title"), + "description" = schema_i18n!("fields.name.description"), + "markdownDescription" = schema_i18n!("fields.name.markdownDescription"), + ))] + pub name: PluginCommandName, + /// The executable to run for the command. + #[schemars(extend( + "title" = schema_i18n!("fields.executable.title"), + "description" = schema_i18n!("fields.executable.description"), + "markdownDescription" = schema_i18n!("fields.executable.markdownDescription"), + ))] + pub executable: String, + /// The arguments to pass to the executable before the arguments the user specified. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(extend( + "title" = schema_i18n!("fields.args.title"), + "description" = schema_i18n!("fields.args.description"), + "markdownDescription" = schema_i18n!("fields.args.markdownDescription"), + ))] + pub args: Option>, +} + +impl PluginCommand { + /// Resolves the argument definitions into the arguments to pass to the executable. + /// + /// Static string arguments are passed as written. A `manifestRelativePath` argument is + /// replaced with the path of the file joined to the manifest directory. + /// + /// # Arguments + /// + /// * `manifest_directory` - The directory that contains the plug-in manifest. + #[must_use] + pub fn to_command_args(&self, manifest_directory: &Path) -> Vec { + self.args.iter().flatten().map(|arg| match arg { + PluginArgKind::String(text) => OsString::from(text), + PluginArgKind::ManifestRelativePath { manifest_relative_path } => { + manifest_directory.join(manifest_relative_path).into_os_string() + }, + }).collect() + } +} + +/// Defines an argument that DSC passes to the executable of a plug-in. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +#[schemars(inline)] +pub enum PluginArgKind { + /// A static string argument to pass to the executable as written, like `-NoProfile`. + String(String), + /// A file that ships with the plug-in. DSC passes the absolute path of the file, resolved + /// against the directory that contains the manifest. A plug-in runs in the caller's working + /// directory, not in the manifest directory, so this is how a manifest names a script that + /// sits next to it. + ManifestRelativePath { + /// The path of the file relative to the directory that contains the manifest, like + /// `graph.ps1` or `scripts/graph.py`. + #[serde(rename = "manifestRelativePath")] + manifest_relative_path: PathBuf, + }, +} + +/// Indicates whether a path can be resolved against the manifest directory. +/// +/// A path is relative to the manifest directory when it isn't empty and has neither a root, like +/// `/tools/graph.ps1`, nor a prefix, like `C:graph.ps1`. Joining a path with a root or a prefix to +/// the manifest directory would discard part or all of the manifest directory. +/// +/// # Arguments +/// +/// * `path` - The path from a `manifestRelativePath` argument. +fn is_manifest_relative(path: &Path) -> bool { + !path.as_os_str().is_empty() + && !path.components().any(|component| { + matches!(component, Component::Prefix(_) | Component::RootDir) + }) +} diff --git a/lib/dsc-lib/src/settings/dsc_settings.rs b/lib/dsc-lib/src/settings/dsc_settings.rs index 9abce5f36..776b70b79 100644 --- a/lib/dsc-lib/src/settings/dsc_settings.rs +++ b/lib/dsc-lib/src/settings/dsc_settings.rs @@ -123,6 +123,8 @@ pub struct DscSettings { /// directories: [] /// append_env_path: true /// restricted: false + /// plugins: + /// enabled: true /// ``` #[schemars( title = schema_i18n!("fields.default.title"), @@ -1319,6 +1321,7 @@ impl DscSettings { resolve_field!(tracing => [level, format]); resolve_field!(resource_path => [append_env_path, directories, restricted]); + resolve_field!(plugins => [enabled]); } /// Resolves the policy settings and applies them to the resolved settings. @@ -1371,6 +1374,7 @@ impl DscSettings { // Resolve remaining fields as normal. resolve_field!(tracing => [level, format]); resolve_field!(resource_path => [append_env_path, directories, restricted]); + resolve_field!(plugins => [enabled]); } /// Indicates whether the folder containing the DSC policy settings file has diff --git a/lib/dsc-lib/src/settings/fields/mod.rs b/lib/dsc-lib/src/settings/fields/mod.rs index 66049f3d6..e302c0cbe 100644 --- a/lib/dsc-lib/src/settings/fields/mod.rs +++ b/lib/dsc-lib/src/settings/fields/mod.rs @@ -201,6 +201,8 @@ mod forbid_ignore_settings_file; pub use forbid_ignore_settings_file::*; mod ignore_settings_file; pub use ignore_settings_file::*; +mod plugins; +pub use plugins::*; mod resource_path; pub use resource_path::*; mod tracing; diff --git a/lib/dsc-lib/src/settings/fields/plugins.rs b/lib/dsc-lib/src/settings/fields/plugins.rs new file mode 100644 index 000000000..96b294afb --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/plugins.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::schemas::dsc_repo::DscRepoSchema; +use super::macros::*; + +define_container_field!{ + base_name: "plugins", + folder_path: "settings/fields", + field_definition: { + /// Defines the available settings for command plug-ins in DSC. + /// + /// This struct is used to represent the settings as they are defined + /// in both [`PolicyFileData`] and [`PreferenceFileData`]. + /// + /// A command plug-in adds a top-level command to the `dsc` CLI + /// without being compiled into it. When the first argument to `dsc` + /// isn't a built-in command, DSC looks for a plug-in manifest that + /// provides the command and runs the executable that the manifest + /// names. + /// + /// The plug-in settings define whether DSC uses plug-ins at all: + /// + /// - When [`enabled`] is resolved as `true`, DSC discovers plug-in + /// manifests, lists plug-in commands in the help, and dispatches + /// unknown commands to the plug-in that provides them. + /// - When [`enabled`] is resolved as `false`, DSC doesn't discover + /// plug-in manifests. A command that isn't built in fails as an + /// unrecognized subcommand, the help doesn't list plug-in commands, + /// and listing the plug-ins returns nothing. + /// + /// To configure whether DSC uses plug-ins, users can define the + /// `plugins.enabled` field in the [`Policy`], [`Machine`], [`User`], + /// or [`Workspace`] settings files. The setting can't be defined as an + /// environment variable or a command line argument. + /// + /// Defining the field in the [`Policy`] settings file gives an + /// administrator one switch to keep a machine to the built-in + /// commands, because no other scope can override a policy setting. + /// + /// The directories that DSC searches for plug-in manifests are the + /// same directories it searches for every other manifest. To limit + /// where plug-ins can be discovered instead of disabling them, see + /// [`ResourcePathFileData`]. + /// + /// [`PolicyFileData`]: crate::settings::sources::PolicyFileData + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + /// [`ResourcePathFileData`]: crate::settings::fields::ResourcePathFileData + /// [`enabled`]: Self::enabled + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + #[schemars(extend("unevaluatedProperties" = false))] + struct PluginsFileData { + /// Whether DSC discovers and runs command plug-ins. + /// + /// If set to `false`, DSC doesn't discover plug-in manifests and + /// only the built-in commands are available. The default value is + /// `true`. + enabled: bool = true, + } + }, + resolved_container_type: { + /// Defines the resolved settings for command plug-ins in DSC. + /// + /// For more information about the available settings, see + /// [`PluginsFileData`]. + PluginsResolvedSettings + }, + code_default_type: { + /// Defines the structure of the code defaults for the plug-in settings + /// in DSC. + /// + /// For more information about the available settings, see + /// [`PluginsFileData`]. For more information about the code defaults, + /// see [`CODE_DEFAULT_PLUGINS`]. + PluginsCodeDefaults + }, + code_default_const: { + /// Defines the default values for the plug-in settings in DSC. + /// + /// The following snippet shows the effective code defaults as YAML + /// data: + /// + /// ```yaml + /// plugins: + /// enabled: true + /// ``` + /// + /// With these code defaults, DSC discovers plug-in manifests and + /// dispatches commands that aren't built in to the plug-in that + /// provides them. + /// + /// For more information about defining the settings, see + /// [`PluginsFileData`]. + CODE_DEFAULT_PLUGINS + } +} diff --git a/lib/dsc-lib/src/settings/mod.rs b/lib/dsc-lib/src/settings/mod.rs index e08ed4354..28ef91d8f 100644 --- a/lib/dsc-lib/src/settings/mod.rs +++ b/lib/dsc-lib/src/settings/mod.rs @@ -284,6 +284,27 @@ //! manifest, DSC can only invoke commands that exist in the specified directories. Attempting //! to invoke commands outside of the specified directories raises an error. //! +//! ## Plug-in settings +//! +//! A command plug-in adds a top-level command to the `dsc` CLI without being compiled into it. +//! When the first argument to `dsc` isn't a built-in command, DSC looks for a plug-in manifest +//! that provides the command and runs the executable that the manifest names. By default, DSC +//! discovers plug-in manifests in the same directories it searches for every other manifest. +//! +//! Users can control whether DSC uses plug-ins by defining the [`plugins.enabled`] field in the +//! [`Policy`], [`Machine`], [`User`], or [`Workspace`] settings files. The setting can't be +//! defined as an environment variable or a command line argument. +//! +//! When the resolved value for [`plugins.enabled`] is `false`, DSC doesn't discover plug-in +//! manifests. A command that isn't built in fails as an unrecognized subcommand, the help doesn't +//! list plug-in commands, and listing the plug-ins returns nothing. +//! +//! Defining the field in the [`Policy`] settings file gives an administrator one switch to keep a +//! machine to the built-in commands, because no other scope can override a policy setting. +//! +//! The resource path settings limit where DSC discovers plug-in manifests in the same way they +//! limit the discovery of every other manifest. +//! //! [scopes]: DscSettingsScope //! [`Default`]: DscSettingsScope::Default //! [`Machine`]: DscSettingsScope::Machine @@ -316,6 +337,7 @@ //! [`append_env_path`]: fields::ResourcePathResolvedSettings::append_env_path //! [`resource_path.directories`]: fields::ResourcePathResolvedSettings::directories //! [`resource_path.restricted`]: fields::ResourcePathResolvedSettings::restricted +//! [`plugins.enabled`]: fields::PluginsFileData::enabled //! [`new_with_command_line()`]: DscSettings::new_with_command_line //! [`load()`]: DscSettings::load //! [`try_load()`]: DscSettings::try_load diff --git a/lib/dsc-lib/src/settings/resolved/settings.rs b/lib/dsc-lib/src/settings/resolved/settings.rs index 31aa1bb00..7e7eed659 100644 --- a/lib/dsc-lib/src/settings/resolved/settings.rs +++ b/lib/dsc-lib/src/settings/resolved/settings.rs @@ -197,6 +197,35 @@ pub struct DscSettingsResolved { ) )] pub resource_path: ResourcePathResolvedSettings, + /// Indicates whether DSC should discover and run command plug-ins during + /// command execution. + /// + /// These settings control whether DSC looks for plug-in manifests, lists + /// plug-in commands in the help, and dispatches commands that aren't + /// built in to the plug-in that provides them. + /// + /// The following snippet shows the effective code defaults as YAML data: + /// + /// ```yaml + /// plugins: + /// enabled: true + /// ``` + /// + /// These settings can only be defined in the [policy settings file] and + /// the [preference settings files]. For more information on defining + /// them, see [`PluginsFileData`]. + /// + /// [`PluginsFileData`]: crate::settings::fields::PluginsFileData + /// [policy settings file]: crate::settings::sources::PolicyFileData + /// [preference settings files]: crate::settings::sources::PreferenceFileData + #[schemars( + title = schema_i18n!("plugins.title"), + description = schema_i18n!("plugins.description"), + extend( + "markdownDescription" = schema_i18n!("plugins.markdownDescription") + ) + )] + pub plugins: PluginsResolvedSettings, } impl Default for DscSettingsResolved { @@ -206,6 +235,7 @@ impl Default for DscSettingsResolved { ignore_settings_file: DscSettingsResolvedField::for_code_default(CODE_DEFAULT_IGNORE_SETTINGS_FILE), tracing: TracingResolvedSettings::default(), resource_path: ResourcePathResolvedSettings::default(), + plugins: PluginsResolvedSettings::default(), } } } diff --git a/lib/dsc-lib/src/settings/sources/code_defaults.rs b/lib/dsc-lib/src/settings/sources/code_defaults.rs index cb37940a2..012ffdee0 100644 --- a/lib/dsc-lib/src/settings/sources/code_defaults.rs +++ b/lib/dsc-lib/src/settings/sources/code_defaults.rs @@ -128,6 +128,29 @@ pub struct DscSettingsCodeDefaults { /// [`Environment`]: crate::settings::DscSettingsScope::Environment /// [`EnvironmentData`]: crate::settings::sources::EnvironmentData pub resource_path: ResourcePathCodeDefaults, + /// Defines whether DSC discovers and runs command plug-ins. + /// + /// The code default for this container field is defined by + /// [`CODE_DEFAULT_PLUGINS`]. + /// + /// You can override this setting in the file-based scopes only. It can't + /// be defined in the [`Environment`] or [`CommandLine`] scopes. For more + /// information, see: + /// + /// - [`PolicyFileData::plugins`] to define this setting in the [`Policy`] + /// scope. + /// - [`PreferenceFileData::plugins`] to define this setting as a + /// preference in the [`Machine`], [`User`], and [`Workspace`] scopes. + /// + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + /// [`CommandLine`]: crate::settings::DscSettingsScope::CommandLine + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`PolicyFileData::plugins`]: crate::settings::sources::PolicyFileData::plugins + /// [`PreferenceFileData::plugins`]: crate::settings::sources::PreferenceFileData::plugins + pub plugins: PluginsCodeDefaults, } impl JsonSchema for DscSettingsCodeDefaults { @@ -161,12 +184,15 @@ impl JsonSchema for DscSettingsCodeDefaults { /// append_env_path: true /// directories: [] /// restricted: false +/// plugins: +/// enabled: true /// ``` pub const DSC_SETTINGS_CODE_DEFAULTS: DscSettingsCodeDefaults = DscSettingsCodeDefaults { forbid_ignore_settings_file: CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE, ignore_settings_file: CODE_DEFAULT_IGNORE_SETTINGS_FILE, tracing: CODE_DEFAULT_TRACING, resource_path: CODE_DEFAULT_RESOURCE_PATH, + plugins: CODE_DEFAULT_PLUGINS, }; impl Default for DscSettingsCodeDefaults { diff --git a/lib/dsc-lib/src/settings/sources/policy_file.rs b/lib/dsc-lib/src/settings/sources/policy_file.rs index bcd5d47bb..c8a2f5681 100644 --- a/lib/dsc-lib/src/settings/sources/policy_file.rs +++ b/lib/dsc-lib/src/settings/sources/policy_file.rs @@ -136,6 +136,24 @@ pub struct PolicyFileData { ), )] pub resource_path: Option, + /// Defines settings that affect whether DSC discovers and runs command + /// plug-ins. + /// + /// You can control whether DSC dispatches commands that aren't built in + /// to a plug-in. Defining `plugins.enabled` as `false` in the policy file + /// keeps DSC to the built-in commands for every user, because no other + /// scope can override a policy setting. + /// + /// For more information, see [`PluginsFileData`]. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("plugins.title"), + description = schema_i18n!("plugins.description"), + extend( + "markdownDescription" = schema_i18n!("plugins.markdownDescription") + ), + )] + pub plugins: Option, } /// Public API @@ -209,6 +227,7 @@ impl PolicyFileData { pub(crate) fn any_defined(&self) -> bool { self.forbid_ignore_settings_file.is_some() || self.ignore_settings_file.is_some() || + self.plugins.is_some() || self.resource_path.is_some() || self.tracing.is_some() } diff --git a/lib/dsc-lib/src/settings/sources/preference_file.rs b/lib/dsc-lib/src/settings/sources/preference_file.rs index 7d3113873..85fb2a597 100644 --- a/lib/dsc-lib/src/settings/sources/preference_file.rs +++ b/lib/dsc-lib/src/settings/sources/preference_file.rs @@ -104,6 +104,22 @@ pub struct PreferenceFileData { ), )] pub resource_path: Option, + /// Defines settings that affect whether DSC discovers and runs command + /// plug-ins. + /// + /// You can control whether DSC dispatches commands that aren't built in + /// to a plug-in. + /// + /// For more information, see [`PluginsFileData`]. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("plugins.title"), + description = schema_i18n!("plugins.description"), + extend( + "markdownDescription" = schema_i18n!("plugins.markdownDescription") + ), + )] + pub plugins: Option, } /// Public API @@ -175,6 +191,7 @@ impl PreferenceFileData { /// /// `true` if any of the file data fields are populated, otherwise `false`. pub(crate) fn any_defined(&self) -> bool { + self.plugins.is_some() || self.resource_path.is_some() || self.tracing.is_some() } diff --git a/lib/dsc-lib/src/tests/settings/sources/policy_file.rs b/lib/dsc-lib/src/tests/settings/sources/policy_file.rs index a7e4524d8..566193b6b 100644 --- a/lib/dsc-lib/src/tests/settings/sources/policy_file.rs +++ b/lib/dsc-lib/src/tests/settings/sources/policy_file.rs @@ -5,6 +5,7 @@ use crate::settings::DscSettingsError; use crate::settings::fields::{ ForbidIgnoreSettingsFileField, IgnoreSettingsFileField, + PluginsFileData, TracingFileData, TracingLevelField, TracingFormatField, @@ -162,6 +163,9 @@ mod from_file { "appendEnvPath": true, "directories": ["dir1", "dir2"], "restricted": false, + }, + "plugins": { + "enabled": false } }); let json_str = serde_json::to_string_pretty(&json).unwrap(); @@ -192,6 +196,9 @@ mod from_file { ]), restricted: Some(false), }), + plugins: Some(PluginsFileData { + enabled: Some(false), + }), ..Default::default() }; diff --git a/lib/dsc-lib/src/tests/settings/sources/preference_file.rs b/lib/dsc-lib/src/tests/settings/sources/preference_file.rs index 3722da09f..887cc4a88 100644 --- a/lib/dsc-lib/src/tests/settings/sources/preference_file.rs +++ b/lib/dsc-lib/src/tests/settings/sources/preference_file.rs @@ -3,6 +3,7 @@ use crate::settings::DscSettingsError; use crate::settings::fields::{ + PluginsFileData, TracingFileData, TracingLevelField, TracingFormatField, @@ -168,6 +169,9 @@ use super::*; "appendEnvPath": true, "directories": ["dir1", "dir2"], "restricted": false, + }, + "plugins": { + "enabled": false } }); let json_str = serde_json::to_string_pretty(&json).unwrap(); @@ -196,6 +200,9 @@ use super::*; ]), restricted: Some(false), }), + plugins: Some(PluginsFileData { + enabled: Some(false), + }), ..Default::default() }; diff --git a/lib/dsc-lib/tests/integration/main.rs b/lib/dsc-lib/tests/integration/main.rs index 00003360a..87117ce9f 100644 --- a/lib/dsc-lib/tests/integration/main.rs +++ b/lib/dsc-lib/tests/integration/main.rs @@ -36,6 +36,7 @@ pub(crate) mod macros { mod command_resource; mod extensions; +mod plugins; #[macro_use] pub(crate) mod schemas; mod settings; mod types; diff --git a/lib/dsc-lib/tests/integration/plugins/command_name.rs b/lib/dsc-lib/tests/integration/plugins/command_name.rs new file mode 100644 index 000000000..436122009 --- /dev/null +++ b/lib/dsc-lib/tests/integration/plugins/command_name.rs @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + #[cfg(test)] + mod parse { + use dsc_lib::plugins::command_name::{PluginCommandName, PluginCommandNameError}; + use test_case::test_case; + + #[test_case("lint"; "lowercase letters")] + #[test_case("a"; "single letter")] + #[test_case("lint2"; "letters followed by a digit")] + #[test_case("resource-kit"; "hyphen between two groups")] + #[test_case("a-1-b2"; "several hyphenated groups")] + fn valid(text: &str) { + let name = PluginCommandName::parse(text).expect("name should be valid"); + + pretty_assertions::assert_eq!(name.to_string(), text); + } + + #[test_case("" => + vec![PluginCommandNameError::EmptyName]; + "empty string" + )] + #[test_case("Lint" => + vec![ + PluginCommandNameError::InvalidFirstCharacter { character: 'L' }, + PluginCommandNameError::InvalidCharacters { characters: "L".to_string() }, + ]; + "uppercase first letter" + )] + #[test_case("2lint" => + vec![PluginCommandNameError::InvalidFirstCharacter { character: '2' }]; + "digit as first character" + )] + #[test_case("-lint" => + vec![PluginCommandNameError::InvalidFirstCharacter { character: '-' }]; + "hyphen as first character" + )] + #[test_case("lint_docs" => + vec![PluginCommandNameError::InvalidCharacters { characters: "_".to_string() }]; + "underscore" + )] + #[test_case("lint docs" => + vec![PluginCommandNameError::InvalidCharacters { characters: " ".to_string() }]; + "space" + )] + #[test_case("lintDOCS.DOC" => + vec![PluginCommandNameError::InvalidCharacters { characters: "DOCS.".to_string() }]; + "lists each invalid character once" + )] + #[test_case("lint-" => + vec![PluginCommandNameError::MisplacedHyphen]; + "trailing hyphen" + )] + #[test_case("lint--docs" => + vec![PluginCommandNameError::MisplacedHyphen]; + "consecutive hyphens" + )] + #[test_case("L_i--" => + vec![ + PluginCommandNameError::InvalidFirstCharacter { character: 'L' }, + PluginCommandNameError::InvalidCharacters { characters: "L_".to_string() }, + PluginCommandNameError::MisplacedHyphen, + ]; + "reports every rule the name breaks" + )] + fn invalid(text: &str) -> Vec { + match PluginCommandName::parse(text).unwrap_err() { + PluginCommandNameError::InvalidName { text: actual_text, errors } => { + pretty_assertions::assert_eq!(actual_text, text); + errors + }, + other => panic!("expected the collecting error, got: {other:?}"), + } + } + } + + #[cfg(test)] + mod validate { + use dsc_lib::plugins::command_name::PluginCommandName; + use regex::Regex; + use test_case::test_case; + + #[test_case("lint")] + #[test_case("a")] + #[test_case("lint2")] + #[test_case("resource-kit")] + #[test_case("a-1-b2")] + #[test_case("")] + #[test_case("Lint")] + #[test_case("2lint")] + #[test_case("-lint")] + #[test_case("lint_docs")] + #[test_case("lint docs")] + #[test_case("lint-")] + #[test_case("lint--docs")] + #[test_case("-")] + #[test_case("lint\n")] + fn agrees_with_validating_pattern(text: &str) { + let pattern = Regex::new(PluginCommandName::VALIDATING_PATTERN).expect("pattern is valid"); + + pretty_assertions::assert_eq!( + PluginCommandName::validate(text).is_ok(), + pattern.is_match(text), + "validation and the published pattern disagree for '{}'", + text + ); + } + } +} + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::plugins::command_name::PluginCommandName + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + pattern_error_message => "patternErrorMessage" + }, + test_validation: { + lowercase_name_is_valid => { + input_json: json!("lint"), + expected_valid: true, + }, + hyphenated_name_is_valid => { + input_json: json!("resource-kit"), + expected_valid: true, + }, + name_with_digits_is_valid => { + input_json: json!("lint2"), + expected_valid: true, + }, + empty_string_is_invalid => { + input_json: json!(""), + expected_valid: false, + }, + uppercase_name_is_invalid => { + input_json: json!("Lint"), + expected_valid: false, + }, + name_with_leading_digit_is_invalid => { + input_json: json!("2lint"), + expected_valid: false, + }, + name_with_trailing_hyphen_is_invalid => { + input_json: json!("lint-"), + expected_valid: false, + }, + name_with_consecutive_hyphens_is_invalid => { + input_json: json!("lint--docs"), + expected_valid: false, + }, + name_with_underscore_is_invalid => { + input_json: json!("lint_docs"), + expected_valid: false, + }, + boolean_is_invalid => { + input_json: json!(true), + expected_valid: false, + }, + number_is_invalid => { + input_json: json!(1), + expected_valid: false, + }, + object_is_invalid => { + input_json: json!({"name": "lint"}), + expected_valid: false, + }, + array_is_invalid => { + input_json: json!(["lint"]), + expected_valid: false, + }, + null_is_invalid => { + input_json: json!(null), + expected_valid: false, + } + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::plugins::command_name::PluginCommandName; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("lint"; "lowercase name")] + #[test_case("resource-kit"; "hyphenated name")] + fn serializing(name: &str) { + let actual = serde_json::to_string(&PluginCommandName::parse(name).expect("name should be valid")) + .expect("serialization should never fail"); + + pretty_assertions::assert_eq!(actual, format!(r#""{name}""#)); + } + + #[test_case(json!("lint") => matches Ok(_); "lowercase string value is valid")] + #[test_case(json!("resource-kit") => matches Ok(_); "hyphenated string value is valid")] + #[test_case(json!("Lint") => matches Err(_); "uppercase string value raises error")] + #[test_case(json!("") => matches Err(_); "empty string value raises error")] + #[test_case(json!(true) => matches Err(_); "boolean value raises error")] + #[test_case(json!(1) => matches Err(_); "integer value raises error")] + #[test_case(json!({"name": "lint"}) => matches Err(_); "object value raises error")] + #[test_case(json!(["lint"]) => matches Err(_); "array value raises error")] + #[test_case(Value::Null => matches Err(_); "null value raises error")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } + + #[test] + fn deserializing_error_names_the_broken_rule() { + let err = serde_json::from_value::(json!("lint-")).unwrap_err(); + + assert!( + err.to_string().contains("lint-"), + "expected the error to name the invalid text, got: {err}" + ); + } +} + +#[cfg(test)] +mod traits { + use std::str::FromStr; + + use dsc_lib::plugins::command_name::{PluginCommandName, PluginCommandNameError}; + use test_case::test_case; + + fn name() -> PluginCommandName { + PluginCommandName::parse("resource-kit").expect("name should be valid") + } + + #[test] + fn display() { + pretty_assertions::assert_eq!(format!("dsc {}", name()), "dsc resource-kit"); + } + + #[test] + fn as_ref() { + let name = name(); + let text: &str = name.as_ref(); + + pretty_assertions::assert_eq!(text, "resource-kit"); + } + + #[test] + fn deref() { + let name = name(); + + assert!(name.starts_with("resource")); + pretty_assertions::assert_eq!(name.len(), 12); + } + + #[test_case("lint" => matches Ok(_); "valid name")] + #[test_case("Lint" => matches Err(PluginCommandNameError::InvalidName { .. }); "invalid name")] + fn from_str(text: &str) -> Result { + PluginCommandName::from_str(text) + } + + #[test_case("lint" => matches Ok(_); "valid name")] + #[test_case("Lint" => matches Err(PluginCommandNameError::InvalidName { .. }); "invalid name")] + fn try_from_string(text: &str) -> Result { + PluginCommandName::try_from(text.to_string()) + } + + #[test_case("lint" => matches Ok(_); "valid name")] + #[test_case("Lint" => matches Err(PluginCommandNameError::InvalidName { .. }); "invalid name")] + fn try_from_str(text: &str) -> Result { + PluginCommandName::try_from(text) + } + + #[test] + fn into_string() { + let text: String = name().into(); + + pretty_assertions::assert_eq!(text, "resource-kit".to_string()); + } + + #[test] + fn partial_eq() { + let name = name(); + + assert!(name == "resource-kit"); + assert!("resource-kit" == name); + assert!(name == *"resource-kit"); + assert!(*"resource-kit" == name); + assert!(name == "resource-kit".to_string()); + assert!("resource-kit".to_string() == name); + assert!(name != "Resource-Kit"); + assert!(name != "lint"); + } + + #[test] + fn ord() { + let mut names = vec![ + PluginCommandName::parse("lint").unwrap(), + PluginCommandName::parse("graph").unwrap(), + PluginCommandName::parse("lint2").unwrap(), + ]; + names.sort(); + + pretty_assertions::assert_eq!( + names.iter().map(ToString::to_string).collect::>(), + vec!["graph".to_string(), "lint".to_string(), "lint2".to_string()] + ); + } +} diff --git a/lib/dsc-lib/tests/integration/plugins/discovery.rs b/lib/dsc-lib/tests/integration/plugins/discovery.rs new file mode 100644 index 000000000..d75f8e844 --- /dev/null +++ b/lib/dsc-lib/tests/integration/plugins/discovery.rs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod helpers { + //! Defines helper functions for writing plug-in manifests to temporary directories. + + use std::fs; + use std::path::{Path, PathBuf}; + + use dsc_lib::plugins::discovery::discover_plugins_in; + use dsc_lib::plugins::dscplugin::DscPlugin; + use serde_json::{json, Value}; + use tempfile::TempDir; + + pub(super) use super::super::helpers::manifest_json; + + /// The commands the tests treat as built into the host. + pub(super) const RESERVED: [&str; 3] = ["config", "resource", "help"]; + + /// Creates a temporary directory to search for plug-ins. + pub(super) fn directory() -> TempDir { + tempfile::tempdir().expect("temporary directory should be created") + } + + /// Writes a JSON value to a file in the directory and returns the path of the file. + pub(super) fn write_json(directory: &Path, file_name: &str, value: &Value) -> PathBuf { + let path = directory.join(file_name); + fs::write(&path, serde_json::to_string_pretty(value).expect("value should serialize")) + .expect("file should be written"); + path + } + + /// Writes text to a file in the directory and returns the path of the file. + pub(super) fn write_text(directory: &Path, file_name: &str, text: &str) -> PathBuf { + let path = directory.join(file_name); + fs::write(&path, text).expect("file should be written"); + path + } + + /// Writes a manifest with only the required properties as `.dsc.plugin.json`. + pub(super) fn write_manifest(directory: &Path, type_name: &str, version: &str, command: &str) -> PathBuf { + let file_name = format!("{}.{command}.dsc.plugin.json", type_name.replace('/', ".")); + write_json(directory, &file_name, &manifest_json(type_name, version, command)) + } + + /// Returns a manifest with a condition. + pub(super) fn manifest_with_condition(type_name: &str, command: &str, condition: &str) -> Value { + let mut manifest = manifest_json(type_name, "0.1.0", command); + manifest["condition"] = json!(condition); + manifest + } + + /// Discovers the plug-ins in the directories with the reserved commands of the tests. + pub(super) fn discover(directories: &[&Path]) -> Vec { + let directories: Vec = directories.iter().map(|directory| directory.to_path_buf()).collect(); + discover_plugins_in(&directories, &RESERVED) + } + + /// Returns the type, version, command, and shadowed state of each plug-in, for comparisons. + pub(super) fn summarize(plugins: &[DscPlugin]) -> Vec<(String, String, String, bool)> { + plugins.iter().map(|plugin| ( + plugin.type_name.to_string(), + plugin.version.to_string(), + plugin.command.to_string(), + plugin.shadowed, + )).collect() + } + + /// Returns the expected summary entry for a plug-in. + pub(super) fn entry(type_name: &str, version: &str, command: &str, shadowed: bool) -> (String, String, String, bool) { + (type_name.to_string(), version.to_string(), command.to_string(), shadowed) + } +} + +#[cfg(test)] +mod discover_plugins_in { + use std::fs; + + use serde_json::json; + use test_case::test_case; + + use super::helpers::*; + + #[test] + fn empty_directory_has_no_plugins() { + let directory = directory(); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn missing_directory_has_no_plugins() { + let directory = directory(); + let missing = directory.path().join("missing"); + + assert!(discover(&[&missing]).is_empty()); + } + + #[test_case("graph.dsc.plugin.json"; "json manifest")] + #[test_case("Graph.DSC.Plugin.JSON"; "file name suffix in another casing")] + fn finds_json_manifest(file_name: &str) { + let directory = directory(); + let path = write_json(directory.path(), file_name, &manifest_json("Test/Graph", "0.1.0", "graph")); + + let plugins = discover(&[directory.path()]); + + pretty_assertions::assert_eq!(summarize(&plugins), vec![entry("Test/Graph", "0.1.0", "graph", false)]); + pretty_assertions::assert_eq!(plugins[0].path, path); + pretty_assertions::assert_eq!(plugins[0].directory, directory.path()); + } + + #[test_case("graph.dsc.plugin.yaml"; "yaml extension")] + #[test_case("graph.dsc.plugin.yml"; "yml extension")] + fn finds_yaml_manifest(file_name: &str) { + let directory = directory(); + write_text(directory.path(), file_name, concat!( + "$schema: https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json\n", + "type: Test/Graph\n", + "version: 0.1.0\n", + "command:\n", + " name: graph\n", + " executable: pwsh\n", + " args:\n", + " - -File\n", + " - manifestRelativePath: graph.ps1\n", + )); + + let plugins = discover(&[directory.path()]); + + pretty_assertions::assert_eq!(summarize(&plugins), vec![entry("Test/Graph", "0.1.0", "graph", false)]); + } + + #[test] + fn ignores_other_files_and_subdirectories() { + let directory = directory(); + write_json(directory.path(), "graph.json", &manifest_json("Test/Json", "0.1.0", "json")); + write_json(directory.path(), "graph.dsc.extension.json", &manifest_json("Test/Extension", "0.1.0", "extension")); + write_json(directory.path(), "graph.dsc.plugin.json.bak", &manifest_json("Test/Backup", "0.1.0", "backup")); + let subdirectory = directory.path().join("nested"); + fs::create_dir(&subdirectory).expect("subdirectory should be created"); + write_manifest(&subdirectory, "Test/Nested", "0.1.0", "nested"); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn finds_plugins_in_manifest_list() { + let directory = directory(); + let path = write_json(directory.path(), "tools.dsc.manifests.json", &json!({ + "plugins": [ + manifest_json("Test/Graph", "0.1.0", "graph"), + manifest_json("Test/Explain", "0.2.0", "explain"), + ] + })); + + let plugins = discover(&[directory.path()]); + + pretty_assertions::assert_eq!( + summarize(&plugins), + vec![ + entry("Test/Explain", "0.2.0", "explain", false), + entry("Test/Graph", "0.1.0", "graph", false), + ] + ); + pretty_assertions::assert_eq!(plugins[0].path, path); + } + + #[test] + fn finds_plugins_in_yaml_manifest_list() { + let directory = directory(); + write_text(directory.path(), "tools.dsc.manifests.yaml", concat!( + "plugins:\n", + " - $schema: https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json\n", + " type: Test/Graph\n", + " version: 0.1.0\n", + " command:\n", + " name: graph\n", + " executable: pwsh\n", + )); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![entry("Test/Graph", "0.1.0", "graph", false)] + ); + } + + #[test] + fn manifest_list_with_invalid_resource_still_yields_plugins() { + let directory = directory(); + write_json(directory.path(), "tools.dsc.manifests.json", &json!({ + "resources": [{ "type": "not a resource manifest" }], + "plugins": [manifest_json("Test/Graph", "0.1.0", "graph")] + })); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![entry("Test/Graph", "0.1.0", "graph", false)] + ); + } + + #[test_case(json!({ "resources": [] }); "list without plugins")] + #[test_case(json!({ "plugins": null }); "plugins defined as null")] + #[test_case(json!({ "plugins": {} }); "plugins defined as an object")] + #[test_case(json!({ "plugins": [] }); "plugins defined as an empty array")] + fn manifest_list_without_plugins_has_no_plugins(list: serde_json::Value) { + let directory = directory(); + write_json(directory.path(), "tools.dsc.manifests.json", &list); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn skips_invalid_entry_in_manifest_list() { + let directory = directory(); + write_json(directory.path(), "tools.dsc.manifests.json", &json!({ + "plugins": [ + { "type": "Test/Broken" }, + manifest_json("Test/Graph", "0.1.0", "graph"), + ] + })); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![entry("Test/Graph", "0.1.0", "graph", false)] + ); + } + + #[test] + fn skips_manifest_that_is_not_valid_json() { + let directory = directory(); + write_text(directory.path(), "broken.dsc.plugin.json", "{ not json"); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![entry("Test/Graph", "0.1.0", "graph", false)] + ); + } + + #[test] + fn skips_manifest_with_invalid_command_name() { + let directory = directory(); + write_json(directory.path(), "broken.dsc.plugin.json", &manifest_json("Test/Broken", "0.1.0", "Not-Valid")); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn skips_manifest_with_unknown_property() { + let directory = directory(); + let mut manifest = manifest_json("Test/Broken", "0.1.0", "broken"); + manifest["discover"] = json!({ "executable": "pwsh" }); + write_json(directory.path(), "broken.dsc.plugin.json", &manifest); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn skips_manifest_with_unrecognized_schema_uri() { + let directory = directory(); + let mut manifest = manifest_json("Test/Broken", "0.1.0", "broken"); + manifest["$schema"] = json!("https://example.com/not-a-dsc-schema.json"); + write_json(directory.path(), "broken.dsc.plugin.json", &manifest); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn skips_manifest_with_path_that_is_not_relative() { + let directory = directory(); + let mut manifest = manifest_json("Test/Broken", "0.1.0", "broken"); + manifest["command"]["args"] = json!([{ "manifestRelativePath": "/tools/broken.ps1" }]); + write_json(directory.path(), "broken.dsc.plugin.json", &manifest); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn keeps_manifest_whose_condition_is_true() { + let directory = directory(); + write_json( + directory.path(), + "graph.dsc.plugin.json", + &manifest_with_condition("Test/Graph", "graph", "[equals(1, 1)]") + ); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![entry("Test/Graph", "0.1.0", "graph", false)] + ); + } + + #[test] + fn skips_manifest_whose_condition_is_false() { + let directory = directory(); + write_json( + directory.path(), + "graph.dsc.plugin.json", + &manifest_with_condition("Test/Graph", "graph", "[equals(1, 2)]") + ); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test] + fn skips_manifest_whose_condition_is_not_a_boolean() { + let directory = directory(); + write_json( + directory.path(), + "graph.dsc.plugin.json", + &manifest_with_condition("Test/Graph", "graph", "[concat('a', 'b')]") + ); + + assert!(discover(&[directory.path()]).is_empty()); + } + + #[test_case("config"; "name of a built-in command")] + #[test_case("help"; "help command")] + fn skips_manifest_with_reserved_command(command: &str) { + let directory = directory(); + write_manifest(directory.path(), "Test/Reserved", "0.1.0", command); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![entry("Test/Graph", "0.1.0", "graph", false)] + ); + } + + #[test] + fn reserved_command_is_compared_case_sensitively() { + let directory = directory(); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + + let plugins = dsc_lib::plugins::discovery::discover_plugins_in( + &[directory.path().to_path_buf()], + &["GRAPH"] + ); + + pretty_assertions::assert_eq!(summarize(&plugins), vec![entry("Test/Graph", "0.1.0", "graph", false)]); + } + + #[test] + fn keeps_highest_version_of_a_type_found_in_later_directory() { + let first = directory(); + let second = directory(); + write_manifest(first.path(), "Test/Graph", "1.0.0", "graph"); + let newer = write_manifest(second.path(), "Test/Graph", "1.1.0", "graph"); + + let plugins = discover(&[first.path(), second.path()]); + + pretty_assertions::assert_eq!(summarize(&plugins), vec![entry("Test/Graph", "1.1.0", "graph", false)]); + pretty_assertions::assert_eq!(plugins[0].path, newer); + } + + #[test] + fn keeps_highest_version_of_a_type_found_in_earlier_directory() { + let first = directory(); + let second = directory(); + let newer = write_manifest(first.path(), "Test/Graph", "1.1.0", "graph"); + write_manifest(second.path(), "Test/Graph", "1.0.0", "graph"); + + let plugins = discover(&[first.path(), second.path()]); + + pretty_assertions::assert_eq!(summarize(&plugins), vec![entry("Test/Graph", "1.1.0", "graph", false)]); + pretty_assertions::assert_eq!(plugins[0].path, newer); + } + + #[test] + fn keeps_highest_version_of_a_type_within_one_directory() { + let directory = directory(); + write_json(directory.path(), "a.dsc.plugin.json", &manifest_json("Test/Graph", "1.0.0", "graph")); + write_json(directory.path(), "b.dsc.plugin.json", &manifest_json("Test/Graph", "2.0.0", "graph")); + write_json(directory.path(), "c.dsc.plugin.json", &manifest_json("Test/Graph", "1.5.0", "graph")); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![entry("Test/Graph", "2.0.0", "graph", false)] + ); + } + + #[test] + fn type_names_are_compared_case_insensitively() { + let first = directory(); + let second = directory(); + write_manifest(first.path(), "Test/Graph", "1.0.0", "graph"); + write_manifest(second.path(), "test/graph", "1.1.0", "graph"); + + let plugins = discover(&[first.path(), second.path()]); + + pretty_assertions::assert_eq!(plugins.len(), 1); + pretty_assertions::assert_eq!(plugins[0].version.to_string(), "1.1.0"); + } + + #[test] + fn first_directory_provides_a_command_claimed_by_different_types() { + let first = directory(); + let second = directory(); + write_manifest(first.path(), "Zeta/Graph", "0.1.0", "graph"); + write_manifest(second.path(), "Alpha/Graph", "9.0.0", "graph"); + + pretty_assertions::assert_eq!( + summarize(&discover(&[first.path(), second.path()])), + vec![ + entry("Zeta/Graph", "0.1.0", "graph", false), + entry("Alpha/Graph", "9.0.0", "graph", true), + ] + ); + } + + #[test] + fn type_that_sorts_first_provides_a_command_within_one_directory() { + let directory = directory(); + write_json(directory.path(), "a.dsc.plugin.json", &manifest_json("Zeta/Graph", "0.1.0", "graph")); + write_json(directory.path(), "b.dsc.plugin.json", &manifest_json("Alpha/Graph", "0.1.0", "graph")); + + pretty_assertions::assert_eq!( + summarize(&discover(&[directory.path()])), + vec![ + entry("Alpha/Graph", "0.1.0", "graph", false), + entry("Zeta/Graph", "0.1.0", "graph", true), + ] + ); + } + + #[test] + fn returns_plugins_in_search_order() { + let first = directory(); + let second = directory(); + write_manifest(first.path(), "Test/Lint", "0.1.0", "lint"); + write_manifest(first.path(), "Test/Graph", "0.1.0", "graph"); + write_manifest(second.path(), "Test/Explain", "0.1.0", "explain"); + + pretty_assertions::assert_eq!( + summarize(&discover(&[first.path(), second.path()])), + vec![ + entry("Test/Graph", "0.1.0", "graph", false), + entry("Test/Lint", "0.1.0", "lint", false), + entry("Test/Explain", "0.1.0", "explain", false), + ] + ); + } +} + +#[cfg(test)] +mod find_plugin_by_type { + use dsc_lib::plugins::discovery::find_plugin_by_type; + use test_case::test_case; + + use super::helpers::*; + + #[test_case("Alpha/Graph"; "type name as defined")] + #[test_case("alpha/GRAPH"; "type name in another casing")] + fn returns_a_shadowed_plugin(type_name: &str) { + let first = directory(); + let second = directory(); + write_manifest(first.path(), "Zeta/Graph", "0.1.0", "graph"); + write_manifest(second.path(), "Alpha/Graph", "0.1.0", "graph"); + let plugins = discover(&[first.path(), second.path()]); + + let plugin = find_plugin_by_type(&plugins, type_name).expect("plug-in should be found"); + + pretty_assertions::assert_eq!(plugin.type_name.to_string(), "Alpha/Graph"); + assert!(plugin.shadowed); + } + + #[test] + fn returns_the_plugin_that_provides_its_command() { + let directory = directory(); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + let plugins = discover(&[directory.path()]); + + let plugin = find_plugin_by_type(&plugins, "Test/Graph").expect("plug-in should be found"); + + assert!(!plugin.shadowed); + } + + #[test_case("Test/Lint"; "type that is not installed")] + #[test_case("graph"; "command name")] + fn returns_none_for_unknown_type(type_name: &str) { + let directory = directory(); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + let plugins = discover(&[directory.path()]); + + assert!(find_plugin_by_type(&plugins, type_name).is_none()); + } +} + +#[cfg(test)] +mod find_plugin { + use dsc_lib::plugins::discovery::find_plugin; + + use super::helpers::*; + + #[test] + fn returns_the_plugin_that_provides_the_command() { + let directory = directory(); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + write_manifest(directory.path(), "Test/Lint", "0.1.0", "lint"); + let plugins = discover(&[directory.path()]); + + let plugin = find_plugin(&plugins, "lint").expect("plug-in should be found"); + + pretty_assertions::assert_eq!(plugin.type_name.to_string(), "Test/Lint"); + } + + #[test] + fn never_returns_a_shadowed_plugin() { + let first = directory(); + let second = directory(); + write_manifest(first.path(), "Zeta/Graph", "0.1.0", "graph"); + write_manifest(second.path(), "Alpha/Graph", "0.1.0", "graph"); + let plugins = discover(&[first.path(), second.path()]); + + let plugin = find_plugin(&plugins, "graph").expect("plug-in should be found"); + + pretty_assertions::assert_eq!(plugin.type_name.to_string(), "Zeta/Graph"); + assert!(!plugin.shadowed); + } + + #[test] + fn returns_none_for_unknown_command() { + let directory = directory(); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + let plugins = discover(&[directory.path()]); + + assert!(find_plugin(&plugins, "lint").is_none()); + } + + #[test] + fn compares_the_command_case_sensitively() { + let directory = directory(); + write_manifest(directory.path(), "Test/Graph", "0.1.0", "graph"); + let plugins = discover(&[directory.path()]); + + assert!(find_plugin(&plugins, "Graph").is_none()); + } +} diff --git a/lib/dsc-lib/tests/integration/plugins/dscplugin.rs b/lib/dsc-lib/tests/integration/plugins/dscplugin.rs new file mode 100644 index 000000000..394b29790 --- /dev/null +++ b/lib/dsc-lib/tests/integration/plugins/dscplugin.rs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + #[cfg(test)] + mod from_manifest { + use std::path::Path; + + use dsc_lib::plugins::dscplugin::DscPlugin; + use dsc_lib::plugins::plugin_manifest::PluginManifest; + use serde_json::json; + + use super::super::super::helpers::{manifest, manifest_json}; + + #[test] + fn copies_the_identity_of_the_manifest() { + let manifest = manifest("Test/Graph", "1.2.3", "graph"); + let path = Path::new("plugins").join("graph.dsc.plugin.json"); + + let plugin = DscPlugin::from_manifest(&path, manifest.clone()); + + pretty_assertions::assert_eq!(plugin.type_name.to_string(), "Test/Graph"); + pretty_assertions::assert_eq!(plugin.version.to_string(), "1.2.3"); + pretty_assertions::assert_eq!(plugin.command.to_string(), "graph"); + pretty_assertions::assert_eq!(plugin.description, None); + pretty_assertions::assert_eq!(plugin.deprecation_message, None); + pretty_assertions::assert_eq!(plugin.path, path); + pretty_assertions::assert_eq!(plugin.directory, Path::new("plugins")); + pretty_assertions::assert_eq!(plugin.manifest, manifest); + } + + #[test] + fn copies_the_description_and_deprecation_message() { + let mut json = manifest_json("Test/Graph", "1.2.3", "graph"); + json["description"] = json!("Show the dependency graph of a configuration document"); + json["deprecationMessage"] = json!("Use 'dsc diagram' instead."); + let manifest: PluginManifest = serde_json::from_value(json).expect("manifest should deserialize"); + + let plugin = DscPlugin::from_manifest(Path::new("graph.dsc.plugin.json"), manifest); + + pretty_assertions::assert_eq!( + plugin.description.as_deref(), + Some("Show the dependency graph of a configuration document") + ); + pretty_assertions::assert_eq!(plugin.deprecation_message.as_deref(), Some("Use 'dsc diagram' instead.")); + } + + #[test] + fn is_not_shadowed() { + let plugin = DscPlugin::from_manifest( + Path::new("graph.dsc.plugin.json"), + manifest("Test/Graph", "1.2.3", "graph") + ); + + assert!(!plugin.shadowed); + } + } +} + +#[cfg(test)] +mod serde { + use std::path::Path; + + use dsc_lib::plugins::dscplugin::DscPlugin; + use serde_json::json; + + use super::super::helpers::{manifest, manifest_json}; + + #[test] + fn serializing_uses_the_documented_property_names() { + let path = Path::new("plugins").join("graph.dsc.plugin.json"); + let plugin = DscPlugin::from_manifest(&path, manifest("Test/Graph", "1.2.3", "graph")); + + pretty_assertions::assert_eq!( + serde_json::to_value(&plugin).expect("serialization should never fail"), + json!({ + "type": "Test/Graph", + "version": "1.2.3", + "command": "graph", + "description": null, + "deprecationMessage": null, + "path": path, + "directory": Path::new("plugins"), + "shadowed": false, + "manifest": manifest_json("Test/Graph", "1.2.3", "graph") + }) + ); + } + + #[test] + fn round_trips() { + let plugin = DscPlugin::from_manifest( + &Path::new("plugins").join("graph.dsc.plugin.json"), + manifest("Test/Graph", "1.2.3", "graph") + ); + + let json = serde_json::to_string(&plugin).expect("serialization should never fail"); + let actual: DscPlugin = serde_json::from_str(&json).expect("plug-in should deserialize"); + + pretty_assertions::assert_eq!(actual, plugin); + } +} + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::plugins::dscplugin::DscPlugin, + properties: { + TYPE_SCHEMA => "type", + VERSION_SCHEMA => "version", + COMMAND_SCHEMA => "command", + DESCRIPTION_SCHEMA => "description", + DEPRECATION_MESSAGE_SCHEMA => "deprecationMessage", + PATH_SCHEMA => "path", + DIRECTORY_SCHEMA => "directory", + SHADOWED_SCHEMA => "shadowed", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + type_title => (TYPE_SCHEMA, "title"), + type_description => (TYPE_SCHEMA, "description"), + type_markdown_description => (TYPE_SCHEMA, "markdownDescription"), + version_title => (VERSION_SCHEMA, "title"), + version_description => (VERSION_SCHEMA, "description"), + version_markdown_description => (VERSION_SCHEMA, "markdownDescription"), + command_title => (COMMAND_SCHEMA, "title"), + command_description => (COMMAND_SCHEMA, "description"), + command_markdown_description => (COMMAND_SCHEMA, "markdownDescription"), + description_title => (DESCRIPTION_SCHEMA, "title"), + description_description => (DESCRIPTION_SCHEMA, "description"), + description_markdown_description => (DESCRIPTION_SCHEMA, "markdownDescription"), + deprecation_message_title => (DEPRECATION_MESSAGE_SCHEMA, "title"), + deprecation_message_description => (DEPRECATION_MESSAGE_SCHEMA, "description"), + deprecation_message_markdown_description => (DEPRECATION_MESSAGE_SCHEMA, "markdownDescription"), + path_title => (PATH_SCHEMA, "title"), + path_description => (PATH_SCHEMA, "description"), + path_markdown_description => (PATH_SCHEMA, "markdownDescription"), + directory_title => (DIRECTORY_SCHEMA, "title"), + directory_description => (DIRECTORY_SCHEMA, "description"), + directory_markdown_description => (DIRECTORY_SCHEMA, "markdownDescription"), + shadowed_title => (SHADOWED_SCHEMA, "title"), + shadowed_description => (SHADOWED_SCHEMA, "description"), + shadowed_markdown_description => (SHADOWED_SCHEMA, "markdownDescription") + }, + test_validation: { + plugin_with_every_property_is_valid => { + input_json: json!({ + "type": "Test/Graph", + "version": "1.2.3", + "command": "graph", + "description": "Show the dependency graph of a configuration document", + "deprecationMessage": null, + "path": "/opt/plugins/graph.dsc.plugin.json", + "directory": "/opt/plugins", + "shadowed": false, + "manifest": { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "1.2.3", + "command": { "name": "graph", "executable": "pwsh" } + } + }), + expected_valid: true, + }, + plugin_without_manifest_is_invalid => { + input_json: json!({ + "type": "Test/Graph", + "version": "1.2.3", + "command": "graph", + "description": null, + "deprecationMessage": null, + "path": "/opt/plugins/graph.dsc.plugin.json", + "directory": "/opt/plugins", + "shadowed": false + }), + expected_valid: false, + }, + plugin_with_invalid_command_is_invalid => { + input_json: json!({ + "type": "Test/Graph", + "version": "1.2.3", + "command": "Graph", + "description": null, + "deprecationMessage": null, + "path": "/opt/plugins/graph.dsc.plugin.json", + "directory": "/opt/plugins", + "shadowed": false, + "manifest": { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "1.2.3", + "command": { "name": "graph", "executable": "pwsh" } + } + }), + expected_valid: false, + }, + empty_object_is_invalid => { + input_json: json!({}), + expected_valid: false, + } + } +} diff --git a/lib/dsc-lib/tests/integration/plugins/mod.rs b/lib/dsc-lib/tests/integration/plugins/mod.rs new file mode 100644 index 000000000..07ddfed71 --- /dev/null +++ b/lib/dsc-lib/tests/integration/plugins/mod.rs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines integration tests for the public types in the `plugins` module of [`dsc-lib`]. + +#[cfg(test)] +mod command_name; +#[cfg(test)] +mod discovery; +#[cfg(test)] +mod dscplugin; +#[cfg(test)] +mod plugin_manifest; + +pub(crate) mod helpers { + //! Defines helper functions for creating plug-in manifests. + + use dsc_lib::plugins::plugin_manifest::PluginManifest; + use serde_json::{json, Value}; + + /// The default URI for the `$schema` property of a plug-in manifest. + pub(crate) const MANIFEST_SCHEMA_URI: &str = "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json"; + + /// Returns the JSON for a manifest with only the required properties. + pub(crate) fn manifest_json(type_name: &str, version: &str, command: &str) -> Value { + json!({ + "$schema": MANIFEST_SCHEMA_URI, + "type": type_name, + "version": version, + "command": { + "name": command, + "executable": "plugin-executable" + } + }) + } + + /// Returns a manifest with only the required properties. + pub(crate) fn manifest(type_name: &str, version: &str, command: &str) -> PluginManifest { + serde_json::from_value(manifest_json(type_name, version, command)) + .expect("manifest should be valid") + } +} diff --git a/lib/dsc-lib/tests/integration/plugins/plugin_manifest.rs b/lib/dsc-lib/tests/integration/plugins/plugin_manifest.rs new file mode 100644 index 000000000..3835976c1 --- /dev/null +++ b/lib/dsc-lib/tests/integration/plugins/plugin_manifest.rs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + #[cfg(test)] + mod validate { + use dsc_lib::dscerror::DscError; + use dsc_lib::plugins::plugin_manifest::PluginManifest; + use dsc_lib::schemas::dsc_repo::DscRepoSchema; + use serde_json::json; + use test_case::test_case; + + use super::super::super::helpers::{manifest_json, MANIFEST_SCHEMA_URI}; + + /// Returns a manifest whose command passes a single `manifestRelativePath` argument. + fn manifest_with_path(path: &str) -> PluginManifest { + let mut manifest = manifest_json("Test/Graph", "0.1.0", "graph"); + manifest["command"]["args"] = json!(["-File", { "manifestRelativePath": path }]); + serde_json::from_value(manifest).expect("manifest should deserialize") + } + + #[test] + fn default_schema_uri_is_the_documented_uri() { + pretty_assertions::assert_eq!(PluginManifest::default_schema_id_uri(), MANIFEST_SCHEMA_URI); + } + + #[test] + fn manifest_with_required_properties_is_valid() { + let manifest: PluginManifest = serde_json::from_value(manifest_json("Test/Graph", "0.1.0", "graph")) + .expect("manifest should deserialize"); + + assert!(manifest.validate().is_ok()); + } + + #[test] + fn unrecognized_schema_uri_is_invalid() { + let mut manifest = manifest_json("Test/Graph", "0.1.0", "graph"); + manifest["$schema"] = json!("https://example.com/not-a-dsc-schema.json"); + let manifest: PluginManifest = serde_json::from_value(manifest).expect("manifest should deserialize"); + + assert!(matches!(manifest.validate(), Err(DscError::UnrecognizedSchemaUri(_)))); + } + + #[test_case("graph.ps1"; "file next to the manifest")] + #[test_case("scripts/graph.py"; "file in a subdirectory")] + #[test_case("../shared/graph.ps1"; "file in a sibling directory")] + fn relative_path_is_valid(path: &str) { + assert!(manifest_with_path(path).validate().is_ok()); + } + + #[test_case(""; "empty path")] + #[test_case("/tools/graph.ps1"; "path with a root")] + #[cfg_attr(windows, test_case(r"C:\tools\graph.ps1"; "path with a drive and a root"))] + #[cfg_attr(windows, test_case("C:graph.ps1"; "path with a drive and no root"))] + #[cfg_attr(windows, test_case(r"\\server\share\graph.ps1"; "UNC path"))] + fn path_that_is_not_relative_is_invalid(path: &str) { + let err = manifest_with_path(path).validate().unwrap_err(); + + assert!(matches!(err, DscError::InvalidManifest(_)), "unexpected error: {err:?}"); + assert!(err.to_string().contains("Test/Graph"), "error should name the plug-in: {err}"); + } + } + + #[cfg(test)] + mod to_command_args { + use std::ffi::OsString; + use std::path::Path; + + use dsc_lib::plugins::plugin_manifest::PluginManifest; + use serde_json::json; + + use super::super::super::helpers::manifest_json; + + #[test] + fn without_args_returns_no_arguments() { + let manifest: PluginManifest = serde_json::from_value(manifest_json("Test/Graph", "0.1.0", "graph")) + .expect("manifest should deserialize"); + + assert!(manifest.command.to_command_args(Path::new("plugins")).is_empty()); + } + + #[test] + fn passes_strings_as_written_and_resolves_manifest_relative_paths() { + let mut manifest = manifest_json("Test/Graph", "0.1.0", "graph"); + manifest["command"]["args"] = json!([ + "-NoProfile", + "-File", + { "manifestRelativePath": "graph.ps1" }, + "./not-resolved.txt" + ]); + let manifest: PluginManifest = serde_json::from_value(manifest).expect("manifest should deserialize"); + let directory = Path::new("plugins").join("graph"); + + pretty_assertions::assert_eq!( + manifest.command.to_command_args(&directory), + vec![ + OsString::from("-NoProfile"), + OsString::from("-File"), + directory.join("graph.ps1").into_os_string(), + OsString::from("./not-resolved.txt"), + ] + ); + } + } +} + +#[cfg(test)] +mod serde { + use std::path::PathBuf; + + use dsc_lib::plugins::plugin_manifest::{PluginArgKind, PluginManifest}; + use serde_json::{json, Value}; + use test_case::test_case; + + use super::super::helpers::manifest_json; + + /// Returns the minimal manifest with one property replaced or removed. + fn manifest_with(property: &str, value: Option) -> Value { + let mut manifest = manifest_json("Test/Graph", "0.1.0", "graph"); + let object = manifest.as_object_mut().expect("manifest is an object"); + match value { + Some(value) => { object.insert(property.to_string(), value); }, + None => { object.remove(property); }, + } + manifest + } + + #[test] + fn deserializing_minimal_manifest() { + let manifest: PluginManifest = serde_json::from_value(manifest_json("Test/Graph", "0.1.0", "graph")) + .expect("manifest should deserialize"); + + pretty_assertions::assert_eq!(manifest.r#type.to_string(), "Test/Graph"); + pretty_assertions::assert_eq!(manifest.version.to_string(), "0.1.0"); + pretty_assertions::assert_eq!(manifest.command.name.to_string(), "graph"); + pretty_assertions::assert_eq!(manifest.command.executable, "plugin-executable"); + assert!(manifest.command.args.is_none()); + assert!(manifest.condition.is_none()); + assert!(manifest.deprecation_message.is_none()); + assert!(manifest.description.is_none()); + assert!(manifest.tags.is_empty()); + assert!(manifest.metadata.is_none()); + } + + #[test] + fn deserializing_manifest_with_every_property() { + let mut manifest = manifest_json("Test/Graph", "0.1.0", "graph"); + manifest["condition"] = json!("[equals(1, 1)]"); + manifest["deprecationMessage"] = json!("Use 'dsc diagram' instead."); + manifest["description"] = json!("Show the dependency graph of a configuration document"); + manifest["tags"] = json!(["graph", "authoring"]); + manifest["metadata"] = json!({ "owner": "tests" }); + manifest["command"]["args"] = json!(["-File", { "manifestRelativePath": "graph.ps1" }]); + + let manifest: PluginManifest = serde_json::from_value(manifest).expect("manifest should deserialize"); + + pretty_assertions::assert_eq!(manifest.condition.as_deref(), Some("[equals(1, 1)]")); + pretty_assertions::assert_eq!(manifest.deprecation_message.as_deref(), Some("Use 'dsc diagram' instead.")); + pretty_assertions::assert_eq!( + manifest.description.as_deref(), + Some("Show the dependency graph of a configuration document") + ); + pretty_assertions::assert_eq!(manifest.tags.len(), 2); + pretty_assertions::assert_eq!(manifest.metadata.expect("metadata is defined")["owner"], json!("tests")); + pretty_assertions::assert_eq!( + manifest.command.args, + Some(vec![ + PluginArgKind::String("-File".to_string()), + PluginArgKind::ManifestRelativePath { manifest_relative_path: PathBuf::from("graph.ps1") }, + ]) + ); + } + + #[test] + fn deserializing_yaml_manifest() { + let yaml = r#" + $schema: https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json + type: Test/Graph + version: 0.1.0 + command: + name: graph + executable: pwsh + args: + - -File + - manifestRelativePath: graph.ps1 + "#; + + let manifest: PluginManifest = serde_yaml::from_str(yaml).expect("manifest should deserialize"); + + pretty_assertions::assert_eq!(manifest.command.name.to_string(), "graph"); + pretty_assertions::assert_eq!(manifest.command.args.map(|args| args.len()), Some(2)); + } + + #[test_case("$schema"; "schema")] + #[test_case("type"; "type")] + #[test_case("version"; "version")] + #[test_case("command"; "command")] + fn deserializing_without_required_property_fails(property: &str) { + let manifest = manifest_with(property, None); + + assert!(serde_json::from_value::(manifest).is_err()); + } + + #[test_case("type", json!("NotATypeName"); "type that is not a fully qualified type name")] + #[test_case("version", json!("1.0"); "version that is not a semantic version")] + #[test_case("command", json!("graph"); "command that is not an object")] + #[test_case("command", json!({ "name": "graph" }); "command without an executable")] + #[test_case("command", json!({ "executable": "pwsh" }); "command without a name")] + #[test_case("command", json!({ "name": "Graph", "executable": "pwsh" }); "command with an invalid name")] + #[test_case( + "command", + json!({ "name": "graph", "executable": "pwsh", "input": "stdin" }); + "command with an unknown property" + )] + #[test_case( + "command", + json!({ "name": "graph", "executable": "pwsh", "args": [1] }); + "argument that is a number" + )] + #[test_case( + "command", + json!({ "name": "graph", "executable": "pwsh", "args": [{ "jsonInputArg": "--input" }] }); + "argument kind of another manifest" + )] + #[test_case( + "command", + json!({ "name": "graph", "executable": "pwsh", "args": [{ "manifestRelativePath": "a", "other": "b" }] }); + "argument object with an extra property" + )] + #[test_case("discover", json!({ "executable": "pwsh" }); "property of an extension manifest")] + fn deserializing_invalid_property_fails(property: &str, value: Value) { + let manifest = manifest_with(property, Some(value)); + + assert!(serde_json::from_value::(manifest).is_err()); + } + + #[test] + fn serializing_omits_undefined_optional_properties() { + let json = manifest_json("Test/Graph", "0.1.0", "graph"); + let manifest: PluginManifest = serde_json::from_value(json.clone()).expect("manifest should deserialize"); + + pretty_assertions::assert_eq!( + serde_json::to_value(&manifest).expect("serialization should never fail"), + json + ); + } + + #[test] + fn serializing_round_trips_arguments() { + let mut json = manifest_json("Test/Graph", "0.1.0", "graph"); + json["command"]["args"] = json!(["-File", { "manifestRelativePath": "graph.ps1" }]); + let manifest: PluginManifest = serde_json::from_value(json.clone()).expect("manifest should deserialize"); + + pretty_assertions::assert_eq!( + serde_json::to_value(&manifest).expect("serialization should never fail"), + json + ); + } +} + +#[cfg(test)] +mod manifest { + crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::plugins::plugin_manifest::PluginManifest, + properties: { + TYPE_SCHEMA => "type", + VERSION_SCHEMA => "version", + CONDITION_SCHEMA => "condition", + DEPRECATION_MESSAGE_SCHEMA => "deprecationMessage", + DESCRIPTION_SCHEMA => "description", + TAGS_SCHEMA => "tags", + METADATA_SCHEMA => "metadata", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + type_title => (TYPE_SCHEMA, "title"), + type_description => (TYPE_SCHEMA, "description"), + type_markdown_description => (TYPE_SCHEMA, "markdownDescription"), + version_title => (VERSION_SCHEMA, "title"), + version_description => (VERSION_SCHEMA, "description"), + version_markdown_description => (VERSION_SCHEMA, "markdownDescription"), + condition_title => (CONDITION_SCHEMA, "title"), + condition_description => (CONDITION_SCHEMA, "description"), + condition_markdown_description => (CONDITION_SCHEMA, "markdownDescription"), + deprecation_message_title => (DEPRECATION_MESSAGE_SCHEMA, "title"), + deprecation_message_description => (DEPRECATION_MESSAGE_SCHEMA, "description"), + deprecation_message_markdown_description => (DEPRECATION_MESSAGE_SCHEMA, "markdownDescription"), + description_title => (DESCRIPTION_SCHEMA, "title"), + description_description => (DESCRIPTION_SCHEMA, "description"), + description_markdown_description => (DESCRIPTION_SCHEMA, "markdownDescription"), + tags_title => (TAGS_SCHEMA, "title"), + tags_description => (TAGS_SCHEMA, "description"), + tags_markdown_description => (TAGS_SCHEMA, "markdownDescription"), + metadata_title => (METADATA_SCHEMA, "title"), + metadata_description => (METADATA_SCHEMA, "description"), + metadata_markdown_description => (METADATA_SCHEMA, "markdownDescription") + }, + test_validation: { + manifest_with_required_properties_is_valid => { + input_json: json!({ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "0.1.0", + "command": { "name": "graph", "executable": "pwsh" } + }), + expected_valid: true, + }, + manifest_with_every_property_is_valid => { + input_json: json!({ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "0.1.0", + "condition": "[not(equals(tryWhich('pwsh'), null()))]", + "deprecationMessage": "Use 'dsc diagram' instead.", + "description": "Show the dependency graph of a configuration document", + "tags": ["graph", "authoring"], + "metadata": { "owner": "tests" }, + "command": { + "name": "graph", + "executable": "pwsh", + "args": ["-NoProfile", "-File", { "manifestRelativePath": "graph.ps1" }] + } + }), + expected_valid: true, + }, + manifest_without_schema_is_invalid => { + input_json: json!({ + "type": "Test/Graph", + "version": "0.1.0", + "command": { "name": "graph", "executable": "pwsh" } + }), + expected_valid: false, + }, + manifest_with_unrecognized_schema_is_invalid => { + input_json: json!({ + "$schema": "https://example.com/not-a-dsc-schema.json", + "type": "Test/Graph", + "version": "0.1.0", + "command": { "name": "graph", "executable": "pwsh" } + }), + expected_valid: false, + }, + manifest_without_command_is_invalid => { + input_json: json!({ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "0.1.0" + }), + expected_valid: false, + }, + manifest_with_invalid_command_name_is_invalid => { + input_json: json!({ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "0.1.0", + "command": { "name": "Graph", "executable": "pwsh" } + }), + expected_valid: false, + }, + manifest_with_unknown_property_is_invalid => { + input_json: json!({ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "0.1.0", + "command": { "name": "graph", "executable": "pwsh" }, + "discover": { "executable": "pwsh" } + }), + expected_valid: false, + }, + manifest_with_unknown_argument_kind_is_invalid => { + input_json: json!({ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/plugin/manifest.json", + "type": "Test/Graph", + "version": "0.1.0", + "command": { + "name": "graph", + "executable": "pwsh", + "args": [{ "jsonInputArg": "--input" }] + } + }), + expected_valid: false, + }, + string_is_invalid => { + input_json: json!("Test/Graph"), + expected_valid: false, + } + } + } +} + +#[cfg(test)] +mod command { + crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::plugins::plugin_manifest::PluginCommand, + properties: { + NAME_SCHEMA => "name", + EXECUTABLE_SCHEMA => "executable", + ARGS_SCHEMA => "args", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + name_title => (NAME_SCHEMA, "title"), + name_description => (NAME_SCHEMA, "description"), + name_markdown_description => (NAME_SCHEMA, "markdownDescription"), + executable_title => (EXECUTABLE_SCHEMA, "title"), + executable_description => (EXECUTABLE_SCHEMA, "description"), + executable_markdown_description => (EXECUTABLE_SCHEMA, "markdownDescription"), + args_title => (ARGS_SCHEMA, "title"), + args_description => (ARGS_SCHEMA, "description"), + args_markdown_description => (ARGS_SCHEMA, "markdownDescription") + }, + test_validation: { + command_with_name_and_executable_is_valid => { + input_json: json!({ "name": "graph", "executable": "pwsh" }), + expected_valid: true, + }, + command_with_string_arguments_is_valid => { + input_json: json!({ "name": "graph", "executable": "pwsh", "args": ["-NoProfile", "-File"] }), + expected_valid: true, + }, + command_with_manifest_relative_path_argument_is_valid => { + input_json: json!({ + "name": "graph", + "executable": "pwsh", + "args": ["-File", { "manifestRelativePath": "graph.ps1" }] + }), + expected_valid: true, + }, + command_with_empty_arguments_is_valid => { + input_json: json!({ "name": "graph", "executable": "pwsh", "args": [] }), + expected_valid: true, + }, + command_without_name_is_invalid => { + input_json: json!({ "executable": "pwsh" }), + expected_valid: false, + }, + command_without_executable_is_invalid => { + input_json: json!({ "name": "graph" }), + expected_valid: false, + }, + command_with_numeric_argument_is_invalid => { + input_json: json!({ "name": "graph", "executable": "pwsh", "args": [1] }), + expected_valid: false, + }, + command_with_unknown_property_is_invalid => { + input_json: json!({ "name": "graph", "executable": "pwsh", "input": "stdin" }), + expected_valid: false, + }, + empty_object_is_invalid => { + input_json: json!({}), + expected_valid: false, + } + } + } +} diff --git a/lib/dsc-lib/tests/integration/schemas/schema_for.rs b/lib/dsc-lib/tests/integration/schemas/schema_for.rs index 19a99a8a6..3bba877f2 100644 --- a/lib/dsc-lib/tests/integration/schemas/schema_for.rs +++ b/lib/dsc-lib/tests/integration/schemas/schema_for.rs @@ -158,6 +158,23 @@ macro_rules! test_schema_for { test_schema_for!(dsc_lib::extensions::secret::SecretMethod); } } + + #[cfg(test)] mod plugins { + #[allow(unused_must_use)] + #[cfg(test)] mod command_name { + test_schema_for!(dsc_lib::plugins::command_name::PluginCommandName); + } + #[allow(unused_must_use)] + #[cfg(test)] mod dscplugin { + test_schema_for!(dsc_lib::plugins::dscplugin::DscPlugin); + } + #[allow(unused_must_use)] + #[cfg(test)] mod plugin_manifest { + test_schema_for!(dsc_lib::plugins::plugin_manifest::PluginArgKind); + test_schema_for!(dsc_lib::plugins::plugin_manifest::PluginCommand); + test_schema_for!(dsc_lib::plugins::plugin_manifest::PluginManifest); + } + } mod settings { test_schema_for!(dsc_lib::settings::DscSettings); test_schema_for!(dsc_lib::settings::DscSettingsScope); @@ -165,6 +182,8 @@ macro_rules! test_schema_for { mod fields { test_schema_for!(dsc_lib::settings::fields::ForbidIgnoreSettingsFileField); test_schema_for!(dsc_lib::settings::fields::IgnoreSettingsFileField); + test_schema_for!(dsc_lib::settings::fields::PluginsFileData); + test_schema_for!(dsc_lib::settings::fields::PluginsResolvedSettings); test_schema_for!(dsc_lib::settings::fields::ResourcePathFileData); test_schema_for!(dsc_lib::settings::fields::ResourcePathResolvedSettings); test_schema_for!(dsc_lib::settings::fields::TracingFileData); diff --git a/lib/dsc-lib/tests/integration/settings/builders.rs b/lib/dsc-lib/tests/integration/settings/builders.rs index 8ea7781ac..8de88d04c 100644 --- a/lib/dsc-lib/tests/integration/settings/builders.rs +++ b/lib/dsc-lib/tests/integration/settings/builders.rs @@ -157,12 +157,56 @@ impl ResourcePathFileDataBuilder { } } +/// Builder for [`PluginsFileData`] +pub struct PluginsFileDataBuilder { + enabled: Option, +} + +impl PluginsFileDataBuilder { + /// Creates a new instance of the builder with all fields undefined. + /// + /// # Returns + /// + /// An instance of [`PluginsFileDataBuilder`]. + pub fn new() -> Self { + Self { + enabled: None, + } + } + + /// Adds the specified enabled flag to the builder. + /// + /// # Arguments + /// + /// - `enabled` - A boolean indicating whether DSC discovers and runs command plug-ins. + /// + /// # Returns + /// + /// The updated instance of [`PluginsFileDataBuilder`]. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = Some(enabled); + self + } + + /// Builds the [`PluginsFileData`] instance from the specified fields. + /// + /// # Returns + /// + /// An instance of [`PluginsFileData`]. + pub fn build(self) -> PluginsFileData { + PluginsFileData { + enabled: self.enabled, + } + } +} + /// Builder for [`PolicyFileData`] pub struct PolicyFileDataBuilder { pub forbid_ignore_settings_file: Option, pub ignore_settings_file: Option, pub tracing: Option, pub resource_path: Option, + pub plugins: Option, } impl PolicyFileDataBuilder { @@ -177,6 +221,7 @@ impl PolicyFileDataBuilder { ignore_settings_file: None, tracing: None, resource_path: None, + plugins: None, } } /// Adds the specified forbid ignore settings file flag to the builder. @@ -231,6 +276,19 @@ impl PolicyFileDataBuilder { self.resource_path = Some(resource_path); self } + /// Adds the specified plug-in configuration to the builder. + /// + /// # Arguments + /// + /// - `plugins` - An instance of [`PluginsFileData`]. + /// + /// # Returns + /// + /// The updated instance of [`PolicyFileDataBuilder`]. + pub fn with_plugins(mut self, plugins: PluginsFileData) -> Self { + self.plugins = Some(plugins); + self + } /// Builds the [`PolicyFileData`] instance from the specified fields. /// /// # Returns @@ -242,6 +300,7 @@ impl PolicyFileDataBuilder { ignore_settings_file: self.ignore_settings_file, tracing: self.tracing, resource_path: self.resource_path, + plugins: self.plugins, } } } @@ -250,6 +309,7 @@ impl PolicyFileDataBuilder { pub struct PreferenceFileDataBuilder { pub tracing: Option, pub resource_path: Option, + pub plugins: Option, } impl PreferenceFileDataBuilder { /// Creates a new instance of the [`PreferenceFileDataBuilder`] with default values. @@ -261,6 +321,7 @@ impl PreferenceFileDataBuilder { Self { tracing: None, resource_path: None, + plugins: None, } } @@ -292,6 +353,20 @@ impl PreferenceFileDataBuilder { self } + /// Adds the specified plug-in configuration to the builder. + /// + /// # Arguments + /// + /// - `plugins` - An instance of [`PluginsFileData`]. + /// + /// # Returns + /// + /// The updated instance of [`PreferenceFileDataBuilder`]. + pub fn with_plugins(mut self, plugins: PluginsFileData) -> Self { + self.plugins = Some(plugins); + self + } + /// Builds the [`PreferenceFileData`] instance from the specified fields. /// /// # Returns @@ -301,6 +376,7 @@ impl PreferenceFileDataBuilder { PreferenceFileData { tracing: self.tracing, resource_path: self.resource_path, + plugins: self.plugins, } } } @@ -773,12 +849,60 @@ impl TracingResolvedSettingsBuilder { } } +/// Builder for constructing instances of [`PluginsResolvedSettings`]. +pub struct PluginsResolvedSettingsBuilder { + pub enabled: Option>, +} + +impl PluginsResolvedSettingsBuilder { + /// Creates a new instance of the builder with default values. + /// + /// # Returns + /// + /// The newly created instance of [`PluginsResolvedSettingsBuilder`]. + pub fn new() -> Self { + Self { + enabled: None, + } + } + + /// Adds the specified enabled setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the enabled setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`PluginsResolvedSettingsBuilder`]. + pub fn with_enabled(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.enabled = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + /// Builds the final instance of [`PluginsResolvedSettings`] from the builder. + /// + /// # Returns + /// + /// The constructed instance of [`PluginsResolvedSettings`]. + pub fn build(self) -> PluginsResolvedSettings { + let mut resolved = PluginsResolvedSettings::default(); + if let Some(enabled) = self.enabled { + resolved.enabled = enabled; + } + + resolved + } +} + /// Builder for constructing instances of [`DscSettingsResolved`]. pub struct DscSettingsResolvedBuilder { pub forbid_ignore_settings_file: Option>, pub ignore_settings_file: Option>, pub tracing: Option, pub resource_path: Option, + pub plugins: Option, } impl DscSettingsResolvedBuilder { @@ -793,6 +917,7 @@ impl DscSettingsResolvedBuilder { ignore_settings_file: None, tracing: None, resource_path: None, + plugins: None, } } @@ -853,6 +978,20 @@ impl DscSettingsResolvedBuilder { self.resource_path = Some(resource_path); self } + + /// Adds the specified plug-in settings to the builder. + /// + /// # Arguments + /// + /// - `plugins` - The plug-in settings to set. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsResolvedBuilder`]. + pub fn with_plugins(mut self, plugins: PluginsResolvedSettings) -> Self { + self.plugins = Some(plugins); + self + } /// Builds the final instance of [`DscSettingsResolved`] using the settings provided to the builder. /// /// # Returns @@ -872,6 +1011,9 @@ impl DscSettingsResolvedBuilder { if let Some(resource_path) = self.resource_path { settings.resource_path = resource_path; } + if let Some(plugins) = self.plugins { + settings.plugins = plugins; + } settings } diff --git a/lib/dsc-lib/tests/integration/settings/dsc_settings.rs b/lib/dsc-lib/tests/integration/settings/dsc_settings.rs index dd78bf7e2..da890bd7a 100644 --- a/lib/dsc-lib/tests/integration/settings/dsc_settings.rs +++ b/lib/dsc-lib/tests/integration/settings/dsc_settings.rs @@ -453,6 +453,167 @@ mod methods { } } + mod resolved_plugins { + use super::*; + use dsc_lib::settings::sources::{PolicyFileData, PreferenceFileData}; + use test_case::test_case; + + /// Builds preference file data that only defines `plugins.enabled`. + /// + /// # Arguments + /// + /// - `enabled` - The value for the `plugins.enabled` field. + /// + /// # Returns + /// + /// An instance of [`PreferenceFileData`] with only `plugins.enabled` defined. + fn preference(enabled: bool) -> PreferenceFileData { + PreferenceFileDataBuilder::new() + .with_plugins(PluginsFileDataBuilder::new().with_enabled(enabled).build()) + .build() + } + + /// Builds policy file data that only defines `plugins.enabled`. + /// + /// # Arguments + /// + /// - `enabled` - The value for the `plugins.enabled` field. + /// + /// # Returns + /// + /// An instance of [`PolicyFileData`] with only `plugins.enabled` defined. + fn policy(enabled: bool) -> PolicyFileData { + PolicyFileDataBuilder::new() + .with_plugins(PluginsFileDataBuilder::new().with_enabled(enabled).build()) + .build() + } + + /// Builds settings from the optional `plugins.enabled` value for each file-based scope. + /// + /// # Arguments + /// + /// - `machine` - The value for the machine preference file, if it defines one. + /// - `user` - The value for the user preference file, if it defines one. + /// - `workspace` - The value for the workspace preference file, if it defines one. + /// - `policy_value` - The value for the policy file, if it defines one. + /// + /// # Returns + /// + /// An instance of [`DscSettings`] with the specified scopes loaded. + fn settings_for( + machine: Option, + user: Option, + workspace: Option, + policy_value: Option, + ) -> DscSettings { + let mut builder = DscSettingsBuilder::new(); + if let Some(enabled) = machine { + builder = builder.with_machine(preference(enabled)); + } + if let Some(enabled) = user { + builder = builder.with_user(preference(enabled)); + } + if let Some(enabled) = workspace { + builder = builder.with_workspace(preference(enabled)); + } + if let Some(enabled) = policy_value { + builder = builder.with_policy(policy(enabled)); + } + + builder.build() + } + + #[test_case(None, None, None, None => (true, DscSettingsScope::Default); "code default enables plugins")] + #[test_case(Some(false), None, None, None => (false, DscSettingsScope::Machine); "machine overrides code default")] + #[test_case(Some(false), Some(true), None, None => (true, DscSettingsScope::User); "user overrides machine")] + #[test_case(Some(true), Some(false), None, None => (false, DscSettingsScope::User); "user disables after machine enables")] + #[test_case(Some(false), Some(false), Some(true), None => (true, DscSettingsScope::Workspace); "workspace overrides user and machine")] + #[test_case(None, None, Some(false), None => (false, DscSettingsScope::Workspace); "workspace overrides code default")] + #[test_case(None, None, None, Some(false) => (false, DscSettingsScope::Policy); "policy overrides code default")] + #[test_case(Some(true), Some(true), Some(true), Some(false) => (false, DscSettingsScope::Policy); "policy disabling overrides every preference")] + #[test_case(Some(false), Some(false), Some(false), Some(true) => (true, DscSettingsScope::Policy); "policy enabling overrides every preference")] + fn enabled( + machine: Option, + user: Option, + workspace: Option, + policy_value: Option, + ) -> (bool, DscSettingsScope) { + let settings = &mut settings_for(machine, user, workspace, policy_value); + let resolved = settings.resolved().plugins.enabled.clone(); + + (resolved.value, resolved.scope) + } + + #[test] fn only_plugins_differs_from_code_defaults() { + let settings = &mut settings_for(None, Some(false), None, None); + let expected = DscSettingsResolvedBuilder::new() + .with_plugins( + PluginsResolvedSettingsBuilder::new() + .with_enabled(false, DscSettingsScope::User) + .build() + ) + .build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + + #[test] fn container_without_enabled_keeps_lower_scope_value() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(preference(false)) + .with_user( + PreferenceFileDataBuilder::new() + .with_plugins(PluginsFileDataBuilder::new().build()) + .build() + ) + .build(); + let resolved = settings.resolved().plugins.enabled.clone(); + pretty_assertions::assert_eq!(resolved.value, false); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Machine); + } + + #[test] fn ignoring_settings_files_skips_preferences() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(preference(false)) + .with_user(preference(false)) + .with_workspace(preference(false)) + .with_environment( + EnvironmentDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build(); + let resolved = settings.resolved().plugins.enabled.clone(); + pretty_assertions::assert_eq!(resolved.value, true); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Default); + } + + #[test] fn ignoring_settings_files_keeps_policy() { + let settings = &mut DscSettingsBuilder::new() + .with_user(preference(true)) + .with_policy(policy(false)) + .with_command_line( + CommandLineDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build(); + let resolved = settings.resolved().plugins.enabled.clone(); + pretty_assertions::assert_eq!(resolved.value, false); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Policy); + } + + #[test] fn environment_and_command_line_cannot_override() { + let settings = &mut DscSettingsBuilder::new() + .with_user(preference(false)) + .with_environment(ENVIRONMENT_DATA.clone()) + .with_command_line(COMMAND_LINE_DATA.clone()) + .build(); + let resolved = settings.resolved().plugins.enabled.clone(); + pretty_assertions::assert_eq!(resolved.value, false); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::User); + } + } + #[test] fn resolve_all() { let settings = &mut DscSettingsBuilder::new() .with_machine(MACHINE_DATA.clone()) diff --git a/lib/dsc-lib/tests/integration/settings/fields/plugins.rs b/lib/dsc-lib/tests/integration/settings/fields/plugins.rs new file mode 100644 index 000000000..2053b6e03 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/fields/plugins.rs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::fields::PluginsFileData, + properties: { + ENABLED_SCHEMA => "enabled", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + enabled_title => (ENABLED_SCHEMA, "title"), + enabled_description => (ENABLED_SCHEMA, "description"), + enabled_markdown_description => (ENABLED_SCHEMA, "markdownDescription"), + }, + test_validation: { + empty_object_is_valid => { + input_json: json!({}), + expected_valid: true, + }, + with_enabled_as_true_is_valid => { + input_json: json!({"enabled": true}), + expected_valid: true, + }, + with_enabled_as_false_is_valid => { + input_json: json!({"enabled": false}), + expected_valid: true, + }, + with_enabled_as_string_is_invalid => { + input_json: json!({"enabled": "true"}), + expected_valid: false, + }, + with_enabled_as_integer_is_invalid => { + input_json: json!({"enabled": 1}), + expected_valid: false, + }, + with_enabled_as_null_is_valid => { + input_json: json!({"enabled": null}), + expected_valid: true, + }, + with_an_unexpected_field_is_invalid => { + input_json: json!({"unexpectedField": true}), + expected_valid: false, + }, + array_value_is_invalid => { + input_json: json!([]), + expected_valid: false, + }, + string_value_is_invalid => { + input_json: json!("invalid"), + expected_valid: false, + }, + null_value_is_invalid => { + input_json: json!(null), + expected_valid: false, + }, + boolean_value_is_invalid => { + input_json: json!(true), + expected_valid: false, + }, + integer_value_is_invalid => { + input_json: json!(123), + expected_valid: false, + }, + }, +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::fields::PluginsFileData; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case( + PluginsFileData::default() => json!({}); + "default serializes as empty object" + )] + #[test_case( + PluginsFileData { + enabled: Some(true), + } => json!({ + "enabled": true + }); "enabled as true serializes as boolean" + )] + #[test_case( + PluginsFileData { + enabled: Some(false), + } => json!({ + "enabled": false + }); "enabled as false serializes as boolean" + )] + fn serializing(data: PluginsFileData) -> Value { + serde_json::to_value(data).expect("serialization should never fail") + } + + #[test_case(&json!("string"); "string value")] + #[test_case(&json!(123); "integer value")] + #[test_case(&json!(1.2); "number value")] + #[test_case(&json!(true); "boolean value")] + #[test_case(&json!(null); "null value")] + #[test_case(&json!([{"enabled": true}]); "array value")] + #[test_case(&json!({"enabled": "invalid"}); "object with enabled as string value")] + #[test_case(&json!({"enabled": 123}); "object with enabled as number value")] + #[test_case(&json!({"enabled": {"nested": "object"}}); "object with enabled as object value")] + #[test_case(&json!({"enabled": [false]}); "object with enabled as array value")] + fn deserializing_invalid(input_value: &Value) { + serde_json::from_value::(input_value.clone()) + .expect_err(&format!("json value '{input_value}' should be invalid")); + } + + #[test_case( + &json!({}) => PluginsFileData::default(); + "empty object deserializes as default" + )] + #[test_case( + &json!({ + "enabled": null + }) => PluginsFileData::default(); + "enabled as null deserializes as undefined" + )] + #[test_case( + &json!({ + "enabled": true + }) => PluginsFileData { + enabled: Some(true), + }; + "enabled as true deserializes as boolean" + )] + #[test_case( + &json!({ + "enabled": false + }) => PluginsFileData { + enabled: Some(false), + }; + "enabled as false deserializes as boolean" + )] + fn deserializing_valid(input_value: &Value) -> PluginsFileData { + serde_json::from_value::(input_value.clone()) + .expect(&format!( + "expected input to serialize but failed for input: {}", + serde_json::to_string_pretty(input_value).unwrap(), + )) + } +} + +#[cfg(test)] +mod defaults { + use dsc_lib::settings::{ + DscSettingsScope, + fields::{CODE_DEFAULT_PLUGINS, PluginsCodeDefaults, PluginsResolvedSettings}, + }; + + #[test] fn code_default_enables_plugins() { + pretty_assertions::assert_eq!(CODE_DEFAULT_PLUGINS.enabled, true); + pretty_assertions::assert_eq!(PluginsCodeDefaults::default(), CODE_DEFAULT_PLUGINS); + } + + #[test] fn resolved_default_is_enabled_from_default_scope() { + let resolved = PluginsResolvedSettings::default(); + pretty_assertions::assert_eq!(resolved.enabled.value, true); + pretty_assertions::assert_eq!(resolved.enabled.scope, DscSettingsScope::Default); + } +} diff --git a/lib/dsc-lib/tests/integration/settings/mod.rs b/lib/dsc-lib/tests/integration/settings/mod.rs index 17b3d7a32..4e6a88764 100644 --- a/lib/dsc-lib/tests/integration/settings/mod.rs +++ b/lib/dsc-lib/tests/integration/settings/mod.rs @@ -6,6 +6,7 @@ mod builders; mod fields { mod ignore_settings_file; mod forbid_ignore_settings_file; + mod plugins; mod resource_path; mod tracing; } diff --git a/lib/dsc-lib/tests/integration/settings/resolved/settings.rs b/lib/dsc-lib/tests/integration/settings/resolved/settings.rs index 11a78b4c1..54728f8bb 100644 --- a/lib/dsc-lib/tests/integration/settings/resolved/settings.rs +++ b/lib/dsc-lib/tests/integration/settings/resolved/settings.rs @@ -9,6 +9,7 @@ crate::macros::test_dsc_repo_schema! { IGNORE_SETTINGS_FILE_SCHEMA => "ignoreSettingsFile", TRACING_SCHEMA => "tracing", RESOURCE_PATH_SCHEMA => "resourcePath", + PLUGINS_SCHEMA => "plugins", } }, test_meta_schema: {}, @@ -28,6 +29,9 @@ crate::macros::test_dsc_repo_schema! { resource_path_title => (RESOURCE_PATH_SCHEMA, "title"), resource_path_description => (RESOURCE_PATH_SCHEMA, "description"), resource_path_markdown_description => (RESOURCE_PATH_SCHEMA, "markdownDescription"), + plugins_title => (PLUGINS_SCHEMA, "title"), + plugins_description => (PLUGINS_SCHEMA, "description"), + plugins_markdown_description => (PLUGINS_SCHEMA, "markdownDescription"), }, test_validation: { with_all_defined_is_valid => { @@ -63,9 +67,55 @@ crate::macros::test_dsc_repo_schema! { "value": true, "scope": "policy" }, + }, + "plugins": { + "enabled": { + "value": false, + "scope": "policy" + }, } }), expected_valid: true, + }, + with_plugins_enabled_as_bare_boolean_is_invalid => { + input_json: json!({ + "forbidIgnoreSettingsFile": { + "value": false, + "scope": "default" + }, + "ignoreSettingsFile": { + "value": false, + "scope": "default" + }, + "tracing": { + "level": { + "value": "warn", + "scope": "default" + }, + "format": { + "value": "default", + "scope": "default" + } + }, + "resourcePath": { + "appendEnvPath": { + "value": true, + "scope": "default" + }, + "directories": { + "value": [], + "scope": "default" + }, + "restricted": { + "value": false, + "scope": "default" + }, + }, + "plugins": { + "enabled": false + } + }), + expected_valid: false, } } } @@ -92,10 +142,38 @@ mod serde { directories: DscSettingsResolvedField::for_policy(vec!["/dsc/resources".into(), "/dsc/extensions".into()]), restricted: DscSettingsResolvedField::for_policy(true.into()), }, + plugins: PluginsResolvedSettings { + enabled: DscSettingsResolvedField::for_policy(false), + }, }; - serde_json::to_value(&settings) + let value = serde_json::to_value(&settings) + .expect("serialization should never fail"); + + pretty_assertions::assert_eq!( + value["plugins"], + serde_json::json!({ + "enabled": { + "value": false, + "scope": "policy" + } + }) + ); + } + + #[test] fn default_enables_plugins_from_code_defaults() { + let value = serde_json::to_value(DscSettingsResolved::default()) .expect("serialization should never fail"); + + pretty_assertions::assert_eq!( + value["plugins"], + serde_json::json!({ + "enabled": { + "value": true, + "scope": "default" + } + }) + ); } // No tests for deserializing, as the type only implements Serialize. diff --git a/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs b/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs index 3b78eb436..3f8daf559 100644 --- a/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs +++ b/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs @@ -19,6 +19,7 @@ crate::macros::test_dsc_repo_schema! { IGNORE_PROPERTY_SCHEMA => "ignoreSettingsFile", TRACING_PROPERTY_SCHEMA => "tracing", RESOURCE_PATH_PROPERTY_SCHEMA => "resourcePath", + PLUGINS_PROPERTY_SCHEMA => "plugins", } }, test_meta_schema: {}, @@ -38,6 +39,9 @@ crate::macros::test_dsc_repo_schema! { tracing_title => (TRACING_PROPERTY_SCHEMA, "title"), tracing_description => (TRACING_PROPERTY_SCHEMA, "description"), tracing_markdown_description => (TRACING_PROPERTY_SCHEMA, "markdownDescription"), + plugins_title => (PLUGINS_PROPERTY_SCHEMA, "title"), + plugins_description => (PLUGINS_PROPERTY_SCHEMA, "description"), + plugins_markdown_description => (PLUGINS_PROPERTY_SCHEMA, "markdownDescription"), }, test_validation: { empty_object_is_valid => { @@ -76,12 +80,28 @@ crate::macros::test_dsc_repo_schema! { input_json: json!({"resourcePath": {"appendEnvPath": "invalid"}}), expected_valid: false, }, + plugins_field_as_empty_object_is_valid => { + input_json: json!({"plugins": {}}), + expected_valid: true, + }, + plugins_field_with_valid_data_is_valid => { + input_json: json!({"plugins": {"enabled": false}}), + expected_valid: true, + }, + plugins_field_with_invalid_data_is_invalid => { + input_json: json!({"plugins": {"enabled": "invalid"}}), + expected_valid: false, + }, + plugins_field_as_boolean_is_invalid => { + input_json: json!({"plugins": false}), + expected_valid: false, + }, } } #[cfg(test)] mod serde { - use dsc_lib::settings::{fields::{ForbidIgnoreSettingsFileField, ResourcePathFileData}, sources::PolicyFileData}; + use dsc_lib::settings::{fields::{ForbidIgnoreSettingsFileField, PluginsFileData, ResourcePathFileData}, sources::PolicyFileData}; use std::path::PathBuf; use serde_json::{json, Value}; use test_case::test_case; @@ -98,7 +118,8 @@ mod serde { PathBuf::from("/dsc/extensions") ]), ..Default::default() - }) + }), + plugins: None, }, json!({ "forbidIgnoreSettingsFile": true, @@ -108,6 +129,18 @@ mod serde { }); "with defined fields only includes those defined fields" )] + #[test_case( + &PolicyFileData { + plugins: Some(PluginsFileData { enabled: Some(false) }), + ..Default::default() + }, + json!({ + "plugins": { + "enabled": false + } + }); + "with plugins defined only includes plugins" + )] fn serializing(data: &PolicyFileData, expected: Value) { let serialized = serde_json::to_value(data) .expect("serialization should never fail"); @@ -118,6 +151,8 @@ mod serde { #[test_case(json!({"ignoreSettingsFile": "invalid"}); "ignore_settings_file as non-boolean is invalid")] #[test_case(json!({"resourcePath": {"appendEnvPath": "invalid"}}); "resource_path with invalid data is invalid")] #[test_case(json!({"tracing": {"level": "invalid"}}); "tracing with invalid data is invalid")] + #[test_case(json!({"plugins": {"enabled": "invalid"}}); "plugins with invalid data is invalid")] + #[test_case(json!({"plugins": false}); "plugins as boolean is invalid")] fn derserializing_invalid(input_value: Value) { let result = serde_json::from_value::(input_value.clone()); assert!(result.is_err(), "expected deserialization to fail for input: {:?}", input_value); @@ -127,6 +162,7 @@ mod serde { #[test_case(json!({"ignoreSettingsFile": true}); "ignore_settings_file as boolean is valid")] #[test_case(json!({"resourcePath": {"directories": ["/dsc/resources"]}}); "resource_path with valid data is valid")] #[test_case(json!({"tracing": {"level": "info"}}); "tracing with valid data is valid")] + #[test_case(json!({"plugins": {"enabled": false}}); "plugins with valid data is valid")] fn deserializing_valid(input_value: Value) { let result = serde_json::from_value::(input_value.clone()); assert!( diff --git a/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs b/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs index 84ec08526..997772fa6 100644 --- a/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs +++ b/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs @@ -17,6 +17,7 @@ crate::macros::test_dsc_repo_schema!{ properties: { TRACING_PROPERTY_SCHEMA => "tracing", RESOURCE_PATH_PROPERTY_SCHEMA => "resourcePath", + PLUGINS_PROPERTY_SCHEMA => "plugins", } }, test_meta_schema: {}, @@ -30,6 +31,9 @@ crate::macros::test_dsc_repo_schema!{ resource_path_title => (RESOURCE_PATH_PROPERTY_SCHEMA, "title"), resource_path_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "description"), resource_path_markdown_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "markdownDescription"), + plugins_title => (PLUGINS_PROPERTY_SCHEMA, "title"), + plugins_description => (PLUGINS_PROPERTY_SCHEMA, "description"), + plugins_markdown_description => (PLUGINS_PROPERTY_SCHEMA, "markdownDescription"), }, test_validation: { empty_object_is_valid => { @@ -52,12 +56,28 @@ crate::macros::test_dsc_repo_schema!{ input_json: json!({"resourcePath": {"appendEnvPath": "invalid"}}), expected_valid: false, }, + plugins_field_as_empty_object_is_valid => { + input_json: json!({"plugins": {}}), + expected_valid: true, + }, + plugins_field_with_valid_data_is_valid => { + input_json: json!({"plugins": {"enabled": false}}), + expected_valid: true, + }, + plugins_field_with_invalid_data_is_invalid => { + input_json: json!({"plugins": {"enabled": "invalid"}}), + expected_valid: false, + }, + plugins_field_as_boolean_is_invalid => { + input_json: json!({"plugins": false}), + expected_valid: false, + }, } } #[cfg(test)] mod serde { - use dsc_lib::settings::{fields::ResourcePathFileData, sources::PreferenceFileData}; + use dsc_lib::settings::{fields::{PluginsFileData, ResourcePathFileData}, sources::PreferenceFileData}; use std::path::PathBuf; use serde_json::{json, Value}; use test_case::test_case; @@ -72,7 +92,8 @@ mod serde { PathBuf::from("/dsc/extensions") ]), ..Default::default() - }) + }), + plugins: None, }, json!({ "resourcePath": { @@ -81,6 +102,18 @@ mod serde { }); "with defined fields only includes those defined fields" )] + #[test_case( + &PreferenceFileData { + plugins: Some(PluginsFileData { enabled: Some(false) }), + ..Default::default() + }, + json!({ + "plugins": { + "enabled": false + } + }); + "with plugins defined only includes plugins" + )] fn serializing(data: &PreferenceFileData, expected: Value) { let serialized = serde_json::to_value(data) .expect("serialization should never fail"); @@ -89,6 +122,8 @@ mod serde { #[test_case(json!({"tracing": {"level": "invalid"}}); "tracing with invalid data is invalid")] #[test_case(json!({"resourcePath": {"appendEnvPath": "invalid"}}); "resource_path with invalid data is invalid")] + #[test_case(json!({"plugins": {"enabled": "invalid"}}); "plugins with invalid data is invalid")] + #[test_case(json!({"plugins": false}); "plugins as boolean is invalid")] fn derserializing_invalid(input_value: Value) { let result = serde_json::from_value::(input_value.clone()); assert!(result.is_err(), "expected deserialization to fail for input: {:?}", input_value); @@ -96,6 +131,7 @@ mod serde { #[test_case(json!({}); "empty object is valid")] #[test_case(json!({"resourcePath": {"directories": ["/dsc/resources"]}}); "resource_path with valid data is valid")] #[test_case(json!({"tracing": {"level": "info"}}); "tracing with valid data is valid")] + #[test_case(json!({"plugins": {"enabled": false}}); "plugins with valid data is valid")] fn deserializing_valid(input_value: Value) { let result = serde_json::from_value::(input_value.clone()); assert!( diff --git a/xtask/src/schemas/export.rs b/xtask/src/schemas/export.rs index 04c51644c..3be720ca2 100644 --- a/xtask/src/schemas/export.rs +++ b/xtask/src/schemas/export.rs @@ -149,11 +149,16 @@ pub(crate) fn export_schemas( dsc_lib::extensions::extension_manifest::ExtensionManifest, dsc_lib::extensions::import::ImportMethod, dsc_lib::extensions::secret::SecretMethod, + dsc_lib::plugins::command_name::PluginCommandName, + dsc_lib::plugins::dscplugin::DscPlugin, + dsc_lib::plugins::plugin_manifest::PluginCommand, + dsc_lib::plugins::plugin_manifest::PluginManifest, dsc_lib::functions::FunctionArgKind, dsc_lib::functions::FunctionCategory, dsc_lib::functions::FunctionDefinition, dsc_lib::settings::fields::ForbidIgnoreSettingsFileField, dsc_lib::settings::fields::IgnoreSettingsFileField, + dsc_lib::settings::fields::PluginsFileData, dsc_lib::settings::fields::ResourcePathFileData, dsc_lib::settings::fields::TracingFileData, dsc_lib::settings::fields::TracingFormatField,